diff --git a/packages/opencode/src/cli/cmd/run/stream.transport.ts b/packages/opencode/src/cli/cmd/run/stream.transport.ts index 4c3f2a589a1b..ad8db111c6f1 100644 --- a/packages/opencode/src/cli/cmd/run/stream.transport.ts +++ b/packages/opencode/src/cli/cmd/run/stream.transport.ts @@ -400,8 +400,12 @@ const collectDescendantSessions = Effect.fn("RunStreamTransport.collectDescendan const levels = yield* Effect.all( frontier.map((sessionID) => Effect.promise(() => sdk.session.children({ sessionID })).pipe( - Effect.map((item) => item.data ?? []), - Effect.orElseSucceed(() => []), + Effect.flatMap((item) => { + if (item.error) { + return Effect.fail(new Error(`failed to list child sessions for ${sessionID}`, { cause: item.error })) + } + return Effect.succeed(item.data ?? []) + }), ), ), { concurrency: "unbounded" }, diff --git a/packages/opencode/test/cli/run/stream.transport.test.ts b/packages/opencode/test/cli/run/stream.transport.test.ts index 320e72ccf9b4..98dd8f4ba093 100644 --- a/packages/opencode/test/cli/run/stream.transport.test.ts +++ b/packages/opencode/test/cli/run/stream.transport.test.ts @@ -1525,6 +1525,40 @@ describe("run stream transport", () => { } }) + test("fails bootstrap when descendant discovery fails", async () => { + const src = eventFeed() + const ui = footer() + const children = (async ({ sessionID }: { sessionID: string }) => { + if (sessionID === "session-1") return ok([child("child-1")]) + return { + data: undefined, + error: { + name: "NotFoundError", + data: { message: "child lookup failed" }, + }, + request: new Request("https://opencode.test"), + response: new Response(undefined, { status: 404 }), + } + }) as OpencodeClient["session"]["children"] + const result = createSessionTransport({ + sdk: sdk({ + stream: src.stream, + children, + }), + sessionID: "session-1", + thinking: true, + limits: () => ({}), + footer: ui.api, + }) + + try { + await expect(result).rejects.toThrow("failed to list child sessions for child-1") + } finally { + src.close() + await result.then((transport) => transport.close()).catch(() => undefined) + } + }) + test("bootstraps child session output before selection", async () => { const ui = footer() const transport = await createSessionTransport({ diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 22a4e25b231b..8f2d7838863c 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -232,7 +232,7 @@ export function Session() { : [], ) const subtree = createMemo(() => { - // NOTE: 仅根会话收集整棵子树的权限/问题;子会话自身不重复收集(避免与父视图双重展示) + // Only root sessions collect subtree requests; child views do not duplicate them. const s = session() if (!s || s.parentID) return [] return collectSubtree(sync.data.session, s.id) diff --git a/packages/tui/test/routes/session/collect-subtree.test.ts b/packages/tui/test/routes/session/collect-subtree.test.ts index 20a9af7e7f2a..0b6e6b595d83 100644 --- a/packages/tui/test/routes/session/collect-subtree.test.ts +++ b/packages/tui/test/routes/session/collect-subtree.test.ts @@ -41,10 +41,25 @@ describe("collectSubtree", () => { 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"]) + 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("preserves breadth-first order and session identity", () => { + const root = s({ id: "root" }) + const first = s({ id: "first", parentID: "root" }) + const grandchild = s({ id: "grandchild", parentID: "first" }) + const second = s({ id: "second", parentID: "root" }) + + expect(collectSubtree([root, first, grandchild, second], "root")).toEqual([root, first, second, grandchild]) }) test("includes root itself in the result", () => { @@ -58,10 +73,7 @@ describe("collectSubtree", () => { }) test("does not loop forever on a parentID cycle", () => { - const sessions = [ - s({ id: "root", parentID: "child" }), - s({ id: "child", parentID: "root" }), - ] + 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")