From 0be4411c2324199a29c30f94bf40f530cade4707 Mon Sep 17 00:00:00 2001 From: huangzhibin1 Date: Thu, 9 Jul 2026 15:54:10 +0800 Subject: [PATCH] fix(tui): show permission prompts from nested subagent chains Nested subagents (subagent spawning a subagent) could trigger a permission request that was never rendered in the UI, deadlocking the whole call chain. The TUI and CLI only tracked direct child sessions, so grandchild session events were silently dropped. - tui: collect the full session subtree (not just direct children) when gathering pending permissions/questions - opencode: bootstrap the CLI subagent footer with all descendants via a level-by-level BFS instead of only direct children - opencode: register grandchild tabs when a known child session spawns a nested task, without skipping the child's own event tracking - app: add regression coverage confirming auto-accept rules already propagate correctly across a three-level session chain Signed-off-by: huangzhibin1 --- .../context/permission-auto-respond.test.ts | 39 ++++++ .../src/cli/cmd/run/stream.transport.ts | 47 +++++-- .../opencode/src/cli/cmd/run/subagent-data.ts | 7 +- .../test/cli/run/stream.transport.test.ts | 126 ++++++++++++++++++ .../test/cli/run/subagent-data.test.ts | 60 +++++++++ packages/tui/src/routes/session/index.tsx | 40 +++++- .../routes/session/collect-subtree.test.ts | 70 ++++++++++ 7 files changed, 372 insertions(+), 17 deletions(-) create mode 100644 packages/tui/test/routes/session/collect-subtree.test.ts diff --git a/packages/app/src/context/permission-auto-respond.test.ts b/packages/app/src/context/permission-auto-respond.test.ts index 002ae94e5b92..d699a36a3ea9 100644 --- a/packages/app/src/context/permission-auto-respond.test.ts +++ b/packages/app/src/context/permission-auto-respond.test.ts @@ -81,6 +81,45 @@ describe("autoRespondsPermission", () => { expect(autoRespondsPermission(autoAccept, sessions, permission("root"), directory)).toBe(false) }) + + test("inherits an ancestor's directory-scoped auto-accept across a grandchild", () => { + const directory = "/tmp/project" + // root → child → grandchild lineage + const sessions = [ + session({ id: "root" }), + session({ id: "child", parentID: "root" }), + session({ id: "grandchild", parentID: "child" }), + ] + const autoAccept = { + [`${base64Encode(directory)}/root`]: true, + } + + expect(autoRespondsPermission(autoAccept, sessions, permission("grandchild"), directory)).toBe(true) + }) + + test("inherits a root session's legacy auto-accept key from a grandchild", () => { + const sessions = [ + session({ id: "root" }), + session({ id: "child", parentID: "root" }), + session({ id: "grandchild", parentID: "child" }), + ] + + expect(autoRespondsPermission({ root: true }, sessions, permission("grandchild"), "/tmp/project")).toBe(true) + }) + + test("requires approval for a grandchild with no ancestor override", () => { + const sessions = [ + session({ id: "root" }), + session({ id: "child", parentID: "root" }), + session({ id: "grandchild", parentID: "child" }), + session({ id: "unrelated" }), + ] + const autoAccept = { + unrelated: true, + } + + expect(autoRespondsPermission(autoAccept, sessions, permission("grandchild"), "/tmp/project")).toBe(false) + }) }) describe("isDirectoryAutoAccepting", () => { diff --git a/packages/opencode/src/cli/cmd/run/stream.transport.ts b/packages/opencode/src/cli/cmd/run/stream.transport.ts index e4817f514dca..4c3f2a589a1b 100644 --- a/packages/opencode/src/cli/cmd/run/stream.transport.ts +++ b/packages/opencode/src/cli/cmd/run/stream.transport.ts @@ -15,7 +15,7 @@ // The tick counter prevents stale idle events from resolving the wrong turn. // We also re-check live session status before resolving an idle event so a // delayed idle from an older turn cannot complete a newer busy turn. -import type { Event, GlobalEvent, OpencodeClient } from "@opencode-ai/sdk/v2" +import type { Event, GlobalEvent, OpencodeClient, Session } from "@opencode-ai/sdk/v2" import { Context, Deferred, Effect, Exit, Layer, Scope, Stream } from "effect" import { makeRuntime } from "@/effect/run-service" import { @@ -388,6 +388,42 @@ function traceTabs(trace: Trace | undefined, prev: FooterSubagentTab[], next: Fo } } +const collectDescendantSessions = Effect.fn("RunStreamTransport.collectDescendantSessions")(function* ( + sdk: OpencodeClient, + rootSessionID: string, +) { + const visited = new Set([rootSessionID]) + const descendants: Session[] = [] + let frontier = [rootSessionID] + + while (frontier.length > 0) { + const levels = yield* Effect.all( + frontier.map((sessionID) => + Effect.promise(() => sdk.session.children({ sessionID })).pipe( + Effect.map((item) => item.data ?? []), + Effect.orElseSucceed(() => []), + ), + ), + { concurrency: "unbounded" }, + ) + + frontier = [] + for (const children of levels) { + for (const child of children) { + if (visited.has(child.id)) { + continue + } + + visited.add(child.id) + descendants.push(child) + frontier.push(child.id) + } + } + } + + return descendants +}) + function createLayer(input: StreamInput) { return Layer.fresh( Layer.effect( @@ -684,14 +720,7 @@ function createLayer(input: StreamInput) { : Math.max(input.replayLimit, SUBAGENT_BOOTSTRAP_LIMIT) : SUBAGENT_BOOTSTRAP_LIMIT, ), - Effect.promise(() => - input.sdk.session.children({ - sessionID: input.sessionID, - }), - ).pipe( - Effect.map((item) => item.data ?? []), - Effect.orElseSucceed(() => []), - ), + collectDescendantSessions(input.sdk, input.sessionID), Effect.promise(() => input.sdk.permission.list()).pipe( Effect.map((item) => item.data ?? []), Effect.orElseSucceed(() => []), diff --git a/packages/opencode/src/cli/cmd/run/subagent-data.ts b/packages/opencode/src/cli/cmd/run/subagent-data.ts index 172741d3b3cc..7c69a9f15313 100644 --- a/packages/opencode/src/cli/cmd/run/subagent-data.ts +++ b/packages/opencode/src/cli/cmd/run/subagent-data.ts @@ -798,6 +798,7 @@ export function reduceSubagentData(input: { }) { const event = input.event + let changed = false if (event.type === "message.part.updated") { const part = event.properties.part if (part.sessionID === input.sessionID) { @@ -807,6 +808,10 @@ export function reduceSubagentData(input: { return syncTaskTab(input.data, part) } + + if (part.type === "tool" && knownSession(input.data, part.sessionID)) { + changed = syncTaskTab(input.data, part) + } } const sessionID = @@ -871,6 +876,6 @@ export function reduceSubagentData(input: { event, thinking: input.thinking, limits: input.limits, - }) || cancelled + }) || cancelled || changed ) } diff --git a/packages/opencode/test/cli/run/stream.transport.test.ts b/packages/opencode/test/cli/run/stream.transport.test.ts index 5bb578447f1c..320e72ccf9b4 100644 --- a/packages/opencode/test/cli/run/stream.transport.test.ts +++ b/packages/opencode/test/cli/run/stream.transport.test.ts @@ -1399,6 +1399,132 @@ describe("run stream transport", () => { } }) + test("bootstraps grandchild tabs and permissions from a nested subagent chain", async () => { + const src = eventFeed() + const ui = footer() + const transport = await createSessionTransport({ + sdk: sdk({ + stream: src.stream, + messages: async ({ sessionID }) => { + if (sessionID === "session-1") { + return ok([ + assistantMessage({ + sessionID: "session-1", + id: "msg-1", + parts: [ + runningTool({ + sessionID: "session-1", + messageID: "msg-1", + id: "task-1", + callID: "call-1", + tool: "task", + body: { + description: "Explore run folder", + subagent_type: "explore", + }, + metadata: { + sessionId: "child-1", + }, + }), + ], + }), + ]) + } + + if (sessionID === "child-1") { + return ok([ + assistantMessage({ + sessionID: "child-1", + id: "msg-child-1", + parts: [ + runningTool({ + sessionID: "child-1", + messageID: "msg-child-1", + id: "task-2", + callID: "call-2", + tool: "task", + body: { + description: "Grep for callers", + subagent_type: "explore", + }, + metadata: { + sessionId: "grandchild-1", + }, + }), + ], + }), + ]) + } + + return ok([ + assistantMessage({ + sessionID: "grandchild-1", + id: "msg-grandchild-1", + parts: [ + runningTool({ + sessionID: "grandchild-1", + messageID: "msg-grandchild-1", + id: "grep-1", + callID: "call-grep-1", + tool: "grep", + body: { + pattern: "callers", + }, + }), + ], + }), + ]) + }, + children: async ({ sessionID }) => { + if (sessionID === "session-1") return ok([child("child-1")]) + if (sessionID === "child-1") return ok([child("grandchild-1")]) + return ok([]) + }, + permissions: async () => + ok([ + { + id: "perm-grandchild-1", + sessionID: "grandchild-1", + permission: "grep", + patterns: ["callers"], + metadata: {}, + always: [], + tool: { + messageID: "msg-grandchild-1", + callID: "call-grep-1", + }, + }, + ]), + }), + sessionID: "session-1", + thinking: true, + limits: () => ({}), + footer: ui.api, + }) + + try { + const boot = await waitFor(() => { + const item = ui.events.findLast((event) => event.type === "stream.subagent") + const state = item?.type === "stream.subagent" ? item.state : undefined + return state?.tabs.some((tab) => tab.sessionID === "grandchild-1") && + state.permissions.some((req) => req.id === "perm-grandchild-1") + ? state + : undefined + }) + + expect(boot.tabs.map((tab) => tab.sessionID).sort()).toEqual(["child-1", "grandchild-1"]) + expect(boot.permissions).toEqual([ + expect.objectContaining({ + id: "perm-grandchild-1", + sessionID: "grandchild-1", + }), + ]) + } finally { + src.close() + await transport.close() + } + }) + test("bootstraps child session output before selection", async () => { const ui = footer() const transport = await createSessionTransport({ diff --git a/packages/opencode/test/cli/run/subagent-data.test.ts b/packages/opencode/test/cli/run/subagent-data.test.ts index 4dcbd09608bf..a711b6d1e679 100644 --- a/packages/opencode/test/cli/run/subagent-data.test.ts +++ b/packages/opencode/test/cli/run/subagent-data.test.ts @@ -544,4 +544,64 @@ describe("run subagent data", () => { }), ]) }) + + test("registers a grandchild tab when a known child session spawns a nested task", () => { + const data = createSubagentData() + + bootstrapSubagentData({ + data, + messages: [taskMessage("child-1", "running")], + children: [{ id: "child-1" }], + permissions: [], + questions: [], + }) + + const changed = reduce(data, { + type: "message.part.updated", + properties: { + part: { + id: "part-grandchild-1", + sessionID: "child-1", + messageID: "msg-child-1", + type: "tool", + callID: "call-grandchild-1", + tool: "task", + state: { + status: "running", + input: { + description: "Grep for callers", + subagent_type: "explore", + }, + metadata: { + sessionId: "grandchild-1", + }, + time: { start: 1 }, + }, + }, + }, + }) + + expect(changed).toBe(true) + + const tabs = snapshotSubagentData(data).tabs + expect(tabs.map((tab) => tab.sessionID).sort()).toEqual(["child-1", "grandchild-1"]) + + reduce(data, { + type: "permission.asked", + properties: { + id: "perm-grandchild-1", + sessionID: "grandchild-1", + permission: "grep", + patterns: ["callers"], + metadata: {}, + always: [], + tool: { + messageID: "msg-grandchild-1", + callID: "call-grep-1", + }, + }, + }) + + expect(snapshotSubagentData(data).permissions.map((item) => item.id)).toEqual(["perm-grandchild-1"]) + }) }) diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 6d77b0ea58fd..6ca297df0b1f 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -31,6 +31,7 @@ import type { AssistantMessage, Part, Provider, + Session, ToolPart, UserMessage, TextPart, @@ -224,14 +225,14 @@ export function Session() { ) : [], ) - const permissions = createMemo(() => { - if (session()?.parentID) return [] - return children().flatMap((x) => sync.data.permission[x.id] ?? []) - }) - const questions = createMemo(() => { - if (session()?.parentID) return [] - return children().flatMap((x) => sync.data.question[x.id] ?? []) + const subtree = createMemo(() => { + // NOTE: 仅根会话收集整棵子树的权限/问题;子会话自身不重复收集(避免与父视图双重展示) + const s = session() + if (!s || s.parentID) return [] + return collectSubtree(sync.data.session, s.id) }) + const permissions = createMemo(() => subtree().flatMap((x) => sync.data.permission[x.id] ?? [])) + const questions = createMemo(() => subtree().flatMap((x) => sync.data.question[x.id] ?? [])) const visible = createMemo(() => !session()?.parentID && permissions().length === 0 && questions().length === 0) const disabled = createMemo(() => permissions().length > 0 || questions().length > 0) @@ -2653,6 +2654,31 @@ function recordValue(value: unknown): Record | undefined { return value as Record } +export function collectSubtree(sessions: Session[], rootID: string): Session[] { + const root = sessions.find((item) => item.id === rootID) + if (!root) return [] + const childrenByParent = new Map() + for (const item of sessions) { + if (!item.parentID) continue + const siblings = childrenByParent.get(item.parentID) ?? [] + siblings.push(item) + childrenByParent.set(item.parentID, siblings) + } + const result = [root] + const visited = new Set([root.id]) + const queue = [root] + while (queue.length > 0) { + const current = queue.shift()! + for (const child of childrenByParent.get(current.id) ?? []) { + if (visited.has(child.id)) continue + visited.add(child.id) + result.push(child) + queue.push(child) + } + } + return result +} + export function parseApplyPatchFiles(value: unknown) { if (!Array.isArray(value)) return [] return value.flatMap((item) => { diff --git a/packages/tui/test/routes/session/collect-subtree.test.ts b/packages/tui/test/routes/session/collect-subtree.test.ts new file mode 100644 index 000000000000..20a9af7e7f2a --- /dev/null +++ b/packages/tui/test/routes/session/collect-subtree.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, test } from "bun:test" +import type { Session } from "@opencode-ai/sdk/v2" +import { collectSubtree } from "../../../src/routes/session" + +const s = (input: { id: string; parentID?: string }) => + ({ + id: input.id, + parentID: input.parentID, + title: input.id, + }) as Session + +describe("collectSubtree", () => { + test("returns only root when there are no children", () => { + const result = collectSubtree([s({ id: "root" })], "root") + expect(result.map((x) => x.id)).toEqual(["root"]) + }) + + test("returns root with direct children", () => { + const sessions = [s({ id: "root" }), s({ id: "child-1", parentID: "root" }), s({ id: "child-2", parentID: "root" })] + const result = collectSubtree(sessions, "root") + expect(result.map((x) => x.id).sort()).toEqual(["child-1", "child-2", "root"]) + expect(result[0].id).toBe("root") + }) + + test("returns the full chain for a three-level tree", () => { + const sessions = [ + s({ id: "root" }), + s({ id: "child", parentID: "root" }), + s({ id: "grandchild", parentID: "child" }), + ] + const result = collectSubtree(sessions, "root") + expect(result.map((x) => x.id).sort()).toEqual(["child", "grandchild", "root"]) + expect(result[0].id).toBe("root") + }) + + test("does not cross sibling branches", () => { + const sessions = [ + s({ id: "root" }), + s({ id: "child-1", parentID: "root" }), + s({ id: "grandchild-1", parentID: "child-1" }), + s({ id: "child-2", parentID: "root" }), + s({ id: "grandchild-2", parentID: "child-2" }), + ] + expect(collectSubtree(sessions, "root").map((x) => x.id).sort()).toEqual( + ["child-1", "child-2", "grandchild-1", "grandchild-2", "root"], + ) + expect(collectSubtree(sessions, "child-1").map((x) => x.id).sort()).toEqual(["child-1", "grandchild-1"]) + }) + + test("includes root itself in the result", () => { + const sessions = [s({ id: "root" }), s({ id: "child", parentID: "root" })] + const ids = collectSubtree(sessions, "root").map((x) => x.id) + expect(ids).toContain("root") + }) + + test("returns empty array when root is missing", () => { + expect(collectSubtree([s({ id: "child", parentID: "root" })], "root")).toEqual([]) + }) + + test("does not loop forever on a parentID cycle", () => { + const sessions = [ + s({ id: "root", parentID: "child" }), + s({ id: "child", parentID: "root" }), + ] + const ids = collectSubtree(sessions, "root").map((x) => x.id) + expect(ids).toContain("root") + expect(ids).toContain("child") + expect(new Set(ids).size).toBe(ids.length) + }) +})