fix(tui): background foreground subagents

This commit is contained in:
Kit Langton
2026-06-30 13:07:40 -04:00
parent cf3b601731
commit 3005e8bb98
7 changed files with 388 additions and 84 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" })
+50 -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,48 @@ 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)))
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: 20 })) {
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,
})
}),
),
),
)
})
+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,9 +7,6 @@ import { useTheme, selectedForeground } from "../../../context/theme"
import { Locale } from "../../../util/locale"
import { useBindings, useCommandShortcut } from "../../../keymap"
import { useComposerTab } from "./index"
import { useSDK } from "../../../context/sdk"
import { useToast } from "../../../ui/toast"
import { errorMessage } from "../../../util/error"
interface SubagentEntry {
sessionID: string
@@ -26,10 +23,7 @@ export function SubagentsTab(props: { sessionID: string }) {
const { theme } = useTheme()
const fg = selectedForeground(theme)
const composer = useComposerTab()
const sdk = useSDK()
const toast = useToast()
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)
@@ -116,33 +110,6 @@ export function SubagentsTab(props: { sessionID: string }) {
}
}
async function background() {
const parent = data.session.get(parentID())
const location = parent?.location ?? session()?.location
if (!location) return
try {
const capabilities = await sdk.client.experimental.capabilities.get(
{ directory: location.directory, workspace: location.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: parentID(), directory: location.directory, workspace: location.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 })
}
}
onMount(() => {
const cleanup = composer.register({
id: "subagents",
@@ -150,10 +117,7 @@ export function SubagentsTab(props: { sessionID: string }) {
hints: () => {
const entry = selectedEntry()
if (!entry || entry.status !== "running") return []
return [
{ label: "interrupt", shortcut: interruptHint() },
{ label: "background", shortcut: backgroundHint() },
]
return [{ label: "interrupt", shortcut: interruptHint() }]
},
onClose: () => {
const parentID = session()?.parentID
@@ -205,23 +169,12 @@ 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") return
void background()
},
},
],
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" },
],
}))
+37 -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)
@@ -148,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()
@@ -242,6 +243,15 @@ 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(descendantSessionIDs, (sessionIDs) => {
@@ -264,7 +274,7 @@ export function Session() {
variant: "error",
duration: 5000,
})
navigate({ type: "home" })
router.navigate({ type: "home" })
return
}
@@ -278,7 +288,7 @@ export function Session() {
variant: "error",
duration: 5000,
})
navigate({ type: "home" })
router.navigate({ type: "home" })
})
})
@@ -790,7 +800,7 @@ export function Session() {
run: async () => {
const current = location()
if (!current) return
const targetSessionID = session()?.parentID ?? route.sessionID
const targetSessionID = backgroundSessionID()
dialog.clear()
try {
const capabilities = await sdk.client.experimental.capabilities.get(
@@ -834,7 +844,7 @@ export function Session() {
run: () => {
const parentID = session()?.parentID
if (parentID) {
navigate({
router.navigate({
type: "session",
sessionID: parentID,
})
@@ -891,7 +901,15 @@ export function Session() {
useBindings(() => ({
mode: OPENCODE_BASE_MODE,
enabled: () => renderer.currentFocusedEditor === null || (prompt?.focused === true && prompt.current.input === ""),
enabled: () =>
foregroundSubagents() > 0 &&
(renderer.currentFocusedEditor === null || (prompt?.focused === true && prompt.current.input === "")),
bindings: tuiConfig.keybinds.gather("session.background", sessionBackgroundBindingCommands),
}))
useBindings(() => ({
mode: "composer",
enabled: () => foregroundSubagents() > 0 && composer.open,
bindings: tuiConfig.keybinds.gather("session.background", sessionBackgroundBindingCommands),
}))
@@ -2200,26 +2218,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>
)
+122
View File
@@ -0,0 +1,122 @@
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 runningSessionTitles = new Set(
input.sessions
.filter((session) => runningSessionIDs.has(session.id) && session.title)
.map((session) => session.title),
)
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) => {
if (subagentSessionID(part.state?.structured ?? {})) return false
const input = part.state?.input
if (typeof input !== "object" || input === null || Array.isArray(input) || !("description" in input)) return true
return typeof input.description === "string" ? !runningSessionTitles.has(input.description) : true
}).length
const runningSessions = [...runningSessionIDs].filter((session) => !runningRowSessionIDs.has(session)).length
return runningSessions + runningRowSessionIDs.size + anonymousRunningRows
}
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 ? "│" : "✓",
}
}
function subagentSessionID(metadata: Record<string, unknown>) {
if (typeof metadata.sessionID === "string") return metadata.sessionID
if (typeof metadata.sessionId === "string") return metadata.sessionId
return undefined
}
+150
View File
@@ -0,0 +1,150 @@
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("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 when the child session is unknown", () => {
expect(
subagentDisplayState({
toolStatus: "completed",
metadata: { sessionID: "child", status: "running", background: true },
sessionStatus: () => "idle",
}),
).toEqual({ background: true, running: false, icon: "✓" })
})
})