Skip to content

Commit 888b123

Browse files
feat: ACP - stream bash output and synthetic pending events (anomalyco#14079)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
1 parent 13cabae commit 888b123

2 files changed

Lines changed: 244 additions & 37 deletions

File tree

packages/opencode/src/acp/agent.ts

Lines changed: 88 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ import { Config } from "@/config/config"
4141
import { Todo } from "@/session/todo"
4242
import { z } from "zod"
4343
import { LoadAPIKeyError } from "ai"
44-
import type { AssistantMessage, Event, OpencodeClient, SessionMessageResponse } from "@opencode-ai/sdk/v2"
44+
import type { AssistantMessage, Event, OpencodeClient, SessionMessageResponse, ToolPart } from "@opencode-ai/sdk/v2"
4545
import { applyPatch } from "diff"
4646

4747
type ModeOption = { id: string; name: string; description?: string }
@@ -135,6 +135,8 @@ export namespace ACP {
135135
private sessionManager: ACPSessionManager
136136
private eventAbort = new AbortController()
137137
private eventStarted = false
138+
private bashSnapshots = new Map<string, string>()
139+
private toolStarts = new Set<string>()
138140
private permissionQueues = new Map<string, Promise<void>>()
139141
private permissionOptions: PermissionOption[] = [
140142
{ optionId: "once", kind: "allow_once", name: "Allow once" },
@@ -266,47 +268,68 @@ export namespace ACP {
266268
const session = this.sessionManager.tryGet(part.sessionID)
267269
if (!session) return
268270
const sessionId = session.id
269-
const directory = session.cwd
270-
271-
const message = await this.sdk.session
272-
.message(
273-
{
274-
sessionID: part.sessionID,
275-
messageID: part.messageID,
276-
directory,
277-
},
278-
{ throwOnError: true },
279-
)
280-
.then((x) => x.data)
281-
.catch((error) => {
282-
log.error("unexpected error when fetching message", { error })
283-
return undefined
284-
})
285-
286-
if (!message || message.info.role !== "assistant") return
287271

288272
if (part.type === "tool") {
273+
if (!this.toolStarts.has(part.callID)) {
274+
this.toolStarts.add(part.callID)
275+
await this.connection
276+
.sessionUpdate({
277+
sessionId,
278+
update: {
279+
sessionUpdate: "tool_call",
280+
toolCallId: part.callID,
281+
title: part.tool,
282+
kind: toToolKind(part.tool),
283+
status: "pending",
284+
locations: [],
285+
rawInput: {},
286+
},
287+
})
288+
.catch((error) => {
289+
log.error("failed to send tool pending to ACP", { error })
290+
})
291+
}
292+
289293
switch (part.state.status) {
290294
case "pending":
291-
await this.connection
292-
.sessionUpdate({
293-
sessionId,
294-
update: {
295-
sessionUpdate: "tool_call",
296-
toolCallId: part.callID,
297-
title: part.tool,
298-
kind: toToolKind(part.tool),
299-
status: "pending",
300-
locations: [],
301-
rawInput: {},
302-
},
303-
})
304-
.catch((error) => {
305-
log.error("failed to send tool pending to ACP", { error })
306-
})
295+
this.bashSnapshots.delete(part.callID)
307296
return
308297

309298
case "running":
299+
const output = this.bashOutput(part)
300+
const content: ToolCallContent[] = []
301+
if (output) {
302+
const hash = String(Bun.hash(output))
303+
if (part.tool === "bash") {
304+
if (this.bashSnapshots.get(part.callID) === hash) {
305+
await this.connection
306+
.sessionUpdate({
307+
sessionId,
308+
update: {
309+
sessionUpdate: "tool_call_update",
310+
toolCallId: part.callID,
311+
status: "in_progress",
312+
kind: toToolKind(part.tool),
313+
title: part.tool,
314+
locations: toLocations(part.tool, part.state.input),
315+
rawInput: part.state.input,
316+
},
317+
})
318+
.catch((error) => {
319+
log.error("failed to send tool in_progress to ACP", { error })
320+
})
321+
return
322+
}
323+
this.bashSnapshots.set(part.callID, hash)
324+
}
325+
content.push({
326+
type: "content",
327+
content: {
328+
type: "text",
329+
text: output,
330+
},
331+
})
332+
}
310333
await this.connection
311334
.sessionUpdate({
312335
sessionId,
@@ -318,6 +341,7 @@ export namespace ACP {
318341
title: part.tool,
319342
locations: toLocations(part.tool, part.state.input),
320343
rawInput: part.state.input,
344+
...(content.length > 0 && { content }),
321345
},
322346
})
323347
.catch((error) => {
@@ -326,6 +350,8 @@ export namespace ACP {
326350
return
327351

328352
case "completed": {
353+
this.toolStarts.delete(part.callID)
354+
this.bashSnapshots.delete(part.callID)
329355
const kind = toToolKind(part.tool)
330356
const content: ToolCallContent[] = [
331357
{
@@ -405,6 +431,8 @@ export namespace ACP {
405431
return
406432
}
407433
case "error":
434+
this.toolStarts.delete(part.callID)
435+
this.bashSnapshots.delete(part.callID)
408436
await this.connection
409437
.sessionUpdate({
410438
sessionId,
@@ -426,6 +454,7 @@ export namespace ACP {
426454
],
427455
rawOutput: {
428456
error: part.state.error,
457+
metadata: part.state.metadata,
429458
},
430459
},
431460
})
@@ -802,6 +831,7 @@ export namespace ACP {
802831
if (part.type === "tool") {
803832
switch (part.state.status) {
804833
case "pending":
834+
this.bashSnapshots.delete(part.callID)
805835
await this.connection
806836
.sessionUpdate({
807837
sessionId,
@@ -820,6 +850,17 @@ export namespace ACP {
820850
})
821851
break
822852
case "running":
853+
const output = this.bashOutput(part)
854+
const runningContent: ToolCallContent[] = []
855+
if (output) {
856+
runningContent.push({
857+
type: "content",
858+
content: {
859+
type: "text",
860+
text: output,
861+
},
862+
})
863+
}
823864
await this.connection
824865
.sessionUpdate({
825866
sessionId,
@@ -831,13 +872,15 @@ export namespace ACP {
831872
title: part.tool,
832873
locations: toLocations(part.tool, part.state.input),
833874
rawInput: part.state.input,
875+
...(runningContent.length > 0 && { content: runningContent }),
834876
},
835877
})
836878
.catch((err) => {
837879
log.error("failed to send tool in_progress to ACP", { error: err })
838880
})
839881
break
840882
case "completed":
883+
this.bashSnapshots.delete(part.callID)
841884
const kind = toToolKind(part.tool)
842885
const content: ToolCallContent[] = [
843886
{
@@ -916,6 +959,7 @@ export namespace ACP {
916959
})
917960
break
918961
case "error":
962+
this.bashSnapshots.delete(part.callID)
919963
await this.connection
920964
.sessionUpdate({
921965
sessionId,
@@ -937,6 +981,7 @@ export namespace ACP {
937981
],
938982
rawOutput: {
939983
error: part.state.error,
984+
metadata: part.state.metadata,
940985
},
941986
},
942987
})
@@ -1063,6 +1108,14 @@ export namespace ACP {
10631108
}
10641109
}
10651110

1111+
private bashOutput(part: ToolPart) {
1112+
if (part.tool !== "bash") return
1113+
if (!("metadata" in part.state) || !part.state.metadata || typeof part.state.metadata !== "object") return
1114+
const output = part.state.metadata["output"]
1115+
if (typeof output !== "string") return
1116+
return output
1117+
}
1118+
10661119
private async loadAvailableModes(directory: string): Promise<ModeOption[]> {
10671120
const agents = await this.config.sdk.app
10681121
.agents(

0 commit comments

Comments
 (0)