diff --git a/packages/opencode/src/acp/event.ts b/packages/opencode/src/acp/event.ts index 05b8b77b370d..d7140e584818 100644 --- a/packages/opencode/src/acp/event.ts +++ b/packages/opencode/src/acp/event.ts @@ -87,7 +87,9 @@ export class Subscription { for (const part of message.parts) { await this.recordFetchedPart(message.info.sessionID, message, part) if (part.type === "tool") { - await this.handleToolPart(message.info.sessionID, part) + const session = await Effect.runPromise(this.input.session.tryGet(message.info.sessionID)) + const cwd = message.info.role === "assistant" ? message.info.path?.cwd : undefined + await this.handleToolPart(message.info.sessionID, part, cwd ?? session?.cwd ?? process.cwd()) continue } await this.replayContentPart(message, part) @@ -152,7 +154,7 @@ export class Subscription { }), ) if (part.type === "tool") { - await this.handleToolPart(session.id, part) + await this.handleToolPart(session.id, part, session.cwd) } } @@ -240,8 +242,8 @@ export class Subscription { ) } - private async handleToolPart(sessionId: string, part: ToolPart) { - await this.toolStart(sessionId, part) + private async handleToolPart(sessionId: string, part: ToolPart, cwd: string) { + await this.toolStart(sessionId, part, cwd) switch (part.state.status) { case "pending": @@ -249,7 +251,7 @@ export class Subscription { return case "running": - await this.runningTool(sessionId, part) + await this.runningTool(sessionId, part, cwd) return case "completed": @@ -262,6 +264,7 @@ export class Subscription { toolCallId: part.callID, toolName: part.tool, state: part.state, + cwd, }), }, }) @@ -277,6 +280,7 @@ export class Subscription { toolCallId: part.callID, toolName: part.tool, state: part.state, + cwd, }), }, }) @@ -284,7 +288,7 @@ export class Subscription { } } - private async runningTool(sessionId: string, part: ToolPart) { + private async runningTool(sessionId: string, part: ToolPart, cwd: string) { if (part.state.status !== "running") return const output = part.tool === "bash" ? shellOutputSnapshot(part.state) : undefined @@ -298,6 +302,7 @@ export class Subscription { toolCallId: part.callID, toolName: part.tool, state: part.state, + cwd, }), }, }) @@ -315,12 +320,13 @@ export class Subscription { toolName: part.tool, state: part.state, output, + cwd, }), }, }) } - private async toolStart(sessionId: string, part: ToolPart) { + private async toolStart(sessionId: string, part: ToolPart, cwd: string) { if (this.toolStarts.has(part.callID)) return this.toolStarts.add(part.callID) await this.input.connection.sessionUpdate({ @@ -330,6 +336,8 @@ export class Subscription { ...pendingToolCall({ toolCallId: part.callID, toolName: part.tool, + state: part.state, + cwd, }), }, }) diff --git a/packages/opencode/src/acp/tool.ts b/packages/opencode/src/acp/tool.ts index 0e8b4f098501..22e1c159191e 100644 --- a/packages/opencode/src/acp/tool.ts +++ b/packages/opencode/src/acp/tool.ts @@ -1,4 +1,12 @@ -import type { ToolCall, ToolCallContent, ToolCallLocation, ToolCallUpdate, ToolKind } from "@agentclientprotocol/sdk" +import { isAbsolute, resolve } from "path" +import type { + ToolCall, + ToolCallContent, + ToolCallLocation, + ToolCallStatus, + ToolCallUpdate, + ToolKind, +} from "@agentclientprotocol/sdk" export type ToolInput = Record @@ -29,18 +37,33 @@ export type ErrorToolState = { readonly metadata?: unknown } +type ToolState = + | { readonly status: "pending"; readonly input: ToolInput; readonly title?: string } + | RunningToolState + | (CompletedToolState & { readonly title?: string }) + | ErrorToolState + export type ImageAttachment = { readonly mimeType: string readonly data: string } +const TOOL_STATUS_MAP = { + pending: "pending", + running: "in_progress", + completed: "completed", + error: "failed", +} as const satisfies Record + +const FENCE_RE = /^`{3,}/gm + export function toToolKind(toolName: string): ToolKind { const tool = toolName.toLocaleLowerCase() switch (tool) { case "bash": case "shell": - return "execute" + return "other" case "webfetch": return "fetch" @@ -69,10 +92,14 @@ export function toToolKind(toolName: string): ToolKind { } } -export function toLocations(toolName: string, input: ToolInput): ToolCallLocation[] { +export function toLocations(toolName: string, input: ToolInput, cwd?: string): ToolCallLocation[] { const tool = toolName.toLocaleLowerCase() switch (tool) { + case "bash": + case "shell": + return shellLocation(input, cwd) + case "read": case "edit": case "write": @@ -88,10 +115,6 @@ export function toLocations(toolName: string, input: ToolInput): ToolCallLocatio case "context7_get_library_docs": return locationFrom(input.path) - case "bash": - case "shell": - return [] - default: return [] } @@ -99,7 +122,11 @@ export function toLocations(toolName: string, input: ToolInput): ToolCallLocatio export function completedToolContent(toolName: string, state: CompletedToolState): ToolCallContent[] { const text = - toolName.toLocaleLowerCase() === "read" ? (readDisplayText(state.metadata) ?? state.output) : state.output + toolName.toLocaleLowerCase() === "read" + ? (readDisplayText(state.metadata) ?? state.output) + : isShell(toolName) + ? fenceWith(state.output, "sh") + : state.output const content: ToolCallContent[] = [ { type: "content", @@ -118,7 +145,23 @@ export function completedToolContent(toolName: string, state: CompletedToolState return content } -export function pendingToolCall(input: { readonly toolCallId: string; readonly toolName: string }): ToolCall { +export function pendingToolCall(input: { + readonly toolCallId: string + readonly toolName: string + readonly state?: ToolState + readonly cwd?: string +}): ToolCall { + if (input.state) { + return { + toolCallId: input.toolCallId, + title: toolTitle(input.toolName, input.state), + kind: toToolKind(input.toolName), + status: TOOL_STATUS_MAP[input.state.status], + locations: toLocations(input.toolName, input.state.input, input.cwd), + rawInput: rawInput(input.toolName, input.state.input, input.cwd), + } + } + return { toolCallId: input.toolCallId, title: input.toolName, @@ -134,6 +177,7 @@ export function runningToolUpdate(input: { readonly toolName: string readonly state: RunningToolState readonly output?: string + readonly cwd?: string }): ToolCallUpdate { const content = input.output ? [ @@ -141,7 +185,7 @@ export function runningToolUpdate(input: { type: "content" as const, content: { type: "text" as const, - text: input.output, + text: isShell(input.toolName) ? fenceWith(input.output, "sh") : input.output, }, }, ] @@ -151,9 +195,9 @@ export function runningToolUpdate(input: { toolCallId: input.toolCallId, status: "in_progress", kind: toToolKind(input.toolName), - title: input.state.title ?? input.toolName, - locations: toLocations(input.toolName, input.state.input), - rawInput: input.state.input, + title: toolTitle(input.toolName, input.state), + locations: toLocations(input.toolName, input.state.input, input.cwd), + rawInput: rawInput(input.toolName, input.state.input, input.cwd), ...(content ? { content } : {}), } } @@ -162,30 +206,33 @@ export function duplicateRunningToolUpdate(input: { readonly toolCallId: string readonly toolName: string readonly state: RunningToolState + readonly cwd?: string }): ToolCallUpdate { return { toolCallId: input.toolCallId, status: "in_progress", kind: toToolKind(input.toolName), - title: input.state.title ?? input.toolName, - locations: toLocations(input.toolName, input.state.input), - rawInput: input.state.input, + title: toolTitle(input.toolName, input.state), + locations: toLocations(input.toolName, input.state.input, input.cwd), + rawInput: rawInput(input.toolName, input.state.input, input.cwd), } } export function completedToolUpdate(input: { readonly toolCallId: string readonly toolName: string - readonly state: CompletedToolState & { readonly title: string } + readonly state: CompletedToolState & { readonly title?: string } + readonly cwd?: string }): ToolCallUpdate { return { toolCallId: input.toolCallId, status: "completed", kind: toToolKind(input.toolName), - title: input.state.title, + title: toolTitle(input.toolName, input.state), + locations: toLocations(input.toolName, input.state.input, input.cwd), + rawInput: rawInput(input.toolName, input.state.input, input.cwd), content: completedToolContent(input.toolName, input.state), - rawInput: input.state.input, - rawOutput: completedToolRawOutput(input.state), + rawOutput: completedToolRawOutputForTool(input.toolName, input.state), } } @@ -193,26 +240,25 @@ export function errorToolUpdate(input: { readonly toolCallId: string readonly toolName: string readonly state: ErrorToolState + readonly cwd?: string }): ToolCallUpdate { return { toolCallId: input.toolCallId, status: "failed", kind: toToolKind(input.toolName), - title: input.toolName, - rawInput: input.state.input, + title: toolTitle(input.toolName, input.state), + locations: toLocations(input.toolName, input.state.input, input.cwd), + rawInput: rawInput(input.toolName, input.state.input, input.cwd), content: [ { type: "content", content: { type: "text", - text: input.state.error, + text: isShell(input.toolName) ? fenceWith(input.state.error, "sh") : input.state.error, }, }, ], - rawOutput: { - error: input.state.error, - metadata: input.state.metadata, - }, + rawOutput: errorToolRawOutputForTool(input.toolName, input.state), } } @@ -249,6 +295,76 @@ export function shellOutputSnapshot(state: { readonly metadata?: unknown }) { return stringValue((state.metadata as Record).output) } +function completedToolRawOutputForTool(toolName: string, state: CompletedToolState) { + if (!isShell(toolName)) return completedToolRawOutput(state) + return { + stdout: state.output, + ...(state.metadata !== undefined ? { metadata: state.metadata } : {}), + ...(state.attachments?.length ? { attachments: state.attachments } : {}), + } +} + +function errorToolRawOutputForTool(toolName: string, state: ErrorToolState) { + if (!isShell(toolName)) { + return { + error: state.error, + metadata: state.metadata, + } + } + return { + stderr: state.error, + ...(state.metadata !== undefined ? { metadata: state.metadata } : {}), + } +} + +function toolTitle(toolName: string, state: ToolState) { + if (isShell(toolName)) return stringValue(state.input.description) ?? shellCommand(state.input) ?? "Terminal" + return ("title" in state && state.title) || toolName +} + +function rawInput(toolName: string, input: ToolInput, cwd?: string) { + if (!isShell(toolName)) return input + const workdir = shellWorkdir(input, cwd) + if (!workdir || input.cwd || input.workdir || input.workingDir || input.directory) return input + return { ...input, cwd: workdir } +} + +function shellLocation(input: ToolInput, cwd?: string): ToolCallLocation[] { + const workdir = shellWorkdir(input, cwd) + if (!workdir || (!shellCommand(input) && !input.workdir && !input.cwd && !input.workingDir && !input.directory)) return [] + return [{ path: workdir }] +} + +function shellCommand(input: ToolInput) { + return stringValue(input.command) ?? stringValue(input.cmd) +} + +function shellWorkdir(input: ToolInput, cwd?: string) { + const workdir = + stringValue(input.workdir) ?? stringValue(input.cwd) ?? stringValue(input.workingDir) ?? stringValue(input.directory) + return resolvePath(workdir, cwd) ?? cwd +} + +function resolvePath(value: string | undefined, cwd?: string) { + if (!value) return undefined + if (isAbsolute(value)) return value + return resolve(cwd ?? process.cwd(), value) +} + +function isShell(toolName: string) { + const tool = toolName.toLocaleLowerCase() + return tool === "bash" || tool === "shell" +} + +function fenceWith(text: string, lang: string) { + if (!text) return text + let fence = "```" + for (const match of text.matchAll(FENCE_RE)) { + while (match[0].length >= fence.length) fence += "`" + } + return `${fence}${lang}\n${text.replace(/\n+$/, "")}\n${fence}` +} + export const mapToolKind = toToolKind export const extractLocations = toLocations export const buildCompletedToolContent = completedToolContent diff --git a/packages/opencode/test/acp/event.test.ts b/packages/opencode/test/acp/event.test.ts index 6edd7f0fbba0..af787076a27e 100644 --- a/packages/opencode/test/acp/event.test.ts +++ b/packages/opencode/test/acp/event.test.ts @@ -517,7 +517,7 @@ describe("acp event routing", () => { expect(harness.updates).toHaveLength(0) }) - it("emits synthetic pending before the first running tool update", async () => { + it("emits first running tool call with raw input before the first running tool update", async () => { const harness = createHarness() await Effect.runPromise(harness.session.create({ id: "ses_tool", cwd: "/workspace" })) @@ -527,7 +527,14 @@ describe("acp event routing", () => { "tool_call", "tool_call_update", ]) - expect(harness.updates[0]?.update).toMatchObject({ status: "pending", toolCallId: "call_1" }) + expect(harness.updates[0]?.update).toMatchObject({ + status: "in_progress", + toolCallId: "call_1", + title: "printf hello", + kind: "other", + locations: [{ path: "/workspace" }], + rawInput: { cmd: "printf hello", cwd: "/workspace" }, + }) expect(harness.updates[1]?.update).toMatchObject({ status: "in_progress", toolCallId: "call_1" }) }) @@ -557,7 +564,7 @@ describe("acp event routing", () => { expect(updates).toHaveLength(3) expect(updates[1]?.update).toMatchObject({ sessionUpdate: "tool_call_update", - content: [{ type: "content", content: { type: "text", text: "same" } }], + content: [{ type: "content", content: { type: "text", text: "```sh\nsame\n```" } }], }) expect(updates[2]?.update).toMatchObject({ sessionUpdate: "tool_call_update", status: "in_progress" }) expect("content" in updates[2]!.update).toBe(false) @@ -590,8 +597,8 @@ describe("acp event routing", () => { .filter((item) => item.update.sessionUpdate === "tool_call_update") .map((item) => ("content" in item.update ? item.update.content : undefined)), ).toEqual([ - [{ type: "content", content: { type: "text", text: "repeat" } }], - [{ type: "content", content: { type: "text", text: "repeat" } }], + [{ type: "content", content: { type: "text", text: "```sh\nrepeat\n```" } }], + [{ type: "content", content: { type: "text", text: "```sh\nrepeat\n```" } }], ]) }) @@ -605,8 +612,8 @@ describe("acp event routing", () => { sessionUpdate: "tool_call_update", toolCallId: "call_done", status: "completed", - content: [{ type: "content", content: { type: "text", text: "finished" } }], - rawOutput: { output: "finished", metadata: { exit: 0 } }, + content: [{ type: "content", content: { type: "text", text: "```sh\nfinished\n```" } }], + rawOutput: { stdout: "finished", metadata: { exit: 0 } }, }) }) @@ -669,8 +676,8 @@ describe("acp event routing", () => { sessionUpdate: "tool_call_update", toolCallId: "call_error", status: "failed", - content: [{ type: "content", content: { type: "text", text: "failed hard" } }], - rawOutput: { error: "failed hard", metadata: { exit: 1 } }, + content: [{ type: "content", content: { type: "text", text: "```sh\nfailed hard\n```" } }], + rawOutput: { stderr: "failed hard", metadata: { exit: 1 } }, }) }) @@ -696,14 +703,14 @@ describe("acp event routing", () => { expect( toolUpdates(harness.updates) .filter((item) => item.update.sessionUpdate === "tool_call_update" && item.update.status === "completed") - .map((item) => ("content" in item.update ? item.update.content : [])), + .map((item) => ("content" in item.update ? item.update.content : [])), ).toEqual([ [ - { type: "content", content: { type: "text", text: "live" } }, + { type: "content", content: { type: "text", text: "```sh\nlive\n```" } }, { type: "content", content: { type: "image", mimeType: "image/png", data: image } }, ], [ - { type: "content", content: { type: "text", text: "replayed" } }, + { type: "content", content: { type: "text", text: "```sh\nreplayed\n```" } }, { type: "content", content: { type: "image", mimeType: "image/png", data: image } }, ], ]) diff --git a/packages/opencode/test/acp/tool.test.ts b/packages/opencode/test/acp/tool.test.ts index e7ad1c1b1638..151af382557d 100644 --- a/packages/opencode/test/acp/tool.test.ts +++ b/packages/opencode/test/acp/tool.test.ts @@ -11,8 +11,8 @@ import { describe("acp tool conversion", () => { test("maps OpenCode tool ids to ACP tool kinds", () => { - expect(toToolKind("bash")).toBe("execute") - expect(toToolKind("shell")).toBe("execute") + expect(toToolKind("bash")).toBe("other") + expect(toToolKind("shell")).toBe("other") expect(toToolKind("webfetch")).toBe("fetch") expect(toToolKind("edit")).toBe("edit") expect(toToolKind("apply_patch")).toBe("edit") @@ -37,6 +37,10 @@ describe("acp tool conversion", () => { expect(toLocations("external_directory", { directories: ["/tmp/outside"], patterns: ["/tmp/outside/*"] })).toEqual([ { path: "/tmp/outside" }, ]) + expect(toLocations("bash", { cmd: "pwd" }, "/workspace")).toEqual([{ path: "/workspace" }]) + expect(toLocations("bash", { command: "pwd", workdir: "subdir" }, "/workspace")).toEqual([ + { path: "/workspace/subdir" }, + ]) expect(toLocations("bash", { filePath: "/tmp/nope.ts", path: "/tmp" })).toEqual([]) expect(toLocations("read", { path: "/tmp/missing-file-path.ts" })).toEqual([]) })