Skip to content

Commit 22d75e5

Browse files
committed
fix(acp): forward subagent session events to ACP client
Child sessions created by the Task tool have their own session IDs that are not registered in ACPSessionManager. This caused all message.part.updated, message.part.delta, and permission.asked events from subagents to be silently dropped in handleEvent(). Add resolveRootSession() which walks the parentID chain via the SDK to find the nearest ACP-tracked ancestor session. Events from child sessions are now forwarded to the ACP client attributed to the root session ID, making subagent activity visible in clients like agent-shell. A childToRootSession cache avoids repeated SDK calls for the same child session across multiple events.
1 parent 66de7be commit 22d75e5

1 file changed

Lines changed: 54 additions & 5 deletions

File tree

packages/opencode/src/acp/agent.ts

Lines changed: 54 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ import { pathToFileURL } from "url"
3636
import { Filesystem } from "../util/filesystem"
3737
import { Hash } from "../util/hash"
3838
import { ACPSessionManager } from "./session"
39-
import type { ACPConfig } from "./types"
39+
import type { ACPConfig, ACPSessionState } from "./types"
4040
import { Provider } from "../provider/provider"
4141
import { ModelID, ProviderID } from "../provider/schema"
4242
import { Agent as AgentModule } from "../agent/agent"
@@ -145,6 +145,8 @@ export namespace ACP {
145145
private bashSnapshots = new Map<string, string>()
146146
private toolStarts = new Set<string>()
147147
private permissionQueues = new Map<string, Promise<void>>()
148+
// Maps child session IDs to their root ACP-tracked session ID
149+
private childToRootSession = new Map<string, string>()
148150
private permissionOptions: PermissionOption[] = [
149151
{ optionId: "once", kind: "allow_once", name: "Allow once" },
150152
{ optionId: "always", kind: "allow_always", name: "Always allow" },
@@ -185,11 +187,55 @@ export namespace ACP {
185187
}
186188
}
187189

190+
/**
191+
* Given a session ID, returns the ACPSessionState for that session or the
192+
* nearest ACP-tracked ancestor. Child sessions created by the Task tool
193+
* have a parentID chain leading back to the root ACP session. We cache
194+
* the mapping so we only traverse the chain once per child session.
195+
*/
196+
private async resolveRootSession(sessionID: string): Promise<ACPSessionState | undefined> {
197+
// Fast path: directly tracked by ACP session manager
198+
const direct = this.sessionManager.tryGet(sessionID)
199+
if (direct) return direct
200+
201+
// Check cache
202+
const cached = this.childToRootSession.get(sessionID)
203+
if (cached) return this.sessionManager.tryGet(cached)
204+
205+
// Walk the parentID chain via the SDK until we find a known root session
206+
let currentID = sessionID
207+
const visited: string[] = []
208+
while (true) {
209+
visited.push(currentID)
210+
const info = await this.sdk.session
211+
.get({ sessionID: currentID, directory: "" }, { throwOnError: false })
212+
.then((x) => x.data)
213+
.catch(() => undefined)
214+
215+
if (!info) break
216+
217+
const parentID = info.parentID
218+
if (!parentID) break
219+
220+
const root = this.sessionManager.tryGet(parentID)
221+
if (root) {
222+
// Cache the mapping for all visited IDs
223+
for (const id of visited) {
224+
this.childToRootSession.set(id, root.id)
225+
}
226+
return root
227+
}
228+
currentID = parentID
229+
}
230+
231+
return undefined
232+
}
233+
188234
private async handleEvent(event: Event) {
189235
switch (event.type) {
190236
case "permission.asked": {
191237
const permission = event.properties
192-
const session = this.sessionManager.tryGet(permission.sessionID)
238+
const session = await this.resolveRootSession(permission.sessionID)
193239
if (!session) return
194240

195241
const prev = this.permissionQueues.get(permission.sessionID) ?? Promise.resolve()
@@ -272,7 +318,7 @@ export namespace ACP {
272318
log.info("message part updated", { event: event.properties })
273319
const props = event.properties
274320
const part = props.part
275-
const session = this.sessionManager.tryGet(part.sessionID)
321+
const session = await this.resolveRootSession(part.sessionID)
276322
if (!session) return
277323
const sessionId = session.id
278324

@@ -464,16 +510,19 @@ export namespace ACP {
464510

465511
case "message.part.delta": {
466512
const props = event.properties
467-
const session = this.sessionManager.tryGet(props.sessionID)
513+
const session = await this.resolveRootSession(props.sessionID)
468514
if (!session) return
469515
const sessionId = session.id
516+
// Use the child session's own cwd for SDK lookups (may differ from root)
517+
const childSession = this.sessionManager.tryGet(props.sessionID)
518+
const directory = childSession?.cwd ?? session.cwd
470519

471520
const message = await this.sdk.session
472521
.message(
473522
{
474523
sessionID: props.sessionID,
475524
messageID: props.messageID,
476-
directory: session.cwd,
525+
directory,
477526
},
478527
{ throwOnError: true },
479528
)

0 commit comments

Comments
 (0)