diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 2fa7649c75f9..bc49d6dc594d 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -51,40 +51,46 @@ function normalizeMessages( _options: Record, ): ModelMessage[] { // Anthropic rejects messages with empty content - filter out empty string messages - // and remove empty text/reasoning parts from array content - if (model.api.npm === "@ai-sdk/anthropic") { + // and remove empty text/reasoning parts from array content. + // Assistant messages with reasoning blocks are excluded: their content must be + // replayed verbatim because thinking block signatures encode positional context. + // Removing an empty text part between two reasoning blocks changes the block + // arrangement and invalidates the cryptographic signatures, causing the API to + // reject the request. Assistant messages without reasoning blocks are filtered + // normally since there are no signatures to preserve. + if (model.api.npm === "@ai-sdk/anthropic" || model.api.npm === "@ai-sdk/amazon-bedrock") { msgs = msgs .map((msg) => { - if (typeof msg.content === "string") { - if (msg.content === "") return undefined - return msg - } - if (!Array.isArray(msg.content)) return msg - const filtered = msg.content.filter((part) => { - if (part.type === "text" || part.type === "reasoning") { - return part.text !== "" + if (msg.role === "assistant") { + if (!Array.isArray(msg.content)) return msg + if (msg.content.some((part) => part.type === "reasoning")) { + // Strip trailing empty text parts — only interstitial ones + // (between reasoning blocks) affect thinking-block signature + // positions. A trailing empty text can receive cache_control + // from applyCaching, which Anthropic rejects with: + // "cache_control cannot be set for empty text blocks" + let end = msg.content.length + while (end > 0) { + const part = msg.content[end - 1] + if (part.type === "text" && part.text === "") { + end-- + continue + } + break + } + if (end === 0) return undefined + if (end < msg.content.length) return { ...msg, content: msg.content.slice(0, end) } + return msg } - return true - }) - if (filtered.length === 0) return undefined - return { ...msg, content: filtered } - }) - .filter((msg): msg is ModelMessage => msg !== undefined && msg.content !== "") - } - - // Bedrock specific transforms - if (model.api.npm === "@ai-sdk/amazon-bedrock") { - msgs = msgs - .map((msg) => { + // No reasoning blocks — safe to filter empty text + } if (typeof msg.content === "string") { if (msg.content === "") return undefined return msg } if (!Array.isArray(msg.content)) return msg const filtered = msg.content.filter((part) => { - if (part.type === "text" || part.type === "reasoning") { - return part.text !== "" - } + if (part.type === "text") return part.text !== "" return true }) if (filtered.length === 0) return undefined @@ -283,15 +289,27 @@ function applyCaching(msgs: ModelMessage[], model: Provider.Model): ModelMessage model.api.npm === "@ai-sdk/amazon-bedrock" const shouldUseContentOptions = !useMessageLevelOptions && Array.isArray(msg.content) && msg.content.length > 0 - if (shouldUseContentOptions) { - const lastContent = msg.content[msg.content.length - 1] + if (shouldUseContentOptions && Array.isArray(msg.content)) { + // Walk backwards to skip empty text parts — Anthropic rejects + // cache_control on empty text blocks. + const target = msg.content.findLast( + (part: unknown) => + !( + typeof part === "object" && + part && + "type" in part && + part.type === "text" && + "text" in part && + part.text === "" + ), + ) if ( - lastContent && - typeof lastContent === "object" && - lastContent.type !== "tool-approval-request" && - lastContent.type !== "tool-approval-response" + target && + typeof target === "object" && + target.type !== "tool-approval-request" && + target.type !== "tool-approval-response" ) { - lastContent.providerOptions = mergeDeep(lastContent.providerOptions ?? {}, providerOptions) + target.providerOptions = mergeDeep(target.providerOptions ?? {}, providerOptions) continue } } diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 5f97074b20c0..ac8d9d366052 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -844,7 +844,10 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* ( msg.info.error && !( AbortedError.isInstance(msg.info.error) && - msg.parts.some((part) => part.type !== "step-start" && part.type !== "reasoning") + msg.parts.some( + (part) => + part.type !== "step-start" && part.type !== "reasoning" && !(part.type === "text" && part.text === ""), + ) ) ) { continue @@ -859,7 +862,13 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* ( assistantMessage.parts.push({ type: "text", text: part.text, - ...(differentModel ? {} : { providerMetadata: part.metadata }), + // Empty text parts between reasoning blocks must survive the AI SDK's + // internal convertToLanguageModelPrompt filter, which strips text parts + // where text==="" and providerOptions==null. Fall back to {} so the + // downstream filter sees a non-null value and preserves the part. + // Removing an empty text part shifts thinking block positions and + // invalidates cryptographic signatures. + ...(differentModel ? {} : { providerMetadata: part.text === "" && !part.metadata ? {} : part.metadata }), }) if (part.type === "step-start") assistantMessage.parts.push({ diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index 9b66eaa77c5d..a74639d25b77 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -1273,10 +1273,10 @@ describe("ProviderTransform.message - anthropic empty content filtering", () => expect(result[1].content).toBe("World") }) - test("filters out empty text parts from array content", () => { + test("filters out empty text parts from array content in non-assistant messages", () => { const msgs = [ { - role: "assistant", + role: "user", content: [ { type: "text", text: "" }, { type: "text", text: "Hello" }, @@ -1292,7 +1292,7 @@ describe("ProviderTransform.message - anthropic empty content filtering", () => expect(result[0].content[0]).toEqual({ type: "text", text: "Hello" }) }) - test("filters out empty reasoning parts from array content", () => { + test("preserves all content in assistant messages including empty reasoning parts", () => { const msgs = [ { role: "assistant", @@ -1306,20 +1306,20 @@ describe("ProviderTransform.message - anthropic empty content filtering", () => const result = ProviderTransform.message(msgs, anthropicModel, {}) + // Assistant messages must be replayed verbatim — no filtering expect(result).toHaveLength(1) - expect(result[0].content).toHaveLength(1) - expect(result[0].content[0]).toEqual({ type: "text", text: "Answer" }) + expect(result[0].content).toHaveLength(3) + expect(result[0].content[0]).toEqual({ type: "reasoning", text: "" }) + expect(result[0].content[1]).toEqual({ type: "text", text: "Answer" }) + expect(result[0].content[2]).toEqual({ type: "reasoning", text: "" }) }) - test("removes entire message when all parts are empty", () => { + test("removes entire non-assistant message when all parts are empty", () => { const msgs = [ { role: "user", content: "Hello" }, { - role: "assistant", - content: [ - { type: "text", text: "" }, - { type: "reasoning", text: "" }, - ], + role: "user", + content: [{ type: "text", text: "" }], }, { role: "user", content: "World" }, ] as any[] @@ -1331,7 +1331,7 @@ describe("ProviderTransform.message - anthropic empty content filtering", () => expect(result[1].content).toBe("World") }) - test("keeps non-text/reasoning parts even if text parts are empty", () => { + test("filters empty text from assistant messages without reasoning blocks", () => { const msgs = [ { role: "assistant", @@ -1344,6 +1344,7 @@ describe("ProviderTransform.message - anthropic empty content filtering", () => const result = ProviderTransform.message(msgs, anthropicModel, {}) + // No reasoning blocks — empty text is filtered expect(result).toHaveLength(1) expect(result[0].content).toHaveLength(1) expect(result[0].content[0]).toEqual({ @@ -1354,7 +1355,26 @@ describe("ProviderTransform.message - anthropic empty content filtering", () => }) }) - test("keeps messages with valid text alongside empty parts", () => { + test("removes assistant message entirely when all parts are empty and no reasoning", () => { + // Reproduces the compaction summary bug: assistant message with only + // empty text parts and no reasoning blocks should be removed entirely. + const msgs = [ + { role: "user", content: "Hello" }, + { + role: "assistant", + content: [{ type: "text", text: "" }], + }, + { role: "user", content: "World" }, + ] as any[] + + const result = ProviderTransform.message(msgs, anthropicModel, {}) + + expect(result).toHaveLength(2) + expect(result[0].content).toBe("Hello") + expect(result[1].content).toBe("World") + }) + + test("preserves assistant messages with valid text alongside empty parts", () => { const msgs = [ { role: "assistant", @@ -1368,10 +1388,150 @@ describe("ProviderTransform.message - anthropic empty content filtering", () => const result = ProviderTransform.message(msgs, anthropicModel, {}) + // Assistant messages must be replayed verbatim — no filtering expect(result).toHaveLength(1) - expect(result[0].content).toHaveLength(2) + expect(result[0].content).toHaveLength(3) expect(result[0].content[0]).toEqual({ type: "reasoning", text: "Thinking..." }) - expect(result[0].content[1]).toEqual({ type: "text", text: "Result" }) + expect(result[0].content[1]).toEqual({ type: "text", text: "" }) + expect(result[0].content[2]).toEqual({ type: "text", text: "Result" }) + }) + + test("preserves empty text between reasoning blocks in assistant messages (thinking block signatures are positionally sensitive)", () => { + // When Anthropic returns adaptive thinking, it commonly produces: + // reasoning(sig1) → text("") → reasoning(sig2) → text("...") → tool_use + // The empty text between reasoning blocks encodes positional context in the + // thinking block signatures. Removing it changes the block arrangement, + // causing the API to reject the replayed message with: + // "thinking blocks in the latest assistant message cannot be modified" + // + // Real-world DB evidence from session ses_330fc3f4dffe0Yjzl5J0topEED: + // reasoning (767 chars, has signature) → text (0 chars) → reasoning (804 chars, has signature) + const msgs = [ + { + role: "assistant", + content: [ + { + type: "reasoning", + text: "Let me analyze the API response structure...", + providerOptions: { anthropic: { signature: "EqoBCkgIARgCIkAa0MK2" } }, + }, + { type: "text", text: "" }, + { + type: "reasoning", + text: "Now I need to check the authentication flow...", + providerOptions: { anthropic: { signature: "FrsBCkgIARgCIkDpN7Wx" } }, + }, + { type: "text", text: "Let me check the API endpoint." }, + { + type: "tool-call", + toolCallId: "toolu_01ABC", + toolName: "grep", + input: { pattern: "endpoint" }, + }, + ], + }, + ] as any[] + + const result = ProviderTransform.message(msgs, anthropicModel, {}) + + expect(result).toHaveLength(1) + // All 5 parts must be preserved — the empty text between reasoning blocks + // is structurally significant for signature validation + const parts = result[0].content as any[] + expect(parts).toHaveLength(5) + expect(parts[0].type).toBe("reasoning") + expect(parts[1]).toEqual({ type: "text", text: "" }) + expect(parts[2].type).toBe("reasoning") + expect(parts[3]).toEqual({ type: "text", text: "Let me check the API endpoint." }) + expect(parts[4].type).toBe("tool-call") + }) + + test("strips trailing empty text from assistant messages with reasoning blocks to prevent cache_control on empty text", () => { + // Reproduces the defensive fix for ses_3272b8b1dffe8ACUMp7xjxhFEw: + // When applyCaching sets message-level cache_control, the @ai-sdk/anthropic + // SDK propagates it to content blocks. If the last content block is an empty + // text, Anthropic rejects with: + // "cache_control cannot be set for empty text blocks" + // Trailing empty text after the last reasoning block does not affect + // thinking-block signature positions, so it's safe to remove. + const msgs = [ + { + role: "assistant", + content: [ + { + type: "reasoning", + text: "The user wants to discuss...", + providerOptions: { anthropic: { signature: "EoEXCkYICxgC..." } }, + }, + { type: "text", text: "" }, + ], + }, + ] as any[] + + const result = ProviderTransform.message(msgs, anthropicModel, {}) + + expect(result).toHaveLength(1) + // The trailing empty text should be stripped + const parts = result[0].content as any[] + expect(parts).toHaveLength(1) + expect(parts[0].type).toBe("reasoning") + }) + + test("preserves interstitial empty text but strips trailing empty text in the same message", () => { + const msgs = [ + { + role: "assistant", + content: [ + { + type: "reasoning", + text: "Step 1...", + providerOptions: { anthropic: { signature: "sig1" } }, + }, + { type: "text", text: "" }, + { + type: "reasoning", + text: "Step 2...", + providerOptions: { anthropic: { signature: "sig2" } }, + }, + { type: "text", text: "" }, + ], + }, + ] as any[] + + const result = ProviderTransform.message(msgs, anthropicModel, {}) + + expect(result).toHaveLength(1) + const parts = result[0].content as any[] + // Interstitial empty text (between reasoning blocks) is preserved, + // but trailing empty text is stripped + expect(parts).toHaveLength(3) + expect(parts[0].type).toBe("reasoning") + expect(parts[1]).toEqual({ type: "text", text: "" }) + expect(parts[2].type).toBe("reasoning") + }) + + test("strips trailing empty text but preserves empty reasoning in assistant messages", () => { + const msgs = [ + { role: "user", content: "Hello" }, + { + role: "assistant", + content: [ + { type: "reasoning", text: "" }, + { type: "text", text: "" }, + ], + }, + { role: "user", content: "World" }, + ] as any[] + + const result = ProviderTransform.message(msgs, anthropicModel, {}) + + // Trailing empty text is stripped, but empty reasoning is preserved + // (assistant messages with reasoning blocks are kept for signature integrity) + expect(result).toHaveLength(3) + expect(result[0].content).toBe("Hello") + expect(result[1].content).toHaveLength(1) + expect(result[1].content[0]).toEqual({ type: "reasoning", text: "" }) + expect(result[2].content).toBe("World") }) test("filters empty content for bedrock provider", () => { @@ -2172,6 +2332,88 @@ describe("ProviderTransform.message - cache control on gateway", () => { }) }) +describe("ProviderTransform.message - applyCaching skips empty text parts", () => { + // Uses openrouter + claude so applyCaching runs with content-level caching + // (providerID is not "anthropic"/"bedrock") and normalizeMessages does not + // filter (npm is not "@ai-sdk/anthropic"). + const model = { + id: "openrouter/anthropic/claude-sonnet-4", + providerID: "openrouter", + api: { + id: "anthropic/claude-sonnet-4", + url: "https://openrouter.ai/api/v1", + npm: "@openrouter/ai-sdk-provider", + }, + name: "Claude Sonnet 4 (OpenRouter)", + capabilities: { + temperature: true, + reasoning: true, + attachment: true, + toolcall: true, + input: { text: true, audio: false, image: true, video: false, pdf: true }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + interleaved: false, + }, + cost: { input: 0.003, output: 0.015, cache: { read: 0.0003, write: 0.00375 } }, + limit: { context: 200_000, output: 8192 }, + status: "active", + options: {}, + headers: {}, + } as any + + test("cache_control is set on reasoning part, not trailing empty text", () => { + // Reproduces the aborted-message scenario: the LLM started a text block + // but produced no content, leaving [reasoning, text("")] in the DB. + // applyCaching must not decorate the empty text with cache_control. + const msgs = [ + { role: "system", content: "You are a helpful assistant" }, + { + role: "assistant", + content: [ + { + type: "reasoning", + text: "Let me think about this...", + providerOptions: { anthropic: { signature: "EqoBCkgIARgC" } }, + }, + { type: "text", text: "" }, + ], + }, + ] as any[] + + const result = ProviderTransform.message(msgs, model, {}) + + const parts = result.find((m: any) => m.role === "assistant")!.content as any[] + // The reasoning part should have cache_control + expect(parts[0].providerOptions).toBeDefined() + expect(parts[0].providerOptions.openrouter).toEqual({ + cacheControl: { type: "ephemeral" }, + }) + // The empty text part must NOT have cache_control + expect(parts[1].providerOptions).toBeUndefined() + }) + + test("cache_control falls through to message-level when all content parts are empty text", () => { + const msgs = [ + { role: "system", content: "You are a helpful assistant" }, + { + role: "assistant", + content: [{ type: "text", text: "" }], + }, + ] as any[] + + const result = ProviderTransform.message(msgs, model, {}) + + const assistant = result.find((m: any) => m.role === "assistant")! + // No content part should have cache_control + expect((assistant.content as any[])[0].providerOptions).toBeUndefined() + // Falls through to message-level cache_control instead + expect(assistant.providerOptions).toBeDefined() + expect(assistant.providerOptions!.openrouter).toEqual({ + cacheControl: { type: "ephemeral" }, + }) + }) +}) + describe("ProviderTransform.variants", () => { const createMockModel = (overrides: Partial = {}): any => ({ id: "test/test-model", diff --git a/packages/opencode/test/session/message-v2.test.ts b/packages/opencode/test/session/message-v2.test.ts index a7853be0b8bf..a597aa0ba82b 100644 --- a/packages/opencode/test/session/message-v2.test.ts +++ b/packages/opencode/test/session/message-v2.test.ts @@ -881,6 +881,65 @@ describe("session.message-v2.toModelMessage", () => { ]) }) + test("excludes aborted assistant messages that only have reasoning and empty text", async () => { + // Reproduces the bug from session ses_3272b8b1dffe8ACUMp7xjxhFEw: + // An aborted message had [step-start, reasoning, text("")]. The empty text + // part passed the "has real content" check, causing the message to be + // included. After normalizeMessages preserved it (reasoning blocks present) + // and applyCaching set cache_control, the Anthropic API rejected with: + // "messages.5.content.1.text: cache_control cannot be set for empty text blocks" + const userID = "m-user" + const assistantID = "m-aborted" + const aborted = new MessageV2.AbortedError({ + message: "The operation was aborted.", + }).toObject() as MessageV2.Assistant["error"] + + const input: MessageV2.WithParts[] = [ + { + info: userInfo(userID), + parts: [ + { + ...basePart(userID, "u1"), + type: "text", + text: "hello", + }, + ] as MessageV2.Part[], + }, + { + info: assistantInfo(assistantID, "m-parent", aborted), + parts: [ + { + ...basePart(assistantID, "s1"), + type: "step-start", + }, + { + ...basePart(assistantID, "r1"), + type: "reasoning", + text: "The user wants to discuss the data source...", + time: { start: 0 }, + metadata: { anthropic: { signature: "EoEXCkYICxgC..." } }, + }, + { + ...basePart(assistantID, "t1"), + type: "text", + text: "", + time: { start: 1773165854386 }, + }, + ] as MessageV2.Part[], + }, + ] + + const result = await MessageV2.toModelMessages(input, model) + + // The aborted message should be excluded — empty text is not real content + expect(result).toStrictEqual([ + { + role: "user", + content: [{ type: "text", text: "hello" }], + }, + ]) + }) + test("preserves OpenRouter reasoning details through provider transform", async () => { const assistantID = "m-assistant" const openrouterModel: Provider.Model = { @@ -1009,6 +1068,81 @@ describe("session.message-v2.toModelMessage", () => { expect(await MessageV2.toModelMessages(input, model)).toStrictEqual([]) }) + test("preserves empty text between reasoning blocks in same-model assistant messages", async () => { + // When Anthropic returns adaptive thinking with interleaved steps, the DB stores: + // reasoning(signature) → text("") → reasoning(signature) → text("answer") + // The empty text between reasoning blocks encodes positional context in the + // thinking block signatures. The AI SDK's convertToModelMessages strips empty + // text parts that lack providerOptions, which shifts thinking block positions + // and invalidates signatures. toModelMessages must ensure the empty text + // survives by setting providerMetadata to a non-null value. + const userID = "m-user" + const assistantID = "m-assistant" + + const input: MessageV2.WithParts[] = [ + { + info: userInfo(userID), + parts: [ + { + ...basePart(userID, "u1"), + type: "text", + text: "hello", + }, + ] as MessageV2.Part[], + }, + { + info: assistantInfo(assistantID, userID), + parts: [ + { + ...basePart(assistantID, "a1"), + type: "reasoning", + text: "thinking step 1", + time: { start: 0, end: 1 }, + metadata: { anthropic: { signature: "sig1" } }, + }, + { + ...basePart(assistantID, "a2"), + type: "text", + text: "", + // no metadata — this is the common case from the DB + }, + { + ...basePart(assistantID, "a3"), + type: "reasoning", + text: "thinking step 2", + time: { start: 2, end: 3 }, + metadata: { anthropic: { signature: "sig2" } }, + }, + { + ...basePart(assistantID, "a4"), + type: "text", + text: "answer", + }, + ] as MessageV2.Part[], + }, + ] + + const result = await MessageV2.toModelMessages(input, model) + + expect(result).toHaveLength(2) + expect(result[1].role).toBe("assistant") + const content = (result[1] as any).content as any[] + // All 4 parts must be present — the empty text must not be stripped + expect(content).toHaveLength(4) + expect(content[0].type).toBe("reasoning") + expect(content[1].type).toBe("text") + expect(content[1].text).toBe("") + expect(content[2].type).toBe("reasoning") + expect(content[3].text).toBe("answer") + // The empty text part MUST have providerOptions set (even if empty object) + // so that the AI SDK's internal convertToLanguageModelPrompt does not strip + // it. That function filters: part.type !== "text" || part.text !== "" || part.providerOptions != null + // Without providerOptions, the empty text is removed, shifting thinking block + // positions and invalidating cryptographic signatures. + expect(content[1].providerOptions).toBeDefined() + expect(content[1].providerOptions).not.toBeNull() + }) + test("converts pending/running tool calls to error results to prevent dangling tool_use", async () => { const userID = "m-user" const assistantID = "m-assistant"