From 51719861f58a4b8d8e3ce94239f8869fc26f7b75 Mon Sep 17 00:00:00 2001 From: Juan Cruz Linsalata <25271111+linsaftw@users.noreply.github.com> Date: Thu, 12 Mar 2026 23:53:01 +0000 Subject: [PATCH 1/5] fix(app): ensure tool calls are properly formatted with newlines in user and assistant messages --- packages/opencode/src/session/message-v2.ts | 29 +++++++- .../src/session/prompt/anthropic-20250930.txt | 1 + .../opencode/src/session/prompt/anthropic.txt | 1 + .../src/session/prompt/codex_header.txt | 1 + .../opencode/src/session/prompt/gemini.txt | 1 + packages/opencode/src/session/prompt/qwen.txt | 1 + .../opencode/src/session/prompt/trinity.txt | 1 + .../opencode/test/session/message-v2.test.ts | 71 +++++++++++++++++++ 8 files changed, 105 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 03ccb44c1ad4..2c2e19e5bf16 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -499,6 +499,33 @@ export namespace MessageV2 { model: Provider.Model, options?: { stripMedia?: boolean }, ): ModelMessage[] { + // Solution A: the agent prompts (see session/prompt/*) now explicitly + // tell the model to start each `` on a fresh line. This helps + // avoid the problem in the first place. + // Solution B: some tool callers still forget and stick the tag immediately + // after text. the underlying parser in the AI SDK only recognizes a tool + // call if it begins on a new line, so we sanitize the text by inserting a + // newline whenever a tool call tag appears immediately after non-newline + // content. + function sanitizeToolCalls(messages: UIMessage[]) { + for (const msg of messages) { + for (const part of msg.parts) { + if (part.type === "text" && typeof part.text === "string") { + // add a newline before any that isn't already on its own line + part.text = part.text.replace(/([^\n])()/g, "$1\n$2") + } + } + } + } + + // copy input so we can sanitize without mutating the original array. + // we shallow-copy the message and its parts array; the parts themselves + // are left by reference, which lets the sanitizer still alter text but + // avoids surprising callers who hold the original `input` array. after + // sanitization we operate on `msgs` when building the output. + const msgs = input.map((m) => ({ ...m, parts: [...m.parts] })) + sanitizeToolCalls(msgs) + const result: UIMessage[] = [] const toolNames = new Set() // Track media from tool results that need to be injected as user messages @@ -555,7 +582,7 @@ export namespace MessageV2 { return { type: "json", value: output as never } } - for (const msg of input) { + for (const msg of msgs) { if (msg.parts.length === 0) continue if (msg.info.role === "user") { diff --git a/packages/opencode/src/session/prompt/anthropic-20250930.txt b/packages/opencode/src/session/prompt/anthropic-20250930.txt index e15080e6c8a0..3c8ee86f40f0 100644 --- a/packages/opencode/src/session/prompt/anthropic-20250930.txt +++ b/packages/opencode/src/session/prompt/anthropic-20250930.txt @@ -58,6 +58,7 @@ assistant: src/foo.c When you run a non-trivial bash command, you should explain what the command does and why you are running it, to make sure the user understands what you are doing (this is especially important when you are running a command that will make changes to the user's system). Remember that your output will be displayed on a command line interface. Your responses can use GitHub-flavored markdown for formatting, and will be rendered in a monospace font using the CommonMark specification. Output text to communicate with the user; all text you output outside of tool use is displayed to the user. Only use tools to complete tasks. Never use tools like Bash or code comments as means to communicate with the user during the session. +- **Tool call formatting:** When initiating a tool call you should always place it on its own line with a newline (or blank line) before the `` tag. This ensures the markup is recognized even if there is preceding text, and helps avoid situations where a function call is ignored. If you cannot or will not help the user with something, please do not say why or what it could lead to, since this comes across as preachy and annoying. Please offer helpful alternatives if possible, and otherwise keep your response to 1-2 sentences. Only use emojis if the user explicitly requests it. Avoid using emojis in all communication unless asked. IMPORTANT: Keep your responses short, since they will be displayed on a command line interface. diff --git a/packages/opencode/src/session/prompt/anthropic.txt b/packages/opencode/src/session/prompt/anthropic.txt index 21d9c0e9f216..dccc306ac3a3 100644 --- a/packages/opencode/src/session/prompt/anthropic.txt +++ b/packages/opencode/src/session/prompt/anthropic.txt @@ -81,6 +81,7 @@ The user will primarily request you perform software engineering tasks. This inc - When WebFetch returns a message about a redirect to a different host, you should immediately make a new WebFetch request with the redirect URL provided in the response. - You can call multiple tools in a single response. If you intend to call multiple tools and there are no dependencies between them, make all independent tool calls in parallel. Maximize use of parallel tool calls where possible to increase efficiency. However, if some tool calls depend on previous calls to inform dependent values, do NOT call these tools in parallel and instead call them sequentially. For instance, if one operation must complete before another starts, run these operations sequentially instead. Never use placeholders or guess missing parameters in tool calls. +- **Tool call formatting:** When initiating a tool call you should always place it on its own line with a newline before the `` tag. This ensures the markup is recognized even if there is preceding text. - If the user specifies that they want you to run tools "in parallel", you MUST send a single message with multiple tool use content blocks. For example, if you need to launch multiple agents in parallel, send a single message with multiple Task tool calls. - Use specialized tools instead of bash commands when possible, as this provides a better user experience. For file operations, use dedicated tools: Read for reading files instead of cat/head/tail, Edit for editing instead of sed/awk, and Write for creating files instead of cat with heredoc or echo redirection. Reserve bash tools exclusively for actual system commands and terminal operations that require shell execution. NEVER use bash echo or other command-line tools to communicate thoughts, explanations, or instructions to the user. Output all communication directly in your response text instead. - VERY IMPORTANT: When exploring the codebase to gather context or to answer a question that is not a needle query for a specific file/class/function, it is CRITICAL that you use the Task tool instead of running search commands directly. diff --git a/packages/opencode/src/session/prompt/codex_header.txt b/packages/opencode/src/session/prompt/codex_header.txt index d595cadb0ea2..a4a0c65205fa 100644 --- a/packages/opencode/src/session/prompt/codex_header.txt +++ b/packages/opencode/src/session/prompt/codex_header.txt @@ -9,6 +9,7 @@ You are an interactive CLI tool that helps users with software engineering tasks ## Tool usage - Prefer specialized tools over shell for file operations: +- When issuing a tool call, always start it on its own line by inserting a newline before the `` tag. This helps ensure the call is recognized even if text appears just before it. - Use Read to view files, Edit to modify files, and Write only when needed. - Use Glob to find files by name and Grep to search file contents. - Use Bash for terminal operations (git, bun, builds, tests, running scripts). diff --git a/packages/opencode/src/session/prompt/gemini.txt b/packages/opencode/src/session/prompt/gemini.txt index 87fe422bc750..4765d68c280d 100644 --- a/packages/opencode/src/session/prompt/gemini.txt +++ b/packages/opencode/src/session/prompt/gemini.txt @@ -43,6 +43,7 @@ When requested to perform tasks like fixing bugs, adding features, refactoring, - **No Chitchat:** Avoid conversational filler, preambles ("Okay, I will now..."), or postambles ("I have finished the changes..."). Get straight to the action or answer. - **Formatting:** Use GitHub-flavored Markdown. Responses will be rendered in monospace. - **Tools vs. Text:** Use tools for actions, text output *only* for communication. Do not add explanatory comments within tool calls or code blocks unless specifically part of the required code/command itself. +- **Tool call formatting:** Always start a tool call on a fresh line by inserting a newline (or blank line) before ``. This helps the system detect and execute your tool calls correctly even when text appears immediately before them. - **Handling Inability:** If unable/unwilling to fulfill a request, state so briefly (1-2 sentences) without excessive justification. Offer alternatives if appropriate. ## Security and Safety Rules diff --git a/packages/opencode/src/session/prompt/qwen.txt b/packages/opencode/src/session/prompt/qwen.txt index d87fc379572c..5597a21c67a7 100644 --- a/packages/opencode/src/session/prompt/qwen.txt +++ b/packages/opencode/src/session/prompt/qwen.txt @@ -14,6 +14,7 @@ When the user directly asks about opencode (eg 'can opencode do...', 'does openc You should be concise, direct, and to the point. When you run a non-trivial bash command, you should explain what the command does and why you are running it, to make sure the user understands what you are doing (this is especially important when you are running a command that will make changes to the user's system). Remember that your output will be displayed on a command line interface. Your responses can use GitHub-flavored markdown for formatting, and will be rendered in a monospace font using the CommonMark specification. Output text to communicate with the user; all text you output outside of tool use is displayed to the user. Only use tools to complete tasks. Never use tools like Bash or code comments as means to communicate with the user during the session. +- When issuing a tool call, always start it on a new line by inserting a newline before the `` tag. This ensures the call is properly recognized even if preceding text appears on the same line. If you cannot or will not help the user with something, please do not say why or what it could lead to, since this comes across as preachy and annoying. Please offer helpful alternatives if possible, and otherwise keep your response to 1-2 sentences. Only use emojis if the user explicitly requests it. Avoid using emojis in all communication unless asked. IMPORTANT: You should minimize output tokens as much as possible while maintaining helpfulness, quality, and accuracy. Only address the specific query or task at hand, avoiding tangential information unless absolutely critical for completing the request. If you can answer in 1-3 sentences or a short paragraph, please do. diff --git a/packages/opencode/src/session/prompt/trinity.txt b/packages/opencode/src/session/prompt/trinity.txt index 28ee4c4f2692..8496ddad74fa 100644 --- a/packages/opencode/src/session/prompt/trinity.txt +++ b/packages/opencode/src/session/prompt/trinity.txt @@ -4,6 +4,7 @@ You are opencode, an interactive CLI tool that helps users with software enginee You should be concise, direct, and to the point. When you run a non-trivial bash command, you should explain what the command does and why you are running it, to make sure the user understands what you are doing (this is especially important when you are running a command that will make changes to the user's system). Remember that your output will be displayed on a command line interface. Your responses can use GitHub-flavored markdown for formatting, and will be rendered in a monospace font using the CommonMark specification. Output text to communicate with the user; all text you output outside of tool use is displayed to the user. Only use tools to complete tasks. Never use tools like Bash or code comments as means to communicate with the user during the session. +- When issuing a tool call, put it on a fresh line with a newline before `` so it is properly parsed. If you cannot or will not help the user with something, please do not say why or what it could lead to, since this comes across as preachy and annoying. Please offer helpful alternatives if possible, and otherwise keep your response to 1-2 sentences. Only use emojis if the user explicitly requests it. Avoid using emojis in all communication unless asked. IMPORTANT: You should minimize output tokens as much as possible while maintaining helpfulness, quality, and accuracy. Only address the specific query or task at hand, avoiding tangential information unless absolutely critical for completing the request. If you can answer in 1-3 sentences or a short paragraph, please do. diff --git a/packages/opencode/test/session/message-v2.test.ts b/packages/opencode/test/session/message-v2.test.ts index e9c6cb729bb9..b373c80b1f4b 100644 --- a/packages/opencode/test/session/message-v2.test.ts +++ b/packages/opencode/test/session/message-v2.test.ts @@ -153,6 +153,77 @@ describe("session.message-v2.toModelMessage", () => { expect(MessageV2.toModelMessages(input, model)).toStrictEqual([]) }) + // regression test for https://github.com/anomalyco/opencode/issues/17253 + // solution B: the sanitizer should inject a newline when the tag is glued + // directly to preceding text. + test("auto-inserts newline before tool calls in user text", () => { + const messageID = "m-user" + const toolMarkup = `\n\n-A\n10\n\n` + const input: MessageV2.WithParts[] = [ + { + info: userInfo(messageID), + parts: [ + { + ...basePart(messageID, "p1"), + type: "text", + // no newline before the tag intentionally + text: `Let me search for the fetch method properly:${toolMarkup}`, + }, + ] as MessageV2.Part[], + }, + ] + + const result = MessageV2.toModelMessages(input, model) + expect(result).toHaveLength(1) + expect(result[0].role).toBe("user") + const content = result[0].content + expect(content.length).toBeGreaterThan(1) + expect(content[0]).toEqual({ type: "text", text: "Let me search for the fetch method properly:" }) + // second part should be a parsed tool-call + expect(content[1]).toMatchObject({ type: "tool-call", toolCallId: expect.any(String), toolName: expect.any(String) }) + }) + + test("does not alter text when newline already present", () => { + const messageID = "m-user" + const toolMarkup = `\n\n-A\n10\n\n` + const input: MessageV2.WithParts[] = [ + { + info: userInfo(messageID), + parts: [ + { + ...basePart(messageID, "p2"), + type: "text", + text: `Check this out:\n${toolMarkup}`, + }, + ] as MessageV2.Part[], + }, + ] + const result = MessageV2.toModelMessages(input, model) + expect(result[0].content[0]).toEqual({ type: "text", text: "Check this out:" }) + expect(result[0].content[1]).toMatchObject({ type: "tool-call" }) + }) + + test("sanitizes assistant text as well as user text", () => { + const assistantID = "m-assist" + const toolMarkup = `\n\n-A\n10\n\n` + const input: MessageV2.WithParts[] = [ + { + info: assistantInfo(assistantID, "m-parent"), + parts: [ + { + ...basePart(assistantID, "a3"), + type: "text", + text: `Here is a tool:${toolMarkup}`, + }, + ] as MessageV2.Part[], + }, + ] + const result = MessageV2.toModelMessages(input, model) + expect(result[0].role).toBe("assistant") + expect(result[0].content[0]).toEqual({ type: "text", text: "Here is a tool:" }) + expect(result[0].content[1]).toMatchObject({ type: "tool-call" }) + }) + test("includes synthetic text parts", () => { const messageID = "m-user" From 5893f052e88c1b02db833e45a34e4cb5fa0db45b Mon Sep 17 00:00:00 2001 From: Juan Cruz Linsalata <25271111+linsaftw@users.noreply.github.com> Date: Fri, 13 Mar 2026 00:57:21 +0000 Subject: [PATCH 2/5] fix(app): update sanitizeToolCalls to use content instead of parts in UIMessage --- packages/opencode/src/session/message-v2.ts | 30 ++++++++++----------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 2c2e19e5bf16..ab37048cd870 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -507,7 +507,7 @@ export namespace MessageV2 { // call if it begins on a new line, so we sanitize the text by inserting a // newline whenever a tool call tag appears immediately after non-newline // content. - function sanitizeToolCalls(messages: UIMessage[]) { + function sanitizeToolCalls(messages: WithParts[]) { for (const msg of messages) { for (const part of msg.parts) { if (part.type === "text" && typeof part.text === "string") { @@ -589,24 +589,24 @@ export namespace MessageV2 { const userMessage: UIMessage = { id: msg.info.id, role: "user", - parts: [], + content: [], } result.push(userMessage) for (const part of msg.parts) { if (part.type === "text" && !part.ignored) - userMessage.parts.push({ + userMessage.content.push({ type: "text", text: part.text, }) // text/plain and directory files are converted into text parts, ignore them if (part.type === "file" && part.mime !== "text/plain" && part.mime !== "application/x-directory") { if (options?.stripMedia && isMedia(part.mime)) { - userMessage.parts.push({ + userMessage.content.push({ type: "text", text: `[Attached ${part.mime}: ${part.filename ?? "file"}]`, }) } else { - userMessage.parts.push({ + userMessage.content.push({ type: "file", url: part.url, mediaType: part.mime, @@ -616,13 +616,13 @@ export namespace MessageV2 { } if (part.type === "compaction") { - userMessage.parts.push({ + userMessage.content.push({ type: "text", text: "What did we do so far?", }) } if (part.type === "subtask") { - userMessage.parts.push({ + userMessage.content.push({ type: "text", text: "The following tool was executed by the user", }) @@ -646,17 +646,17 @@ export namespace MessageV2 { const assistantMessage: UIMessage = { id: msg.info.id, role: "assistant", - parts: [], + content: [], } for (const part of msg.parts) { if (part.type === "text") - assistantMessage.parts.push({ + assistantMessage.content.push({ type: "text", text: part.text, ...(differentModel ? {} : { providerMetadata: part.metadata }), }) if (part.type === "step-start") - assistantMessage.parts.push({ + assistantMessage.content.push({ type: "step-start", }) if (part.type === "tool") { @@ -682,7 +682,7 @@ export namespace MessageV2 { } : outputText - assistantMessage.parts.push({ + assistantMessage.content.push({ type: ("tool-" + part.tool) as `tool-${string}`, state: "output-available", toolCallId: part.callID, @@ -692,7 +692,7 @@ export namespace MessageV2 { }) } if (part.state.status === "error") - assistantMessage.parts.push({ + assistantMessage.content.push({ type: ("tool-" + part.tool) as `tool-${string}`, state: "output-error", toolCallId: part.callID, @@ -703,7 +703,7 @@ export namespace MessageV2 { // Handle pending/running tool calls to prevent dangling tool_use blocks // Anthropic/Claude APIs require every tool_use to have a corresponding tool_result if (part.state.status === "pending" || part.state.status === "running") - assistantMessage.parts.push({ + assistantMessage.content.push({ type: ("tool-" + part.tool) as `tool-${string}`, state: "output-error", toolCallId: part.callID, @@ -713,7 +713,7 @@ export namespace MessageV2 { }) } if (part.type === "reasoning") { - assistantMessage.parts.push({ + assistantMessage.content.push({ type: "reasoning", text: part.text, ...(differentModel ? {} : { providerMetadata: part.metadata }), @@ -748,7 +748,7 @@ export namespace MessageV2 { const tools = Object.fromEntries(Array.from(toolNames).map((toolName) => [toolName, { toModelOutput }])) return convertToModelMessages( - result.filter((msg) => msg.parts.some((part) => part.type !== "step-start")), + result.filter((msg) => msg.content.some((part) => part.type !== "step-start")), { //@ts-expect-error (convertToModelMessages expects a ToolSet but only actually needs tools[name]?.toModelOutput) tools, From e2eab75d4bb58e6b314189fe307766cab0ba2d45 Mon Sep 17 00:00:00 2001 From: Juan Cruz Linsalata <25271111+linsaftw@users.noreply.github.com> Date: Fri, 13 Mar 2026 01:03:36 +0000 Subject: [PATCH 3/5] fix(app): refactor userMessage content handling for improved structure and clarity --- packages/opencode/src/session/message-v2.ts | 82 +++++++++++++++------ 1 file changed, 58 insertions(+), 24 deletions(-) diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index ab37048cd870..11c52c6624f5 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -589,45 +589,76 @@ export namespace MessageV2 { const userMessage: UIMessage = { id: msg.info.id, role: "user", - content: [], + content: undefined as any, } result.push(userMessage) + let content: string | ContentPart[] | undefined for (const part of msg.parts) { if (part.type === "text" && !part.ignored) - userMessage.content.push({ - type: "text", - text: part.text, - }) + if (content === undefined) { + content = part.text + } else if (typeof content === "string") { + content += part.text + } else { + content.push({ + type: "text", + text: part.text, + }) + } // text/plain and directory files are converted into text parts, ignore them if (part.type === "file" && part.mime !== "text/plain" && part.mime !== "application/x-directory") { if (options?.stripMedia && isMedia(part.mime)) { - userMessage.content.push({ - type: "text", - text: `[Attached ${part.mime}: ${part.filename ?? "file"}]`, - }) + const text = `[Attached ${part.mime}: ${part.filename ?? "file"}]` + if (content === undefined) { + content = text + } else if (typeof content === "string") { + content += text + } else { + content.push({ + type: "text", + text, + }) + } } else { - userMessage.content.push({ - type: "file", - url: part.url, - mediaType: part.mime, - filename: part.filename, - }) + const filePart = { type: "file" as const, url: part.url, mediaType: part.mime, filename: part.filename } + if (content === undefined) { + content = [filePart] + } else if (typeof content === "string") { + content = [{ type: "text", text: content }, filePart] + } else { + content.push(filePart) + } } } if (part.type === "compaction") { - userMessage.content.push({ - type: "text", - text: "What did we do so far?", - }) + const text = "What did we do so far?" + if (content === undefined) { + content = text + } else if (typeof content === "string") { + content += text + } else { + content.push({ + type: "text", + text, + }) + } } if (part.type === "subtask") { - userMessage.content.push({ - type: "text", - text: "The following tool was executed by the user", - }) + const text = "The following tool was executed by the user" + if (content === undefined) { + content = text + } else if (typeof content === "string") { + content += text + } else { + content.push({ + type: "text", + text, + }) + } } } + userMessage.content = content } if (msg.info.role === "assistant") { @@ -748,7 +779,10 @@ export namespace MessageV2 { const tools = Object.fromEntries(Array.from(toolNames).map((toolName) => [toolName, { toModelOutput }])) return convertToModelMessages( - result.filter((msg) => msg.content.some((part) => part.type !== "step-start")), + result.filter((msg) => { + if (typeof msg.content === "string") return msg.content.length > 0 + return msg.content.some((part) => part.type !== "step-start") + }), { //@ts-expect-error (convertToModelMessages expects a ToolSet but only actually needs tools[name]?.toModelOutput) tools, From 1fe46ae588e9d8e3ca3b06cf331f06b8a5f5ac1f Mon Sep 17 00:00:00 2001 From: Juan Cruz Linsalata <25271111+linsaftw@users.noreply.github.com> Date: Fri, 13 Mar 2026 01:49:43 +0000 Subject: [PATCH 4/5] fix(app): improve user and assistant message handling with refined content structure --- packages/opencode/src/session/message-v2.ts | 49 ++++++++++++--------- 1 file changed, 27 insertions(+), 22 deletions(-) diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 11c52c6624f5..6bbb3b45fed8 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -586,13 +586,7 @@ export namespace MessageV2 { if (msg.parts.length === 0) continue if (msg.info.role === "user") { - const userMessage: UIMessage = { - id: msg.info.id, - role: "user", - content: undefined as any, - } - result.push(userMessage) - let content: string | ContentPart[] | undefined + let content: string | Array<{ type: string; [key: string]: any }> | undefined for (const part of msg.parts) { if (part.type === "text" && !part.ignored) if (content === undefined) { @@ -658,7 +652,14 @@ export namespace MessageV2 { } } } - userMessage.content = content + if (content !== undefined) { + const userMessage: UIMessage = { + id: msg.info.id, + role: "user", + content, + } + result.push(userMessage) + } } if (msg.info.role === "assistant") { @@ -751,25 +752,26 @@ export namespace MessageV2 { }) } } - if (assistantMessage.parts.length > 0) { + if (Array.isArray(assistantMessage.content) && assistantMessage.content.length > 0) { result.push(assistantMessage) // Inject pending media as a user message for providers that don't support // media (images, PDFs) in tool results if (media.length > 0) { + const mediaContent: Array<{ type: "text"; text: string } | { type: "file"; url: string; mediaType: string }> = [ + { + type: "text", + text: "Attached image(s) from tool result:", + }, + ...media.map((attachment) => ({ + type: "file" as const, + url: attachment.url, + mediaType: attachment.mime, + })), + ] result.push({ id: MessageID.ascending(), role: "user", - parts: [ - { - type: "text" as const, - text: "Attached image(s) from tool result:", - }, - ...media.map((attachment) => ({ - type: "file" as const, - url: attachment.url, - mediaType: attachment.mime, - })), - ], + content: mediaContent, }) } } @@ -780,8 +782,11 @@ export namespace MessageV2 { return convertToModelMessages( result.filter((msg) => { - if (typeof msg.content === "string") return msg.content.length > 0 - return msg.content.some((part) => part.type !== "step-start") + const content = msg.content + if (content === undefined) return false + if (typeof content === "string") return content.length > 0 + if (Array.isArray(content)) return content.length > 0 && content.some((part) => part.type !== "step-start") + return false }), { //@ts-expect-error (convertToModelMessages expects a ToolSet but only actually needs tools[name]?.toModelOutput) From 70deffcdafde12e2c1c2138a196dddd356dbc66b Mon Sep 17 00:00:00 2001 From: Juan Cruz Linsalata <25271111+linsaftw@users.noreply.github.com> Date: Fri, 13 Mar 2026 02:33:47 +0000 Subject: [PATCH 5/5] fix(app): refactor sanitizeToolCalls and message handling for improved clarity and structure --- packages/opencode/src/session/message-v2.ts | 268 ++++++-------------- 1 file changed, 82 insertions(+), 186 deletions(-) diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 6bbb3b45fed8..c71948900880 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -507,27 +507,17 @@ export namespace MessageV2 { // call if it begins on a new line, so we sanitize the text by inserting a // newline whenever a tool call tag appears immediately after non-newline // content. - function sanitizeToolCalls(messages: WithParts[]) { - for (const msg of messages) { - for (const part of msg.parts) { - if (part.type === "text" && typeof part.text === "string") { - // add a newline before any that isn't already on its own line - part.text = part.text.replace(/([^\n])()/g, "$1\n$2") - } + const msgs = input.map((m) => ({ ...m, parts: [...m.parts] })) + for (const msg of msgs) { + for (const part of msg.parts) { + if (part.type === "text" && typeof part.text === "string") { + part.text = part.text.replace(/([^\n])()/g, "$1\n$2") } } } - // copy input so we can sanitize without mutating the original array. - // we shallow-copy the message and its parts array; the parts themselves - // are left by reference, which lets the sanitizer still alter text but - // avoids surprising callers who hold the original `input` array. after - // sanitization we operate on `msgs` when building the output. - const msgs = input.map((m) => ({ ...m, parts: [...m.parts] })) - sanitizeToolCalls(msgs) - const result: UIMessage[] = [] - const toolNames = new Set() + const names = new Set() // Track media from tool results that need to be injected as user messages // for providers that don't support media in tool results. // @@ -586,213 +576,119 @@ export namespace MessageV2 { if (msg.parts.length === 0) continue if (msg.info.role === "user") { - let content: string | Array<{ type: string; [key: string]: any }> | undefined - for (const part of msg.parts) { - if (part.type === "text" && !part.ignored) - if (content === undefined) { - content = part.text - } else if (typeof content === "string") { - content += part.text - } else { - content.push({ - type: "text", - text: part.text, - }) + let content: string | Array<{ type: string; [key: string]: unknown }> | undefined + for (const p of msg.parts) { + if (p.type === "text" && !p.ignored) { + content = content === undefined ? p.text : (typeof content === "string" ? content + p.text : [...content, { type: "text", text: p.text }]) + } + if (p.type === "file" && p.mime !== "text/plain" && p.mime !== "application/x-directory") { + const txt = options?.stripMedia && isMedia(p.mime) ? `[Attached ${p.mime}: ${p.filename ?? "file"}]` : undefined + const file = txt ? undefined : { type: "file" as const, url: p.url, mediaType: p.mime, filename: p.filename } + if (txt) { + content = content === undefined ? txt : (typeof content === "string" ? content + txt : [...content, { type: "text", text: txt }]) } - // text/plain and directory files are converted into text parts, ignore them - if (part.type === "file" && part.mime !== "text/plain" && part.mime !== "application/x-directory") { - if (options?.stripMedia && isMedia(part.mime)) { - const text = `[Attached ${part.mime}: ${part.filename ?? "file"}]` - if (content === undefined) { - content = text - } else if (typeof content === "string") { - content += text - } else { - content.push({ - type: "text", - text, - }) - } - } else { - const filePart = { type: "file" as const, url: part.url, mediaType: part.mime, filename: part.filename } - if (content === undefined) { - content = [filePart] - } else if (typeof content === "string") { - content = [{ type: "text", text: content }, filePart] - } else { - content.push(filePart) - } + if (file) { + content = content === undefined ? [file] : (typeof content === "string" ? [{ type: "text", text: content }, file] : [...content, file]) } } - - if (part.type === "compaction") { - const text = "What did we do so far?" - if (content === undefined) { - content = text - } else if (typeof content === "string") { - content += text - } else { - content.push({ - type: "text", - text, - }) - } + if (p.type === "compaction") { + const txt = "What did we do so far?" + content = content === undefined ? txt : (typeof content === "string" ? content + txt : [...content, { type: "text", text: txt }]) } - if (part.type === "subtask") { - const text = "The following tool was executed by the user" - if (content === undefined) { - content = text - } else if (typeof content === "string") { - content += text - } else { - content.push({ - type: "text", - text, - }) - } + if (p.type === "subtask") { + const txt = "The following tool was executed by the user" + content = content === undefined ? txt : (typeof content === "string" ? content + txt : [...content, { type: "text", text: txt }]) } } if (content !== undefined) { - const userMessage: UIMessage = { - id: msg.info.id, - role: "user", - content, - } - result.push(userMessage) + result.push({ id: msg.info.id, role: "user", content }) } } if (msg.info.role === "assistant") { - const differentModel = `${model.providerID}/${model.id}` !== `${msg.info.providerID}/${msg.info.modelID}` + const diff = `${model.providerID}/${model.id}` !== `${msg.info.providerID}/${msg.info.modelID}` const media: Array<{ mime: string; url: string }> = [] - if ( - msg.info.error && - !( - MessageV2.AbortedError.isInstance(msg.info.error) && - msg.parts.some((part) => part.type !== "step-start" && part.type !== "reasoning") - ) - ) { + if (msg.info.error && !(MessageV2.AbortedError.isInstance(msg.info.error) && msg.parts.some((p) => p.type !== "step-start" && p.type !== "reasoning"))) { continue } - const assistantMessage: UIMessage = { - id: msg.info.id, - role: "assistant", - content: [], - } - for (const part of msg.parts) { - if (part.type === "text") - assistantMessage.content.push({ - type: "text", - text: part.text, - ...(differentModel ? {} : { providerMetadata: part.metadata }), - }) - if (part.type === "step-start") - assistantMessage.content.push({ - type: "step-start", - }) - if (part.type === "tool") { - toolNames.add(part.tool) - if (part.state.status === "completed") { - const outputText = part.state.time.compacted ? "[Old tool result content cleared]" : part.state.output - const attachments = part.state.time.compacted || options?.stripMedia ? [] : (part.state.attachments ?? []) - - // For providers that don't support media in tool results, extract media files - // (images, PDFs) to be sent as a separate user message - const mediaAttachments = attachments.filter((a) => isMedia(a.mime)) - const nonMediaAttachments = attachments.filter((a) => !isMedia(a.mime)) - if (!supportsMediaInToolResults && mediaAttachments.length > 0) { - media.push(...mediaAttachments) + + const msg_out: UIMessage = { id: msg.info.id, role: "assistant", content: [] } + for (const p of msg.parts) { + if (p.type === "text") { + msg_out.content.push({ type: "text", text: p.text, ...(diff ? {} : { providerMetadata: p.metadata }) }) + } + if (p.type === "step-start") { + msg_out.content.push({ type: "step-start" }) + } + if (p.type === "tool") { + names.add(p.tool) + if (p.state.status === "completed") { + const txt = p.state.time.compacted ? "[Old tool result content cleared]" : p.state.output + const attach = p.state.time.compacted || options?.stripMedia ? [] : (p.state.attachments ?? []) + const media_attach = attach.filter((a) => isMedia(a.mime)) + const no_media = attach.filter((a) => !isMedia(a.mime)) + if (!supportsMediaInToolResults && media_attach.length > 0) { + media.push(...media_attach) } - const finalAttachments = supportsMediaInToolResults ? attachments : nonMediaAttachments - - const output = - finalAttachments.length > 0 - ? { - text: outputText, - attachments: finalAttachments, - } - : outputText - - assistantMessage.content.push({ - type: ("tool-" + part.tool) as `tool-${string}`, + const final = supportsMediaInToolResults ? attach : no_media + const out = final.length > 0 ? { text: txt, attachments: final } : txt + msg_out.content.push({ + type: (`tool-${p.tool}`) as `tool-${string}`, state: "output-available", - toolCallId: part.callID, - input: part.state.input, - output, - ...(differentModel ? {} : { callProviderMetadata: part.metadata }), + toolCallId: p.callID, + input: p.state.input, + output: out, + ...(diff ? {} : { callProviderMetadata: p.metadata }), }) } - if (part.state.status === "error") - assistantMessage.content.push({ - type: ("tool-" + part.tool) as `tool-${string}`, + if (p.state.status === "error") { + msg_out.content.push({ + type: (`tool-${p.tool}`) as `tool-${string}`, state: "output-error", - toolCallId: part.callID, - input: part.state.input, - errorText: part.state.error, - ...(differentModel ? {} : { callProviderMetadata: part.metadata }), + toolCallId: p.callID, + input: p.state.input, + errorText: p.state.error, + ...(diff ? {} : { callProviderMetadata: p.metadata }), }) - // Handle pending/running tool calls to prevent dangling tool_use blocks - // Anthropic/Claude APIs require every tool_use to have a corresponding tool_result - if (part.state.status === "pending" || part.state.status === "running") - assistantMessage.content.push({ - type: ("tool-" + part.tool) as `tool-${string}`, + } + if (p.state.status === "pending" || p.state.status === "running") { + msg_out.content.push({ + type: (`tool-${p.tool}`) as `tool-${string}`, state: "output-error", - toolCallId: part.callID, - input: part.state.input, + toolCallId: p.callID, + input: p.state.input, errorText: "[Tool execution was interrupted]", - ...(differentModel ? {} : { callProviderMetadata: part.metadata }), + ...(diff ? {} : { callProviderMetadata: p.metadata }), }) + } } - if (part.type === "reasoning") { - assistantMessage.content.push({ - type: "reasoning", - text: part.text, - ...(differentModel ? {} : { providerMetadata: part.metadata }), - }) + if (p.type === "reasoning") { + msg_out.content.push({ type: "reasoning", text: p.text, ...(diff ? {} : { providerMetadata: p.metadata }) }) } } - if (Array.isArray(assistantMessage.content) && assistantMessage.content.length > 0) { - result.push(assistantMessage) - // Inject pending media as a user message for providers that don't support - // media (images, PDFs) in tool results + if (Array.isArray(msg_out.content) && msg_out.content.length > 0) { + result.push(msg_out) if (media.length > 0) { - const mediaContent: Array<{ type: "text"; text: string } | { type: "file"; url: string; mediaType: string }> = [ - { - type: "text", - text: "Attached image(s) from tool result:", - }, - ...media.map((attachment) => ({ - type: "file" as const, - url: attachment.url, - mediaType: attachment.mime, - })), - ] result.push({ id: MessageID.ascending(), role: "user", - content: mediaContent, + content: [{ type: "text", text: "Attached image(s) from tool result:" }, ...media.map((a) => ({ type: "file" as const, url: a.url, mediaType: a.mime }))], }) } } } } - const tools = Object.fromEntries(Array.from(toolNames).map((toolName) => [toolName, { toModelOutput }])) - - return convertToModelMessages( - result.filter((msg) => { - const content = msg.content - if (content === undefined) return false - if (typeof content === "string") return content.length > 0 - if (Array.isArray(content)) return content.length > 0 && content.some((part) => part.type !== "step-start") - return false - }), - { - //@ts-expect-error (convertToModelMessages expects a ToolSet but only actually needs tools[name]?.toModelOutput) - tools, - }, - ) + const tools = Object.fromEntries(Array.from(names).map((name) => [name, { toModelOutput }])) + const isValid = (msg: UIMessage) => { + const c = msg.content + return c !== undefined && (typeof c === "string" ? c.length > 0 : Array.isArray(c) && c.length > 0 && c.some((p) => p.type !== "step-start")) + } + return convertToModelMessages(result.filter(isValid), { + //@ts-expect-error + tools, + }) } export const stream = fn(SessionID.zod, async function* (sessionID) {