Skip to content

Commit afa6984

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 663e798 commit afa6984

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"
@@ -144,6 +144,8 @@ export namespace ACP {
144144
private bashSnapshots = new Map<string, string>()
145145
private toolStarts = new Set<string>()
146146
private permissionQueues = new Map<string, Promise<void>>()
147+
// Maps child session IDs to their root ACP-tracked session ID
148+
private childToRootSession = new Map<string, string>()
147149
private permissionOptions: PermissionOption[] = [
148150
{ optionId: "once", kind: "allow_once", name: "Allow once" },
149151
{ optionId: "always", kind: "allow_always", name: "Always allow" },
@@ -184,11 +186,55 @@ export namespace ACP {
184186
}
185187
}
186188

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

194240
const prev = this.permissionQueues.get(permission.sessionID) ?? Promise.resolve()
@@ -271,7 +317,7 @@ export namespace ACP {
271317
log.info("message part updated", { event: event.properties })
272318
const props = event.properties
273319
const part = props.part
274-
const session = this.sessionManager.tryGet(part.sessionID)
320+
const session = await this.resolveRootSession(part.sessionID)
275321
if (!session) return
276322
const sessionId = session.id
277323

@@ -470,16 +516,19 @@ export namespace ACP {
470516

471517
case "message.part.delta": {
472518
const props = event.properties
473-
const session = this.sessionManager.tryGet(props.sessionID)
519+
const session = await this.resolveRootSession(props.sessionID)
474520
if (!session) return
475521
const sessionId = session.id
522+
// Use the child session's own cwd for SDK lookups (may differ from root)
523+
const childSession = this.sessionManager.tryGet(props.sessionID)
524+
const directory = childSession?.cwd ?? session.cwd
476525

477526
const message = await this.sdk.session
478527
.message(
479528
{
480529
sessionID: props.sessionID,
481530
messageID: props.messageID,
482-
directory: session.cwd,
531+
directory,
483532
},
484533
{ throwOnError: true },
485534
)

0 commit comments

Comments
 (0)