From c0763a4d1bbcc99209f8d4313363a30c6dd5953c Mon Sep 17 00:00:00 2001 From: Mert Can Demir Date: Fri, 30 Jan 2026 01:48:25 +0300 Subject: [PATCH 1/3] fix: update tool title handling in ACP agent to improve command display --- packages/opencode/src/acp/agent.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/acp/agent.ts b/packages/opencode/src/acp/agent.ts index cc9a029a04..1878e54712 100644 --- a/packages/opencode/src/acp/agent.ts +++ b/packages/opencode/src/acp/agent.ts @@ -46,6 +46,13 @@ type ModelOption = { modelId: string; name: string } const DEFAULT_VARIANT_VALUE = "default" +function toolTitle(part: MessageV2.ToolPart): string { + const state = part.state as MessageV2.ToolStateCompleted + if (part.tool !== "bash") return state.title + const cmd = state.input["command"] + return typeof cmd === "string" ? cmd : state.title +} + export namespace ACP { const log = Log.create({ service: "acp-agent" }) @@ -321,7 +328,7 @@ export namespace ACP { status: "completed", kind, content, - title: part.state.title, + title: toolTitle(part), rawInput: part.state.input, rawOutput: { output: part.state.output, @@ -803,7 +810,7 @@ export namespace ACP { status: "completed", kind, content, - title: part.state.title, + title: toolTitle(part), rawInput: part.state.input, rawOutput: { output: part.state.output, From 47608a18876d1604a32e76bc1f65df984e2dda3c Mon Sep 17 00:00:00 2001 From: Mert Can Demir Date: Fri, 30 Jan 2026 13:48:54 +0300 Subject: [PATCH 2/3] refactor: streamline tool call handling in ACP agent and introduce parsing utilities --- packages/opencode/src/acp/agent.ts | 357 +++++--------- packages/opencode/src/acp/parse-command.ts | 94 ++++ packages/opencode/src/acp/tool-format.ts | 452 ++++++++++++++++++ .../opencode/test/acp/parse-command.test.ts | 113 +++++ 4 files changed, 783 insertions(+), 233 deletions(-) create mode 100644 packages/opencode/src/acp/parse-command.ts create mode 100644 packages/opencode/src/acp/tool-format.ts create mode 100644 packages/opencode/test/acp/parse-command.test.ts diff --git a/packages/opencode/src/acp/agent.ts b/packages/opencode/src/acp/agent.ts index 1878e54712..35614a6cce 100644 --- a/packages/opencode/src/acp/agent.ts +++ b/packages/opencode/src/acp/agent.ts @@ -23,8 +23,6 @@ import { type SetSessionModelRequest, type SetSessionModeRequest, type SetSessionModeResponse, - type ToolCallContent, - type ToolKind, } from "@agentclientprotocol/sdk" import { Log } from "../util/log" @@ -40,19 +38,13 @@ import { z } from "zod" import { LoadAPIKeyError } from "ai" import type { Event, OpencodeClient, SessionMessageResponse } from "@opencode-ai/sdk/v2" import { applyPatch } from "diff" +import { toolCallFromPart, toolResultFromPart } from "./tool-format" type ModeOption = { id: string; name: string; description?: string } type ModelOption = { modelId: string; name: string } const DEFAULT_VARIANT_VALUE = "default" -function toolTitle(part: MessageV2.ToolPart): string { - const state = part.state as MessageV2.ToolStateCompleted - if (part.tool !== "bash") return state.title - const cmd = state.input["command"] - return typeof cmd === "string" ? cmd : state.title -} - export namespace ACP { const log = Log.create({ service: "acp-agent" }) @@ -72,6 +64,7 @@ export namespace ACP { private eventAbort = new AbortController() private eventStarted = false private permissionQueues = new Map>() + private emittedToolCalls = new Set() private permissionOptions: PermissionOption[] = [ { optionId: "once", kind: "allow_once", name: "Allow once" }, { optionId: "always", kind: "allow_always", name: "Always allow" }, @@ -124,16 +117,17 @@ export namespace ACP { .then(async () => { const directory = session.cwd + const permissionInfo = toolCallFromPart(permission.permission, permission.metadata ?? {}) const res = await this.connection .requestPermission({ sessionId: permission.sessionID, toolCall: { toolCallId: permission.tool?.callID ?? permission.id, status: "pending", - title: permission.permission, - rawInput: permission.metadata, - kind: toToolKind(permission.permission), - locations: toLocations(permission.permission, permission.metadata), + title: permissionInfo.title, + rawInput: permissionInfo.rawInput, + kind: permissionInfo.kind, + locations: permissionInfo.locations, }, options: this.permissionOptions, }) @@ -225,72 +219,38 @@ export namespace ACP { if (part.type === "tool") { switch (part.state.status) { case "pending": - await this.connection - .sessionUpdate({ - sessionId, - update: { - sessionUpdate: "tool_call", - toolCallId: part.callID, - title: part.tool, - kind: toToolKind(part.tool), - status: "pending", - locations: [], - rawInput: {}, - }, - }) - .catch((error) => { - log.error("failed to send tool pending to ACP", { error }) - }) return - case "running": - await this.connection - .sessionUpdate({ - sessionId, - update: { - sessionUpdate: "tool_call_update", - toolCallId: part.callID, - status: "in_progress", - kind: toToolKind(part.tool), - title: part.tool, - locations: toLocations(part.tool, part.state.input), - rawInput: part.state.input, - }, - }) - .catch((error) => { - log.error("failed to send tool in_progress to ACP", { error }) - }) + case "running": { + const toolCallId = part.callID + const info = toolCallFromPart(part.tool, part.state.input) + + if (!this.emittedToolCalls.has(toolCallId)) { + this.emittedToolCalls.add(toolCallId) + await this.connection + .sessionUpdate({ + sessionId, + update: { + sessionUpdate: "tool_call", + toolCallId, + title: info.title, + kind: info.kind, + status: "in_progress", + locations: info.locations, + rawInput: info.rawInput, + }, + }) + .catch((error) => { + log.error("failed to send tool_call to ACP", { error }) + }) + } return + } case "completed": { - const kind = toToolKind(part.tool) - const content: ToolCallContent[] = [ - { - type: "content", - content: { - type: "text", - text: part.state.output, - }, - }, - ] - - if (kind === "edit") { - const input = part.state.input - const filePath = typeof input["filePath"] === "string" ? input["filePath"] : "" - const oldText = typeof input["oldString"] === "string" ? input["oldString"] : "" - const newText = - typeof input["newString"] === "string" - ? input["newString"] - : typeof input["content"] === "string" - ? input["content"] - : "" - content.push({ - type: "diff", - path: filePath, - oldText, - newText, - }) - } + const input = part.state.input + const info = toolCallFromPart(part.tool, input) + const result = toolResultFromPart(part.tool, input, part.state.output, false) if (part.tool === "todowrite") { const parsedTodos = z.array(Todo.Info).safeParse(JSON.parse(part.state.output)) @@ -319,6 +279,8 @@ export namespace ACP { } } + this.emittedToolCalls.delete(part.callID) + await this.connection .sessionUpdate({ sessionId, @@ -326,14 +288,7 @@ export namespace ACP { sessionUpdate: "tool_call_update", toolCallId: part.callID, status: "completed", - kind, - content, - title: toolTitle(part), - rawInput: part.state.input, - rawOutput: { - output: part.state.output, - metadata: part.state.metadata, - }, + rawOutput: result.rawOutput, }, }) .catch((error) => { @@ -341,7 +296,12 @@ export namespace ACP { }) return } - case "error": + case "error": { + const input = part.state.input + const result = toolResultFromPart(part.tool, input, part.state.error, true) + + this.emittedToolCalls.delete(part.callID) + await this.connection .sessionUpdate({ sessionId, @@ -349,27 +309,14 @@ export namespace ACP { sessionUpdate: "tool_call_update", toolCallId: part.callID, status: "failed", - kind: toToolKind(part.tool), - title: part.tool, - rawInput: part.state.input, - content: [ - { - type: "content", - content: { - type: "text", - text: part.state.error, - }, - }, - ], - rawOutput: { - error: part.state.error, - }, + rawOutput: result.rawOutput, }, }) .catch((error) => { log.error("failed to send tool error to ACP", { error }) }) return + } } } @@ -707,72 +654,58 @@ export namespace ACP { for (const part of message.parts) { if (part.type === "tool") { + const toolCallId = part.callID + const input = part.state.input + const info = toolCallFromPart(part.tool, input) + switch (part.state.status) { case "pending": - await this.connection - .sessionUpdate({ - sessionId, - update: { - sessionUpdate: "tool_call", - toolCallId: part.callID, - title: part.tool, - kind: toToolKind(part.tool), - status: "pending", - locations: [], - rawInput: {}, - }, - }) - .catch((err) => { - log.error("failed to send tool pending to ACP", { error: err }) - }) break - case "running": - await this.connection - .sessionUpdate({ - sessionId, - update: { - sessionUpdate: "tool_call_update", - toolCallId: part.callID, - status: "in_progress", - kind: toToolKind(part.tool), - title: part.tool, - locations: toLocations(part.tool, part.state.input), - rawInput: part.state.input, - }, - }) - .catch((err) => { - log.error("failed to send tool in_progress to ACP", { error: err }) - }) - break - case "completed": - const kind = toToolKind(part.tool) - const content: ToolCallContent[] = [ - { - type: "content", - content: { - type: "text", - text: part.state.output, - }, - }, - ] - - if (kind === "edit") { - const input = part.state.input - const filePath = typeof input["filePath"] === "string" ? input["filePath"] : "" - const oldText = typeof input["oldString"] === "string" ? input["oldString"] : "" - const newText = - typeof input["newString"] === "string" - ? input["newString"] - : typeof input["content"] === "string" - ? input["content"] - : "" - content.push({ - type: "diff", - path: filePath, - oldText, - newText, - }) + case "running": { + if (!this.emittedToolCalls.has(toolCallId)) { + this.emittedToolCalls.add(toolCallId) + await this.connection + .sessionUpdate({ + sessionId, + update: { + sessionUpdate: "tool_call", + toolCallId, + title: info.title, + kind: info.kind, + status: "in_progress", + locations: info.locations, + rawInput: info.rawInput, + }, + }) + .catch((err) => { + log.error("failed to send tool_call to ACP", { error: err }) + }) } + break + } + case "completed": { + if (!this.emittedToolCalls.has(toolCallId)) { + this.emittedToolCalls.add(toolCallId) + await this.connection + .sessionUpdate({ + sessionId, + update: { + sessionUpdate: "tool_call", + toolCallId, + title: info.title, + kind: info.kind, + status: "in_progress", + locations: info.locations, + rawInput: info.rawInput, + }, + }) + .catch((err) => { + log.error("failed to send tool_call to ACP", { error: err }) + }) + } + this.emittedToolCalls.delete(toolCallId) + + const result = toolResultFromPart(part.tool, input, part.state.output, false) if (part.tool === "todowrite") { const parsedTodos = z.array(Todo.Info).safeParse(JSON.parse(part.state.output)) @@ -806,51 +739,56 @@ export namespace ACP { sessionId, update: { sessionUpdate: "tool_call_update", - toolCallId: part.callID, + toolCallId, status: "completed", - kind, - content, - title: toolTitle(part), - rawInput: part.state.input, - rawOutput: { - output: part.state.output, - metadata: part.state.metadata, - }, + rawOutput: result.rawOutput, }, }) .catch((err) => { log.error("failed to send tool completed to ACP", { error: err }) }) break - case "error": + } + case "error": { + if (!this.emittedToolCalls.has(toolCallId)) { + this.emittedToolCalls.add(toolCallId) + await this.connection + .sessionUpdate({ + sessionId, + update: { + sessionUpdate: "tool_call", + toolCallId, + title: info.title, + kind: info.kind, + status: "in_progress", + locations: info.locations, + rawInput: info.rawInput, + }, + }) + .catch((err) => { + log.error("failed to send tool_call to ACP", { error: err }) + }) + } + + this.emittedToolCalls.delete(toolCallId) + + const result = toolResultFromPart(part.tool, input, part.state.error, true) + await this.connection .sessionUpdate({ sessionId, update: { sessionUpdate: "tool_call_update", - toolCallId: part.callID, + toolCallId, status: "failed", - kind: toToolKind(part.tool), - title: part.tool, - rawInput: part.state.input, - content: [ - { - type: "content", - content: { - type: "text", - text: part.state.error, - }, - }, - ], - rawOutput: { - error: part.state.error, - }, + rawOutput: result.rawOutput, }, }) .catch((err) => { log.error("failed to send tool error to ACP", { error: err }) }) break + } } } else if (part.type === "text") { if (part.text) { @@ -1310,53 +1248,6 @@ export namespace ACP { } } - function toToolKind(toolName: string): ToolKind { - const tool = toolName.toLocaleLowerCase() - switch (tool) { - case "bash": - return "execute" - case "webfetch": - return "fetch" - - case "edit": - case "patch": - case "write": - return "edit" - - case "grep": - case "glob": - case "context7_resolve_library_id": - case "context7_get_library_docs": - return "search" - - case "list": - case "read": - return "read" - - default: - return "other" - } - } - - function toLocations(toolName: string, input: Record): { path: string }[] { - const tool = toolName.toLocaleLowerCase() - switch (tool) { - case "read": - case "edit": - case "write": - return input["filePath"] ? [{ path: input["filePath"] }] : [] - case "glob": - case "grep": - return input["path"] ? [{ path: input["path"] }] : [] - case "bash": - return [] - case "list": - return input["path"] ? [{ path: input["path"] }] : [] - default: - return [] - } - } - async function defaultModel(config: ACPConfig, cwd?: string) { const sdk = config.sdk const configured = config.defaultModel diff --git a/packages/opencode/src/acp/parse-command.ts b/packages/opencode/src/acp/parse-command.ts new file mode 100644 index 0000000000..907ae537b3 --- /dev/null +++ b/packages/opencode/src/acp/parse-command.ts @@ -0,0 +1,94 @@ +import type { ToolKind } from "@agentclientprotocol/sdk" + +export namespace ParseCommand { + export type Parsed = + | { type: "read"; cmd: string; name: string; path: string } + | { type: "list"; cmd: string; path?: string } + | { type: "search"; cmd: string; query?: string; path?: string } + | { type: "unknown"; cmd: string } + + export interface Result { + kind: ToolKind + title: string + locations: { path: string }[] + terminalOutput: boolean + } + + const LIST_COMMANDS = new Set(["ls", "dir", "exa", "eza", "tree", "lsd"]) + const READ_COMMANDS = new Set(["cat", "head", "tail", "less", "more", "bat", "view"]) + const SEARCH_COMMANDS = new Set(["grep", "rg", "ag", "ack", "find", "fd", "fzf", "locate"]) + + export function parse(command: string): Parsed { + const trimmed = command.trim() + const parts = trimmed.split(/\s+/).filter(Boolean) + const cmd = parts[0] || "" + const args = parts.slice(1).filter((p) => !p.startsWith("-")) + + if (LIST_COMMANDS.has(cmd)) { + return { type: "list", cmd, path: args[0] } + } + + if (READ_COMMANDS.has(cmd)) { + if (args[0]) { + const name = args[0].split("/").pop() || args[0] + return { type: "read", cmd, name, path: args[0] } + } + return { type: "unknown", cmd: trimmed } + } + + if (SEARCH_COMMANDS.has(cmd)) { + return { type: "search", cmd, query: args[0], path: args[1] } + } + + return { type: "unknown", cmd: trimmed } + } + + export function format(parsed: Parsed, cwd: string): Result { + switch (parsed.type) { + case "read": + return { + kind: "read", + title: `Read ${parsed.name}`, + locations: [{ path: parsed.path }], + terminalOutput: false, + } + + case "list": { + const dir = parsed.path ? (parsed.path.startsWith("/") ? parsed.path : `${cwd}/${parsed.path}`) : cwd || "." + return { + kind: "search", + title: `List ${dir}`, + locations: [{ path: dir }], + terminalOutput: false, + } + } + + case "search": { + const title = + parsed.query && parsed.path + ? `Search ${parsed.query} in ${parsed.path}` + : parsed.query + ? `Search ${parsed.query}` + : `Search ${parsed.cmd}` + return { + kind: "search", + title: truncate(title, 50), + locations: parsed.path ? [{ path: parsed.path }] : [], + terminalOutput: false, + } + } + + case "unknown": + return { + kind: "execute", + title: `Run ${truncate(parsed.cmd, 40)}`, + locations: [], + terminalOutput: true, + } + } + } + + function truncate(str: string, max: number): string { + return str.length > max ? str.substring(0, max - 3) + "..." : str + } +} diff --git a/packages/opencode/src/acp/tool-format.ts b/packages/opencode/src/acp/tool-format.ts new file mode 100644 index 0000000000..eaf81fde22 --- /dev/null +++ b/packages/opencode/src/acp/tool-format.ts @@ -0,0 +1,452 @@ +import type { ToolCallContent, ToolKind } from "@agentclientprotocol/sdk" +import { ParseCommand } from "./parse-command" + +export interface ToolCallInfo { + title: string + kind: ToolKind + content: ToolCallContent[] + locations: { path: string; line?: number }[] + rawInput: unknown +} + +export interface ToolResultInfo { + content: ToolCallContent[] + rawOutput: unknown + title?: string +} + +function normalize(name: string): string { + return name.toLowerCase().replace(/^mcp__acp__/, "") +} + +function truncate(str: string, max: number): string { + return str.length > max ? str.substring(0, max - 3) + "..." : str +} + +function escapeBackticks(str: string): string { + return str.replaceAll("`", "\\`") +} + +function wrapBackticks(str: string): string { + return "`" + escapeBackticks(str) + "`" +} + +function markdownEscape(text: string): string { + let fence = "```" + for (const match of text.matchAll(/^`{3,}/gm)) { + while (match[0].length >= fence.length) fence += "`" + } + return fence + "\n" + text + (text.endsWith("\n") ? "" : "\n") + fence +} + +function textContent(text: string): ToolCallContent { + return { type: "content", content: { type: "text", text } } +} + +function diffContent(path: string, oldText: string | null, newText: string): ToolCallContent { + return { type: "diff", path, oldText, newText } +} + +function str(v: unknown): string { + return typeof v === "string" ? v : "" +} + +function num(v: unknown): number | undefined { + return typeof v === "number" ? v : undefined +} + +function getFilePath(input: Record): string { + return str(input.filePath ?? input.file_path ?? input.filepath ?? input.path) +} + +function getOldString(input: Record): string { + return str(input.oldString ?? input.old_string) +} + +function getNewString(input: Record): string { + return str(input.newString ?? input.new_string ?? input.content ?? input.new_content) +} + +function getCommand(input: Record): string { + return str(input.command ?? input.cmd) +} + +function getDescription(input: Record): string { + return str(input.description ?? input.desc) +} + +function getPattern(input: Record): string { + return str(input.pattern ?? input.filePattern ?? input.glob) +} + +function getQuery(input: Record): string { + return str(input.query ?? input.q) +} + +function getUrl(input: Record): string { + return str(input.url ?? input.uri) +} + +function getDiff(input: Record): string { + return str(input.diff ?? input.patch ?? input.unifiedDiff) +} + +export function toolCallFromPart(tool: string, input: Record): ToolCallInfo { + const name = normalize(tool) + + switch (name) { + case "bash": + case "shell": + case "terminal": { + const command = getCommand(input) + const cwd = str(input.cwd ?? input.workdir ?? input.workingDir ?? input.directory) + const parsed = ParseCommand.parse(command) + const result = ParseCommand.format(parsed, cwd) + return { + title: result.title, + kind: result.kind, + content: [], + locations: result.locations, + rawInput: input, + } + } + + case "bashoutput": { + return { + title: "Tail Logs", + kind: "execute", + content: [], + locations: [], + rawInput: input, + } + } + + + case "read": + case "view": { + const filePath = getFilePath(input) + const offset = num(input.offset) ?? num(input.line) ?? 0 + const limit = num(input.limit) ?? 0 + let suffix = "" + if (limit) { + suffix = ` (${offset + 1} - ${offset + limit})` + } else if (offset) { + suffix = ` (from line ${offset + 1})` + } + return { + title: filePath ? `Read ${filePath}${suffix}` : "Read File", + kind: "read", + content: [], + locations: filePath ? [{ path: filePath, line: offset }] : [], + rawInput: input, + } + } + + case "list": + case "ls": { + const path = str(input.path) + return { + title: path ? `List \`${path}\`` : "List directory", + kind: "search", + content: [], + locations: path ? [{ path }] : [], + rawInput: input, + } + } + + case "edit": + case "str_replace": { + const filePath = getFilePath(input) + const oldString = getOldString(input) + const newString = getNewString(input) + return { + title: filePath ? `Edit \`${filePath}\`` : "Edit", + kind: "edit", + content: filePath ? [diffContent(filePath, oldString, newString)] : [], + locations: filePath ? [{ path: filePath }] : [], + rawInput: input, + } + } + + case "patch": { + const filePath = getFilePath(input) + const patchText = getDiff(input) + return { + title: filePath ? `Patch \`${filePath}\`` : "Patch", + kind: "edit", + content: patchText ? [textContent(patchText)] : [], + locations: filePath ? [{ path: filePath }] : [], + rawInput: input, + } + } + + case "write": + case "create": { + const filePath = getFilePath(input) + const content = getNewString(input) + return { + title: filePath ? `Write ${filePath}` : "Write", + kind: "edit", + content: filePath ? [diffContent(filePath, null, content)] : [], + locations: filePath ? [{ path: filePath }] : [], + rawInput: input, + } + } + + case "glob": + case "find": { + const path = str(input.path) + const pattern = getPattern(input) + let label = "Find" + if (path) label += ` \`${path}\`` + if (pattern) label += ` \`${pattern}\`` + return { + title: label, + kind: "search", + content: [], + locations: path ? [{ path }] : [], + rawInput: input, + } + } + + case "grep": + case "search": { + const pattern = getPattern(input) + const path = str(input.path) + let label = "grep" + if (pattern) label += ` "${truncate(pattern, 30)}"` + if (path) label += ` ${path}` + return { + title: label, + kind: "search", + content: [], + locations: path ? [{ path }] : [], + rawInput: input, + } + } + + case "webfetch": + case "fetch": { + const url = getUrl(input) + const prompt = str(input.prompt) + return { + title: url ? `Fetch ${truncate(url, 40)}` : "Fetch", + kind: "fetch", + content: prompt ? [textContent(prompt)] : [], + locations: [], + rawInput: input, + } + } + + case "websearch": { + const query = getQuery(input) + return { + title: query ? `"${truncate(query, 40)}"` : "Search", + kind: "fetch", + content: [], + locations: [], + rawInput: input, + } + } + + case "task": { + const description = getDescription(input) + const prompt = str(input.prompt) + return { + title: description || "Task", + kind: "think", + content: prompt ? [textContent(prompt)] : [], + locations: [], + rawInput: input, + } + } + + case "todowrite": + case "todoread": { + return { + title: "Update TODOs", + kind: "think", + content: [], + locations: [], + rawInput: input, + } + } + + case "plan_exit": { + return { + title: "Exit Plan Mode", + kind: "think", + content: [], + locations: [], + rawInput: input, + } + } + + case "plan_enter": { + return { + title: "Enter Plan Mode", + kind: "think", + content: [], + locations: [], + rawInput: input, + } + } + + case "apply_patch": { + const filePath = getFilePath(input) + const patchText = getDiff(input) + return { + title: filePath ? `Apply Patch \`${filePath}\`` : "Apply Patch", + kind: "edit", + content: patchText ? [textContent(patchText)] : [], + locations: filePath ? [{ path: filePath }] : [], + rawInput: input, + } + } + + case "multiedit": { + return { + title: "Multi Edit", + kind: "edit", + content: [], + locations: [], + rawInput: input, + } + } + + case "batch": { + return { + title: "Batch", + kind: "other", + content: [], + locations: [], + rawInput: input, + } + } + + case "skill": { + const name = str(input.name) + return { + title: name ? `Skill: ${name}` : "Skill", + kind: "other", + content: [], + locations: [], + rawInput: input, + } + } + + case "question": { + const question = getQuery(input) || str(input.question) + return { + title: question ? truncate(question, 40) : "Question", + kind: "other", + content: [], + locations: [], + rawInput: input, + } + } + + case "lsp": { + return { + title: "LSP", + kind: "other", + content: [], + locations: [], + rawInput: input, + } + } + + case "codesearch": { + const query = getQuery(input) + return { + title: query ? `Search: ${truncate(query, 30)}` : "Code Search", + kind: "search", + content: [], + locations: [], + rawInput: input, + } + } + + default: { + const description = getDescription(input) + const command = getCommand(input) + const title = description || command || tool + return { + title: truncate(title, 50), + kind: "other", + content: [], + locations: [], + rawInput: input, + } + } + } +} + +export function toolResultFromPart( + tool: string, + input: Record, + output: string, + isError: boolean, +): ToolResultInfo { + const name = normalize(tool) + const displayText = isError ? markdownEscape(output) : output + const content: ToolCallContent[] = [textContent(displayText)] + + switch (name) { + case "bash": + case "shell": + case "terminal": { + return { + content, + rawOutput: isError ? { stderr: output } : { stdout: output }, + } + } + + case "edit": + case "str_replace": { + const filePath = getFilePath(input) + const oldString = getOldString(input) + const newString = getNewString(input) + if (filePath && !isError) { + content.push(diffContent(filePath, oldString, newString)) + } + return { + content, + rawOutput: { stdout: output }, + } + } + + case "patch": + case "apply_patch": { + const filePath = getFilePath(input) + const patchText = getDiff(input) + if (filePath && patchText && !isError) { + content.push(textContent(patchText)) + } + return { + content, + rawOutput: { stdout: output }, + } + } + + case "write": + case "create": { + const filePath = getFilePath(input) + const fileContent = getNewString(input) + if (filePath && !isError) { + content.push(diffContent(filePath, null, fileContent)) + } + return { + content, + rawOutput: { stdout: output }, + } + } + + default: { + return { + content, + rawOutput: isError ? { stderr: output } : { stdout: output }, + } + } + } +} diff --git a/packages/opencode/test/acp/parse-command.test.ts b/packages/opencode/test/acp/parse-command.test.ts new file mode 100644 index 0000000000..150490441b --- /dev/null +++ b/packages/opencode/test/acp/parse-command.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from "bun:test" +import { ParseCommand } from "../../src/acp/parse-command" + +describe("ParseCommand", () => { + describe("parse", () => { + it("parses ls as list command", () => { + const result = ParseCommand.parse("ls") + expect(result).toEqual({ type: "list", cmd: "ls", path: undefined }) + }) + + it("parses ls with path as list command", () => { + const result = ParseCommand.parse("ls /some/path") + expect(result).toEqual({ type: "list", cmd: "ls", path: "/some/path" }) + }) + + it("parses ls with flags correctly", () => { + const result = ParseCommand.parse("ls -la /some/path") + expect(result).toEqual({ type: "list", cmd: "ls", path: "/some/path" }) + }) + + it("parses cat as read command", () => { + const result = ParseCommand.parse("cat file.txt") + expect(result).toEqual({ type: "read", cmd: "cat", name: "file.txt", path: "file.txt" }) + }) + + it("parses cat with path as read command", () => { + const result = ParseCommand.parse("cat /some/path/file.txt") + expect(result).toEqual({ type: "read", cmd: "cat", name: "file.txt", path: "/some/path/file.txt" }) + }) + + it("parses grep as search command", () => { + const result = ParseCommand.parse("grep pattern") + expect(result).toEqual({ type: "search", cmd: "grep", query: "pattern", path: undefined }) + }) + + it("parses grep with path as search command", () => { + const result = ParseCommand.parse("grep pattern /some/path") + expect(result).toEqual({ type: "search", cmd: "grep", query: "pattern", path: "/some/path" }) + }) + + it("parses rg as search command", () => { + const result = ParseCommand.parse("rg pattern") + expect(result).toEqual({ type: "search", cmd: "rg", query: "pattern", path: undefined }) + }) + + it("parses unknown command", () => { + const result = ParseCommand.parse("npm install") + expect(result).toEqual({ type: "unknown", cmd: "npm install" }) + }) + + it("parses cat without args as unknown", () => { + const result = ParseCommand.parse("cat") + expect(result).toEqual({ type: "unknown", cmd: "cat" }) + }) + }) + + describe("format", () => { + it("formats list command with cwd", () => { + const parsed = ParseCommand.parse("ls") + const result = ParseCommand.format(parsed, "/home/user") + expect(result.kind).toBe("search") + expect(result.title).toBe("List /home/user") + expect(result.locations).toEqual([{ path: "/home/user" }]) + }) + + it("formats list command with explicit path", () => { + const parsed = ParseCommand.parse("ls /some/path") + const result = ParseCommand.format(parsed, "/home/user") + expect(result.kind).toBe("search") + expect(result.title).toBe("List /some/path") + expect(result.locations).toEqual([{ path: "/some/path" }]) + }) + + it("formats list command with relative path", () => { + const parsed = ParseCommand.parse("ls subdir") + const result = ParseCommand.format(parsed, "/home/user") + expect(result.kind).toBe("search") + expect(result.title).toBe("List /home/user/subdir") + expect(result.locations).toEqual([{ path: "/home/user/subdir" }]) + }) + + it("formats read command", () => { + const parsed = ParseCommand.parse("cat /some/file.txt") + const result = ParseCommand.format(parsed, "/home/user") + expect(result.kind).toBe("read") + expect(result.title).toBe("Read file.txt") + expect(result.locations).toEqual([{ path: "/some/file.txt" }]) + }) + + it("formats search command with query", () => { + const parsed = ParseCommand.parse("grep pattern") + const result = ParseCommand.format(parsed, "/home/user") + expect(result.kind).toBe("search") + expect(result.title).toBe("Search pattern") + }) + + it("formats search command with query and path", () => { + const parsed = ParseCommand.parse("grep pattern /some/path") + const result = ParseCommand.format(parsed, "/home/user") + expect(result.kind).toBe("search") + expect(result.title).toBe("Search pattern in /some/path") + expect(result.locations).toEqual([{ path: "/some/path" }]) + }) + + it("formats unknown command as execute", () => { + const parsed = ParseCommand.parse("npm install") + const result = ParseCommand.format(parsed, "/home/user") + expect(result.kind).toBe("execute") + expect(result.title).toBe("Run npm install") + expect(result.terminalOutput).toBe(true) + }) + }) +}) From 63c69a76bc45ac56bd1b2799a3d2790df073a28c Mon Sep 17 00:00:00 2001 From: Mert Can Demir Date: Fri, 30 Jan 2026 13:57:03 +0300 Subject: [PATCH 3/3] refactor: simplify command parsing and formatting in ACP agent --- packages/opencode/src/acp/parse-command.ts | 87 ++----------- packages/opencode/src/acp/tool-format.ts | 4 +- .../opencode/test/acp/parse-command.test.ts | 117 +++--------------- 3 files changed, 27 insertions(+), 181 deletions(-) diff --git a/packages/opencode/src/acp/parse-command.ts b/packages/opencode/src/acp/parse-command.ts index 907ae537b3..433cd09d95 100644 --- a/packages/opencode/src/acp/parse-command.ts +++ b/packages/opencode/src/acp/parse-command.ts @@ -1,12 +1,6 @@ import type { ToolKind } from "@agentclientprotocol/sdk" export namespace ParseCommand { - export type Parsed = - | { type: "read"; cmd: string; name: string; path: string } - | { type: "list"; cmd: string; path?: string } - | { type: "search"; cmd: string; query?: string; path?: string } - | { type: "unknown"; cmd: string } - export interface Result { kind: ToolKind title: string @@ -14,81 +8,14 @@ export namespace ParseCommand { terminalOutput: boolean } - const LIST_COMMANDS = new Set(["ls", "dir", "exa", "eza", "tree", "lsd"]) - const READ_COMMANDS = new Set(["cat", "head", "tail", "less", "more", "bat", "view"]) - const SEARCH_COMMANDS = new Set(["grep", "rg", "ag", "ack", "find", "fd", "fzf", "locate"]) + export function format(command: string, description: string, cwd: string): Result { + const title = description || command || "Terminal" - export function parse(command: string): Parsed { - const trimmed = command.trim() - const parts = trimmed.split(/\s+/).filter(Boolean) - const cmd = parts[0] || "" - const args = parts.slice(1).filter((p) => !p.startsWith("-")) - - if (LIST_COMMANDS.has(cmd)) { - return { type: "list", cmd, path: args[0] } + return { + kind: "other", + title, + locations: cwd ? [{ path: cwd }] : [], + terminalOutput: true, } - - if (READ_COMMANDS.has(cmd)) { - if (args[0]) { - const name = args[0].split("/").pop() || args[0] - return { type: "read", cmd, name, path: args[0] } - } - return { type: "unknown", cmd: trimmed } - } - - if (SEARCH_COMMANDS.has(cmd)) { - return { type: "search", cmd, query: args[0], path: args[1] } - } - - return { type: "unknown", cmd: trimmed } - } - - export function format(parsed: Parsed, cwd: string): Result { - switch (parsed.type) { - case "read": - return { - kind: "read", - title: `Read ${parsed.name}`, - locations: [{ path: parsed.path }], - terminalOutput: false, - } - - case "list": { - const dir = parsed.path ? (parsed.path.startsWith("/") ? parsed.path : `${cwd}/${parsed.path}`) : cwd || "." - return { - kind: "search", - title: `List ${dir}`, - locations: [{ path: dir }], - terminalOutput: false, - } - } - - case "search": { - const title = - parsed.query && parsed.path - ? `Search ${parsed.query} in ${parsed.path}` - : parsed.query - ? `Search ${parsed.query}` - : `Search ${parsed.cmd}` - return { - kind: "search", - title: truncate(title, 50), - locations: parsed.path ? [{ path: parsed.path }] : [], - terminalOutput: false, - } - } - - case "unknown": - return { - kind: "execute", - title: `Run ${truncate(parsed.cmd, 40)}`, - locations: [], - terminalOutput: true, - } - } - } - - function truncate(str: string, max: number): string { - return str.length > max ? str.substring(0, max - 3) + "..." : str } } diff --git a/packages/opencode/src/acp/tool-format.ts b/packages/opencode/src/acp/tool-format.ts index eaf81fde22..d037871523 100644 --- a/packages/opencode/src/acp/tool-format.ts +++ b/packages/opencode/src/acp/tool-format.ts @@ -99,9 +99,9 @@ export function toolCallFromPart(tool: string, input: Record): case "shell": case "terminal": { const command = getCommand(input) + const description = getDescription(input) const cwd = str(input.cwd ?? input.workdir ?? input.workingDir ?? input.directory) - const parsed = ParseCommand.parse(command) - const result = ParseCommand.format(parsed, cwd) + const result = ParseCommand.format(command, description, cwd) return { title: result.title, kind: result.kind, diff --git a/packages/opencode/test/acp/parse-command.test.ts b/packages/opencode/test/acp/parse-command.test.ts index 150490441b..782cf0fac5 100644 --- a/packages/opencode/test/acp/parse-command.test.ts +++ b/packages/opencode/test/acp/parse-command.test.ts @@ -2,111 +2,30 @@ import { describe, expect, it } from "bun:test" import { ParseCommand } from "../../src/acp/parse-command" describe("ParseCommand", () => { - describe("parse", () => { - it("parses ls as list command", () => { - const result = ParseCommand.parse("ls") - expect(result).toEqual({ type: "list", cmd: "ls", path: undefined }) - }) - - it("parses ls with path as list command", () => { - const result = ParseCommand.parse("ls /some/path") - expect(result).toEqual({ type: "list", cmd: "ls", path: "/some/path" }) - }) - - it("parses ls with flags correctly", () => { - const result = ParseCommand.parse("ls -la /some/path") - expect(result).toEqual({ type: "list", cmd: "ls", path: "/some/path" }) - }) - - it("parses cat as read command", () => { - const result = ParseCommand.parse("cat file.txt") - expect(result).toEqual({ type: "read", cmd: "cat", name: "file.txt", path: "file.txt" }) - }) - - it("parses cat with path as read command", () => { - const result = ParseCommand.parse("cat /some/path/file.txt") - expect(result).toEqual({ type: "read", cmd: "cat", name: "file.txt", path: "/some/path/file.txt" }) - }) - - it("parses grep as search command", () => { - const result = ParseCommand.parse("grep pattern") - expect(result).toEqual({ type: "search", cmd: "grep", query: "pattern", path: undefined }) - }) - - it("parses grep with path as search command", () => { - const result = ParseCommand.parse("grep pattern /some/path") - expect(result).toEqual({ type: "search", cmd: "grep", query: "pattern", path: "/some/path" }) - }) - - it("parses rg as search command", () => { - const result = ParseCommand.parse("rg pattern") - expect(result).toEqual({ type: "search", cmd: "rg", query: "pattern", path: undefined }) - }) - - it("parses unknown command", () => { - const result = ParseCommand.parse("npm install") - expect(result).toEqual({ type: "unknown", cmd: "npm install" }) - }) - - it("parses cat without args as unknown", () => { - const result = ParseCommand.parse("cat") - expect(result).toEqual({ type: "unknown", cmd: "cat" }) - }) - }) - describe("format", () => { - it("formats list command with cwd", () => { - const parsed = ParseCommand.parse("ls") - const result = ParseCommand.format(parsed, "/home/user") - expect(result.kind).toBe("search") - expect(result.title).toBe("List /home/user") + it("uses description as title when provided", () => { + const result = ParseCommand.format("ls", "List files in current directory", "/home/user") + expect(result.title).toBe("List files in current directory") + expect(result.kind).toBe("other") + }) + + it("falls back to command when no description", () => { + const result = ParseCommand.format("ls -la", "", "/home/user") + expect(result.title).toBe("ls -la") + }) + + it("includes cwd in locations", () => { + const result = ParseCommand.format("ls", "List files", "/home/user") expect(result.locations).toEqual([{ path: "/home/user" }]) }) - it("formats list command with explicit path", () => { - const parsed = ParseCommand.parse("ls /some/path") - const result = ParseCommand.format(parsed, "/home/user") - expect(result.kind).toBe("search") - expect(result.title).toBe("List /some/path") - expect(result.locations).toEqual([{ path: "/some/path" }]) + it("handles empty cwd", () => { + const result = ParseCommand.format("ls", "List files", "") + expect(result.locations).toEqual([]) }) - it("formats list command with relative path", () => { - const parsed = ParseCommand.parse("ls subdir") - const result = ParseCommand.format(parsed, "/home/user") - expect(result.kind).toBe("search") - expect(result.title).toBe("List /home/user/subdir") - expect(result.locations).toEqual([{ path: "/home/user/subdir" }]) - }) - - it("formats read command", () => { - const parsed = ParseCommand.parse("cat /some/file.txt") - const result = ParseCommand.format(parsed, "/home/user") - expect(result.kind).toBe("read") - expect(result.title).toBe("Read file.txt") - expect(result.locations).toEqual([{ path: "/some/file.txt" }]) - }) - - it("formats search command with query", () => { - const parsed = ParseCommand.parse("grep pattern") - const result = ParseCommand.format(parsed, "/home/user") - expect(result.kind).toBe("search") - expect(result.title).toBe("Search pattern") - }) - - it("formats search command with query and path", () => { - const parsed = ParseCommand.parse("grep pattern /some/path") - const result = ParseCommand.format(parsed, "/home/user") - expect(result.kind).toBe("search") - expect(result.title).toBe("Search pattern in /some/path") - expect(result.locations).toEqual([{ path: "/some/path" }]) - }) - - it("formats unknown command as execute", () => { - const parsed = ParseCommand.parse("npm install") - const result = ParseCommand.format(parsed, "/home/user") - expect(result.kind).toBe("execute") - expect(result.title).toBe("Run npm install") + it("sets terminalOutput to true", () => { + const result = ParseCommand.format("npm install", "Install dependencies", "/home/user") expect(result.terminalOutput).toBe(true) }) })