refactor(app): remove leftover foreground subagent UI (#63391)

This commit is contained in:
James Long 2026-11-11 15:53:31 -06:00 • committed by GitHub
parent 23dd370ab5
commit ffcaeec36c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 221 additions and 189 deletions

View file

@ -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",

View file

@ -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;
}
}

View file

@ -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}"]`,
)

View file

@ -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<ShellOutputOutput>
ended: Parameters<typeof createShellPanel>[0]["ended"]
}
onOpen: (item: SessionActivityItem) => void
onOpen: (item: Extract<SessionActivityItem, { type: "subagent" }>) => void
onStop: (item: SessionActivityItem) => Promise<boolean>
// 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) => (
<span data-slot="session-activity-end">
<span
@ -145,12 +139,6 @@ export function SessionActivityChips(props: {
>
{formatElapsed(now() - item.started, language.t)}
</span>
{stop(item)}
</span>
)
const stop = (item: SessionActivityItem) => (
<Show when={stoppable(item)}>
{/* The row selects on press, so the stop button keeps its pointer events to itself. */}
<IconButton
type="button"
@ -172,7 +160,7 @@ export function SessionActivityChips(props: {
panel.stop(item)
}}
/>
</Show>
</span>
)
const label = () => language.activitySummary(counts())

View file

@ -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<SessionActivityItem[]>(() =>
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<SessionActivityItem[]>(() =>
@ -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}
/>

View file

@ -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 }),
)

View file

@ -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) {

View file

@ -14,14 +14,12 @@ type Subagent = {
id: string
type: "subagent"
sessionID: string
label: string | undefined
label: string
agent?: string
started: number
}
type Task =
| (Omit<Subagent, "label"> & { 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 }
}

View file

@ -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,

View file

@ -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}
/>
)}
</Show>

View file

@ -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)),

View file

@ -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<string>()
@ -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)
})
})
})

View file

@ -122,7 +122,6 @@ type Input = {
disclosure: Readonly<Record<string, boolean | undefined>>,
) => 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?.(() => {})
}

View file

@ -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

View file

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

View file

@ -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[]
}

View file

@ -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: `<subagent sessionID="${report.childID}" state="${report.state}" description="${report.description}">\n${report.body}\n</subagent>`,
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" },
)

View file

@ -31,6 +31,7 @@ export {
standaloneShellRunningDocument,
streamingDocument,
subagentDocument,
subagentReport,
terminalFailedDocument,
terminalPassedDocument,
terminalRunningDocument,

View file

@ -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 }

View file

@ -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>): 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")

View file

@ -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. */

View file

@ -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

View file

@ -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

View file

@ -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() },
})
}