Compare commits

..

1 Commits

Author SHA1 Message Date
Xiangzhe
0e85e7833d fix(memory): avoid rejected mid-conversation system injection on Claude when preceding turn isn't a tool result (#11290)
Claude Opus 5 rejects the #3890 cache-safe mid-array system splice with
HTTP 400 when the assistant turn immediately before the splice point is
plain text rather than a server-side tool result. Adding claude/anthropic
outright to systemMessageMustBeFirst() would revert the #3890 cache-hit
optimization for every Claude request, so instead injectMemory() now only
falls back to leading-system-message placement for the specific Claude-
family requests where that condition holds, preserving the mid-array
splice everywhere it remains safe (non-Claude providers unconditionally,
and Claude providers whose preceding turn does end in a server tool
result).
2026-08-23 21:16:27 -03:00
6 changed files with 177 additions and 131 deletions

View File

@@ -20,7 +20,6 @@ import { checkSemanticCache } from "./chatCore/semanticCache.ts";
import { checkLifecycle, resolveLifecycle } from "./chatCore/modelLifecyclePolicy.ts";
import {
shouldDefaultAllowClassifier,
detectClassifierFormat,
buildDefaultAllowClaudeMessage,
} from "./chatCore/claudeClassifierCompat.ts";
import { applyClientUsageBuffer } from "./chatCore/clientUsageBuffer.ts";
@@ -779,12 +778,11 @@ export async function handleChatCore({
classifierSettings.claudeClassifierCompat as string | undefined
)
) {
const classifierFormat = detectClassifierFormat(body as Record<string, unknown>);
log?.warn?.(
"CHAT",
`classifier compat=${classifierSettings.claudeClassifierCompat} format=${classifierFormat} | short-circuit default-allow`
`classifier compat=${classifierSettings.claudeClassifierCompat} | short-circuit default-allow`
);
return buildDefaultAllowClaudeMessage(requestedModel, classifierFormat);
return buildDefaultAllowClaudeMessage(requestedModel);
}
}

View File

@@ -24,19 +24,14 @@ const SECURITY_MONITOR_MARKER = "You are a security monitor for autonomous AI co
export type ClaudeClassifierCompatMode = "off" | "auto" | "always";
/** The two synthetic-response shapes Claude Code's classifier can expect. */
export type ClaudeClassifierFormat = "block" | "severity";
function extractSystemTexts(body: Record<string, unknown> | null | undefined): string[] {
const system = body?.system;
if (typeof system === "string") return [system];
if (Array.isArray(system)) {
return system
.map((part) =>
part && typeof (part as { text?: unknown }).text === "string"
? (part as { text: string }).text
: ""
)
.map((part) => (part && typeof (part as { text?: unknown }).text === "string"
? ((part as { text: string }).text)
: ""))
.filter(Boolean);
}
return [];
@@ -65,29 +60,6 @@ export function shouldDefaultAllowClassifier(
return extractSystemTexts(body).some((text) => text.includes(SECURITY_MONITOR_MARKER));
}
/**
* Detect which synthetic-response shape the classifier request expects.
*
* Newer Claude Code builds send a "severity classifier" variant of the same internal
* request: it carries `stop_sequences: [..., "</severity>", ...]` and parses a
* `<severity>N</severity>` reply instead of `<block>no</block>`/`<block>yes</block>`.
* Feeding it the legacy `<block>no</block>` shape is unparseable, so it retries both
* stages and then fails closed — the same "blocking it for safety" failure this compat
* shim exists to avoid. Only `stop_sequences` distinguishes the two shapes; callers
* should only consult this after `shouldDefaultAllowClassifier` has already confirmed
* the request is the classifier (via the system-prompt marker), so an unrelated app
* that merely happens to use `</severity>` as a stop token is never affected (#8189).
*/
export function detectClassifierFormat(
body: Record<string, unknown> | null | undefined
): ClaudeClassifierFormat {
const stopSequences = body?.stop_sequences;
if (Array.isArray(stopSequences) && stopSequences.includes("</severity>")) {
return "severity";
}
return "block";
}
/**
* Build the synthetic Claude `message` ALLOW response. Always returns a plain JSON
* body (matching the upstream reference implementation) — Claude Code's classifier
@@ -95,10 +67,7 @@ export function detectClassifierFormat(
* satisfies both streaming and non-streaming callers without needing to plumb a
* synthetic SSE encoding through the streaming/sseToJson/non-streaming handlers.
*/
export function buildDefaultAllowClaudeMessage(
model?: string | null,
format: ClaudeClassifierFormat = "block"
): {
export function buildDefaultAllowClaudeMessage(model?: string | null): {
success: true;
response: Response;
} {
@@ -107,12 +76,7 @@ export function buildDefaultAllowClaudeMessage(
type: "message",
role: "assistant",
model: model || "claude-3-5-sonnet-20241022",
content: [
{
type: "text",
text: format === "severity" ? "<severity>0</severity>" : "<block>no</block>",
},
],
content: [{ type: "text", text: "<block>no</block>" }],
stop_reason: "end_turn",
stop_sequence: null,
usage: { input_tokens: 1, output_tokens: 1 },

View File

@@ -188,6 +188,32 @@ describe("injectMemory — edge cases", () => {
});
});
describe("injectMemory — Claude-family cache-safe splice gate (#11290)", () => {
test("does not splice mid-array on anthropic when the last turn before the splice point is plain assistant text", () => {
const request = makeRequest({
messages: [
{ role: "system", content: "SYSTEM PROMPT" },
{ role: "user", content: "turn 1 question" },
{ role: "assistant", content: "turn 1 answer" },
{ role: "user", content: "turn 2 question" },
],
});
const memories = [makeMemory("dark mode")];
const result = injectMemory(request, memories, "anthropic", { cacheSafe: true });
// The plain-text assistant turn must stay immediately followed by the final user
// turn — no system message spliced between them (that shape is what Opus 5 rejects
// with HTTP 400, #11290). Memory is merged into the leading system message instead.
expect(result.messages).toHaveLength(4);
expect(result.messages[0].role).toBe("system");
expect(result.messages[0].content).toContain("Memory context: dark mode");
expect(result.messages[0].content).toContain("SYSTEM PROMPT");
expect(result.messages[2]).toEqual({ role: "assistant", content: "turn 1 answer" });
expect(result.messages[3]).toEqual({ role: "user", content: "turn 2 question" });
});
});
describe("shouldInjectMemory", () => {
test("returns true when messages are present and enabled not set", () => {
const request = makeRequest();

View File

@@ -12,6 +12,10 @@
import { Memory } from "./types";
import { logger } from "../../../open-sse/utils/logger.ts";
import {
isAnthropicCompatibleProvider,
isClaudeCodeCompatibleProvider,
} from "../../shared/constants/providers";
const log = logger("MEMORY_INJECTION");
@@ -170,6 +174,43 @@ function injectSystemFirst(
return { ...request, messages: [memorySystemMessage, ...messages] };
}
/**
* #11290: providers in the Claude family (direct Anthropic, and any
* anthropic-compatible / Claude-Code-compatible passthrough connection) — the
* ones affected by the stricter Opus 5 message-ordering validation described
* below. Deliberately narrower than `systemMessageMustBeFirst()`'s strict-set:
* this only gates the cache-safe mid-array splice, not the leading-system-message
* requirement, so non-Claude providers keep the #3890 cache-hit optimization
* unconditionally.
*/
function isClaudeFamilyProvider(provider: string | null | undefined): boolean {
if (!provider) return false;
const normalized = provider.toLowerCase().trim();
return (
normalized === "claude" ||
normalized === "anthropic" ||
isClaudeCodeCompatibleProvider(provider) ||
isAnthropicCompatibleProvider(provider)
);
}
/**
* True when an assistant message's content ends in a server-side tool result
* block (e.g. `web_search_tool_result`, `code_execution_tool_result`,
* `mcp_tool_result` — any Anthropic content block whose type ends in
* `_tool_result`, produced by a server-executed tool rather than a
* client-executed one). `content` is typed as `string` on `ChatMessage` for
* the common case, but the Claude-native wire shape carries an array of
* content blocks — this only recognizes that richer shape.
*/
function endsWithServerToolResult(message: ChatMessage | undefined): boolean {
if (!message || message.role !== "assistant") return false;
const content = message.content as unknown;
if (!Array.isArray(content) || content.length === 0) return false;
const lastBlock = content[content.length - 1] as { type?: unknown } | null | undefined;
return typeof lastBlock?.type === "string" && lastBlock.type.endsWith("_tool_result");
}
/**
* Place a memory message at the #3890 cache-safe anchor (just before the last
* user turn) when one exists, else prepend it. Shared by the system and user
@@ -222,6 +263,24 @@ export function injectMemory(
return injectSystemFirst(request, messages, memoryText, memories.length);
}
// #11290: Claude Opus 5 tightened server-side validation of the cache-safe
// mid-array splice — a system message spliced right after a plain-text assistant
// turn is rejected with HTTP 400 (the immediately preceding message must end in a
// server-side tool result for a following system message to be accepted). Rather
// than adding "claude"/"anthropic" outright to `systemMessageMustBeFirst()` (which
// would revert the #3890 cache-hit optimization for every Claude request, including
// the ones that work fine today), only fall back to the leading-system-message
// placement for the specific requests where the turn right before the splice point
// isn't a server tool result.
if (
supportsSystem &&
cacheSafeIndex >= 0 &&
isClaudeFamilyProvider(provider) &&
!endsWithServerToolResult(messages[cacheSafeIndex - 1])
) {
return injectSystemFirst(request, messages, memoryText, memories.length);
}
// Strategy 1 (system): prepend before existing system messages, preserving the
// caller's own instructions. Strategy 2 (user, e.g. o1-mini): inject as a user
// message. Both honor the #3890 cache-safe anchor via placeMessage.

View File

@@ -25,8 +25,9 @@ process.env.DATA_DIR = TEST_DATA_DIR;
const core = await import("../../src/lib/db/core.ts");
const { updateSettings } = await import("../../src/lib/db/settings.ts");
const { handleChatCore } = await import("../../open-sse/handlers/chatCore.ts");
const { shouldDefaultAllowClassifier, detectClassifierFormat, buildDefaultAllowClaudeMessage } =
await import("../../open-sse/handlers/chatCore/claudeClassifierCompat.ts");
const { shouldDefaultAllowClassifier, buildDefaultAllowClaudeMessage } = await import(
"../../open-sse/handlers/chatCore/claudeClassifierCompat.ts"
);
const { FORMATS } = await import("../../open-sse/translator/formats.ts");
const originalFetch = globalThis.fetch;
@@ -57,14 +58,6 @@ const CLASSIFIER_BODY = {
max_tokens: 8,
};
// Newer Claude Code builds send a "severity classifier" variant of the same internal
// request: same security-monitor marker, but `stop_sequences` carries `</severity>`
// instead of `</block>`, and it expects a `<severity>N</severity>` reply (#11289).
const SEVERITY_CLASSIFIER_BODY = {
...CLASSIFIER_BODY,
stop_sequences: ["</severity>"],
};
test.after(() => {
globalThis.fetch = originalFetch;
core.resetDbInstance();
@@ -130,12 +123,7 @@ test("detector: always does NOT fire for normal chat without classifier marker (
test("detector: always fires when classifier marker is present", () => {
const classifier = {
system: [
{
type: "text",
text: "You are a security monitor for autonomous AI coding agents. Evaluate the following action.",
},
],
system: [{ type: "text", text: "You are a security monitor for autonomous AI coding agents. Evaluate the following action." }],
stop_sequences: ["</block>"],
};
assert.equal(
@@ -145,21 +133,6 @@ test("detector: always fires when classifier marker is present", () => {
);
});
// ─── Pure detector: detectClassifierFormat (#11289) ──────────────────────────
test("format detector: defaults to 'block' for the legacy </block> classifier shape", () => {
assert.equal(detectClassifierFormat(CLASSIFIER_BODY), "block");
});
test("format detector: returns 'severity' when stop_sequences carries </severity>", () => {
assert.equal(detectClassifierFormat(SEVERITY_CLASSIFIER_BODY), "severity");
});
test("format detector: defaults to 'block' when stop_sequences is missing/empty", () => {
assert.equal(detectClassifierFormat({}), "block");
assert.equal(detectClassifierFormat({ stop_sequences: [] }), "block");
});
// ─── Pure builder: buildDefaultAllowClaudeMessage ────────────────────────────
test("builder: synthetic message text STARTS WITH <block>no</block>", async () => {
@@ -182,16 +155,6 @@ test("builder: synthetic message text STARTS WITH <block>no</block>", async () =
assert.ok(!text.includes("<block>yes"), "must not signal BLOCK");
});
test("builder: format='severity' returns <severity>0</severity> (#11289)", async () => {
const built = buildDefaultAllowClaudeMessage("claude-3-5-haiku-20241022", "severity");
assert.equal(built.success, true);
const payload = (await built.response.json()) as {
content: Array<{ type: string; text?: string }>;
};
const text = payload.content.find((b) => b.type === "text")?.text ?? "";
assert.equal(text, "<severity>0</severity>");
});
// ─── Handler-level: end-to-end short-circuit through handleChatCore ──────────
test("handler: claudeClassifierCompat=auto short-circuits WITHOUT calling upstream, text starts with <block>no</block>", async () => {
@@ -233,44 +196,3 @@ test("handler: claudeClassifierCompat=auto short-circuits WITHOUT calling upstre
globalThis.fetch = originalFetch;
}
});
test("handler: claudeClassifierCompat=auto emits <severity>0</severity> for the severity-classifier shape (#11289)", async () => {
await updateSettings({ claudeClassifierCompat: "auto" });
let fetchCalls = 0;
globalThis.fetch = (async () => {
fetchCalls++;
throw new Error("upstream fetch should NOT be called when the classifier short-circuits");
}) as typeof fetch;
try {
const result = await handleChatCore({
body: structuredClone(SEVERITY_CLASSIFIER_BODY),
modelInfo: { provider: "openai", model: "gpt-4o-mini", extendedContext: false },
credentials: { apiKey: "sk-test", providerSpecificData: {} },
log: noopLog(),
clientRawRequest: {
endpoint: "/v1/messages",
body: structuredClone(SEVERITY_CLASSIFIER_BODY),
headers: new Headers({ accept: "application/json" }),
},
userAgent: "unit-test",
});
assert.equal(fetchCalls, 0, "upstream fetch must NOT be called");
assert.equal(result.success, true, "handleChatCore must report success");
const payload = (await (result as { response: Response }).response.json()) as {
type: string;
content: Array<{ type: string; text?: string }>;
};
assert.equal(payload.type, "message");
const text = payload.content.find((b) => b.type === "text")?.text ?? "";
assert.equal(
text,
"<severity>0</severity>",
`expected severity-classifier response to be <severity>0</severity>, got: ${text}`
);
} finally {
globalThis.fetch = originalFetch;
}
});

View File

@@ -39,16 +39,20 @@ function multiTurn(): ChatRequest {
describe("injectMemory cache-safe positioning (#3890)", () => {
it("default (cacheSafe off) prepends memory at index 0 — unchanged legacy behavior", () => {
const out = injectMemory(multiTurn(), [mem("dark mode")], "anthropic");
const out = injectMemory(multiTurn(), [mem("dark mode")], "openai");
assert.equal(out.messages[0].role, "system");
assert.ok(out.messages[0].content.includes("Memory context"));
assert.equal(out.messages[1].content, "SYSTEM PROMPT");
});
// Note: "openai" here stands in for any non-Claude-family provider that honors the
// cache-safe mid-array splice (e.g. DashScope/Xiaomi MiMo via OpenAI-format
// cache_control). Claude-family providers (anthropic/claude/CC-compatible) have their
// own, narrower gate covered in the "#11290" describe block below.
it("cacheSafe inserts memory just before the last user message, preserving the prefix", () => {
const req = multiTurn();
const prefixBefore = JSON.stringify(req.messages.slice(0, 3)); // sys, u1, a1
const out = injectMemory(req, [mem("dark mode")], "anthropic", { cacheSafe: true });
const out = injectMemory(req, [mem("dark mode")], "openai", { cacheSafe: true });
// The cacheable prefix (system + prior turns up to the last assistant) is byte-identical.
assert.equal(JSON.stringify(out.messages.slice(0, 3)), prefixBefore);
@@ -75,8 +79,8 @@ describe("injectMemory cache-safe positioning (#3890)", () => {
};
const turn2 = multiTurn();
const out1 = injectMemory(turn1, [mem("A")], "anthropic", { cacheSafe: true });
const out2 = injectMemory(turn2, [mem("B")], "anthropic", { cacheSafe: true });
const out1 = injectMemory(turn1, [mem("A")], "openai", { cacheSafe: true });
const out2 = injectMemory(turn2, [mem("B")], "openai", { cacheSafe: true });
// The cache-breakpoint-bearing system message stays at the head, byte-identical, in
// both turns (and is NOT displaced by the per-query memory) — so the prompt cache
@@ -103,3 +107,76 @@ describe("injectMemory cache-safe positioning (#3890)", () => {
assert.equal(out.messages[1].content, "SYS");
});
});
/**
* #11290: Claude Opus 5 tightened server-side validation and started rejecting the
* #3890 cache-safe mid-array splice with HTTP 400 whenever the assistant turn
* immediately before the splice point is a plain-text turn (not a server-side tool
* result). These tests pin the narrower, Claude-family-only gate added to
* `injectMemory()`: fall back to leading-system-message placement in that specific
* case, while still honoring the mid-array splice everywhere it is safe (non-Claude
* providers unconditionally, and Claude providers whose preceding turn IS a server
* tool result).
*/
describe("injectMemory cache-safe positioning — Claude-family server-tool-result gate (#11290)", () => {
it("falls back to leading system-message placement for anthropic when the preceding assistant turn is plain text", () => {
const out = injectMemory(multiTurn(), [mem("dark mode")], "anthropic", { cacheSafe: true });
// No splice: the memory is merged into the leading system message instead of being
// inserted right after the plain-text "turn 1 answer" assistant turn.
assert.equal(out.messages.length, 4);
assert.equal(out.messages[0].role, "system");
assert.ok(out.messages[0].content.includes("Memory context: dark mode"));
assert.ok(out.messages[0].content.includes("SYSTEM PROMPT"));
assert.equal(out.messages[1].content, "turn 1 question");
assert.equal(out.messages[2].content, "turn 1 answer");
assert.equal(out.messages[3].content, "turn 2 question");
});
it("still splices mid-array for anthropic when the preceding assistant turn ends in a server tool result", () => {
const req: ChatRequest = {
model: "anthropic/claude-opus-5",
messages: [
{ role: "system", content: "SYSTEM PROMPT" },
{ role: "user", content: "turn 1 question" },
{
role: "assistant",
content: [
{ type: "server_tool_use", id: "srvtoolu_1", name: "web_search", input: {} },
{ type: "web_search_tool_result", tool_use_id: "srvtoolu_1", content: [] },
],
} as unknown as ChatRequest["messages"][number],
{ role: "user", content: "turn 2 question" },
],
};
const out = injectMemory(req, [mem("dark mode")], "anthropic", { cacheSafe: true });
assert.equal(out.messages.length, 5);
assert.equal(out.messages[0].content, "SYSTEM PROMPT");
assert.equal(out.messages[3].role, "system");
assert.ok(out.messages[3].content.includes("Memory context"));
assert.equal(out.messages[4].content, "turn 2 question");
});
it("applies the same fallback to a Claude-Code-compatible passthrough provider id", () => {
const out = injectMemory(multiTurn(), [mem("dark mode")], "anthropic-compatible-cc-github-copilot", {
cacheSafe: true,
});
assert.equal(out.messages.length, 4);
assert.equal(out.messages[0].role, "system");
assert.ok(out.messages[0].content.includes("Memory context: dark mode"));
assert.ok(out.messages[0].content.includes("SYSTEM PROMPT"));
});
it("does not gate non-Claude providers even without a server tool result", () => {
const out = injectMemory(multiTurn(), [mem("dark mode")], "openai", { cacheSafe: true });
// Unaffected by #11290: the mid-array splice is preserved for non-Claude providers.
assert.equal(out.messages.length, 5);
assert.equal(out.messages[3].role, "system");
assert.ok(out.messages[3].content.includes("Memory context"));
assert.equal(out.messages[4].content, "turn 2 question");
});
});