diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index a2c24bea2b..aa918d2f8d 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -1242,6 +1242,41 @@ function isCopilotClient( return false; } +export function extractSystemRoleMessages(payload: Record): void { + if (!Array.isArray(payload.messages)) return; + const messages = payload.messages as Array<{ role?: unknown; content?: unknown }>; + const systemMessages = messages.filter( + (m) => typeof m.role === "string" && m.role.toLowerCase() === "system" + ); + if (systemMessages.length === 0) return; + + const extraBlocks: Array> = []; + for (const sm of systemMessages) { + if (typeof sm.content === "string" && sm.content.length > 0) { + extraBlocks.push({ type: "text", text: sm.content }); + } else if (Array.isArray(sm.content)) { + for (const block of sm.content as Array>) { + if (block?.type === "text" && typeof block.text === "string" && block.text.length > 0) { + extraBlocks.push({ ...block }); + } + } + } + } + if (extraBlocks.length > 0) { + const existingSystem = payload.system; + if (typeof existingSystem === "string" && existingSystem.length > 0) { + payload.system = [{ type: "text", text: existingSystem }, ...extraBlocks]; + } else if (Array.isArray(existingSystem)) { + payload.system = [...(existingSystem as Array>), ...extraBlocks]; + } else { + payload.system = extraBlocks; + } + } + payload.messages = messages.filter( + (m) => typeof m.role !== "string" || m.role.toLowerCase() !== "system" + ); +} + export async function handleChatCore({ body, modelInfo, @@ -2577,45 +2612,6 @@ export async function handleChatCore({ content?: unknown; }; - /** - * Lightweight extraction: only lifts role:"system" messages to the top-level - * `system` parameter. Unlike normalizeClaudeUpstreamMessages, this does NOT - * convert file/document blocks, drop unknown types, or change tool history. - * Used in the semantic passthrough path where Claude Code's native payload - * structure must be preserved — only memory injection (which prepends a - * system message) needs this correction. - */ - const extractSystemRoleMessages = (payload: Record) => { - if (!Array.isArray(payload.messages)) return; - const messages = payload.messages as ClaudeMessage[]; - const systemMessages = messages.filter((m) => m.role === "system"); - if (systemMessages.length === 0) return; - - const extraBlocks: ClaudeContentBlock[] = []; - for (const sm of systemMessages) { - if (typeof sm.content === "string" && sm.content.length > 0) { - extraBlocks.push({ type: "text", text: sm.content }); - } else if (Array.isArray(sm.content)) { - for (const block of sm.content as ClaudeContentBlock[]) { - if (block?.type === "text" && typeof block.text === "string" && block.text.length > 0) { - extraBlocks.push(block); - } - } - } - } - if (extraBlocks.length > 0) { - const existingSystem = payload.system; - if (typeof existingSystem === "string" && existingSystem.length > 0) { - payload.system = [{ type: "text", text: existingSystem }, ...extraBlocks]; - } else if (Array.isArray(existingSystem)) { - payload.system = [...(existingSystem as ClaudeContentBlock[]), ...extraBlocks]; - } else { - payload.system = extraBlocks; - } - } - payload.messages = messages.filter((m) => m.role !== "system"); - }; - const normalizeClaudeUpstreamMessages = ( payload: Record, options?: { preserveToolResultBlocks?: boolean } diff --git a/tests/unit/system-role-extraction.test.ts b/tests/unit/system-role-extraction.test.ts new file mode 100644 index 0000000000..306fb7feef --- /dev/null +++ b/tests/unit/system-role-extraction.test.ts @@ -0,0 +1,119 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { extractSystemRoleMessages } from "../../open-sse/handlers/chatCore.ts"; + +test("extractSystemRoleMessages moves role=system to top-level system", () => { + const payload = { + messages: [ + { role: "system", content: "Memory context: foo" }, + { role: "user", content: "hello" }, + { role: "assistant", content: "hi" }, + ], + }; + extractSystemRoleMessages(payload); + assert.equal(payload.messages.length, 2); + assert.equal(payload.messages[0].role, "user"); + assert.deepEqual(payload.system, [{ type: "text", text: "Memory context: foo" }]); +}); + +test("extractSystemRoleMessages merges with existing top-level system string", () => { + const payload = { + system: "You are Claude.", + messages: [ + { role: "system", content: "Memory context: bar" }, + { role: "user", content: "hello" }, + ], + }; + extractSystemRoleMessages(payload); + assert.equal(payload.messages.length, 1); + assert.deepEqual(payload.system, [ + { type: "text", text: "You are Claude." }, + { type: "text", text: "Memory context: bar" }, + ]); +}); + +test("extractSystemRoleMessages merges with existing top-level system array", () => { + const payload = { + system: [{ type: "text", text: "Existing system" }], + messages: [ + { role: "system", content: "Memory context: baz" }, + { role: "user", content: "hello" }, + ], + }; + extractSystemRoleMessages(payload); + assert.equal(payload.messages.length, 1); + assert.deepEqual(payload.system, [ + { type: "text", text: "Existing system" }, + { type: "text", text: "Memory context: baz" }, + ]); +}); + +test("extractSystemRoleMessages does nothing when no system role messages", () => { + const payload = { + messages: [ + { role: "user", content: "hello" }, + { role: "assistant", content: "hi" }, + ], + }; + extractSystemRoleMessages(payload); + assert.equal(payload.messages.length, 2); + assert.equal(payload.system, undefined); +}); + +test("extractSystemRoleMessages handles non-array messages gracefully", () => { + const payload = { messages: "not-an-array" }; + extractSystemRoleMessages(payload); + assert.equal(payload.messages, "not-an-array"); +}); + +test("extractSystemRoleMessages handles empty messages array", () => { + const payload = { messages: [] }; + extractSystemRoleMessages(payload); + assert.equal(payload.messages.length, 0); +}); + +test("extractSystemRoleMessages handles case-insensitive role System", () => { + const payload = { + messages: [ + { role: "System", content: "Memory context: caps" }, + { role: "user", content: "hello" }, + ], + }; + extractSystemRoleMessages(payload); + assert.equal(payload.messages.length, 1); + assert.deepEqual(payload.system, [{ type: "text", text: "Memory context: caps" }]); +}); + +test("extractSystemRoleMessages drops empty text content from system messages", () => { + const payload = { + messages: [ + { role: "system", content: "" }, + { role: "system", content: "valid" }, + { role: "user", content: "hello" }, + ], + }; + extractSystemRoleMessages(payload); + assert.equal(payload.messages.length, 1); + assert.deepEqual(payload.system, [{ type: "text", text: "valid" }]); +}); + +test("extractSystemRoleMessages handles system messages with array content", () => { + const payload = { + messages: [ + { + role: "system", + content: [ + { type: "text", text: "Block 1" }, + { type: "text", text: "Block 2" }, + ], + }, + { role: "user", content: "hello" }, + ], + }; + extractSystemRoleMessages(payload); + assert.equal(payload.messages.length, 1); + assert.deepEqual(payload.system, [ + { type: "text", text: "Block 1" }, + { type: "text", text: "Block 2" }, + ]); +});