From 7df021a9c43861c05f6f039d1cf8358eea104c61 Mon Sep 17 00:00:00 2001 From: nizheming Date: Thu, 23 Apr 2026 16:29:58 +0800 Subject: [PATCH] fix(opencode): prefer semantic ACP tool title and input before completion --- packages/opencode/src/acp/agent.ts | 44 +++++++----- .../test/acp/event-subscription.test.ts | 67 +++++++++++++++++++ 2 files changed, 95 insertions(+), 16 deletions(-) diff --git a/packages/opencode/src/acp/agent.ts b/packages/opencode/src/acp/agent.ts index f12328153b6e..210191904693 100644 --- a/packages/opencode/src/acp/agent.ts +++ b/packages/opencode/src/acp/agent.ts @@ -286,6 +286,7 @@ export class Agent implements ACPAgent { return case "running": + const input = this.toolInput(part) const output = this.bashOutput(part) const content: ToolCallContent[] = [] if (output) { @@ -300,9 +301,9 @@ export class Agent implements ACPAgent { toolCallId: part.callID, status: "in_progress", kind: toToolKind(part.tool), - title: part.tool, - locations: toLocations(part.tool, part.state.input), - rawInput: part.state.input, + title: this.toolTitle(part), + locations: toLocations(part.tool, input), + rawInput: input, }, }) .catch((error) => { @@ -328,9 +329,9 @@ export class Agent implements ACPAgent { toolCallId: part.callID, status: "in_progress", kind: toToolKind(part.tool), - title: part.tool, - locations: toLocations(part.tool, part.state.input), - rawInput: part.state.input, + title: this.toolTitle(part), + locations: toLocations(part.tool, input), + rawInput: input, ...(content.length > 0 && { content }), }, }) @@ -431,8 +432,8 @@ export class Agent implements ACPAgent { toolCallId: part.callID, status: "failed", kind: toToolKind(part.tool), - title: part.tool, - rawInput: part.state.input, + title: this.toolTitle(part), + rawInput: this.toolInput(part), content: [ { type: "content", @@ -839,6 +840,7 @@ export class Agent implements ACPAgent { this.bashSnapshots.delete(part.callID) break case "running": + const input = this.toolInput(part) const output = this.bashOutput(part) const runningContent: ToolCallContent[] = [] if (output) { @@ -858,9 +860,9 @@ export class Agent implements ACPAgent { toolCallId: part.callID, status: "in_progress", kind: toToolKind(part.tool), - title: part.tool, - locations: toLocations(part.tool, part.state.input), - rawInput: part.state.input, + title: this.toolTitle(part), + locations: toLocations(part.tool, input), + rawInput: input, ...(runningContent.length > 0 && { content: runningContent }), }, }) @@ -959,8 +961,8 @@ export class Agent implements ACPAgent { toolCallId: part.callID, status: "failed", kind: toToolKind(part.tool), - title: part.tool, - rawInput: part.state.input, + title: this.toolTitle(part), + rawInput: this.toolInput(part), content: [ { type: "content", @@ -1112,20 +1114,30 @@ export class Agent implements ACPAgent { return output } + private toolInput(part: ToolPart) { + return part.state.input ?? {} + } + + private toolTitle(part: ToolPart) { + if ("title" in part.state && typeof part.state.title === "string") return part.state.title + return part.tool + } + private async toolStart(sessionId: string, part: ToolPart) { if (this.toolStarts.has(part.callID)) return this.toolStarts.add(part.callID) + const input = this.toolInput(part) await this.connection .sessionUpdate({ sessionId, update: { sessionUpdate: "tool_call", toolCallId: part.callID, - title: part.tool, + title: this.toolTitle(part), kind: toToolKind(part.tool), status: "pending", - locations: [], - rawInput: {}, + locations: toLocations(part.tool, input), + rawInput: input, }, }) .catch((error) => { diff --git a/packages/opencode/test/acp/event-subscription.test.ts b/packages/opencode/test/acp/event-subscription.test.ts index bce5e94598cf..71f5ee2c4f3a 100644 --- a/packages/opencode/test/acp/event-subscription.test.ts +++ b/packages/opencode/test/acp/event-subscription.test.ts @@ -35,6 +35,12 @@ function isToolCallUpdate( return update.sessionUpdate === "tool_call_update" } +function isToolCall( + update: SessionUpdateParams["update"], +): update is Extract { + return update.sessionUpdate === "tool_call" +} + function toolEvent( sessionId: string, cwd: string, @@ -614,6 +620,67 @@ describe("acp.agent event subscription", () => { }) }) + test("prefers semantic title and input for pending and running tool events", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const { agent, controller, sessionUpdates, stop } = createFakeAgent() + const cwd = "/tmp/opencode-acp-test" + const sessionId = await agent.newSession({ cwd, mcpServers: [] } as any).then((x) => x.sessionId) + const input = { filePath: "/tmp/semantic.txt" } + + controller.push({ + directory: cwd, + payload: { + type: "message.part.updated", + properties: { + sessionID: sessionId, + time: Date.now(), + part: { + id: "part_call_semantic", + sessionID: sessionId, + messageID: "msg_call_semantic", + type: "tool", + callID: "call_semantic", + tool: "read", + state: { + status: "running", + title: "Fetch cluster metadata", + input, + time: { start: Date.now() }, + }, + }, + }, + }, + }) + await new Promise((r) => setTimeout(r, 20)) + + const semanticUpdates = sessionUpdates + .filter((u) => u.sessionId === sessionId) + .map((u) => u.update) + .filter((u) => "toolCallId" in u && u.toolCallId === "call_semantic") + + expect(semanticUpdates).toHaveLength(2) + + const pending = semanticUpdates.find(isToolCall) + expect(pending).toBeDefined() + expect(pending?.title).toBe("Fetch cluster metadata") + expect(pending?.rawInput).toEqual(input) + expect(pending?.locations).toEqual([{ path: "/tmp/semantic.txt" }]) + + const running = semanticUpdates.find(isToolCallUpdate) + expect(running).toBeDefined() + expect(running?.status).toBe("in_progress") + expect(running?.title).toBe("Fetch cluster metadata") + expect(running?.rawInput).toEqual(input) + expect(running?.locations).toEqual([{ path: "/tmp/semantic.txt" }]) + + stop() + }, + }) + }) + test("does not emit duplicate synthetic pending after replayed running tool", async () => { await using tmp = await tmpdir() await Instance.provide({