mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-08 00:02:20 +03:00
fix(notion-web): accept OpenAI content-parts arrays in transcript (#7896)
Agent clients often send message.content as [{type:\"text\",text:\"...\"}]
instead of a plain string. buildNotionMessageStep previously required a
string and silently dropped those turns, so system injects (jailbreak /
agentic conversion) and multimodal user messages never reached Notion.
Normalize string | content-parts | bare string parts via
extractNotionMessageText, and add regression coverage in the transcript
unit suite.
This commit is contained in:
@@ -188,6 +188,30 @@ function buildNotionContextValue(opts: {
|
||||
return contextValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize OpenAI-style message content to a plain string.
|
||||
* Accepts a string or content-parts array (`{ type:"text", text }` / `{ text }`).
|
||||
* Previously only string content was accepted — array-shaped system/user messages
|
||||
* (common from agent clients) were silently dropped, so system/jailbreak/agentic
|
||||
* injects never reached Notion when any message used parts.
|
||||
*/
|
||||
function extractNotionMessageText(content: unknown): string {
|
||||
if (typeof content === "string") return content;
|
||||
if (!Array.isArray(content)) return "";
|
||||
const parts: string[] = [];
|
||||
for (const p of content) {
|
||||
if (typeof p === "string") {
|
||||
if (p) parts.push(p);
|
||||
continue;
|
||||
}
|
||||
if (!p || typeof p !== "object") continue;
|
||||
const o = p as Record<string, unknown>;
|
||||
if (typeof o.text === "string" && o.text) parts.push(o.text);
|
||||
else if (typeof o.content === "string" && o.content) parts.push(o.content);
|
||||
}
|
||||
return parts.join("\n");
|
||||
}
|
||||
|
||||
/** Converts one OpenAI-style message into a transcript step, or `null` when it
|
||||
* was folded into the context (system prompts). */
|
||||
function buildNotionMessageStep(
|
||||
@@ -195,13 +219,15 @@ function buildNotionMessageStep(
|
||||
contextValue: Record<string, unknown>,
|
||||
opts: { userId?: string; now: string }
|
||||
): Record<string, unknown> | null {
|
||||
if (typeof m?.content !== "string" || m.content.length === 0) return null;
|
||||
// Accept string OR content-parts array (agent clients often send parts).
|
||||
const text = extractNotionMessageText((m as { content?: unknown })?.content);
|
||||
if (!text || text.length === 0) return null;
|
||||
const role = (m.role || "").toLowerCase();
|
||||
|
||||
if (role === "system") {
|
||||
// Fold system prompts into context instructions rather than a separate step.
|
||||
const existing = typeof contextValue.instructions === "string" ? contextValue.instructions : "";
|
||||
contextValue.instructions = existing ? `${existing}\n${m.content}` : m.content;
|
||||
contextValue.instructions = existing ? `${existing}\n${text}` : text;
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -209,7 +235,7 @@ function buildNotionMessageStep(
|
||||
return {
|
||||
id: randomUUID(),
|
||||
type: "agent-inference",
|
||||
value: [{ type: "text", content: m.content }],
|
||||
value: [{ type: "text", content: text }],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -217,7 +243,7 @@ function buildNotionMessageStep(
|
||||
const userStep: Record<string, unknown> = {
|
||||
id: randomUUID(),
|
||||
type: "user",
|
||||
value: [[m.content]],
|
||||
value: [[text]],
|
||||
createdAt: opts.now,
|
||||
};
|
||||
if (opts.userId) userStep.userId = opts.userId;
|
||||
|
||||
@@ -452,7 +452,7 @@ describe("buildNotionTranscript", () => {
|
||||
assert.ok(transcript.every((t) => typeof t.id === "string" && (t.id as string).length > 0));
|
||||
});
|
||||
|
||||
it("drops messages with empty/non-string content but keeps config+context", () => {
|
||||
it("drops messages with empty content but keeps config+context", () => {
|
||||
const transcript = buildNotionTranscript([
|
||||
{ role: "user", content: "" },
|
||||
{ role: "user", content: "keep me" },
|
||||
@@ -461,6 +461,49 @@ describe("buildNotionTranscript", () => {
|
||||
assert.equal(transcript[2].type, "user");
|
||||
});
|
||||
|
||||
it("accepts OpenAI content-parts arrays for system + user (agent clients)", () => {
|
||||
// Regression: array-shaped content was previously dropped entirely, so
|
||||
// system injects (jailbreak/agentic) and multimodal user turns never
|
||||
// reached Notion's transcript.
|
||||
const transcript = buildNotionTranscript(
|
||||
[
|
||||
{
|
||||
role: "system",
|
||||
content: [
|
||||
{ type: "text", text: "[VP-JB] follow tools" },
|
||||
{ type: "text", text: "second system part" },
|
||||
] as unknown as string,
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "find icon skill" },
|
||||
] as unknown as string,
|
||||
},
|
||||
],
|
||||
{ spaceId: "s1" }
|
||||
);
|
||||
assert.deepEqual(
|
||||
transcript.map((t) => t.type),
|
||||
["config", "context", "user"]
|
||||
);
|
||||
const ctx = transcript[1].value as { instructions?: string };
|
||||
assert.match(String(ctx.instructions), /\[VP-JB\] follow tools/);
|
||||
assert.match(String(ctx.instructions), /second system part/);
|
||||
assert.deepEqual(transcript[2].value, [["find icon skill"]]);
|
||||
});
|
||||
|
||||
it("accepts bare string parts inside content arrays", () => {
|
||||
const transcript = buildNotionTranscript([
|
||||
{
|
||||
role: "user",
|
||||
content: ["hello", "world"] as unknown as string,
|
||||
},
|
||||
]);
|
||||
assert.equal(transcript[2].type, "user");
|
||||
assert.deepEqual(transcript[2].value, [["hello\nworld"]]);
|
||||
});
|
||||
|
||||
it("puts model food-codename on config when provided", () => {
|
||||
const transcript = buildNotionTranscript([{ role: "user", content: "hi" }], {
|
||||
notionModel: "acai-budino-high",
|
||||
|
||||
Reference in New Issue
Block a user