diff --git a/docs/claude-instructions-cache-stabilize/PAPER_OCXFIX.pdf b/docs/claude-instructions-cache-stabilize/PAPER_OCXFIX.pdf new file mode 100644 index 0000000000..5206d30d1f Binary files /dev/null and b/docs/claude-instructions-cache-stabilize/PAPER_OCXFIX.pdf differ diff --git a/docs/claude-instructions-cache-stabilize/README.md b/docs/claude-instructions-cache-stabilize/README.md new file mode 100644 index 0000000000..2725f7d45d --- /dev/null +++ b/docs/claude-instructions-cache-stabilize/README.md @@ -0,0 +1,36 @@ +# Claude instructions cache stabilize (OCXFIX) + +OpenCodex inbound conversion puts Claude Code system text into OpenAI Responses +`instructions`. Claude Code then appends a growing `` +footer (and occasional TaskCreate nudges) on every turn. That prefix churn +causes prompt-cache misses on Muse/Go. + +This change strips those dynamic footers from `instructions` and reattaches the +latest notice as a trailing `input` message so the cacheable prefix stays stable. + +Relocation is opt-in: `translateAnthropicRequest` / `anthropicToResponsesTranslation` +take `stabilizePromptCache?: boolean` (default **false**). Ordinary Anthropic +callers keep a matching suffix in `instructions`. The Claude Code `/v1/messages` +inbound path passes `true`. The matcher is `N tokens left` +plus the exact TaskCreate paragraph; it is not gated on `metadata.user_id`. +Outside opt-in, the Desktop `prompt_cache_key` fallback hashes raw `systemParts`. +When opted in, that fallback hashes the same string as `body.instructions`. + +## Paper + +See [PAPER_OCXFIX.pdf](./PAPER_OCXFIX.pdf) (Warexpor). + +Measured cache-hit rates on Muse Spark 1.3 via OpenCode Go / OpenCodex: + +| Slice | Baseline mean | OCXFIX mean | +| --- | ---: | ---: | +| Claude Code (n=75) | 0.168384 | 0.864374 | +| Claude S/T4 (n=5) | 0.134922 | 0.982700 | +| Grok Build (n=75) | 0.966526 | 0.941345 | + +Cause: Anthropic→Responses conversion stores Claude system text in `instructions`; +Claude Code appends growing `` (and rare TaskCreate nudges), so +`instructions_sha` changes every turn. Grok traffic has no `instructions` field +and is the control. + +Code: `src/claude/inbound-cache-stabilize.ts`, wired from `src/claude/inbound.ts`. diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 0ff7d4861c..ef04b588e2 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -304,6 +304,7 @@ "claude-desktop-remote-hub.test.ts": "claude-integration", "claude-dotenv-provenance-transport.test.ts": "claude-integration", "claude-gateway-cache.test.ts": "claude-integration", + "claude-inbound-cache-stabilize.test.ts": "claude-integration", "claude-inbound-debug.test.ts": "claude-integration", "claude-inbound.test.ts": "claude-integration", "claude-management-api.test.ts": "claude-integration", diff --git a/src/claude/inbound-cache-stabilize.ts b/src/claude/inbound-cache-stabilize.ts new file mode 100644 index 0000000000..78b859350e --- /dev/null +++ b/src/claude/inbound-cache-stabilize.ts @@ -0,0 +1,128 @@ +/** + * Claude Code appends growing `` footers (and + * occasional TaskCreate nudges) into system text that becomes Responses + * `instructions`. That churn breaks Muse/Go prefix cache on the instructions + * prefix even when tools stay stable. Strip dynamics from instructions; + * surface the latest notice on `input` instead. + * + * Relocation is identified by harness shape: only a trailing, unfenced, + * canonical notice at the end of instructions is moved. An unmatched fence + * opener covers through EOF. No match → the original string is returned + * byte-for-byte. The matcher is content identity only; `translateAnthropicRequest` + * must pass `stabilizePromptCache: true` before this helper runs. Claude Code + * writes `N tokens left`. + */ + +const TRAILING_TOTAL_RE = + /(?:^|(?:\r?\n)+)[ \t]*(\d+\s+tokens left<\/total_tokens>)[ \t]*(?:\r?\n)*$/; + +const TRAILING_NUDGE_RE = + /(?:^|(?:\r?\n)+)[ \t]*(The task tools haven't been used recently\.\s+If you're working on tasks that would benefit from tracking, consider using TaskCreate to add them\.\s+Only use these if relevant to the current work\.\s+This is just a gentle reminder - ignore if not applicable\.)[ \t]*(?:\r?\n)*$/; + +interface FenceRange { + start: number; + end: number; +} + +const FENCE_OPEN_RE = /^( {0,3})(`{3,}|~{3,})/; +const FENCE_CLOSE_RE = /^( {0,3})(`{3,}|~{3,})[ \t]*$/; + +/** + * Markdown fence ranges. An unmatched opener covers through EOF: unfinished + * fenced examples are code content, not a harness suffix. A closer must be a + * standalone fence line (no info string) of the same character and at least + * the opener's length. + */ +function fencedRanges(source: string): FenceRange[] { + const ranges: FenceRange[] = []; + let offset = 0; + let openAt: number | null = null; + let openFence = ""; + while (offset <= source.length) { + const nl = source.indexOf("\n", offset); + const lineEnd = nl === -1 ? source.length : nl; + const line = source.slice(offset, lineEnd).replace(/\r$/, ""); + if (openAt === null) { + const open = FENCE_OPEN_RE.exec(line); + if (open) { + openAt = offset; + openFence = open[2]!; + } + } else { + const close = FENCE_CLOSE_RE.exec(line); + if ( + close + && close[2]![0] === openFence[0] + && close[2]!.length >= openFence.length + ) { + ranges.push({ start: openAt, end: lineEnd }); + openAt = null; + openFence = ""; + } + } + if (nl === -1) break; + offset = nl + 1; + } + if (openAt !== null) ranges.push({ start: openAt, end: source.length }); + return ranges; +} + +function isInsideFence(ranges: readonly FenceRange[], index: number): boolean { + return ranges.some(range => index >= range.start && index < range.end); +} + +function peelOne( + rest: string, + ranges: readonly FenceRange[], +): { rest: string; total?: string; nudge?: string } | null { + const total = rest.match(TRAILING_TOTAL_RE); + if (total?.[1]) { + const matchStart = rest.length - total[0].length; + const tagAt = matchStart + total[0].indexOf(total[1]); + if (!isInsideFence(ranges, tagAt)) { + return { rest: rest.slice(0, rest.length - total[0].length), total: total[1] }; + } + } + const nudge = rest.match(TRAILING_NUDGE_RE); + if (nudge?.[1]) { + const matchStart = rest.length - nudge[0].length; + const tagAt = matchStart + nudge[0].indexOf(nudge[1]); + if (!isInsideFence(ranges, tagAt)) { + return { rest: rest.slice(0, rest.length - nudge[0].length), nudge: nudge[1] }; + } + } + return null; +} + +export function stabilizeClaudeInstructionsForPromptCache( + instructions: string, +): { instructions: string; dynamicNotice: string | null } { + if (!instructions) { + return { instructions: "", dynamicNotice: null }; + } + + const ranges = fencedRanges(instructions); + let rest = instructions; + let latestTotal: string | null = null; + let latestNudge: string | null = null; + let peeled = false; + for (;;) { + const next = peelOne(rest, ranges); + if (!next) break; + peeled = true; + rest = next.rest; + if (next.total && latestTotal === null) latestTotal = next.total; + if (next.nudge && latestNudge === null) latestNudge = next.nudge; + } + + if (!peeled) { + return { instructions, dynamicNotice: null }; + } + + const noticeParts: string[] = []; + if (latestTotal) noticeParts.push(latestTotal); + if (latestNudge) noticeParts.push(latestNudge); + const dynamicNotice = noticeParts.length > 0 ? noticeParts.join("\n\n") : null; + + return { instructions: rest, dynamicNotice }; +} diff --git a/src/claude/inbound.ts b/src/claude/inbound.ts index c2e3ded9b2..eddac06ac8 100644 --- a/src/claude/inbound.ts +++ b/src/claude/inbound.ts @@ -17,6 +17,7 @@ export { resolveInboundModel, effortForThinkingBudget, effortFromOutputConfig, e import { AnthropicRequestError, isRec, type Rec } from "./inbound-records"; import { resolveInboundModel, effortForThinkingBudget, effortFromOutputConfig, formatFromOutputConfig } from "./inbound-model-options"; import { systemToInstructions, toolsToResponses, toolChoiceToResponses } from "./inbound-content-options"; +import { stabilizeClaudeInstructionsForPromptCache } from "./inbound-cache-stabilize"; import { decodeReasoningEnvelope, encodeReasoningEnvelope, OCX_REASONING_PREFIX } from "../responses/reasoning-envelope"; import { createTranslatorBudget, type TranslatorBudget } from "../lib/translator-budget"; @@ -287,12 +288,25 @@ export interface ClaudeInboundTranslation { cacheKeySource: ClaudeCacheKeySource; } +/** + * Translator-level opt-in for Claude Code harness footer relocation. + * Default is off: a matching textual suffix is not permission to change its role. + * The `/v1/messages` inbound path passes true. Do not infer this from metadata.user_id. + */ +export interface ClaudeInboundTranslateOptions { + stabilizePromptCache?: boolean; +} + /** * Translate an Anthropic Messages request body into a /v1/responses request body. * Throws AnthropicRequestError (-> 400 invalid_request_error) on malformed input. */ -export function anthropicToResponsesBody(raw: unknown, cc?: OcxClaudeCodeConfig): Rec { - return anthropicToResponsesTranslation(raw, cc).body; +export function anthropicToResponsesBody( + raw: unknown, + cc?: OcxClaudeCodeConfig, + options?: ClaudeInboundTranslateOptions, +): Rec { + return anthropicToResponsesTranslation(raw, cc, undefined, options).body; } /** @@ -300,16 +314,26 @@ export function anthropicToResponsesBody(raw: unknown, cc?: OcxClaudeCodeConfig) * OUT-OF-BODY tuple (audit 133 R3#1 — an in-body marker would leak upstream through * the native Responses forward and 400). */ -export function anthropicToResponsesTranslation(raw: unknown, cc?: OcxClaudeCodeConfig, budget?: TranslatorBudget): ClaudeInboundTranslation { +export function anthropicToResponsesTranslation( + raw: unknown, + cc?: OcxClaudeCodeConfig, + budget?: TranslatorBudget, + options?: ClaudeInboundTranslateOptions, +): ClaudeInboundTranslation { const activeBudget = budget ?? createTranslatorBudget(); try { - return translateAnthropicRequest(raw, cc, activeBudget); + return translateAnthropicRequest(raw, cc, activeBudget, options); } finally { if (!budget) activeBudget.dispose(); } } -function translateAnthropicRequest(raw: unknown, cc: OcxClaudeCodeConfig | undefined, budget: TranslatorBudget): ClaudeInboundTranslation { +function translateAnthropicRequest( + raw: unknown, + cc: OcxClaudeCodeConfig | undefined, + budget: TranslatorBudget, + options?: ClaudeInboundTranslateOptions, +): ClaudeInboundTranslation { if (!isRec(raw)) throw new AnthropicRequestError("request body must be a JSON object"); if (typeof raw.model !== "string" || raw.model.length === 0) { throw new AnthropicRequestError("model is required"); @@ -345,7 +369,32 @@ function translateAnthropicRequest(raw: unknown, cc: OcxClaudeCodeConfig | undef stream: raw.stream === true, }; - if (systemParts.length > 0) body.instructions = systemParts.join("\n\n"); + const joinedSystem = systemParts.length > 0 ? systemParts.join("\n\n") : ""; + const stabilizePromptCache = options?.stabilizePromptCache === true; + // Desktop fallback hashes raw systemParts unless the caller opted into + // harness cleanup. Opt-in then hashes the same string as body.instructions. + let cacheSystem: string | string[] = systemParts; + if (joinedSystem) { + if (stabilizePromptCache) { + // Claude Code appends growing N tokens left + // footers (and occasional TaskCreate nudges) into system text. That churn + // breaks Muse/Go prefix cache on the Responses instructions prefix even + // when tools stay stable. Relocation is caller-opted, not inferred from + // a matching suffix or metadata.user_id. + const stabilized = stabilizeClaudeInstructionsForPromptCache(joinedSystem); + if (stabilized.instructions) body.instructions = stabilized.instructions; + if (stabilized.dynamicNotice) { + input.push({ + type: "message", + role: "user", + content: [{ type: "input_text", text: stabilized.dynamicNotice }], + }); + } + cacheSystem = stabilized.instructions; + } else { + body.instructions = joinedSystem; + } + } const tools = toolsToResponses(raw.tools); if (tools) body.tools = tools; @@ -383,11 +432,14 @@ function translateAnthropicRequest(raw: unknown, cc: OcxClaudeCodeConfig | undef // Exact-prefix matching still isolates content; the key only steers routing // affinity. Callers must NOT synthesize a session_id header from this fallback // (audit 133 R2#3). + // Outside opt-in, hash the raw systemParts array (pre-stabilize Desktop + // key). Opt-in hashes the same string used for body.instructions so the + // key tracks the cacheable prefix after peel. body.prompt_cache_key = createHash("sha256") .update(canonicalJson({ version: 2, model: body.model, - system: systemParts, + system: cacheSystem, tools: Array.isArray(body.tools) ? body.tools : [], })) .digest("hex").slice(0, 32); diff --git a/src/server/claude-messages.ts b/src/server/claude-messages.ts index 8c3e37eea8..f672f9f911 100644 --- a/src/server/claude-messages.ts +++ b/src/server/claude-messages.ts @@ -755,7 +755,9 @@ async function handleClaudeMessagesWithBudget( }; delete anthropicBody.thinking; } - const translation = anthropicToResponsesTranslation(anthropicBody, config.claudeCode, translatorBudget); + const translation = anthropicToResponsesTranslation(anthropicBody, config.claudeCode, translatorBudget, { + stabilizePromptCache: true, + }); internalBody = translation.body; // The Anthropic translator builds its body from model/input/store/stream plus sampling // fields only, so the caller intent is applied to the TRANSLATED body rather than the diff --git a/tests/claude-integration/claude-inbound-cache-stabilize.test.ts b/tests/claude-integration/claude-inbound-cache-stabilize.test.ts new file mode 100644 index 0000000000..fe044f358a --- /dev/null +++ b/tests/claude-integration/claude-inbound-cache-stabilize.test.ts @@ -0,0 +1,327 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { stabilizeClaudeInstructionsForPromptCache } from "../../src/claude/inbound-cache-stabilize"; +import { anthropicToResponsesTranslation } from "../../src/claude/inbound"; +import { repoPath } from "../helpers/repo-root"; + +const TASKCREATE_NUDGE = [ + "The task tools haven't been used recently. If you're working on tasks that would benefit from tracking, consider using TaskCreate to add them.", + "Only use these if relevant to the current work. This is just a gentle reminder - ignore if not applicable.", +].join(" "); + +function footer(used: number): string { + return `${used} tokens left`; +} + +function translate( + system: string, + options?: { user_id?: string; stabilizePromptCache?: boolean }, +) { + return anthropicToResponsesTranslation( + { + model: "m", + max_tokens: 1, + system, + messages: [{ role: "user", content: "hi" }], + ...(options?.user_id ? { metadata: { user_id: options.user_id } } : {}), + }, + undefined, + undefined, + options?.stabilizePromptCache === undefined + ? undefined + : { stabilizePromptCache: options.stabilizePromptCache }, + ); +} + +function translateHarness(system: string, metadata?: { user_id: string }) { + return anthropicToResponsesTranslation( + { + model: "m", + max_tokens: 1, + system, + messages: [{ role: "user", content: "hi" }], + ...(metadata ? { metadata } : {}), + }, + undefined, + undefined, + { stabilizePromptCache: true }, + ); +} + +function userTurns(body: { input: unknown }) { + return body.input as Array>; +} + +describe("stabilizeClaudeInstructionsForPromptCache", () => { + test("empty input is a no-op", () => { + expect(stabilizeClaudeInstructionsForPromptCache("")).toEqual({ + instructions: "", + dynamicNotice: null, + }); + }); + + test("stable instructions without dynamics pass through", () => { + const instructions = "You are Claude Code.\n\nPrefer terse answers."; + expect(stabilizeClaudeInstructionsForPromptCache(instructions)).toEqual({ + instructions, + dynamicNotice: null, + }); + }); + + test("no-match whitespace is returned byte-for-byte", () => { + const instructions = "You are Claude Code.\n\n\nPrefer terse answers.\n"; + expect(stabilizeClaudeInstructionsForPromptCache(instructions)).toEqual({ + instructions, + dynamicNotice: null, + }); + }); + + test("whitespace-only system without a footer is unchanged", () => { + const instructions = " \n\n "; + expect(stabilizeClaudeInstructionsForPromptCache(instructions)).toEqual({ + instructions, + dynamicNotice: null, + }); + }); + + test("three trailing total_tokens footers keep only the latest in the notice", () => { + const stable = "You are Claude Code."; + const first = footer(1000); + const second = footer(4000); + const third = footer(8000); + const result = stabilizeClaudeInstructionsForPromptCache( + [stable, first, second, third].join("\n\n"), + ); + expect(result.instructions).toBe(stable); + expect(result.instructions).not.toContain(""); + expect(result.dynamicNotice).toBe(third); + }); + + test("real Claude Code 15000000 tokens left trailing footer peels", () => { + const stable = "You are Claude Code."; + const harness = footer(15_000_000); + const result = stabilizeClaudeInstructionsForPromptCache(`${stable}\n\n${harness}`); + expect(result.instructions).toBe(stable); + expect(result.dynamicNotice).toBe("15000000 tokens left"); + }); + + test("bare numeric total_tokens without tokens left is not a harness footer", () => { + const docs = "You are Claude Code.\n\n123"; + const result = stabilizeClaudeInstructionsForPromptCache(docs); + expect(result.instructions).toBe(docs); + expect(result.dynamicNotice).toBeNull(); + }); + + test("mid-document total_tokens stays; only the trailing harness footer relocates", () => { + const result = stabilizeClaudeInstructionsForPromptCache( + `System.\n${footer(1)}\nMore system.\n${footer(3)}`, + ); + expect(result.instructions).toBe(`System.\n${footer(1)}\nMore system.`); + expect(result.dynamicNotice).toBe(footer(3)); + }); + + test("TaskCreate nudge is stripped from instructions and kept in the notice", () => { + const stable = "You are Claude Code."; + const result = stabilizeClaudeInstructionsForPromptCache( + `${stable}\n\n${TASKCREATE_NUDGE}`, + ); + expect(result.instructions).toBe(stable); + expect(result.instructions).not.toContain("TaskCreate"); + expect(result.dynamicNotice).toBe(TASKCREATE_NUDGE); + }); + + test("latest footer and latest nudge both surface in the notice", () => { + const stable = "Stay stable."; + const older = footer(10); + const latest = footer(50); + const result = stabilizeClaudeInstructionsForPromptCache( + [stable, older, TASKCREATE_NUDGE, latest].join("\n\n"), + ); + expect(result.instructions).toBe(stable); + expect(result.dynamicNotice).toBe(`${latest}\n\n${TASKCREATE_NUDGE}`); + }); + + test("inline documentation of total_tokens tags stays in instructions", () => { + const docs = "The harness may emit a 123 footer; do not invent one."; + const result = stabilizeClaudeInstructionsForPromptCache(docs); + expect(result.instructions).toBe(docs); + expect(result.dynamicNotice).toBeNull(); + }); + + test("TaskCreate mentioned in docs is not treated as the harness nudge", () => { + const docs = "The task tools haven't been used recently. You may mention TaskCreate in docs without the reminder."; + const result = stabilizeClaudeInstructionsForPromptCache(docs); + expect(result.instructions).toBe(docs); + expect(result.dynamicNotice).toBeNull(); + }); + + test("fenced standalone total_tokens example stays byte-for-byte", () => { + const docs = ["You are a docs bot.", "```", footer(123), "```", ""].join("\n"); + const result = stabilizeClaudeInstructionsForPromptCache(docs); + expect(result.instructions).toBe(docs); + expect(result.dynamicNotice).toBeNull(); + }); + + test("docs plus a real trailing footer keep the docs and move only the latest footer", () => { + const docs = "Describe 0 in the protocol guide."; + const latest = footer(8000); + const result = stabilizeClaudeInstructionsForPromptCache( + [docs, footer(1), latest].join("\n\n"), + ); + expect(result.instructions).toBe(docs); + expect(result.dynamicNotice).toBe(latest); + }); + + test("fenced example plus a trailing harness footer moves only the footer", () => { + const docs = ["Docs:", "```", footer(123), "```"].join("\n"); + const latest = footer(8000); + const result = stabilizeClaudeInstructionsForPromptCache(`${docs}\n\n${latest}`); + expect(result.instructions).toBe(docs); + expect(result.dynamicNotice).toBe(latest); + }); + + test("unclosed fence through EOF is not a harness suffix", () => { + const docs = ["You are a docs bot.", "```", footer(123)].join("\n"); + const result = stabilizeClaudeInstructionsForPromptCache(docs); + expect(result.instructions).toBe(docs); + expect(result.dynamicNotice).toBeNull(); + }); + + test("a fence line with an info string does not close an open fence", () => { + const docs = ["```", footer(123), "```xml"].join("\n"); + const result = stabilizeClaudeInstructionsForPromptCache(docs); + expect(result.instructions).toBe(docs); + expect(result.dynamicNotice).toBeNull(); + }); +}); + +describe("anthropicToResponsesTranslation cache-stabilize wire-in", () => { + test("ordinary caller with the exact unfenced suffix keeps instructions and input unchanged", () => { + const latest = footer(15_000_000); + const system = ["You are Claude Code.", latest].join("\n\n"); + const { body } = translate(system); + expect(body.instructions).toBe(system); + expect(userTurns(body)).toEqual([ + { type: "message", role: "user", content: [{ type: "input_text", text: "hi" }] }, + ]); + }); + + test("ordinary caller with the exact TaskCreate paragraph keeps instructions and input unchanged", () => { + const system = ["You are Claude Code.", TASKCREATE_NUDGE].join("\n\n"); + const { body } = translate(system); + expect(body.instructions).toBe(system); + expect(userTurns(body)).toEqual([ + { type: "message", role: "user", content: [{ type: "input_text", text: "hi" }] }, + ]); + }); + + test("opted-in harness relocates the latest total_tokens footer onto a trailing input user message", () => { + const first = footer(1000); + const latest = footer(8000); + const { body } = translateHarness(["You are Claude Code.", first, latest].join("\n\n")); + expect(body.instructions).toBe("You are Claude Code."); + expect(String(body.instructions)).not.toContain(""); + const input = userTurns(body); + const last = input[input.length - 1]!; + expect(last).toEqual({ + type: "message", + role: "user", + content: [{ type: "input_text", text: latest }], + }); + expect(input.some(item => item.role === "user" && item !== last)).toBe(true); + }); + + test("opted-in peel does not require metadata.user_id", () => { + const latest = footer(14_980_071); + const { body } = translateHarness(["You are Claude Code.", latest].join("\n\n")); + expect(body.instructions).toBe("You are Claude Code."); + const input = userTurns(body); + expect(input[input.length - 1]).toEqual({ + type: "message", + role: "user", + content: [{ type: "input_text", text: latest }], + }); + }); + + test("fenced standalone total_tokens example is a translator no-op when opted in", () => { + const system = ["You are a docs bot.", "```", footer(123), "```", ""].join("\n"); + const { body } = translateHarness(system); + expect(body.instructions).toBe(system); + expect(userTurns(body)).toEqual([ + { type: "message", role: "user", content: [{ type: "input_text", text: "hi" }] }, + ]); + }); + + test("open fence to EOF with a trailing total_tokens tag is not relocated", () => { + const system = ["You are a docs bot.", "```", footer(123)].join("\n"); + const { body } = translateHarness(system); + expect(body.instructions).toBe(system); + expect(userTurns(body)).toEqual([ + { type: "message", role: "user", content: [{ type: "input_text", text: "hi" }] }, + ]); + }); + + test("real footer after a closed fence still relocates when opted in", () => { + const docs = ["Docs:", "```", footer(123), "```"].join("\n"); + const latest = footer(8000); + const { body } = translateHarness(`${docs}\n\n${latest}`); + expect(body.instructions).toBe(docs); + const input = userTurns(body); + expect(input[input.length - 1]).toEqual({ + type: "message", + role: "user", + content: [{ type: "input_text", text: latest }], + }); + }); + + test("whitespace-only system without a footer is preserved byte-for-byte", () => { + const system = " \n\n "; + const { body } = translate(system); + expect(body.instructions).toBe(system); + expect(userTurns(body)).toEqual([ + { type: "message", role: "user", content: [{ type: "input_text", text: "hi" }] }, + ]); + }); + + test("Claude Code session prompt_cache_key is unchanged across trailing footers", () => { + const stable = "You are Claude Code."; + const keyOf = (system: string) => + translateHarness(system, { user_id: "user-abc" }).body.prompt_cache_key as string; + const stableKey = keyOf(stable); + expect(stableKey).toMatch(/^[0-9a-f]{32}$/); + expect(keyOf([stable, footer(1000), footer(8000)].join("\n\n"))).toBe(stableKey); + expect(keyOf([stable, footer(99999)].join("\n\n"))).toBe(stableKey); + }); + + test("outside opt-in, Desktop prompt_cache_key hashes raw systemParts including footers", () => { + const stable = "You are Claude Code."; + const keyOf = (system: string) => translate(system).body.prompt_cache_key as string; + const stableKey = keyOf(stable); + expect(stableKey).toMatch(/^[0-9a-f]{32}$/); + expect(keyOf(stable)).toBe(stableKey); + expect(keyOf([stable, footer(8000)].join("\n\n"))).not.toBe(stableKey); + }); + + test("opted-in Desktop prompt_cache_key hashes stabilized instructions, not total_tokens footers", () => { + const stable = "You are Claude Code."; + const keyOf = (system: string) => translateHarness(system).body.prompt_cache_key as string; + const stableKey = keyOf(stable); + expect(stableKey).toMatch(/^[0-9a-f]{32}$/); + expect(keyOf([stable, footer(1000), footer(8000)].join("\n\n"))).toBe(stableKey); + expect(keyOf([stable, footer(99999)].join("\n\n"))).toBe(stableKey); + }); + + test("outside opt-in, a no-match Desktop key differs from the opted-in instructions-string key", () => { + const system = "You are Claude Code.\n\nPrefer terse answers."; + const rawKey = translate(system).body.prompt_cache_key as string; + const optedInKey = translateHarness(system).body.prompt_cache_key as string; + expect(rawKey).toMatch(/^[0-9a-f]{32}$/); + expect(optedInKey).toMatch(/^[0-9a-f]{32}$/); + expect(rawKey).not.toBe(optedInKey); + }); + + test("Claude Code /v1/messages inbound opts into prompt-cache stabilize", () => { + const source = readFileSync(repoPath("src/server/claude-messages.ts"), "utf8"); + expect(source).toContain("stabilizePromptCache: true"); + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 98907bd26a..a213efebf0 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -139,6 +139,7 @@ "claude-desktop-remote-hub.test.ts": "claude-integration", "claude-dotenv-provenance-transport.test.ts": "claude-integration", "claude-gateway-cache.test.ts": "claude-integration", + "claude-inbound-cache-stabilize.test.ts": "claude-integration", "claude-inbound-debug.test.ts": "claude-integration", "claude-inbound.test.ts": "claude-integration", "claude-management-api.test.ts": "claude-integration",