diff --git a/packages/core/src/tool/subagent.ts b/packages/core/src/tool/subagent.ts index 517d1ce65fa..14d37c8d011 100644 --- a/packages/core/src/tool/subagent.ts +++ b/packages/core/src/tool/subagent.ts @@ -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" }) diff --git a/packages/core/test/tool-subagent.test.ts b/packages/core/test/tool-subagent.test.ts index 8bf145eddbe..399cec716eb 100644 --- a/packages/core/test/tool-subagent.test.ts +++ b/packages/core/test/tool-subagent.test.ts @@ -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() 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, + }) + }), + ), + ), + ) }) diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index 85fb86cd027..858ade76efc 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -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, diff --git a/packages/tui/src/routes/session/composer/subagents-tab.tsx b/packages/tui/src/routes/session/composer/subagents-tab.tsx index 04ba3cb9c6f..72c72d8f471 100644 --- a/packages/tui/src/routes/session/composer/subagents-tab.tsx +++ b/packages/tui/src/routes/session/composer/subagents-tab.tsx @@ -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" }, ], })) diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index ff06aaf3d22..ffb98ad58d1 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -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 ( { 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, )} ) diff --git a/packages/tui/src/util/subagent.ts b/packages/tui/src/util/subagent.ts new file mode 100644 index 00000000000..ed60e5c81d2 --- /dev/null +++ b/packages/tui/src/util/subagent.ts @@ -0,0 +1,122 @@ +const subagentTools = new Set(["subagent", "task"]) + +type SubagentDisplayStateInput = { + readonly toolStatus: string + readonly metadata: Record + 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 + } +} + +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) { + if (typeof metadata.sessionID === "string") return metadata.sessionID + if (typeof metadata.sessionId === "string") return metadata.sessionId + return undefined +} diff --git a/packages/tui/test/util/subagent.test.ts b/packages/tui/test/util/subagent.test.ts new file mode 100644 index 00000000000..9e279a8b337 --- /dev/null +++ b/packages/tui/test/util/subagent.test.ts @@ -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: "✓" }) + }) +})