Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 10d82a60c6 | |||
| 3d0ff1c705 | |||
| 3005e8bb98 | |||
| cf3b601731 |
@@ -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" })
|
||||
|
||||
@@ -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* () {
|
||||
|
||||
@@ -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,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>
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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: "│" })
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user