diff --git a/src/index.test.ts b/src/index.test.ts index 4922d07..bcf149b 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -29,6 +29,7 @@ import { shouldInjectClaudeTools, stripAssistantPrefillForClaude, stripSystemCacheControl, + stripMessageCacheControlPreservingTail, } from "./index.js"; import { createSseProcessor, parseSseEvent, buildSseEvent } from "./stream.js"; @@ -171,12 +172,59 @@ describe("Claude assistant prefill stripping", () => { ]); }); - it("keeps non-synthetic assistant messages intact", () => { - const input = [ + it("appends a user turn after a real assistant tail so the request ends on a user message", () => { + const out = stripAssistantPrefillForClaude([ + { role: "user", content: [{ type: "text", text: "hello" }] }, + { role: "assistant", content: "Real assistant content" }, + ]); + assert.deepEqual(out, [ + { role: "user", content: [{ type: "text", text: "hello" }] }, + { role: "assistant", content: "Real assistant content" }, + { role: "user", content: "Continue." }, + ]); + }); + + it("drops the nudge then appends a user turn when it trails a real assistant turn", () => { + const out = stripAssistantPrefillForClaude([ { role: "user", content: [{ type: "text", text: "hello" }] }, { role: "assistant", content: "Real assistant content" }, + { role: "assistant", content: "Continue with your tasks." }, + ]); + assert.deepEqual(out, [ + { role: "user", content: [{ type: "text", text: "hello" }] }, + { role: "assistant", content: "Real assistant content" }, + { role: "user", content: "Continue." }, + ]); + }); + + it("ends on a user turn after opencode's max-steps assistant prefill", () => { + const maxSteps = "CRITICAL - MAXIMUM STEPS REACHED\n\nTools are disabled until next user input. Respond with text only."; + for (const content of [maxSteps, [{ type: "text", text: maxSteps }]]) { + const out = stripAssistantPrefillForClaude([ + { role: "user", content: [{ type: "text", text: "hello" }] }, + { role: "assistant", content }, + ]); + assert.deepEqual(out, [ + { role: "user", content: [{ type: "text", text: "hello" }] }, + { role: "assistant", content }, + { role: "user", content: "Continue." }, + ]); + } + }); + + it("leaves a user-tailed conversation unchanged", () => { + const input = [ + { role: "assistant", content: "earlier answer" }, + { role: "user", content: [{ type: "text", text: "hello" }] }, ]; - assert.deepEqual(stripAssistantPrefillForClaude(input), input); + const out = stripAssistantPrefillForClaude(input); + // Assert against a fresh literal (not the same reference) so the test would + // fail if the function mutated the input or appended a synthetic turn. + assert.deepEqual(out, [ + { role: "assistant", content: "earlier answer" }, + { role: "user", content: [{ type: "text", text: "hello" }] }, + ]); + assert.equal(out.length, 2); }); }); @@ -192,6 +240,59 @@ describe("system cache control", () => { }); }); +describe("message cache control (tail breakpoint)", () => { + it("preserves the tail message's text cache_control but strips earlier ones", () => { + const messages = [ + { + role: "user", + content: [{ type: "text", text: "old", cache_control: { type: "ephemeral" } }], + }, + { + role: "assistant", + content: [{ type: "text", text: "mid", cache_control: { type: "ephemeral" } }], + }, + { + role: "user", + content: [{ type: "text", text: "tail", cache_control: { type: "ephemeral" } }], + }, + ]; + stripMessageCacheControlPreservingTail(messages); + // Earlier messages: cache_control stripped. + assert.equal("cache_control" in messages[0].content[0], false); + assert.equal("cache_control" in messages[1].content[0], false); + // Tail message: cache_control preserved (the surviving breakpoint). + assert.deepEqual(messages[2].content[0].cache_control, { type: "ephemeral" }); + }); + + it("preserves a tail breakpoint even when the tail block is text (text-tailed request)", () => { + const messages = [ + { role: "user", content: [{ type: "text", text: "q", cache_control: { type: "ephemeral" } }] }, + ]; + stripMessageCacheControlPreservingTail(messages); + assert.deepEqual(messages[0].content[0].cache_control, { type: "ephemeral" }); + }); + + it("leaves non-text blocks and messages without content untouched", () => { + const messages: Array<{ role: string; content?: unknown }> = [ + { + role: "user", + content: [ + { type: "tool_result", tool_use_id: "t1", content: "r", cache_control: { type: "ephemeral" } }, + { type: "text", text: "x", cache_control: { type: "ephemeral" } }, + ], + }, + { role: "assistant", content: undefined }, + { role: "user", content: [{ type: "text", text: "tail" }] }, + ]; + stripMessageCacheControlPreservingTail(messages); + const first = messages[0].content as Array>; + // Non-text block: never stripped. + assert.deepEqual(first[0].cache_control, { type: "ephemeral" }); + // Earlier text block: stripped. + assert.equal("cache_control" in first[1], false); + }); +}); + describe("tool schema selection", () => { it("uses Claude schemas for native Anthropic requests", () => { assert.equal(shouldUseClaudeToolSchemas({ diff --git a/src/index.ts b/src/index.ts index d9fcaac..1d4ad15 100644 --- a/src/index.ts +++ b/src/index.ts @@ -226,6 +226,37 @@ export function stripSystemCacheControl( }); } +/** + * Strip OpenCode's cache_control from message TEXT blocks, but PRESERVE the + * breakpoint on the tail (last) message. + * + * OpenCode's automatic cache policy marks the tail message's final content part + * with cache_control (the prompt-prefix cache breakpoint). We must strip the + * client-set cache_control on the Claude Code subscription endpoint — EXCEPT the + * single tail breakpoint. Without it, a text-tailed request (e.g. a plain user + * turn, or a compaction/summary request whose last message is text) has no + * surviving breakpoint and Anthropic never reads the warm prefix (cache_read 0); + * only tool-block-tailed requests cached, because non-text blocks are never + * stripped here. Keeping the one tail breakpoint restores caching for every + * request shape without ADDING breakpoints (stays within Anthropic's 4-breakpoint + * cap — OpenCode already placed and capped it). Mutates in place. + */ +export function stripMessageCacheControlPreservingTail( + messages: Array<{ content?: unknown }>, +): void { + const tailIndex = messages.length - 1; + for (let index = 0; index < messages.length; index++) { + if (index === tailIndex) continue; // preserve the tail message's breakpoint + const content = messages[index]?.content; + if (!Array.isArray(content)) continue; + for (const block of content) { + if (block && block.type === "text" && "cache_control" in block) { + delete block.cache_control; + } + } + } +} + export function shouldInjectClaudeTools(input: { model?: string; requestUrl?: string; @@ -487,15 +518,44 @@ function maybeUnquoteText(text: string): string { return text; } +/** opencode's synthetic continuation nudge — a content-free assistant turn, safe to drop. */ +const OPENCODE_CONTINUATION_NUDGE = "Continue with your tasks."; +/** + * Appended after any remaining assistant tail so the request ends on a user turn. + * The Claude subscription endpoint rejects assistant-message prefill + * ("does not support assistant message prefill. The conversation must end with a user message"). + */ +const ASSISTANT_TAIL_USER_CONTINUATION = "Continue."; + +// The Claude subscription endpoint (unlike the raw Anthropic API) does NOT support +// assistant-message prefill: the messages array must end with a user turn. Drop +// opencode's content-free continuation nudge, then guarantee a user tail for ANY +// other assistant-tailed request (e.g. a resumed session whose last saved turn was +// the assistant's) by appending a minimal user turn — preserving the assistant +// content instead of discarding it. Previously only the exact nudge string was +// handled, so every other assistant tail hit a 400. export function stripAssistantPrefillForClaude( messages: Array<{ role?: string; content?: unknown }>, ): Array<{ role?: string; content?: unknown }> { if (messages.length === 0) return messages; - const last = messages[messages.length - 1]; - if (last?.role !== "assistant") return messages; - if (typeof last.content !== "string") return messages; - if (last.content.trim() !== "Continue with your tasks.") return messages; - return messages.slice(0, -1); + let result = messages; + const last = result[result.length - 1]; + if ( + last?.role === "assistant" && + typeof last.content === "string" && + last.content.trim() === OPENCODE_CONTINUATION_NUDGE + ) { + result = result.slice(0, -1); + } + const tail = result[result.length - 1]; + if (tail?.role === "assistant") { + // Assumes the assistant tail is a final/text turn. A dangling tool_use tail + // (a turn interrupted between the tool call and its result) would make this + // appended user turn violate Anthropic's tool_use/tool_result pairing — but + // that corrupted-resume shape already fails the endpoint's prefill rule too. + result = [...result, { role: "user", content: ASSISTANT_TAIL_USER_CONTINUATION }]; + } + return result; } function normalizeSystemBlocks( @@ -849,12 +909,15 @@ const OpenCodeClaudeBridge = async ({ client }: { client: PluginClient }) => { // - Translate tool_use input arguments (camelCase → snake_case) // - Clean up text blocks if (parsed.messages && Array.isArray(parsed.messages)) { + // Preserve the tail cache_control breakpoint so text-tailed + // requests still cache on the subscription route (earlier text + // blocks are still stripped). See the helper for the rationale. + stripMessageCacheControlPreservingTail(parsed.messages); for (const msg of parsed.messages) { if (!Array.isArray(msg.content)) continue; for (const block of msg.content) { if (block.type === "text" && typeof block.text === "string") { block.text = maybeUnquoteText(block.text); - delete block.cache_control; } if (block.type === "tool_use" && block.name) { block.name = mapOutboundToolName(block.name);