fix: ACP both live and load share synthetic pending status preceeding… (#14916)

This commit is contained in:
Noam Bressler
2026-02-24 15:54:10 +02:00
committed by GitHub
parent e27d3d5d40
commit 2cee947671
2 changed files with 84 additions and 35 deletions

View File

@@ -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<ModeOption[]> {
const agents = await this.config.sdk.app
.agents(

View File

@@ -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({