From d72aa2e3e226606d6c9fa13a08a30b9f9fb5223e Mon Sep 17 00:00:00 2001 From: wangxc Date: Mon, 25 May 2026 13:34:53 +0800 Subject: [PATCH] fix(tui): surface direct child sessions without task metadata Direct session.create(parentID) children need to appear in the TUI and run footer even when plugin orchestration does not create native task tool parts. Constraint: session.create(parentID) direct children have no persisted task tool part metadata. Rejected: routing OpenSDD through the task tool by default | it changes deterministic orchestration semantics. Confidence: high Scope-risk: moderate Directive: Keep native task metadata tabs authoritative when task parts exist for the same child session. Tested: bun test test/cli/run/subagent-data.test.ts test/cli/run/stream.transport.test.ts; bun test test/cli/run/footer.view.test.tsx test/cli/cmd/tui/sync.test.tsx test/cli/cmd/tui/sync-undefined-messages.test.tsx test/cli/tui/use-event.test.tsx test/server/httpapi-session.test.ts; bun test test/server/httpapi-sdk.test.ts test/server/session-list.test.ts; bun typecheck; bun run lint; prettier; git diff --check; live HTTP API smoke Not-tested: Manual interactive TUI visual smoke in a real user terminal --- .../src/cli/cmd/run/stream.transport.ts | 27 ++- .../opencode/src/cli/cmd/run/subagent-data.ts | 169 ++++++++++++++-- .../src/cli/cmd/tui/routes/session/index.tsx | 74 ++++++- .../tui/routes/session/subagent-footer.tsx | 2 +- .../test/cli/run/stream.transport.test.ts | 123 +++++++++++- .../test/cli/run/subagent-data.test.ts | 186 ++++++++++++++++++ 6 files changed, 560 insertions(+), 21 deletions(-) diff --git a/packages/opencode/src/cli/cmd/run/stream.transport.ts b/packages/opencode/src/cli/cmd/run/stream.transport.ts index 41a083c702fc..205dec902b8c 100644 --- a/packages/opencode/src/cli/cmd/run/stream.transport.ts +++ b/packages/opencode/src/cli/cmd/run/stream.transport.ts @@ -134,6 +134,8 @@ function sid(event: Event): string | undefined { } if ( + event.type === "session.created" || + event.type === "session.updated" || event.type === "session.next.shell.started" || event.type === "session.next.shell.ended" || event.type === "permission.asked" || @@ -444,7 +446,19 @@ function createLayer(input: StreamInput) { const replayedParts = new Set() const recovering = new Set() const tracked = (sessionID: string | undefined) => - sessionID === input.sessionID || (!!sessionID && state.subagent.tabs.has(sessionID)) + sessionID === input.sessionID || + (!!sessionID && (state.subagent.tabs.has(sessionID) || state.subagent.children.has(sessionID))) + const trackedEvent = (event: Event) => { + if (tracked(sid(event))) { + return true + } + + if (event.type === "session.created" || event.type === "session.updated") { + return event.properties.info.parentID === input.sessionID + } + + return false + } const currentSubagentState = () => { if (state.selectedSubagent && !state.subagent.tabs.has(state.selectedSubagent)) { state.selectedSubagent = undefined @@ -627,7 +641,7 @@ function createLayer(input: StreamInput) { }) const bootstrap = Effect.fn("RunStreamTransport.bootstrap")(function* () { - const [messagesList, children, permissions, questions] = yield* Effect.all( + const [messagesList, children, status, permissions, questions] = yield* Effect.all( [ messages( input.sessionID, @@ -645,6 +659,10 @@ function createLayer(input: StreamInput) { Effect.map((item) => item.data ?? []), Effect.orElseSucceed(() => []), ), + Effect.promise(() => input.sdk.session.status()).pipe( + Effect.map((item) => item.data ?? {}), + Effect.orElseSucceed(() => ({})), + ), Effect.promise(() => input.sdk.permission.list()).pipe( Effect.map((item) => item.data ?? []), Effect.orElseSucceed(() => []), @@ -709,6 +727,7 @@ function createLayer(input: StreamInput) { data: state.subagent, messages: messagesList, children, + status, permissions, questions, }) @@ -901,7 +920,7 @@ function createLayer(input: StreamInput) { const next: Event[] = [] let changed = false for (const event of pending) { - if (!tracked(sid(event))) { + if (!trackedEvent(event)) { next.push(event) continue } @@ -951,7 +970,7 @@ function createLayer(input: StreamInput) { return } - if (!tracked(sessionID)) { + if (!trackedEvent(event)) { if (sessionID) { input.trace?.write("recv.event", event) buffered.push(event) diff --git a/packages/opencode/src/cli/cmd/run/subagent-data.ts b/packages/opencode/src/cli/cmd/run/subagent-data.ts index ecd3def44419..8632d99c2f9d 100644 --- a/packages/opencode/src/cli/cmd/run/subagent-data.ts +++ b/packages/opencode/src/cli/cmd/run/subagent-data.ts @@ -1,4 +1,12 @@ -import type { Event, Message, Part, PermissionRequest, QuestionRequest, ToolPart } from "@opencode-ai/sdk/v2" +import type { + Event, + Message, + Part, + PermissionRequest, + QuestionRequest, + SessionStatus, + ToolPart, +} from "@opencode-ai/sdk/v2" import * as Locale from "@/util/locale" import { bootstrapSessionData, @@ -37,15 +45,28 @@ type DetailState = { frames: Frame[] } +type ChildSessionInfo = { + id: string + title?: string + parentID?: string + agent?: string + time?: { + created?: number + updated?: number + } +} + export type SubagentData = { tabs: Map details: Map + children: Map } export type BootstrapSubagentInput = { data: SubagentData messages: SessionMessage[] - children: Array<{ id: string; title?: string }> + children: ChildSessionInfo[] + status?: Record permissions: PermissionRequest[] questions: QuestionRequest[] } @@ -313,6 +334,88 @@ function taskSessionID(part: ToolPart) { return text(metadata(part, "sessionId")) ?? text(metadata(part, "sessionID")) } +function childSessionAgent(info: ChildSessionInfo) { + return text(info.agent) ?? text(info.title?.match(/\(@([^)]+) subagent\)/)?.[1]) +} + +function childSessionLabel(info: ChildSessionInfo) { + const agent = childSessionAgent(info) + if (agent) { + return Locale.titlecase(agent) + } + + return "Child Session" +} + +function childDescription(info: ChildSessionInfo) { + return text(info.title) ?? "Direct child session" +} + +function childStatus(status: SessionStatus | undefined, current: FooterSubagentTab | undefined) { + if (!status) { + return current?.status ?? "completed" + } + + if (status.type === "busy") { + return "running" as const + } + + if (status.type === "retry") { + return "error" as const + } + + return "completed" as const +} + +function childUpdatedAt(info: ChildSessionInfo, current: FooterSubagentTab | undefined) { + return info.time?.updated ?? info.time?.created ?? current?.lastUpdatedAt ?? Date.now() +} + +function rememberChild(data: SubagentData, info: ChildSessionInfo) { + const current = data.children.get(info.id) + const next = current + ? { + ...current, + ...info, + time: { + ...current.time, + ...info.time, + }, + } + : info + + data.children.set(next.id, next) + return next +} + +function ensureChildTab(data: SubagentData, info: ChildSessionInfo, status?: SessionStatus) { + const child = rememberChild(data, info) + const current = data.tabs.get(child.id) + if (current && !current.partID.startsWith("child:")) { + ensureDetail(data, child.id) + return false + } + + const next = { + sessionID: child.id, + partID: `child:${child.id}`, + callID: `child:${child.id}`, + label: childSessionLabel(child), + description: childDescription(child), + status: childStatus(status, current), + title: child.title, + lastUpdatedAt: childUpdatedAt(child, current), + } + if (sameSubagentTab(current, next)) { + ensureDetail(data, child.id) + return false + } + + data.tabs.set(child.id, next) + ensureDetail(data, child.id) + return true +} + function syncTaskTab(data: SubagentData, part: ToolPart, children?: Set) { if (part.tool !== "task") { return false @@ -599,7 +702,7 @@ function bootstrapChildMessages(input: { } function knownSession(data: SubagentData, sessionID: string) { - return data.tabs.has(sessionID) + return data.tabs.has(sessionID) || data.children.has(sessionID) } export function listSubagentPermissions(data: SubagentData) { @@ -614,6 +717,7 @@ export function createSubagentData(): SubagentData { return { tabs: new Map(), details: new Map(), + children: new Map(), } } @@ -681,6 +785,22 @@ export function bootstrapSubagentData(input: BootstrapSubagentInput) { } } + for (const item of input.children) { + const hasBlocker = + input.permissions.some((request) => request.sessionID === item.id) || + input.questions.some((request) => request.sessionID === item.id) + const status = input.status?.[item.id] + if (!status && !hasBlocker && !input.data.tabs.has(item.id)) { + continue + } + + if (status?.type === "idle" && !hasBlocker && !input.data.tabs.has(item.id)) { + continue + } + + changed = ensureChildTab(input.data, item, hasBlocker ? { type: "busy" } : status) || changed + } + for (const item of input.permissions) { if (!children.has(item.sessionID)) { continue @@ -759,6 +879,7 @@ export function clearFinishedSubagents(data: SubagentData) { data.tabs.delete(sessionID) data.details.delete(sessionID) + data.children.delete(sessionID) changed = true } @@ -774,6 +895,22 @@ export function reduceSubagentData(input: { }) { const event = input.event + if (event.type === "session.created") { + if (event.properties.info.parentID !== input.sessionID) { + return false + } + + return ensureChildTab(input.data, event.properties.info, { type: "busy" }) + } + + if (event.type === "session.updated") { + if (event.properties.info.parentID !== input.sessionID && !knownSession(input.data, event.properties.info.id)) { + return false + } + + return ensureChildTab(input.data, event.properties.info) + } + if (event.type === "message.part.updated") { const part = event.properties.part if (part.sessionID === input.sessionID) { @@ -806,19 +943,23 @@ export function reduceSubagentData(input: { const detail = ensureDetail(input.data, sessionID) if (event.type === "session.status") { - if (event.properties.status.type !== "retry") { - return false + const info = input.data.children.get(sessionID) + const tabChanged = info ? ensureChildTab(input.data, info, event.properties.status) : false + if (event.properties.status.type === "retry") { + return ( + appendCommits(detail, [ + { + kind: "error", + text: event.properties.status.message, + phase: "start", + source: "system", + messageID: `retry:${event.properties.status.attempt}`, + }, + ]) || tabChanged + ) } - return appendCommits(detail, [ - { - kind: "error", - text: event.properties.status.message, - phase: "start", - source: "system", - messageID: `retry:${event.properties.status.attempt}`, - }, - ]) + return tabChanged } if (event.type === "session.error" && event.properties.error) { diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 270c11049e0f..212b271984ad 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -1426,6 +1426,20 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las if (!user || !user.time) return 0 return props.message.time.completed - user.time.created }) + const showSubagentHint = createMemo(() => { + if (props.parts.some((x) => x.type === "tool" && x.tool === "task")) return true + if (!props.last) return false + if (sync.session.get(props.message.sessionID)?.parentID) return false + return sync.data.session.some((x) => x.parentID === props.message.sessionID) + }) + const directSubagents = createMemo(() => { + if (props.parts.some((x) => x.type === "tool" && x.tool === "task")) return [] + if (!props.last) return [] + if (sync.session.get(props.message.sessionID)?.parentID) return [] + return sync.data.session + .filter((x) => x.parentID === props.message.sessionID) + .toSorted((a, b) => a.time.created - b.time.created) + }) const childShortcut = useCommandShortcut("session.child.first") @@ -1446,7 +1460,8 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las ) }} - x.type === "tool" && x.tool === "task")}> + + {childShortcut()} @@ -1498,6 +1513,63 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las ) } +function DirectSubagentSessions(props: { sessions: ReturnType["data"]["session"] }) { + const { theme } = useTheme() + const sync = useSync() + const route = useRoute() + const renderer = useRenderer() + const [hover, setHover] = createSignal() + + return ( + + {(session) => { + const status = createMemo(() => sync.data.session_status[session.id]) + const running = createMemo(() => status()?.type === "busy") + const errored = createMemo(() => status()?.type === "retry") + const agent = createMemo(() => { + const match = session.title.match(/\(@([^)]+) subagent\)/) + return session.agent ?? match?.[1] ?? "general" + }) + const description = createMemo( + () => session.title.replace(/\s*\(@([^)]+) subagent\)\s*$/, "").trim() || session.title, + ) + const content = createMemo(() => { + const lines = [`${Locale.titlecase(agent())} Task — ${description()}`] + const current = status() + if (running()) lines.push("↳ Running") + if (current?.type === "retry") lines.push(`↳ ${current.message}`) + return lines.join("\n") + }) + + return ( + setHover(session.id)} + onMouseOut={() => setHover(undefined)} + onMouseUp={() => { + if (renderer.getSelection()?.getSelectedText()) return + route.navigate({ type: "session", sessionID: session.id }) + }} + > + + {errored() ? "✗" : "✓"}{" "} + {content()} + + } + > + {content()} + + + ) + }} + + ) +} + const PART_MAPPING = { text: TextPart, tool: ToolPart, diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/subagent-footer.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/subagent-footer.tsx index e1be4286f12b..c762a29ccc73 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/subagent-footer.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/subagent-footer.tsx @@ -17,7 +17,7 @@ export function SubagentFooter() { const subagentInfo = createMemo(() => { const s = session() if (!s) return { label: "Subagent", index: 0, total: 0 } - const agentMatch = s.title.match(/@(\w+) subagent/) + const agentMatch = s.title.match(/\(@([^)]+) subagent\)/) const label = agentMatch ? Locale.titlecase(agentMatch[1]) : "Subagent" if (!s.parentID) return { label, index: 0, total: 0 } diff --git a/packages/opencode/test/cli/run/stream.transport.test.ts b/packages/opencode/test/cli/run/stream.transport.test.ts index d1b145db24b2..86e769a96512 100644 --- a/packages/opencode/test/cli/run/stream.transport.test.ts +++ b/packages/opencode/test/cli/run/stream.transport.test.ts @@ -324,7 +324,7 @@ function textDelta(messageID: string, partID: string, delta: string, sessionID = } } -function child(id: string): SessionChild { +function child(id: string, input: Partial = {}): SessionChild { return { id, slug: id, @@ -336,6 +336,7 @@ function child(id: string): SessionChild { created: 1, updated: 1, }, + ...input, } } @@ -776,6 +777,126 @@ describe("run stream transport", () => { } }) + test("bootstraps busy direct child sessions without task parts", async () => { + const src = eventFeed() + const ui = footer() + const transport = await createSessionTransport({ + sdk: sdk({ + stream: src.stream, + children: async () => + ok([ + child("child-1", { + title: "OpenSDD analysis (@opensdd-analysis subagent)", + agent: "opensdd-analysis", + }), + ]), + status: async () => ok({ "child-1": { type: "busy" } }), + }), + sessionID: "session-1", + thinking: true, + limits: () => ({}), + footer: ui.api, + }) + + try { + const state = await waitFor(() => { + const item = ui.events.findLast((event) => event.type === "stream.subagent") + return item?.type === "stream.subagent" && item.state.tabs.some((tab) => tab.sessionID === "child-1") + ? item.state + : undefined + }) + + expect(state.tabs).toEqual([ + expect.objectContaining({ + sessionID: "child-1", + partID: "child:child-1", + label: "Opensdd-Analysis", + status: "running", + }), + ]) + } finally { + src.close() + await transport.close() + } + }) + + test("discovers direct child sessions from global session.created events", async () => { + const global = globalFeed() + const ui = footer() + const transport = await createSessionTransport({ + sdk: sdk({ + globalStream: global.stream, + }), + sessionID: "session-1", + thinking: true, + limits: () => ({}), + footer: ui.api, + }) + + try { + global.push( + globalEvent({ + id: "evt-child-created", + type: "session.created", + properties: { + sessionID: "child-1", + info: child("child-1", { + parentID: "session-1", + title: "OpenSDD execution (@opensdd-execute subagent)", + agent: "opensdd-execute", + }), + }, + }), + ) + + await waitFor(() => { + const item = ui.events.findLast((event) => event.type === "stream.subagent") + return item?.type === "stream.subagent" && item.state.tabs.some((tab) => tab.sessionID === "child-1") + ? item + : undefined + }) + + transport.selectSubagent("child-1") + + global.push( + globalEvent({ + id: "evt-child-message", + type: "message.updated", + properties: { + sessionID: "child-1", + info: assistantMessage({ + sessionID: "child-1", + id: "msg-child-1", + parts: [], + }).info, + }, + }), + ) + global.push(globalEvent(textUpdated(textPart("txt-child-1", "msg-child-1", "direct child output", "child-1")))) + + expect( + await waitFor(() => { + const item = ui.events.findLast((event) => event.type === "stream.subagent") + const detail = item?.type === "stream.subagent" ? item.state.details["child-1"] : undefined + return detail?.commits.some((commit) => commit.kind === "assistant" && commit.text === "direct child output") + ? detail + : undefined + }), + ).toEqual({ + sessionID: "child-1", + commits: [ + expect.objectContaining({ + kind: "assistant", + text: "direct child output", + }), + ], + }) + } finally { + global.close() + await transport.close() + } + }) + test("bootstraps child tabs and resumed blocker input", async () => { const src = eventFeed() const ui = footer() diff --git a/packages/opencode/test/cli/run/subagent-data.test.ts b/packages/opencode/test/cli/run/subagent-data.test.ts index e31136b22f0f..0bc550e7eb91 100644 --- a/packages/opencode/test/cli/run/subagent-data.test.ts +++ b/packages/opencode/test/cli/run/subagent-data.test.ts @@ -182,6 +182,192 @@ function childMessage(input: { } describe("run subagent data", () => { + test("bootstraps busy child sessions without task parts as generic tabs", () => { + const data = createSubagentData() + + expect( + bootstrapSubagentData({ + data, + messages: [], + children: [ + { + id: "child-1", + title: "OpenSDD analysis (@opensdd-analysis subagent)", + time: { + created: 1, + updated: 2, + }, + }, + ], + status: { + "child-1": { type: "busy" }, + }, + permissions: [], + questions: [], + }), + ).toBe(true) + + expect(snapshotSubagentData(data).tabs).toEqual([ + expect.objectContaining({ + sessionID: "child-1", + partID: "child:child-1", + callID: "child:child-1", + label: "Opensdd-Analysis", + description: "OpenSDD analysis (@opensdd-analysis subagent)", + status: "running", + lastUpdatedAt: 2, + }), + ]) + }) + + test("does not bootstrap idle child sessions without blockers", () => { + const data = createSubagentData() + + expect( + bootstrapSubagentData({ + data, + messages: [], + children: [{ id: "child-1", title: "Historical child" }], + status: { + "child-1": { type: "idle" }, + }, + permissions: [], + questions: [], + }), + ).toBe(false) + + expect(snapshotSubagentData(data).tabs).toEqual([]) + }) + + test("keeps task tabs ahead of generic child session tabs", () => { + const data = createSubagentData() + + bootstrapSubagentData({ + data, + messages: [taskMessage("child-1", "running")], + children: [{ id: "child-1", title: "Generic child" }], + status: { + "child-1": { type: "busy" }, + }, + permissions: [], + questions: [], + }) + + expect(snapshotSubagentData(data).tabs).toEqual([ + expect.objectContaining({ + sessionID: "child-1", + partID: "part-child-1", + label: "Explore", + description: "Scan reducer paths", + status: "running", + }), + ]) + }) + + test("discovers direct child sessions from session.created events", () => { + const data = createSubagentData() + + expect( + reduce(data, { + type: "session.created", + properties: { + sessionID: "child-1", + info: { + id: "child-1", + parentID: "parent-1", + title: "Direct child (@opensdd-execute subagent)", + time: { + created: 1, + }, + }, + }, + }), + ).toBe(true) + + expect(snapshotSubagentData(data).tabs).toEqual([ + expect.objectContaining({ + sessionID: "child-1", + partID: "child:child-1", + label: "Opensdd-Execute", + status: "running", + }), + ]) + }) + + test("applies child events after generic discovery", () => { + const data = createSubagentData() + + reduce(data, { + type: "session.created", + properties: { + sessionID: "child-1", + info: { + id: "child-1", + parentID: "parent-1", + title: "Direct child (@opensdd-execute subagent)", + }, + }, + }) + reduce(data, { + type: "message.updated", + properties: { + sessionID: "child-1", + info: { + id: "msg-assistant-1", + role: "assistant", + }, + }, + }) + reduce(data, { + type: "message.part.updated", + properties: { + part: { + id: "txt-1", + messageID: "msg-assistant-1", + sessionID: "child-1", + type: "text", + text: "generic child output", + }, + }, + }) + + expect(visible(snapshotSubagentData(data).details["child-1"]?.commits ?? [])).toEqual(["generic child output"]) + }) + + test("marks generic child tabs completed from idle status", () => { + const data = createSubagentData() + + reduce(data, { + type: "session.created", + properties: { + sessionID: "child-1", + info: { + id: "child-1", + parentID: "parent-1", + title: "Direct child", + }, + }, + }) + + expect( + reduce(data, { + type: "session.status", + properties: { + sessionID: "child-1", + status: { + type: "idle", + }, + }, + }), + ).toBe(true) + expect(snapshotSubagentData(data).tabs).toEqual([ + expect.objectContaining({ sessionID: "child-1", status: "completed" }), + ]) + + expect(clearFinishedSubagents(data)).toBe(true) + expect(snapshotSubagentData(data).tabs).toEqual([]) + }) + test("bootstraps tabs and child blockers from parent task parts", () => { const data = createSubagentData()