From ca5782da33e6021b78503d642c947dd7557583e1 Mon Sep 17 00:00:00 2001 From: Drewry Pope Date: Sun, 18 Oct 2026 10:38:39 -0500 Subject: [PATCH 01/13] fix(client): discover nested subagent sessions at any depth Subagents can launch their own, so syncing a session with children now walks its whole subtree breadth first: one paged list per session, each level published as it arrives, and a seen set so a parent cycle still ends the walk. The family index, root costs and everything built on them now include grandchildren on a fresh client instead of only direct children. --- packages/client/src/solid/data.ts | 52 ++++++++++---- packages/client/test/solid-data.test.ts | 96 ++++++++++++++++++++++++- packages/tui/test/cli/tui/data.test.tsx | 9 +-- 3 files changed, 138 insertions(+), 19 deletions(-) diff --git a/packages/client/src/solid/data.ts b/packages/client/src/solid/data.ts index 810cfe7dc1d3..ef14f203cd93 100644 --- a/packages/client/src/solid/data.ts +++ b/packages/client/src/solid/data.ts @@ -533,6 +533,41 @@ export function createData(config: CreateDataInput) { return [...page.data, ...(await listChildren({ cursor: page.cursor.next, limit: CHILD_PAGE }))] } + // Subagents can launch their own, so walk the rest of the tree one level per round of requests, + // publishing each level as it arrives. `seen` holds the sessions whose children were already + // listed, so every session is listed once and a parent cycle still ends the walk. + async function syncDescendants(parents: SessionInfo[], seen: Set): Promise { + const expand = parents.filter((parent) => { + if (seen.has(parent.id)) return false + seen.add(parent.id) + return true + }) + if (expand.length === 0) return + const children = ( + await Promise.all(expand.map((parent) => listChildren({ parentID: parent.id, order: "desc", limit: CHILD_PAGE }))) + ) + .flat() + .filter((child) => !seen.has(child.id)) + publishSessions(children) + return syncDescendants(children, seen) + } + + function publishSessions(sessions: SessionInfo[]) { + batch(() => { + setStore( + "session", + "info", + produce((draft) => { + for (const session of sessions) draft[session.id] = session + }), + ) + for (const session of sessions) { + sync.complete(`session:${session.id}`) + registerSession(session.id) + } + }) + } + // Walk parentID upward through loaded session info to the family root. When a // parent's info is missing, that missing ID is the furthest-known ancestor and // is returned so orphan subtrees group under it until the parent arrives. A @@ -1655,26 +1690,15 @@ export function createData(config: CreateDataInput) { throw error }) }, + /** With `children`, also loads every descendant session at any depth, so the family index is complete. */ sync(sessionID: string, options?: { children?: boolean }) { return sync.run(options?.children ? `session.family:${sessionID}` : `session:${sessionID}`, async () => { const [info, children] = await Promise.all([ api().session.get({ sessionID }), options?.children ? listChildren({ parentID: sessionID, order: "desc", limit: CHILD_PAGE }) : [], ]) - const sessions = [info, ...children] - batch(() => { - setStore( - "session", - "info", - produce((draft) => { - for (const session of sessions) draft[session.id] = session - }), - ) - for (const session of sessions) { - sync.complete(`session:${session.id}`) - registerSession(session.id) - } - }) + publishSessions([info, ...children]) + if (options?.children) await syncDescendants(children, new Set([sessionID])) }) }, invalidate(sessionID: string) { diff --git a/packages/client/test/solid-data.test.ts b/packages/client/test/solid-data.test.ts index bd9bbdafca66..77f44bfc42d9 100644 --- a/packages/client/test/solid-data.test.ts +++ b/packages/client/test/solid-data.test.ts @@ -71,6 +71,8 @@ test("counts every subagent page in a root session's cost", async () => { if (url.searchParams.get("cursor") === "page_2") return Response.json({ data: Array.from({ length: 50 }, (_, index) => child(200 + index)), cursor: { next: "page_3" } }) + if (url.searchParams.get("parentID") !== "ses_root") return Response.json({ data: [], cursor: {} }) + return Response.json({ data: Array.from({ length: 200 }, (_, index) => child(index)), cursor: { next: "page_2" } }) }, }) @@ -83,11 +85,103 @@ test("counts every subagent page in a root session's cost", async () => { await setup.data.session.sync("ses_root", { children: true }) expect(setup.data.session.family("ses_root")).toHaveLength(251) expect(setup.data.session.cost("ses_root")).toBe(250.5) - expect(requests.toSorted()).toEqual([ + const nested = requests.filter((request) => request.includes("parentID=ses_child_")) + expect(requests.filter((request) => !request.includes("parentID=ses_child_")).toSorted()).toEqual([ "/api/session/ses_root?", "/api/session?limit=200&cursor=page_2", "/api/session?limit=200&order=desc&parentID=ses_root", ]) + // Each subagent is asked once for subagents of its own. + expect(new Set(nested).size).toBe(250) + expect(nested).toHaveLength(250) + } finally { + setup.dispose() + } +}) + +test("discovers nested subagents at any depth, publishing each level as it arrives", async () => { + const parents: Record = { + ses_a: "ses_root", + ses_b: "ses_root", + ses_a1: "ses_a", + ses_a2: "ses_a", + ses_a1x: "ses_a1", + } + const deepest = Promise.withResolvers() + const requests: string[] = [] + const api = OpenCode.make({ + baseUrl: "http://opencode.local", + fetch: async (input, init) => { + const url = new URL((input instanceof Request ? input : new Request(input, init)).url) + requests.push(`${url.pathname}?${url.searchParams}`) + if (url.pathname === "/api/session/ses_root") return Response.json({ data: { ...session(0), id: "ses_root" } }) + const parentID = url.searchParams.get("parentID") + if (parentID === "ses_a1") await deepest.promise + return Response.json({ + data: Object.entries(parents) + .filter(([, parent]) => parent === parentID) + .map(([id, parent]) => ({ ...session(0), id, parentID: parent })), + cursor: {}, + }) + }, + }) + const setup = createRoot((dispose) => ({ + data: createData({ api: () => api, directory: "/project", event: { on: () => () => {}, listen: () => () => {} } }), + dispose, + })) + + try { + const synced = setup.data.session.sync("ses_root", { children: true }) + await wait(() => setup.data.session.get("ses_a2") !== undefined) + expect(setup.data.session.get("ses_a1x")).toBeUndefined() + deepest.resolve() + await synced + + expect(setup.data.session.family("ses_root")).toEqual(["ses_root", "ses_a", "ses_b", "ses_a1", "ses_a2", "ses_a1x"]) + expect(setup.data.session.root("ses_a1x")).toBe("ses_root") + expect(requests.toSorted()).toEqual( + [ + "/api/session/ses_root?", + ...["ses_root", "ses_a", "ses_b", "ses_a1", "ses_a2", "ses_a1x"].map( + (id) => `/api/session?limit=200&order=desc&parentID=${id}`, + ), + ].toSorted(), + ) + } finally { + deepest.resolve() + setup.dispose() + } +}) + +test("lists each session once when parent links form a cycle", async () => { + const requests: string[] = [] + const api = OpenCode.make({ + baseUrl: "http://opencode.local", + fetch: async (input, init) => { + const url = new URL((input instanceof Request ? input : new Request(input, init)).url) + requests.push(`${url.pathname}?${url.searchParams}`) + if (url.pathname === "/api/session/ses_x") + return Response.json({ data: { ...session(0), id: "ses_x", parentID: "ses_y" } }) + const parentID = url.searchParams.get("parentID") + return Response.json({ + data: [{ ...session(0), id: parentID === "ses_x" ? "ses_y" : "ses_x", parentID }], + cursor: {}, + }) + }, + }) + const setup = createRoot((dispose) => ({ + data: createData({ api: () => api, directory: "/project", event: { on: () => () => {}, listen: () => () => {} } }), + dispose, + })) + + try { + await setup.data.session.sync("ses_x", { children: true }) + expect(setup.data.session.get("ses_y")?.parentID).toBe("ses_x") + expect(requests.toSorted()).toEqual([ + "/api/session/ses_x?", + "/api/session?limit=200&order=desc&parentID=ses_x", + "/api/session?limit=200&order=desc&parentID=ses_y", + ]) } finally { setup.dispose() } diff --git a/packages/tui/test/cli/tui/data.test.tsx b/packages/tui/test/cli/tui/data.test.tsx index 9f5dd721f681..cdcc5d3ba5d2 100644 --- a/packages/tui/test/cli/tui/data.test.tsx +++ b/packages/tui/test/cli/tui/data.test.tsx @@ -3227,15 +3227,16 @@ async function mountData(parents: Record, costs: Record { - const { data, app } = await mountData({ child: "root", sibling: "root", grandchild: "child" }) +test("syncs every descendant session info with a navigated root", async () => { + const { data, app } = await mountData({ child: "root", sibling: "root", grandchild: "child", great: "grandchild" }) try { await data.session.sync("root", { children: true }) expect(data.session.get("root")?.id).toBe("root") expect(data.session.get("child")?.parentID).toBe("root") expect(data.session.get("sibling")?.parentID).toBe("root") - expect(data.session.get("grandchild")).toBeUndefined() - expect(data.session.family("root")).toEqual(["root", "child", "sibling"]) + expect(data.session.get("grandchild")?.parentID).toBe("child") + expect(data.session.get("great")?.parentID).toBe("grandchild") + expect(data.session.family("root")).toEqual(["root", "child", "sibling", "grandchild", "great"]) } finally { app.renderer.destroy() } From 50f164defae68f3c6bc0c9efedd5d908006eed9b Mon Sep 17 00:00:00 2001 From: Drewry Pope Date: Sun, 18 Oct 2026 10:38:43 -0500 Subject: [PATCH 02/13] fix(tui): bubble nested subagent asks up to the root The session view syncs permissions and forms for every member of the viewed family, so the root view prompts for asks from subagents at any depth and every view of the family marks its tab. A subagent view opened before its ancestors walks up to the root and loads the root's whole tree. Background tabs also load their nested subagents' pending asks, so a fresh start marks them. --- packages/tui/src/context/session-tabs.tsx | 8 ++ packages/tui/src/routes/session/index.tsx | 16 ++- .../tui/test/context/session-tabs.test.tsx | 43 +++++- .../tui/test/session-nested-asks.test.tsx | 132 ++++++++++++++++++ 4 files changed, 196 insertions(+), 3 deletions(-) create mode 100644 packages/tui/test/session-nested-asks.test.tsx diff --git a/packages/tui/src/context/session-tabs.tsx b/packages/tui/src/context/session-tabs.tsx index 87f1069eddb5..94deeeb6d656 100644 --- a/packages/tui/src/context/session-tabs.tsx +++ b/packages/tui/src/context/session-tabs.tsx @@ -291,7 +291,15 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp data.session.pending.sync(sessionID), data.session.permission.sync(sessionID), data.session.form.sync(sessionID), + data.session.sync(sessionID, { children: true }), ]) + if (stale) return + // A pending ask from a subagent at any depth marks its root's tab. + await Promise.allSettled( + data.session + .family(sessionID) + .flatMap((id) => [data.session.permission.sync(id), data.session.form.sync(id)]), + ) } }, TAB_PREFETCH_DELAY) onCleanup(() => { diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index d3dec817211b..b4e2b7db24ad 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -195,9 +195,11 @@ export function Session(props: { setEpilogue(sessionEpilogue({ title, sessionID: session()?.id })) }) onCleanup(() => setEpilogue()) + // The viewed family at every depth. Its root may be several subagent levels above a viewed subagent. + const familySessionIDs = createMemo(() => [...data.session.family(route.sessionID)]) const descendantSessionIDs = createMemo(() => { if (session()?.parentID) return [] - return data.session.family(route.sessionID).filter((id) => id !== route.sessionID) + return familySessionIDs().filter((id) => id !== route.sessionID) }) const permissions = createMemo(() => { if (session()?.parentID) return [] @@ -318,8 +320,9 @@ export function Session(props: { ), ) + // Asks from every family member: the root view prompts for them, and every view of the family marks its tab. createEffect( - on([descendantSessionIDs, () => client.connection.status()], ([sessionIDs, status]) => { + on([familySessionIDs, () => client.connection.status()], ([sessionIDs, status]) => { if (status !== "connected") return void Promise.allSettled( sessionIDs.flatMap((sessionID) => [data.session.permission.sync(sessionID), data.session.form.sync(sessionID)]), @@ -327,6 +330,15 @@ export function Session(props: { }), ) + // A subagent view opened before its ancestors walks up to the root, whose tree holds the whole family. + createEffect(() => { + if (client.connection.status() !== "connected" || !session()?.parentID) return + const root = data.session.root(route.sessionID) + void (data.session.get(root) ? data.session.sync(root, { children: true }) : data.session.sync(root)).catch( + () => undefined, + ) + }) + createEffect(() => { if (client.connection.status() !== "connected") return setSynced(false) diff --git a/packages/tui/test/context/session-tabs.test.tsx b/packages/tui/test/context/session-tabs.test.tsx index c2be51639115..fb2ba6c8d9eb 100644 --- a/packages/tui/test/context/session-tabs.test.tsx +++ b/packages/tui/test/context/session-tabs.test.tsx @@ -1,6 +1,6 @@ /** @jsxImportSource @opentui/solid */ import { expect, test } from "bun:test" -import type { OpenCodeEvent } from "@opencode/client" +import type { FormInfo, OpenCodeEvent, PermissionRequest } from "@opencode/client" import { testRender } from "@opentui/solid" import { mkdirSync, watch } from "fs" import path from "path" @@ -36,6 +36,8 @@ async function renderSessionTabs( sessionGate?: Promise sessionDirectories?: Record sessionParents?: Record + sessionPermissions?: Record + sessionForms?: Record sessionTimes?: Record sessionOutcomes?: Record newLocation?: "launch" | "inherit" @@ -96,6 +98,11 @@ async function renderSessionTabs( .map(([sessionID]) => sessionInfo(sessionID)) return json({ data: children, cursor: {} }) } + const asks = url.pathname.match(/^\/api\/session\/([^/]+)\/(permission|form)$/) + if (asks && request.method === "GET") + return json({ + data: (asks[2] === "permission" ? options?.sessionPermissions : options?.sessionForms)?.[asks[1]] ?? [], + }) const viewed = url.pathname.match(/^\/api\/session\/([^/]+)\/view$/)?.[1] if (viewed && request.method === "POST") { views.push(viewed) @@ -664,6 +671,40 @@ test("distinguishes family questions and permissions without clearing them on se } }) +test("marks a background tab with asks its nested subagents left pending before startup", async () => { + const setup = await renderSessionTabs("other", { + persisted: ["root"], + sessionParents: { child: "root", grandchild: "child", great: "grandchild" }, + sessionPermissions: { + grandchild: [{ id: "per_nested", sessionID: "grandchild", action: "shell", resources: ["bun run test"] }], + }, + sessionForms: { + great: [ + { + id: "frm_nested", + sessionID: "great", + title: "Choose an approach", + fields: [{ key: "approach", type: "string", title: "Approach" }], + }, + ], + }, + }) + try { + await wait(() => setup.tabs.status("root").attention === "permission", 5_000, "nested permission") + expect(setup.tabs.current()).toBe("other") + + setup.emit({ + id: "evt_permission_reply", + created: 1, + type: "permission.replied", + data: { sessionID: "grandchild", requestID: "per_nested", reply: "once" }, + }) + await wait(() => setup.tabs.status("root").attention === "question", 2_000, "nested question") + } finally { + await setup.destroy() + } +}) + test("concurrent TUIs do not alternate shared tab titles from divergent session caches", async () => { await using temporary = await tmpdir() const state = temporary.path diff --git a/packages/tui/test/session-nested-asks.test.tsx b/packages/tui/test/session-nested-asks.test.tsx new file mode 100644 index 000000000000..56583442c0f8 --- /dev/null +++ b/packages/tui/test/session-nested-asks.test.tsx @@ -0,0 +1,132 @@ +import { expect, test } from "bun:test" +import type { FormInfo, PermissionRequest, SessionInfo } from "@opencode/client" +import { createAppFixture } from "./fixture/app" +import { directory, json } from "./fixture/tui-client" +import { tmpdir } from "./fixture/fixture" + +const root = session("ses_nested_root", "Nested root") +const child = session("ses_nested_child", "Nested child", root.id, 3) +const sibling = session("ses_nested_sibling", "Nested sibling", root.id, 2) +const grandchild = session("ses_nested_grandchild", "Nested grandchild", child.id) + +test("the root view prompts for and answers asks a nested subagent left pending before startup", async () => { + await using state = await tmpdir() + const permission = { + id: "per_nested", + sessionID: grandchild.id, + action: "shell", + resources: ["echo nested-permission"], + } satisfies PermissionRequest + const form = { + id: "frm_nested", + sessionID: grandchild.id, + title: "Nested form fixture", + fields: [{ key: "target", type: "string", title: "Target", options: [{ value: "staging", label: "Staging" }] }], + } satisfies FormInfo + await using setup = await createNestedFixture(state.path, { viewed: root.id, permission, form }) + await setup.ready + const frame = () => setup.captureCharFrame() + + // The grandchild is two levels below the viewed root, so its asks appear only once the whole tree is discovered. + await until(() => frame().includes("Permission required") && frame().includes("echo nested-permission"), frame) + setup.mockInput.pressEnter() + await until(() => setup.replies.permission.length === 1, frame) + expect(setup.replies.permission).toEqual([{ decision: "once" }]) + + await until(() => frame().includes(form.title) && !frame().includes("Permission required"), frame) + setup.mockInput.pressEnter() + await until(() => setup.replies.form.length === 1, frame) + await until(() => !frame().includes(form.title), frame) +}) + +test("a subagent view opened before its ancestors lists the whole family", async () => { + await using state = await tmpdir() + await using setup = await createNestedFixture(state.path, { + viewed: grandchild.id, + running: [child.id, sibling.id, grandchild.id], + }) + await setup.ready + const frame = () => setup.captureCharFrame() + const row = (text: string) => + frame() + .split("\n") + .findIndex((line) => line.includes(text)) + + // A subagent view opens its picker, which needs the root's tree rather than the viewed session's. + await until(() => row("Build: Nested sibling") > -1, frame) + expect(row("└─ Build: Nested grandchild")).toBe(row("Build: Nested child") + 1) + expect(row("Build: Nested sibling")).toBe(row("Build: Nested child") + 2) +}) + +async function createNestedFixture( + state: string, + input: { viewed: string; running?: string[]; permission?: PermissionRequest; form?: FormInfo }, +) { + const sessions = [root, child, sibling, grandchild] + const location = { directory, project: { id: root.projectID, directory, canonical: directory } } + const replies = { permission: [] as unknown[], form: [] as unknown[] } + const setup = await createAppFixture({ + width: 120, + height: 36, + state, + args: { sessionID: input.viewed }, + config: { animations: false, tabs: { mode: "off" }, session: { sidebar: "hide" } }, + fetch: async (url, request) => { + if (url.pathname === "/api/location") return json(location) + if (url.pathname === "/api/session/active") + return json({ data: Object.fromEntries((input.running ?? []).map((id) => [id, { type: "running" }])) }) + if (url.pathname === "/api/session") { + const parentID = url.searchParams.get("parentID") + return json({ data: sessions.filter((item) => parentID && item.parentID === parentID), cursor: {} }) + } + const match = url.pathname.match(/^\/api\/session\/([^/]+)(?:\/(.+))?$/) + const target = sessions.find((item) => item.id === match?.[1]) + if (!match || !target) return undefined + if (match[2] === undefined) return json({ data: target }) + if (match[2] === "message") return json({ data: [], cursor: {} }) + if (match[2] === "inbox") return json({ data: [] }) + if (match[2] === "permission") { + const pending = input.permission?.sessionID === target.id && replies.permission.length === 0 + return json({ data: pending ? [input.permission] : [] }) + } + if (match[2] === "form") { + const pending = input.form?.sessionID === target.id && replies.form.length === 0 + return json({ data: pending ? [input.form] : [] }) + } + if (match[2] === `permission/${input.permission?.id}/reply`) { + replies.permission.push(await request.json()) + return new Response(null, { status: 204 }) + } + if (match[2] === `form/${input.form?.id}/reply`) { + replies.form.push(await request.json()) + return new Response(null, { status: 204 }) + } + return undefined + }, + }) + return { ...setup, replies } +} + +function session(id: string, title: string, parentID?: string, updated = 1): SessionInfo { + return { + id, + ...(parentID ? { parentID } : {}), + title, + projectID: "proj_nested", + location: { directory }, + agent: "build", + model: { providerID: "fixture", id: "model" }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 1, updated }, + } +} + +// Polls on the wall clock: discovery and replies are requests, not frames. +async function until(check: () => boolean, frame: () => string) { + const started = Date.now() + while (!check()) { + if (Date.now() - started > 10_000) throw new Error(`Timed out waiting for condition\n${frame()}`) + await Bun.sleep(10) + } +} From 162c085f1ab318cdfb5d1fb55022f0ae4ef24053 Mon Sep 17 00:00:00 2001 From: Drewry Pope Date: Sun, 18 Oct 2026 10:38:48 -0500 Subject: [PATCH 03/13] fix(tui): keep filtered subagents under their parents in the picker The active filter ran after the tree prefixes were drawn, so a running subagent under an idle one showed a connector to a missing row and siblings kept the wrong corner. Filter while building the tree instead: a filtered-out ancestor of a match stays as context, connectors are drawn for the visible tree, and a parent cycle no longer loops forever. --- .../routes/session/composer/subagents-tab.tsx | 42 ++++++++-------- packages/tui/src/util/session.ts | 37 ++++++++++---- .../tui/test/cli/tui/composer-keymap.test.tsx | 50 +++++++++++++++---- packages/tui/test/util/session.test.ts | 39 +++++++++++++++ 4 files changed, 129 insertions(+), 39 deletions(-) diff --git a/packages/tui/src/routes/session/composer/subagents-tab.tsx b/packages/tui/src/routes/session/composer/subagents-tab.tsx index 5565fd08c9a5..3cc891395505 100644 --- a/packages/tui/src/routes/session/composer/subagents-tab.tsx +++ b/packages/tui/src/routes/session/composer/subagents-tab.tsx @@ -40,26 +40,28 @@ export function SubagentsTab(props: { sessionID: string }) { const current = session() if (!current) return [] - const result = sessionFamily(data.session.list(), current.id).map( - ({ session, prefix }): SubagentEntry => { - const title = withTimestampedFallback(session) - const agentMatch = title.match(/@(\w+) subagent/) - return { - sessionID: session.id, - agent: session.agent - ? Locale.titlecase(session.agent) - : agentMatch - ? Locale.titlecase(agentMatch[1]) - : "Subagent", - title: agentMatch ? title.replace(agentMatch[0], "").trim() || title : title, - status: data.session.status(session.id), - current: session.id === route.sessionID, - prefix, - } - }, - ) - - return result.filter((entry) => (filter.active ? entry.status === "running" : entry.status !== "running")) + // Filter while building the tree, so a match under a filtered-out subagent keeps that parent as context instead + // of drawing a connector to a missing row. + return sessionFamily( + data.session.list(), + current.id, + (session) => (data.session.status(session.id) === "running") === filter.active, + ).map(({ session, prefix }): SubagentEntry => { + const title = withTimestampedFallback(session) + const agentMatch = title.match(/@(\w+) subagent/) + return { + sessionID: session.id, + agent: session.agent + ? Locale.titlecase(session.agent) + : agentMatch + ? Locale.titlecase(agentMatch[1]) + : "Subagent", + title: agentMatch ? title.replace(agentMatch[0], "").trim() || title : title, + status: data.session.status(session.id), + current: session.id === route.sessionID, + prefix, + } + }) }) let selectedSessionID = "" diff --git a/packages/tui/src/util/session.ts b/packages/tui/src/util/session.ts index c7213e2205ed..aea45e2d2590 100644 --- a/packages/tui/src/util/session.ts +++ b/packages/tui/src/util/session.ts @@ -7,7 +7,15 @@ type SessionNode = { parentID?: string | null } -export function sessionFamily(sessions: readonly T[], sessionID: string) { +type FamilyNode = { session: T; children: FamilyNode[] } + +// The family around `sessionID` below its root, in tree order with a connector prefix per row. Sessions that fail +// `include` stay only as ancestors of sessions that pass, so every row is drawn under its own parent. +export function sessionFamily( + sessions: readonly T[], + sessionID: string, + include: (session: T) => boolean = () => true, +) { const byID = new Map(sessions.map((session) => [session.id, session])) const current = byID.get(sessionID) if (!current) return [] @@ -20,15 +28,25 @@ export function sessionFamily(sessions: readonly T[], ses else children.set(session.parentID, [session]) }) - function root(session: T): T { + // Parent links can form a cycle, so each walk visits a session once. + function root(session: T, seen: Set): T { + seen.add(session.id) const parent = session.parentID ? byID.get(session.parentID) : undefined - return parent ? root(parent) : session + return parent && !seen.has(parent.id) ? root(parent, seen) : session + } + + function tree(parentID: string, seen: Set): FamilyNode[] { + return (children.get(parentID) ?? []).flatMap((session) => { + if (seen.has(session.id)) return [] + seen.add(session.id) + const nested = tree(session.id, seen) + return include(session) || nested.length > 0 ? [{ session, children: nested }] : [] + }) } - function walk(parentID: string, ancestors: boolean[]): Array<{ session: T; prefix: string }> { - const group = children.get(parentID) ?? [] - return group.flatMap((session, index) => { - const last = index === group.length - 1 + function walk(nodes: FamilyNode[], ancestors: boolean[]): Array<{ session: T; prefix: string }> { + return nodes.flatMap((node, index) => { + const last = index === nodes.length - 1 const prefix = ancestors.length === 0 ? "" @@ -36,11 +54,12 @@ export function sessionFamily(sessions: readonly T[], ses .slice(1) .map((ancestor) => (ancestor ? " " : "│ ")) .join("") + (last ? "└─ " : "├─ ") - return [{ session, prefix }, ...walk(session.id, [...ancestors, last])] + return [{ session: node.session, prefix }, ...walk(node.children, [...ancestors, last])] }) } - return walk(root(current).id, []) + const top = root(current, new Set()) + return walk(tree(top.id, new Set([top.id])), []) } export function contextUsage( diff --git a/packages/tui/test/cli/tui/composer-keymap.test.tsx b/packages/tui/test/cli/tui/composer-keymap.test.tsx index 75625386b425..7fe1cae8c21d 100644 --- a/packages/tui/test/cli/tui/composer-keymap.test.tsx +++ b/packages/tui/test/cli/tui/composer-keymap.test.tsx @@ -36,6 +36,10 @@ async function renderComposer( defaultTab: "subagents" | "shell", keybinds: Partial, focusedTextarea = false, + family: { sessions: Record>; running: string[] } = { + sessions, + running: ["child-a", "child-b"], + }, ) { const events = createEventStream() const interrupted: string[] = [] @@ -47,9 +51,9 @@ async function renderComposer( let route!: ReturnType const calls = createFetch((url, request) => { if (url.pathname === "/api/session/active") - return json({ data: { "child-a": { type: "running" }, "child-b": { type: "running" } } }) + return json({ data: Object.fromEntries(family.running.map((id) => [id, { type: "running" }])) }) const sessionID = url.pathname.match(/^\/api\/session\/([^/]+)$/)?.[1] - if (sessionID && sessionID in sessions) return json({ data: sessions[sessionID as keyof typeof sessions] }) + if (sessionID && sessionID in family.sessions) return json({ data: family.sessions[sessionID] }) const interruptID = url.pathname.match(/^\/api\/session\/([^/]+)\/interrupt$/)?.[1] if (interruptID && request.method === "POST") { interrupted.push(interruptID) @@ -81,14 +85,8 @@ async function renderComposer( route = useRoute() dispatch = Keymap.use().dispatch onMount(() => { - void Promise.all([ - data.session.sync("parent"), - data.session.sync("child-a"), - data.session.sync("child-b"), - data.session.sync("child-c"), - data.shell.sync(), - ]) - .then(() => wait(() => data.session.status("child-a") === "running")) + void Promise.all([...Object.keys(family.sessions).map((id) => data.session.sync(id)), data.shell.sync()]) + .then(() => wait(() => family.running.every((id) => data.session.status(id) === "running"))) .then(() => ready.resolve(), ready.reject) }) return ( @@ -209,6 +207,38 @@ test("the inactive subagent filter only changes with its toggle", async () => { } }) +test("the subagent filter keeps nested matches under their own parent", async () => { + const composer = await renderComposer("subagents", {}, false, { + sessions: { + parent: session("parent", "Parent"), + planner: { ...session("planner", "Planner", "parent"), time: { created: 0, updated: 2 } }, + worker: { ...session("worker", "Worker", "planner"), time: { created: 0, updated: 2 } }, + reviewer: { ...session("reviewer", "Reviewer", "planner"), time: { created: 0, updated: 1 } }, + archivist: { ...session("archivist", "Archivist", "parent"), time: { created: 0, updated: 1 } }, + }, + running: ["worker"], + }) + const row = (text: string) => { + const lines = composer.app.captureCharFrame().split("\n") + return lines.findIndex((line) => line.includes(text)) + } + try { + // The idle planner stays as context, so its running worker is not drawn as an orphan. + expect(row("Build: Planner")).toBeGreaterThan(-1) + expect(row("└─ Build: Worker")).toBe(row("Build: Planner") + 1) + expect(row("Reviewer")).toBe(-1) + expect(row("Archivist")).toBe(-1) + + composer.app.mockInput.pressKey("a", { ctrl: true }) + await composer.app.renderOnce() + expect(row("└─ Build: Reviewer")).toBe(row("Build: Planner") + 1) + expect(row("Build: Archivist")).toBeGreaterThan(-1) + expect(row("Worker")).toBe(-1) + } finally { + composer.app.renderer.destroy() + } +}) + test("disabled shell bindings have no component fallbacks", async () => { const composer = await renderComposer("shell", { "composer.shell.up": "none", diff --git a/packages/tui/test/util/session.test.ts b/packages/tui/test/util/session.test.ts index 9adc00617b82..6d8f5977f7d9 100644 --- a/packages/tui/test/util/session.test.ts +++ b/packages/tui/test/util/session.test.ts @@ -22,4 +22,43 @@ describe("util.session", () => { { session: sessions[6], prefix: "└─ " }, ]) }) + + test("keeps filtered-out ancestors of matches and draws connectors for the filtered tree", () => { + const sessions = [ + { id: "root" }, + { id: "idle-a", parentID: "root" }, + { id: "running-a1", parentID: "idle-a" }, + { id: "idle-a1x", parentID: "running-a1" }, + { id: "idle-a2", parentID: "idle-a" }, + { id: "idle-b", parentID: "root" }, + { id: "idle-b1", parentID: "idle-b" }, + { id: "idle-b1x", parentID: "idle-b1" }, + { id: "running-b1x1", parentID: "idle-b1x" }, + { id: "running-c", parentID: "root" }, + ] + + expect(sessionFamily(sessions, "root", (session) => session.id.startsWith("running"))).toEqual([ + { session: sessions[1], prefix: "" }, + { session: sessions[2], prefix: "└─ " }, + { session: sessions[5], prefix: "" }, + { session: sessions[6], prefix: "└─ " }, + { session: sessions[7], prefix: " └─ " }, + { session: sessions[8], prefix: " └─ " }, + { session: sessions[9], prefix: "" }, + ]) + expect(sessionFamily(sessions, "root", (session) => session.id === "missing")).toEqual([]) + }) + + test("ends at a parent cycle", () => { + const sessions = [ + { id: "x", parentID: "y" }, + { id: "y", parentID: "x" }, + { id: "z", parentID: "y" }, + ] + + expect(sessionFamily(sessions, "z")).toEqual([ + { session: sessions[1], prefix: "" }, + { session: sessions[2], prefix: "└─ " }, + ]) + }) }) From 8f8cdac95e2584fd1cfa40a73510af2a3cbf9edc Mon Sep 17 00:00:00 2001 From: Drewry Pope Date: Sun, 18 Oct 2026 10:38:49 -0500 Subject: [PATCH 04/13] fix(tui): load unknown sessions before announcing them An event from a session this client never loaded, such as a nested subagent of a root that is not open, was announced as a root session: a desktop notification, the done sound and no title. Load the session first so subagents stay sound-only and every notification has its title. --- .../feature-plugins/system/notifications.ts | 27 ++++++++-- .../test/cli/cmd/tui/notifications.test.ts | 49 +++++++++++++++++++ 2 files changed, 71 insertions(+), 5 deletions(-) diff --git a/packages/tui/src/feature-plugins/system/notifications.ts b/packages/tui/src/feature-plugins/system/notifications.ts index 5435f49689fc..f07ec4d63e18 100644 --- a/packages/tui/src/feature-plugins/system/notifications.ts +++ b/packages/tui/src/feature-plugins/system/notifications.ts @@ -26,6 +26,13 @@ export default Plugin.define({ const forms = new Set() const permissions = new Set() + // Events can name a session this client never loaded, such as a nested subagent of a root that is not open. + // Load it first so a subagent is not announced as a root session. + const loaded = (sessionID: string, run: () => void) => { + if (sessionID === "global" || context.data.session.get(sessionID)) return run() + void context.data.session.sync(sessionID).then(run, run) + } + const started = (sessionID: string) => { errored.delete(sessionID) terminal.delete(sessionID) @@ -37,22 +44,32 @@ export default Plugin.define({ errored.delete(sessionID) return } - const session = context.data.session.get(sessionID) - notify(context, sessionID, "Session done", session?.parentID ? "subagent_done" : "done") + loaded(sessionID, () => + notify( + context, + sessionID, + "Session done", + context.data.session.get(sessionID)?.parentID ? "subagent_done" : "done", + ), + ) } const dispose = [ context.data.on("form.created", (event) => { if (forms.has(event.data.form.id)) return forms.add(event.data.form.id) - notify(context, event.data.form.sessionID, "Input needs response", "question", event.data.form.title) + loaded(event.data.form.sessionID, () => + notify(context, event.data.form.sessionID, "Input needs response", "question", event.data.form.title), + ) }), context.data.on("form.replied", (event) => forms.delete(event.data.id)), context.data.on("form.cancelled", (event) => forms.delete(event.data.id)), context.data.on("permission.asked", (event) => { if (permissions.has(event.data.id)) return permissions.add(event.data.id) - notify(context, event.data.sessionID, "Permission needs input", "permission") + loaded(event.data.sessionID, () => + notify(context, event.data.sessionID, "Permission needs input", "permission"), + ) }), context.data.on("permission.replied", (event) => permissions.delete(event.data.requestID)), context.data.on("session.execution.started", (event) => started(event.data.sessionID)), @@ -66,7 +83,7 @@ export default Plugin.define({ return } errored.add(sessionID) - notify(context, sessionID, event.data.error.message, "error") + loaded(sessionID, () => notify(context, sessionID, event.data.error.message, "error")) context.ui.toast.show({ sessionID, title: "Session failed", message: event.data.error.message, variant: "error" }) ended(sessionID) }), diff --git a/packages/tui/test/cli/cmd/tui/notifications.test.ts b/packages/tui/test/cli/cmd/tui/notifications.test.ts index 75ef0d263fe5..76114bb16f17 100644 --- a/packages/tui/test/cli/cmd/tui/notifications.test.ts +++ b/packages/tui/test/cli/cmd/tui/notifications.test.ts @@ -20,6 +20,11 @@ async function setup(route: Route = { type: "session", sessionID: "session" }) { abort: session("abort", "Abort session"), timeout: session("timeout", "Timeout session"), } + // Known to the server but not yet loaded by this client, such as a subagent nested under a root that is not open. + const remote: Record = { + nested: session("nested", "Nested subagent session", "subagent"), + } + const synced: Promise[] = [] await Notifications.setup({ ui: { @@ -51,6 +56,15 @@ async function setup(route: Route = { type: "session", sessionID: "session" }) { session: { get: (sessionID: string) => sessions[sessionID], status: () => "running" as const, + sync: (sessionID: string) => { + const loaded = Promise.resolve().then(() => { + const info = remote[sessionID] + if (!info) throw new Error(`Session not found: ${sessionID}`) + sessions[sessionID] = info + }) + synced.push(loaded.catch(() => undefined)) + return loaded + }, }, }, } as unknown as Context) @@ -61,6 +75,11 @@ async function setup(route: Route = { type: "session", sessionID: "session" }) { emit(event: OpenCodeEvent) { for (const handler of handlers.get(event.type) ?? []) handler(event) }, + // Settles session loads the plugin started, then the notifications that waited for them. + async settled() { + await Promise.all(synced) + await Promise.resolve() + }, } } @@ -296,4 +315,34 @@ describe("internal notifications TUI plugin", () => { }, ]) }) + + test("loads a session this client has not seen before announcing it", async () => { + const harness = await setup() + + harness.emit({ id: "event-1", created: 0, type: "permission.asked", data: permission("permission-1", "nested") }) + harness.emit(executionStarted("event-2", "nested")) + harness.emit(executionSucceeded("event-3", "nested")) + harness.emit(executionSucceeded("event-4", "deleted")) + await harness.settled() + + expect(harness.notifications).toEqual([ + { + title: "Nested subagent session", + message: "Permission needs input", + notification: false, + sound: { name: "permission", when: "always" }, + }, + { + title: "Nested subagent session", + message: "Session done", + notification: false, + sound: { name: "subagent_done", when: "always" }, + }, + { + message: "Session done", + notification: { when: "blurred" }, + sound: { name: "done", when: "always" }, + }, + ]) + }) }) From b7530b3a23be4a1cc4fb448f2a9aecb90a7b95e5 Mon Sep 17 00:00:00 2001 From: Drewry Pope Date: Sun, 18 Oct 2026 10:48:49 -0500 Subject: [PATCH 05/13] fix(client): keep nested discovery best effort and bounded --- packages/client/src/solid/data.ts | 23 +++++++++++---- packages/client/test/solid-data.test.ts | 38 +++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 5 deletions(-) diff --git a/packages/client/src/solid/data.ts b/packages/client/src/solid/data.ts index ef14f203cd93..8fd3d3feb920 100644 --- a/packages/client/src/solid/data.ts +++ b/packages/client/src/solid/data.ts @@ -227,6 +227,9 @@ function createSync() { } const CHILD_PAGE = 200 +// Listings in flight while walking below a session's direct children, so a wide tree does not queue the +// client's other requests behind it. +const DESCENDANT_LISTINGS = 8 export function createData(config: CreateDataInput) { const api = config.api @@ -535,7 +538,9 @@ export function createData(config: CreateDataInput) { // Subagents can launch their own, so walk the rest of the tree one level per round of requests, // publishing each level as it arrives. `seen` holds the sessions whose children were already - // listed, so every session is listed once and a parent cycle still ends the walk. + // listed, so every session is listed once and a parent cycle still ends the walk. Below the + // direct children the walk is best effort: a branch whose listing fails is left to live events + // rather than failing the sync and closing the view. async function syncDescendants(parents: SessionInfo[], seen: Set): Promise { const expand = parents.filter((parent) => { if (seen.has(parent.id)) return false @@ -543,11 +548,19 @@ export function createData(config: CreateDataInput) { return true }) if (expand.length === 0) return - const children = ( - await Promise.all(expand.map((parent) => listChildren({ parentID: parent.id, order: "desc", limit: CHILD_PAGE }))) + const groups = Array.from({ length: Math.ceil(expand.length / DESCENDANT_LISTINGS) }, (_, index) => + expand.slice(index * DESCENDANT_LISTINGS, (index + 1) * DESCENDANT_LISTINGS), ) - .flat() - .filter((child) => !seen.has(child.id)) + const listed: SessionInfo[] = [] + for (const group of groups) { + const pages = await Promise.all( + group.map((parent) => + listChildren({ parentID: parent.id, order: "desc", limit: CHILD_PAGE }).catch((): SessionInfo[] => []), + ), + ) + listed.push(...pages.flat()) + } + const children = listed.filter((child) => !seen.has(child.id)) publishSessions(children) return syncDescendants(children, seen) } diff --git a/packages/client/test/solid-data.test.ts b/packages/client/test/solid-data.test.ts index 77f44bfc42d9..cde648efb0eb 100644 --- a/packages/client/test/solid-data.test.ts +++ b/packages/client/test/solid-data.test.ts @@ -153,6 +153,44 @@ test("discovers nested subagents at any depth, publishing each level as it arriv } }) +test("keeps the rest of the tree when a nested listing fails, and lists a few sessions at a time", async () => { + const children = Array.from({ length: 20 }, (_, index) => `ses_c${index}`) + const flight = { now: 0, most: 0 } + const api = OpenCode.make({ + baseUrl: "http://opencode.local", + fetch: async (input, init) => { + const url = new URL((input instanceof Request ? input : new Request(input, init)).url) + if (url.pathname === "/api/session/ses_root") return Response.json({ data: { ...session(0), id: "ses_root" } }) + const parentID = url.searchParams.get("parentID") + if (parentID === "ses_root") + return Response.json({ data: children.map((id) => ({ ...session(0), id, parentID })), cursor: {} }) + flight.now++ + flight.most = Math.max(flight.most, flight.now) + await Bun.sleep(1) + flight.now-- + if (parentID === "ses_c0") return new Response(null, { status: 500 }) + return Response.json({ + data: parentID === "ses_c1" ? [{ ...session(0), id: "ses_c1_child", parentID }] : [], + cursor: {}, + }) + }, + }) + const setup = createRoot((dispose) => ({ + data: createData({ api: () => api, directory: "/project", event: { on: () => () => {}, listen: () => () => {} } }), + dispose, + })) + + try { + await setup.data.session.sync("ses_root", { children: true }) + + expect(setup.data.session.get("ses_c1_child")?.parentID).toBe("ses_c1") + expect(setup.data.session.family("ses_root")).toHaveLength(22) + expect(flight.most).toBeLessThanOrEqual(8) + } finally { + setup.dispose() + } +}) + test("lists each session once when parent links form a cycle", async () => { const requests: string[] = [] const api = OpenCode.make({ From 8f5df9c3e50739f8f364098f0cacc5ce6cf9bff3 Mon Sep 17 00:00:00 2001 From: Drewry Pope Date: Sun, 18 Oct 2026 11:24:36 -0500 Subject: [PATCH 06/13] fix(client): share one descendant listing limit across discovery walks The eight-listing limit applied per walk, and session tabs sync every open tab's family at once, so several walks could still queue the client's other requests behind discovery. One limiter per data instance now caps descendant listings across every walk, and each listing publishes its children as soon as it resolves instead of waiting for the rest of its level. --- packages/client/src/solid/data.ts | 70 +++++++++++++++---------- packages/client/test/solid-data.test.ts | 61 +++++++++++++++------ 2 files changed, 87 insertions(+), 44 deletions(-) diff --git a/packages/client/src/solid/data.ts b/packages/client/src/solid/data.ts index 8fd3d3feb920..ec5f85ea0fd4 100644 --- a/packages/client/src/solid/data.ts +++ b/packages/client/src/solid/data.ts @@ -226,9 +226,28 @@ function createSync() { } } +// Runs at most `limit` tasks at once and starts queued tasks in order as running ones settle. +function createLimiter(limit: number) { + const queue: (() => void)[] = [] + let running = 0 + const next = () => { + running-- + queue.shift()?.() + } + return (task: () => Promise) => + new Promise((resolve, reject) => { + const start = () => { + running++ + void Promise.resolve().then(task).then(resolve, reject).finally(next) + } + if (running < limit) return start() + queue.push(start) + }) +} + const CHILD_PAGE = 200 -// Listings in flight while walking below a session's direct children, so a wide tree does not queue the -// client's other requests behind it. +// Listings in flight across every walk below direct children in one client, so wide trees and several open +// tabs do not queue the client's other requests behind discovery. const DESCENDANT_LISTINGS = 8 export function createData(config: CreateDataInput) { @@ -284,6 +303,7 @@ export function createData(config: CreateDataInput) { ) const messageIndex = new Map>() const sync = createSync() + const descendantListing = createLimiter(DESCENDANT_LISTINGS) let activeUpdates: Map | undefined const pendingUpdates = new Map>() @@ -536,33 +556,29 @@ export function createData(config: CreateDataInput) { return [...page.data, ...(await listChildren({ cursor: page.cursor.next, limit: CHILD_PAGE }))] } - // Subagents can launch their own, so walk the rest of the tree one level per round of requests, - // publishing each level as it arrives. `seen` holds the sessions whose children were already - // listed, so every session is listed once and a parent cycle still ends the walk. Below the - // direct children the walk is best effort: a branch whose listing fails is left to live events - // rather than failing the sync and closing the view. + // Subagents can launch their own, so the walk continues below every listed session. Each listing publishes + // its children as soon as it resolves, and every walk shares one listing limit. `seen` holds the sessions + // already listed, so every session is listed once and a parent cycle still ends the walk. Below the direct + // children the walk is best effort: a branch whose listing fails is left to live events rather than failing + // the sync and closing the view. async function syncDescendants(parents: SessionInfo[], seen: Set): Promise { - const expand = parents.filter((parent) => { - if (seen.has(parent.id)) return false - seen.add(parent.id) - return true - }) - if (expand.length === 0) return - const groups = Array.from({ length: Math.ceil(expand.length / DESCENDANT_LISTINGS) }, (_, index) => - expand.slice(index * DESCENDANT_LISTINGS, (index + 1) * DESCENDANT_LISTINGS), + await Promise.all( + parents + .filter((parent) => { + if (seen.has(parent.id)) return false + seen.add(parent.id) + return true + }) + .map(async (parent) => { + const children = ( + await descendantListing(() => + listChildren({ parentID: parent.id, order: "desc", limit: CHILD_PAGE }), + ).catch((): SessionInfo[] => []) + ).filter((child) => !seen.has(child.id)) + publishSessions(children) + return syncDescendants(children, seen) + }), ) - const listed: SessionInfo[] = [] - for (const group of groups) { - const pages = await Promise.all( - group.map((parent) => - listChildren({ parentID: parent.id, order: "desc", limit: CHILD_PAGE }).catch((): SessionInfo[] => []), - ), - ) - listed.push(...pages.flat()) - } - const children = listed.filter((child) => !seen.has(child.id)) - publishSessions(children) - return syncDescendants(children, seen) } function publishSessions(sessions: SessionInfo[]) { diff --git a/packages/client/test/solid-data.test.ts b/packages/client/test/solid-data.test.ts index cde648efb0eb..0cd24a785c2f 100644 --- a/packages/client/test/solid-data.test.ts +++ b/packages/client/test/solid-data.test.ts @@ -99,13 +99,14 @@ test("counts every subagent page in a root session's cost", async () => { } }) -test("discovers nested subagents at any depth, publishing each level as it arrives", async () => { +test("discovers nested subagents at any depth, publishing each listing as it resolves", async () => { const parents: Record = { ses_a: "ses_root", ses_b: "ses_root", ses_a1: "ses_a", ses_a2: "ses_a", ses_a1x: "ses_a1", + ses_a2x: "ses_a2", } const deepest = Promise.withResolvers() const requests: string[] = [] @@ -132,17 +133,26 @@ test("discovers nested subagents at any depth, publishing each level as it arriv try { const synced = setup.data.session.sync("ses_root", { children: true }) - await wait(() => setup.data.session.get("ses_a2") !== undefined) + // a2's listing publishes its child while a1's listing on the same level is still pending. + await wait(() => setup.data.session.get("ses_a2x") !== undefined) expect(setup.data.session.get("ses_a1x")).toBeUndefined() deepest.resolve() await synced - expect(setup.data.session.family("ses_root")).toEqual(["ses_root", "ses_a", "ses_b", "ses_a1", "ses_a2", "ses_a1x"]) + expect(setup.data.session.family("ses_root")).toEqual([ + "ses_root", + "ses_a", + "ses_b", + "ses_a1", + "ses_a2", + "ses_a2x", + "ses_a1x", + ]) expect(setup.data.session.root("ses_a1x")).toBe("ses_root") expect(requests.toSorted()).toEqual( [ "/api/session/ses_root?", - ...["ses_root", "ses_a", "ses_b", "ses_a1", "ses_a2", "ses_a1x"].map( + ...["ses_root", "ses_a", "ses_b", "ses_a1", "ses_a2", "ses_a1x", "ses_a2x"].map( (id) => `/api/session?limit=200&order=desc&parentID=${id}`, ), ].toSorted(), @@ -153,24 +163,28 @@ test("discovers nested subagents at any depth, publishing each level as it arriv } }) -test("keeps the rest of the tree when a nested listing fails, and lists a few sessions at a time", async () => { - const children = Array.from({ length: 20 }, (_, index) => `ses_c${index}`) +test("shares one listing limit across concurrent walks and keeps a tree whose nested listing fails", async () => { + const children: Record = Object.fromEntries( + ["ses_one", "ses_two"].map((root) => [root, Array.from({ length: 10 }, (_, index) => `${root}_c${index}`)]), + ) + const gate = Promise.withResolvers() const flight = { now: 0, most: 0 } const api = OpenCode.make({ baseUrl: "http://opencode.local", fetch: async (input, init) => { const url = new URL((input instanceof Request ? input : new Request(input, init)).url) - if (url.pathname === "/api/session/ses_root") return Response.json({ data: { ...session(0), id: "ses_root" } }) - const parentID = url.searchParams.get("parentID") - if (parentID === "ses_root") - return Response.json({ data: children.map((id) => ({ ...session(0), id, parentID })), cursor: {} }) + const root = url.pathname.match(/^\/api\/session\/(ses_one|ses_two)$/)?.[1] + if (root) return Response.json({ data: { ...session(0), id: root } }) + const parentID = url.searchParams.get("parentID") ?? "" + const direct = children[parentID] + if (direct) return Response.json({ data: direct.map((id) => ({ ...session(0), id, parentID })), cursor: {} }) flight.now++ flight.most = Math.max(flight.most, flight.now) - await Bun.sleep(1) + await gate.promise flight.now-- - if (parentID === "ses_c0") return new Response(null, { status: 500 }) + if (parentID === "ses_one_c0") return new Response(null, { status: 500 }) return Response.json({ - data: parentID === "ses_c1" ? [{ ...session(0), id: "ses_c1_child", parentID }] : [], + data: parentID === "ses_two_c1" ? [{ ...session(0), id: "ses_two_c1_child", parentID }] : [], cursor: {}, }) }, @@ -181,12 +195,25 @@ test("keeps the rest of the tree when a nested listing fails, and lists a few se })) try { - await setup.data.session.sync("ses_root", { children: true }) + const synced = Promise.all(["ses_one", "ses_two"].map((root) => setup.data.session.sync(root, { children: true }))) + // All twenty direct children wait for a listing, but both walks together start only eight. + await wait( + () => + flight.now === 8 && + Object.values(children) + .flat() + .every((id) => setup.data.session.get(id)), + ) + expect(flight.most).toBe(8) + gate.resolve() + await synced - expect(setup.data.session.get("ses_c1_child")?.parentID).toBe("ses_c1") - expect(setup.data.session.family("ses_root")).toHaveLength(22) - expect(flight.most).toBeLessThanOrEqual(8) + expect(flight.most).toBe(8) + expect(setup.data.session.get("ses_two_c1_child")?.parentID).toBe("ses_two_c1") + expect(setup.data.session.family("ses_one")).toHaveLength(11) + expect(setup.data.session.family("ses_two")).toHaveLength(12) } finally { + gate.resolve() setup.dispose() } }) From f04b560481de5fc520cb0f9876f7ec0eec4204d2 Mon Sep 17 00:00:00 2001 From: Drewry Pope Date: Sun, 18 Oct 2026 11:24:45 -0500 Subject: [PATCH 07/13] fix(client): keep live session updates over older listed snapshots A family listing could publish a descendant's snapshot after a live rename had already updated it, and the settled session read then made later syncs no-ops, so the stale title stuck. A listed snapshot now only fills in sessions this client has not read; a session whose own read is settled or in flight stays current through that read and live events. --- packages/client/src/solid/data.ts | 10 ++++- packages/client/test/solid-data.test.ts | 60 +++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 2 deletions(-) diff --git a/packages/client/src/solid/data.ts b/packages/client/src/solid/data.ts index ec5f85ea0fd4..46da61d31ad9 100644 --- a/packages/client/src/solid/data.ts +++ b/packages/client/src/solid/data.ts @@ -575,12 +575,18 @@ export function createData(config: CreateDataInput) { listChildren({ parentID: parent.id, order: "desc", limit: CHILD_PAGE }), ).catch((): SessionInfo[] => []) ).filter((child) => !seen.has(child.id)) - publishSessions(children) + publishSessions(children.filter(unread)) return syncDescendants(children, seen) }), ) } + // A listing fills in sessions this client has not read. A session whose own read is settled or in flight is + // kept current by that read and by live events such as a rename, so an older listed snapshot must not replace it. + function unread(session: SessionInfo) { + return !sync.has(`session:${session.id}`) + } + function publishSessions(sessions: SessionInfo[]) { batch(() => { setStore( @@ -1726,7 +1732,7 @@ export function createData(config: CreateDataInput) { api().session.get({ sessionID }), options?.children ? listChildren({ parentID: sessionID, order: "desc", limit: CHILD_PAGE }) : [], ]) - publishSessions([info, ...children]) + publishSessions([info, ...children.filter(unread)]) if (options?.children) await syncDescendants(children, new Set([sessionID])) }) }, diff --git a/packages/client/test/solid-data.test.ts b/packages/client/test/solid-data.test.ts index 0cd24a785c2f..24c421b77670 100644 --- a/packages/client/test/solid-data.test.ts +++ b/packages/client/test/solid-data.test.ts @@ -218,6 +218,66 @@ test("shares one listing limit across concurrent walks and keeps a tree whose ne } }) +test("keeps a live rename that lands while discovery lists the renamed subagent", async () => { + const listed = Promise.withResolvers() + const release = Promise.withResolvers() + const listeners = new Set[0]>() + const info = (id: string, parentID?: string): SessionInfo => ({ ...session(0), id, parentID, title: "Old title" }) + const api = OpenCode.make({ + baseUrl: "http://opencode.local", + fetch: async (input, init) => { + const url = new URL((input instanceof Request ? input : new Request(input, init)).url) + if (url.pathname === "/api/session/ses_root") return Response.json({ data: info("ses_root") }) + if (url.pathname === "/api/session/ses_grandchild") + return Response.json({ data: info("ses_grandchild", "ses_child") }) + const parentID = url.searchParams.get("parentID") + if (parentID === "ses_root") return Response.json({ data: [info("ses_child", "ses_root")], cursor: {} }) + if (parentID !== "ses_child") return Response.json({ data: [], cursor: {} }) + // The listing reads the grandchild before the rename, and its response lands after it. + listed.resolve() + await release.promise + return Response.json({ data: [info("ses_grandchild", "ses_child")], cursor: {} }) + }, + }) + const setup = createRoot((dispose) => ({ + data: createData({ + api: () => api, + directory: "/project", + event: { + on: () => () => {}, + listen(handler) { + listeners.add(handler) + return () => listeners.delete(handler) + }, + }, + }), + dispose, + })) + + try { + await setup.data.session.sync("ses_grandchild") + const synced = setup.data.session.sync("ses_root", { children: true }) + await listed.promise + const renamed: OpenCodeEvent = { + id: "evt_renamed", + created: 1, + type: "session.renamed", + durable: { aggregateID: "ses_grandchild", seq: 1, version: 1 }, + data: { sessionID: "ses_grandchild", title: "New title" }, + } + listeners.forEach((listener) => listener({ name: renamed.type, details: renamed })) + await wait(() => setup.data.session.get("ses_grandchild")?.title === "New title") + release.resolve() + await synced + + expect(setup.data.session.get("ses_grandchild")?.title).toBe("New title") + expect(setup.data.session.family("ses_root").toSorted()).toEqual(["ses_child", "ses_grandchild", "ses_root"]) + } finally { + release.resolve() + setup.dispose() + } +}) + test("lists each session once when parent links form a cycle", async () => { const requests: string[] = [] const api = OpenCode.make({ From d39589fce39c8cda00f88b7eb2e3febc5e437560 Mon Sep 17 00:00:00 2001 From: Drewry Pope Date: Sun, 18 Oct 2026 11:24:50 -0500 Subject: [PATCH 08/13] fix(tui): drop deferred notifications settled while their session loads A notification waiting for an unknown session to load could still fire after another client answered the permission, replied to or cancelled the form, or after a new execution started. The deferred callbacks now recheck that the ask is still pending and that their completion is still the session's latest. --- .../feature-plugins/system/notifications.ts | 44 +++++++++++++------ .../test/cli/cmd/tui/notifications.test.ts | 39 ++++++++++++++++ 2 files changed, 69 insertions(+), 14 deletions(-) diff --git a/packages/tui/src/feature-plugins/system/notifications.ts b/packages/tui/src/feature-plugins/system/notifications.ts index f07ec4d63e18..fdacc9b66866 100644 --- a/packages/tui/src/feature-plugins/system/notifications.ts +++ b/packages/tui/src/feature-plugins/system/notifications.ts @@ -22,12 +22,15 @@ export default Plugin.define({ id: "opencode.notifications", setup(context) { const errored = new Set() - const terminal = new Set() + // The current execution's completion per session. A new execution clears it, so a notification deferred + // while its session loads is dropped once the session has moved on. + const terminal = new Map() const forms = new Set() const permissions = new Set() // Events can name a session this client never loaded, such as a nested subagent of a root that is not open. - // Load it first so a subagent is not announced as a root session. + // Load it first so a subagent is not announced as a root session. Callers recheck that what they announce + // is still current, since another client can settle it while the session loads. const loaded = (sessionID: string, run: () => void) => { if (sessionID === "global" || context.data.session.get(sessionID)) return run() void context.data.session.sync(sessionID).then(run, run) @@ -39,37 +42,41 @@ export default Plugin.define({ } const ended = (sessionID: string) => { if (terminal.has(sessionID)) return - terminal.add(sessionID) + const completion = Symbol() + terminal.set(sessionID, completion) if (errored.has(sessionID)) { errored.delete(sessionID) return } - loaded(sessionID, () => + loaded(sessionID, () => { + if (terminal.get(sessionID) !== completion) return notify( context, sessionID, "Session done", context.data.session.get(sessionID)?.parentID ? "subagent_done" : "done", - ), - ) + ) + }) } const dispose = [ context.data.on("form.created", (event) => { if (forms.has(event.data.form.id)) return forms.add(event.data.form.id) - loaded(event.data.form.sessionID, () => - notify(context, event.data.form.sessionID, "Input needs response", "question", event.data.form.title), - ) + loaded(event.data.form.sessionID, () => { + if (!forms.has(event.data.form.id)) return + notify(context, event.data.form.sessionID, "Input needs response", "question", event.data.form.title) + }) }), context.data.on("form.replied", (event) => forms.delete(event.data.id)), context.data.on("form.cancelled", (event) => forms.delete(event.data.id)), context.data.on("permission.asked", (event) => { if (permissions.has(event.data.id)) return permissions.add(event.data.id) - loaded(event.data.sessionID, () => - notify(context, event.data.sessionID, "Permission needs input", "permission"), - ) + loaded(event.data.sessionID, () => { + if (!permissions.has(event.data.id)) return + notify(context, event.data.sessionID, "Permission needs input", "permission") + }) }), context.data.on("permission.replied", (event) => permissions.delete(event.data.requestID)), context.data.on("session.execution.started", (event) => started(event.data.sessionID)), @@ -83,9 +90,18 @@ export default Plugin.define({ return } errored.add(sessionID) - loaded(sessionID, () => notify(context, sessionID, event.data.error.message, "error")) - context.ui.toast.show({ sessionID, title: "Session failed", message: event.data.error.message, variant: "error" }) + context.ui.toast.show({ + sessionID, + title: "Session failed", + message: event.data.error.message, + variant: "error", + }) ended(sessionID) + const completion = terminal.get(sessionID) + loaded(sessionID, () => { + if (terminal.get(sessionID) !== completion) return + notify(context, sessionID, event.data.error.message, "error") + }) }), ] diff --git a/packages/tui/test/cli/cmd/tui/notifications.test.ts b/packages/tui/test/cli/cmd/tui/notifications.test.ts index 76114bb16f17..21b9686871c0 100644 --- a/packages/tui/test/cli/cmd/tui/notifications.test.ts +++ b/packages/tui/test/cli/cmd/tui/notifications.test.ts @@ -345,4 +345,43 @@ describe("internal notifications TUI plugin", () => { }, ]) }) + + test("drops an announcement settled by another client while its session loads", async () => { + const harness = await setup() + + harness.emit({ id: "event-1", created: 0, type: "permission.asked", data: permission("permission-1", "nested") }) + harness.emit({ + id: "event-2", + created: 0, + type: "permission.replied", + data: { sessionID: "nested", requestID: "permission-1", reply: "once" }, + }) + harness.emit({ id: "event-3", created: 0, type: "form.created", data: { form: form("form-1", "nested") } }) + harness.emit({ id: "event-4", created: 0, type: "form.cancelled", data: { sessionID: "nested", id: "form-1" } }) + harness.emit(executionStarted("event-5", "nested")) + harness.emit(executionFailed("event-6", "nested")) + harness.emit(executionStarted("event-7", "nested")) + await harness.settled() + + expect(harness.notifications).toEqual([]) + }) + + test("announces only the latest completion deferred while its session loads", async () => { + const harness = await setup() + + harness.emit(executionStarted("event-1", "nested")) + harness.emit(executionSucceeded("event-2", "nested")) + harness.emit(executionStarted("event-3", "nested")) + harness.emit(executionSucceeded("event-4", "nested")) + await harness.settled() + + expect(harness.notifications).toEqual([ + { + title: "Nested subagent session", + message: "Session done", + notification: false, + sound: { name: "subagent_done", when: "always" }, + }, + ]) + }) }) From 62557d38f0ddeeadf88fcf7cbcd07f4a333b979e Mon Sep 17 00:00:00 2001 From: Drewry Pope Date: Sun, 18 Oct 2026 11:24:51 -0500 Subject: [PATCH 09/13] fix(tui): retry ancestor discovery for a cold subagent view A transient failure while a subagent view walked up to its root was swallowed, and nothing reran the walk until a reconnect, so the view stayed without its family. The walk now lives in createFamilyDiscovery and retries a failed read after 250 ms, doubling up to five times; a reconnect or a new view starts over. --- packages/tui/src/routes/session/family.ts | 52 +++++++++ packages/tui/src/routes/session/index.tsx | 12 +- .../tui/test/cli/tui/session-family.test.ts | 107 ++++++++++++++++++ 3 files changed, 164 insertions(+), 7 deletions(-) create mode 100644 packages/tui/src/routes/session/family.ts create mode 100644 packages/tui/test/cli/tui/session-family.test.ts diff --git a/packages/tui/src/routes/session/family.ts b/packages/tui/src/routes/session/family.ts new file mode 100644 index 000000000000..4f1fa154eeb5 --- /dev/null +++ b/packages/tui/src/routes/session/family.ts @@ -0,0 +1,52 @@ +import { createEffect, createSignal, onCleanup, type Accessor } from "solid-js" +import type { Data } from "@opencode/client/solid" + +// Waits before each retry of a failed read. After the last one, a reconnect or a new view starts over. +const RETRY_DELAYS = [250, 500, 1_000, 2_000, 4_000] + +/** + * A subagent view opened before its ancestors walks up to the root, whose tree holds the whole family. A failed + * read is retried with a bounded backoff. + */ +export function createFamilyDiscovery(input: { + session: Pick + sessionID: Accessor + connected: Accessor + /** Schedules a retry and returns a function that cancels it. Defaults to a timer. */ + schedule?: (retry: () => void, delay: number) => () => void +}) { + const schedule = + input.schedule ?? + ((retry, delay) => { + const timer = setTimeout(retry, delay) + return () => clearTimeout(timer) + }) + const [attempt, setAttempt] = createSignal(0) + let failures = 0 + + createEffect(() => { + attempt() + if (!input.connected()) { + failures = 0 + return + } + if (!input.session.get(input.sessionID())?.parentID) return + const root = input.session.root(input.sessionID()) + const state = { stale: false, cancel: () => {} } + onCleanup(() => { + state.stale = true + state.cancel() + }) + void (input.session.get(root) ? input.session.sync(root, { children: true }) : input.session.sync(root)).then( + () => { + failures = 0 + }, + () => { + if (state.stale) return + const delay = RETRY_DELAYS[failures++] + if (delay === undefined) return + state.cancel = schedule(() => setAttempt((value) => value + 1), delay) + }, + ) + }) +} diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index b4e2b7db24ad..dcd7210f8986 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -121,6 +121,7 @@ import { createDelayedPresence } from "../../util/delayed-presence" import { SessionLocationMissing } from "./location-missing" import { isRecord } from "../../util/record" import { createHistoryPrepend } from "./history" +import { createFamilyDiscovery } from "./family" import { context, use, type PendingAction } from "./render-context" import { INLINE_TOOL_ICON_WIDTH, InlineToolRow, ReasoningPart, TextPart, toolDisplay } from "./message-parts" import { defaultVerbosity, type GroupKind, type SessionEntry } from "./grouping/session" @@ -330,13 +331,10 @@ export function Session(props: { }), ) - // A subagent view opened before its ancestors walks up to the root, whose tree holds the whole family. - createEffect(() => { - if (client.connection.status() !== "connected" || !session()?.parentID) return - const root = data.session.root(route.sessionID) - void (data.session.get(root) ? data.session.sync(root, { children: true }) : data.session.sync(root)).catch( - () => undefined, - ) + createFamilyDiscovery({ + session: data.session, + sessionID: () => route.sessionID, + connected: () => client.connection.status() === "connected", }) createEffect(() => { diff --git a/packages/tui/test/cli/tui/session-family.test.ts b/packages/tui/test/cli/tui/session-family.test.ts new file mode 100644 index 000000000000..eeda7b460f31 --- /dev/null +++ b/packages/tui/test/cli/tui/session-family.test.ts @@ -0,0 +1,107 @@ +import { expect, test } from "bun:test" +import type { SessionInfo } from "@opencode/client" +import { createData } from "@opencode/client/solid" +import { createRoot, createSignal } from "solid-js" +import { createFamilyDiscovery } from "../../../src/routes/session/family" +import { createApi, createFetch, directory, json } from "../../fixture/tui-client" + +const sessions: Record = { + root: info("root"), + child: info("child", "root"), + sibling: info("sibling", "root"), + grandchild: info("grandchild", "child"), +} + +// A subagent view of the grandchild, opened before its ancestors. Retries run when the test runs them. +function setup(failures: number) { + const reads = { child: 0 } + const retries: { delay: number; run: () => void }[] = [] + const api = createApi( + createFetch((url) => { + if (url.pathname === "/api/session") { + const parentID = url.searchParams.get("parentID") + return json({ data: Object.values(sessions).filter((session) => session.parentID === parentID), cursor: {} }) + } + const sessionID = url.pathname.match(/^\/api\/session\/([^/]+)$/)?.[1] ?? "" + if (sessionID === "child" && reads.child++ < failures) + return json({ _tag: "UnknownError", message: "Session store unavailable" }, { status: 503 }) + const session = sessions[sessionID] + if (session) return json({ data: session }) + }).fetch, + ) + return createRoot((dispose) => { + const data = createData({ api: () => api, event: { on: () => () => {}, listen: () => () => {} }, directory }) + const [connected, setConnected] = createSignal(true) + createFamilyDiscovery({ + session: data.session, + sessionID: () => "grandchild", + connected, + schedule: (run, delay) => { + retries.push({ delay, run }) + return () => {} + }, + }) + return { data, reads, retries, setConnected, dispose } + }) +} + +test("retries a failed ancestor read, then loads the root's whole tree", async () => { + const scope = setup(1) + try { + await scope.data.session.sync("grandchild") + await wait(() => scope.retries.length === 1) + expect(scope.retries[0]?.delay).toBe(250) + expect(scope.data.session.get("sibling")).toBeUndefined() + + scope.retries[0]?.run() + await wait(() => scope.data.session.get("sibling") !== undefined) + expect(scope.data.session.family("grandchild").toSorted()).toEqual(["child", "grandchild", "root", "sibling"]) + expect(scope.retries).toHaveLength(1) + } finally { + scope.dispose() + } +}) + +test("bounds the retries and starts over after a reconnect", async () => { + const scope = setup(Infinity) + try { + await scope.data.session.sync("grandchild") + for (const count of [1, 2, 3, 4, 5]) { + await wait(() => scope.retries.length === count) + scope.retries[count - 1]?.run() + } + // Join the fifth retry's read: it fails like the others and schedules nothing further. + await scope.data.session.sync("child").catch(() => undefined) + expect(scope.reads.child).toBe(6) + expect(scope.retries).toHaveLength(5) + + scope.setConnected(false) + scope.setConnected(true) + await wait(() => scope.retries.length === 6) + expect(scope.reads.child).toBe(7) + expect(scope.retries.map((retry) => retry.delay)).toEqual([250, 500, 1_000, 2_000, 4_000, 250]) + } finally { + scope.dispose() + } +}) + +function info(id: string, parentID?: string): SessionInfo { + return { + id, + ...(parentID ? { parentID } : {}), + projectID: "project", + location: { directory }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 0, updated: 0 }, + } +} + +// Requests resolve on their own; this only waits for them, never for a retry delay. +async function wait(check: () => boolean) { + const started = Date.now() + while (!check()) { + if (Date.now() - started > 2_000) throw new Error("Timed out waiting for condition") + await Bun.sleep(10) + } +} From de9c937ae35544c9c56c94b398893c4af9e3e9c4 Mon Sep 17 00:00:00 2001 From: Drewry Pope Date: Sun, 18 Oct 2026 11:41:58 -0500 Subject: [PATCH 10/13] fix(client): let discovery fill a subagent whose own read is still in flight --- packages/client/src/solid/data.ts | 7 +++-- packages/client/test/solid-data.test.ts | 41 +++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 3 deletions(-) diff --git a/packages/client/src/solid/data.ts b/packages/client/src/solid/data.ts index 46da61d31ad9..1b8c5bfc1b6d 100644 --- a/packages/client/src/solid/data.ts +++ b/packages/client/src/solid/data.ts @@ -581,10 +581,11 @@ export function createData(config: CreateDataInput) { ) } - // A listing fills in sessions this client has not read. A session whose own read is settled or in flight is - // kept current by that read and by live events such as a rename, so an older listed snapshot must not replace it. + // A listing fills in sessions this client holds no info for, even while their own read is in flight: if that read + // fails, the listed snapshot is all there is. Info already held is kept current by its read and by live events such + // as a rename, so an older listed snapshot must not replace it. function unread(session: SessionInfo) { - return !sync.has(`session:${session.id}`) + return !store.session.info[session.id] } function publishSessions(sessions: SessionInfo[]) { diff --git a/packages/client/test/solid-data.test.ts b/packages/client/test/solid-data.test.ts index 24c421b77670..6fb6d8e154d3 100644 --- a/packages/client/test/solid-data.test.ts +++ b/packages/client/test/solid-data.test.ts @@ -278,6 +278,47 @@ test("keeps a live rename that lands while discovery lists the renamed subagent" } }) +test("fills a subagent from discovery while its own read is in flight, and keeps it when that read fails", async () => { + const reading = Promise.withResolvers() + const failRead = Promise.withResolvers() + const info = (id: string, parentID?: string): SessionInfo => ({ ...session(0), id, parentID }) + const api = OpenCode.make({ + baseUrl: "http://opencode.local", + fetch: async (input, init) => { + const url = new URL((input instanceof Request ? input : new Request(input, init)).url) + if (url.pathname === "/api/session/ses_root") return Response.json({ data: info("ses_root") }) + if (url.pathname === "/api/session/ses_grandchild") { + reading.resolve() + await failRead.promise + return new Response(null, { status: 503 }) + } + const parentID = url.searchParams.get("parentID") + if (parentID === "ses_root") return Response.json({ data: [info("ses_child", "ses_root")], cursor: {} }) + if (parentID === "ses_child") return Response.json({ data: [info("ses_grandchild", "ses_child")], cursor: {} }) + return Response.json({ data: [], cursor: {} }) + }, + }) + const setup = createRoot((dispose) => ({ + data: createData({ api: () => api, directory: "/project", event: { on: () => () => {}, listen: () => () => {} } }), + dispose, + })) + + try { + // Something else, such as a notification, starts the grandchild's own read before discovery lists it. + const read = setup.data.session.sync("ses_grandchild").catch(() => undefined) + await reading.promise + await setup.data.session.sync("ses_root", { children: true }) + failRead.resolve() + await read + + expect(setup.data.session.get("ses_grandchild")?.parentID).toBe("ses_child") + expect(setup.data.session.family("ses_root").toSorted()).toEqual(["ses_child", "ses_grandchild", "ses_root"]) + } finally { + failRead.resolve() + setup.dispose() + } +}) + test("lists each session once when parent links form a cycle", async () => { const requests: string[] = [] const api = OpenCode.make({ From 785a3eeb9b542b399a861795aa10f23466b8e2a5 Mon Sep 17 00:00:00 2001 From: Drewry Pope Date: Sun, 18 Oct 2026 11:58:36 -0500 Subject: [PATCH 11/13] fix(client): refresh cached descendants from discovery after a reconnect --- packages/client/src/solid/data.ts | 9 ++-- packages/tui/test/cli/tui/data.test.tsx | 61 +++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 4 deletions(-) diff --git a/packages/client/src/solid/data.ts b/packages/client/src/solid/data.ts index 1b8c5bfc1b6d..6aeecf5b99a4 100644 --- a/packages/client/src/solid/data.ts +++ b/packages/client/src/solid/data.ts @@ -581,11 +581,12 @@ export function createData(config: CreateDataInput) { ) } - // A listing fills in sessions this client holds no info for, even while their own read is in flight: if that read - // fails, the listed snapshot is all there is. Info already held is kept current by its read and by live events such - // as a rename, so an older listed snapshot must not replace it. + // A listing fills in a session this client holds no info for, even while that session's own read is in flight: if + // the read fails, the listed snapshot is all there is. It also refreshes info that no read has covered since the + // last invalidation, such as after a reconnect. Info read since then is kept current by that read and by live events + // such as a rename, so an older listed snapshot must not replace it. function unread(session: SessionInfo) { - return !store.session.info[session.id] + return !store.session.info[session.id] || !sync.has(`session:${session.id}`) } function publishSessions(sessions: SessionInfo[]) { diff --git a/packages/tui/test/cli/tui/data.test.tsx b/packages/tui/test/cli/tui/data.test.tsx index cdcc5d3ba5d2..d4314a7dc02c 100644 --- a/packages/tui/test/cli/tui/data.test.tsx +++ b/packages/tui/test/cli/tui/data.test.tsx @@ -3242,6 +3242,67 @@ test("syncs every descendant session info with a navigated root", async () => { } }) +test("refreshes cached descendants from discovery after a reconnect", async () => { + const events = createEventStream() + const parents: Record = { child: "root", grandchild: "child" } + const titles: Record = { child: "Before", grandchild: "Before" } + const info = (id: string) => ({ ...sessionInfo(id, parents[id]), title: titles[id] ?? id }) + const calls = createFetch((url) => { + if (url.pathname === "/api/event") return events.v2() + if (url.pathname === "/api/session") { + const parentID = url.searchParams.get("parentID") + return json({ + data: Object.keys(parents) + .filter((id) => parents[id] === parentID) + .map(info), + cursor: {}, + }) + } + const match = url.pathname.match(/^\/api\/session\/([^/]+)$/) + if (match && match[1] !== "active") return json({ data: info(match[1]) }) + }, events) + let data!: ReturnType + let client!: ReturnType + + function Probe() { + data = useData() + client = useClient() + return + } + + const app = await testRender(() => ( + + + + + + + + + + )) + + try { + await wait(() => client.connection.status() === "connected") + await data.session.sync("root", { children: true }) + expect(data.session.get("grandchild")?.title).toBe("Before") + + // Renamed while the client is offline, so it never sees the events. + events.disconnect() + await wait(() => client.connection.status() === "reconnecting") + titles.child = "After" + titles.grandchild = "After" + // The client waits a second before it reconnects. + await wait(() => client.connection.status() === "connected", 4000) + await data.session.sync("root", { children: true }) + + expect(data.session.get("child")?.title).toBe("After") + expect(data.session.get("grandchild")?.title).toBe("After") + } finally { + app.renderer.destroy() + } +}) + test("groups an orphan child under its missing parent until the root arrives", async () => { const { data, app } = await mountData({ child: "root" }) try { From d1e9b20f47d3244b912582fdfbbd3e5258f668dc Mon Sep 17 00:00:00 2001 From: Drewry Pope Date: Sun, 18 Oct 2026 12:18:15 -0500 Subject: [PATCH 12/13] fix(client): keep snapshots a reconnect overtook from passing as current --- packages/client/src/solid/data.ts | 38 +++++-- packages/tui/test/cli/tui/data.test.tsx | 132 ++++++++++++++++++++---- 2 files changed, 138 insertions(+), 32 deletions(-) diff --git a/packages/client/src/solid/data.ts b/packages/client/src/solid/data.ts index 6aeecf5b99a4..1c405beb2ec8 100644 --- a/packages/client/src/solid/data.ts +++ b/packages/client/src/solid/data.ts @@ -176,6 +176,9 @@ function createSync() { // that window are already covered, since the reload has not read anything yet. type Pending = { promise: Promise; invalidated: boolean; started: boolean } const state = new Map() + // Counts full invalidations, such as a disconnect. A read requested before the latest one may be older than what + // the reload brings. + let generation = 0 const start = (key: string, load: () => Promise, wait?: Promise) => { const entry: Pending = { promise: Promise.resolve(), invalidated: false, started: !wait } state.set(key, entry) @@ -207,10 +210,16 @@ function createSync() { has(key: string) { return state.has(key) }, + /** Whether a settled read, or one in flight that no invalidation has overtaken, covers the key. */ + current(key: string) { + const active = state.get(key) + return active === true || (active !== undefined && !active.invalidated) + }, pending(key: string) { const active = state.get(key) return active !== undefined && active !== true }, + generation: () => generation, invalidate(key?: string) { if (key) { const active = state.get(key) @@ -218,6 +227,7 @@ function createSync() { if (active !== undefined && active !== true && active.started) active.invalidated = true return } + generation++ state.forEach((active, current) => { if (active === true) state.delete(current) if (active !== true && active.started) active.invalidated = true @@ -570,36 +580,43 @@ export function createData(config: CreateDataInput) { return true }) .map(async (parent) => { + const generation = sync.generation() const children = ( await descendantListing(() => listChildren({ parentID: parent.id, order: "desc", limit: CHILD_PAGE }), ).catch((): SessionInfo[] => []) ).filter((child) => !seen.has(child.id)) - publishSessions(children.filter(unread)) + publishSessions(children.filter(unread), generation) return syncDescendants(children, seen) }), ) } // A listing fills in a session this client holds no info for, even while that session's own read is in flight: if - // the read fails, the listed snapshot is all there is. It also refreshes info that no read has covered since the - // last invalidation, such as after a reconnect. Info read since then is kept current by that read and by live events - // such as a rename, so an older listed snapshot must not replace it. + // the read fails, the listed snapshot is all there is. It also refreshes info that no current read covers, such as + // after a reconnect, including info whose read was still in flight when the reconnect overtook it. Info a current + // read covers is kept current by that read and by live events such as a rename, so an older listed snapshot must not + // replace it. function unread(session: SessionInfo) { - return !store.session.info[session.id] || !sync.has(`session:${session.id}`) + return !store.session.info[session.id] || !sync.current(`session:${session.id}`) } - function publishSessions(sessions: SessionInfo[]) { + // `generation` is the sync generation when the snapshot was requested. One requested before the latest full + // invalidation, such as a disconnect, may be older than what the reload brings: it only fills in sessions with no + // info, and leaves them unconfirmed so the reload still replaces them. + function publishSessions(sessions: SessionInfo[], generation: number) { + const stale = generation !== sync.generation() + const published = stale ? sessions.filter((session) => !store.session.info[session.id]) : sessions batch(() => { setStore( "session", "info", produce((draft) => { - for (const session of sessions) draft[session.id] = session + for (const session of published) draft[session.id] = session }), ) - for (const session of sessions) { - sync.complete(`session:${session.id}`) + for (const session of published) { + if (!stale) sync.complete(`session:${session.id}`) registerSession(session.id) } }) @@ -1730,11 +1747,12 @@ export function createData(config: CreateDataInput) { /** With `children`, also loads every descendant session at any depth, so the family index is complete. */ sync(sessionID: string, options?: { children?: boolean }) { return sync.run(options?.children ? `session.family:${sessionID}` : `session:${sessionID}`, async () => { + const generation = sync.generation() const [info, children] = await Promise.all([ api().session.get({ sessionID }), options?.children ? listChildren({ parentID: sessionID, order: "desc", limit: CHILD_PAGE }) : [], ]) - publishSessions([info, ...children.filter(unread)]) + publishSessions([info, ...children.filter(unread)], generation) if (options?.children) await syncDescendants(children, new Set([sessionID])) }) }, diff --git a/packages/tui/test/cli/tui/data.test.tsx b/packages/tui/test/cli/tui/data.test.tsx index d4314a7dc02c..f1eaad05d6bd 100644 --- a/packages/tui/test/cli/tui/data.test.tsx +++ b/packages/tui/test/cli/tui/data.test.tsx @@ -21,7 +21,15 @@ import { ToastProvider } from "../../../src/ui/toast" import { createSessionRows, type SessionRow } from "../../../src/routes/session/rows" import { groupRefs } from "../../../src/routes/session/grouping/session" import { unwrap } from "solid-js/store" -import { createApi, createEventStream, createFetch, directory, json, worktree } from "../../fixture/tui-client" +import { + createApi, + createEventStream, + createFetch, + directory, + json, + worktree, + type FetchHandler, +} from "../../fixture/tui-client" import { emptyThemeSource, tmpdir } from "../../fixture/fixture" import { TestTuiContexts } from "../../fixture/tui-environment" import { createTuiResolvedConfig } from "../../fixture/tui-runtime" @@ -3242,13 +3250,10 @@ test("syncs every descendant session info with a navigated root", async () => { } }) -test("refreshes cached descendants from discovery after a reconnect", async () => { - const events = createEventStream() - const parents: Record = { child: "root", grandchild: "child" } - const titles: Record = { child: "Before", grandchild: "Before" } +// Answers session reads and child listings from `parents` and `titles` as they stand when the request arrives. +function sessionRoutes(parents: Record, titles: Record) { const info = (id: string) => ({ ...sessionInfo(id, parents[id]), title: titles[id] ?? id }) - const calls = createFetch((url) => { - if (url.pathname === "/api/event") return events.v2() + return (url: URL) => { if (url.pathname === "/api/session") { const parentID = url.searchParams.get("parentID") return json({ @@ -3260,7 +3265,13 @@ test("refreshes cached descendants from discovery after a reconnect", async () = } const match = url.pathname.match(/^\/api\/session\/([^/]+)$/) if (match && match[1] !== "active") return json({ data: info(match[1]) }) - }, events) + } +} + +// Mounts a DataProvider over an event stream the test can drop and restore. +async function mountReconnectable(respond: FetchHandler) { + const events = createEventStream() + const calls = createFetch(respond, events) let data!: ReturnType let client!: ReturnType @@ -3281,25 +3292,102 @@ test("refreshes cached descendants from discovery after a reconnect", async () = )) + await wait(() => client.connection.status() === "connected") + return { + data, + app, + // Drops the event stream and applies `offline` while the client is disconnected, so it never sees those changes. + async reconnect(offline: () => void) { + events.disconnect() + await wait(() => client.connection.status() === "reconnecting") + offline() + // The client waits a second before it reconnects. + await wait(() => client.connection.status() === "connected", 4000) + }, + } +} + +test("refreshes cached descendants from discovery after a reconnect", async () => { + const titles: Record = { child: "Before", grandchild: "Before" } + const setup = await mountReconnectable(sessionRoutes({ child: "root", grandchild: "child" }, titles)) try { - await wait(() => client.connection.status() === "connected") - await data.session.sync("root", { children: true }) - expect(data.session.get("grandchild")?.title).toBe("Before") + await setup.data.session.sync("root", { children: true }) + expect(setup.data.session.get("grandchild")?.title).toBe("Before") - // Renamed while the client is offline, so it never sees the events. - events.disconnect() - await wait(() => client.connection.status() === "reconnecting") - titles.child = "After" - titles.grandchild = "After" - // The client waits a second before it reconnects. - await wait(() => client.connection.status() === "connected", 4000) - await data.session.sync("root", { children: true }) + await setup.reconnect(() => Object.assign(titles, { child: "After", grandchild: "After" })) + await setup.data.session.sync("root", { children: true }) - expect(data.session.get("child")?.title).toBe("After") - expect(data.session.get("grandchild")?.title).toBe("After") + expect(setup.data.session.get("child")?.title).toBe("After") + expect(setup.data.session.get("grandchild")?.title).toBe("After") } finally { - app.renderer.destroy() + setup.app.renderer.destroy() + } +}) + +test("refreshes a descendant whose listing was answered before a reconnect but arrived after it", async () => { + const titles: Record = { child: "Before", grandchild: "Before" } + const routes = sessionRoutes({ child: "root", grandchild: "child" }, titles) + const holding = Promise.withResolvers() + const held = Promise.withResolvers() + const listings = { child: 0 } + const setup = await mountReconnectable(async (url) => { + const reply = routes(url) + // The first walk lists the grandchild before the rename, and its reply is held across the reconnect. + if (url.searchParams.get("parentID") === "child" && ++listings.child === 1) { + holding.resolve() + await held.promise + } + return reply + }) + + try { + const first = setup.data.session.sync("root", { children: true }) + await holding.promise + await setup.reconnect(() => Object.assign(titles, { child: "After", grandchild: "After" })) + const reload = setup.data.session.sync("root", { children: true }) + held.resolve() + await Promise.all([first, reload]) + + expect(setup.data.session.get("child")?.title).toBe("After") + expect(setup.data.session.get("grandchild")?.title).toBe("After") + } finally { + held.resolve() + setup.app.renderer.destroy() + } +}) + +test("keeps a reconnect's fresh listing over the reads that reconnect overtook", async () => { + const titles: Record = { failing: "Before", late: "Before" } + const routes = sessionRoutes({ failing: "root", late: "root" }, titles) + const holding = { failing: Promise.withResolvers(), late: Promise.withResolvers() } + const held = Promise.withResolvers() + const setup = await mountReconnectable(async (url) => { + const reply = routes(url) + const id = url.pathname.split("/").at(-1) + if (id !== "failing" && id !== "late") return reply + // Each subagent's own read is held across the reconnect. One then fails; the other answers with what it read. + holding[id].resolve() + await held.promise + return id === "failing" ? new Response(null, { status: 503 }) : reply + }) + + try { + await setup.data.session.sync("root", { children: true }) + setup.data.session.invalidate("failing") + setup.data.session.invalidate("late") + const reads = Promise.allSettled([setup.data.session.sync("failing"), setup.data.session.sync("late")]) + await Promise.all([holding.failing.promise, holding.late.promise]) + await setup.reconnect(() => Object.assign(titles, { failing: "After", late: "After" })) + await setup.data.session.sync("root", { children: true }) + held.resolve() + await reads + + expect(setup.data.session.get("failing")?.title).toBe("After") + expect(setup.data.session.get("late")?.title).toBe("After") + } finally { + held.resolve() + setup.app.renderer.destroy() } }) From a6fa7153cec8dad9a4af89998173ff9ca6a806f4 Mon Sep 17 00:00:00 2001 From: Drewry Pope Date: Sun, 18 Oct 2026 12:35:30 -0500 Subject: [PATCH 13/13] fix(client): list a failed branch again on the next family sync --- packages/client/src/solid/data.ts | 21 ++++++++------- packages/client/test/solid-data.test.ts | 35 +++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 9 deletions(-) diff --git a/packages/client/src/solid/data.ts b/packages/client/src/solid/data.ts index 1c405beb2ec8..ebebf7e6ba6d 100644 --- a/packages/client/src/solid/data.ts +++ b/packages/client/src/solid/data.ts @@ -570,9 +570,9 @@ export function createData(config: CreateDataInput) { // its children as soon as it resolves, and every walk shares one listing limit. `seen` holds the sessions // already listed, so every session is listed once and a parent cycle still ends the walk. Below the direct // children the walk is best effort: a branch whose listing fails is left to live events rather than failing - // the sync and closing the view. - async function syncDescendants(parents: SessionInfo[], seen: Set): Promise { - await Promise.all( + // the sync and closing the view. The walk resolves to whether every listing succeeded. + async function syncDescendants(parents: SessionInfo[], seen: Set): Promise { + const branches = await Promise.all( parents .filter((parent) => { if (seen.has(parent.id)) return false @@ -581,15 +581,16 @@ export function createData(config: CreateDataInput) { }) .map(async (parent) => { const generation = sync.generation() - const children = ( - await descendantListing(() => - listChildren({ parentID: parent.id, order: "desc", limit: CHILD_PAGE }), - ).catch((): SessionInfo[] => []) - ).filter((child) => !seen.has(child.id)) + const listed = await descendantListing(() => + listChildren({ parentID: parent.id, order: "desc", limit: CHILD_PAGE }), + ).catch(() => undefined) + if (!listed) return false + const children = listed.filter((child) => !seen.has(child.id)) publishSessions(children.filter(unread), generation) return syncDescendants(children, seen) }), ) + return branches.every(Boolean) } // A listing fills in a session this client holds no info for, even while that session's own read is in flight: if @@ -1753,7 +1754,9 @@ export function createData(config: CreateDataInput) { options?.children ? listChildren({ parentID: sessionID, order: "desc", limit: CHILD_PAGE }) : [], ]) publishSessions([info, ...children.filter(unread)], generation) - if (options?.children) await syncDescendants(children, new Set([sessionID])) + // A walk that skipped a failed branch is not kept, so the next sync lists the tree again. + if (options?.children && !(await syncDescendants(children, new Set([sessionID])))) + sync.invalidate(`session.family:${sessionID}`) }) }, invalidate(sessionID: string) { diff --git a/packages/client/test/solid-data.test.ts b/packages/client/test/solid-data.test.ts index 6fb6d8e154d3..5731111ab9c6 100644 --- a/packages/client/test/solid-data.test.ts +++ b/packages/client/test/solid-data.test.ts @@ -319,6 +319,41 @@ test("fills a subagent from discovery while its own read is in flight, and keeps } }) +test("lists a branch again on the next sync after its listing failed", async () => { + const failures = { left: 1 } + const info = (id: string, parentID?: string): SessionInfo => ({ ...session(0), id, parentID }) + const api = OpenCode.make({ + baseUrl: "http://opencode.local", + fetch: async (input, init) => { + const url = new URL((input instanceof Request ? input : new Request(input, init)).url) + if (url.pathname === "/api/session/ses_root") return Response.json({ data: info("ses_root") }) + const parentID = url.searchParams.get("parentID") + if (parentID === "ses_root") return Response.json({ data: [info("ses_child", "ses_root")], cursor: {} }) + if (parentID === "ses_child" && failures.left > 0) { + failures.left-- + return new Response(null, { status: 500 }) + } + if (parentID === "ses_child") return Response.json({ data: [info("ses_grandchild", "ses_child")], cursor: {} }) + return Response.json({ data: [], cursor: {} }) + }, + }) + const setup = createRoot((dispose) => ({ + data: createData({ api: () => api, directory: "/project", event: { on: () => () => {}, listen: () => () => {} } }), + dispose, + })) + + try { + await setup.data.session.sync("ses_root", { children: true }) + expect(setup.data.session.get("ses_grandchild")).toBeUndefined() + + await setup.data.session.sync("ses_root", { children: true }) + expect(setup.data.session.get("ses_grandchild")?.parentID).toBe("ses_child") + expect(setup.data.session.family("ses_root").toSorted()).toEqual(["ses_child", "ses_grandchild", "ses_root"]) + } finally { + setup.dispose() + } +}) + test("lists each session once when parent links form a cycle", async () => { const requests: string[] = [] const api = OpenCode.make({