fix(memory): avoid rejected mid-conversation system injection on Claude when preceding turn isn't a tool result (#11290) (#11303)

Validated on a 4-PR combined board: memory-cache-safe-injection + memory-glm-injection + chatcore-memory-skills-injection 39/39 (node:test), injection.test.ts 23/23 (vitest), typecheck:core clean, check:cycles clean, gates within baseline. Narrows the #3890 mid-array system-splice skip to the exact shape that 400s on Claude Opus 5 (splice point not preceded by a server tool result) instead of disabling the cache-safe optimization for every Claude request — preserves the fast path everywhere it's actually safe. Closes #11290.
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-08-23 21:34:50 -03:00
committed by GitHub
parent d282ec7ad6
commit 3daa455e1f
3 changed files with 166 additions and 4 deletions

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.