From 99135d7ebeb0af3596a91dd99b330360e5518df3 Mon Sep 17 00:00:00 2001 From: NOXX - Commiter Date: Mon, 20 Jul 2026 23:53:18 +0300 Subject: [PATCH] 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. --- open-sse/executors/notion-web.ts | 34 ++++++++++++++++--- tests/unit/executor-notion-web.test.ts | 45 +++++++++++++++++++++++++- 2 files changed, 74 insertions(+), 5 deletions(-) diff --git a/open-sse/executors/notion-web.ts b/open-sse/executors/notion-web.ts index 00b9fbb7f3..364bbb4769 100644 --- a/open-sse/executors/notion-web.ts +++ b/open-sse/executors/notion-web.ts @@ -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; + 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, opts: { userId?: string; now: string } ): Record | 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 = { id: randomUUID(), type: "user", - value: [[m.content]], + value: [[text]], createdAt: opts.now, }; if (opts.userId) userStep.userId = opts.userId; diff --git a/tests/unit/executor-notion-web.test.ts b/tests/unit/executor-notion-web.test.ts index a195973f35..4c2b26e35a 100644 --- a/tests/unit/executor-notion-web.test.ts +++ b/tests/unit/executor-notion-web.test.ts @@ -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",