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()