diff --git a/packages/opencode/src/acp/agent.ts b/packages/opencode/src/acp/agent.ts index 5db98bc7044..8b338f1b571 100644 --- a/packages/opencode/src/acp/agent.ts +++ b/packages/opencode/src/acp/agent.ts @@ -270,25 +270,7 @@ export namespace ACP { const sessionId = session.id if (part.type === "tool") { - if (!this.toolStarts.has(part.callID)) { - this.toolStarts.add(part.callID) - 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 }) - }) - } + await this.toolStart(sessionId, part) switch (part.state.status) { case "pending": @@ -829,25 +811,10 @@ export namespace ACP { for (const part of message.parts) { if (part.type === "tool") { + await this.toolStart(sessionId, part) switch (part.state.status) { case "pending": this.bashSnapshots.delete(part.callID) - 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": const output = this.bashOutput(part) @@ -880,6 +847,7 @@ export namespace ACP { }) break case "completed": + this.toolStarts.delete(part.callID) this.bashSnapshots.delete(part.callID) const kind = toToolKind(part.tool) const content: ToolCallContent[] = [ @@ -959,6 +927,7 @@ export namespace ACP { }) break case "error": + this.toolStarts.delete(part.callID) this.bashSnapshots.delete(part.callID) await this.connection .sessionUpdate({ @@ -1116,6 +1085,27 @@ export namespace ACP { return output } + private async toolStart(sessionId: string, part: ToolPart) { + if (this.toolStarts.has(part.callID)) return + this.toolStarts.add(part.callID) + 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 }) + }) + } + private async loadAvailableModes(directory: string): Promise { const agents = await this.config.sdk.app .agents( diff --git a/packages/opencode/test/acp/event-subscription.test.ts b/packages/opencode/test/acp/event-subscription.test.ts index d61240d715b..1abf578281d 100644 --- a/packages/opencode/test/acp/event-subscription.test.ts +++ b/packages/opencode/test/acp/event-subscription.test.ts @@ -572,6 +572,65 @@ describe("acp.agent event subscription", () => { }) }) + test("does not emit duplicate synthetic pending after replayed running tool", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const { agent, controller, sessionUpdates, stop, sdk } = createFakeAgent() + const cwd = "/tmp/opencode-acp-test" + const sessionId = await agent.newSession({ cwd, mcpServers: [] } as any).then((x) => x.sessionId) + const input = { command: "echo hi", description: "run command" } + + sdk.session.messages = async () => ({ + data: [ + { + info: { + role: "assistant", + sessionID: sessionId, + }, + parts: [ + { + type: "tool", + callID: "call_1", + tool: "bash", + state: { + status: "running", + input, + metadata: { output: "hi\n" }, + time: { start: Date.now() }, + }, + }, + ], + }, + ], + }) + + await agent.loadSession({ sessionId, cwd, mcpServers: [] } as any) + controller.push( + toolEvent(sessionId, cwd, { + callID: "call_1", + tool: "bash", + status: "running", + input, + metadata: { output: "hi\nthere\n" }, + }), + ) + await new Promise((r) => setTimeout(r, 20)) + + const types = sessionUpdates + .filter((u) => u.sessionId === sessionId) + .map((u) => u.update) + .filter((u) => "toolCallId" in u && u.toolCallId === "call_1") + .map((u) => u.sessionUpdate) + .filter((u) => u === "tool_call" || u === "tool_call_update") + + expect(types).toEqual(["tool_call", "tool_call_update", "tool_call_update"]) + stop() + }, + }) + }) + test("clears bash snapshot marker on pending state", async () => { await using tmp = await tmpdir() await Instance.provide({