Compare commits

...

4 Commits

Author SHA1 Message Date
Kit Langton 10d82a60c6 test(core): wait for subagent tool registration 2026-06-30 14:36:01 -04:00
Kit Langton 3d0ff1c705 fix(tui): tighten subagent background shortcut 2026-06-30 14:27:20 -04:00
Kit Langton 3005e8bb98 fix(tui): background foreground subagents 2026-06-30 14:27:20 -04:00
Kit Langton cf3b601731 feat(tui): background running subagents 2026-06-30 14:26:27 -04:00
8 changed files with 494 additions and 93 deletions
+3 -2
View File
@@ -28,6 +28,7 @@ export const Output = Schema.Struct({
sessionID: SessionSchema.ID,
status: Schema.Literals(["completed", "running"]),
output: Schema.String,
background: Schema.Boolean.pipe(Schema.optional),
})
export const description = [
@@ -146,7 +147,7 @@ export const Plugin = {
if (background) {
yield* runtime.job.background(info.id)
yield* notifyWhenDone(context.sessionID, child.id, input.description)
return { sessionID: child.id, status: "running" as const, output: BACKGROUND_STARTED }
return { sessionID: child.id, status: "running" as const, output: BACKGROUND_STARTED, background: true }
}
const result = yield* runtime.job.block({ id: child.id, sessionID: context.sessionID }).pipe(
@@ -158,7 +159,7 @@ export const Plugin = {
)
if (result?.type === "backgrounded") {
yield* notifyWhenDone(context.sessionID, child.id, input.description)
return { sessionID: child.id, status: "running" as const, output: BACKGROUND_STARTED }
return { sessionID: child.id, status: "running" as const, output: BACKGROUND_STARTED, background: true }
}
if (result?.info.status === "error")
return yield* new ToolFailure({ message: result.info.error ?? "Subagent failed" })
+51 -3
View File
@@ -1,5 +1,5 @@
import { describe, expect } from "bun:test"
import { DateTime, Effect, Layer, Schema } from "effect"
import { DateTime, Effect, Fiber, Layer, Schema } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { makeGlobalNode } from "@opencode-ai/core/effect/app-node"
@@ -43,7 +43,9 @@ const executionNode = makeGlobalNode({
const completed = new Set<SessionV2.ID>()
const complete = Effect.fn("SubagentTest.complete")(function* (sessionID: SessionV2.ID) {
if (completed.has(sessionID)) return
if ((yield* store.get(sessionID))?.title.includes("fail")) {
const session = yield* store.get(sessionID)
if (session?.title.includes("manual background")) yield* Effect.never
if (session?.title.includes("fail")) {
yield* new SessionRunnerModel.ModelNotSelectedError({ sessionID })
return
}
@@ -191,6 +193,7 @@ describe("SubagentTool", () => {
})
expect(settled.output?.structured).toMatchObject({ status: "completed", output: childText })
expect(Schema.decodeUnknownSync(SubagentTool.Output)(settled.output?.structured).background).toBeUndefined()
const child = yield* sessions.get(outputSessionID(settled.output?.structured))
expect(child).toMatchObject({
parentID: parent.id,
@@ -274,7 +277,7 @@ describe("SubagentTool", () => {
},
})
const childID = outputSessionID(settled.output?.structured)
expect(settled.output?.structured).toMatchObject({ status: "running" })
expect(settled.output?.structured).toMatchObject({ status: "running", background: true })
yield* Effect.yieldNow
const synthetic = (yield* sessions.context(parent.id)).filter((message) => message.type === "synthetic")
@@ -285,4 +288,49 @@ describe("SubagentTool", () => {
),
),
)
it.live("marks foreground subagents as backgrounded when blocking wait is backgrounded", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((dir) =>
Effect.gen(function* () {
const location = Location.Ref.make({ directory: AbsolutePath.make(dir.path) })
const sessions = yield* SessionV2.Service
const jobs = yield* Job.Service
const parent = yield* sessions.create({ location })
yield* withSubagent(parent.location)
const locations = yield* LocationServiceMap.Service
const registry = yield* ToolRegistry.Service.pipe(Effect.provide(locations.get(parent.location)))
yield* waitForTool(registry, SubagentTool.name)
const settled = yield* settleTool(registry, {
sessionID: parent.id,
...toolIdentity,
call: {
type: "tool-call",
id: "call-manual-background-subagent",
name: SubagentTool.name,
input: { agent: "reviewer", description: "manual background review", prompt: "review" },
},
}).pipe(Effect.forkScoped)
const backgrounded = yield* Effect.gen(function* () {
for (const _ of Array.from({ length: 100 })) {
const result = yield* jobs.backgroundAll({ sessionID: parent.id, type: SubagentTool.name })
if (result.length > 0) return result
yield* Effect.sleep("10 millis")
}
return []
})
expect(backgrounded).toHaveLength(1)
expect((yield* Fiber.join(settled)).output?.structured).toMatchObject({
status: "running",
background: true,
})
}),
),
),
)
})
@@ -159,7 +159,9 @@ export const experimentalHandlers = HttpApiBuilder.group(InstanceHttpApi, "exper
params: { sessionID: SessionID }
}) {
if (!flags.experimentalBackgroundSubagents) return false
return (yield* jobs.backgroundAll({ sessionID: ctx.params.sessionID, type: "task" })).length > 0
const tasks = yield* jobs.backgroundAll({ sessionID: ctx.params.sessionID, type: "task" })
const subagents = yield* jobs.backgroundAll({ sessionID: ctx.params.sessionID, type: "subagent" })
return tasks.length > 0 || subagents.length > 0
})
const resource = Effect.fn("ExperimentalHttpApi.resource")(function* () {
+25 -20
View File
@@ -57,6 +57,7 @@ import { usePromptMove } from "./move"
import { readLocalAttachment } from "./local-attachment"
import { useData } from "../../context/data"
import { useLocation } from "../../context/location"
import { foregroundSubagentCount } from "../../util/subagent"
export type PromptProps = {
sessionID?: string
@@ -160,12 +161,13 @@ export function Prompt(props: PromptProps) {
const dialog = useDialog()
const toast = useToast()
const status = createMemo(() => data.session.status(props.sessionID ?? ""))
const activeSubagents = createMemo(
() =>
data.session
.list()
.filter((session) => session.parentID === props.sessionID && data.session.status(session.id) === "running")
.length,
const activeSubagents = createMemo(() =>
foregroundSubagentCount({
sessionID: props.sessionID,
sessions: data.session.list(),
messages: props.sessionID ? data.session.message.list(props.sessionID) : [],
status: (sessionID) => data.session.status(sessionID),
}),
)
const runningShells = createMemo(
() => data.shell.list().filter((shell) => shell.metadata.sessionID === props.sessionID).length,
@@ -175,6 +177,7 @@ export function Prompt(props: PromptProps) {
const keymap = useOpencodeKeymap()
const agentShortcut = useCommandShortcut("agent.cycle")
const paletteShortcut = useCommandShortcut("command.palette.show")
const backgroundShortcut = useCommandShortcut("session.background")
const renderer = useRenderer()
const exit = useExit()
const dimensions = useTerminalDimensions()
@@ -290,20 +293,6 @@ export function Prompt(props: PromptProps) {
}
})
// Far-right footer cluster: live work counts lead, then context/cost usage, all dot-joined.
// When empty, the cluster falls back to the hotkey hints.
const statusItems = createMemo(() => {
const agents = activeSubagents()
const shells = runningShells()
const stats = usage()
return [
agents ? `${agents} subagent${agents === 1 ? "" : "s"}` : undefined,
shells ? `${shells} shell${shells === 1 ? "" : "s"}` : undefined,
stats?.context,
stats?.cost,
].filter(Boolean)
})
const [store, setStore] = createStore<{
prompt: PromptInfo
mode: "normal" | "shell"
@@ -321,6 +310,22 @@ export function Prompt(props: PromptProps) {
interrupt: 0,
})
// Far-right footer cluster: live work counts lead, then context/cost usage, all dot-joined.
// When empty, the cluster falls back to the hotkey hints.
const statusItems = createMemo(() => {
const agents = activeSubagents()
const shells = runningShells()
const stats = usage()
const background = backgroundShortcut()
return [
agents ? `${agents} subagent${agents === 1 ? "" : "s"}` : undefined,
agents && store.prompt.input === "" && background ? `${background} background` : undefined,
shells ? `${shells} shell${shells === 1 ? "" : "s"}` : undefined,
stats?.context,
stats?.cost,
].filter(Boolean)
})
createEffect(
on(
() => props.sessionID,
@@ -7,6 +7,7 @@ import { useTheme, selectedForeground } from "../../../context/theme"
import { Locale } from "../../../util/locale"
import { useBindings, useCommandShortcut } from "../../../keymap"
import { useComposerTab } from "./index"
import { useTuiConfig } from "../../../config"
interface SubagentEntry {
sessionID: string
@@ -17,54 +18,40 @@ interface SubagentEntry {
}
export function SubagentsTab(props: { sessionID: string }) {
const route = useRouteData("session")
const routeData = useRouteData("session")
const route = useRoute()
const data = useData()
const { theme } = useTheme()
const fg = selectedForeground(theme)
const navigate = useRoute().navigate
const composer = useComposerTab()
const tuiConfig = useTuiConfig()
const interruptHint = useCommandShortcut("composer.subagent.interrupt")
const backgroundHint = useCommandShortcut("composer.background")
const session = createMemo(() => data.session.get(props.sessionID))
const parentID = createMemo(() => session()?.parentID ?? props.sessionID)
const entries = createMemo<SubagentEntry[]>(() => {
const current = session()
if (!current) return []
const result: SubagentEntry[] = []
if (current.parentID) {
const siblings = data.session.list().filter((s) => s.parentID === current.parentID)
for (const sibling of siblings) {
const agentMatch = sibling.title.match(/@(\w+) subagent/)
const agent = sibling.agent ? Locale.titlecase(sibling.agent) : agentMatch ? Locale.titlecase(agentMatch[1]) : "Subagent"
const name = agentMatch ? sibling.title.replace(agentMatch[0], "").trim() || sibling.title : sibling.title
result.push({
sessionID: sibling.id,
agent,
title: name,
status: data.session.status(sibling.id),
current: sibling.id === route.sessionID,
})
}
} else {
const children = data.session.list().filter((s) => s.parentID === props.sessionID)
for (const child of children) {
return data.session
.list()
.filter((child) => child.parentID === parentID())
.map((child) => {
const agentMatch = child.title.match(/@(\w+) subagent/)
const agent = child.agent ? Locale.titlecase(child.agent) : agentMatch ? Locale.titlecase(agentMatch[1]) : "Subagent"
const name = agentMatch ? child.title.replace(agentMatch[0], "").trim() || child.title : child.title
result.push({
const agent = child.agent
? Locale.titlecase(child.agent)
: agentMatch
? Locale.titlecase(agentMatch[1])
: "Subagent"
return {
sessionID: child.id,
agent,
title: name,
title: agentMatch ? child.title.replace(agentMatch[0], "").trim() || child.title : child.title,
status: data.session.status(child.id),
current: child.id === route.sessionID,
})
}
}
return result
current: child.id === routeData.sessionID,
}
})
})
const [store, setStore] = createStore({ selected: 0 })
@@ -88,10 +75,10 @@ export function SubagentsTab(props: { sessionID: string }) {
return
}
const list = entries()
if (selectedSessionID !== route.sessionID && list.length > 0) {
if (selectedSessionID !== routeData.sessionID && list.length > 0) {
const currentIdx = list.findIndex((e) => e.current)
const next = currentIdx >= 0 ? currentIdx : 0
selectedSessionID = route.sessionID
selectedSessionID = routeData.sessionID
setStore("selected", next)
const scrollCurrentIntoView = () => scrollToIndex(next, true)
scrollCurrentIntoView()
@@ -132,14 +119,11 @@ export function SubagentsTab(props: { sessionID: string }) {
hints: () => {
const entry = selectedEntry()
if (!entry || entry.status !== "running") return []
return [
{ label: "interrupt", shortcut: interruptHint() },
...(entry.current ? [{ label: "background", shortcut: backgroundHint() }] : []),
]
return [{ label: "interrupt", shortcut: interruptHint() }]
},
onClose: () => {
const parentID = session()?.parentID
if (parentID) navigate({ type: "session", sessionID: parentID })
if (parentID) route.navigate({ type: "session", sessionID: parentID })
},
})
onCleanup(cleanup)
@@ -175,7 +159,7 @@ export function SubagentsTab(props: { sessionID: string }) {
category: "Composer",
run() {
const entry = entries()[store.selected]
if (entry) navigate({ type: "session", sessionID: entry.sessionID })
if (entry) route.navigate({ type: "session", sessionID: entry.sessionID })
},
},
{
@@ -187,32 +171,19 @@ export function SubagentsTab(props: { sessionID: string }) {
if (!entry || entry.status !== "running") return
},
},
{
name: "composer.background",
title: "Background subagent",
category: "Composer",
run() {
const entry = selectedEntry()
if (!entry || entry.status !== "running" || !entry.current) return
},
},
],
bindings: [
{ key: "up", desc: "Previous subagent", group: "Subagents", cmd: "composer.subagent.up" },
{ key: "down", desc: "Next subagent", group: "Subagents", cmd: "composer.subagent.down" },
{ key: "return", desc: "Navigate to subagent", group: "Subagents", cmd: "composer.subagent.select" },
{ key: "ctrl+d", desc: "Interrupt subagent", group: "Subagents", cmd: "composer.subagent.interrupt" },
{ key: "ctrl+b", desc: "Background subagent", group: "Subagents", cmd: "composer.background" },
...tuiConfig.keybinds.gather("session.background", ["session.background"] as const),
],
}))
return (
<Show when={composer.active("subagents")}>
<scrollbox
scrollbarOptions={{ visible: false }}
maxHeight={5}
ref={(r: ScrollBoxRenderable) => (scroll = r)}
>
<scrollbox scrollbarOptions={{ visible: false }} maxHeight={5} ref={(r: ScrollBoxRenderable) => (scroll = r)}>
<Show when={entries().length > 0} fallback={<text fg={theme.textMuted}> No subagents</text>}>
<For each={entries()}>
{(entry, index) => {
@@ -230,7 +201,7 @@ export function SubagentsTab(props: { sessionID: string }) {
onMouseOver={() => setStore("selected", index())}
onMouseUp={() => {
setStore("selected", index())
navigate({ type: "session", sessionID: entry.sessionID })
route.navigate({ type: "session", sessionID: entry.sessionID })
}}
>
<box flexGrow={1} minWidth={0} flexDirection="row">
+71 -11
View File
@@ -71,6 +71,7 @@ import { OPENCODE_BASE_MODE, useBindings, useCommandShortcut } from "../../keyma
import { usePathFormatter } from "../../context/path-format"
import { LocationProvider } from "../../context/location"
import { createSessionRows, type PartRef, type SessionRow } from "./rows"
import { foregroundSubagentCount, subagentDisplayState } from "../../util/subagent"
addDefaultParsers(parsers.parsers)
@@ -99,13 +100,14 @@ const sessionBindingCommands = [
"messages.copy",
"session.copy",
"session.export",
"session.background",
"session.child.first",
"session.parent",
"session.child.next",
"session.child.previous",
] as const
const sessionBackgroundBindingCommands = ["session.background"] as const
const sessionGlobalBindingCommands = [
"session.page.up",
"session.page.down",
@@ -147,7 +149,7 @@ export function Session() {
}
const pluginRuntime = usePluginRuntime()
const route = useRouteData("session")
const { navigate } = useRoute()
const router = useRoute()
const data = useData()
const project = useProject()
const paths = useTuiPaths()
@@ -241,6 +243,22 @@ export function Session() {
const sdk = useSDK()
const editor = useEditorContext()
const rows = createSessionRows(() => route.sessionID)
const backgroundSessionID = createMemo(() => session()?.parentID ?? route.sessionID)
const foregroundSubagents = createMemo(() =>
foregroundSubagentCount({
sessionID: backgroundSessionID(),
sessions: data.session.list(),
messages: data.session.message.list(backgroundSessionID()),
status: (sessionID) => data.session.status(sessionID),
}),
)
createEffect(
on(backgroundSessionID, (sessionID) => {
if (sessionID === route.sessionID) return
void data.session.message.refresh(sessionID)
}),
)
createEffect(
on(descendantSessionIDs, (sessionIDs) => {
@@ -263,7 +281,7 @@ export function Session() {
variant: "error",
duration: 5000,
})
navigate({ type: "home" })
router.navigate({ type: "home" })
return
}
@@ -277,7 +295,7 @@ export function Session() {
variant: "error",
duration: 5000,
})
navigate({ type: "home" })
router.navigate({ type: "home" })
})
})
@@ -786,7 +804,33 @@ export function Session() {
value: "session.background",
category: "Session",
hidden: true,
run: () => unavailable("Backgrounding subagents"),
run: async () => {
const current = location()
if (!current) return
const targetSessionID = backgroundSessionID()
dialog.clear()
try {
const capabilities = await sdk.client.experimental.capabilities.get(
{ directory: current.directory, workspace: current.workspaceID },
{ throwOnError: true },
)
if (!capabilities.data.backgroundSubagents) {
toast.show({ message: "Background subagents are not enabled", variant: "info", duration: 3000 })
return
}
const result = await sdk.client.experimental.session.background(
{ sessionID: targetSessionID, directory: current.directory, workspace: current.workspaceID },
{ throwOnError: true },
)
toast.show({
message: result.data ? "Backgrounded running subagents" : "No running subagents to background",
variant: result.data ? "success" : "info",
duration: 3000,
})
} catch (error) {
toast.show({ message: errorMessage(error), variant: "error", duration: 5000 })
}
},
},
{
title: "Toggle subagent picker",
@@ -807,7 +851,7 @@ export function Session() {
run: () => {
const parentID = session()?.parentID
if (parentID) {
navigate({
router.navigate({
type: "session",
sessionID: parentID,
})
@@ -862,6 +906,14 @@ export function Session() {
bindings: tuiConfig.keybinds.gather("session", sessionBindingCommands),
}))
useBindings(() => ({
mode: OPENCODE_BASE_MODE,
enabled: () =>
foregroundSubagents() > 0 &&
(renderer.currentFocusedEditor === null || (prompt?.focused === true && prompt.current.input === "")),
bindings: tuiConfig.keybinds.gather("session.background", sessionBackgroundBindingCommands),
}))
// snap to bottom when session changes
createEffect(on(() => route.sessionID, toBottom))
createEffect(
@@ -2167,26 +2219,34 @@ function WebSearch(props: ToolProps) {
}
function Task(props: ToolProps) {
const { navigate } = useRoute()
const router = useRoute()
const data = useData()
const sessionID = createMemo(() => stringValue(props.metadata.sessionID) ?? stringValue(props.metadata.sessionId))
const description = createMemo(() => stringValue(props.input.description))
const display = createMemo(() =>
subagentDisplayState({
toolStatus: props.part.state.status,
metadata: props.metadata,
sessionStatus: (sessionID) => data.session.status(sessionID),
}),
)
return (
<InlineTool
icon={props.part.state.status === "completed" ? "✓" : "│"}
spinner={props.part.state.status === "running"}
icon={display().icon}
spinner={display().running}
complete={description()}
pending="Delegating..."
part={props.part}
onClick={() => {
const id = sessionID()
if (id) navigate({ type: "session", sessionID: id })
if (id) router.navigate({ type: "session", sessionID: id })
}}
>
{formatSubagentTitle(
Locale.titlecase(stringValue(props.input.agent) ?? stringValue(props.input.subagent_type) ?? "General"),
description() ?? "Subagent",
props.metadata.background === true,
display().background,
)}
</InlineTool>
)
+127
View File
@@ -0,0 +1,127 @@
const subagentTools = new Set(["subagent", "task"])
type SubagentDisplayStateInput = {
readonly toolStatus: string
readonly metadata: Record<string, unknown>
readonly sessionStatus: (sessionID: string) => string
}
type SessionSummary = {
readonly id: string
readonly parentID?: string
readonly title?: string
}
type TimelinePart = {
readonly type: string
readonly name?: string
readonly state?: {
readonly status: string
readonly input?: unknown
readonly structured?: Record<string, unknown>
}
}
type TimelineMessage = {
readonly type: string
readonly content?: readonly TimelinePart[]
}
export function foregroundSubagentCount(input: {
readonly sessionID?: string
readonly sessions: readonly SessionSummary[]
readonly messages: readonly TimelineMessage[]
readonly status: (sessionID: string) => string
}) {
if (!input.sessionID) return 0
const backgrounded = new Set(
input.messages.flatMap((message) =>
message.type === "assistant"
? (message.content ?? []).flatMap((part) => {
if (part.type !== "tool") return []
if (!part.name || !subagentTools.has(part.name)) return []
if (part.state?.status === "pending" || part.state?.structured?.background !== true) return []
const sessionID =
typeof part.state.structured.sessionID === "string"
? part.state.structured.sessionID
: typeof part.state.structured.sessionId === "string"
? part.state.structured.sessionId
: undefined
return sessionID ? [sessionID] : []
})
: [],
),
)
const runningSessionIDs = new Set(
input.sessions
.filter(
(session) =>
session.parentID === input.sessionID &&
input.status(session.id) === "running" &&
!backgrounded.has(session.id),
)
.map((session) => session.id),
)
const runningSessionTitleCounts = input.sessions
.filter((session) => runningSessionIDs.has(session.id) && session.title)
.reduce((counts, session) => {
if (!session.title) return counts
return counts.set(session.title, (counts.get(session.title) ?? 0) + 1)
}, new Map<string, number>())
const runningRows = input.messages.flatMap((message) =>
message.type === "assistant"
? (message.content ?? []).filter(
(part) =>
part.type === "tool" &&
part.name !== undefined &&
subagentTools.has(part.name) &&
part.state?.status === "running" &&
part.state.structured?.background !== true,
)
: [],
)
const runningRowSessionIDs = new Set(
runningRows.flatMap((part) => {
const sessionID = subagentSessionID(part.state?.structured ?? {})
return sessionID ? [sessionID] : []
}),
)
const anonymousRunningRows = runningRows.filter((part) => !subagentSessionID(part.state?.structured ?? {}))
const matchedAnonymousRows = anonymousRunningRows.filter((part) => {
const input = part.state?.input
if (typeof input !== "object" || input === null || Array.isArray(input) || !("description" in input)) return false
if (typeof input.description !== "string") return false
const remaining = runningSessionTitleCounts.get(input.description) ?? 0
if (remaining <= 0) return false
runningSessionTitleCounts.set(input.description, remaining - 1)
return true
}).length
const runningSessions = [...runningSessionIDs].filter((session) => !runningRowSessionIDs.has(session)).length
return runningSessions + runningRowSessionIDs.size + anonymousRunningRows.length - matchedAnonymousRows
}
export function subagentDisplayState(input: SubagentDisplayStateInput) {
const sessionID = subagentSessionID(input.metadata)
const background = input.metadata.background === true
const childRunning = background && sessionID !== undefined && input.sessionStatus(sessionID) === "running"
const running = input.toolStatus === "running" || childRunning
return {
background,
running,
icon: running ? "│" : input.toolStatus === "completed" ? "✓" : "│",
}
}
function subagentSessionID(metadata: Record<string, unknown>) {
if (typeof metadata.sessionID === "string") return metadata.sessionID
if (typeof metadata.sessionId === "string") return metadata.sessionId
return undefined
}
+187
View File
@@ -0,0 +1,187 @@
import { describe, expect, test } from "bun:test"
import { foregroundSubagentCount, subagentDisplayState } from "../../src/util/subagent"
describe("foregroundSubagentCount", () => {
test("counts running subagent timeline rows before child session output is available", () => {
expect(
foregroundSubagentCount({
sessionID: "parent",
sessions: [],
messages: [
{
type: "assistant",
content: [
{ type: "tool", name: "subagent", state: { status: "running", structured: {} } },
{ type: "tool", name: "task", state: { status: "running", structured: {} } },
],
},
],
status: () => "idle",
}),
).toBe(2)
})
test("does not double-count running rows that match known child sessions", () => {
expect(
foregroundSubagentCount({
sessionID: "parent",
sessions: [{ id: "child-1", parentID: "parent", title: "Audit slow path" }],
messages: [
{
type: "assistant",
content: [
{
type: "tool",
name: "subagent",
state: { status: "running", input: { description: "Audit slow path" }, structured: {} },
},
],
},
],
status: () => "running",
}),
).toBe(1)
})
test("counts mixed timeline-only and session-only foreground subagents", () => {
expect(
foregroundSubagentCount({
sessionID: "parent",
sessions: [{ id: "child-1", parentID: "parent", title: "Known child" }],
messages: [
{
type: "assistant",
content: [
{
type: "tool",
name: "subagent",
state: { status: "running", input: { description: "New child" }, structured: {} },
},
],
},
],
status: () => "running",
}),
).toBe(2)
})
test("does not undercount duplicate descriptions when only one child session is synced", () => {
expect(
foregroundSubagentCount({
sessionID: "parent",
sessions: [{ id: "child-1", parentID: "parent", title: "Same task" }],
messages: [
{
type: "assistant",
content: [
{
type: "tool",
name: "subagent",
state: { status: "running", input: { description: "Same task" }, structured: {} },
},
{
type: "tool",
name: "subagent",
state: { status: "running", input: { description: "Same task" }, structured: {} },
},
],
},
],
status: () => "running",
}),
).toBe(2)
})
test("counts running child sessions", () => {
expect(
foregroundSubagentCount({
sessionID: "parent",
sessions: [
{ id: "child-1", parentID: "parent" },
{ id: "child-2", parentID: "parent" },
{ id: "child-3", parentID: "other" },
],
messages: [],
status: (sessionID) => (sessionID === "child-1" || sessionID === "child-3" ? "running" : "idle"),
}),
).toBe(1)
})
test("excludes running subagents already marked as backgrounded in the timeline", () => {
expect(
foregroundSubagentCount({
sessionID: "parent",
sessions: [
{ id: "child-1", parentID: "parent" },
{ id: "child-2", parentID: "parent" },
],
messages: [
{
type: "assistant",
content: [
{
type: "tool",
name: "subagent",
state: {
status: "completed",
structured: { sessionID: "child-1", background: true },
},
},
{
type: "tool",
name: "task",
state: {
status: "completed",
structured: { sessionId: "child-2", background: false },
},
},
],
},
],
status: () => "running",
}),
).toBe(1)
})
})
describe("subagentDisplayState", () => {
test("keeps a backgrounded subagent spinning while the child session is running", () => {
expect(
subagentDisplayState({
toolStatus: "completed",
metadata: { sessionID: "child", status: "running", background: true },
sessionStatus: () => "running",
}),
).toEqual({ background: true, running: true, icon: "│" })
})
test("shows a checkmark once a backgrounded child session is no longer running", () => {
expect(
subagentDisplayState({
toolStatus: "completed",
metadata: { sessionID: "child", status: "running", background: true },
sessionStatus: () => "idle",
}),
).toEqual({ background: true, running: false, icon: "✓" })
})
test("does not spin forever from stale background metadata without a child session ID", () => {
expect(
subagentDisplayState({
toolStatus: "completed",
metadata: { status: "running", background: true },
sessionStatus: () => "idle",
}),
).toEqual({ background: true, running: false, icon: "✓" })
})
test("does not show a checkmark for errored subagent rows", () => {
expect(
subagentDisplayState({
toolStatus: "error",
metadata: { background: true },
sessionStatus: () => "idle",
}),
).toEqual({ background: true, running: false, icon: "│" })
})
})