Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 104 additions & 3 deletions src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
shouldInjectClaudeTools,
stripAssistantPrefillForClaude,
stripSystemCacheControl,
stripMessageCacheControlPreservingTail,
} from "./index.js";
import { createSseProcessor, parseSseEvent, buildSseEvent } from "./stream.js";

Expand Down Expand Up @@ -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);
});
});

Expand All @@ -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<Record<string, unknown>>;
// 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({
Expand Down
75 changes: 69 additions & 6 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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);
Expand Down
Loading