From 143a77637398d456d3041e86a98942669ba54954 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Wed, 5 Aug 2026 18:57:15 +0530 Subject: [PATCH] fix(acp): surface subagent activity (#40438) --- packages/cli/src/acp/event.ts | 231 +++++++++++++++--- packages/cli/src/acp/permission.ts | 20 +- packages/cli/src/acp/service.ts | 39 ++- packages/cli/test/acp/event-behavior.test.ts | 192 ++++++++++++++- .../cli/test/acp/permission-behavior.test.ts | 62 +++++ packages/cli/test/acp/service.test.ts | 7 + 6 files changed, 502 insertions(+), 49 deletions(-) diff --git a/packages/cli/src/acp/event.ts b/packages/cli/src/acp/event.ts index 33fcdd21837..7d6e2b586f4 100644 --- a/packages/cli/src/acp/event.ts +++ b/packages/cli/src/acp/event.ts @@ -1,4 +1,4 @@ -import type { AgentSideConnection, PromptResponse } from "@agentclientprotocol/sdk" +import type { AgentSideConnection, PromptResponse, SessionUpdate } from "@agentclientprotocol/sdk" import type { EventSubscribeOutput, OpenCodeClient, @@ -37,6 +37,34 @@ export type TurnStart = | { readonly type: "skill"; readonly id: string } | { readonly type: "compaction"; readonly id: string } +export const ChildSessionUpdatesCapability = "opencode/child-session-updates" +export const ChildSessionUpdateMethod = "opencode/session/child_update" + +type ChildSessionUpdateBase = { + readonly rootSessionId: string + readonly childSessionId: string + readonly parentSessionId: string + readonly depth: number + readonly title?: string +} + +type ChildSessionEvent = + | { readonly type: "update"; readonly update: SessionUpdate } + | { + readonly type: "status" + readonly status: "created" | "running" | "completed" | "failed" | "interrupted" + readonly error?: { readonly type: string; readonly message: string } + } + +export type ChildSessionUpdate = ChildSessionUpdateBase & ChildSessionEvent + +type ChildSession = { + readonly id: string + readonly parentID: string + readonly depth: number + readonly title?: string +} + function emptyToolState(): ToolState { return { name: "tool", input: {}, metadata: {}, content: [] } } @@ -50,8 +78,13 @@ export async function streamTurn(input: { readonly writeTextFile: boolean readonly submit: (signal: AbortSignal) => Promise readonly control: TurnControl + readonly childSessionUpdate?: (update: ChildSessionUpdate) => Promise + readonly connectionSignal?: AbortSignal + readonly sessionSignal?: AbortSignal }): Promise { const streamController = new AbortController() + const connectionAbort = () => streamController.abort() + input.connectionSignal?.addEventListener("abort", connectionAbort, { once: true }) const stream = input.client.event.subscribe({ signal: streamController.signal })[Symbol.asyncIterator]() const connected = await stream.next() if (connected.done) throw new Error("event stream disconnected before prompt admission") @@ -62,47 +95,101 @@ export async function streamTurn(input: { let finish: SessionMessageAssistant["finish"] let executionError: { readonly type: string; readonly message: string } | undefined const tools = new Map() + const children = new Map() + const openChildren = new Set() + let handedOff = false - const update = (value: Parameters[0]["update"]) => - input.connection.sessionUpdate({ sessionId: input.sessionID, update: value }) + const notifyChild = async (child: ChildSession, value: ChildSessionEvent) => { + if (!input.childSessionUpdate) return + await input + .childSessionUpdate({ + rootSessionId: input.sessionID, + childSessionId: child.id, + parentSessionId: child.parentID, + depth: child.depth, + ...(child.title ? { title: child.title } : {}), + ...value, + }) + .catch(() => {}) + } - const consume = async () => { + const updateSession = async (value: SessionUpdate, child: ChildSession | undefined, mode: "turn" | "background") => { + const projected = child ? projectChildUpdate(value, child) : value + if (mode === "turn" && (!child || !input.childSessionUpdate)) { + await input.connection.sessionUpdate({ sessionId: input.sessionID, update: projected }) + } + if (child) await notifyChild(child, { type: "update", update: projected }) + } + + const consume = async (mode: "turn" | "background") => { while (!streamController.signal.aborted) { const next = await stream.next() if (next.done) throw new Error("event stream disconnected during prompt execution") const event = next.value - if (event.type === "permission.asked" && event.data.sessionID === input.sessionID) { - const tool = event.data.source?.id ? tools.get(event.data.source.id) : undefined + if (event.type === "session.created") { + const parentID = event.data.info.parentID + if (!parentID) continue + const parent = parentID === input.sessionID ? undefined : children.get(parentID) + if ((mode === "turn" && parentID === input.sessionID) || parent) { + const child = { + id: event.data.sessionID, + parentID, + depth: parent ? parent.depth + 1 : 1, + title: event.data.info.title, + } + children.set(child.id, child) + openChildren.add(child.id) + await notifyChild(child, { type: "status", status: "created" }) + } + continue + } + + const eventSessionID = sessionIDFromEvent(event) + const child = eventSessionID ? children.get(eventSessionID) : undefined + const send = (update: SessionUpdate) => updateSession(update, child, mode) + if (mode === "background" && !child) continue + + if (event.type === "permission.asked" && (event.data.sessionID === input.sessionID || child)) { + const tool = event.data.source?.id ? tools.get(toolKey(event.data.sessionID, event.data.source.id)) : undefined await replyPermission({ client: input.client, connection: input.connection, event, - sessionID: input.sessionID, + sessionID: event.data.sessionID, + clientSessionID: input.sessionID, cwd: input.cwd, tool, + ...(child ? { toolCallPrefix: child.id, titlePrefix: child.title } : {}), }) continue } - if (event.type === "form.created" && event.data.form.sessionID === input.sessionID) { + if (event.type === "form.created" && (event.data.form.sessionID === input.sessionID || child)) { await input.client.form - .cancel({ sessionID: input.sessionID, formID: event.data.form.id }) - .catch(() => input.client.session.interrupt({ sessionID: input.sessionID }).catch(() => {})) + .cancel({ sessionID: event.data.form.sessionID, formID: event.data.form.id }) + .catch(() => input.client.session.interrupt({ sessionID: event.data.form.sessionID }).catch(() => {})) continue } - if (!("sessionID" in event.data) || event.data.sessionID !== input.sessionID) continue + if (!eventSessionID || (eventSessionID !== input.sessionID && !child)) continue if (matchesStart(event, input.start)) { started = true continue } if (!started) continue + if (event.type === "session.execution.started") { + if (child) { + await notifyChild(child, { type: "status", status: "running" }) + } + continue + } + if (event.type === "session.step.started") { - assistantMessageID = event.data.assistantMessageID + if (!child) assistantMessageID = event.data.assistantMessageID continue } if (event.type === "session.text.delta") { - assistantMessageID = event.data.assistantMessageID - await update({ + if (!child) assistantMessageID = event.data.assistantMessageID + await send({ sessionUpdate: "agent_message_chunk", messageId: event.data.assistantMessageID, content: { type: "text", text: event.data.delta }, @@ -110,8 +197,8 @@ export async function streamTurn(input: { continue } if (event.type === "session.reasoning.delta") { - assistantMessageID = event.data.assistantMessageID - await update({ + if (!child) assistantMessageID = event.data.assistantMessageID + await send({ sessionUpdate: "agent_thought_chunk", messageId: event.data.assistantMessageID, content: { type: "text", text: event.data.delta }, @@ -119,9 +206,14 @@ export async function streamTurn(input: { continue } if (event.type === "session.tool.input.started") { - assistantMessageID = event.data.assistantMessageID - tools.set(event.data.id, { name: event.data.name, input: {}, metadata: {}, content: [] }) - await update({ + if (!child) assistantMessageID = event.data.assistantMessageID + tools.set(toolKey(event.data.sessionID, event.data.id), { + name: event.data.name, + input: {}, + metadata: {}, + content: [], + }) + await send({ sessionUpdate: "tool_call", ...pendingToolCall({ toolCallId: event.data.id, @@ -133,11 +225,12 @@ export async function streamTurn(input: { continue } if (event.type === "session.tool.called") { - assistantMessageID = event.data.assistantMessageID - const current = tools.get(event.data.id) ?? emptyToolState() + if (!child) assistantMessageID = event.data.assistantMessageID + const key = toolKey(event.data.sessionID, event.data.id) + const current = tools.get(key) ?? emptyToolState() current.input = event.data.input - tools.set(event.data.id, current) - await update({ + tools.set(key, current) + await send({ sessionUpdate: "tool_call_update", ...runningToolUpdate({ toolCallId: event.data.id, @@ -149,10 +242,10 @@ export async function streamTurn(input: { continue } if (event.type === "session.tool.progress") { - const current = tools.get(event.data.id) + const current = tools.get(toolKey(event.data.sessionID, event.data.id)) if (!current) continue current.metadata = event.data.metadata - await update({ + await send({ sessionUpdate: "tool_call_update", ...runningToolUpdate({ toolCallId: event.data.id, @@ -164,8 +257,9 @@ export async function streamTurn(input: { continue } if (event.type === "session.tool.success") { - const current = tools.get(event.data.id) ?? emptyToolState() - tools.delete(event.data.id) + const key = toolKey(event.data.sessionID, event.data.id) + const current = tools.get(key) ?? emptyToolState() + tools.delete(key) await syncEditedFiles({ connection: input.connection, writeTextFile: input.writeTextFile, @@ -175,7 +269,7 @@ export async function streamTurn(input: { toolInput: current.input, metadata: event.data.metadata ?? {}, }).catch(() => {}) - await update({ + await send({ sessionUpdate: "tool_call_update", ...completedToolUpdate({ toolCallId: event.data.id, @@ -188,9 +282,10 @@ export async function streamTurn(input: { continue } if (event.type === "session.tool.failed") { - const current = tools.get(event.data.id) ?? emptyToolState() - tools.delete(event.data.id) - await update({ + const key = toolKey(event.data.sessionID, event.data.id) + const current = tools.get(key) ?? emptyToolState() + tools.delete(key) + await send({ sessionUpdate: "tool_call_update", ...errorToolUpdate({ toolCallId: event.data.id, @@ -205,13 +300,33 @@ export async function streamTurn(input: { continue } if (event.type === "session.step.ended") { - assistantMessageID = event.data.assistantMessageID - finish = event.data.finish + if (!child) { + assistantMessageID = event.data.assistantMessageID + finish = event.data.finish + } + continue + } + if (event.type === "session.execution.succeeded") { + if (!child) return "succeeded" as const + openChildren.delete(child.id) + await notifyChild(child, { type: "status", status: "completed" }) + if (mode === "background" && openChildren.size === 0) return "succeeded" as const + continue + } + if (event.type === "session.execution.interrupted") { + if (!child) return "interrupted" as const + openChildren.delete(child.id) + await notifyChild(child, { type: "status", status: "interrupted" }) + if (mode === "background" && openChildren.size === 0) return "interrupted" as const continue } - if (event.type === "session.execution.succeeded") return "succeeded" as const - if (event.type === "session.execution.interrupted") return "interrupted" as const if (event.type === "session.execution.failed") { + if (child) { + openChildren.delete(child.id) + await notifyChild(child, { type: "status", status: "failed", error: event.data.error }) + if (mode === "background" && openChildren.size === 0) return "failed" as const + continue + } executionError = event.data.error return "failed" as const } @@ -219,7 +334,13 @@ export async function streamTurn(input: { return "interrupted" as const } - const completed = consume() + const completed = consume("turn") + const closeStream = async () => { + streamController.abort() + input.connectionSignal?.removeEventListener("abort", connectionAbort) + input.sessionSignal?.removeEventListener("abort", connectionAbort) + await stream.return?.(undefined).catch(() => {}) + } try { await input.submit(control.admission.signal).catch((error) => { if (!control.cancelled) throw error @@ -233,6 +354,13 @@ export async function streamTurn(input: { } } const terminal = await completed + if (input.childSessionUpdate && openChildren.size > 0 && !input.sessionSignal?.aborted) { + handedOff = true + input.sessionSignal?.addEventListener("abort", connectionAbort, { once: true }) + void consume("background") + .catch(() => {}) + .finally(closeStream) + } const assistant = assistantMessageID ? await input.client.session .message({ sessionID: input.sessionID, messageID: assistantMessageID }) @@ -250,11 +378,38 @@ export async function streamTurn(input: { await completed.catch(() => {}) throw error } finally { - streamController.abort() - await stream.return?.(undefined).catch(() => {}) + if (!handedOff) await closeStream() } } +function sessionIDFromEvent(event: EventSubscribeOutput) { + if ("sessionID" in event.data && typeof event.data.sessionID === "string") return event.data.sessionID + if (event.type === "form.created") return event.data.form.sessionID + return undefined +} + +function toolKey(sessionID: string, id: string) { + return `${sessionID}:${id}` +} + +function projectChildUpdate(update: SessionUpdate, child: ChildSession) { + const projected = { ...update } + projected._meta = { + ...projected._meta, + "opencode/child-session": { + id: child.id, + parentID: child.parentID, + depth: child.depth, + ...(child.title ? { title: child.title } : {}), + }, + } + if (projected.sessionUpdate === "tool_call" || projected.sessionUpdate === "tool_call_update") { + projected.toolCallId = `${child.id}:${projected.toolCallId}` + if (projected.title && child.title) projected.title = `${child.title}: ${projected.title}` + } + return projected +} + export async function replayMessages( connection: Pick, sessionID: string, diff --git a/packages/cli/src/acp/permission.ts b/packages/cli/src/acp/permission.ts index d32073442ea..cef7fc2cf4c 100644 --- a/packages/cli/src/acp/permission.ts +++ b/packages/cli/src/acp/permission.ts @@ -20,20 +20,28 @@ export async function replyPermission(input: { readonly connection: Connection readonly event: PermissionEvent readonly sessionID: string + readonly clientSessionID?: string readonly cwd: string readonly tool?: Tool + readonly toolCallPrefix?: string + readonly titlePrefix?: string }) { const toolName = input.tool?.name ?? input.event.data.action const toolInput = { ...input.event.data.metadata, ...input.tool?.input } const previews = await permissionPreviews(toolName, toolInput, input.cwd) + const toolCallID = input.event.data.source?.id ?? input.event.data.id + const title = permissionTitle(toolName, toolInput, previews) const result = await input.connection .requestPermission({ - sessionId: input.sessionID, + sessionId: input.clientSessionID ?? input.sessionID, toolCall: { ...pendingToolCall({ - toolCallId: input.event.data.source?.id ?? input.event.data.id, + toolCallId: input.toolCallPrefix ? `${input.toolCallPrefix}:${toolCallID}` : toolCallID, toolName, - state: { input: toolInput, title: permissionTitle(toolName, toolInput, previews) }, + state: { + input: toolInput, + title: prefixedTitle(input.titlePrefix, title), + }, cwd: input.cwd, }), locations: permissionLocations(toolName, toolInput, input.event.data.resources, input.cwd, previews), @@ -51,6 +59,12 @@ export async function replyPermission(input: { }) } +function prefixedTitle(prefix: string | undefined, title: string | undefined) { + if (!prefix) return title + if (!title) return prefix + return `${prefix}: ${title}` +} + export async function syncEditedFiles(input: { readonly connection: Partial> readonly writeTextFile: boolean diff --git a/packages/cli/src/acp/service.ts b/packages/cli/src/acp/service.ts index cc1dd8896a2..4e7db628a9f 100644 --- a/packages/cli/src/acp/service.ts +++ b/packages/cli/src/acp/service.ts @@ -43,13 +43,21 @@ import { OPENCODE_VERSION } from "../version" import { SessionMessage } from "@opencode-ai/schema/session-message" import { buildConfigOptions, parseModelSelection, type ConfigOptionProvider } from "./config-option" import { promptContentToParts } from "./content" -import { replayMessages, streamTurn, type TurnControl, type TurnStart } from "./event" +import { + ChildSessionUpdateMethod, + ChildSessionUpdatesCapability, + replayMessages, + streamTurn, + type ChildSessionUpdate, + type TurnControl, + type TurnStart, +} from "./event" import { ACPError } from "./error" export const AuthMethodID = "opencode-login" type Connection = Pick & - Partial> + Partial> type Catalog = { readonly providers: ConfigOptionProvider[] @@ -64,6 +72,7 @@ type Catalog = { type Attached = { readonly id: string readonly cwd: string + readonly abort: AbortController catalog: Catalog model: ModelRef modeID: string @@ -100,7 +109,7 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti const catalogs = new Map>() const registeredMcp = new Map>() const active = new Map() - const capabilities = { writeTextFile: false } + const capabilities = { writeTextFile: false, childSessionUpdates: false } const catalog = (cwd: string) => { const cached = catalogs.get(cwd) @@ -119,11 +128,19 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti throw new ACPError.SessionNotFoundError({ sessionId: sessionID }) } + const detach = (sessionID: string) => { + sessions.get(sessionID)?.abort.abort() + sessions.delete(sessionID) + registeredMcp.delete(sessionID) + } + const attach = async (session: SessionInfo, cwd: string, mcpServers: readonly McpServer[]) => { const currentCatalog = await catalog(cwd) + sessions.get(session.id)?.abort.abort() const state: Attached = { id: session.id, cwd, + abort: new AbortController(), catalog: currentCatalog, model: session.model ?? currentCatalog.defaultModel, modeID: session.agent ?? currentCatalog.defaultModeID, @@ -161,6 +178,7 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti return { initialize: async (params) => { capabilities.writeTextFile = params.clientCapabilities?.fs?.writeTextFile === true + capabilities.childSessionUpdates = params.clientCapabilities?._meta?.[ChildSessionUpdatesCapability] === true const authMethod: AuthMethod = { description: "Run `opencode auth login` in the terminal", name: "Login with opencode", @@ -178,6 +196,7 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti mcpCapabilities: { http: true, sse: false }, promptCapabilities: { embeddedContext: true, image: true }, sessionCapabilities: { close: {}, delete: {}, fork: {}, list: {}, resume: {} }, + _meta: { [ChildSessionUpdatesCapability]: true }, }, authMethods: [authMethod], agentInfo: { name: "OpenCode", version: OPENCODE_VERSION }, @@ -224,8 +243,7 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti await input.client.session.remove({ sessionID: params.sessionId }).catch((error) => { if (!isSessionNotFoundError(error)) throw error }) - sessions.delete(params.sessionId) - registeredMcp.delete(params.sessionId) + detach(params.sessionId) return {} }, resumeSession: async (params) => { @@ -234,8 +252,7 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti return { configOptions: configOptions(state) } }, closeSession: async (params) => { - sessions.delete(params.sessionId) - registeredMcp.delete(params.sessionId) + detach(params.sessionId) const turn = active.get(params.sessionId) if (turn) { turn.cancelled = true @@ -296,6 +313,11 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti const messageID = SessionMessage.ID.create() const prepared = preparePrompt(state.catalog, params.prompt, messageID) const control: TurnControl = { cancelled: false, admission: new AbortController() } + const extNotification = input.connection.extNotification + const childSessionUpdate = + capabilities.childSessionUpdates && extNotification + ? (update: ChildSessionUpdate) => extNotification(ChildSessionUpdateMethod, update).then(() => {}) + : undefined active.set(state.id, control) const response = await streamTurn({ client: input.client, @@ -305,7 +327,10 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti start: prepared.start, writeTextFile: capabilities.writeTextFile, control, + connectionSignal: input.connection.signal, + sessionSignal: state.abort.signal, submit: (signal) => submitPrompt(input.client, state, prepared, signal), + ...(childSessionUpdate ? { childSessionUpdate } : {}), }).finally(() => { if (active.get(state.id) === control) active.delete(state.id) }) diff --git a/packages/cli/test/acp/event-behavior.test.ts b/packages/cli/test/acp/event-behavior.test.ts index 998b2b665b0..71ea8382591 100644 --- a/packages/cli/test/acp/event-behavior.test.ts +++ b/packages/cli/test/acp/event-behavior.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test" import type { AgentSideConnection } from "@agentclientprotocol/sdk" import type { SessionMessageInfo } from "@opencode-ai/client/promise" import { resolve } from "node:path" -import { replayMessages, streamTurn, type TurnControl } from "../../src/acp/event" +import { replayMessages, streamTurn, type ChildSessionUpdate, type TurnControl } from "../../src/acp/event" import { createSseFixture, durableEvent, ephemeralEvent, withTimeout } from "./sse-fixture" type SessionUpdateParams = Parameters[0] @@ -191,6 +191,181 @@ describe("acp event behavior", () => { } }) + test("projects foreground child session updates onto the parent turn", async () => { + const updates: SessionUpdateParams[] = [] + const fixture = createSseFixture({ + onPrompt({ id, send }) { + send(durableEvent("session.input.promoted", { sessionID: "ses_parent", inputID: id })) + send( + durableEvent("session.created", { + sessionID: "ses_child", + info: childSession("ses_child", "ses_parent", "Explore code"), + }), + ) + send(durableEvent("session.execution.started", { sessionID: "ses_child" })) + send( + durableEvent("session.tool.input.started", { + sessionID: "ses_child", + assistantMessageID: "msg_child", + id: "call_read", + name: "read", + }), + ) + send( + durableEvent("session.tool.called", { + sessionID: "ses_child", + assistantMessageID: "msg_child", + id: "call_read", + input: { path: "/workspace/src/index.ts" }, + executed: false, + }), + ) + send( + durableEvent("session.tool.success", { + sessionID: "ses_child", + assistantMessageID: "msg_child", + id: "call_read", + metadata: {}, + content: [{ type: "text", text: "source" }], + executed: true, + }), + ) + send(durableEvent("session.execution.succeeded", { sessionID: "ses_child" })) + send(durableEvent("session.execution.succeeded", { sessionID: "ses_parent" })) + }, + }) + + try { + const response = await turn({ + fixture, + connection: recordingConnection(updates), + sessionID: "ses_parent", + inputID: "input_parent", + }) + + expect(updates.map((item) => [item.sessionId, item.update.sessionUpdate])).toEqual([ + ["ses_parent", "tool_call"], + ["ses_parent", "tool_call_update"], + ["ses_parent", "tool_call_update"], + ]) + expect(updates.map((item) => ("toolCallId" in item.update ? item.update.toolCallId : undefined))).toEqual([ + "ses_child:call_read", + "ses_child:call_read", + "ses_child:call_read", + ]) + expect(updates[0]?.update).toMatchObject({ + title: "Explore code: read", + _meta: { + "opencode/child-session": { + id: "ses_child", + parentID: "ses_parent", + depth: 1, + title: "Explore code", + }, + }, + }) + expect(response.stopReason).toBe("end_turn") + } finally { + await fixture.stop() + } + }) + + test("continues child extension updates after the parent turn ends", async () => { + const updates: SessionUpdateParams[] = [] + const childUpdates: ChildSessionUpdate[] = [] + const completed = Promise.withResolvers() + const fixture = createSseFixture({ + onPrompt({ id, send }) { + send(durableEvent("session.input.promoted", { sessionID: "ses_parent", inputID: id })) + send( + durableEvent("session.created", { + sessionID: "ses_background", + info: childSession("ses_background", "ses_parent", "Background research"), + }), + ) + send(durableEvent("session.execution.succeeded", { sessionID: "ses_parent" })) + }, + }) + + try { + const response = await turn({ + fixture, + connection: recordingConnection(updates), + sessionID: "ses_parent", + inputID: "input_parent", + childSessionUpdate: async (update) => { + childUpdates.push(update) + if (update.type === "status" && update.status === "completed") completed.resolve() + }, + }) + expect(response.stopReason).toBe("end_turn") + + fixture.send( + durableEvent("session.created", { + sessionID: "ses_future", + info: childSession("ses_future", "ses_parent", "Later turn child"), + }), + ) + fixture.send(durableEvent("session.execution.started", { sessionID: "ses_future" })) + fixture.send(durableEvent("session.execution.started", { sessionID: "ses_background" })) + fixture.send( + durableEvent("session.tool.input.started", { + sessionID: "ses_background", + assistantMessageID: "msg_background", + id: "call_shell", + name: "shell", + }), + ) + fixture.send( + durableEvent("session.tool.called", { + sessionID: "ses_background", + assistantMessageID: "msg_background", + id: "call_shell", + input: { command: "pwd" }, + executed: false, + }), + ) + fixture.send( + durableEvent("session.tool.success", { + sessionID: "ses_background", + assistantMessageID: "msg_background", + id: "call_shell", + metadata: { exit: 0 }, + content: [{ type: "text", text: "/workspace" }], + executed: true, + }), + ) + fixture.send(durableEvent("session.execution.succeeded", { sessionID: "ses_background" })) + await withTimeout(completed.promise, "background child completion was not delivered") + + expect(updates).toEqual([]) + expect( + childUpdates.map((update) => + update.type === "status" ? [update.type, update.status] : [update.type, update.update.sessionUpdate], + ), + ).toEqual([ + ["status", "created"], + ["status", "running"], + ["update", "tool_call"], + ["update", "tool_call_update"], + ["update", "tool_call_update"], + ["status", "completed"], + ]) + expect(childUpdates[2]).toMatchObject({ + rootSessionId: "ses_parent", + childSessionId: "ses_background", + parentSessionId: "ses_parent", + depth: 1, + title: "Background research", + type: "update", + update: { toolCallId: "ses_background:call_shell" }, + }) + expect(childUpdates.some((update) => update.childSessionId === "ses_future")).toBe(false) + } finally { + await fixture.stop() + } + }) + test("streams tool pending, progress, success, and failure updates", async () => { const updates: SessionUpdateParams[] = [] const fixture = createSseFixture({ @@ -556,6 +731,7 @@ function turn(input: { readonly connection: Connection readonly sessionID: string readonly inputID: string + readonly childSessionUpdate?: (update: ChildSessionUpdate) => Promise }) { return streamTurn({ client: input.fixture.client, @@ -565,11 +741,25 @@ function turn(input: { start: { type: "input", id: input.inputID }, writeTextFile: false, control: { cancelled: false, admission: new AbortController() }, + childSessionUpdate: input.childSessionUpdate, submit: (signal) => input.fixture.client.session.prompt({ sessionID: input.sessionID, id: input.inputID, text: "hello" }, { signal }), }) } +function childSession(id: string, parentID: string, title: string) { + return { + id, + slug: id, + projectID: "project", + directory: "/workspace", + parentID, + title, + version: "test", + time: { created: 1, updated: 1 }, + } +} + function tokens() { return { input: 1, output: 1, reasoning: 0, cache: { read: 0, write: 0 } } } diff --git a/packages/cli/test/acp/permission-behavior.test.ts b/packages/cli/test/acp/permission-behavior.test.ts index d32150bd409..a0044f1ff9d 100644 --- a/packages/cli/test/acp/permission-behavior.test.ts +++ b/packages/cli/test/acp/permission-behavior.test.ts @@ -153,6 +153,68 @@ describe("acp permission behavior", () => { } }) + test("routes foreground child permissions through the parent ACP session", async () => { + const permissionRequests: RequestPermissionRequest[] = [] + const fixture = createSseFixture({ + onPrompt({ id, send }) { + send(durableEvent("session.input.promoted", { sessionID: "ses_parent", inputID: id })) + send( + durableEvent("session.created", { + sessionID: "ses_child", + info: { + id: "ses_child", + slug: "ses_child", + projectID: "project", + directory: "/workspace", + parentID: "ses_parent", + title: "Review code", + version: "test", + time: { created: 1, updated: 1 }, + }, + }), + ) + send(durableEvent("session.execution.started", { sessionID: "ses_child" })) + send( + permissionAsked("ses_child", "perm_child", { + action: "read", + metadata: { path: "/workspace/child.ts" }, + source: { type: "tool", messageID: "msg_child", id: "call_child" }, + }), + ) + send(durableEvent("session.execution.succeeded", { sessionID: "ses_child" })) + send(durableEvent("session.execution.succeeded", { sessionID: "ses_parent" })) + }, + }) + const connection = { + sessionUpdate: async () => {}, + requestPermission: async (request) => { + permissionRequests.push(request) + return { outcome: { outcome: "selected", optionId: "once" } } as const + }, + } satisfies Connection + + try { + await startTurn(fixture, connection, "ses_parent", "input_parent") + + expect(permissionRequests).toHaveLength(1) + expect(permissionRequests[0]).toMatchObject({ + sessionId: "ses_parent", + toolCall: { + toolCallId: "ses_child:call_child", + title: "Review code: /workspace/child.ts", + }, + }) + expect(fixture.requests).toContainEqual( + expect.objectContaining({ + method: "POST", + path: "/api/session/ses_child/permission/perm_child/reply", + }), + ) + } finally { + await fixture.stop() + } + }) + test("previews edits during approval and syncs the completed file", async () => { const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-acp-permission-")) const file = path.join(cwd, "file.ts") diff --git a/packages/cli/test/acp/service.test.ts b/packages/cli/test/acp/service.test.ts index 08bd95b310e..d2bd8371926 100644 --- a/packages/cli/test/acp/service.test.ts +++ b/packages/cli/test/acp/service.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test" import type { AgentSideConnection } from "@agentclientprotocol/sdk" import { OpenCode } from "@opencode-ai/client/promise" import { ACPService } from "../../src/acp/service" +import { ChildSessionUpdatesCapability } from "../../src/acp/event" describe("acp service", () => { test("creates a v2 session, registers mcp, and publishes commands", async () => { @@ -39,11 +40,17 @@ describe("acp service", () => { }) try { + const initialized = await service.initialize({ + protocolVersion: 1, + clientCapabilities: { _meta: { [ChildSessionUpdatesCapability]: true } }, + clientInfo: { name: "test", version: "1" }, + }) const result = await service.newSession({ cwd: "/workspace", mcpServers: [{ name: "docs", command: "bun", args: ["docs.ts"], env: [{ name: "TOKEN", value: "x" }] }], }) expect(result.sessionId).toBe("ses_acp") + expect(initialized.agentCapabilities?._meta).toEqual({ [ChildSessionUpdatesCapability]: true }) expect(result.configOptions?.map((option) => option.id)).toEqual(["model", "effort", "mode"]) expect(requests).toContainEqual({ method: "PUT",