From fc57f7e6765f58963589aa9e5caa213e3862bfba Mon Sep 17 00:00:00 2001 From: Simon Klee Date: Fri, 17 Apr 2026 13:22:54 +0200 Subject: [PATCH] cli: add live subagent footer inspector to run Keep direct-mode subagent activity in the footer so child sessions can be inspected. --- packages/opencode/src/cli/cmd/run/demo.ts | 128 +++- .../src/cli/cmd/run/footer.subagent.tsx | 194 +++++ packages/opencode/src/cli/cmd/run/footer.ts | 94 +-- .../opencode/src/cli/cmd/run/footer.view.tsx | 499 +++++++----- .../src/cli/cmd/run/runtime.lifecycle.ts | 4 +- packages/opencode/src/cli/cmd/run/runtime.ts | 8 + .../src/cli/cmd/run/scrollback.writer.tsx | 282 ++++--- .../opencode/src/cli/cmd/run/session-data.ts | 63 +- .../src/cli/cmd/run/stream.transport.ts | 407 +++++++++- packages/opencode/src/cli/cmd/run/stream.ts | 128 +++- .../opencode/src/cli/cmd/run/subagent-data.ts | 715 ++++++++++++++++++ packages/opencode/src/cli/cmd/run/types.ts | 31 + .../src/cli/cmd/tui/component/spinner.tsx | 4 +- packages/opencode/test/cli/run/footer.test.ts | 6 + .../test/cli/run/footer.view.test.tsx | 6 + packages/opencode/test/cli/run/stream.test.ts | 66 +- .../test/cli/run/stream.transport.test.ts | 239 ++++++ .../test/cli/run/subagent-data.test.ts | 367 +++++++++ 18 files changed, 2851 insertions(+), 390 deletions(-) create mode 100644 packages/opencode/src/cli/cmd/run/footer.subagent.tsx create mode 100644 packages/opencode/src/cli/cmd/run/subagent-data.ts create mode 100644 packages/opencode/test/cli/run/footer.test.ts create mode 100644 packages/opencode/test/cli/run/footer.view.test.tsx create mode 100644 packages/opencode/test/cli/run/subagent-data.test.ts diff --git a/packages/opencode/src/cli/cmd/run/demo.ts b/packages/opencode/src/cli/cmd/run/demo.ts index a8fcd944c3..b1638c1e8d 100644 --- a/packages/opencode/src/cli/cmd/run/demo.ts +++ b/packages/opencode/src/cli/cmd/run/demo.ts @@ -17,7 +17,15 @@ import path from "path" import type { Event } from "@opencode-ai/sdk/v2" import { createSessionData, reduceSessionData, type SessionData } from "./session-data" import { writeSessionOutput } from "./stream" -import type { FooterApi, PermissionReply, QuestionReject, QuestionReply, RunDemo, RunPrompt } from "./types" +import type { + FooterApi, + PermissionReply, + QuestionReject, + QuestionReply, + RunDemo, + RunPrompt, + StreamCommit, +} from "./types" const KINDS = ["text", "reasoning", "bash", "write", "edit", "patch", "task", "todo", "question", "error", "mix"] const PERMISSIONS = ["edit", "bash", "read", "task", "external", "doom"] as const @@ -115,6 +123,60 @@ function note(footer: FooterApi, text: string): void { }) } +function clearSubagent(footer: FooterApi): void { + footer.event({ + type: "stream.subagent", + state: { + tabs: [], + details: {}, + permissions: [], + questions: [], + }, + }) +} + +function showSubagent( + state: State, + input: { + sessionID: string + partID: string + callID: string + label: string + description: string + status: "running" | "completed" | "error" + title?: string + toolCalls?: number + commits: StreamCommit[] + }, +) { + state.footer.event({ + type: "stream.subagent", + state: { + tabs: [ + { + sessionID: input.sessionID, + partID: input.partID, + callID: input.callID, + label: input.label, + description: input.description, + status: input.status, + title: input.title, + toolCalls: input.toolCalls, + lastUpdatedAt: Date.now(), + }, + ], + details: { + [input.sessionID]: { + sessionID: input.sessionID, + commits: input.commits, + }, + }, + permissions: [], + questions: [], + }, + }) +} + function wait(ms: number, signal?: AbortSignal): Promise { return new Promise((resolve) => { if (!signal) { @@ -564,6 +626,68 @@ function emitTask(state: State): void { sessionId: "sub_demo_1", }, }) + showSubagent(state, { + sessionID: "sub_demo_1", + partID: ref.part, + callID: ref.call, + label: "Explore", + description: "Scan run/* for reducer touchpoints", + status: "completed", + title: "Reducer touchpoints found", + toolCalls: 4, + commits: [ + { + kind: "user", + text: "Scan run/* for reducer touchpoints", + phase: "start", + source: "system", + }, + { + kind: "reasoning", + text: "Thinking: tracing reducer and footer boundaries", + phase: "progress", + source: "reasoning", + messageID: "sub_demo_msg_reasoning", + partID: "sub_demo_reasoning_1", + }, + { + kind: "tool", + text: "running read", + phase: "start", + source: "tool", + messageID: "sub_demo_msg_tool", + partID: "sub_demo_tool_1", + tool: "read", + part: { + id: "sub_demo_tool_1", + type: "tool", + sessionID: "sub_demo_1", + messageID: "sub_demo_msg_tool", + callID: "sub_demo_call_1", + tool: "read", + state: { + status: "running", + input: { + filePath: "packages/opencode/src/cli/cmd/run/stream.ts", + offset: 1, + limit: 200, + }, + time: { + start: Date.now(), + }, + }, + } as never, + }, + { + kind: "assistant", + text: "Footer updates flow through stream.ts into RunFooter", + phase: "progress", + source: "assistant", + messageID: "sub_demo_msg_text", + partID: "sub_demo_text_1", + }, + ], + }) } function emitTodo(state: State): void { @@ -980,6 +1104,8 @@ export function createRunDemo(input: Input) { const list = text.split(/\s+/) const cmd = list[0] || "" + clearSubagent(state.footer) + if (cmd === "/help") { intro(state) return true diff --git a/packages/opencode/src/cli/cmd/run/footer.subagent.tsx b/packages/opencode/src/cli/cmd/run/footer.subagent.tsx new file mode 100644 index 0000000000..7e5e4dacf6 --- /dev/null +++ b/packages/opencode/src/cli/cmd/run/footer.subagent.tsx @@ -0,0 +1,194 @@ +/** @jsxImportSource @opentui/solid */ +import type { ScrollBoxRenderable } from "@opentui/core" +import { useKeyboard } from "@opentui/solid" +import "opentui-spinner/solid" +import { For, createMemo, createSignal } from "solid-js" +import { SPINNER_FRAMES } from "../tui/component/spinner" +import { RunEntryContent, sameEntryGroup } from "./scrollback.writer" +import type { FooterSubagentDetail, FooterSubagentTab, RunDiffStyle } from "./types" +import type { RunFooterTheme, RunTheme } from "./theme" + +export const SUBAGENT_TAB_ROWS = 2 +export const SUBAGENT_INSPECTOR_ROWS = 8 + +function statusColor(theme: RunFooterTheme, status: FooterSubagentTab["status"]) { + if (status === "completed") { + return theme.success + } + + if (status === "error") { + return theme.error + } + + return theme.highlight +} + +function statusIcon(status: FooterSubagentTab["status"]) { + if (status === "completed") { + return "●" + } + + if (status === "error") { + return "◍" + } + + return "◔" +} + +export function RunFooterSubagentTabs(props: { + tabs: FooterSubagentTab[] + selected?: string + theme: RunFooterTheme + onToggle: (sessionID: string) => void +}) { + const [hover, setHover] = createSignal() + + return ( + + + {props.tabs.map((tab) => { + const active = () => props.selected === tab.sessionID + const hovered = () => hover() === tab.sessionID + const emphasized = () => active() || hovered() + return ( + { + setHover(tab.sessionID) + }} + onMouseOut={() => { + if (hover() === tab.sessionID) { + setHover(undefined) + } + }} + onMouseUp={() => { + props.onToggle(tab.sessionID) + }} + > + + {tab.status === "running" ? ( + + + + ) : ( + + {statusIcon(tab.status)} + + )} + + {tab.label} + + + + ) + })} + + + ) +} + +export function RunFooterSubagentBody(props: { + active: () => boolean + theme: () => RunTheme + detail: () => FooterSubagentDetail | undefined + width: () => number + diffStyle?: RunDiffStyle + onCycle: (dir: -1 | 1) => void + onClose: () => void +}) { + const commits = createMemo(() => props.detail()?.commits ?? []) + const entries = createMemo(() => { + return commits().map((commit, index, list) => ({ + commit, + gap: index > 0 && !sameEntryGroup(list[index - 1], commit), + })) + }) + let scroll: ScrollBoxRenderable | undefined + + useKeyboard((event) => { + if (!props.active()) { + return + } + + if (event.name === "escape") { + event.preventDefault() + props.onClose() + return + } + + if (event.name === "tab") { + event.preventDefault() + props.onCycle(event.shift ? -1 : 1) + return + } + + if (event.name === "up" || event.name === "k") { + event.preventDefault() + scroll?.scrollBy(-1) + return + } + + if (event.name === "down" || event.name === "j") { + event.preventDefault() + scroll?.scrollBy(1) + } + }) + + return ( + + + { + scroll = item as ScrollBoxRenderable + }} + > + + + {(item) => ( + + {item.gap ? : null} + + + )} + + {entries().length === 0 ? ( + + No subagent activity yet + + ) : null} + + + + + ) +} diff --git a/packages/opencode/src/cli/cmd/run/footer.ts b/packages/opencode/src/cli/cmd/run/footer.ts index def4fb1c8c..f36d765751 100644 --- a/packages/opencode/src/cli/cmd/run/footer.ts +++ b/packages/opencode/src/cli/cmd/run/footer.ts @@ -26,13 +26,13 @@ import { CliRenderEvents, type CliRenderer } from "@opentui/core" import { render } from "@opentui/solid" import { createComponent, createSignal, type Accessor, type Setter } from "solid-js" +import { SUBAGENT_INSPECTOR_ROWS, SUBAGENT_TAB_ROWS } from "./footer.subagent" import { PROMPT_MAX_ROWS, TEXTAREA_MIN_ROWS } from "./footer.prompt" import { printableBinding } from "./prompt.shared" import { RunFooterView } from "./footer.view" import { normalizeEntry } from "./scrollback.format" import { entryWriter } from "./scrollback" -import { spacerWriter } from "./scrollback.writer" -import { toolView } from "./tool" +import { sameEntryGroup, spacerWriter } from "./scrollback.writer" import type { RunTheme } from "./theme" import type { RunAgent, @@ -40,9 +40,11 @@ import type { FooterEvent, FooterKeybinds, FooterPatch, + FooterPromptRoute, RunPrompt, RunResource, FooterState, + FooterSubagentState, FooterView, PermissionReply, QuestionReject, @@ -74,11 +76,21 @@ type RunFooterOptions = { onCycleVariant?: () => CycleResult | void onInterrupt?: () => void onExit?: () => void + onSubagentSelect?: (sessionID: string | undefined) => void } const PERMISSION_ROWS = 12 const QUESTION_ROWS = 14 +function createEmptySubagentState(): FooterSubagentState { + return { + tabs: [], + details: {}, + permissions: [], + questions: [], + } +} + export class RunFooter implements FooterApi { private closed = false private destroyed = false @@ -98,6 +110,10 @@ export class RunFooter implements FooterApi { private setState: Setter private view: Accessor private setView: Setter + private subagent: Accessor + private setSubagent: Setter + private promptRoute: FooterPromptRoute = { type: "composer" } + private tabsVisible = false private interruptTimeout: NodeJS.Timeout | undefined private exitTimeout: NodeJS.Timeout | undefined private interruptHint: string @@ -122,6 +138,9 @@ export class RunFooter implements FooterApi { const [view, setView] = createSignal({ type: "prompt" }) this.view = view this.setView = setView + const [subagent, setSubagent] = createSignal(createEmptySubagentState()) + this.subagent = subagent + this.setSubagent = setSubagent this.base = Math.max(1, renderer.footerHeight - TEXTAREA_MIN_ROWS) this.interruptHint = printableBinding(options.keybinds.interrupt, options.keybinds.leader) || "esc" @@ -133,11 +152,11 @@ export class RunFooter implements FooterApi { directory: options.directory, state: this.state, view: this.view, + subagent: this.subagent, findFiles: options.findFiles, agents: () => options.agents, resources: () => options.resources, - theme: options.theme.footer, - block: options.theme.block, + theme: options.theme, diffStyle: options.diffStyle, keybinds: options.keybinds, history: options.history, @@ -151,7 +170,9 @@ export class RunFooter implements FooterApi { onExitRequest: this.handleExit, onExit: () => this.close(), onRows: this.syncRows, + onLayout: this.syncLayout, onStatus: this.setStatus, + onSubagentSelect: options.onSubagentSelect, }), this.renderer as unknown as Parameters[1], ).catch(() => { @@ -241,6 +262,16 @@ export class RunFooter implements FooterApi { return } + if (next.type === "stream.subagent") { + if (this.destroyed || this.renderer.isDestroyed) { + return + } + + this.setSubagent(next.state) + this.applyHeight() + return + } + this.present(next.view) } @@ -382,12 +413,18 @@ export class RunFooter implements FooterApi { // get fixed extra rows; the prompt view scales with textarea line count. private applyHeight(): void { const type = this.view().type + const tabs = this.tabsVisible ? SUBAGENT_TAB_ROWS : 0 const height = type === "permission" ? this.base + PERMISSION_ROWS : type === "question" ? this.base + QUESTION_ROWS - : Math.max(this.base + TEXTAREA_MIN_ROWS, Math.min(this.base + PROMPT_MAX_ROWS, this.base + this.rows)) + : this.promptRoute.type === "subagent" + ? this.base + tabs + SUBAGENT_INSPECTOR_ROWS + : Math.max( + this.base + TEXTAREA_MIN_ROWS, + Math.min(this.base + tabs + PROMPT_MAX_ROWS, this.base + tabs + this.rows), + ) if (height !== this.renderer.footerHeight) { this.renderer.footerHeight = height @@ -410,6 +447,14 @@ export class RunFooter implements FooterApi { } } + private syncLayout = (next: { route: FooterPromptRoute; tabs: boolean }): void => { + this.promptRoute = next.route + this.tabsVisible = next.tabs + if (this.view().type === "prompt") { + this.applyHeight() + } + } + private handlePrompt = (input: RunPrompt): boolean => { if (this.isClosed) { return false @@ -586,7 +631,7 @@ export class RunFooter implements FooterApi { } for (const item of this.queue.splice(0)) { - const same = sameGroup(this.tail, item) + const same = sameEntryGroup(this.tail, item) if (this.wrote && !same) { this.renderer.writeToScrollback(spacerWriter()) } @@ -597,40 +642,3 @@ export class RunFooter implements FooterApi { } } } - -function snap(commit: StreamCommit): boolean { - const tool = commit.tool ?? commit.part?.tool - return ( - commit.kind === "tool" && - commit.phase === "final" && - (commit.toolState ?? commit.part?.state.status) === "completed" && - typeof tool === "string" && - Boolean(toolView(tool).snap) - ) -} - -function groupKey(commit: StreamCommit): string | undefined { - if (!commit.partID) { - return - } - - if (snap(commit)) { - return `tool:${commit.partID}:final` - } - - return `${commit.kind}:${commit.partID}` -} - -function sameGroup(a: StreamCommit | undefined, b: StreamCommit): boolean { - if (!a) { - return false - } - - const left = groupKey(a) - const right = groupKey(b) - if (left && right && left === right) { - return true - } - - return a.kind === "tool" && a.phase === "start" && b.kind === "tool" && b.phase === "start" -} diff --git a/packages/opencode/src/cli/cmd/run/footer.view.tsx b/packages/opencode/src/cli/cmd/run/footer.view.tsx index ef0e4722b7..cfe746c76b 100644 --- a/packages/opencode/src/cli/cmd/run/footer.view.tsx +++ b/packages/opencode/src/cli/cmd/run/footer.view.tsx @@ -11,26 +11,29 @@ // The view itself is stateless except for derived memos. /** @jsxImportSource @opentui/solid */ import { useTerminalDimensions } from "@opentui/solid" -import { Match, Show, Switch, createMemo } from "solid-js" +import { Match, Show, Switch, createEffect, createMemo, createSignal } from "solid-js" import "opentui-spinner/solid" import { createColors, createFrames } from "../tui/ui/spinner" +import { RunFooterSubagentBody, RunFooterSubagentTabs } from "./footer.subagent" import { RunPromptAutocomplete, RunPromptBody, createPromptState, hintFlags } from "./footer.prompt" import { RunPermissionBody } from "./footer.permission" import { RunQuestionBody } from "./footer.question" import { printableBinding } from "./prompt.shared" import type { FooterKeybinds, + FooterPromptRoute, RunAgent, RunPrompt, RunResource, FooterState, + FooterSubagentState, FooterView, PermissionReply, QuestionReject, QuestionReply, RunDiffStyle, } from "./types" -import { RUN_THEME_FALLBACK, type RunBlockTheme, type RunFooterTheme } from "./theme" +import { RUN_THEME_FALLBACK, type RunTheme } from "./theme" const EMPTY_BORDER = { topLeft: "", @@ -53,8 +56,8 @@ type RunFooterViewProps = { resources: () => RunResource[] state: () => FooterState view?: () => FooterView - theme?: RunFooterTheme - block?: RunBlockTheme + subagent?: () => FooterSubagentState + theme?: RunTheme diffStyle?: RunDiffStyle keybinds: FooterKeybinds history?: RunPrompt[] @@ -68,7 +71,9 @@ type RunFooterViewProps = { onExitRequest?: () => boolean onExit: () => void onRows: (rows: number) => void + onLayout: (input: { route: FooterPromptRoute; tabs: boolean }) => void onStatus: (text: string) => void + onSubagentSelect?: (sessionID: string | undefined) => void } export { TEXTAREA_MIN_ROWS, TEXTAREA_MAX_ROWS } from "./footer.prompt" @@ -76,7 +81,33 @@ export { TEXTAREA_MIN_ROWS, TEXTAREA_MAX_ROWS } from "./footer.prompt" export function RunFooterView(props: RunFooterViewProps) { const term = useTerminalDimensions() const active = createMemo(() => props.view?.() ?? { type: "prompt" }) - const prompt = createMemo(() => active().type === "prompt") + const subagent = createMemo(() => { + return ( + props.subagent?.() ?? { + tabs: [], + details: {}, + permissions: [], + questions: [], + } + ) + }) + const [route, setRoute] = createSignal({ type: "composer" }) + const prompt = createMemo(() => active().type === "prompt" && route().type === "composer") + const inspecting = createMemo(() => active().type === "prompt" && route().type === "subagent") + const selected = createMemo(() => { + const current = route() + return current.type === "subagent" ? current.sessionID : undefined + }) + const tabs = createMemo(() => subagent().tabs) + const showTabs = createMemo(() => active().type === "prompt" && tabs().length > 0) + const detail = createMemo(() => { + const current = route() + if (current.type !== "subagent") { + return + } + + return subagent().details[current.sessionID] + }) const variant = createMemo(() => printableBinding(props.keybinds.variantCycle, props.keybinds.leader)) const interrupt = createMemo(() => printableBinding(props.keybinds.interrupt, props.keybinds.leader)) const hints = createMemo(() => hintFlags(term().width)) @@ -87,8 +118,9 @@ export function RunFooterView(props: RunFooterViewProps) { const duration = createMemo(() => props.state().duration) const usage = createMemo(() => props.state().usage) const interruptKey = createMemo(() => interrupt() || "/exit") - const theme = createMemo(() => props.theme ?? RUN_THEME_FALLBACK.footer) - const block = createMemo(() => props.block ?? RUN_THEME_FALLBACK.block) + const runTheme = createMemo(() => props.theme ?? RUN_THEME_FALLBACK) + const theme = createMemo(() => runTheme().footer) + const block = createMemo(() => runTheme().block) const spin = createMemo(() => { return { frames: createFrames({ @@ -113,6 +145,50 @@ export function RunFooterView(props: RunFooterViewProps) { const view = active() return view.type === "question" ? view : undefined }) + const promptView = createMemo(() => { + if (active().type !== "prompt") { + return active().type + } + + return route().type === "composer" ? "prompt" : "subagent" + }) + + const openTab = (sessionID: string) => { + setRoute({ type: "subagent", sessionID }) + props.onSubagentSelect?.(sessionID) + } + + const closeTab = () => { + setRoute({ type: "composer" }) + props.onSubagentSelect?.(undefined) + } + + const toggleTab = (sessionID: string) => { + const current = route() + if (current.type === "subagent" && current.sessionID === sessionID) { + closeTab() + return + } + + openTab(sessionID) + } + + const cycleTab = (dir: -1 | 1) => { + if (tabs().length === 0) { + return + } + + const routeState = route() + const current = + routeState.type === "subagent" ? tabs().findIndex((item) => item.sessionID === routeState.sessionID) : -1 + const index = current === -1 ? 0 : (current + dir + tabs().length) % tabs().length + const next = tabs()[index] + if (!next) { + return + } + + openTab(next.sessionID) + } const composer = createPromptState({ directory: props.directory, findFiles: props.findFiles, @@ -120,7 +196,7 @@ export function RunFooterView(props: RunFooterViewProps) { resources: props.resources, keybinds: props.keybinds, state: props.state, - view: () => active().type, + view: promptView, prompt, width: () => term().width, theme, @@ -133,7 +209,27 @@ export function RunFooterView(props: RunFooterViewProps) { onRows: props.onRows, onStatus: props.onStatus, }) - const menu = createMemo(() => active().type === "prompt" && composer.visible()) + const menu = createMemo(() => prompt() && composer.visible()) + + createEffect(() => { + const current = route() + if (current.type === "composer") { + return + } + + if (tabs().some((item) => item.sessionID === current.sessionID)) { + return + } + + closeTab() + }) + + createEffect(() => { + props.onLayout({ + route: route(), + tabs: tabs().length > 0, + }) + }) return ( - - - - - - - - - - - - - - - - - - - {props.agent} - - - {props.state().model} - - - - - - - - + + + - - - - - Press Ctrl-c again to exit + + + + + + + + + + + + + + + + + + + + {props.agent} - - - - - - - - {interruptKey()}{" "} - - {armed() ? "again to interrupt" : "interrupt"} - - - - - - - 0}> - - - ▣ - - - - · - - - {duration()} + {props.state().model} - - - - - - 0}> - - {queue()} queued - - - 0}> - - {usage()} - - - 0 && hints().variant}> - - {variant()} variant - - + + + + + + + + + + + Press Ctrl-c again to exit + + + + + + + + + + {interruptKey()}{" "} + + {armed() ? "again to interrupt" : "interrupt"} + + + + + + + 0}> + + + ▣ + + + + · + + + {duration()} + + + + + + + + + 0}> + + {queue()} queued + + + 0}> + + {usage()} + + + 0 && hints().variant}> + + {variant()} variant + + + + + } + > + + } > - + + term().width} + diffStyle={props.diffStyle} + onCycle={cycleTab} + onClose={closeTab} + /> + ) diff --git a/packages/opencode/src/cli/cmd/run/runtime.lifecycle.ts b/packages/opencode/src/cli/cmd/run/runtime.lifecycle.ts index 4e642de4b4..ecaae8e43c 100644 --- a/packages/opencode/src/cli/cmd/run/runtime.lifecycle.ts +++ b/packages/opencode/src/cli/cmd/run/runtime.lifecycle.ts @@ -63,6 +63,7 @@ export type LifecycleInput = { onQuestionReject: (input: QuestionReject) => void | Promise onCycleVariant?: () => CycleResult | void onInterrupt?: () => void + onSubagentSelect?: (sessionID: string | undefined) => void } export type Lifecycle = { @@ -153,7 +154,7 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise { diff --git a/packages/opencode/src/cli/cmd/run/runtime.ts b/packages/opencode/src/cli/cmd/run/runtime.ts index 69a7fe7e85..670a8f808a 100644 --- a/packages/opencode/src/cli/cmd/run/runtime.ts +++ b/packages/opencode/src/cli/cmd/run/runtime.ts @@ -90,6 +90,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput): Promise { ]) shown = !session.first let activeVariant = resolveVariant(ctx.variant, session.variant, savedVariant, variants) + let selectSubagent: ((sessionID: string | undefined) => void) | undefined const shell = await createRuntimeLifecycle({ directory: ctx.directory, @@ -160,6 +161,12 @@ async function runInteractiveRuntime(input: RunRuntimeInput): Promise { aborting = false }) }, + onSubagentSelect: (sessionID) => { + selectSubagent?.(sessionID) + log?.write("subagent.select", { + sessionID, + }) + }, }) const footer = shell.footer @@ -209,6 +216,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput): Promise { footer, trace: log, }) + selectSubagent = stream.selectSubagent try { if (demo) { diff --git a/packages/opencode/src/cli/cmd/run/scrollback.writer.tsx b/packages/opencode/src/cli/cmd/run/scrollback.writer.tsx index e1a92b80af..1bb4cc5c79 100644 --- a/packages/opencode/src/cli/cmd/run/scrollback.writer.tsx +++ b/packages/opencode/src/cli/cmd/run/scrollback.writer.tsx @@ -25,7 +25,7 @@ import { import { createScrollbackWriter, type JSX } from "@opentui/solid" import { For, Show } from "solid-js" import * as Filesystem from "../../../util/filesystem" -import { toolDiffView, toolFiletype, toolFrame, toolSnapshot } from "./tool" +import { toolDiffView, toolFiletype, toolFrame, toolSnapshot, toolView } from "./tool" import { clean, normalizeEntry } from "./scrollback.format" import { RUN_THEME_FALLBACK, type RunEntryTheme, type RunTheme } from "./theme" import type { ScrollbackOptions, StreamCommit } from "./types" @@ -426,23 +426,165 @@ function QuestionTool(props: { theme: RunTheme; data: QuestionInput }) { ) } -function textWriter(body: string, commit: StreamCommit, theme: RunEntryTheme, flags: Flags): ScrollbackWriter { - const style = look(commit, theme) - return (ctx) => - fit( - createScrollbackWriter(() => , { - width: cols(ctx), - startOnNewLine: flags.startOnNewLine, - trailingNewline: flags.trailingNewline, - })(ctx), - ctx, - ) +function snapCommit(commit: StreamCommit) { + const state = commit.toolState ?? commit.part?.state.status + return ( + commit.kind === "tool" && + commit.phase === "final" && + state === "completed" && + Boolean(toolView(commit.tool ?? commit.part?.tool).snap) + ) } -function reasoningWriter(body: string, theme: RunEntryTheme, flags: Flags): ScrollbackWriter { +export function entryGroupKey(commit: StreamCommit): string | undefined { + if (!commit.partID) { + return + } + + if (snapCommit(commit)) { + return `tool:${commit.partID}:final` + } + + return `${commit.kind}:${commit.partID}` +} + +export function sameEntryGroup(left: StreamCommit | undefined, right: StreamCommit): boolean { + if (!left) { + return false + } + + const current = entryGroupKey(left) + const next = entryGroupKey(right) + if (current && next && current === next) { + return true + } + + return left.kind === "tool" && left.phase === "start" && right.kind === "tool" && right.phase === "start" +} + +export function RunEntryTextContent(props: { commit: StreamCommit; theme: RunEntryTheme }) { + const body = normalizeEntry(props.commit) + if (props.commit.kind === "reasoning") { + return + } + + const style = look(props.commit, props.theme) + return +} + +export function RunEntrySnapContent(props: { + commit: StreamCommit + theme: RunTheme + opts?: ScrollbackOptions + width?: number +}) { + const raw = clean(props.commit.text) + const snap = toolSnapshot(props.commit, raw) + if (!snap) { + return + } + + const info = toolFrame(props.commit, raw) + if (snap.kind === "code") { + return ( + + ) + } + + if (snap.kind === "diff") { + const list = snap.items + .map((item) => { + if (!item.diff.trim()) { + return + } + + return { + title: item.title, + diff: item.diff, + filetype: toolFiletype(item.file), + deletions: item.deletions, + diagnostics: diagnostics(info.meta, item.file ?? ""), + } + }) + .filter((item): item is NonNullable => Boolean(item)) + + if (list.length === 0) { + return + } + + return ( + + + {(item) => ( + + )} + + + ) + } + + if (snap.kind === "task") { + return ( + + ) + } + + if (snap.kind === "todo") { + return ( + + ) + } + + return ( + + ) +} + +export function RunEntryContent(props: { + commit: StreamCommit + theme?: RunTheme + opts?: ScrollbackOptions + width?: number +}) { + const theme = props.theme ?? RUN_THEME_FALLBACK + if (snapCommit(props.commit)) { + return + } + + return +} + +function textWriter(commit: StreamCommit, theme: RunEntryTheme, flags: Flags): ScrollbackWriter { return (ctx) => fit( - createScrollbackWriter(() => , { + createScrollbackWriter(() => , { width: cols(ctx), startOnNewLine: flags.startOnNewLine, trailingNewline: flags.trailingNewline, @@ -468,35 +610,6 @@ function textBlockWriter(body: string, theme: RunEntryTheme): ScrollbackWriter { }) } -function codeWriter(data: CodeInput, theme: RunTheme, flags: Flags): ScrollbackWriter { - return (ctx) => full(() => , ctx, flags) -} - -function diffWriter(list: DiffInput[], theme: RunTheme, flags: Flags, view: "unified" | "split"): ScrollbackWriter { - return (ctx) => - full( - () => ( - - {(data) => } - - ), - ctx, - flags, - ) -} - -function taskWriter(data: TaskInput, theme: RunTheme, flags: Flags): ScrollbackWriter { - return (ctx) => full(() => , ctx, flags) -} - -function todoWriter(data: TodoInput, theme: RunTheme, flags: Flags): ScrollbackWriter { - return (ctx) => full(() => , ctx, flags) -} - -function questionWriter(data: QuestionInput, theme: RunTheme, flags: Flags): ScrollbackWriter { - return (ctx) => full(() => , ctx, flags) -} - function flags(commit: StreamCommit): Flags { if (commit.kind === "user") { return { @@ -540,13 +653,7 @@ function flags(commit: StreamCommit): Flags { } export function textEntryWriter(commit: StreamCommit, theme: RunEntryTheme): ScrollbackWriter { - const body = normalizeEntry(commit) - const snap = flags(commit) - if (commit.kind === "reasoning") { - return reasoningWriter(body, theme, snap) - } - - return textWriter(body, commit, theme, snap) + return textWriter(commit, theme, flags(commit)) } export function snapEntryWriter(commit: StreamCommit, theme: RunTheme, opts: ScrollbackOptions): ScrollbackWriter { @@ -555,81 +662,10 @@ export function snapEntryWriter(commit: StreamCommit, theme: RunTheme, opts: Scr return textEntryWriter(commit, theme.entry) } - const info = toolFrame(commit, clean(commit.text)) const style = flags(commit) - if (snap.kind === "code") { - return codeWriter( - { - title: snap.title, - content: snap.content, - filetype: toolFiletype(snap.file), - diagnostics: diagnostics(info.meta, snap.file ?? ""), - }, - theme, - style, - ) - } - - if (snap.kind === "diff") { - if (snap.items.length === 0) { - return textEntryWriter(commit, theme.entry) - } - - const list = snap.items - .map((item) => { - if (!item.diff.trim()) { - return - } - - return { - title: item.title, - diff: item.diff, - filetype: toolFiletype(item.file), - deletions: item.deletions, - diagnostics: diagnostics(info.meta, item.file ?? ""), - } - }) - .filter((item): item is NonNullable => Boolean(item)) - - if (list.length === 0) { - return textEntryWriter(commit, theme.entry) - } - - return (ctx) => diffWriter(list, theme, style, toolDiffView(ctx.width, opts.diffStyle))(ctx) - } - - if (snap.kind === "task") { - return taskWriter( - { - title: snap.title, - rows: snap.rows, - tail: snap.tail, - }, - theme, - style, - ) - } - - if (snap.kind === "todo") { - return todoWriter( - { - items: snap.items, - tail: snap.tail, - }, - theme, - style, - ) - } - - return questionWriter( - { - items: snap.items, - tail: snap.tail, - }, - theme, - style, - ) + return (ctx) => + full(() => , ctx, style) } export function blockWriter(text: string, theme: RunEntryTheme = RUN_THEME_FALLBACK.entry): ScrollbackWriter { diff --git a/packages/opencode/src/cli/cmd/run/session-data.ts b/packages/opencode/src/cli/cmd/run/session-data.ts index a5bc5d2b3e..31f49381d0 100644 --- a/packages/opencode/src/cli/cmd/run/session-data.ts +++ b/packages/opencode/src/cli/cmd/run/session-data.ts @@ -24,7 +24,7 @@ // `data.questions`. The footer shows whichever is first. When a reply // event arrives, the queue entry is removed and the footer falls back // to the next pending request or to the prompt view. -import type { Event, PermissionRequest, QuestionRequest, ToolPart } from "@opencode-ai/sdk/v2" +import type { Event, Part, PermissionRequest, QuestionRequest, ToolPart } from "@opencode-ai/sdk/v2" import * as Locale from "../../../util/locale" import { toolView } from "./tool" import type { FooterOutput, FooterPatch, FooterView, StreamCommit } from "./types" @@ -44,7 +44,7 @@ type Tokens = { } } -type PartKind = "assistant" | "reasoning" +type PartKind = "assistant" | "reasoning" | "user" type MessageRole = "assistant" | "user" type Dict = Record type SessionCommit = StreamCommit @@ -63,6 +63,7 @@ type SessionCommit = StreamCommit // - end: part IDs whose time.end has arrived (part is finished) // - echo: message ID → bash outputs to strip from the next assistant chunk export type SessionData = { + includeUserText: boolean announced: boolean ids: Set tools: Set @@ -92,8 +93,13 @@ export type SessionDataOutput = { footer?: FooterOutput } -export function createSessionData(): SessionData { +export function createSessionData( + input: { + includeUserText?: boolean + } = {}, +): SessionData { return { + includeUserText: input.includeUserText ?? false, announced: false, ids: new Set(), tools: new Set(), @@ -143,7 +149,7 @@ function formatUsage( return text } -function formatError(error: { +export function formatError(error: { name?: string message?: string data?: { @@ -255,6 +261,33 @@ function remove(list: T[], id: string): boolean { return true } +export function bootstrapSessionData(input: { + data: SessionData + messages: Array<{ + parts: Part[] + }> + permissions: PermissionRequest[] + questions: QuestionRequest[] +}) { + for (const message of input.messages) { + for (const part of message.parts) { + if (part.type !== "tool") { + continue + } + + input.data.call.set(key(part.messageID, part.callID), part.state.input) + } + } + + for (const request of input.permissions.slice().sort((a, b) => a.id.localeCompare(b.id))) { + upsert(input.data.permissions, enrichPermission(input.data, request)) + } + + for (const request of input.questions.slice().sort((a, b) => a.id.localeCompare(b.id))) { + upsert(input.data.questions, request) + } +} + function key(msg: string, call: string): string { return `${msg}:${call}` } @@ -360,7 +393,11 @@ function ready(data: SessionData, partID: string): boolean { return false } - return role === "assistant" + if (role === "assistant") { + return true + } + + return data.includeUserText && role === "user" } function syncText(data: SessionData, partID: string, next: string) { @@ -458,7 +495,7 @@ function flushPart(data: SessionData, commits: SessionCommit[], partID: string, kind, text: chunk, phase: "progress", - source: kind, + source: kind === "user" ? "system" : kind, messageID: msg, partID, }) @@ -472,7 +509,7 @@ function flushPart(data: SessionData, commits: SessionCommit[], partID: string, kind, text: "", phase: "final", - source: kind, + source: kind === "user" ? "system" : kind, messageID: msg, partID, interrupted: true, @@ -496,7 +533,7 @@ function replay(data: SessionData, commits: SessionCommit[], messageID: string, continue } - if (role === "user") { + if (role === "user" && !data.includeUserText) { data.ids.add(partID) drop(data, partID) continue @@ -507,6 +544,10 @@ function replay(data: SessionData, commits: SessionCommit[], messageID: string, continue } + if (role === "user" && kind === "assistant") { + data.part.set(partID, "user") + } + if (kind === "reasoning" && !thinking) { if (data.end.has(partID)) { data.ids.add(partID) @@ -577,7 +618,7 @@ export function flushInterrupted(data: SessionData, commits: SessionCommit[]) { } const msg = data.msg.get(partID) - if (msg && data.role.get(msg) === "user") { + if (msg && data.role.get(msg) === "user" && !data.includeUserText) { data.ids.add(partID) drop(data, partID) continue @@ -785,7 +826,7 @@ export function reduceSessionData(input: SessionDataInput): SessionDataOutput { const msg = part.messageID const role = msg ? data.role.get(msg) : undefined - if (role === "user") { + if (role === "user" && part.type === "text" && !data.includeUserText) { data.ids.add(part.id) drop(data, part.id) return out(data, commits) @@ -799,7 +840,7 @@ export function reduceSessionData(input: SessionDataInput): SessionDataOutput { return out(data, commits) } - data.part.set(part.id, kind) + data.part.set(part.id, role === "user" && kind === "assistant" ? "user" : kind) syncText(data, part.id, part.text) if (part.time?.end) { diff --git a/packages/opencode/src/cli/cmd/run/stream.transport.ts b/packages/opencode/src/cli/cmd/run/stream.transport.ts index e589898a8b..15f902bde1 100644 --- a/packages/opencode/src/cli/cmd/run/stream.transport.ts +++ b/packages/opencode/src/cli/cmd/run/stream.transport.ts @@ -13,9 +13,41 @@ // We also re-check live session status before resolving an idle event so a // delayed idle from an older turn cannot complete a newer busy turn. import type { Event, OpencodeClient } from "@opencode-ai/sdk/v2" -import { createSessionData, flushInterrupted, reduceSessionData } from "./session-data" -import { writeSessionOutput } from "./stream" -import type { FooterApi, RunFilePart, RunInput, RunPrompt, StreamCommit } from "./types" +import { + bootstrapSessionData, + createSessionData, + flushInterrupted, + reduceSessionData, + type SessionData, +} from "./session-data" +import { + bootstrapSubagentCalls, + bootstrapSubagentData, + clearFinishedSubagents, + createSubagentData, + listSubagentPermissions, + listSubagentQuestions, + listSubagentTabs, + reduceSubagentData, + snapshotSelectedSubagentData, + snapshotSubagentData, + SUBAGENT_BOOTSTRAP_LIMIT, + SUBAGENT_CALL_BOOTSTRAP_LIMIT, + type SubagentData, +} from "./subagent-data" +import { traceFooterOutput, writeSessionOutput } from "./stream" +import type { + FooterApi, + FooterOutput, + FooterPatch, + FooterSubagentState, + FooterSubagentTab, + FooterView, + RunFilePart, + RunInput, + RunPrompt, + StreamCommit, +} from "./types" type Trace = { write(type: string, data?: unknown): void @@ -52,6 +84,7 @@ export type SessionTurnInput = { export type SessionTransport = { runPromptTurn(input: SessionTurnInput): Promise + selectSubagent(sessionID: string | undefined): void close(): Promise } @@ -162,6 +195,161 @@ export function formatUnknownError(error: unknown): string { return "unknown error" } +function sameView(a: FooterView, b: FooterView) { + if (a.type !== b.type) { + return false + } + + if (a.type === "prompt" && b.type === "prompt") { + return true + } + + if (a.type === "prompt" || b.type === "prompt") { + return false + } + + return a.request === b.request +} + +function blockerStatus(view: FooterView) { + if (view.type === "permission") { + return "awaiting permission" + } + + if (view.type === "question") { + return "awaiting answer" + } + + return "" +} + +function blockerOrder(order: Map, id: string) { + return order.get(id) ?? Number.MAX_SAFE_INTEGER +} + +function firstByOrder(left: T[], right: T[], order: Map) { + return [...left, ...right].sort((a, b) => { + const next = blockerOrder(order, a.id) - blockerOrder(order, b.id) + if (next !== 0) { + return next + } + + return a.id.localeCompare(b.id) + })[0] +} + +function pickView(data: SessionData, subagent: SubagentData, order: Map): FooterView { + const permission = firstByOrder(data.permissions, listSubagentPermissions(subagent), order) + if (permission) { + return { type: "permission", request: permission } + } + + const question = firstByOrder(data.questions, listSubagentQuestions(subagent), order) + if (question) { + return { type: "question", request: question } + } + + return { type: "prompt" } +} + +function composeFooter(input: { + patch?: FooterPatch + subagent?: FooterSubagentState + current: FooterView + previous: FooterView +}) { + let footer: FooterOutput | undefined + + if (input.subagent) { + footer = { + ...(footer ?? {}), + subagent: input.subagent, + } + } + + if (!sameView(input.previous, input.current)) { + footer = { + ...(footer ?? {}), + view: input.current, + } + } + + if (input.current.type !== "prompt") { + footer = { + ...(footer ?? {}), + patch: { + ...(input.patch ?? {}), + status: blockerStatus(input.current), + }, + } + return footer + } + + if (input.patch) { + footer = { + ...(footer ?? {}), + patch: input.patch, + } + return footer + } + + if (input.previous.type !== "prompt") { + footer = { + ...(footer ?? {}), + patch: { + status: "", + }, + } + } + + return footer +} + +function sameTab(a: FooterSubagentTab | undefined, b: FooterSubagentTab | undefined) { + if (!a || !b) { + return false + } + + return ( + a.sessionID === b.sessionID && + a.partID === b.partID && + a.callID === b.callID && + a.label === b.label && + a.description === b.description && + a.status === b.status && + a.title === b.title && + a.toolCalls === b.toolCalls && + a.lastUpdatedAt === b.lastUpdatedAt + ) +} + +function traceTabs(trace: Trace | undefined, prev: FooterSubagentTab[], next: FooterSubagentTab[]) { + const before = new Map(prev.map((item) => [item.sessionID, item])) + const after = new Map(next.map((item) => [item.sessionID, item])) + + for (const [sessionID, tab] of after) { + if (sameTab(before.get(sessionID), tab)) { + continue + } + + trace?.write("subagent.tab", { + sessionID, + tab, + }) + } + + for (const sessionID of before.keys()) { + if (after.has(sessionID)) { + continue + } + + trace?.write("subagent.tab", { + sessionID, + cleared: true, + }) + } +} + // Opens an SDK event subscription and returns a SessionTransport. // // The background `watch` loop consumes every SDK event, runs it through the @@ -191,10 +379,169 @@ export async function createSessionTransport(input: StreamInput): Promise() + + const currentSubagentState = () => { + if (selectedSubagent && !subagent.tabs.has(selectedSubagent)) { + selectedSubagent = undefined + } + + return snapshotSelectedSubagentData(subagent, selectedSubagent) + } + + const seedBlocker = (id: string) => { + if (blockers.has(id)) { + return + } + + blockerTick += 1 + blockers.set(id, blockerTick) + } + + const trackBlocker = (event: Event) => { + if (event.type !== "permission.asked" && event.type !== "question.asked") { + return + } + + if (event.properties.sessionID !== input.sessionID && !subagent.tabs.has(event.properties.sessionID)) { + return + } + + seedBlocker(event.properties.id) + } + + const releaseBlocker = (event: Event) => { + if ( + event.type !== "permission.replied" && + event.type !== "question.replied" && + event.type !== "question.rejected" + ) { + return + } + + blockers.delete(event.properties.requestID) + } + + const syncFooter = (commits: StreamCommit[], patch?: FooterPatch, nextSubagent?: FooterSubagentState) => { + const current = pickView(data, subagent, blockers) + const footer = composeFooter({ + patch, + subagent: nextSubagent, + current, + previous: footerView, + }) + + if (commits.length === 0 && !footer) { + footerView = current + return + } + + input.trace?.write("reduce.output", { + commits, + footer: traceFooterOutput(footer), + }) + writeSessionOutput( + { + footer: input.footer, + trace: input.trace, + }, + { + commits, + footer, + }, + ) + footerView = current + } + + const bootstrap = async () => { + const [messages, children, permissions, questions] = await Promise.all([ + input.sdk.session + .messages({ + sessionID: input.sessionID, + limit: SUBAGENT_BOOTSTRAP_LIMIT, + }) + .then((x) => x.data ?? []) + .catch(() => []), + input.sdk.session + .children({ + sessionID: input.sessionID, + }) + .then((x) => x.data ?? []) + .catch(() => []), + input.sdk.permission + .list() + .then((x) => x.data ?? []) + .catch(() => []), + input.sdk.question + .list() + .then((x) => x.data ?? []) + .catch(() => []), + ]) + + bootstrapSessionData({ + data, + messages, + permissions: permissions.filter((item) => item.sessionID === input.sessionID), + questions: questions.filter((item) => item.sessionID === input.sessionID), + }) + bootstrapSubagentData({ + data: subagent, + messages, + children, + permissions, + questions, + }) + + const callSessions = [ + ...new Set( + listSubagentPermissions(subagent) + .filter((item) => item.tool && item.metadata?.input === undefined) + .map((item) => item.sessionID), + ), + ] + if (callSessions.length > 0) { + await Promise.all( + callSessions.map(async (sessionID) => { + const messages = await input.sdk.session + .messages({ + sessionID, + limit: SUBAGENT_CALL_BOOTSTRAP_LIMIT, + }) + .then((x) => x.data ?? []) + .catch(() => []) + + bootstrapSubagentCalls({ + data: subagent, + sessionID, + messages, + }) + }), + ) + } + + for (const request of [ + ...data.permissions, + ...listSubagentPermissions(subagent), + ...data.questions, + ...listSubagentQuestions(subagent), + ].sort((a, b) => a.id.localeCompare(b.id))) { + seedBlocker(request.id) + } + + const snapshot = currentSubagentState() + traceTabs(input.trace, [], snapshot.tabs) + syncFooter([], undefined, snapshot) + } + + await bootstrap() const idle = async () => { try { @@ -252,16 +599,7 @@ export async function createSessionTransport(input: StreamInput): Promise { const commits: StreamCommit[] = [] flushInterrupted(data, commits) - writeSessionOutput( - { - footer: input.footer, - trace: input.trace, - }, - { - data, - commits, - }, - ) + syncFooter(commits) input.trace?.write(type, { sessionID: input.sessionID, }) @@ -276,6 +614,8 @@ export async function createSessionTransport(input: StreamInput): Promise 0 || next.footer?.patch || next.footer?.view) { - input.trace?.write("reduce.output", { - commits: next.commits, - footer: next.footer, - }) + const subagentChanged = reduceSubagentData({ + data: subagent, + event, + sessionID: input.sessionID, + thinking: input.thinking, + limits: input.limits(), + }) + if (subagentChanged && prevTabs) { + traceTabs(input.trace, prevTabs, listSubagentTabs(subagent)) } + releaseBlocker(event) - writeSessionOutput( - { - footer: input.footer, - trace: input.trace, - }, - next, - ) + syncFooter(next.commits, next.footer?.patch, subagentChanged ? currentSubagentState() : undefined) touch(event) await mark(event) @@ -328,6 +667,13 @@ export async function createSessionTransport(input: StreamInput): Promise { + const next = sessionID && subagent.tabs.has(sessionID) ? sessionID : undefined + if (selectedSubagent === next) { + return + } + + selectedSubagent = next + syncFooter([], undefined, currentSubagentState()) + } + const close = async () => { if (closed) { return @@ -439,6 +795,7 @@ export async function createSessionTransport(input: StreamInput): Promise [ + sessionID, + { + sessionID, + commits: detail.commits.map(traceCommit), + }, + ]), + ), + permissions: state.permissions.map((item) => ({ + id: item.id, + sessionID: item.sessionID, + permission: item.permission, + patterns: item.patterns, + tool: item.tool, + metadata: item.metadata + ? { + keys: Object.keys(item.metadata), + input: summarize(item.metadata.input), + } + : undefined, + })), + questions: state.questions.map((item) => ({ + id: item.id, + sessionID: item.sessionID, + questions: item.questions.map((question) => ({ + header: question.header, + question: question.question, + options: question.options.length, + multiple: question.multiple, + })), + })), + } +} + +export function traceFooterOutput(footer?: FooterOutput) { + if (!footer?.subagent) { + return footer + } + + return { + ...footer, + subagent: traceSubagentState(footer.subagent), + } +} + // Forwards reducer output to the footer: commits go to scrollback, patches update the status bar. -export function writeSessionOutput(input: OutputInput, out: SessionDataOutput): void { +export function writeSessionOutput(input: OutputInput, out: StreamOutput): void { for (const commit of out.commits) { input.trace?.write("ui.commit", commit) input.footer.append(commit) @@ -45,6 +153,14 @@ export function writeSessionOutput(input: OutputInput, out: SessionDataOutput): }) } + if (out.footer?.subagent) { + input.trace?.write("ui.subagent", traceSubagentState(out.footer.subagent)) + input.footer.event({ + type: "stream.subagent", + state: out.footer.subagent, + }) + } + if (!out.footer?.view) { return } diff --git a/packages/opencode/src/cli/cmd/run/subagent-data.ts b/packages/opencode/src/cli/cmd/run/subagent-data.ts new file mode 100644 index 0000000000..38e409f33a --- /dev/null +++ b/packages/opencode/src/cli/cmd/run/subagent-data.ts @@ -0,0 +1,715 @@ +import type { Event, Part, PermissionRequest, QuestionRequest, ToolPart } from "@opencode-ai/sdk/v2" +import * as Locale from "../../../util/locale" +import { + bootstrapSessionData, + createSessionData, + formatError, + reduceSessionData, + type SessionData, +} from "./session-data" +import type { FooterSubagentState, FooterSubagentTab, StreamCommit } from "./types" + +export const SUBAGENT_BOOTSTRAP_LIMIT = 200 +export const SUBAGENT_CALL_BOOTSTRAP_LIMIT = 80 + +const SUBAGENT_COMMIT_LIMIT = 80 +const SUBAGENT_CALL_LIMIT = 32 +const SUBAGENT_ROLE_LIMIT = 32 +const SUBAGENT_ERROR_LIMIT = 16 +const SUBAGENT_ECHO_LIMIT = 8 + +type SessionMessage = { + parts: Part[] +} + +type Frame = { + key: string + commit: StreamCommit +} + +type DetailState = { + sessionID: string + data: SessionData + frames: Frame[] +} + +export type SubagentData = { + tabs: Map + details: Map +} + +export type BootstrapSubagentInput = { + data: SubagentData + messages: SessionMessage[] + children: Array<{ id: string; title?: string }> + permissions: PermissionRequest[] + questions: QuestionRequest[] +} + +function createDetail(sessionID: string): DetailState { + return { + sessionID, + data: createSessionData({ + includeUserText: true, + }), + frames: [], + } +} + +function ensureDetail(data: SubagentData, sessionID: string) { + const current = data.details.get(sessionID) + if (current) { + return current + } + + const next = createDetail(sessionID) + data.details.set(sessionID, next) + return next +} + +function sameTab(a: FooterSubagentTab | undefined, b: FooterSubagentTab) { + if (!a) { + return false + } + + return ( + a.sessionID === b.sessionID && + a.partID === b.partID && + a.callID === b.callID && + a.label === b.label && + a.description === b.description && + a.status === b.status && + a.title === b.title && + a.toolCalls === b.toolCalls && + a.lastUpdatedAt === b.lastUpdatedAt + ) +} + +function sameQueue(left: T[], right: T[]) { + return ( + left.length === right.length && left.every((item, index) => item.id === right[index]?.id && item === right[index]) + ) +} + +function sameCommit(left: StreamCommit, right: StreamCommit) { + return ( + left.kind === right.kind && + left.text === right.text && + left.phase === right.phase && + left.source === right.source && + left.messageID === right.messageID && + left.partID === right.partID && + left.tool === right.tool && + left.interrupted === right.interrupted && + left.toolState === right.toolState && + left.toolError === right.toolError + ) +} + +function text(value: unknown) { + if (typeof value !== "string") { + return + } + + const next = value.trim() + return next || undefined +} + +function num(value: unknown) { + if (typeof value === "number" && Number.isFinite(value)) { + return value + } + + return +} + +function inputLabel(input: Record) { + const description = text(input.description) + if (description) { + return description + } + + const command = text(input.command) + if (command) { + return command + } + + const filePath = text(input.filePath) ?? text(input.filepath) + if (filePath) { + return filePath + } + + const pattern = text(input.pattern) + if (pattern) { + return pattern + } + + const query = text(input.query) + if (query) { + return query + } + + const url = text(input.url) + if (url) { + return url + } + + const path = text(input.path) + if (path) { + return path + } + + const prompt = text(input.prompt) + if (prompt) { + return prompt + } + + return +} + +function stateTitle(part: ToolPart) { + return text("title" in part.state ? part.state.title : undefined) +} + +function callKey(messageID: string | undefined, callID: string | undefined) { + if (!messageID || !callID) { + return + } + + return `${messageID}:${callID}` +} + +function recent(input: Iterable, limit: number) { + const list = [...input] + return list.slice(Math.max(0, list.length - limit)) +} + +function copyMap(source: Map, keep: Set) { + const out = new Map() + for (const [key, value] of source) { + if (!keep.has(key)) { + continue + } + + out.set(key, value) + } + return out +} + +function compactToolPart(part: ToolPart): ToolPart { + return { + id: part.id, + type: "tool", + sessionID: part.sessionID, + messageID: part.messageID, + callID: part.callID, + tool: part.tool, + state: { + status: part.state.status, + input: part.state.input, + metadata: "metadata" in part.state ? part.state.metadata : undefined, + time: "time" in part.state ? part.state.time : undefined, + title: "title" in part.state ? part.state.title : undefined, + error: "error" in part.state ? part.state.error : undefined, + }, + } as ToolPart +} + +function compactCommit(commit: StreamCommit): StreamCommit { + if (!commit.part) { + return commit + } + + return { + ...commit, + part: compactToolPart(commit.part), + } +} + +function stateUpdatedAt(part: ToolPart) { + if (!("time" in part.state)) { + return Date.now() + } + + const time = part.state.time + if (!("end" in time)) { + return time.start ?? Date.now() + } + + return time.end ?? time.start ?? Date.now() +} + +function metadata(part: ToolPart, key: string) { + return ("metadata" in part.state ? part.state.metadata?.[key] : undefined) ?? part.metadata?.[key] +} + +function taskTab(part: ToolPart, sessionID: string): FooterSubagentTab { + const label = Locale.titlecase(text(part.state.input.subagent_type) ?? "general") + const description = text(part.state.input.description) ?? stateTitle(part) ?? inputLabel(part.state.input) ?? "" + const status = part.state.status === "error" ? "error" : part.state.status === "completed" ? "completed" : "running" + + return { + sessionID, + partID: part.id, + callID: part.callID, + label, + description, + status, + title: stateTitle(part), + toolCalls: num(metadata(part, "toolcalls")) ?? num(metadata(part, "toolCalls")) ?? num(metadata(part, "calls")), + lastUpdatedAt: stateUpdatedAt(part), + } +} + +function taskSessionID(part: ToolPart) { + return text(metadata(part, "sessionId")) ?? text(metadata(part, "sessionID")) +} + +function syncTaskTab(data: SubagentData, part: ToolPart, children?: Set) { + if (part.tool !== "task") { + return false + } + + const sessionID = taskSessionID(part) + if (!sessionID) { + return false + } + + if (children && children.size > 0 && !children.has(sessionID)) { + return false + } + + const next = taskTab(part, sessionID) + if (sameTab(data.tabs.get(sessionID), next)) { + ensureDetail(data, sessionID) + return false + } + + data.tabs.set(sessionID, next) + ensureDetail(data, sessionID) + return true +} + +function frameKey(commit: StreamCommit) { + if (commit.partID) { + return `${commit.kind}:${commit.partID}:${commit.phase}` + } + + if (commit.messageID) { + return `${commit.kind}:${commit.messageID}:${commit.phase}` + } + + return `${commit.kind}:${commit.phase}:${commit.text}` +} + +function limitFrames(detail: DetailState) { + if (detail.frames.length <= SUBAGENT_COMMIT_LIMIT) { + return + } + + detail.frames.splice(0, detail.frames.length - SUBAGENT_COMMIT_LIMIT) +} + +function mergeLiveCommit(current: StreamCommit, next: StreamCommit) { + if (current.phase !== "progress" || next.phase !== "progress") { + if (sameCommit(current, next)) { + return current + } + + return next + } + + const merged = { + ...current, + ...next, + text: current.text + next.text, + } + + if (sameCommit(current, merged)) { + return current + } + + return merged +} + +function appendCommits(detail: DetailState, commits: StreamCommit[]) { + let changed = false + + for (const commit of commits.map(compactCommit)) { + const key = frameKey(commit) + const index = detail.frames.findIndex((item) => item.key === key) + if (index === -1) { + detail.frames.push({ + key, + commit, + }) + changed = true + continue + } + + const next = mergeLiveCommit(detail.frames[index].commit, commit) + if (sameCommit(detail.frames[index].commit, next)) { + continue + } + + detail.frames[index] = { + key, + commit: next, + } + changed = true + } + + if (changed) { + limitFrames(detail) + } + + return changed +} + +function ensureBlockerTab( + data: SubagentData, + sessionID: string, + title: string | undefined, + kind: "permission" | "question", +) { + if (data.tabs.has(sessionID)) { + ensureDetail(data, sessionID) + return false + } + + data.tabs.set(sessionID, { + sessionID, + partID: `bootstrap:${sessionID}`, + callID: `bootstrap:${sessionID}`, + label: text(title) ?? Locale.titlecase(kind), + description: kind === "permission" ? "Pending permission" : "Pending question", + status: "running", + lastUpdatedAt: Date.now(), + }) + ensureDetail(data, sessionID) + return true +} + +function compactCallMap(detail: DetailState) { + const keep = new Set(recent(detail.data.call.keys(), SUBAGENT_CALL_LIMIT)) + + for (const request of detail.data.permissions) { + const key = callKey(request.tool?.messageID, request.tool?.callID) + if (key) { + keep.add(key) + } + } + + for (const item of detail.frames) { + const key = callKey(item.commit.part?.messageID, item.commit.part?.callID) + if (key) { + keep.add(key) + } + } + + return copyMap(detail.data.call, keep) +} + +function compactEchoMap(data: SessionData, messageIDs: Set) { + const keys = new Set([...messageIDs, ...recent(data.echo.keys(), SUBAGENT_ECHO_LIMIT)]) + return copyMap(data.echo, keys) +} + +function compactIDs(detail: DetailState) { + return new Set(recent(detail.data.ids, SUBAGENT_COMMIT_LIMIT + SUBAGENT_ERROR_LIMIT)) +} + +function compactDetail(detail: DetailState) { + const next = createSessionData({ + includeUserText: true, + }) + const activePartIDs = new Set(detail.data.part.keys()) + const framePartIDs = new Set(detail.frames.flatMap((item) => (item.commit.partID ? [item.commit.partID] : []))) + const partIDs = new Set([...activePartIDs, ...framePartIDs, ...detail.data.tools]) + const messageIDs = new Set([ + ...[...activePartIDs] + .map((partID) => detail.data.msg.get(partID)) + .filter((item): item is string => typeof item === "string"), + ...recent(detail.data.role.keys(), SUBAGENT_ROLE_LIMIT), + ]) + + next.announced = detail.data.announced + next.permissions = detail.data.permissions + next.questions = detail.data.questions + next.ids = compactIDs(detail) + next.tools = new Set([...detail.data.tools].filter((item) => partIDs.has(item))) + next.call = compactCallMap(detail) + next.role = copyMap(detail.data.role, messageIDs) + next.msg = copyMap(detail.data.msg, activePartIDs) + next.part = copyMap(detail.data.part, activePartIDs) + next.text = copyMap(detail.data.text, activePartIDs) + next.sent = copyMap(detail.data.sent, activePartIDs) + next.end = new Set([...detail.data.end].filter((item) => activePartIDs.has(item))) + next.echo = compactEchoMap(detail.data, messageIDs) + detail.data = next +} + +function applyChildEvent(input: { + detail: DetailState + event: Event + thinking: boolean + limits: Record +}) { + const beforePermissions = input.detail.data.permissions.slice() + const beforeQuestions = input.detail.data.questions.slice() + const out = reduceSessionData({ + data: input.detail.data, + event: input.event, + sessionID: input.detail.sessionID, + thinking: input.thinking, + limits: input.limits, + }) + const changed = appendCommits(input.detail, out.commits) + compactDetail(input.detail) + + return ( + changed || + !sameQueue(beforePermissions, input.detail.data.permissions) || + !sameQueue(beforeQuestions, input.detail.data.questions) + ) +} + +function knownSession(data: SubagentData, sessionID: string) { + return data.tabs.has(sessionID) +} + +export function listSubagentPermissions(data: SubagentData) { + return [...data.details.values()].flatMap((detail) => detail.data.permissions) +} + +export function listSubagentQuestions(data: SubagentData) { + return [...data.details.values()].flatMap((detail) => detail.data.questions) +} + +export function createSubagentData(): SubagentData { + return { + tabs: new Map(), + details: new Map(), + } +} + +function snapshotDetail(detail: DetailState) { + return { + sessionID: detail.sessionID, + commits: detail.frames.map((item) => item.commit), + } +} + +export function listSubagentTabs(data: SubagentData) { + return [...data.tabs.values()].sort((a, b) => { + const active = Number(b.status === "running") - Number(a.status === "running") + if (active !== 0) { + return active + } + + return b.lastUpdatedAt - a.lastUpdatedAt + }) +} + +function snapshotQueues(data: SubagentData) { + return { + permissions: listSubagentPermissions(data).sort((a, b) => a.id.localeCompare(b.id)), + questions: listSubagentQuestions(data).sort((a, b) => a.id.localeCompare(b.id)), + } +} + +export function snapshotSubagentData(data: SubagentData): FooterSubagentState { + return { + tabs: listSubagentTabs(data), + details: Object.fromEntries( + [...data.details.entries()].map(([sessionID, detail]) => [sessionID, snapshotDetail(detail)]), + ), + ...snapshotQueues(data), + } +} + +export function snapshotSelectedSubagentData( + data: SubagentData, + selectedSessionID: string | undefined, +): FooterSubagentState { + const detail = selectedSessionID ? data.details.get(selectedSessionID) : undefined + + return { + tabs: listSubagentTabs(data), + details: detail ? { [detail.sessionID]: snapshotDetail(detail) } : {}, + ...snapshotQueues(data), + } +} + +export function bootstrapSubagentData(input: BootstrapSubagentInput) { + const child = new Map(input.children.map((item) => [item.id, item])) + const children = new Set(child.keys()) + let changed = false + + for (const message of input.messages) { + for (const part of message.parts) { + if (part.type !== "tool") { + continue + } + + changed = syncTaskTab(input.data, part, children) || changed + } + } + + for (const item of input.permissions) { + if (!children.has(item.sessionID)) { + continue + } + + changed = ensureBlockerTab(input.data, item.sessionID, child.get(item.sessionID)?.title, "permission") || changed + } + + for (const item of input.questions) { + if (!children.has(item.sessionID)) { + continue + } + + changed = ensureBlockerTab(input.data, item.sessionID, child.get(item.sessionID)?.title, "question") || changed + } + + for (const sessionID of input.data.tabs.keys()) { + const detail = ensureDetail(input.data, sessionID) + const beforePermissions = detail.data.permissions.slice() + const beforeQuestions = detail.data.questions.slice() + + bootstrapSessionData({ + data: detail.data, + messages: [], + permissions: input.permissions + .filter((item) => item.sessionID === sessionID) + .sort((a, b) => a.id.localeCompare(b.id)), + questions: input.questions + .filter((item) => item.sessionID === sessionID) + .sort((a, b) => a.id.localeCompare(b.id)), + }) + compactDetail(detail) + + changed = + !sameQueue(beforePermissions, detail.data.permissions) || + !sameQueue(beforeQuestions, detail.data.questions) || + changed + } + + return changed +} + +export function bootstrapSubagentCalls(input: { data: SubagentData; sessionID: string; messages: SessionMessage[] }) { + if (!knownSession(input.data, input.sessionID) || input.messages.length === 0) { + return false + } + + const detail = ensureDetail(input.data, input.sessionID) + const beforePermissions = detail.data.permissions.slice() + const beforeQuestions = detail.data.questions.slice() + const beforeCallCount = detail.data.call.size + bootstrapSessionData({ + data: detail.data, + messages: input.messages, + permissions: detail.data.permissions, + questions: detail.data.questions, + }) + compactDetail(detail) + + return ( + beforeCallCount !== detail.data.call.size || + !sameQueue(beforePermissions, detail.data.permissions) || + !sameQueue(beforeQuestions, detail.data.questions) + ) +} + +export function clearFinishedSubagents(data: SubagentData) { + let changed = false + + for (const [sessionID, tab] of [...data.tabs.entries()]) { + if (tab.status === "running") { + continue + } + + data.tabs.delete(sessionID) + data.details.delete(sessionID) + changed = true + } + + return changed +} + +export function reduceSubagentData(input: { + data: SubagentData + event: Event + sessionID: string + thinking: boolean + limits: Record +}) { + const event = input.event + + if (event.type === "message.part.updated") { + const part = event.properties.part + if (part.sessionID === input.sessionID) { + if (part.type !== "tool") { + return false + } + + return syncTaskTab(input.data, part) + } + } + + const sessionID = + event.type === "message.updated" || + event.type === "message.part.delta" || + event.type === "permission.asked" || + event.type === "permission.replied" || + event.type === "question.asked" || + event.type === "question.replied" || + event.type === "question.rejected" || + event.type === "session.error" || + event.type === "session.status" + ? event.properties.sessionID + : event.type === "message.part.updated" + ? event.properties.part.sessionID + : undefined + + if (!sessionID || !knownSession(input.data, sessionID)) { + return false + } + + const detail = ensureDetail(input.data, sessionID) + if (event.type === "session.status") { + if (event.properties.status.type !== "retry") { + return false + } + + return appendCommits(detail, [ + { + kind: "error", + text: event.properties.status.message, + phase: "start", + source: "system", + messageID: `retry:${event.properties.status.attempt}`, + }, + ]) + } + + if (event.type === "session.error" && event.properties.error) { + return appendCommits(detail, [ + { + kind: "error", + text: formatError(event.properties.error), + phase: "start", + source: "system", + messageID: `session.error:${event.properties.sessionID}:${formatError(event.properties.error)}`, + }, + ]) + } + + return applyChildEvent({ + detail, + event, + thinking: input.thinking, + limits: input.limits, + }) +} diff --git a/packages/opencode/src/cli/cmd/run/types.ts b/packages/opencode/src/cli/cmd/run/types.ts index a407886589..2686e7bc0e 100644 --- a/packages/opencode/src/cli/cmd/run/types.ts +++ b/packages/opencode/src/cli/cmd/run/types.ts @@ -92,10 +92,37 @@ export type FooterView = | { type: "permission"; request: PermissionRequest } | { type: "question"; request: QuestionRequest } +export type FooterPromptRoute = { type: "composer" } | { type: "subagent"; sessionID: string } + +export type FooterSubagentTab = { + sessionID: string + partID: string + callID: string + label: string + description: string + status: "running" | "completed" | "error" + title?: string + toolCalls?: number + lastUpdatedAt: number +} + +export type FooterSubagentDetail = { + sessionID: string + commits: StreamCommit[] +} + +export type FooterSubagentState = { + tabs: FooterSubagentTab[] + details: Record + permissions: PermissionRequest[] + questions: QuestionRequest[] +} + // The reducer emits this alongside scrollback commits so the footer can update in the same frame. export type FooterOutput = { patch?: FooterPatch view?: FooterView + subagent?: FooterSubagentState } // Typed messages sent to RunFooter.event(). The prompt queue and stream @@ -137,6 +164,10 @@ export type FooterEvent = type: "stream.view" view: FooterView } + | { + type: "stream.subagent" + state: FooterSubagentState + } export type PermissionReply = Parameters[0] diff --git a/packages/opencode/src/cli/cmd/tui/component/spinner.tsx b/packages/opencode/src/cli/cmd/tui/component/spinner.tsx index 8dc5455504..7007803141 100644 --- a/packages/opencode/src/cli/cmd/tui/component/spinner.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/spinner.tsx @@ -5,7 +5,7 @@ import type { JSX } from "@opentui/solid" import type { RGBA } from "@opentui/core" import "opentui-spinner/solid" -const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] +export const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] export function Spinner(props: { children?: JSX.Element; color?: RGBA }) { const { theme } = useTheme() @@ -14,7 +14,7 @@ export function Spinner(props: { children?: JSX.Element; color?: RGBA }) { return ( ⋯ {props.children}}> - + {props.children} diff --git a/packages/opencode/test/cli/run/footer.test.ts b/packages/opencode/test/cli/run/footer.test.ts new file mode 100644 index 0000000000..779c575cf8 --- /dev/null +++ b/packages/opencode/test/cli/run/footer.test.ts @@ -0,0 +1,6 @@ +import { expect, test } from "bun:test" +import { RunFooter } from "../../../src/cli/cmd/run/footer" + +test("run footer class loads", () => { + expect(typeof RunFooter).toBe("function") +}) diff --git a/packages/opencode/test/cli/run/footer.view.test.tsx b/packages/opencode/test/cli/run/footer.view.test.tsx new file mode 100644 index 0000000000..76f83f86c1 --- /dev/null +++ b/packages/opencode/test/cli/run/footer.view.test.tsx @@ -0,0 +1,6 @@ +import { expect, test } from "bun:test" +import { RunFooterView } from "../../../src/cli/cmd/run/footer.view" + +test("run footer view loads", () => { + expect(typeof RunFooterView).toBe("function") +}) diff --git a/packages/opencode/test/cli/run/stream.test.ts b/packages/opencode/test/cli/run/stream.test.ts index a450e5e610..f66f85b3b6 100644 --- a/packages/opencode/test/cli/run/stream.test.ts +++ b/packages/opencode/test/cli/run/stream.test.ts @@ -37,7 +37,6 @@ describe("run stream bridge", () => { footer: out.api, }, { - data: {} as never, commits, }, ) @@ -53,7 +52,6 @@ describe("run stream bridge", () => { footer: out.api, }, { - data: {} as never, commits: [], footer: { patch: { @@ -82,7 +80,6 @@ describe("run stream bridge", () => { footer: out.api, }, { - data: {} as never, commits: [], footer: { view: { @@ -101,4 +98,67 @@ describe("run stream bridge", () => { }, ]) }) + + test("forwards subagent footer snapshots as stream.subagent events", () => { + const out = footer() + + writeSessionOutput( + { + footer: out.api, + }, + { + commits: [], + footer: { + subagent: { + tabs: [ + { + sessionID: "child-1", + partID: "part-1", + callID: "call-1", + label: "Explore", + description: "Scan reducer paths", + status: "running", + lastUpdatedAt: 1, + }, + ], + details: { + "child-1": { + sessionID: "child-1", + commits: [], + }, + }, + permissions: [], + questions: [], + }, + }, + }, + ) + + expect(out.events).toEqual([ + { + type: "stream.subagent", + state: { + tabs: [ + { + sessionID: "child-1", + partID: "part-1", + callID: "call-1", + label: "Explore", + description: "Scan reducer paths", + status: "running", + lastUpdatedAt: 1, + }, + ], + details: { + "child-1": { + sessionID: "child-1", + commits: [], + }, + }, + permissions: [], + questions: [], + }, + }, + ]) + }) }) diff --git a/packages/opencode/test/cli/run/stream.transport.test.ts b/packages/opencode/test/cli/run/stream.transport.test.ts index ad74cd06de..3ea3cff7ce 100644 --- a/packages/opencode/test/cli/run/stream.transport.test.ts +++ b/packages/opencode/test/cli/run/stream.transport.test.ts @@ -139,6 +139,13 @@ function sdk( opt: { promptAsync?: (input: unknown, opt?: { signal?: AbortSignal }) => Promise status?: () => Promise<{ data?: Record }> + messages?: (input: { + sessionID: string + limit?: number + }) => Promise<{ data?: Array<{ info: unknown; parts: unknown[] }> }> + children?: () => Promise<{ data?: Array<{ id: string }> }> + permissions?: () => Promise<{ data?: unknown[] }> + questions?: () => Promise<{ data?: unknown[] }> } = {}, ) { return { @@ -150,11 +157,235 @@ function sdk( session: { promptAsync: opt.promptAsync ?? (async () => {}), status: opt.status ?? (async () => ({ data: {} })), + messages: opt.messages ?? (async () => ({ data: [] })), + children: opt.children ?? (async () => ({ data: [] })), + }, + permission: { + list: opt.permissions ?? (async () => ({ data: [] })), + }, + question: { + list: opt.questions ?? (async () => ({ data: [] })), }, } as unknown as OpencodeClient } describe("run stream transport", () => { + test("bootstraps subagent tabs from parent task parts", async () => { + const src = feed() + const ui = footer() + const transport = await createSessionTransport({ + sdk: sdk(src, { + messages: async ({ sessionID }) => { + if (sessionID !== "session-1") { + throw new Error("unexpected child bootstrap") + } + + return { + data: [ + { + info: { + id: "msg-1", + role: "assistant", + }, + parts: [ + { + id: "task-1", + sessionID: "session-1", + messageID: "msg-1", + type: "tool", + callID: "call-1", + tool: "task", + state: { + status: "running", + input: { + description: "Explore run folder", + subagent_type: "explore", + }, + metadata: { + sessionId: "child-1", + }, + time: { + start: 1, + }, + }, + }, + ], + }, + ], + } + }, + children: async () => ({ + data: [{ id: "child-1" }], + }), + }), + sessionID: "session-1", + thinking: true, + limits: () => ({}), + footer: ui.api, + }) + + try { + expect(ui.events).toContainEqual({ + type: "stream.subagent", + state: { + tabs: [ + expect.objectContaining({ + sessionID: "child-1", + label: "Explore", + description: "Explore run folder", + status: "running", + }), + ], + details: {}, + permissions: [], + questions: [], + }, + }) + + transport.selectSubagent("child-1") + + expect(ui.events).toContainEqual({ + type: "stream.subagent", + state: { + tabs: [ + expect.objectContaining({ + sessionID: "child-1", + label: "Explore", + description: "Explore run folder", + status: "running", + }), + ], + details: { + "child-1": { + sessionID: "child-1", + commits: [], + }, + }, + permissions: [], + questions: [], + }, + }) + } finally { + src.close() + await transport.close() + } + }) + + test("bootstraps resumed child permission input without recent parent task parts", async () => { + const src = feed() + const ui = footer() + const transport = await createSessionTransport({ + sdk: sdk(src, { + messages: async ({ sessionID }) => { + if (sessionID === "session-1") { + return { data: [] } + } + + return { + data: [ + { + info: { + id: "msg-child-1", + role: "assistant", + }, + parts: [ + { + id: "edit-1", + sessionID: "child-1", + messageID: "msg-child-1", + type: "tool", + callID: "call-edit-1", + tool: "edit", + state: { + status: "running", + input: { + filePath: "src/run/subagent-data.ts", + diff: "@@ -1 +1 @@", + }, + time: { + start: 1, + }, + }, + }, + ], + }, + ], + } + }, + children: async () => ({ + data: [{ id: "child-1" }], + }), + permissions: async () => ({ + data: [ + { + id: "perm-1", + sessionID: "child-1", + permission: "edit", + patterns: ["src/run/subagent-data.ts"], + metadata: {}, + always: [], + tool: { + messageID: "msg-child-1", + callID: "call-edit-1", + }, + }, + ], + }), + }), + sessionID: "session-1", + thinking: true, + limits: () => ({}), + footer: ui.api, + }) + + try { + expect(ui.events).toContainEqual({ + type: "stream.subagent", + state: { + tabs: [ + expect.objectContaining({ + sessionID: "child-1", + status: "running", + }), + ], + details: {}, + permissions: [ + expect.objectContaining({ + id: "perm-1", + sessionID: "child-1", + metadata: { + input: { + filePath: "src/run/subagent-data.ts", + diff: "@@ -1 +1 @@", + }, + }, + }), + ], + questions: [], + }, + }) + + expect(ui.events).toContainEqual({ + type: "stream.view", + view: { + type: "permission", + request: expect.objectContaining({ + id: "perm-1", + metadata: { + input: { + filePath: "src/run/subagent-data.ts", + diff: "@@ -1 +1 @@", + }, + }, + }), + }, + }) + } finally { + src.close() + await transport.close() + } + }) + test("respects the includeFiles flag when building prompt payloads", async () => { const src = feed() const ui = footer() @@ -491,6 +722,14 @@ describe("run stream transport", () => { session: { promptAsync: async () => {}, status: async () => ({ data: {} }), + messages: async () => ({ data: [] }), + children: async () => ({ data: [] }), + }, + permission: { + list: async () => ({ data: [] }), + }, + question: { + list: async () => ({ data: [] }), }, } as unknown as OpencodeClient, sessionID: "session-1", diff --git a/packages/opencode/test/cli/run/subagent-data.test.ts b/packages/opencode/test/cli/run/subagent-data.test.ts new file mode 100644 index 0000000000..0fcdb6bdda --- /dev/null +++ b/packages/opencode/test/cli/run/subagent-data.test.ts @@ -0,0 +1,367 @@ +import { describe, expect, test } from "bun:test" +import { normalizeEntry } from "../../../src/cli/cmd/run/scrollback.format" +import { + bootstrapSubagentData, + clearFinishedSubagents, + createSubagentData, + reduceSubagentData, + snapshotSelectedSubagentData, + snapshotSubagentData, +} from "../../../src/cli/cmd/run/subagent-data" + +function taskMessage(sessionID: string, status: "running" | "completed" | "error" = "completed") { + return { + info: { + id: `msg-${sessionID}`, + role: "assistant", + }, + parts: [ + { + id: `part-${sessionID}`, + sessionID: "parent-1", + messageID: `msg-${sessionID}`, + type: "tool", + callID: `call-${sessionID}`, + tool: "task", + state: { + status, + input: { + description: "Scan reducer paths", + subagent_type: "explore", + }, + title: "Reducer touchpoints", + metadata: { + sessionId: sessionID, + toolcalls: 4, + }, + time: status === "running" ? { start: 1 } : { start: 1, end: 2 }, + }, + }, + ], + } as const +} + +function question(id: string, sessionID: string) { + return { + id, + sessionID, + questions: [ + { + question: "Mode?", + header: "Mode", + options: [{ label: "Fast", description: "Quick pass" }], + }, + ], + } +} + +describe("run subagent data", () => { + test("bootstraps tabs and child blockers from parent task parts", () => { + const data = createSubagentData() + + expect( + bootstrapSubagentData({ + data, + messages: [taskMessage("child-1") as never], + children: [{ id: "child-1" }, { id: "child-2" }], + permissions: [ + { + id: "perm-1", + sessionID: "child-1", + permission: "read", + patterns: ["src/**/*.ts"], + metadata: {}, + always: [], + }, + { + id: "perm-2", + sessionID: "other", + permission: "read", + patterns: ["src/**/*.ts"], + metadata: {}, + always: [], + }, + ], + questions: [question("question-1", "child-1"), question("question-2", "other")], + }), + ).toBe(true) + + expect(snapshotSubagentData(data)).toEqual({ + tabs: [ + expect.objectContaining({ + sessionID: "child-1", + label: "Explore", + description: "Scan reducer paths", + title: "Reducer touchpoints", + status: "completed", + toolCalls: 4, + }), + ], + details: { + "child-1": { + sessionID: "child-1", + commits: [], + }, + }, + permissions: [expect.objectContaining({ id: "perm-1", sessionID: "child-1" })], + questions: [expect.objectContaining({ id: "question-1", sessionID: "child-1" })], + }) + }) + + test("reduces child text tool and blocker events into footer detail state", () => { + const data = createSubagentData() + + bootstrapSubagentData({ + data, + messages: [taskMessage("child-1", "running") as never], + children: [{ id: "child-1" }], + permissions: [], + questions: [], + }) + + reduceSubagentData({ + data, + sessionID: "parent-1", + thinking: true, + limits: {}, + event: { + type: "message.part.updated", + properties: { + part: { + id: "txt-1", + messageID: "msg-user-1", + sessionID: "child-1", + type: "text", + text: "Inspect footer tabs", + }, + }, + } as never, + }) + reduceSubagentData({ + data, + sessionID: "parent-1", + thinking: true, + limits: {}, + event: { + type: "message.updated", + properties: { + sessionID: "child-1", + info: { + id: "msg-user-1", + role: "user", + }, + }, + } as never, + }) + reduceSubagentData({ + data, + sessionID: "parent-1", + thinking: true, + limits: {}, + event: { + type: "message.updated", + properties: { + sessionID: "child-1", + info: { + id: "msg-assistant-1", + role: "assistant", + }, + }, + } as never, + }) + reduceSubagentData({ + data, + sessionID: "parent-1", + thinking: true, + limits: {}, + event: { + type: "message.part.updated", + properties: { + part: { + id: "reason-1", + messageID: "msg-assistant-1", + sessionID: "child-1", + type: "reasoning", + text: "planning next steps", + time: { start: 1 }, + }, + }, + } as never, + }) + reduceSubagentData({ + data, + sessionID: "parent-1", + thinking: true, + limits: {}, + event: { + type: "message.part.updated", + properties: { + part: { + id: "tool-1", + messageID: "msg-assistant-1", + sessionID: "child-1", + type: "tool", + callID: "call-1", + tool: "bash", + state: { + status: "running", + input: { + command: "git status --short", + }, + time: { start: 1 }, + }, + }, + }, + } as never, + }) + reduceSubagentData({ + data, + sessionID: "parent-1", + thinking: true, + limits: {}, + event: { + type: "permission.asked", + properties: { + id: "perm-1", + sessionID: "child-1", + permission: "bash", + patterns: ["git status --short"], + metadata: {}, + always: [], + tool: { + messageID: "msg-assistant-1", + callID: "call-1", + }, + }, + } as never, + }) + + const snapshot = snapshotSubagentData(data) + + expect(snapshot.tabs).toEqual([expect.objectContaining({ sessionID: "child-1", status: "running" })]) + expect(snapshot.details["child-1"]).toEqual({ + sessionID: "child-1", + commits: expect.any(Array), + }) + expect(snapshot.details["child-1"]?.commits.map((item) => normalizeEntry(item))).toEqual([ + "› Inspect footer tabs", + "Thinking: planning next steps", + "# Shell\n$ git status --short", + ]) + expect(snapshot.permissions).toEqual([ + expect.objectContaining({ + id: "perm-1", + metadata: { + input: { + command: "git status --short", + }, + }, + }), + ]) + expect(snapshot.questions).toEqual([]) + }) + + test("continues live child text streams", () => { + const data = createSubagentData() + + bootstrapSubagentData({ + data, + messages: [taskMessage("child-1", "running") as never], + children: [{ id: "child-1" }], + permissions: [], + questions: [], + }) + + reduceSubagentData({ + data, + sessionID: "parent-1", + thinking: true, + limits: {}, + event: { + type: "message.updated", + properties: { + sessionID: "child-1", + info: { + id: "msg-assistant-1", + role: "assistant", + }, + }, + } as never, + }) + reduceSubagentData({ + data, + sessionID: "parent-1", + thinking: true, + limits: {}, + event: { + type: "message.part.updated", + properties: { + part: { + id: "txt-1", + messageID: "msg-assistant-1", + sessionID: "child-1", + type: "text", + text: "hello", + }, + }, + } as never, + }) + + reduceSubagentData({ + data, + sessionID: "parent-1", + thinking: true, + limits: {}, + event: { + type: "message.part.delta", + properties: { + sessionID: "child-1", + messageID: "msg-assistant-1", + partID: "txt-1", + field: "text", + delta: " world", + }, + } as never, + }) + reduceSubagentData({ + data, + sessionID: "parent-1", + thinking: true, + limits: {}, + event: { + type: "message.part.updated", + properties: { + part: { + id: "txt-1", + messageID: "msg-assistant-1", + sessionID: "child-1", + type: "text", + text: "hello world", + time: { start: 1, end: 2 }, + }, + }, + } as never, + }) + + expect( + snapshotSelectedSubagentData(data, "child-1").details["child-1"]?.commits.map((item) => normalizeEntry(item)), + ).toEqual(["hello world"]) + }) + + test("clears finished tabs on the next parent prompt", () => { + const data = createSubagentData() + + bootstrapSubagentData({ + data, + messages: [taskMessage("child-1", "completed") as never, taskMessage("child-2", "running") as never], + children: [{ id: "child-1" }, { id: "child-2" }], + permissions: [], + questions: [], + }) + + expect(clearFinishedSubagents(data)).toBe(true) + expect(snapshotSubagentData(data).tabs).toEqual([ + expect.objectContaining({ sessionID: "child-2", status: "running" }), + ]) + }) +})