diff --git a/packages/app/src/runtime/i18n/en.ts b/packages/app/src/runtime/i18n/en.ts index d7f2f560bed4..59aae6ca64c3 100644 --- a/packages/app/src/runtime/i18n/en.ts +++ b/packages/app/src/runtime/i18n/en.ts @@ -661,8 +661,6 @@ export const dict = { "session.timeline.status.waitingApproval": "Waiting for approval", "session.timeline.status.waitingAnswer": "Waiting for your answer", "session.timeline.status.waitingShell": "Waiting for shell", - "session.timeline.status.waitingAgent": "Waiting for subagent", - "session.timeline.status.waitingAgents": "Waiting for subagents", "session.timeline.status.working": "Working", "session.running.stop.subagent": "Interrupt subagent", "session.running.stop.shell": "Kill shell command", diff --git a/packages/app/src/session/composer/activity-chips.css b/packages/app/src/session/composer/activity-chips.css index a7deefe854fc..7fb5a2f705a7 100644 --- a/packages/app/src/session/composer/activity-chips.css +++ b/packages/app/src/session/composer/activity-chips.css @@ -95,11 +95,11 @@ } /* A row whose Stop is in flight keeps showing its loader until the work leaves the list or the Stop settles. */ - [data-component="menu-v2-item"][data-stoppable]:is( - :hover, - [data-highlighted], - :has([data-slot="session-activity-stop"][aria-disabled="true"]) - ) { + [data-component="menu-v2-item"]:is( + :hover, + [data-highlighted], + :has([data-slot="session-activity-stop"][aria-disabled="true"]) + ) { [data-slot="session-activity-stop"] { visibility: visible; } @@ -126,8 +126,7 @@ } [data-slot="session-activity-stop"], - [data-component="menu-v2-item"][data-stoppable]:is(:hover, [data-highlighted]) - [data-slot="session-activity-elapsed"] { + [data-component="menu-v2-item"]:is(:hover, [data-highlighted]) [data-slot="session-activity-elapsed"] { visibility: visible; } } diff --git a/packages/app/src/session/composer/activity-chips.stories.tsx b/packages/app/src/session/composer/activity-chips.stories.tsx index 902b5ee7c10a..c65b17ab7dc3 100644 --- a/packages/app/src/session/composer/activity-chips.stories.tsx +++ b/packages/app/src/session/composer/activity-chips.stories.tsx @@ -6,6 +6,7 @@ import { STORY_TIME, editThenTestDocument, largeCompletedDocument, + subagentReport, } from "@opencode/session-ui/storybook" import { timelinePresets } from "@opencode/session-ui/timeline/detail" import { createEffect, createMemo, on, onCleanup, onMount } from "solid-js" @@ -85,7 +86,7 @@ function Preview(props: PreviewArgs) { ) } -// Each stage lists the work still running; finished work stays in the timeline as completed calls. +// Each stage lists the work still running; finished subagents report back in the timeline. const stages = [ { started: 0, finished: 0, shells: 0, finishedShells: 0 }, { started: 1, finished: 0, shells: 0, finishedShells: 0 }, @@ -109,20 +110,17 @@ function liveDocument(stage: Stage): SessionDocument { type: "tool", id: `tool_${value.id}`, name: "subagent", - state: - index < stage.finished - ? { - status: "completed", - input: { agent: value.agent, description: value.description, prompt: value.description }, - content: [{ type: "text", text: "No issues found." }], - metadata: { sessionID: value.id, status: "completed" }, - } - : { - status: "running", - input: { agent: value.agent, description: value.description, prompt: value.description }, - metadata: { sessionID: value.id }, - }, - time: { created: STORY_TIME + 301_000 + index * 1_000, ran: STORY_TIME + 301_100 + index * 1_000 }, + state: { + status: "completed", + input: { agent: value.agent, description: value.description, prompt: value.description }, + content: [{ type: "text", text: "The subagent is working in the background." }], + metadata: { sessionID: value.id, status: "running" }, + }, + time: { + created: STORY_TIME + 301_000 + index * 1_000, + ran: STORY_TIME + 301_100 + index * 1_000, + completed: STORY_TIME + 301_200 + index * 1_000, + }, }), ), ...shells.slice(0, stage.shells).map( @@ -162,14 +160,30 @@ function liveDocument(stage: Stage): SessionDocument { text: "I'll delegate the audit to subagents and keep the dev server and tests running in the background.", }, ...tools, - ...(done - ? [{ type: "text" as const, text: "Both audits passed and the background commands have exited." }] - : []), ], - time: { created: STORY_TIME + 300_500, completed: done ? STORY_TIME + 310_000 : undefined }, + time: { created: STORY_TIME + 300_500, completed: stage.finished ? STORY_TIME + 306_000 : undefined }, }, + ...subagents.slice(0, stage.finished).map((value, index) => + subagentReport(`msg_report_${value.id}`, STORY_TIME + 307_000 + index * 1_000, { + childID: value.id, + agent: value.agent, + description: value.description, + state: "completed", + body: "No issues found.", + }), + ), ] + if (done) + messages.push({ + id: "msg_activity_summary", + type: "assistant", + agent: "build", + model: STORY_MODEL, + content: [{ type: "text", text: "Both audits passed and the background commands have exited." }], + time: { created: STORY_TIME + 309_000, completed: STORY_TIME + 310_000 }, + }) + return { sessionID: CURRENT_SESSION_ID, messages, status: done ? { type: "idle" } : { type: "busy" }, diffs: [] } } @@ -206,7 +220,7 @@ function LiveActivity() { .slice(stage().finishedShells, stage().shells) .map((_, index) => shellItem(index + stage().finishedShells)), onOpen: (item) => { - // The app opens the shell's tool call, or the group holding it, and centers it in the timeline. + // The preview has no child route, so show the launch in its timeline. const part = document.querySelector( `[data-slot="story-timeline"] [data-timeline-part-ids*="tool_${item.target}"]`, ) diff --git a/packages/app/src/session/composer/activity-chips.tsx b/packages/app/src/session/composer/activity-chips.tsx index 28da74e09e5e..3c7442a22b43 100644 --- a/packages/app/src/session/composer/activity-chips.tsx +++ b/packages/app/src/session/composer/activity-chips.tsx @@ -16,14 +16,12 @@ import "./activity-chips.css" export type SessionActivityItem = { key: string - type: "subagent" | "shell" label?: string agent?: string - sessionID?: string target: string // When the work started running, from the server's record of it. started: number -} +} & ({ type: "subagent"; sessionID: string } | { type: "shell" }) type DurationKey = | "ui.message.duration.seconds" @@ -58,7 +56,7 @@ export function SessionActivityChips(props: { output: (input: ShellOutputInput) => Promise ended: Parameters[0]["ended"] } - onOpen: (item: SessionActivityItem) => void + onOpen: (item: Extract) => void onStop: (item: SessionActivityItem) => Promise // The menu closed with nothing left running, so the capsule and its trigger are gone; focus goes to the composer. onEmpty: () => void @@ -106,16 +104,12 @@ export function SessionActivityChips(props: { const alignment = useComposerLaneAlignment() - // A subagent can only be interrupted once its child session exists. - const stoppable = (item: SessionActivityItem) => item.type === "shell" || !!item.sessionID - const row = (item: SessionActivityItem) => ({ - "data-stoppable": stoppable(item) || undefined, // A shell opens its own panel in place; a subagent opens where it runs. closeOnSelect: item.type !== "shell", onSelect: () => (item.type === "shell" ? panel.open(item) : props.onOpen(item)), onKeyDown: (event: KeyboardEvent) => { - if ((event.key !== "Delete" && event.key !== "Backspace") || !stoppable(item)) return + if (event.key !== "Delete" && event.key !== "Backspace") return event.preventDefault() panel.stop(item) @@ -135,7 +129,7 @@ export function SessionActivityChips(props: { onCleanup(() => clearInterval(timer)) }) - // The run time and the stop button share the row's end; hovering a stoppable row swaps one for the other. + // The run time and the stop button share the row's end; hovering a row swaps one for the other. const end = (item: SessionActivityItem) => ( {formatElapsed(now() - item.started, language.t)} - {stop(item)} - - ) - - const stop = (item: SessionActivityItem) => ( - {/* The row selects on press, so the stop button keeps its pointer events to itself. */} - + ) const label = () => language.activitySummary(counts()) diff --git a/packages/app/src/session/composer/region.tsx b/packages/app/src/session/composer/region.tsx index fb7abbba8996..e534adf59f2e 100644 --- a/packages/app/src/session/composer/region.tsx +++ b/packages/app/src/session/composer/region.tsx @@ -420,7 +420,6 @@ export function ActiveSessionComposerRegion(props: { screen: SessionScreen model: SessionComposerController suggestionBoundary: () => HTMLElement | undefined - onReveal: (target: string) => void }) { const location = useWorkspaceLocation() const data = useData() @@ -448,14 +447,16 @@ export function ActiveSessionComposerRegion(props: { }, ) - // Subagents count while their session runs, or while their blocking call waits for its child session. const subagents = createMemo(() => - props.model.region.state.background.subagents().map((task) => ({ - ...task, - agent: task.agent ?? (task.sessionID ? data.session.get(task.sessionID)?.agent : undefined), - key: task.id, - target: task.id, - })), + props.model.region.state.background + .tasks() + .filter((task) => task.type === "subagent") + .map((task) => ({ + ...task, + agent: task.agent ?? data.session.get(task.sessionID)?.agent, + key: task.id, + target: task.id, + })), ) const shells = createMemo(() => @@ -479,24 +480,14 @@ export function ActiveSessionComposerRegion(props: { ?.flatMap((entry) => (entry.type === "document" && entry.info.experimental ? [entry.info.experimental] : [])) .at(-1)?.todo === true - const open = (item: SessionActivityItem) => { - const current = props.session.identity.params.id - - if (!current) return - - if (item.sessionID) return openRoute(current, item.sessionID) - - // A starting subagent call has no session yet, so it lives in this timeline. - props.onReveal(item.target) - } - const stop = (item: SessionActivityItem) => { - const request = item.sessionID - ? server.api.session.interrupt({ sessionID: item.sessionID }) - : server.api.shell.remove({ - id: item.target, - location: { directory: data.shell.get(item.target)?.location.directory ?? location().directory }, - }) + const request = + item.type === "subagent" + ? server.api.session.interrupt({ sessionID: item.sessionID }) + : server.api.shell.remove({ + id: item.target, + location: { directory: data.shell.get(item.target)?.location.directory ?? location().directory }, + }) return request.then( () => true, @@ -587,7 +578,11 @@ export function ActiveSessionComposerRegion(props: { subagents={subagents()} shells={shells()} shell={shell} - onOpen={open} + onOpen={(item) => { + const current = props.session.identity.params.id + + if (current) openRoute(current, item.sessionID) + }} onStop={stop} onEmpty={props.model.region.focusPrompt} /> diff --git a/packages/app/src/session/composer/shell-panel-machine.test.ts b/packages/app/src/session/composer/shell-panel-machine.test.ts index b0655206ae28..8aff80249fdb 100644 --- a/packages/app/src/session/composer/shell-panel-machine.test.ts +++ b/packages/app/src/session/composer/shell-panel-machine.test.ts @@ -69,13 +69,19 @@ test("a row's Stop sends once until it settles, and a stop that never settles gi ).toContainEqual(expect.objectContaining({ type: "stop.run", item: server })) }) -test("a subagent row stops only once its child session exists", () => { - const starting: SessionActivityItem = { key: "call_1", type: "subagent", target: "call_1", started: 1 } - const child: SessionActivityItem = { ...starting, key: "call_2", target: "call_2", sessionID: "ses_child" } - const running = work([], [starting, child]) +test("a subagent row can stop its child while it is listed", () => { + const child: SessionActivityItem = { + key: "ses_child", + type: "subagent", + target: "ses_child", + sessionID: "ses_child", + started: 1, + } + + const running = work([], [child]) const listed = replay([{ type: "menu.toggled", open: true, work: running }]).state - expect(transitionShellPanel(listed, { type: "stop.requested", item: starting, work: running }).state).toBe(listed) + expect(transitionShellPanel(listed, { type: "stop.requested", item: child, work: work([]) }).state).toBe(listed) expect(transitionShellPanel(listed, { type: "stop.requested", item: child, work: running }).effects).toContainEqual( expect.objectContaining({ type: "stop.run", item: child }), ) diff --git a/packages/app/src/session/composer/shell-panel-machine.ts b/packages/app/src/session/composer/shell-panel-machine.ts index 1c5595f426a1..cc39629fbec8 100644 --- a/packages/app/src/session/composer/shell-panel-machine.ts +++ b/packages/app/src/session/composer/shell-panel-machine.ts @@ -310,11 +310,10 @@ function running(work: ShellPanelWork, item: SessionActivityItem) { return work.shells.some((shell) => shell.key === item.key) } -// A subagent can only be interrupted once its child session exists. function stoppable(work: ShellPanelWork, item: SessionActivityItem) { if (item.type === "shell") return running(work, item) - return !!item.sessionID && work.subagents.some((subagent) => subagent.key === item.key) + return work.subagents.some((subagent) => subagent.key === item.key) } function count(work: ShellPanelWork) { diff --git a/packages/app/src/session/requests/background.ts b/packages/app/src/session/requests/background.ts index a92d62b29d50..c5af37f9f747 100644 --- a/packages/app/src/session/requests/background.ts +++ b/packages/app/src/session/requests/background.ts @@ -14,14 +14,12 @@ type Subagent = { id: string type: "subagent" sessionID: string - label: string | undefined + label: string agent?: string started: number } -type Task = - | (Omit & { label: string }) - | { id: string; type: "shell"; label: string; started: number } +type Task = Subagent | { id: string; type: "shell"; label: string; started: number } const decodeString = Schema.decodeUnknownOption(Schema.String) @@ -140,8 +138,8 @@ export function createSessionBackground(input: { return [{ id: shell.id, type: "shell" as const, label: shell.command, started: shell.time.started }] }) - return [...subagents().map((task) => ({ ...task, label: task.label ?? task.id })), ...shells] + return [...subagents(), ...shells] }) - return { blocking, subagents, tasks } + return { blocking, tasks } } diff --git a/packages/app/src/session/requests/model.ts b/packages/app/src/session/requests/model.ts index 5e8415038c3d..765ef4476f83 100644 --- a/packages/app/src/session/requests/model.ts +++ b/packages/app/src/session/requests/model.ts @@ -168,7 +168,6 @@ export function createSessionRequestModel() { // Moving to the background and the plugins' background list stay with the primary session's own work. const blocking = createMemo(() => (primary() ? running.blocking() : [])) - const subagents = createMemo(() => (primary() ? running.subagents() : [])) const tasks = createMemo(() => (primary() ? running.tasks() : [])) const moveToBackground = async () => { @@ -268,7 +267,6 @@ export function createSessionRequestModel() { permissionResponding, background: { blocking, - subagents, tasks, move: moveToBackground, steer, diff --git a/packages/app/src/session/screen.tsx b/packages/app/src/session/screen.tsx index 34c609512de1..814ed52302fc 100644 --- a/packages/app/src/session/screen.tsx +++ b/packages/app/src/session/screen.tsx @@ -313,7 +313,6 @@ function SessionScreenContent(props: { setVisibleRef={timeline.view.setVisibleRef} anchor={timeline.view.anchor} setRevealMessage={timeline.view.setRevealMessage} - setRevealPart={timeline.view.setRevealPart} reveal={timeline.view.reveal} setScrollToEnd={timeline.view.setScrollToEnd} setPage={timeline.view.setPage} @@ -439,7 +438,6 @@ function SessionScreenContent(props: { screen={props.screen} model={model} suggestionBoundary={timeline.visible} - onReveal={timeline.actions.revealPart} /> )} diff --git a/packages/app/src/session/timeline/interaction.ts b/packages/app/src/session/timeline/interaction.ts index 92e80d2645db..f236a1ead25e 100644 --- a/packages/app/src/session/timeline/interaction.ts +++ b/packages/app/src/session/timeline/interaction.ts @@ -68,8 +68,6 @@ export function createSessionTimelineInteraction(session: SessionModel, visible: // The active timeline's navigation, registered by its virtualizer. let revealMessage = (_id: string, _partID?: string) => {} - let revealPart = (_target: string) => false - let scrollToEnd = (_behavior?: ScrollBehavior) => {} let page = (_direction: 1 | -1) => {} @@ -425,11 +423,6 @@ export function createSessionTimelineInteraction(session: SessionModel, visible: cursor.dispatch(SCROLL_MOVED_EVENT) revealMessage(id, partID) }, - revealPart: (target: string) => { - cursor.dispatch(SCROLL_MOVED_EVENT) - - return revealPart(target) - }, resume, setActiveMessage, }, @@ -460,9 +453,6 @@ export function createSessionTimelineInteraction(session: SessionModel, visible: setRevealMessage: (reveal: (id: string, partID?: string) => void) => { revealMessage = reveal }, - setRevealPart: (reveal: (target: string) => boolean) => { - revealPart = reveal - }, // The tool call the route opened the session on, attempted once per history entry. reveal: { pending: () => routeRevealTarget(untrack(revealState)), diff --git a/packages/app/src/session/timeline/message-timeline.tsx b/packages/app/src/session/timeline/message-timeline.tsx index d91da9f0b922..8f498fe8ea6a 100644 --- a/packages/app/src/session/timeline/message-timeline.tsx +++ b/packages/app/src/session/timeline/message-timeline.tsx @@ -95,7 +95,6 @@ type MessageTimelineProps = { setVisibleRef?: (el: HTMLDivElement | undefined) => void anchor: (id: string) => string setRevealMessage?: (fn: (id: string, partID?: string) => void) => void - setRevealPart?: (fn: (target: string) => boolean) => void setScrollToEnd?: (fn: (behavior?: ScrollBehavior) => void) => void setPage?: (fn: (direction: 1 | -1) => void) => void reveal?: TimelineRouteReveal @@ -264,7 +263,6 @@ function MessageTimelineView( ) }, setRevealMessage: props.setRevealMessage, - setRevealPart: props.setRevealPart, setScrollToEnd: props.setScrollToEnd, setPage: props.setPage, reveal: props.reveal, @@ -351,7 +349,7 @@ function MessageTimelineView( .findLast((ref) => blocking.has(ref.partID))?.partID }) - // The New timeline's button to move work to the background waits until the shell or subagent has run for a moment, + // The New timeline's button to move work to the background waits until the shell has run for a moment, // so a quick command does not flash it. The wait counts from when the call started, so a call already running when // the timeline opens shows it at once. const [settledBlockingPartID, setSettledBlockingPartID] = createSignal() @@ -444,7 +442,7 @@ function MessageTimelineView( if (content.state.status === "streaming" || content.state.status === "running") return true const taskID = content.state.metadata?.[content.name === "subagent" ? "sessionID" : "shellID"] - return background.has(content.id) || (Predicate.isString(taskID) && background.has(taskID)) + return Predicate.isString(taskID) && background.has(taskID) }) }) }) diff --git a/packages/app/src/session/timeline/virtualizer.tsx b/packages/app/src/session/timeline/virtualizer.tsx index 949406f7041d..50b824dad105 100644 --- a/packages/app/src/session/timeline/virtualizer.tsx +++ b/packages/app/src/session/timeline/virtualizer.tsx @@ -122,7 +122,6 @@ type Input = { disclosure: Readonly>, ) => boolean setRevealMessage?: (fn: (id: string, partID?: string) => void) => void - setRevealPart?: (fn: (target: string) => boolean) => void setScrollToEnd?: (fn: (behavior?: ScrollBehavior) => void) => void /** Pages the timeline for keys pressed outside it, such as PageUp in the composer. */ setPage?: (fn: (direction: 1 | -1) => void) => void @@ -644,7 +643,6 @@ export function createTimelineVirtualizer(input: Input) { reportOffset?.(root.scrollTop, false) dispatchCold(CHECK_REQUESTED) }) - input.setRevealPart?.(revealPart) input.setRevealMessage?.((id, partID) => { if (!active()) return @@ -1242,7 +1240,6 @@ export function createTimelineVirtualizer(input: Input) { if (active()) { input.setScrollRef(undefined) input.setRevealMessage?.(() => {}) - input.setRevealPart?.(() => false) input.setScrollToEnd?.(() => {}) input.setPage?.(() => {}) } diff --git a/packages/app/src/voice/tools.ts b/packages/app/src/voice/tools.ts index f7eaa4a37f09..dde30e6b5b89 100644 --- a/packages/app/src/voice/tools.ts +++ b/packages/app/src/voice/tools.ts @@ -195,7 +195,7 @@ export class VoiceTools { const explained = this.explained.get(sessionID) const full = explained === undefined || Date.now() - explained > headerLapse - // Like a typed steer, move blocking shells and subagents to the background so the agent reads it next. Tried + // Like a typed steer, move blocking shells to the background so the agent reads it next. Tried // once and briefly: the steer goes either way, and an idle session makes it a no-op. if (delivery === "steer") await api.session diff --git a/packages/app/test-browser/session-background.test.ts b/packages/app/test-browser/session-background.test.ts index 365fef2b4b26..1c0289c909c0 100644 --- a/packages/app/test-browser/session-background.test.ts +++ b/packages/app/test-browser/session-background.test.ts @@ -93,7 +93,6 @@ describe("createSessionBackground", () => { expect(background.tasks().map((task) => task.id)).toEqual(["stuck", "noticed"]) setStore("status", { stuck: "idle", noticed: "idle" }) expect(background.tasks()).toEqual([]) - expect(background.subagents()).toEqual([]) dispose() }) }) @@ -103,16 +102,12 @@ describe("createSessionBackground", () => { const background = createSessionBackground({ sessionID: () => "root", messages: () => [], - sessions: () => [ - session("child"), - session("grandchild", "child"), - session("other-child", "other"), - ], + sessions: () => [session("child"), session("grandchild", "child"), session("other-child", "other")], status: () => "running", shells: () => [], }) - expect(background.subagents().map((task) => task.id)).toEqual(["child", "grandchild"]) + expect(background.tasks().map((task) => task.id)).toEqual(["child", "grandchild"]) dispose() }) }) @@ -150,11 +145,10 @@ describe("createSessionBackground", () => { // Subagents always run in the background, so a leftover running call blocks nothing. expect(background.blocking()).toEqual([]) - expect(background.subagents()).toEqual([]) expect(background.tasks()).toEqual([]) setStore("sessions", [session("child")]) - expect(background.subagents()).toEqual([]) + expect(background.tasks()).toEqual([]) setStore("status", "child", "running") const task = { @@ -166,7 +160,6 @@ describe("createSessionBackground", () => { started: 6, } - expect(background.subagents()).toEqual([task]) expect(background.tasks()).toEqual([task]) dispose() }) diff --git a/packages/plugin/src/gui/host-apis.ts b/packages/plugin/src/gui/host-apis.ts index fe7fc28731fb..7913f8bd4b44 100644 --- a/packages/plugin/src/gui/host-apis.ts +++ b/packages/plugin/src/gui/host-apis.ts @@ -685,7 +685,7 @@ export interface Composer { focused(): boolean } -/** Work the session moved to the background. */ +/** A running subagent or background shell command of the session. */ export interface BackgroundTask { /** The task's id. */ id: string @@ -737,7 +737,7 @@ export interface MountedSession extends SessionRef { readonly directory: string /** The session runs in the project root rather than a worktree. Reactive. */ readonly local: boolean - /** Shell commands and subagents the session moved to the background; empty once another session is routed. */ + /** Running subagents and background shell commands of the session; empty once another session is routed. */ readonly background: readonly BackgroundTask[] } diff --git a/packages/session-ui/src/storybook/current-session-fixtures.ts b/packages/session-ui/src/storybook/current-session-fixtures.ts index e7aa60975f38..30d06b26f391 100644 --- a/packages/session-ui/src/storybook/current-session-fixtures.ts +++ b/packages/session-ui/src/storybook/current-session-fixtures.ts @@ -1085,12 +1085,32 @@ export const compactionCancelledDocument = document([ }, ] satisfies SessionMessageInfo[]) +export type SubagentReport = { + childID: string + agent: string + state: "completed" | "error" | "cancelled" + description: string + body: string +} + +export function subagentReport(id: string, created: number, report: SubagentReport): SessionMessageInfo { + return { + id, + type: "synthetic", + description: report.description, + text: `\n${report.body}\n`, + metadata: { source: "subagent", childID: report.childID, agent: report.agent, state: report.state }, + time: { created }, + } +} + export const subagentDocument = document( [ user("msg_user_subagent", "Delegate the fixture review and report the result.", 70_000), assistant({ id: "msg_assistant_subagent", offset: 71_000, + completed: 74_000, agent: "build", content: [ completedTool({ @@ -1102,10 +1122,10 @@ export const subagentDocument = document( description: "Review current Session fixtures", prompt: "Check the current Session fixtures for protocol accuracy and report findings.", }, - output: "The fixtures use fixed current protocol messages and nested tool states.", - metadata: { sessionID: "session_child_review", status: "completed" }, + output: "The subagent is working in the background.", + metadata: { sessionID: "session_child_review", status: "running" }, }), - runningTool({ + completedTool({ id: "tool_subagent_tests", name: "subagent", offset: 73_000, @@ -1114,10 +1134,18 @@ export const subagentDocument = document( description: "Check the Storybook scenarios", prompt: "Run the focused Storybook scenario checks and report any failures.", }, - metadata: { sessionId: "session_child_tests" }, + output: "The subagent is working in the background.", + metadata: { sessionID: "session_child_tests", status: "running" }, }), ], }), + subagentReport("msg_review_report", STORY_TIME + 75_000, { + childID: "session_child_review", + agent: "review", + state: "completed", + description: "Review current Session fixtures", + body: "The fixtures use fixed current protocol messages and nested tool states.", + }), ] satisfies SessionMessageInfo[], { type: "busy" }, ) diff --git a/packages/session-ui/src/storybook/index.ts b/packages/session-ui/src/storybook/index.ts index 2ce70c9ec9e2..d360ab125f0d 100644 --- a/packages/session-ui/src/storybook/index.ts +++ b/packages/session-ui/src/storybook/index.ts @@ -31,6 +31,7 @@ export { standaloneShellRunningDocument, streamingDocument, subagentDocument, + subagentReport, terminalFailedDocument, terminalPassedDocument, terminalRunningDocument, diff --git a/packages/session-ui/src/timeline-next/events.ts b/packages/session-ui/src/timeline-next/events.ts index 52890fc1df1c..d4d159f23bbe 100644 --- a/packages/session-ui/src/timeline-next/events.ts +++ b/packages/session-ui/src/timeline-next/events.ts @@ -45,8 +45,8 @@ export const launchKeys = { const decodeText = Schema.decodeUnknownOption(Schema.NonEmptyString) /** - * What a call says happened. A call still being written has nothing to count yet. A subagent that returns while it - * keeps running was started in the background; one that returned with its work done ran in the foreground. + * What a call says happened. A call still being written has nothing to count yet. Older sessions marked a + * foreground subagent's result as completed; current subagent calls only launch the work. */ export function toolEvent(tool: SessionMessageAssistantTool): TimelineEvent | undefined { const kind = toolRule(tool).name === "shell" ? "shell" : "subagent" @@ -66,10 +66,10 @@ export function toolEvent(tool: SessionMessageAssistantTool): TimelineEvent | un if (state.status === "running") return { ...item, verb: "started", failed: false } - const background = metadata.status === "running" || input.background === true + const background = + kind === "subagent" ? metadata.status !== "completed" : metadata.status === "running" || input.background === true if (state.status === "error") { - // An interrupted turn stops the work it was waiting on. if (state.error.type === "aborted") return { ...item, verb: "stopped", failed: false } return { ...item, verb: background ? "started" : "ran", failed: true } diff --git a/packages/session-ui/src/timeline-next/rules.test.ts b/packages/session-ui/src/timeline-next/rules.test.ts index 124037a72fd4..a69a11a44541 100644 --- a/packages/session-ui/src/timeline-next/rules.test.ts +++ b/packages/session-ui/src/timeline-next/rules.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test" import type { SessionMessageAssistantTool, SessionMessageInfo } from "@opencode/client/promise" import { timelinePresets, type TimelineDetail } from "../timeline/detail" +import { toolEvent } from "./events" import { contentGroup, contentOpens, @@ -12,16 +13,21 @@ import { toolStopped, userShellOpens, } from "./rules" +import { timelineStatus } from "./status" const preset = (id: string) => timelinePresets.find((item) => item.id === id)!.value const custom = (overrides: Partial): TimelineDetail => ({ ...preset("compact"), ...overrides }) -const tool = (name: string, input: { [key: string]: string | boolean } = {}): SessionMessageAssistantTool => ({ +const tool = ( + name: string, + input: { [key: string]: string | boolean } = {}, + metadata: { [key: string]: string } = {}, +): SessionMessageAssistantTool => ({ id: name, type: "tool", name, - state: { status: "completed", input, content: [{ type: "text", text: "" }], metadata: {} }, + state: { status: "completed", input, content: [{ type: "text", text: "" }], metadata }, time: { created: 0 }, }) @@ -47,6 +53,21 @@ const report = (source: "shell" | "subagent", state = "completed") => }) satisfies SessionMessageInfo describe("starting work that runs on follows its own setting", () => { + test("a subagent launch leaves the parent working unless approval is pending", () => { + const content = [{ ...tool("subagent"), state: { status: "running" as const, input: {}, metadata: {} } }] + + expect(timelineStatus({ content })).toBe("working") + expect(timelineStatus({ content, blocked: "approval" })).toBe("waitingApproval") + }) + + test("a failed subagent launch says Started; a stored foreground result says Ran", () => { + expect( + toolEvent(errored("subagent", { type: "ToolExecutionError", message: "Failed to prompt subagent" })), + ).toMatchObject({ verb: "started", failed: true }) + expect(toolEvent(tool("subagent", { background: true }))).toMatchObject({ verb: "started" }) + expect(toolEvent(tool("subagent", {}, { status: "completed" }))).toMatchObject({ verb: "ran", failed: false }) + }) + test("a background shell joins the activity group where shells are grouped, and stands alone where separate", () => { expect(contentGroup(preset("compact"), backgroundShell, new Set())).toBe("context") expect(contentGroup(preset("everything"), backgroundShell, new Set())).toBe("events") diff --git a/packages/session-ui/src/timeline-next/status.ts b/packages/session-ui/src/timeline-next/status.ts index a6d26e26c391..ad15e44f8b41 100644 --- a/packages/session-ui/src/timeline-next/status.ts +++ b/packages/session-ui/src/timeline-next/status.ts @@ -4,17 +4,10 @@ import { liveThinkingShows, toolRule } from "./rules" type Content = SessionMessageAssistant["content"][number] -export type TimelineStatus = - | "waitingApproval" - | "waitingAnswer" - | "waitingShell" - | "waitingAgent" - | "waitingAgents" - | "working" +export type TimelineStatus = "waitingApproval" | "waitingAnswer" | "waitingShell" | "working" /** - * What the status line below a working turn says. It names what the turn waits on, the user or a command or subagent - * it started, and otherwise says Working: what the agent does shows in the timeline above it. + * The status line says Working unless the turn is waiting on the user or a shell command. */ export function timelineStatus(input: { /** A permission or a question waits on the user. */ @@ -28,21 +21,8 @@ export function timelineStatus(input: { const last = input.content.at(-1) if (last?.type !== "tool" || (last.state.status !== "running" && last.state.status !== "streaming")) return "working" - const name = toolRule(last).name - if (name === "shell") return "waitingShell" - - if (name !== "subagent") return "working" - - // Subagents started together run side by side, so the line counts every one still running. - const running = input.content.filter( - (item) => - item.type === "tool" && - toolRule(item).name === "subagent" && - (item.state.status === "running" || item.state.status === "streaming"), - ) - - return running.length > 1 ? "waitingAgents" : "waitingAgent" + return toolRule(last).name === "shell" ? "waitingShell" : "working" } /** Whether the model is writing a thought that shows, which then stands in for the status line's Working. */ diff --git a/packages/session-ui/src/timeline-next/stories/compare.stories.tsx b/packages/session-ui/src/timeline-next/stories/compare.stories.tsx index ee5b6b7ecf7e..87a2e8863d0c 100644 --- a/packages/session-ui/src/timeline-next/stories/compare.stories.tsx +++ b/packages/session-ui/src/timeline-next/stories/compare.stories.tsx @@ -56,8 +56,6 @@ const statusLabels = { waitingApproval: "Waiting for approval", waitingAnswer: "Waiting for your answer", waitingShell: "Waiting for shell", - waitingAgent: "Waiting for subagent", - waitingAgents: "Waiting for subagents", working: "Working", } as const diff --git a/packages/session-ui/src/timeline-next/stories/playground.stories.tsx b/packages/session-ui/src/timeline-next/stories/playground.stories.tsx index e4e48ebfcc5a..ea60e8ee290a 100644 --- a/packages/session-ui/src/timeline-next/stories/playground.stories.tsx +++ b/packages/session-ui/src/timeline-next/stories/playground.stories.tsx @@ -32,8 +32,6 @@ const statusLabels = { waitingApproval: "Waiting for approval", waitingAnswer: "Waiting for your answer", waitingShell: "Waiting for shell", - waitingAgent: "Waiting for subagent", - waitingAgents: "Waiting for subagents", working: "Working", } as const diff --git a/packages/session-ui/src/timeline-next/stories/session-replay.ts b/packages/session-ui/src/timeline-next/stories/session-replay.ts index 7632173e0a5d..002924d794b3 100644 --- a/packages/session-ui/src/timeline-next/stories/session-replay.ts +++ b/packages/session-ui/src/timeline-next/stories/session-replay.ts @@ -6,7 +6,12 @@ import type { } from "@opencode/client/promise" import { createTwoFilesPatch, diffLines } from "diff" import type { SessionDocument } from "../../document" -import { CURRENT_SESSION_ID, STORY_MODEL } from "../../storybook/current-session-fixtures" +import { + CURRENT_SESSION_ID, + STORY_MODEL, + subagentReport, + type SubagentReport, +} from "../../storybook/current-session-fixtures" /** * A scripted session is a list of beats. Each beat takes one or more steps of the playhead: a tool call starts and then @@ -18,15 +23,13 @@ export type Beat = | { kind: "shell"; command: string; output: string; exit: number } | { kind: "goal"; objective: string } /** A background subagent or shell reporting back, as the server delivers it. */ + | ({ kind: "report"; source: "subagent" } & SubagentReport) | { kind: "report" - source: "subagent" | "shell" + source: "shell" state: "completed" | "error" | "cancelled" description: string - agent?: string - /** The subagent's session or the shell that reports, which the timeline links to. */ - childID?: string - shellID?: string + shellID: string } | { kind: "model"; id: string } | { kind: "step" } @@ -463,8 +466,17 @@ const featureWork: Beat[] = [ kind: "tool", name: "subagent", input: { agent: "review", description: "Review the dark mode change", prompt: "Review the diff." }, - output: "Looks good. One nit: name the store setter `setScheme`.", - metadata: { sessionID: "session_child_review", status: "completed" }, + output: "The subagent is working in the background.", + metadata: { sessionID: "session_child_review", status: "running" }, + }, + { + kind: "report", + source: "subagent", + state: "completed", + description: "Review the dark mode change", + agent: "review", + childID: "session_child_review", + body: "Looks good. One nit: name the store setter `setScheme`.", }, { kind: "tool", name: "shell", input: { command: "bun run build", description: "Build the app" }, hold: true }, { kind: "interrupt" }, @@ -614,10 +626,18 @@ const everyTool: Beat[] = [ kind: "tool", name: "subagent", input: { agent: "review", description: "Review the scheme fix", prompt: "Review the diff." }, - error: "The subagent stopped: the model is overloaded", - metadata: { sessionID: "session_child_review_failed" }, + output: "The subagent is working in the background.", + metadata: { sessionID: "session_child_review_failed", status: "running" }, + }, + { + kind: "report", + source: "subagent", + state: "error", + description: "Review the scheme fix", + agent: "review", + childID: "session_child_review_failed", + body: "The model is overloaded.", }, - { kind: "step" }, { kind: "say", text: "Filed **DES-418** and fixed the flash: the scheme now applies before the first paint. Tests pass; lint flags one stray `console` call.", @@ -625,20 +645,20 @@ const everyTool: Beat[] = [ { kind: "idle" }, ] -// Subagents and shells the agent starts in the background, how they report back, and subagents it waits on. +// Subagents and shells the agent starts in the background, and how they report back. const backgroundWork: Beat[] = [ { kind: "user", text: "Review the theme code and check the docs while you keep the dev server running." }, { kind: "tool", name: "subagent", - input: { agent: "review", description: "Review the theme store", prompt: "Review it.", background: true }, + input: { agent: "review", description: "Review the theme store", prompt: "Review it." }, output: "The subagent is working in the background.", metadata: { sessionID: "session_child_store", status: "running" }, }, { kind: "tool", name: "subagent", - input: { agent: "explore", description: "Check the theme docs", prompt: "Check them.", background: true }, + input: { agent: "explore", description: "Check the theme docs", prompt: "Check them." }, output: "The subagent is working in the background.", metadata: { sessionID: "session_child_docs", status: "running" }, }, @@ -663,6 +683,7 @@ const backgroundWork: Beat[] = [ description: "Review the theme store", agent: "review", childID: "session_child_store", + body: "Nothing to change in the theme store.", }, { kind: "report", @@ -671,26 +692,45 @@ const backgroundWork: Beat[] = [ description: "Check the theme docs", agent: "explore", childID: "session_child_docs", + body: "The docs check failed.", }, { kind: "say", - text: "The store review found nothing to change. The docs check failed, so I'll run both checks again and wait for them.", + text: "The store review found nothing to change. I'll retry the docs check and ask for a review of the toggle.", }, { kind: "tool", name: "subagent", input: { agent: "explore", description: "Check the theme docs", prompt: "Check them." }, - output: "The docs describe the scheme setting correctly.", - metadata: { sessionID: "session_child_docs_again", status: "completed" }, + output: "The subagent is working in the background.", + metadata: { sessionID: "session_child_docs_again", status: "running" }, }, { kind: "tool", name: "subagent", input: { agent: "review", description: "Review the toggle", prompt: "Review it." }, - output: "The toggle reads the scheme in an effect, which is right.", - metadata: { sessionID: "session_child_toggle", status: "completed" }, + output: "The subagent is working in the background.", + metadata: { sessionID: "session_child_toggle", status: "running" }, + }, + { kind: "idle" }, + { + kind: "report", + source: "subagent", + state: "completed", + description: "Check the theme docs", + agent: "explore", + childID: "session_child_docs_again", + body: "The docs describe the scheme setting correctly.", + }, + { + kind: "report", + source: "subagent", + state: "completed", + description: "Review the toggle", + agent: "review", + childID: "session_child_toggle", + body: "The toggle reads the scheme in an effect, which is right.", }, - { kind: "step" }, { kind: "say", text: "Both checks pass: the docs are accurate and the toggle reads the scheme correctly." }, { kind: "idle" }, { kind: "report", source: "shell", state: "cancelled", description: "bun run dev", shellID: "shell_dev" }, @@ -833,25 +873,20 @@ export function replayScript(script: Beat[], limit: number, start: number) { state.busy = true } - if (beat.kind === "report") { + if (beat.kind === "report" && beat.source === "subagent") { + closeStep() + messages.push(subagentReport(id("msg_report"), at(), beat)) + state.busy = true + } + + if (beat.kind === "report" && beat.source === "shell") { closeStep() messages.push({ id: id("msg_report"), type: "synthetic", text: `${beat.source} ${beat.state}: ${beat.description}`, description: beat.description, - metadata: { - source: beat.source, - state: beat.state, - // Only the fields the report carries, as core writes them. - ...Object.fromEntries( - [ - ["agent", beat.agent], - ["childID", beat.childID], - ["shellID", beat.shellID], - ].flatMap(([key, value]) => (value ? [[key, value]] : [])), - ), - }, + metadata: { source: beat.source, state: beat.state, shellID: beat.shellID }, time: { created: at() }, }) }