fix: extract system role messages in semantic passthrough path + add test

This commit is contained in:
Tentoxa
2026-05-21 12:26:07 +02:00
parent 140a3d81ed
commit 7e05abd398
2 changed files with 154 additions and 39 deletions

View File

@@ -1242,6 +1242,41 @@ function isCopilotClient(
return false;
}
export function extractSystemRoleMessages(payload: Record<string, unknown>): 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<Record<string, unknown>> = [];
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<Record<string, unknown>>) {
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<Record<string, unknown>>), ...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<string, unknown>) => {
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<string, unknown>,
options?: { preserveToolResultBlocks?: boolean }

View File

@@ -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" },
]);
});