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:
NOXX - Commiter
2026-07-20 23:53:18 +03:00
committed by GitHub
parent 4f52e36082
commit 99135d7ebe
2 changed files with 74 additions and 5 deletions

View File

@@ -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;

View File

@@ -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",