diff --git a/packages/opencode/src/acp/agent.ts b/packages/opencode/src/acp/agent.ts index cc9a029a04..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,6 +38,7 @@ 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 } @@ -65,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" }, @@ -117,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, }) @@ -218,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)) @@ -312,6 +279,8 @@ export namespace ACP { } } + this.emittedToolCalls.delete(part.callID) + await this.connection .sessionUpdate({ sessionId, @@ -319,14 +288,7 @@ export namespace ACP { sessionUpdate: "tool_call_update", toolCallId: part.callID, status: "completed", - kind, - content, - title: part.state.title, - rawInput: part.state.input, - rawOutput: { - output: part.state.output, - metadata: part.state.metadata, - }, + rawOutput: result.rawOutput, }, }) .catch((error) => { @@ -334,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, @@ -342,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 + } } } @@ -700,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)) @@ -799,51 +739,56 @@ export namespace ACP { sessionId, update: { sessionUpdate: "tool_call_update", - toolCallId: part.callID, + toolCallId, status: "completed", - kind, - content, - title: part.state.title, - 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) { @@ -1303,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..433cd09d95 --- /dev/null +++ b/packages/opencode/src/acp/parse-command.ts @@ -0,0 +1,21 @@ +import type { ToolKind } from "@agentclientprotocol/sdk" + +export namespace ParseCommand { + export interface Result { + kind: ToolKind + title: string + locations: { path: string }[] + terminalOutput: boolean + } + + export function format(command: string, description: string, cwd: string): Result { + const title = description || command || "Terminal" + + return { + kind: "other", + title, + locations: cwd ? [{ path: cwd }] : [], + terminalOutput: true, + } + } +} diff --git a/packages/opencode/src/acp/tool-format.ts b/packages/opencode/src/acp/tool-format.ts new file mode 100644 index 0000000000..d037871523 --- /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 description = getDescription(input) + const cwd = str(input.cwd ?? input.workdir ?? input.workingDir ?? input.directory) + const result = ParseCommand.format(command, description, 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..782cf0fac5 --- /dev/null +++ b/packages/opencode/test/acp/parse-command.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "bun:test" +import { ParseCommand } from "../../src/acp/parse-command" + +describe("ParseCommand", () => { + describe("format", () => { + 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("handles empty cwd", () => { + const result = ParseCommand.format("ls", "List files", "") + expect(result.locations).toEqual([]) + }) + + it("sets terminalOutput to true", () => { + const result = ParseCommand.format("npm install", "Install dependencies", "/home/user") + expect(result.terminalOutput).toBe(true) + }) + }) +})