From 1ca249101134cc340126dc202c89a649ecfeaca9 Mon Sep 17 00:00:00 2001 From: Haider Date: Thu, 17 Sep 2026 15:18:52 +0530 Subject: [PATCH 1/3] fix(session): flatten tool history when a request declares no tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Compaction, title and summary run with `"*": "deny"`, so they send no `tools` array while summarizing a session's own history — which is full of `tool-call` and `tool-result` parts. The gateway's Responses-API namespace conversion only runs when `tools` is present, so the history references functions the request never declares, every provider in its fallback chain fails, and the client sees only: UnknownError: "Could not get a response from the agent. Please try again later." The failure therefore looks like a gateway outage while ordinary turns keep working, because ordinary turns do declare their tools. Verified against the live gateway: an otherwise identical compaction request fails with tool messages and no `tools` array, and succeeds once the same history is flattened to text. Measured directly, OpenAI and Anthropic both ACCEPT that shape, so the root defect is gateway-side and is tracked separately. Flattening is still applied to every provider rather than branching on one: a request that declares no tools has no use for structured tool parts either way, and a single code path avoids a provider-specific branch that would rot. `ProviderTransform.flattenToolParts` rewrites `tool-call` parts into `[tool call: name(args)]` and `tool-result` parts into `[tool result: name]` plus the output, merging results into the preceding assistant turn so role alternation is preserved. Toolless requests are the only ones affected; every other request keeps its structured tool parts untouched. Closes #1315 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01V4pzHPsqfbTdLMKzMaSYcF --- packages/opencode/src/provider/transform.ts | 94 +++++++++++++++++++ packages/opencode/src/session/llm.ts | 12 ++- .../test/provider/flatten-tool-parts.test.ts | 85 +++++++++++++++++ 3 files changed, 190 insertions(+), 1 deletion(-) create mode 100644 packages/opencode/test/provider/flatten-tool-parts.test.ts diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 1a30a83a25..6f415bfd6a 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -369,6 +369,100 @@ export namespace ProviderTransform { } // altimate_change start — expose the pure request projection used before input-budget estimation + // altimate_change start — flatten tool history when a request declares no tools + /** + * Render a tool call as the plain text a summarizer can still read. + */ + function renderToolCall(part: any): string { + const name = typeof part?.toolName === "string" ? part.toolName : "tool" + const raw = part?.input ?? part?.args + let args = "" + if (typeof raw === "string") args = raw + else if (raw !== undefined) { + try { + args = JSON.stringify(raw) + } catch { + args = String(raw) + } + } + return args ? `[tool call: ${name}(${args})]` : `[tool call: ${name}]` + } + + /** + * Render a tool result as plain text, tolerating every output shape the SDK emits. + */ + function renderToolResult(part: any): string { + const name = typeof part?.toolName === "string" ? part.toolName : "tool" + const out = part?.output ?? part?.result + let body = "" + if (typeof out === "string") body = out + else if (out && typeof out === "object") { + const value = (out as any).value ?? out + if (typeof value === "string") body = value + else { + try { + body = JSON.stringify(value) + } catch { + body = String(value) + } + } + } else if (out !== undefined) body = String(out) + return `[tool result: ${name}]${body ? `\n${body}` : ""}` + } + + /** + * Rewrite `tool-call` / `tool-result` parts into text when the outgoing request declares + * no tools. + * + * The Altimate gateway rejects a request that carries tool-call messages while declaring no + * `tools`: its Responses-API namespace conversion only runs when `tools` is present, so the + * history references functions the request never declares and every provider in its fallback + * chain fails, surfacing one generic error. Measured directly, OpenAI and Anthropic both + * ACCEPT that shape — this is a gateway-side defect, tracked separately. + * + * Flattening is applied to every provider rather than branching on one, because a request + * that declares no tools has no use for structured tool parts either way: the toolless + * agents — compaction, title and summary — only summarize. One code path avoids a + * provider-specific branch that would silently rot as the gateway changes. + * + * Flattening keeps the information a summarizer needs (which tool ran, with what arguments, + * and what it returned) while removing the structure the provider would validate. + */ + export function flattenToolParts(msgs: ModelMessage[]): ModelMessage[] { + const result: ModelMessage[] = [] + for (const msg of msgs) { + if (msg.role === "tool") { + const parts = Array.isArray(msg.content) ? msg.content : [] + const text = parts + .map((part) => renderToolResult(part)) + .filter((line) => line.trim().length > 0) + .join("\n") + if (!text) continue + const previous = result.at(-1) + // Merge into the preceding assistant turn so role alternation is preserved; providers + // that reject a bare trailing assistant message never see a new one appear. + if (previous && previous.role === "assistant" && Array.isArray(previous.content)) { + previous.content = [...previous.content, { type: "text", text }] + continue + } + result.push({ role: "assistant", content: [{ type: "text", text }] }) + continue + } + if (msg.role === "assistant" && Array.isArray(msg.content)) { + const content = msg.content + .map((part: any) => (part?.type === "tool-call" ? { type: "text" as const, text: renderToolCall(part) } : part)) + .filter((part: any) => !(part?.type === "text" && typeof part.text === "string" && part.text.trim() === "")) + // An assistant turn that held nothing but tool calls must not become an empty message. + if (content.length === 0) continue + result.push({ ...msg, content } as ModelMessage) + continue + } + result.push(msg) + } + return result + } + // altimate_change end + export function messagesForInputEstimate(msgs: ModelMessage[], model: Provider.Model): ModelMessage[] { const projected = unsupportedParts(msgs, model) const mistral = diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index 4f85b10c5b..19c5b7f165 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -267,6 +267,10 @@ export namespace LLM { const requestOptions = clampReasoningBudget(params.options, maxOutputTokens) // altimate_change end + // altimate_change start — detect a toolless request once, for the message flattening below + const declaresNoTools = Object.keys(tools).filter((x) => x !== "invalid").length === 0 + // altimate_change end + return streamText({ onError(error) { l.error("stream error", { @@ -322,7 +326,13 @@ export namespace LLM { content: x, }), ), - ...input.messages, + // altimate_change start — a request that declares no tools must not carry tool-call + // messages. The toolless agents (compaction, title, summary) summarize a session's own + // history, so they would otherwise send tool calls referencing functions the request + // never declares. The Altimate gateway fails every provider in its fallback chain on + // that shape and reports one generic error; see ProviderTransform.flattenToolParts. + ...(declaresNoTools ? ProviderTransform.flattenToolParts(input.messages) : input.messages), + // altimate_change end ], model: wrapLanguageModel({ model: language, diff --git a/packages/opencode/test/provider/flatten-tool-parts.test.ts b/packages/opencode/test/provider/flatten-tool-parts.test.ts new file mode 100644 index 0000000000..bd0ffd308e --- /dev/null +++ b/packages/opencode/test/provider/flatten-tool-parts.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, test } from "bun:test" +import { ProviderTransform } from "@/provider/transform" +import type { ModelMessage } from "ai" + +/** + * A request that declares no tools must not carry tool-call messages: OpenAI, Azure's Responses + * API and Anthropic all reject a call referencing an undeclared function, so every provider in a + * fallback chain fails and the gateway can only report a generic error (issue #1315). + */ +describe("ProviderTransform.flattenToolParts", () => { + const history = (): ModelMessage[] => [ + { role: "user", content: "list the files" }, + { + role: "assistant", + content: [ + { type: "text", text: "Checking." }, + { type: "tool-call", toolCallId: "c1", toolName: "bash", input: { cmd: "ls" } }, + ], + } as unknown as ModelMessage, + { + role: "tool", + content: [{ type: "tool-result", toolCallId: "c1", toolName: "bash", output: { value: "a.txt\nb.txt" } }], + } as unknown as ModelMessage, + { role: "user", content: "Summarize the conversation above." }, + ] + + function toolPartTypes(msgs: ModelMessage[]) { + return msgs.flatMap((m) => (Array.isArray(m.content) ? m.content.map((p: any) => p?.type) : [])) + } + + test("removes every tool-call and tool-result part", () => { + const out = ProviderTransform.flattenToolParts(history()) + expect(toolPartTypes(out)).not.toContain("tool-call") + expect(toolPartTypes(out)).not.toContain("tool-result") + expect(out.some((m) => m.role === "tool")).toBe(false) + }) + + test("preserves the tool name, arguments and output as readable text", () => { + const text = JSON.stringify(ProviderTransform.flattenToolParts(history())) + expect(text).toContain("bash") + expect(text).toContain("ls") + expect(text).toContain("a.txt") + }) + + test("keeps surrounding conversation intact", () => { + const out = ProviderTransform.flattenToolParts(history()) + expect(out[0]).toEqual({ role: "user", content: "list the files" }) + expect(out.at(-1)).toEqual({ role: "user", content: "Summarize the conversation above." }) + }) + + test("an assistant turn holding only tool calls does not become an empty message", () => { + const out = ProviderTransform.flattenToolParts([ + { role: "user", content: "go" }, + { + role: "assistant", + content: [{ type: "tool-call", toolCallId: "c1", toolName: "bash", input: {} }], + } as unknown as ModelMessage, + { role: "user", content: "summarize" }, + ]) + for (const msg of out) { + if (Array.isArray(msg.content)) expect(msg.content.length).toBeGreaterThan(0) + } + }) + + test("an orphaned tool result (head truncated away its call) still flattens", () => { + const out = ProviderTransform.flattenToolParts([ + { role: "user", content: "go" }, + { + role: "tool", + content: [{ type: "tool-result", toolCallId: "gone", toolName: "bash", output: { value: "out" } }], + } as unknown as ModelMessage, + { role: "user", content: "summarize" }, + ]) + expect(out.some((m) => m.role === "tool")).toBe(false) + expect(JSON.stringify(out)).toContain("out") + }) + + test("messages with no tool parts are returned unchanged", () => { + const plain: ModelMessage[] = [ + { role: "user", content: "hi" }, + { role: "assistant", content: [{ type: "text", text: "hello" }] } as unknown as ModelMessage, + ] + expect(ProviderTransform.flattenToolParts(plain)).toEqual(plain) + }) +}) From 3a94ff0ae5b0495b6920ac0dab41af9e9538e226 Mon Sep 17 00:00:00 2001 From: Haider Date: Fri, 18 Sep 2026 14:10:32 +0530 Subject: [PATCH 2/3] fix(session): address consensus review on the toolless flattening MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Findings verified by execution before fixing; each was reproduced first. - Consecutive assistant turns (Major #1). Removing the intervening `tool` message left the ordinary agentic shape `user -> assistant(call) -> tool(result) -> assistant(text) -> user` with two adjacent assistant messages, contradicting the docstring's claim that role alternation is preserved. Assistant turns now coalesce. Checked against the live gateway: it accepts consecutive assistant messages, so this was a correctness and documentation defect rather than a live failure. - Title generation never reached the flattening (Major #2). `ensureTitle` passed `tools: {}` without a `toolChoice`, so `addHistoricalToolStubs` repopulated `tools` from tool parts in the context, `declaresNoTools` became false, and the transform never ran. It now passes `toolChoice: "none"`, as `compaction.ts` already did. - `tool-result` parts embedded in an assistant message are flattened too (Minor #3); they previously survived structurally intact. - The blank-text filter no longer touches parts this transform did not create (Minor #4). It was stripping the single space `message-v2.ts` preserves as a separator between Anthropic signed-reasoning blocks. - `content`-array tool output is unwrapped to its text instead of being JSON-stringified (Minor #8), which also shrinks the flattened payload that Minor #5 flagged as escaping the token estimate. - The test docstring no longer claims OpenAI and Anthropic reject the shape (Minor #9); they accept it, as the implementation and PR body already said. Seven regression tests added, covering the canonical shape, the preserved separator, embedded results, unwrapped content output, parallel calls, output shapes with no `value`, and caller-input immutability. Mutation-checked: disabling the merge fails 2, widening the blank filter fails 1. Deferred, with reasons: marker escaping and output truncation (Minor #7) — after the merge fix a call and its result are adjacent in one turn, so ordering already carries correlation; `any` in the render helpers (NIT #10) — deliberate glue for SDK shape variability. The `summary` agent named in the PR description has no `Agent.get("summary")` call site anywhere in the tree, so only compaction and title actually reach this code; the PR body is corrected accordingly. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01V4pzHPsqfbTdLMKzMaSYcF --- packages/opencode/src/provider/transform.ts | 102 ++++++++----- packages/opencode/src/session/prompt.ts | 5 + .../test/provider/flatten-tool-parts.test.ts | 141 +++++++++++++++++- 3 files changed, 207 insertions(+), 41 deletions(-) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 6f415bfd6a..cdb200eff6 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -370,26 +370,28 @@ export namespace ProviderTransform { // altimate_change start — expose the pure request projection used before input-budget estimation // altimate_change start — flatten tool history when a request declares no tools - /** - * Render a tool call as the plain text a summarizer can still read. - */ + /** JSON.stringify that never throws on circular or non-serializable values. */ + function safeJson(value: unknown): string { + try { + return JSON.stringify(value) ?? "" + } catch { + return String(value) + } + } + + /** Render a tool call as the plain text a summarizer can still read. */ function renderToolCall(part: any): string { const name = typeof part?.toolName === "string" ? part.toolName : "tool" const raw = part?.input ?? part?.args - let args = "" - if (typeof raw === "string") args = raw - else if (raw !== undefined) { - try { - args = JSON.stringify(raw) - } catch { - args = String(raw) - } - } + const args = typeof raw === "string" ? raw : raw === undefined ? "" : safeJson(raw) return args ? `[tool call: ${name}(${args})]` : `[tool call: ${name}]` } /** * Render a tool result as plain text, tolerating every output shape the SDK emits. + * + * The `content` array shape carries readable text parts; stringifying it whole would hand the + * summarizer `[{"type":"text","text":"…"}]` instead of the text it needs, so it is unwrapped. */ function renderToolResult(part: any): string { const name = typeof part?.toolName === "string" ? part.toolName : "tool" @@ -397,14 +399,19 @@ export namespace ProviderTransform { let body = "" if (typeof out === "string") body = out else if (out && typeof out === "object") { - const value = (out as any).value ?? out - if (typeof value === "string") body = value - else { - try { - body = JSON.stringify(value) - } catch { - body = String(value) - } + const shape = out as any + if (shape.type === "content" && Array.isArray(shape.value)) { + body = shape.value + .map((item: any) => { + if (item?.type === "text" && typeof item.text === "string") return item.text + if (item?.type === "media") return `[${item.mediaType ?? "media"}]` + return safeJson(item) + }) + .filter((line: string) => line.length > 0) + .join("\n") + } else { + const value = shape.value ?? shape + body = typeof value === "string" ? value : safeJson(value) } } else if (out !== undefined) body = String(out) return `[tool result: ${name}]${body ? `\n${body}` : ""}` @@ -422,14 +429,33 @@ export namespace ProviderTransform { * * Flattening is applied to every provider rather than branching on one, because a request * that declares no tools has no use for structured tool parts either way: the toolless - * agents — compaction, title and summary — only summarize. One code path avoids a - * provider-specific branch that would silently rot as the gateway changes. + * agents only summarize. One code path avoids a provider-specific branch that would silently + * rot as the gateway changes. + * + * Consecutive assistant turns are coalesced. Removing the intervening `tool` message would + * otherwise leave two adjacent assistant messages for the ordinary agentic shape + * (`user → assistant(call) → tool(result) → assistant(text) → user`). The AI SDK's Anthropic + * provider happens to coalesce those before transport, but the gateway's own request path is + * not known to, and this fix exists precisely because the gateway is stricter than upstream. * - * Flattening keeps the information a summarizer needs (which tool ran, with what arguments, - * and what it returned) while removing the structure the provider would validate. + * Parts that were not converted pass through untouched, so an intentionally blank assistant + * text part — the separator `message-v2.ts` preserves between Anthropic signed-reasoning + * blocks — is never dropped here. */ export function flattenToolParts(msgs: ModelMessage[]): ModelMessage[] { const result: ModelMessage[] = [] + + // Every message this function appends is freshly constructed, so extending `content` in + // place can never mutate the caller's input. + const appendAssistant = (parts: any[]) => { + const previous = result.at(-1) + if (previous && previous.role === "assistant" && Array.isArray(previous.content)) { + previous.content = [...previous.content, ...parts] as typeof previous.content + return + } + result.push({ role: "assistant", content: parts } as ModelMessage) + } + for (const msg of msgs) { if (msg.role === "tool") { const parts = Array.isArray(msg.content) ? msg.content : [] @@ -438,23 +464,23 @@ export namespace ProviderTransform { .filter((line) => line.trim().length > 0) .join("\n") if (!text) continue - const previous = result.at(-1) - // Merge into the preceding assistant turn so role alternation is preserved; providers - // that reject a bare trailing assistant message never see a new one appear. - if (previous && previous.role === "assistant" && Array.isArray(previous.content)) { - previous.content = [...previous.content, { type: "text", text }] - continue - } - result.push({ role: "assistant", content: [{ type: "text", text }] }) + appendAssistant([{ type: "text", text }]) continue } if (msg.role === "assistant" && Array.isArray(msg.content)) { - const content = msg.content - .map((part: any) => (part?.type === "tool-call" ? { type: "text" as const, text: renderToolCall(part) } : part)) - .filter((part: any) => !(part?.type === "text" && typeof part.text === "string" && part.text.trim() === "")) - // An assistant turn that held nothing but tool calls must not become an empty message. - if (content.length === 0) continue - result.push({ ...msg, content } as ModelMessage) + const converted: any[] = [] + for (const part of msg.content as any[]) { + if (part?.type === "tool-call" || part?.type === "tool-result") { + const text = part.type === "tool-call" ? renderToolCall(part) : renderToolResult(part) + // Only a part this function produced may be dropped for being blank. + if (text.trim().length > 0) converted.push({ type: "text", text }) + continue + } + converted.push(part) + } + // An assistant turn that held nothing but tool parts must not become an empty message. + if (converted.length === 0) continue + appendAssistant(converted) continue } result.push(msg) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 6184d53127..48eff50a09 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -4062,6 +4062,11 @@ NOTE: At any point in time through this workflow you should feel free to ask the system: [], small: true, tools: {}, + // altimate_change — title generation is toolless, but without an explicit "none" the + // historical-tool-stub injection in LLM.stream repopulates `tools` from any tool parts in + // the context, which both re-declares tools this request cannot use and suppresses the + // toolless message flattening. compaction.ts passes the same flag for the same reason. + toolChoice: "none" as const, model, abort: new AbortController().signal, sessionID: input.session.id, diff --git a/packages/opencode/test/provider/flatten-tool-parts.test.ts b/packages/opencode/test/provider/flatten-tool-parts.test.ts index bd0ffd308e..fd1f2c52a3 100644 --- a/packages/opencode/test/provider/flatten-tool-parts.test.ts +++ b/packages/opencode/test/provider/flatten-tool-parts.test.ts @@ -3,9 +3,10 @@ import { ProviderTransform } from "@/provider/transform" import type { ModelMessage } from "ai" /** - * A request that declares no tools must not carry tool-call messages: OpenAI, Azure's Responses - * API and Anthropic all reject a call referencing an undeclared function, so every provider in a - * fallback chain fails and the gateway can only report a generic error (issue #1315). + * A request that declares no tools must not carry tool-call messages: the Altimate gateway + * rejects that shape, failing every provider in its fallback chain and reporting one generic + * error (issue #1315). OpenAI and Anthropic both accept it — the defect is gateway-side, and + * this transform is the client-side mitigation. */ describe("ProviderTransform.flattenToolParts", () => { const history = (): ModelMessage[] => [ @@ -82,4 +83,138 @@ describe("ProviderTransform.flattenToolParts", () => { ] expect(ProviderTransform.flattenToolParts(plain)).toEqual(plain) }) + + // --- regressions from the consensus review of PR #1319 --- + + test("the canonical agentic shape does not leave consecutive assistant messages", () => { + // user -> assistant(text+call) -> tool(result) -> assistant(text) -> user is the ordinary + // shape of every session compaction summarizes. Dropping the tool message used to leave two + // adjacent assistant turns. + const out = ProviderTransform.flattenToolParts([ + { role: "user", content: "go" }, + { + role: "assistant", + content: [ + { type: "text", text: "Checking." }, + { type: "tool-call", toolCallId: "c1", toolName: "bash", input: { cmd: "ls" } }, + ], + }, + { + role: "tool", + content: [{ type: "tool-result", toolCallId: "c1", toolName: "bash", output: { type: "text", value: "a.txt" } }], + }, + { role: "assistant", content: [{ type: "text", text: "I found a.txt." }] }, + { role: "user", content: "summarize" }, + ] as unknown as ModelMessage[]) + + expect(out.map((m) => m.role)).toEqual(["user", "assistant", "user"]) + for (let i = 1; i < out.length; i++) expect(out[i].role === out[i - 1].role).toBe(false) + // nothing is lost in the merge + const text = JSON.stringify(out) + expect(text).toContain("Checking.") + expect(text).toContain("a.txt") + expect(text).toContain("I found a.txt.") + }) + + test("an intentionally blank assistant text part survives", () => { + // message-v2.ts preserves a single space between Anthropic signed-reasoning blocks; the + // blank-part filter must only ever drop parts this transform itself produced. + const out = ProviderTransform.flattenToolParts([ + { role: "user", content: "go" }, + { role: "assistant", content: [{ type: "reasoning", text: "thinking" }, { type: "text", text: " " }] }, + { role: "user", content: "summarize" }, + ] as unknown as ModelMessage[]) + const parts = (out[1] as any).content + expect(parts.some((p: any) => p.type === "text" && p.text === " ")).toBe(true) + expect(parts.some((p: any) => p.type === "reasoning")).toBe(true) + }) + + test("a tool-result embedded in an assistant message is flattened too", () => { + const out = ProviderTransform.flattenToolParts([ + { role: "user", content: "go" }, + { + role: "assistant", + content: [{ type: "tool-result", toolCallId: "c9", toolName: "bash", output: { type: "text", value: "x" } }], + }, + { role: "user", content: "summarize" }, + ] as unknown as ModelMessage[]) + expect(JSON.stringify(out)).not.toContain('"tool-result"') + expect(JSON.stringify(out)).toContain("x") + }) + + test("content-array output is unwrapped into readable text, not JSON", () => { + const out = ProviderTransform.flattenToolParts([ + { role: "user", content: "go" }, + { role: "assistant", content: [{ type: "tool-call", toolCallId: "c1", toolName: "read", input: {} }] }, + { + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: "c1", + toolName: "read", + output: { type: "content", value: [{ type: "text", text: "hello world" }] }, + }, + ], + }, + { role: "user", content: "s" }, + ] as unknown as ModelMessage[]) + const rendered = JSON.stringify(out) + expect(rendered).toContain("hello world") + expect(rendered).not.toContain('\\"type\\":\\"text\\"') + }) + + test("parallel tool calls and multiple results keep their order", () => { + const out = ProviderTransform.flattenToolParts([ + { role: "user", content: "go" }, + { + role: "assistant", + content: [ + { type: "tool-call", toolCallId: "a", toolName: "first", input: {} }, + { type: "tool-call", toolCallId: "b", toolName: "second", input: {} }, + ], + }, + { + role: "tool", + content: [ + { type: "tool-result", toolCallId: "a", toolName: "first", output: { type: "text", value: "R1" } }, + { type: "tool-result", toolCallId: "b", toolName: "second", output: { type: "text", value: "R2" } }, + ], + }, + { role: "user", content: "s" }, + ] as unknown as ModelMessage[]) + const flat = (out[1] as any).content.map((p: any) => p.text).join("\n") + expect(flat.indexOf("first")).toBeLessThan(flat.indexOf("second")) + expect(flat.indexOf("R1")).toBeLessThan(flat.indexOf("R2")) + }) + + test("output shapes without a value do not throw", () => { + for (const output of [ + { type: "error-text", value: "boom" }, + { type: "json", value: { a: 1 } }, + { type: "execution-denied" }, + "plain string", + ]) { + const run = () => + ProviderTransform.flattenToolParts([ + { role: "user", content: "go" }, + { + role: "tool", + content: [{ type: "tool-result", toolCallId: "c1", toolName: "t", output }], + }, + ] as unknown as ModelMessage[]) + expect(run).not.toThrow() + } + }) + + test("the caller's input array is never mutated", () => { + const input = [ + { role: "user", content: "go" }, + { role: "assistant", content: [{ type: "tool-call", toolCallId: "c1", toolName: "bash", input: {} }] }, + { role: "tool", content: [{ type: "tool-result", toolCallId: "c1", toolName: "bash", output: { type: "text", value: "o" } }] }, + ] as unknown as ModelMessage[] + const before = JSON.stringify(input) + ProviderTransform.flattenToolParts(input) + expect(JSON.stringify(input)).toEqual(before) + }) }) From f25caf4418c7e6e471182850c3e74a92c313ca29 Mon Sep 17 00:00:00 2001 From: Haider Date: Fri, 18 Sep 2026 14:54:22 +0530 Subject: [PATCH 3/3] fix: wrap toolChoice: "none" with altimate_change markers The consensus-review fix commit added `toolChoice: "none" as const` to `ensureTitle`'s `LLM.stream` call without wrapping it in `altimate_change start`/`end` markers, tripping the Marker Guard CI check on this upstream-shared file. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01WJKLg6TKfGMPntHvkXZd7Z --- packages/opencode/src/session/prompt.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 48eff50a09..3378e68da3 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -4062,11 +4062,12 @@ NOTE: At any point in time through this workflow you should feel free to ask the system: [], small: true, tools: {}, - // altimate_change — title generation is toolless, but without an explicit "none" the + // altimate_change start — title generation is toolless, but without an explicit "none" the // historical-tool-stub injection in LLM.stream repopulates `tools` from any tool parts in // the context, which both re-declares tools this request cannot use and suppresses the // toolless message flattening. compaction.ts passes the same flag for the same reason. toolChoice: "none" as const, + // altimate_change end model, abort: new AbortController().signal, sessionID: input.session.id,