From 669508523ab1fa29f0d4be7b67c723a01182e047 Mon Sep 17 00:00:00 2001 From: Kyle Altendorf Date: Mon, 9 Mar 2026 08:59:39 -0400 Subject: [PATCH 01/17] test: reproduce empty text filtering breaking thinking block signatures (#16748) Add failing test demonstrating that normalizeMessages() removes empty text parts between reasoning blocks in assistant messages, invalidating Anthropic thinking block signatures. The test constructs [reasoning(sig1), text(''), reasoning(sig2), text('...'), tool-call] and asserts all 5 parts are preserved. Currently fails with Expected length: 5, Received length: 4. --- .../opencode/test/provider/transform.test.ts | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index 2329846351c4..4db984ac845f 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -1095,6 +1095,56 @@ describe("ProviderTransform.message - anthropic empty content filtering", () => expect(result[0].content[1]).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("does not filter for non-anthropic providers", () => { const openaiModel = { ...anthropicModel, From bae2f8467afe9e76512a324613bc8e207fc94acc Mon Sep 17 00:00:00 2001 From: Kyle Altendorf Date: Mon, 9 Mar 2026 09:10:33 -0400 Subject: [PATCH 02/17] fix(provider): skip empty-text filtering for assistant messages in normalizeMessages Assistant messages must be replayed verbatim because Anthropic 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 with 'thinking blocks cannot be modified'. The empty-text filter is still applied to user and tool messages where Anthropic rejects empty content. Update existing tests to reflect that assistant content is now preserved, and add tests for non-assistant filtering. --- packages/opencode/src/provider/transform.ts | 11 ++++-- .../opencode/test/provider/transform.test.ts | 38 ++++++++++--------- 2 files changed, 28 insertions(+), 21 deletions(-) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 6980be051888..049bfd7f0c22 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -50,19 +50,22 @@ export namespace ProviderTransform { options: Record, ): ModelMessage[] { // Anthropic rejects messages with empty content - filter out empty string messages - // and remove empty text/reasoning parts from array content + // and remove empty text/reasoning parts from array content. + // Assistant messages 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. if (model.api.npm === "@ai-sdk/anthropic") { msgs = msgs .map((msg) => { + if (msg.role === "assistant") return 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 (part.type === "text") return part.text !== "" return true }) if (filtered.length === 0) return undefined diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index 4db984ac845f..be0d847e4769 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -994,10 +994,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" }, @@ -1013,7 +1013,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", @@ -1027,20 +1027,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[] @@ -1052,7 +1052,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("preserves assistant messages with empty text alongside non-text parts", () => { const msgs = [ { role: "assistant", @@ -1065,9 +1065,11 @@ 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({ + expect(result[0].content).toHaveLength(2) + expect(result[0].content[0]).toEqual({ type: "text", text: "" }) + expect(result[0].content[1]).toEqual({ type: "tool-call", toolCallId: "123", toolName: "bash", @@ -1075,7 +1077,7 @@ describe("ProviderTransform.message - anthropic empty content filtering", () => }) }) - test("keeps messages with valid text alongside empty parts", () => { + test("preserves assistant messages with valid text alongside empty parts", () => { const msgs = [ { role: "assistant", @@ -1089,10 +1091,12 @@ 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)", () => { From 4b4f2ce12a38ad39de352806e1c290c415be65bf Mon Sep 17 00:00:00 2001 From: Kyle Altendorf Date: Mon, 9 Mar 2026 17:30:21 -0400 Subject: [PATCH 03/17] fix: filter empty text from assistant messages without reasoning blocks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous fix preserved all assistant messages verbatim in normalizeMessages to protect thinking block signatures. This was too broad — assistant messages without reasoning blocks (e.g. compaction summaries) also had empty text parts preserved, causing Anthropic to reject with 'text content blocks must be non-empty'. Now only assistant messages with reasoning blocks are preserved verbatim. Assistant messages without reasoning have empty text blocks filtered normally. Also clean up empty text parts at the source: in the processor text-end handler, remove parts that end up empty (no text, no metadata) instead of persisting them. Parts with metadata (thinking signatures) are still preserved. --- packages/opencode/src/provider/transform.ts | 16 ++-- packages/opencode/src/session/message-v2.ts | 8 +- packages/opencode/src/session/processor.ts | 14 +++- .../opencode/test/provider/transform.test.ts | 28 +++++-- .../opencode/test/session/message-v2.test.ts | 75 +++++++++++++++++++ 5 files changed, 129 insertions(+), 12 deletions(-) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 049bfd7f0c22..6b4628d17ef7 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -51,14 +51,20 @@ export namespace ProviderTransform { ): ModelMessage[] { // Anthropic rejects messages with empty content - filter out empty string messages // and remove empty text/reasoning parts from array content. - // Assistant messages 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 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") { msgs = msgs .map((msg) => { - if (msg.role === "assistant") return msg + if (msg.role === "assistant") { + if (!Array.isArray(msg.content)) return msg + if (msg.content.some((part) => part.type === "reasoning")) return msg + // No reasoning blocks — safe to filter empty text + } if (typeof msg.content === "string") { if (msg.content === "") return undefined return msg diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 5b4e7bdbc044..3a89a7a22562 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -625,7 +625,13 @@ export namespace MessageV2 { 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/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 67edc0ecfe35..fc0668b1e161 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -334,7 +334,19 @@ export namespace SessionProcessor { end: Date.now(), } if (value.providerMetadata) currentText.metadata = value.providerMetadata - await Session.updatePart(currentText) + // Remove empty text parts that have no metadata (no thinking + // signature significance). The part was already persisted at + // text-start; clean it up to avoid sending empty text blocks + // to the API on replay. + if (currentText.text === "" && !currentText.metadata) { + await Session.removePart({ + sessionID: currentText.sessionID, + messageID: currentText.messageID, + partID: currentText.id, + }) + } else { + await Session.updatePart(currentText) + } } currentText = undefined break diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index be0d847e4769..68992e6d2a35 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -1052,7 +1052,7 @@ describe("ProviderTransform.message - anthropic empty content filtering", () => expect(result[1].content).toBe("World") }) - test("preserves assistant messages with empty text alongside non-text parts", () => { + test("filters empty text from assistant messages without reasoning blocks", () => { const msgs = [ { role: "assistant", @@ -1065,11 +1065,10 @@ describe("ProviderTransform.message - anthropic empty content filtering", () => const result = ProviderTransform.message(msgs, anthropicModel, {}) - // Assistant messages must be replayed verbatim — no filtering + // No reasoning blocks — empty text is filtered expect(result).toHaveLength(1) - expect(result[0].content).toHaveLength(2) - expect(result[0].content[0]).toEqual({ type: "text", text: "" }) - expect(result[0].content[1]).toEqual({ + expect(result[0].content).toHaveLength(1) + expect(result[0].content[0]).toEqual({ type: "tool-call", toolCallId: "123", toolName: "bash", @@ -1077,6 +1076,25 @@ describe("ProviderTransform.message - anthropic empty content filtering", () => }) }) + 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 = [ { diff --git a/packages/opencode/test/session/message-v2.test.ts b/packages/opencode/test/session/message-v2.test.ts index c043754bdb4e..839cba0239ec 100644 --- a/packages/opencode/test/session/message-v2.test.ts +++ b/packages/opencode/test/session/message-v2.test.ts @@ -695,6 +695,81 @@ describe("session.message-v2.toModelMessage", () => { expect(MessageV2.toModelMessages(input, model)).toStrictEqual([]) }) + test("preserves empty text between reasoning blocks in same-model assistant messages", () => { + // 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 = 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", () => { const userID = "m-user" const assistantID = "m-assistant" From e750dad044112cfac174e6cf4cbec6d009dc8487 Mon Sep 17 00:00:00 2001 From: Kyle Altendorf Date: Mon, 9 Mar 2026 17:45:16 -0400 Subject: [PATCH 04/17] revert: drop removePart for empty text in processor to avoid create-delete race The text-start handler persists an empty part to the DB, then text-end would conditionally delete it. If the process crashes between the two, a dangling empty part remains. The transform.ts and message-v2.ts replay-time defenses already handle empty text parts correctly, making the processor-level cleanup redundant. --- packages/opencode/src/session/processor.ts | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index fc0668b1e161..67edc0ecfe35 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -334,19 +334,7 @@ export namespace SessionProcessor { end: Date.now(), } if (value.providerMetadata) currentText.metadata = value.providerMetadata - // Remove empty text parts that have no metadata (no thinking - // signature significance). The part was already persisted at - // text-start; clean it up to avoid sending empty text blocks - // to the API on replay. - if (currentText.text === "" && !currentText.metadata) { - await Session.removePart({ - sessionID: currentText.sessionID, - messageID: currentText.messageID, - partID: currentText.id, - }) - } else { - await Session.updatePart(currentText) - } + await Session.updatePart(currentText) } currentText = undefined break From 83362bdce81c5ecd6f27e96cd069846984e67470 Mon Sep 17 00:00:00 2001 From: Kyle Altendorf Date: Tue, 10 Mar 2026 18:51:30 -0400 Subject: [PATCH 05/17] fix: prevent cache_control on empty text blocks from aborted messages Aborted assistant messages with [step-start, reasoning, text('')] were included in the conversation replay because the empty text part passed the 'has real content' check. The empty text survived normalizeMessages (reasoning blocks present) and received cache_control via applyCaching, causing Anthropic to reject with 'cache_control cannot be set for empty text blocks'. Two fixes: - Exclude empty text from the aborted-message content check so messages with only reasoning + empty text are skipped entirely - Strip trailing empty text from assistant messages with reasoning in normalizeMessages (interstitial empty text between reasoning blocks is preserved for signature integrity) Reproduces: ses_3272b8b1dffe8ACUMp7xjxhFEw --- packages/opencode/src/provider/transform.ts | 20 ++++- packages/opencode/src/session/message-v2.ts | 5 +- .../opencode/test/provider/transform.test.ts | 88 +++++++++++++++++++ .../opencode/test/session/message-v2.test.ts | 59 +++++++++++++ 4 files changed, 170 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 6b4628d17ef7..d8138fbaf8fd 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -62,7 +62,25 @@ export namespace ProviderTransform { .map((msg) => { if (msg.role === "assistant") { if (!Array.isArray(msg.content)) return msg - if (msg.content.some((part) => part.type === "reasoning")) 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 + } // No reasoning blocks — safe to filter empty text } if (typeof msg.content === "string") { diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 3a89a7a22562..afd7a4078c7c 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -610,7 +610,10 @@ export namespace MessageV2 { msg.info.error && !( MessageV2.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 diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index 68992e6d2a35..3af2eb659305 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -1167,6 +1167,94 @@ describe("ProviderTransform.message - anthropic empty content filtering", () => 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("does not filter for non-anthropic providers", () => { const openaiModel = { ...anthropicModel, diff --git a/packages/opencode/test/session/message-v2.test.ts b/packages/opencode/test/session/message-v2.test.ts index 839cba0239ec..2e7d9f36ce0c 100644 --- a/packages/opencode/test/session/message-v2.test.ts +++ b/packages/opencode/test/session/message-v2.test.ts @@ -640,6 +640,65 @@ describe("session.message-v2.toModelMessage", () => { ]) }) + test("excludes aborted assistant messages that only have reasoning and empty text", () => { + // 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 = 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("splits assistant messages on step-start boundaries", () => { const assistantID = "m-assistant" From a95c4ea41b08a818d76122d31f3c117a686226e9 Mon Sep 17 00:00:00 2001 From: Kyle Altendorf Date: Wed, 11 Mar 2026 13:55:02 -0400 Subject: [PATCH 06/17] fix: skip empty text parts in applyCaching to prevent cache_control rejection Defense-in-depth: when applyCaching selects the last content part to decorate with cache_control, it now walks backwards past empty text parts (text === ""). This prevents Anthropic from rejecting requests with 'cache_control cannot be set for empty text blocks', regardless of how the empty text ended up in the message array. If every content part is empty text, cache_control falls through to message-level options instead. --- packages/opencode/src/provider/transform.ts | 14 +++- .../opencode/test/provider/transform.test.ts | 82 +++++++++++++++++++ 2 files changed, 93 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 89a4fd3023ab..32392c34fc7f 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -225,9 +225,17 @@ export namespace ProviderTransform { const shouldUseContentOptions = !useMessageLevelOptions && Array.isArray(msg.content) && msg.content.length > 0 if (shouldUseContentOptions) { - const lastContent = msg.content[msg.content.length - 1] - if (lastContent && typeof lastContent === "object") { - lastContent.providerOptions = mergeDeep(lastContent.providerOptions ?? {}, providerOptions) + // Walk backwards to skip empty text parts — Anthropic rejects + // cache_control on empty text blocks. + let target: (typeof msg.content)[number] | undefined + for (let i = msg.content.length - 1; i >= 0; i--) { + const part = msg.content[i] + if (typeof part === "object" && part.type === "text" && part.text === "") continue + target = part + break + } + if (target && typeof target === "object") { + target.providerOptions = mergeDeep(target.providerOptions ?? {}, providerOptions) continue } } diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index 041d1e5eef8c..86c307ec074e 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -1789,6 +1789,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", From 97f3c746f3faaa5111c56a1e04cd5dd02be4c2eb Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Wed, 22 Apr 2026 00:35:48 -0400 Subject: [PATCH 07/17] feat: update codex plugin to support 5.5 (#23789) --- packages/opencode/src/plugin/codex.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/opencode/src/plugin/codex.ts b/packages/opencode/src/plugin/codex.ts index c61cb7850900..84d314f476ff 100644 --- a/packages/opencode/src/plugin/codex.ts +++ b/packages/opencode/src/plugin/codex.ts @@ -374,6 +374,7 @@ export async function CodexAuthPlugin(input: PluginInput): Promise { "gpt-5.3-codex", "gpt-5.4", "gpt-5.4-mini", + "gpt-5.5", ]) for (const [modelId, model] of Object.entries(provider.models)) { if (modelId.includes("codex")) continue From 69e2f3b7ba12ce45ba2964ca3df2fe7c5a22a793 Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Wed, 22 Apr 2026 15:18:51 +1000 Subject: [PATCH 08/17] chore: bump Bun to 1.3.13 (#23791) --- bun.lock | 6 +++--- package.json | 4 ++-- packages/containers/bun-node/Dockerfile | 2 +- packages/ui/src/components/timeline-playground.stories.tsx | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/bun.lock b/bun.lock index 77ab24240bb9..64b32feac4eb 100644 --- a/bun.lock +++ b/bun.lock @@ -688,7 +688,7 @@ "@tailwindcss/vite": "4.1.11", "@tsconfig/bun": "1.0.9", "@tsconfig/node22": "22.0.2", - "@types/bun": "1.3.11", + "@types/bun": "1.3.12", "@types/cross-spawn": "6.0.6", "@types/luxon": "3.7.1", "@types/node": "22.13.9", @@ -2302,7 +2302,7 @@ "@types/braces": ["@types/braces@3.0.5", "", {}, "sha512-SQFof9H+LXeWNz8wDe7oN5zu7ket0qwMu5vZubW4GCJ8Kkeh6nBWUz87+KTz/G3Kqsrp0j/W253XJb3KMEeg3w=="], - "@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="], + "@types/bun": ["@types/bun@1.3.12", "", { "dependencies": { "bun-types": "1.3.12" } }, "sha512-DBv81elK+/VSwXHDlnH3Qduw+KxkTIWi7TXkAeh24zpi5l0B2kUg9Ga3tb4nJaPcOFswflgi/yAvMVBPrxMB+A=="], "@types/cacache": ["@types/cacache@20.0.1", "", { "dependencies": { "@types/node": "*", "minipass": "*" } }, "sha512-QlKW3AFoFr/hvPHwFHMIVUH/ZCYeetBNou3PCmxu5LaNDvrtBlPJtIA6uhmU9JRt9oxj7IYoqoLcpxtzpPiTcw=="], @@ -2720,7 +2720,7 @@ "bun-pty": ["bun-pty@0.4.8", "", {}, "sha512-rO70Mrbr13+jxHHHu2YBkk2pNqrJE5cJn29WE++PUr+GFA0hq/VgtQPZANJ8dJo6d7XImvBk37Innt8GM7O28w=="], - "bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="], + "bun-types": ["bun-types@1.3.12", "", { "dependencies": { "@types/node": "*" } }, "sha512-HqOLj5PoFajAQciOMRiIZGNoKxDJSr6qigAttOX40vJuSp6DN/CxWp9s3C1Xwm4oH7ybueITwiaOcWXoYVoRkA=="], "bun-webgpu": ["bun-webgpu@0.1.5", "", { "dependencies": { "@webgpu/types": "^0.1.60" }, "optionalDependencies": { "bun-webgpu-darwin-arm64": "^0.1.5", "bun-webgpu-darwin-x64": "^0.1.5", "bun-webgpu-linux-x64": "^0.1.5", "bun-webgpu-win32-x64": "^0.1.5" } }, "sha512-91/K6S5whZKX7CWAm9AylhyKrLGRz6BUiiPiM/kXadSnD4rffljCD/q9cNFftm5YXhx4MvLqw33yEilxogJvwA=="], diff --git a/package.json b/package.json index 06bf9c91aef0..f918bcd025f5 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "description": "AI-powered development tool", "private": true, "type": "module", - "packageManager": "bun@1.3.11", + "packageManager": "bun@1.3.13", "scripts": { "dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts", "dev:desktop": "bun --cwd packages/desktop-electron dev", @@ -30,7 +30,7 @@ "@effect/opentelemetry": "4.0.0-beta.48", "@effect/platform-node": "4.0.0-beta.48", "@npmcli/arborist": "9.4.0", - "@types/bun": "1.3.11", + "@types/bun": "1.3.12", "@types/cross-spawn": "6.0.6", "@octokit/rest": "22.0.0", "@hono/zod-validator": "0.4.2", diff --git a/packages/containers/bun-node/Dockerfile b/packages/containers/bun-node/Dockerfile index 485375dd9f61..d6f4729bf51e 100644 --- a/packages/containers/bun-node/Dockerfile +++ b/packages/containers/bun-node/Dockerfile @@ -4,7 +4,7 @@ FROM ${REGISTRY}/build/base:24.04 SHELL ["/bin/bash", "-lc"] ARG NODE_VERSION=24.4.0 -ARG BUN_VERSION=1.3.11 +ARG BUN_VERSION=1.3.13 ENV BUN_INSTALL=/opt/bun ENV PATH=/opt/bun/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin diff --git a/packages/ui/src/components/timeline-playground.stories.tsx b/packages/ui/src/components/timeline-playground.stories.tsx index c071db303b7a..72f5730612c5 100644 --- a/packages/ui/src/components/timeline-playground.stories.tsx +++ b/packages/ui/src/components/timeline-playground.stories.tsx @@ -318,7 +318,7 @@ const TOOL_SAMPLES = { tool: "bash", input: { command: "bun test --filter session", description: "Run session tests" }, output: - "bun test v1.3.11\n\n✓ session-turn.test.tsx (3 tests) 45ms\n✓ message-part.test.tsx (7 tests) 120ms\n\nTest Suites: 2 passed, 2 total\nTests: 10 passed, 10 total\nTime: 0.89s", + "bun test v1.3.13\n\n✓ session-turn.test.tsx (3 tests) 45ms\n✓ message-part.test.tsx (7 tests) 120ms\n\nTest Suites: 2 passed, 2 total\nTests: 10 passed, 10 total\nTime: 0.89s", title: "Run session tests", metadata: { command: "bun test --filter session" }, }, From a45d9a9b0aee7f2852bda832313ff7fed5063415 Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Wed, 22 Apr 2026 13:36:20 +0800 Subject: [PATCH 09/17] fix(app): improve icon override handling in project edit dialog (#23768) --- .../src/components/dialog-edit-project.tsx | 36 ++++++++++--------- packages/app/src/context/layout.tsx | 2 +- .../app/src/pages/layout/sidebar-items.tsx | 4 ++- 3 files changed, 23 insertions(+), 19 deletions(-) diff --git a/packages/app/src/components/dialog-edit-project.tsx b/packages/app/src/components/dialog-edit-project.tsx index ea5d70065adc..621d56646df1 100644 --- a/packages/app/src/components/dialog-edit-project.tsx +++ b/packages/app/src/components/dialog-edit-project.tsx @@ -26,8 +26,8 @@ export function DialogEditProject(props: { project: LocalProject }) { const [store, setStore] = createStore({ name: defaultName(), - color: props.project.icon?.color || "pink", - iconUrl: props.project.icon?.override || "", + color: props.project.icon?.color, + iconOverride: props.project.icon?.override, startup: props.project.commands?.start ?? "", dragOver: false, iconHover: false, @@ -39,7 +39,7 @@ export function DialogEditProject(props: { project: LocalProject }) { if (!file.type.startsWith("image/")) return const reader = new FileReader() reader.onload = (e) => { - setStore("iconUrl", e.target?.result as string) + setStore("iconOverride", e.target?.result as string) setStore("iconHover", false) } reader.readAsDataURL(file) @@ -68,7 +68,7 @@ export function DialogEditProject(props: { project: LocalProject }) { } function clearIcon() { - setStore("iconUrl", "") + setStore("iconOverride", "") } const saveMutation = useMutation(() => ({ @@ -81,17 +81,17 @@ export function DialogEditProject(props: { project: LocalProject }) { projectID: props.project.id, directory: props.project.worktree, name, - icon: { color: store.color, override: store.iconUrl }, + icon: { color: store.color || "", override: store.iconOverride || "" }, commands: { start }, }) - globalSync.project.icon(props.project.worktree, store.iconUrl || undefined) + globalSync.project.icon(props.project.worktree, store.iconOverride || undefined) dialog.close() return } globalSync.project.meta(props.project.worktree, { name, - icon: { color: store.color, override: store.iconUrl || undefined }, + icon: { color: store.color || undefined, override: store.iconOverride || undefined }, commands: { start: start || undefined }, }) dialog.close() @@ -130,13 +130,13 @@ export function DialogEditProject(props: { project: LocalProject }) { classList={{ "border-text-interactive-base bg-surface-info-base/20": store.dragOver, "border-border-base hover:border-border-strong": !store.dragOver, - "overflow-hidden": !!store.iconUrl, + "overflow-hidden": !!store.iconOverride, }} onDrop={handleDrop} onDragOver={handleDragOver} onDragLeave={handleDragLeave} onClick={() => { - if (store.iconUrl && store.iconHover) { + if (store.iconOverride && store.iconHover) { clearIcon() } else { iconInput?.click() @@ -144,7 +144,7 @@ export function DialogEditProject(props: { project: LocalProject }) { }} > {language.t("dialog.project.edit.icon.alt")} @@ -165,8 +165,8 @@ export function DialogEditProject(props: { project: LocalProject }) {
@@ -174,8 +174,8 @@ export function DialogEditProject(props: { project: LocalProject }) {
@@ -198,7 +198,7 @@ export function DialogEditProject(props: { project: LocalProject }) {
- +
@@ -215,7 +215,9 @@ export function DialogEditProject(props: { project: LocalProject }) { "bg-transparent border border-transparent hover:bg-surface-base-hover hover:border-border-weak-base": store.color !== color, }} - onClick={() => setStore("color", color)} + onClick={() => { + setStore("color", store.color === color ? undefined : color) + }} > Date: Wed, 22 Apr 2026 06:13:39 +0000 Subject: [PATCH 10/17] chore: update nix node_modules hashes --- nix/hashes.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/nix/hashes.json b/nix/hashes.json index 21279a327d0a..c09604610638 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-NczRp8MPppkqP8PQfWMUWJ/Wofvf2YVy5m4i22Pi3jg=", - "aarch64-linux": "sha256-QIxGOu8Fj+sWgc9hKvm1BLiIErxEtd17SPlwZGac9sQ=", - "aarch64-darwin": "sha256-Rb9qbMM+ARn0iBCaZurwcoUBCplbMXEZwrXVKextp3I=", - "x86_64-darwin": "sha256-KVxOKkaVV7W+K4reEk14MTLgmtoqwCYDqDNXNeS6ync=" + "x86_64-linux": "sha256-AgHhYsiygxbsBo3JN4HqHXKAwh8n1qeuSCe2qqxlxW4=", + "aarch64-linux": "sha256-h2lpWRQ5EDYnjpqZXtUAp1mxKLQxJ4m8MspgSY8Ev78=", + "aarch64-darwin": "sha256-xnd91+WyeAqn06run2ajsekxJvTMiLsnqNPe/rR8VTM=", + "x86_64-darwin": "sha256-rXpz45IOjGEk73xhP9VY86eOj2CZBg2l1vzwzTIOOOQ=" } } From bb696485b645fc323ef7deeefa25685bc14da856 Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Wed, 22 Apr 2026 18:03:34 +1000 Subject: [PATCH 11/17] fix: preserve BOM in text tool round-trips (#23797) --- packages/opencode/src/format/index.ts | 17 +++-- packages/opencode/src/patch/index.ts | 18 +++--- packages/opencode/src/tool/apply_patch.ts | 32 +++++++--- packages/opencode/src/tool/edit.ts | 29 ++++++--- packages/opencode/src/tool/write.ts | 15 +++-- packages/opencode/src/util/bom.ts | 31 +++++++++ packages/opencode/test/format/format.test.ts | 34 +++++++++- .../opencode/test/tool/apply_patch.test.ts | 28 +++++++++ packages/opencode/test/tool/edit.test.ts | 63 +++++++++++++++++++ packages/opencode/test/tool/write.test.ts | 48 ++++++++++++++ 10 files changed, 276 insertions(+), 39 deletions(-) create mode 100644 packages/opencode/src/util/bom.ts diff --git a/packages/opencode/src/format/index.ts b/packages/opencode/src/format/index.ts index 85934ce9c9a3..53a2c10119b1 100644 --- a/packages/opencode/src/format/index.ts +++ b/packages/opencode/src/format/index.ts @@ -25,7 +25,7 @@ export type Status = z.infer export interface Interface { readonly init: () => Effect.Effect readonly status: () => Effect.Effect - readonly file: (filepath: string) => Effect.Effect + readonly file: (filepath: string) => Effect.Effect } export class Service extends Context.Service()("@opencode/Format") {} @@ -70,16 +70,19 @@ export const layer = Layer.effect( } }), ) - return checks.filter((x) => x.cmd).map((x) => ({ item: x.item, cmd: x.cmd! })) + return checks + .filter((x): x is { item: Formatter.Info; cmd: string[] } => x.cmd !== false) + .map((x) => ({ item: x.item, cmd: x.cmd })) } function formatFile(filepath: string) { return Effect.gen(function* () { log.info("formatting", { file: filepath }) - const ext = path.extname(filepath) + const formatters = yield* Effect.promise(() => getFormatter(path.extname(filepath))) - for (const { item, cmd } of yield* Effect.promise(() => getFormatter(ext))) { - if (cmd === false) continue + if (!formatters.length) return false + + for (const { item, cmd } of formatters) { log.info("running", { command: cmd }) const replaced = cmd.map((x) => x.replace("$FILE", filepath)) const dir = yield* InstanceState.directory @@ -113,6 +116,8 @@ export const layer = Layer.effect( }) } } + + return true }) } @@ -188,7 +193,7 @@ export const layer = Layer.effect( const file = Effect.fn("Format.file")(function* (filepath: string) { const { formatFile } = yield* InstanceState.get(state) - yield* formatFile(filepath) + return yield* formatFile(filepath) }) return Service.of({ init, status, file }) diff --git a/packages/opencode/src/patch/index.ts b/packages/opencode/src/patch/index.ts index 19e1d7555bb0..3662f9e908ae 100644 --- a/packages/opencode/src/patch/index.ts +++ b/packages/opencode/src/patch/index.ts @@ -3,6 +3,7 @@ import * as path from "path" import * as fs from "fs/promises" import { readFileSync } from "fs" import { Log } from "../util" +import * as Bom from "../util/bom" const log = Log.create({ service: "patch" }) @@ -305,18 +306,19 @@ export function maybeParseApplyPatch( interface ApplyPatchFileUpdate { unified_diff: string content: string + bom: boolean } export function deriveNewContentsFromChunks(filePath: string, chunks: UpdateFileChunk[]): ApplyPatchFileUpdate { // Read original file content - let originalContent: string + let originalContent: ReturnType try { - originalContent = readFileSync(filePath, "utf-8") + originalContent = Bom.split(readFileSync(filePath, "utf-8")) } catch (error) { throw new Error(`Failed to read file ${filePath}: ${error}`, { cause: error }) } - let originalLines = originalContent.split("\n") + let originalLines = originalContent.text.split("\n") // Drop trailing empty element for consistent line counting if (originalLines.length > 0 && originalLines[originalLines.length - 1] === "") { @@ -331,14 +333,16 @@ export function deriveNewContentsFromChunks(filePath: string, chunks: UpdateFile newLines.push("") } - const newContent = newLines.join("\n") + const next = Bom.split(newLines.join("\n")) + const newContent = next.text // Generate unified diff - const unifiedDiff = generateUnifiedDiff(originalContent, newContent) + const unifiedDiff = generateUnifiedDiff(originalContent.text, newContent) return { unified_diff: unifiedDiff, content: newContent, + bom: originalContent.bom || next.bom, } } @@ -553,13 +557,13 @@ export async function applyHunksToFiles(hunks: Hunk[]): Promise { await fs.mkdir(moveDir, { recursive: true }) } - await fs.writeFile(hunk.move_path, fileUpdate.content, "utf-8") + await fs.writeFile(hunk.move_path, Bom.join(fileUpdate.content, fileUpdate.bom), "utf-8") await fs.unlink(hunk.path) modified.push(hunk.move_path) log.info(`Moved file: ${hunk.path} -> ${hunk.move_path}`) } else { // Regular update - await fs.writeFile(hunk.path, fileUpdate.content, "utf-8") + await fs.writeFile(hunk.path, Bom.join(fileUpdate.content, fileUpdate.bom), "utf-8") modified.push(hunk.path) log.info(`Updated file: ${hunk.path}`) } diff --git a/packages/opencode/src/tool/apply_patch.ts b/packages/opencode/src/tool/apply_patch.ts index 7da7dd255c52..e36d5a65d801 100644 --- a/packages/opencode/src/tool/apply_patch.ts +++ b/packages/opencode/src/tool/apply_patch.ts @@ -14,6 +14,7 @@ import { AppFileSystem } from "@opencode-ai/shared/filesystem" import DESCRIPTION from "./apply_patch.txt" import { File } from "../file" import { Format } from "../format" +import * as Bom from "@/util/bom" const PatchParams = z.object({ patchText: z.string().describe("The full patch text that describes all changes to be made"), @@ -59,6 +60,7 @@ export const ApplyPatchTool = Tool.define( diff: string additions: number deletions: number + bom: boolean }> = [] let totalDiff = "" @@ -72,11 +74,12 @@ export const ApplyPatchTool = Tool.define( const oldContent = "" const newContent = hunk.contents.length === 0 || hunk.contents.endsWith("\n") ? hunk.contents : `${hunk.contents}\n` - const diff = trimDiff(createTwoFilesPatch(filePath, filePath, oldContent, newContent)) + const next = Bom.split(newContent) + const diff = trimDiff(createTwoFilesPatch(filePath, filePath, oldContent, next.text)) let additions = 0 let deletions = 0 - for (const change of diffLines(oldContent, newContent)) { + for (const change of diffLines(oldContent, next.text)) { if (change.added) additions += change.count || 0 if (change.removed) deletions += change.count || 0 } @@ -84,11 +87,12 @@ export const ApplyPatchTool = Tool.define( fileChanges.push({ filePath, oldContent, - newContent, + newContent: next.text, type: "add", diff, additions, deletions, + bom: next.bom, }) totalDiff += diff + "\n" @@ -104,13 +108,16 @@ export const ApplyPatchTool = Tool.define( ) } - const oldContent = yield* afs.readFileString(filePath) + const source = yield* Bom.readFile(afs, filePath) + const oldContent = source.text let newContent = oldContent + let bom = source.bom // Apply the update chunks to get new content try { const fileUpdate = Patch.deriveNewContentsFromChunks(filePath, hunk.chunks) newContent = fileUpdate.content + bom = fileUpdate.bom } catch (error) { return yield* Effect.fail(new Error(`apply_patch verification failed: ${error}`)) } @@ -136,6 +143,7 @@ export const ApplyPatchTool = Tool.define( diff, additions, deletions, + bom, }) totalDiff += diff + "\n" @@ -143,8 +151,8 @@ export const ApplyPatchTool = Tool.define( } case "delete": { - const contentToDelete = yield* afs - .readFileString(filePath) + const source = yield* Bom + .readFile(afs, filePath) .pipe( Effect.catch((error) => Effect.fail( @@ -154,6 +162,7 @@ export const ApplyPatchTool = Tool.define( ), ), ) + const contentToDelete = source.text const deleteDiff = trimDiff(createTwoFilesPatch(filePath, filePath, contentToDelete, "")) const deletions = contentToDelete.split("\n").length @@ -166,6 +175,7 @@ export const ApplyPatchTool = Tool.define( diff: deleteDiff, additions: 0, deletions, + bom: source.bom, }) totalDiff += deleteDiff + "\n" @@ -207,12 +217,12 @@ export const ApplyPatchTool = Tool.define( case "add": // Create parent directories (recursive: true is safe on existing/root dirs) - yield* afs.writeWithDirs(change.filePath, change.newContent) + yield* afs.writeWithDirs(change.filePath, Bom.join(change.newContent, change.bom)) updates.push({ file: change.filePath, event: "add" }) break case "update": - yield* afs.writeWithDirs(change.filePath, change.newContent) + yield* afs.writeWithDirs(change.filePath, Bom.join(change.newContent, change.bom)) updates.push({ file: change.filePath, event: "change" }) break @@ -220,7 +230,7 @@ export const ApplyPatchTool = Tool.define( if (change.movePath) { // Create parent directories (recursive: true is safe on existing/root dirs) - yield* afs.writeWithDirs(change.movePath!, change.newContent) + yield* afs.writeWithDirs(change.movePath!, Bom.join(change.newContent, change.bom)) yield* afs.remove(change.filePath) updates.push({ file: change.filePath, event: "unlink" }) updates.push({ file: change.movePath, event: "add" }) @@ -234,7 +244,9 @@ export const ApplyPatchTool = Tool.define( } if (edited) { - yield* format.file(edited) + if (yield* format.file(edited)) { + yield* Bom.syncFile(afs, edited, change.bom) + } yield* bus.publish(File.Event.Edited, { file: edited }) } } diff --git a/packages/opencode/src/tool/edit.ts b/packages/opencode/src/tool/edit.ts index 2c6c2c13084a..858d14e043fe 100644 --- a/packages/opencode/src/tool/edit.ts +++ b/packages/opencode/src/tool/edit.ts @@ -18,6 +18,7 @@ import { Instance } from "../project/instance" import { Snapshot } from "@/snapshot" import { assertExternalDirectoryEffect } from "./external-directory" import { AppFileSystem } from "@opencode-ai/shared/filesystem" +import * as Bom from "@/util/bom" function normalizeLineEndings(text: string): string { return text.replaceAll("\r\n", "\n") @@ -84,7 +85,11 @@ export const EditTool = Tool.define( Effect.gen(function* () { if (params.oldString === "") { const existed = yield* afs.existsSafe(filePath) - contentNew = params.newString + const source = existed ? yield* Bom.readFile(afs, filePath) : { bom: false, text: "" } + const next = Bom.split(params.newString) + const desiredBom = source.bom || next.bom + contentOld = source.text + contentNew = next.text diff = trimDiff(createTwoFilesPatch(filePath, filePath, contentOld, contentNew)) yield* ctx.ask({ permission: "edit", @@ -95,8 +100,10 @@ export const EditTool = Tool.define( diff, }, }) - yield* afs.writeWithDirs(filePath, params.newString) - yield* format.file(filePath) + yield* afs.writeWithDirs(filePath, Bom.join(contentNew, desiredBom)) + if (yield* format.file(filePath)) { + contentNew = yield* Bom.syncFile(afs, filePath, desiredBom) + } yield* bus.publish(File.Event.Edited, { file: filePath }) yield* bus.publish(FileWatcher.Event.Updated, { file: filePath, @@ -108,13 +115,16 @@ export const EditTool = Tool.define( const info = yield* afs.stat(filePath).pipe(Effect.catch(() => Effect.succeed(undefined))) if (!info) throw new Error(`File ${filePath} not found`) if (info.type === "Directory") throw new Error(`Path is a directory, not a file: ${filePath}`) - contentOld = yield* afs.readFileString(filePath) + const source = yield* Bom.readFile(afs, filePath) + contentOld = source.text const ending = detectLineEnding(contentOld) const old = convertToLineEnding(normalizeLineEndings(params.oldString), ending) - const next = convertToLineEnding(normalizeLineEndings(params.newString), ending) + const replacement = convertToLineEnding(normalizeLineEndings(params.newString), ending) - contentNew = replace(contentOld, old, next, params.replaceAll) + const next = Bom.split(replace(contentOld, old, replacement, params.replaceAll)) + const desiredBom = source.bom || next.bom + contentNew = next.text diff = trimDiff( createTwoFilesPatch( @@ -134,14 +144,15 @@ export const EditTool = Tool.define( }, }) - yield* afs.writeWithDirs(filePath, contentNew) - yield* format.file(filePath) + yield* afs.writeWithDirs(filePath, Bom.join(contentNew, desiredBom)) + if (yield* format.file(filePath)) { + contentNew = yield* Bom.syncFile(afs, filePath, desiredBom) + } yield* bus.publish(File.Event.Edited, { file: filePath }) yield* bus.publish(FileWatcher.Event.Updated, { file: filePath, event: "change", }) - contentNew = yield* afs.readFileString(filePath) diff = trimDiff( createTwoFilesPatch( filePath, diff --git a/packages/opencode/src/tool/write.ts b/packages/opencode/src/tool/write.ts index 741091b21d3c..79ed58519831 100644 --- a/packages/opencode/src/tool/write.ts +++ b/packages/opencode/src/tool/write.ts @@ -13,6 +13,7 @@ import { AppFileSystem } from "@opencode-ai/shared/filesystem" import { Instance } from "../project/instance" import { trimDiff } from "./edit" import { assertExternalDirectoryEffect } from "./external-directory" +import * as Bom from "@/util/bom" const MAX_PROJECT_DIAGNOSTICS_FILES = 5 @@ -38,9 +39,13 @@ export const WriteTool = Tool.define( yield* assertExternalDirectoryEffect(ctx, filepath) const exists = yield* fs.existsSafe(filepath) - const contentOld = exists ? yield* fs.readFileString(filepath) : "" + const source = exists ? yield* Bom.readFile(fs, filepath) : { bom: false, text: "" } + const next = Bom.split(params.content) + const desiredBom = source.bom || next.bom + const contentOld = source.text + const contentNew = next.text - const diff = trimDiff(createTwoFilesPatch(filepath, filepath, contentOld, params.content)) + const diff = trimDiff(createTwoFilesPatch(filepath, filepath, contentOld, contentNew)) yield* ctx.ask({ permission: "edit", patterns: [path.relative(Instance.worktree, filepath)], @@ -51,8 +56,10 @@ export const WriteTool = Tool.define( }, }) - yield* fs.writeWithDirs(filepath, params.content) - yield* format.file(filepath) + yield* fs.writeWithDirs(filepath, Bom.join(contentNew, desiredBom)) + if (yield* format.file(filepath)) { + yield* Bom.syncFile(fs, filepath, desiredBom) + } yield* bus.publish(File.Event.Edited, { file: filepath }) yield* bus.publish(FileWatcher.Event.Updated, { file: filepath, diff --git a/packages/opencode/src/util/bom.ts b/packages/opencode/src/util/bom.ts new file mode 100644 index 000000000000..484228f3d415 --- /dev/null +++ b/packages/opencode/src/util/bom.ts @@ -0,0 +1,31 @@ +import { Effect } from "effect" +import { AppFileSystem } from "@opencode-ai/shared/filesystem" + +const BOM_CODE = 0xfeff +const BOM = String.fromCharCode(BOM_CODE) + +export function split(text: string) { + if (text.charCodeAt(0) !== BOM_CODE) return { bom: false, text } + return { bom: true, text: text.slice(1) } +} + +export function join(text: string, bom: boolean) { + const stripped = split(text).text + if (!bom) return stripped + return BOM + stripped +} + +export const readFile = Effect.fn("Bom.readFile")(function* (fs: AppFileSystem.Interface, filePath: string) { + return split(new TextDecoder("utf-8", { ignoreBOM: true }).decode(yield* fs.readFile(filePath))) +}) + +export const syncFile = Effect.fn("Bom.syncFile")(function* ( + fs: AppFileSystem.Interface, + filePath: string, + bom: boolean, +) { + const current = yield* readFile(fs, filePath) + if (current.bom === bom) return current.text + yield* fs.writeWithDirs(filePath, join(current.text, bom)) + return current.text +}) diff --git a/packages/opencode/test/format/format.test.ts b/packages/opencode/test/format/format.test.ts index 5530e195b268..2f6f235aa165 100644 --- a/packages/opencode/test/format/format.test.ts +++ b/packages/opencode/test/format/format.test.ts @@ -126,6 +126,24 @@ describe("Format", () => { it.live("service initializes without error", () => provideTmpdirInstance(() => Format.Service.use(() => Effect.void))) + it.live("file() returns false when no formatter runs", () => + provideTmpdirInstance( + (dir) => + Effect.gen(function* () { + const file = `${dir}/test.txt` + yield* Effect.promise(() => Bun.write(file, "x")) + + const formatted = yield* Format.Service.use((fmt) => fmt.file(file)) + expect(formatted).toBe(false) + }), + { + config: { + formatter: false, + }, + }, + ), + ) + it.live("status() initializes formatter state per directory", () => Effect.gen(function* () { const a = yield* provideTmpdirInstance(() => Format.Service.use((fmt) => fmt.status()), { @@ -219,7 +237,7 @@ describe("Format", () => { yield* Format.Service.use((fmt) => Effect.gen(function* () { yield* fmt.init() - yield* fmt.file(file) + expect(yield* fmt.file(file)).toBe(true) }), ) @@ -229,11 +247,21 @@ describe("Format", () => { config: { formatter: { first: { - command: ["sh", "-c", 'sleep 0.05; v=$(cat "$1"); printf \'%sA\' "$v" > "$1"', "sh", "$FILE"], + command: [ + "node", + "-e", + "const fs = require('fs'); const file = process.argv[1]; fs.writeFileSync(file, fs.readFileSync(file, 'utf8') + 'A')", + "$FILE", + ], extensions: [".seq"], }, second: { - command: ["sh", "-c", 'v=$(cat "$1"); printf \'%sB\' "$v" > "$1"', "sh", "$FILE"], + command: [ + "node", + "-e", + "const fs = require('fs'); const file = process.argv[1]; fs.writeFileSync(file, fs.readFileSync(file, 'utf8') + 'B')", + "$FILE", + ], extensions: [".seq"], }, }, diff --git a/packages/opencode/test/tool/apply_patch.test.ts b/packages/opencode/test/tool/apply_patch.test.ts index ebfa9a531eec..7ce483726b69 100644 --- a/packages/opencode/test/tool/apply_patch.test.ts +++ b/packages/opencode/test/tool/apply_patch.test.ts @@ -195,6 +195,34 @@ describe("tool.apply_patch freeform", () => { }) }) + test("does not invent a first-line diff for BOM files", async () => { + await using fixture = await tmpdir() + const { ctx, calls } = makeCtx() + + await Instance.provide({ + directory: fixture.path, + fn: async () => { + const bom = String.fromCharCode(0xfeff) + const target = path.join(fixture.path, "example.cs") + await fs.writeFile(target, `${bom}using System;\n\nclass Test {}\n`, "utf-8") + + const patchText = "*** Begin Patch\n*** Update File: example.cs\n@@\n class Test {}\n+class Next {}\n*** End Patch" + + await execute({ patchText }, ctx) + + expect(calls.length).toBe(1) + const shown = calls[0].metadata.files[0]?.patch ?? "" + expect(shown).not.toContain(bom) + expect(shown).not.toContain("-using System;") + expect(shown).not.toContain("+using System;") + + const content = await fs.readFile(target, "utf-8") + expect(content.charCodeAt(0)).toBe(0xfeff) + expect(content.slice(1)).toBe("using System;\n\nclass Test {}\nclass Next {}\n") + }, + }) + }) + test("inserts lines with insert-only hunk", async () => { await using fixture = await tmpdir() const { ctx } = makeCtx() diff --git a/packages/opencode/test/tool/edit.test.ts b/packages/opencode/test/tool/edit.test.ts index b5fbc0a67dde..82e1b4a7fd4b 100644 --- a/packages/opencode/test/tool/edit.test.ts +++ b/packages/opencode/test/tool/edit.test.ts @@ -96,6 +96,37 @@ describe("tool.edit", () => { }) }) + test("preserves BOM when oldString is empty on existing files", async () => { + await using tmp = await tmpdir() + const filepath = path.join(tmp.path, "existing.cs") + const bom = String.fromCharCode(0xfeff) + await fs.writeFile(filepath, `${bom}using System;\n`, "utf-8") + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const edit = await resolve() + const result = await Effect.runPromise( + edit.execute( + { + filePath: filepath, + oldString: "", + newString: "using Up;\n", + }, + ctx, + ), + ) + + expect(result.metadata.diff).toContain("-using System;") + expect(result.metadata.diff).toContain("+using Up;") + + const content = await fs.readFile(filepath, "utf-8") + expect(content.charCodeAt(0)).toBe(0xfeff) + expect(content.slice(1)).toBe("using Up;\n") + }, + }) + }) + test("creates new file with nested directories", async () => { await using tmp = await tmpdir() const filepath = path.join(tmp.path, "nested", "dir", "file.txt") @@ -183,6 +214,38 @@ describe("tool.edit", () => { }) }) + test("replaces the first visible line in BOM files", async () => { + await using tmp = await tmpdir() + const filepath = path.join(tmp.path, "existing.cs") + const bom = String.fromCharCode(0xfeff) + await fs.writeFile(filepath, `${bom}using System;\nclass Test {}\n`, "utf-8") + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const edit = await resolve() + const result = await Effect.runPromise( + edit.execute( + { + filePath: filepath, + oldString: "using System;", + newString: "using Up;", + }, + ctx, + ), + ) + + expect(result.metadata.diff).toContain("-using System;") + expect(result.metadata.diff).toContain("+using Up;") + expect(result.metadata.diff).not.toContain(bom) + + const content = await fs.readFile(filepath, "utf-8") + expect(content.charCodeAt(0)).toBe(0xfeff) + expect(content.slice(1)).toBe("using Up;\nclass Test {}\n") + }, + }) + }) + test("throws error when file does not exist", async () => { await using tmp = await tmpdir() const filepath = path.join(tmp.path, "nonexistent.txt") diff --git a/packages/opencode/test/tool/write.test.ts b/packages/opencode/test/tool/write.test.ts index 50d3b57527f9..36131f9596a3 100644 --- a/packages/opencode/test/tool/write.test.ts +++ b/packages/opencode/test/tool/write.test.ts @@ -114,6 +114,54 @@ describe("tool.write", () => { ), ) + it.live("preserves BOM when overwriting existing files", () => + provideTmpdirInstance((dir) => + Effect.gen(function* () { + const filepath = path.join(dir, "existing.cs") + const bom = String.fromCharCode(0xfeff) + yield* Effect.promise(() => fs.writeFile(filepath, `${bom}using System;\n`, "utf-8")) + + yield* run({ filePath: filepath, content: "using Up;\n" }) + + const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8")) + expect(content.charCodeAt(0)).toBe(0xfeff) + expect(content.slice(1)).toBe("using Up;\n") + }), + ), + ) + + it.live("restores BOM after formatter strips it", () => + provideTmpdirInstance( + (dir) => + Effect.gen(function* () { + const filepath = path.join(dir, "formatted.cs") + const bom = String.fromCharCode(0xfeff) + yield* Effect.promise(() => fs.writeFile(filepath, `${bom}using System;\n`, "utf-8")) + + yield* run({ filePath: filepath, content: "using Up;\n" }) + + const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8")) + expect(content.charCodeAt(0)).toBe(0xfeff) + expect(content.slice(1)).toBe("using Up;\n") + }), + { + config: { + formatter: { + stripbom: { + extensions: [".cs"], + command: [ + "node", + "-e", + "const fs = require('fs'); const file = process.argv[1]; let text = fs.readFileSync(file, 'utf8'); if (text.charCodeAt(0) === 0xfeff) text = text.slice(1); fs.writeFileSync(file, text, 'utf8')", + "$FILE", + ], + }, + }, + }, + }, + ), + ) + it.live("returns diff in metadata for existing files", () => provideTmpdirInstance((dir) => Effect.gen(function* () { From bfb954e7116bd3b9b43a30a35f02fae302062455 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Wed, 22 Apr 2026 08:06:06 +0000 Subject: [PATCH 12/17] chore: generate --- packages/opencode/src/tool/apply_patch.ts | 16 +++++++--------- packages/opencode/test/tool/apply_patch.test.ts | 3 ++- 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/packages/opencode/src/tool/apply_patch.ts b/packages/opencode/src/tool/apply_patch.ts index e36d5a65d801..a4cf1e853f3c 100644 --- a/packages/opencode/src/tool/apply_patch.ts +++ b/packages/opencode/src/tool/apply_patch.ts @@ -151,17 +151,15 @@ export const ApplyPatchTool = Tool.define( } case "delete": { - const source = yield* Bom - .readFile(afs, filePath) - .pipe( - Effect.catch((error) => - Effect.fail( - new Error( - `apply_patch verification failed: ${error instanceof Error ? error.message : String(error)}`, - ), + const source = yield* Bom.readFile(afs, filePath).pipe( + Effect.catch((error) => + Effect.fail( + new Error( + `apply_patch verification failed: ${error instanceof Error ? error.message : String(error)}`, ), ), - ) + ), + ) const contentToDelete = source.text const deleteDiff = trimDiff(createTwoFilesPatch(filePath, filePath, contentToDelete, "")) diff --git a/packages/opencode/test/tool/apply_patch.test.ts b/packages/opencode/test/tool/apply_patch.test.ts index 7ce483726b69..fa88432136a5 100644 --- a/packages/opencode/test/tool/apply_patch.test.ts +++ b/packages/opencode/test/tool/apply_patch.test.ts @@ -206,7 +206,8 @@ describe("tool.apply_patch freeform", () => { const target = path.join(fixture.path, "example.cs") await fs.writeFile(target, `${bom}using System;\n\nclass Test {}\n`, "utf-8") - const patchText = "*** Begin Patch\n*** Update File: example.cs\n@@\n class Test {}\n+class Next {}\n*** End Patch" + const patchText = + "*** Begin Patch\n*** Update File: example.cs\n@@\n class Test {}\n+class Next {}\n*** End Patch" await execute({ patchText }, ctx) From 0595c289046d7f45d82a563ad0c76b3ccfca050b Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Wed, 22 Apr 2026 18:17:35 +1000 Subject: [PATCH 13/17] test: fix cross-spawn stderr race on Windows CI (#23808) --- packages/opencode/test/effect/cross-spawn-spawner.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/opencode/test/effect/cross-spawn-spawner.test.ts b/packages/opencode/test/effect/cross-spawn-spawner.test.ts index 5990635aa211..201d99866782 100644 --- a/packages/opencode/test/effect/cross-spawn-spawner.test.ts +++ b/packages/opencode/test/effect/cross-spawn-spawner.test.ts @@ -169,7 +169,10 @@ describe("cross-spawn spawner", () => { 'process.stderr.write("stderr\\n", done)', ].join("\n"), ) - const [stdout, stderr] = yield* Effect.all([decodeByteStream(handle.stdout), decodeByteStream(handle.stderr)]) + const [stdout, stderr] = yield* Effect.all( + [decodeByteStream(handle.stdout), decodeByteStream(handle.stderr)], + { concurrency: 2 }, + ) expect(stdout).toBe("stdout") expect(stderr).toBe("stderr") }), From 6aa475fcac39cacda4730142314985c64b200bb5 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Wed, 22 Apr 2026 08:18:44 +0000 Subject: [PATCH 14/17] chore: generate --- packages/opencode/test/effect/cross-spawn-spawner.test.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/opencode/test/effect/cross-spawn-spawner.test.ts b/packages/opencode/test/effect/cross-spawn-spawner.test.ts index 201d99866782..b4e52529c1de 100644 --- a/packages/opencode/test/effect/cross-spawn-spawner.test.ts +++ b/packages/opencode/test/effect/cross-spawn-spawner.test.ts @@ -169,10 +169,9 @@ describe("cross-spawn spawner", () => { 'process.stderr.write("stderr\\n", done)', ].join("\n"), ) - const [stdout, stderr] = yield* Effect.all( - [decodeByteStream(handle.stdout), decodeByteStream(handle.stderr)], - { concurrency: 2 }, - ) + const [stdout, stderr] = yield* Effect.all([decodeByteStream(handle.stdout), decodeByteStream(handle.stderr)], { + concurrency: 2, + }) expect(stdout).toBe("stdout") expect(stderr).toBe("stderr") }), From 88c5f6bb19ecac5c60e9c42dcb2c497a416d390b Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Wed, 22 Apr 2026 17:09:00 +0800 Subject: [PATCH 15/17] fix: consolidate project avatar source logic (#23819) --- .../src/components/dialog-edit-project.tsx | 20 +++++++++++++------ .../app/src/pages/layout/sidebar-items.tsx | 16 ++++++++------- 2 files changed, 23 insertions(+), 13 deletions(-) diff --git a/packages/app/src/components/dialog-edit-project.tsx b/packages/app/src/components/dialog-edit-project.tsx index 621d56646df1..8eb12daf52e5 100644 --- a/packages/app/src/components/dialog-edit-project.tsx +++ b/packages/app/src/components/dialog-edit-project.tsx @@ -12,6 +12,7 @@ import { type LocalProject, getAvatarColors } from "@/context/layout" import { getFilename } from "@opencode-ai/shared/util/path" import { Avatar } from "@opencode-ai/ui/avatar" import { useLanguage } from "@/context/language" +import { getProjectAvatarSource } from "@/pages/layout/sidebar-items" const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const @@ -144,7 +145,11 @@ export function DialogEditProject(props: { project: LocalProject }) { }} > } > - {language.t("dialog.project.edit.icon.alt")} + {(src) => ( + {language.t("dialog.project.edit.icon.alt")} + )}
{ + if (store.color === color && !props.project.icon?.url) return setStore("color", store.color === color ? undefined : color) }} > diff --git a/packages/app/src/pages/layout/sidebar-items.tsx b/packages/app/src/pages/layout/sidebar-items.tsx index 88d50db3ed48..5170311a7b32 100644 --- a/packages/app/src/pages/layout/sidebar-items.tsx +++ b/packages/app/src/pages/layout/sidebar-items.tsx @@ -19,6 +19,14 @@ import { childSessionOnPath, hasProjectPermissions } from "./helpers" const OPENCODE_PROJECT_ID = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750" +export function getProjectAvatarSource(id?: string, icon?: { color?: string; url?: string; override?: string }) { + return id === OPENCODE_PROJECT_ID + ? "https://opencode.ai/favicon.svg" + : icon?.color + ? undefined + : icon?.override || icon?.url +} + export const ProjectIcon = (props: { project: LocalProject; class?: string; notify?: boolean }): JSX.Element => { const globalSync = useGlobalSync() const notification = useNotification() @@ -42,13 +50,7 @@ export const ProjectIcon = (props: { project: LocalProject; class?: string; noti
Date: Wed, 22 Apr 2026 16:35:13 +0530 Subject: [PATCH 16/17] fix(tui): fail fast on invalid session startup (#23837) --- packages/opencode/src/cli/cmd/tui/attach.ts | 16 ++++++ .../src/cli/cmd/tui/routes/session/index.tsx | 55 ++++++++++++------- packages/opencode/src/cli/cmd/tui/thread.ts | 14 +++++ .../src/cli/cmd/tui/validate-session.ts | 24 ++++++++ packages/opencode/src/util/error.ts | 9 +++ 5 files changed, 97 insertions(+), 21 deletions(-) create mode 100644 packages/opencode/src/cli/cmd/tui/validate-session.ts diff --git a/packages/opencode/src/cli/cmd/tui/attach.ts b/packages/opencode/src/cli/cmd/tui/attach.ts index 9a93f3f57a63..cb6b95a56cb6 100644 --- a/packages/opencode/src/cli/cmd/tui/attach.ts +++ b/packages/opencode/src/cli/cmd/tui/attach.ts @@ -3,6 +3,8 @@ import { UI } from "@/cli/ui" import { tui } from "./app" import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32" import { TuiConfig } from "@/cli/cmd/tui/config/tui" +import { errorMessage } from "@/util/error" +import { validateSession } from "./validate-session" export const AttachCommand = cmd({ command: "attach ", @@ -65,6 +67,20 @@ export const AttachCommand = cmd({ return { Authorization: auth } })() const config = await TuiConfig.get() + + try { + await validateSession({ + url: args.url, + sessionID: args.session, + directory, + headers, + }) + } catch (error) { + UI.error(errorMessage(error)) + process.exitCode = 1 + return + } + await tui({ url: args.url, config, diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 06be5dfbefbf..2f5da1d23154 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -68,6 +68,7 @@ import { Flag } from "@/flag/flag" import { LANGUAGE_EXTENSIONS } from "@/lsp/language" import parsers from "../../../../../../parsers-config.ts" import * as Clipboard from "../../util/clipboard" +import { errorMessage } from "@/util/error" import { Toast, useToast } from "../../ui/toast" import { useKV } from "../../context/kv.tsx" import * as Editor from "../../util/editor" @@ -180,31 +181,43 @@ export function Session() { const toast = useToast() const sdk = useSDK() - createEffect(async () => { - const previousWorkspace = project.workspace.current() - const result = await sdk.client.session.get({ sessionID: route.sessionID }, { throwOnError: true }) - if (!result.data) { + createEffect(() => { + const sessionID = route.sessionID + void (async () => { + const previousWorkspace = project.workspace.current() + const result = await sdk.client.session.get({ sessionID }, { throwOnError: true }) + if (!result.data) { + toast.show({ + message: `Session not found: ${sessionID}`, + variant: "error", + duration: 5000, + }) + navigate({ type: "home" }) + return + } + + if (result.data.workspaceID !== previousWorkspace) { + project.workspace.set(result.data.workspaceID) + + // Sync all the data for this workspace. Note that this + // workspace may not exist anymore which is why this is not + // fatal. If it doesn't we still want to show the session + // (which will be non-interactive) + try { + await sync.bootstrap({ fatal: false }) + } catch {} + } + await sync.session.sync(sessionID) + if (route.sessionID === sessionID && scroll) scroll.scrollBy(100_000) + })().catch((error) => { + if (route.sessionID !== sessionID) return toast.show({ - message: `Session not found: ${route.sessionID}`, + message: errorMessage(error), variant: "error", + duration: 5000, }) navigate({ type: "home" }) - return - } - - if (result.data.workspaceID !== previousWorkspace) { - project.workspace.set(result.data.workspaceID) - - // Sync all the data for this workspace. Note that this - // workspace may not exist anymore which is why this is not - // fatal. If it doesn't we still want to show the session - // (which will be non-interactive) - try { - await sync.bootstrap({ fatal: false }) - } catch (e) {} - } - await sync.session.sync(route.sessionID) - if (scroll) scroll.scrollBy(100_000) + }) }) let lastSwitch: string | undefined = undefined diff --git a/packages/opencode/src/cli/cmd/tui/thread.ts b/packages/opencode/src/cli/cmd/tui/thread.ts index e3e9eb811779..a2a53ecafa0d 100644 --- a/packages/opencode/src/cli/cmd/tui/thread.ts +++ b/packages/opencode/src/cli/cmd/tui/thread.ts @@ -16,6 +16,7 @@ import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32" import { writeHeapSnapshot } from "v8" import { TuiConfig } from "./config/tui" import { OPENCODE_PROCESS_ROLE, OPENCODE_RUN_ID, ensureRunID, sanitizedProcessEnv } from "@/util/opencode-process" +import { validateSession } from "./validate-session" declare global { const OPENCODE_WORKER_PATH: string @@ -202,6 +203,19 @@ export const TuiThreadCommand = cmd({ events: createEventSource(client), } + try { + await validateSession({ + url: transport.url, + sessionID: args.session, + directory: cwd, + fetch: transport.fetch, + }) + } catch (error) { + UI.error(errorMessage(error)) + process.exitCode = 1 + return + } + setTimeout(() => { client.call("checkUpgrade", { directory: cwd }).catch(() => {}) }, 1000).unref?.() diff --git a/packages/opencode/src/cli/cmd/tui/validate-session.ts b/packages/opencode/src/cli/cmd/tui/validate-session.ts new file mode 100644 index 000000000000..e2a21d51e14c --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/validate-session.ts @@ -0,0 +1,24 @@ +import { createOpencodeClient } from "@opencode-ai/sdk/v2" +import { SessionID } from "@/session/schema" + +export async function validateSession(input: { + url: string + sessionID?: string + directory?: string + fetch?: typeof fetch + headers?: RequestInit["headers"] +}) { + if (!input.sessionID) return + + const result = SessionID.zod.safeParse(input.sessionID) + if (!result.success) { + throw new Error(`Invalid session ID: ${result.error.issues.at(0)?.message ?? "unknown error"}`) + } + + await createOpencodeClient({ + baseUrl: input.url, + directory: input.directory, + fetch: input.fetch, + headers: input.headers, + }).session.get({ sessionID: result.data }, { throwOnError: true }) +} diff --git a/packages/opencode/src/util/error.ts b/packages/opencode/src/util/error.ts index 75fef9fc9a04..76cb9c7cf1c9 100644 --- a/packages/opencode/src/util/error.ts +++ b/packages/opencode/src/util/error.ts @@ -26,6 +26,15 @@ export function errorMessage(error: unknown): string { return error.message } + if ( + isRecord(error) && + isRecord(error.data) && + typeof error.data.message === "string" && + error.data.message + ) { + return error.data.message + } + const text = String(error) if (text && text !== "[object Object]") return text From 266e965572ccc499b585e4a3558b93e56625e10d Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Wed, 22 Apr 2026 11:07:04 +0000 Subject: [PATCH 17/17] chore: generate --- packages/opencode/src/util/error.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/packages/opencode/src/util/error.ts b/packages/opencode/src/util/error.ts index 76cb9c7cf1c9..fbda2dc50e02 100644 --- a/packages/opencode/src/util/error.ts +++ b/packages/opencode/src/util/error.ts @@ -26,12 +26,7 @@ export function errorMessage(error: unknown): string { return error.message } - if ( - isRecord(error) && - isRecord(error.data) && - typeof error.data.message === "string" && - error.data.message - ) { + if (isRecord(error) && isRecord(error.data) && typeof error.data.message === "string" && error.data.message) { return error.data.message }