mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-14 05:23:12 +00:00
refactor(app): remove leftover foreground subagent UI (#63391)
This commit is contained in:
parent
23dd370ab5
commit
ffcaeec36c
24 changed files with 221 additions and 189 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}"]`,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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?.(() => {})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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[]
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
)
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ export {
|
|||
standaloneShellRunningDocument,
|
||||
streamingDocument,
|
||||
subagentDocument,
|
||||
subagentReport,
|
||||
terminalFailedDocument,
|
||||
terminalPassedDocument,
|
||||
terminalRunningDocument,
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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() },
|
||||
})
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue