From c3fcdb03cc51d3f5eff08806cc4b7a2177ea1854 Mon Sep 17 00:00:00 2001 From: Eric Lee Date: Fri, 25 Sep 2026 00:49:45 -0700 Subject: [PATCH] fix(tui): name agents in the spawn tree and keep teammates past their turn The agents overlay listed each spawn only by its task description. A persistent teammate also dropped out of the overlay once the turn that spawned it ended, even though it kept working. - gatewayClient and types: keep the spawn's `name` and `subagent_type` next to its description. Any status that isn't live, including `interrupted`, now ends the run. - Overlay rows start with the name, or else an agent type that isn't one of the defaults. The detail pane shows both, and replayed trees keep them. - A session-scoped roster ($sessionAgents) outlives the turn state. The overlay carries over running agents that the current turn no longer lists. Finished entries are pruned at turn start, and the roster is cleared only when the backend exits. - agent.py: per-message progress sends the resolved agent type. The sync end-of-run frame sends the spawn's own name instead of its type. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 19 +++ src/tool_system/tools/agent.py | 17 +- .../test_r5_ch13_terminal_progress_round5.py | 51 ++++++ .../__tests__/agentsOverlayIdentity.test.tsx | 146 ++++++++++++++++++ .../createGatewayEventHandler.test.ts | 96 +++++++++++- ui-tui/src/__tests__/gatewayClient.test.ts | 26 ++++ .../src/__tests__/spawnHistoryStore.test.ts | 27 ++++ ui-tui/src/__tests__/subagentTree.test.ts | 73 ++++++++- ui-tui/src/app/spawnHistoryStore.ts | 2 + ui-tui/src/app/turnController.ts | 131 ++++++++++------ ui-tui/src/app/turnStore.ts | 8 + ui-tui/src/app/useMainApp.ts | 1 + ui-tui/src/components/agentsOverlay.tsx | 30 +++- ui-tui/src/components/thinking.tsx | 3 +- ui-tui/src/gatewayClient.ts | 17 +- ui-tui/src/gatewayTypes.ts | 2 + ui-tui/src/lib/subagentTree.ts | 61 ++++++++ ui-tui/src/types.ts | 5 + 18 files changed, 648 insertions(+), 67 deletions(-) create mode 100644 ui-tui/src/__tests__/agentsOverlayIdentity.test.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b0f5e821..6a6e94379 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **The TUI's agents overlay names each agent.** Rows in the spawn tree showed + only the task description, so a team of named specialists read as a list of + anonymous tasks. Each row now leads with the spawn's name (a teammate's + `name`) or, when it has none, the agent definition it runs (`Explore`, a + custom agent); the default `general-purpose` type stays unlabeled, as in + Claude Code. The detail pane shows both, and replayed spawn trees (`[` / `]` + in the overlay) keep the label. The backend's live progress now reports the + resolved agent type, which was missing while an agent ran whenever the call + left `subagent_type` out, and an unnamed agent no longer reports its type as + its name when it finishes. +- **Teammates and background agents stay in the agents overlay after their + turn.** The overlay's list lived only for the turn that spawned an agent: at + the next turn boundary a persistent teammate vanished while it kept working, + and its later progress was dropped. A session-wide roster now carries every + agent that is still running, so teammates stay listed — idle or busy, and + across `/clear` — until they exit, beside the current turn's subagents. A + foreground subagent stopped with ESC or from the overlay, a killed teammate, + or an aborted workflow agent now shows as interrupted; its row used to stay + `running`. - **Delegating to a subagent no longer takes minutes at `/effort max`.** A subagent's requests carried no effort of their own, so every subagent fell back to the saved `settings.effort`: the built-in Explore agent — the fast, diff --git a/src/tool_system/tools/agent.py b/src/tool_system/tools/agent.py index 787295053..5e6fc8f4d 100644 --- a/src/tool_system/tools/agent.py +++ b/src/tool_system/tools/agent.py @@ -509,7 +509,11 @@ def _on_subagent_message(message: Any) -> None: "depth": _depth, "name": agent_name, "description": description, - "subagent_type": subagent_type, + # The RESOLVED definition, as the terminal emit + # reports it — the raw tool input is None whenever + # the call omitted subagent_type (general-purpose, + # fork), so clients could not say which agent ran. + "subagent_type": agent_def.agent_type, "model": resolved_model, "activity": activity, "tool_use_count": _tracker.tool_use_count, @@ -637,9 +641,10 @@ def _run_sync_agent( agent_messages: list[Message] = [] interrupted = False # R5 (ch13) — the HUD goal label: use the SAME name/description the - # running emits use, not the truncated prompt (critic residual). - _hud_name = agent_name if agent_name is not None else \ - getattr(run_params.agent_definition, "agent_type", "") + # running emits use, not the truncated prompt (critic residual). The + # name is the spawn's own (None when unnamed), as on every running + # emit and the background path — never the agent type, which + # ``subagent_type`` already carries: clients label rows by name first. _hud_desc = description if description is not None else \ (run_params.prompt or "")[:80] @@ -698,7 +703,7 @@ def _record_then_forward(message: Any) -> None: # delegation lingered "running". Emit failed, then re-raise so # the existing error flow is unchanged. _emit_terminal_agent_progress( - run_params.parent_context, agent_id=agent_id, name=_hud_name, + run_params.parent_context, agent_id=agent_id, name=agent_name, description=_hud_desc, subagent_type=agent_type, status="failed", model=resolved_model, tool_use_id=tool_use_id, ) @@ -726,7 +731,7 @@ def _record_then_forward(message: Any) -> None: # TUI's subagent HUD marks this subagent complete instead of # lingering "running". The per-message emits carry status:"running". _emit_terminal_agent_progress( - run_params.parent_context, agent_id=agent_id, name=_hud_name, + run_params.parent_context, agent_id=agent_id, name=agent_name, description=_hud_desc, subagent_type=agent_type, status="interrupted" if interrupted else "completed", model=resolved_model, tool_use_id=tool_use_id, diff --git a/tests/test_r5_ch13_terminal_progress_round5.py b/tests/test_r5_ch13_terminal_progress_round5.py index 02cfc31cc..d64bc002c 100644 --- a/tests/test_r5_ch13_terminal_progress_round5.py +++ b/tests/test_r5_ch13_terminal_progress_round5.py @@ -87,6 +87,57 @@ async def _fake(_p): # Goal label is the task description, not the truncated prompt. self.assertEqual(done[-1]["description"], "explore the repo") + def test_running_emits_name_the_resolved_agent(self): + # Omitting subagent_type runs general-purpose. The running emits sent + # the raw input (None) while the terminal one sent the resolved type, + # so a client could not say which agent was running until it ended. + with TemporaryDirectory() as tmp: + ctx, emitted = _ctx(tmp) + + async def _fake(p): + message = AssistantMessage(content=[TextBlock(text="working")]) + p.on_message(message) + yield message + + with patch("src.tool_system.tools.agent.run_agent", _fake): + registry = build_default_registry(provider=object()) + registry.dispatch(ToolCall(name="Agent", input={ + "description": "map the proof obligations", + "prompt": "look around", + "name": "nl-sketcher", + }), ctx) + + running = _terminal(emitted, "running") + self.assertTrue(running, "the per-message hook should emit running progress") + self.assertEqual(running[-1]["subagent_type"], "general-purpose") + self.assertEqual(running[-1]["name"], "nl-sketcher") + self.assertEqual(running[-1]["description"], "map the proof obligations") + self.assertEqual(_terminal(emitted, "completed")[-1]["subagent_type"], "general-purpose") + + def test_unnamed_spawn_stays_unnamed_when_it_ends(self): + # The sync terminal emit used to fill `name` with the agent type, so a + # client labelling rows name-first renamed the agent at completion. + with TemporaryDirectory() as tmp: + ctx, emitted = _ctx(tmp) + + async def _fake(p): + message = AssistantMessage(content=[TextBlock(text="working")]) + p.on_message(message) + yield message + + with patch("src.tool_system.tools.agent.run_agent", _fake): + registry = build_default_registry(provider=object()) + registry.dispatch(ToolCall(name="Agent", input={ + "description": "survey the repo", + "prompt": "look around", + "subagent_type": "Explore", + }), ctx) + + self.assertIsNone(_terminal(emitted, "running")[-1]["name"]) + completed = _terminal(emitted, "completed")[-1] + self.assertIsNone(completed["name"]) + self.assertEqual(completed["subagent_type"], "Explore") + def test_sync_failure_emits_failed(self): with TemporaryDirectory() as tmp: ctx, emitted = _ctx(tmp) diff --git a/ui-tui/src/__tests__/agentsOverlayIdentity.test.tsx b/ui-tui/src/__tests__/agentsOverlayIdentity.test.tsx new file mode 100644 index 000000000..3896b4ca0 --- /dev/null +++ b/ui-tui/src/__tests__/agentsOverlayIdentity.test.tsx @@ -0,0 +1,146 @@ +/** + * The agents overlay ("Spawn tree") must say WHO each agent is, not only what + * it was asked to do. Rows used to render just the task description, so a + * team of named specialists (nl-sketcher, fl-formalizer, …) read as a list of + * anonymous tasks: + * + * 1 ● Pin environment encode statement ·104t + * 2 ✓ Map informal proof obligations ·22t + * + * Now the spawn name — or, for an unnamed spawn, the agent definition — leads + * the row. Default types (general-purpose, worker, fork) say nothing about the + * agent and stay hidden, as the reference's userFacingName hides them: + * + * 1 ● fl-formalizer · Pin environment encode statement ·104t + * 2 ✓ Explore · Map informal proof obligations ·22t + * 3 ✓ Extract eligible theorem precisely ·8t + */ +import { PassThrough } from 'node:stream' + +import { renderSync } from '@clawcodex/ink' +import React from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.hoisted(() => { + process.env.FORCE_COLOR = '0' + process.env.NO_COLOR = '1' +}) + +import { clearSpawnHistory } from '../app/spawnHistoryStore.js' +import { $sessionAgents, patchTurnState, resetTurnState } from '../app/turnStore.js' +import { AgentsOverlay } from '../components/agentsOverlay.js' +import type { GatewayClient } from '../gatewayClient.js' +import { stripAnsi } from '../lib/text.js' +import { DEFAULT_THEME } from '../theme.js' +import type { SubagentProgress } from '../types.js' + +const renderToString = (element: React.ReactElement): string => { + const stdout = new PassThrough() + const stdin = new PassThrough() + const stderr = new PassThrough() + let output = '' + + Object.assign(stdout, { columns: 100, rows: 40 }) + // The overlay reads keys (useInput): a non-TTY stdin makes Ink throw "Raw + // mode is not supported" after the first frame. + Object.assign(stdin, { isTTY: true, ref: () => {}, setRawMode: () => {}, unref: () => {} }) + Object.assign(stderr, { isTTY: false }) + stdout.on('data', chunk => { + output += chunk.toString() + }) + + const instance = renderSync(element, { + patchConsole: false, + stderr: stderr as unknown as NodeJS.WriteStream, + stdin: stdin as unknown as NodeJS.ReadStream, + stdout: stdout as unknown as NodeJS.WriteStream + }) + + instance.unmount() + instance.cleanup() + + return stripAnsi(output) +} + +const item = ( + overrides: Partial & Pick +): SubagentProgress => ({ + depth: 0, + notes: [], + parentId: null, + status: 'completed', + taskCount: 1, + thinking: [], + toolCount: 1, + tools: [], + ...overrides +}) + +const gw = { request: vi.fn(() => Promise.resolve({})) } as unknown as GatewayClient + +beforeEach(() => { + resetTurnState() + clearSpawnHistory() + $sessionAgents.set({}) +}) + +describe('agents overlay rows name the agent', () => { + it('leads each row with the spawn name, else the agent type, then the task', () => { + patchTurnState({ + subagents: [ + item({ + agentType: 'math-fl-formalizer', + goal: 'Pin environment encode statement', + id: 'a1', + index: 0, + name: 'fl-formalizer', + status: 'running' + }), + item({ agentType: 'Explore', goal: 'Map informal proof obligations', id: 'a2', index: 1 }), + item({ agentType: 'general-purpose', goal: 'Extract eligible theorem precisely', id: 'a3', index: 2 }), + item({ goal: 'A row from an older gateway', id: 'a4', index: 3 }) + ] + }) + + const out = renderToString( {}} t={DEFAULT_THEME} />) + + // Anchored on the status glyph: a bare substring would also match the + // type ("math-fl-formalizer · …") if name and type swapped precedence. + expect(out).toMatch(/● fl-formalizer · Pin environment encode statement/) + expect(out).toMatch(/✓ Explore · Map informal proof obligations/) + // A default type says nothing — the bare task, with no dangling separator. + expect(out).toMatch(/✓ Extract eligible theorem precisely/) + expect(out).not.toContain('general-purpose ·') + expect(out).toMatch(/✓ A row from an older gateway/) + // A clean mount — no Ink error screen after the first frame. + expect(out).not.toContain('ERROR') + }) +}) + +describe('agents overlay keeps agents that outlive their turn', () => { + it('lists a still-running teammate from an earlier turn beside this turn’s subagents', () => { + // A later turn: its own subagent in turn state, while the teammate spawned + // in an earlier turn survives only in the session roster. + patchTurnState({ + subagents: [item({ agentType: 'Explore', goal: 'Find cutoff eligible literature', id: 'a3', index: 0 })] + }) + $sessionAgents.set({ + a1: item({ goal: 'Read the notes', id: 'a1', index: 0 }), + t1: item({ + agentType: 'math-fl-formalizer', + goal: 'Pin environment encode statement', + id: 't1', + index: 0, + name: 'fl-formalizer', + status: 'running' + }) + }) + + const out = renderToString( {}} t={DEFAULT_THEME} />) + + expect(out).toMatch(/✓ Explore · Find cutoff eligible literature/) + expect(out).toMatch(/● fl-formalizer · Pin environment encode statement/) + // A finished agent from an earlier turn belongs to that turn's archive. + expect(out).not.toContain('Read the notes') + }) +}) diff --git a/ui-tui/src/__tests__/createGatewayEventHandler.test.ts b/ui-tui/src/__tests__/createGatewayEventHandler.test.ts index 32dcf9338..5abb00ce0 100644 --- a/ui-tui/src/__tests__/createGatewayEventHandler.test.ts +++ b/ui-tui/src/__tests__/createGatewayEventHandler.test.ts @@ -3,9 +3,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { createGatewayEventHandler } from '../app/createGatewayEventHandler.js' import { getOverlayState, patchOverlayState, resetOverlayState } from '../app/overlayStore.js' import { turnController } from '../app/turnController.js' -import { getTurnState, resetTurnState } from '../app/turnStore.js' +import { $sessionAgents, getTurnState, resetTurnState } from '../app/turnStore.js' import { getUiState, patchUiState, resetUiState } from '../app/uiStore.js' import { LINK_TIP_TEXT, resetLinkTipForTests } from '../lib/linkAffordance.js' +import { withCarriedAgents } from '../lib/subagentTree.js' import { estimateTokensRough } from '../lib/text.js' import type { Msg } from '../types.js' @@ -64,6 +65,8 @@ describe('createGatewayEventHandler', () => { resetUiState() resetTurnState() turnController.fullReset() + // fullReset() keeps the session roster (/clear keeps teammates running). + turnController.forgetSessionAgents() patchUiState({ showReasoning: true }) }) @@ -939,6 +942,97 @@ describe('createGatewayEventHandler', () => { expect(getTurnState().activity).toMatchObject([{ text: 'boom', tone: 'error' }]) }) + it('keeps a teammate that outlives its spawning turn in the session roster', () => { + const appended: Msg[] = [] + const onEvent = createGatewayEventHandler(buildCtx(appended)) + + const teammate = { + goal: 'Pin environment encode statement', + name: 'fl-formalizer', + subagent_id: 't1', + subagent_type: 'math-fl-formalizer', + task_index: 0 + } + + // Turn 1 spawns a persistent teammate and a sync subagent that finishes. + onEvent({ payload: {}, type: 'message.start' } as any) + onEvent({ payload: teammate, type: 'subagent.start' } as any) + onEvent({ payload: { goal: 'Read the notes', subagent_id: 'a2', task_index: 1 }, type: 'subagent.start' } as any) + onEvent({ + payload: { goal: 'Read the notes', status: 'completed', subagent_id: 'a2', task_index: 1 }, + type: 'subagent.complete' + } as any) + // An agent stopped mid-run (ESC, an overlay kill) ends as `interrupted`. + onEvent({ + payload: { goal: 'Search the archive', subagent_id: 'x1', task_index: 2 }, + type: 'subagent.start' + } as any) + onEvent({ + payload: { goal: 'Search the archive', status: 'interrupted', subagent_id: 'x1', task_index: 2 }, + type: 'subagent.complete' + } as any) + onEvent({ payload: { text: 'team started' }, type: 'message.complete' } as any) + + // Turn 2: the turn-scoped list starts empty (the idle layout depends on + // that), and the teammate's next frame is update-only there. + onEvent({ payload: {}, type: 'message.start' } as any) + onEvent({ + payload: { ...teammate, text: 'Idle; waiting for another assignment' }, + type: 'subagent.progress' + } as any) + + expect(getTurnState().subagents).toEqual([]) + + // The roster still carries the running teammate, fresh, and has dropped + // the finished and the interrupted subagents from turn 1. + const roster = $sessionAgents.get() + + expect(Object.keys(roster)).toEqual(['t1']) + expect(roster.t1?.name).toBe('fl-formalizer') + expect(roster.t1?.status).toBe('running') + expect(roster.t1?.notes).toContain('Idle; waiting for another assignment') + expect(withCarriedAgents(getTurnState().subagents, roster).map(s => s.id)).toEqual(['t1']) + + // /clear, /new and resume reset the view but not the single backend + // session, whose teammates keep running; only a backend exit ends them. + turnController.fullReset() + expect(Object.keys($sessionAgents.get())).toEqual(['t1']) + turnController.forgetSessionAgents() + expect($sessionAgents.get()).toEqual({}) + }) + + it('keeps a subagent name and agent type across later partial events', () => { + const appended: Msg[] = [] + const onEvent = createGatewayEventHandler(buildCtx(appended)) + + onEvent({ + payload: { + goal: 'Map informal proof obligations', + name: 'nl-sketcher', + subagent_id: 'sa-named', + subagent_type: 'math-nl-sketcher', + task_index: 0 + }, + type: 'subagent.start' + } as any) + // A later frame without the identity must not wipe it. + onEvent({ + payload: { + goal: 'Map informal proof obligations', + subagent_id: 'sa-named', + task_index: 0, + text: 'reading notes' + }, + type: 'subagent.progress' + } as any) + + const item = getTurnState().subagents.find(s => s.id === 'sa-named') + + expect(item?.name).toBe('nl-sketcher') + expect(item?.agentType).toBe('math-nl-sketcher') + expect(item?.goal).toBe('Map informal proof obligations') + }) + it('accepts timeout/error subagent terminal statuses and ignores stale live events', () => { const appended: Msg[] = [] const onEvent = createGatewayEventHandler(buildCtx(appended)) diff --git a/ui-tui/src/__tests__/gatewayClient.test.ts b/ui-tui/src/__tests__/gatewayClient.test.ts index 0a0b22f4c..2d33534af 100644 --- a/ui-tui/src/__tests__/gatewayClient.test.ts +++ b/ui-tui/src/__tests__/gatewayClient.test.ts @@ -1083,6 +1083,32 @@ describe('GatewayClient NDJSON adapter', () => { expect(last('subagent.complete').payload.status).toBe('completed') }) + it('ends a run on any non-live status, including interrupted', async () => { + // ESC / an overlay kill, a killed teammate and an aborted workflow agent + // all end as `interrupted`; only completed/failed/killed used to publish + // subagent.complete, so those rows stayed `running` for good. + const base = { agent_id: 'a4', description: 'search the archive', type: 'agent_progress' } + proc.line({ ...base, activity: 'Grep(foo)', status: 'running' }) + await vi.waitFor(() => expect(last('subagent.progress')?.payload.subagent_id).toBe('a4')) + proc.line({ ...base, activity: 'Exited', status: 'interrupted' }) + await vi.waitFor(() => expect(last('subagent.complete')?.payload.subagent_id).toBe('a4')) + expect(last('subagent.complete').payload.status).toBe('interrupted') + }) + + it('keeps the agent name and type apart from the task description', async () => { + // `name` used to reach the TUI only as the goal's fallback — and the + // Agent tool requires a description, so it never did. + proc.line({ + activity: 'reading notes', agent_id: 'a3', description: 'Map informal proof obligations', + name: 'nl-sketcher', status: 'running', subagent_type: 'math-nl-sketcher', type: 'agent_progress' + }) + await vi.waitFor(() => expect(last('subagent.start')?.payload.subagent_id).toBe('a3')) + const p = last('subagent.start').payload + expect(p.goal).toBe('Map informal proof obligations') + expect(p.name).toBe('nl-sketcher') + expect(p.subagent_type).toBe('math-nl-sketcher') + }) + // ch13 round-4 — permission "always allow" persistence (item 1) it('forwards a can_use_tool suggestion as a persistable approval option', async () => { proc.line({ diff --git a/ui-tui/src/__tests__/spawnHistoryStore.test.ts b/ui-tui/src/__tests__/spawnHistoryStore.test.ts index 544280e5c..9f94c18f3 100644 --- a/ui-tui/src/__tests__/spawnHistoryStore.test.ts +++ b/ui-tui/src/__tests__/spawnHistoryStore.test.ts @@ -27,6 +27,33 @@ describe('spawnHistoryStore status normalization', () => { expect(statuses).toEqual(['timeout', 'error']) }) + it('keeps agent name and type from disk snapshots', () => { + pushDiskSnapshot( + { + finished_at: 1_700_000_001, + label: 'names', + session_id: 'sess-1', + started_at: 1_700_000_000, + subagents: [ + { + agentType: 'math-nl-sketcher', + goal: 'Map informal proof obligations', + id: 'sa-1', + index: 0, + name: 'nl-sketcher', + status: 'completed' + } + ] + }, + '/tmp/snap-names.json' + ) + + const s = getSpawnHistory()[0]?.subagents[0] + + expect(s?.name).toBe('nl-sketcher') + expect(s?.agentType).toBe('math-nl-sketcher') + }) + it('falls back unknown disk statuses to completed', () => { pushDiskSnapshot( { diff --git a/ui-tui/src/__tests__/subagentTree.test.ts b/ui-tui/src/__tests__/subagentTree.test.ts index 863646a8e..de37619c2 100644 --- a/ui-tui/src/__tests__/subagentTree.test.ts +++ b/ui-tui/src/__tests__/subagentTree.test.ts @@ -9,11 +9,16 @@ import { fmtTokens, formatSummary, hotnessBucket, + isSubagentAlive, peakHotness, sparkline, + subagentAgentLabel, + subagentIdentity, + subagentTitle, topLevelSubagents, treeTotals, - widthByDepth + widthByDepth, + withCarriedAgents } from '../lib/subagentTree.js' import type { SubagentProgress } from '../types.js' @@ -405,3 +410,69 @@ describe('topLevelSubagents', () => { expect(topLevelSubagents(items).map(s => s.id)).toEqual(['a', 'orphan']) }) }) + +describe('subagentIdentity + subagentTitle + subagentAgentLabel', () => { + it('prefers the spawn name, then a non-default agent type', () => { + expect(subagentIdentity({ agentType: 'math-nl-sketcher', name: 'nl-sketcher' })).toBe('nl-sketcher') + expect(subagentIdentity({ agentType: 'Explore' })).toBe('Explore') + expect(subagentIdentity({})).toBe('') + }) + + it('hides the default types every unnamed spawn runs', () => { + for (const agentType of ['general-purpose', 'worker', 'fork']) { + expect(subagentIdentity({ agentType })).toBe('') + } + + // A name still wins over a default type. + expect(subagentIdentity({ agentType: 'general-purpose', name: 'alice' })).toBe('alice') + }) + + it('titles as identity · goal, and keeps the bare goal without one', () => { + expect(subagentTitle({ agentType: 'Explore', goal: 'Find cutoff literature' })).toBe( + 'Explore · Find cutoff literature' + ) + expect(subagentTitle({ agentType: 'general-purpose', goal: 'Fix the bug' })).toBe('Fix the bug') + expect(subagentTitle({ goal: 'Legacy row' })).toBe('Legacy row') + expect(subagentTitle({ goal: '' }, 'Subagent 2')).toBe('Subagent 2') + }) + + it('labels the detail pane with the name and the raw type', () => { + expect(subagentAgentLabel({ agentType: 'math-nl-sketcher', name: 'nl-sketcher' })).toBe( + 'nl-sketcher (math-nl-sketcher)' + ) + expect(subagentAgentLabel({ agentType: 'general-purpose' })).toBe('general-purpose') + expect(subagentAgentLabel({ agentType: 'Explore', name: 'Explore' })).toBe('Explore') + expect(subagentAgentLabel({})).toBe('') + }) +}) + +describe('withCarriedAgents', () => { + it('appends running agents the turn no longer holds, and nothing else', () => { + const turn = [makeItem({ id: 'a3', index: 0 })] + + const session = { + a3: makeItem({ id: 'a3', index: 0, status: 'completed' }), + q1: makeItem({ id: 'q1', index: 0, status: 'queued' }), + t1: makeItem({ id: 't1', index: 0, status: 'running' }), + x1: makeItem({ id: 'x1', index: 0, status: 'interrupted' }) + } + + // In-turn rows win over their roster copy; finished roster agents stay out. + expect(withCarriedAgents(turn, session).map(s => s.id)).toEqual(['a3', 'q1', 't1']) + }) + + it('returns the turn list itself when nothing is carried', () => { + const turn = [makeItem({ id: 'a1', index: 0 })] + + expect(withCarriedAgents(turn, { a1: turn[0]! })).toBe(turn) + }) + + it('treats only running and queued as alive', () => { + expect(['running', 'queued'].every(s => isSubagentAlive(s as SubagentProgress['status']))).toBe(true) + expect( + ['completed', 'error', 'failed', 'interrupted', 'timeout'].some(s => + isSubagentAlive(s as SubagentProgress['status']) + ) + ).toBe(false) + }) +}) diff --git a/ui-tui/src/app/spawnHistoryStore.ts b/ui-tui/src/app/spawnHistoryStore.ts index ec3614840..61aa1be98 100644 --- a/ui-tui/src/app/spawnHistoryStore.ts +++ b/ui-tui/src/app/spawnHistoryStore.ts @@ -130,6 +130,7 @@ function normaliseSubagent(raw: unknown): SubagentProgress { const arr = (v: unknown): T[] | undefined => (Array.isArray(v) ? (v as T[]) : undefined) return { + agentType: s(o.agentType), apiCalls: n(o.apiCalls), costUsd: n(o.costUsd), depth: typeof o.depth === 'number' ? o.depth : 0, @@ -142,6 +143,7 @@ function normaliseSubagent(raw: unknown): SubagentProgress { inputTokens: n(o.inputTokens), iteration: n(o.iteration), model: s(o.model), + name: s(o.name), notes: (arr(o.notes) ?? []).filter(x => typeof x === 'string'), outputTail: arr(o.outputTail) as SubagentProgress['outputTail'], outputTokens: n(o.outputTokens), diff --git a/ui-tui/src/app/turnController.ts b/ui-tui/src/app/turnController.ts index c5cf230f5..caa5224a3 100644 --- a/ui-tui/src/app/turnController.ts +++ b/ui-tui/src/app/turnController.ts @@ -9,6 +9,7 @@ import type { SessionInterruptResponse, StructuredDiffPayload, SubagentEventPayl import { ensureHighlighter } from '../lib/colorDiff.js' import { appendToolShelfMessage, isToolShelfMessage } from '../lib/liveProgress.js' import { hasReasoningTag, splitReasoning } from '../lib/reasoning.js' +import { isSubagentAlive } from '../lib/subagentTree.js' import { boundedLiveRenderText, buildToolTrailLine, @@ -24,7 +25,7 @@ import type { ActiveTool, ActivityItem, Msg, MsgDiffData, SubagentProgress, Todo import type { Notice } from './interfaces.js' import { getOverlayState, resetFlowOverlays } from './overlayStore.js' import { pushSnapshot } from './spawnHistoryStore.js' -import { archiveDoneTodos, getTurnState, patchTurnState, resetTurnState } from './turnStore.js' +import { $sessionAgents, archiveDoneTodos, getTurnState, patchTurnState, resetTurnState } from './turnStore.js' import { getUiState, patchUiState } from './uiStore.js' const INTERRUPT_COOLDOWN_MS = 1500 @@ -1106,6 +1107,13 @@ class TurnController { resetTurnState() } + // The backend process died, and every agent it ran died with it. Not part + // of reset()/fullReset(): /clear, /new and resume reach those too, while + // the single agent-server session — and its teammates — keep running. + forgetSessionAgents() { + $sessionAgents.set({}) + } + scheduleReasoning() { if (this.reasoningTimer) { return @@ -1177,6 +1185,11 @@ class TurnController { tools: [], turnTrail: [] }) + // Finished agents went into their turn's archived spawn tree; the roster + // only needs to carry the ones still running into this turn. + $sessionAgents.set( + Object.fromEntries(Object.entries($sessionAgents.get()).filter(([, agent]) => isSubagentAlive(agent.status))) + ) } upsertSubagent( @@ -1189,6 +1202,13 @@ class TurnController { // for older gateways that omit the field — those produce a flat list. const id = p.subagent_id || `sa:${p.task_index}:${p.goal || 'subagent'}` + // The session roster takes every event, even one the turn drops below: a + // persistent teammate or background agent keeps working in later turns, + // after its row left the turn-scoped list, and would otherwise vanish + // from the agents overlay while it is still alive. + const agents = $sessionAgents.get() + $sessionAgents.set({ ...agents, [id]: this.mergeSubagentEvent(agents[id], id, p, patch) }) + patchTurnState(state => { const existing = state.subagents.find(item => item.id === id) @@ -1200,54 +1220,7 @@ class TurnController { return state } - const base: SubagentProgress = existing ?? { - depth: p.depth ?? 0, - goal: p.goal, - id, - index: p.task_index, - model: p.model, - notes: [], - parentId: p.parent_id ?? null, - startedAt: Date.now(), - status: 'running', - taskCount: p.task_count ?? 1, - thinking: [], - toolCount: p.tool_count ?? 0, - tools: [], - toolsets: p.toolsets - } - - // Map snake_case payload keys onto camelCase state. Only overwrite - // when the event actually carries the field; `??` preserves prior - // values across streaming events that emit partial payloads. - const outputTail = p.output_tail - ? p.output_tail.map(e => ({ - isError: Boolean(e.is_error), - preview: String(e.preview ?? ''), - tool: String(e.tool ?? 'tool') - })) - : base.outputTail - - const next: SubagentProgress = { - ...base, - apiCalls: p.api_calls ?? base.apiCalls, - costUsd: p.cost_usd ?? base.costUsd, - depth: p.depth ?? base.depth, - filesRead: p.files_read ?? base.filesRead, - filesWritten: p.files_written ?? base.filesWritten, - goal: p.goal || base.goal, - inputTokens: p.input_tokens ?? base.inputTokens, - iteration: p.iteration ?? base.iteration, - model: p.model ?? base.model, - outputTail, - outputTokens: p.output_tokens ?? base.outputTokens, - parentId: p.parent_id ?? base.parentId, - reasoningTokens: p.reasoning_tokens ?? base.reasoningTokens, - taskCount: p.task_count ?? base.taskCount, - toolCount: p.tool_count ?? base.toolCount, - toolsets: p.toolsets ?? base.toolsets, - ...patch(base) - } + const next = this.mergeSubagentEvent(existing, id, p, patch) // Stable order: by spawn (depth, parent, index) rather than insert time. // Without it, grandchildren can shuffle relative to siblings when @@ -1259,6 +1232,66 @@ class TurnController { return { ...state, subagents } }) } + + private mergeSubagentEvent( + existing: SubagentProgress | undefined, + id: string, + p: SubagentEventPayload, + patch: (current: SubagentProgress) => Partial + ): SubagentProgress { + const base: SubagentProgress = existing ?? { + agentType: p.subagent_type, + depth: p.depth ?? 0, + goal: p.goal, + id, + index: p.task_index, + model: p.model, + name: p.name, + notes: [], + parentId: p.parent_id ?? null, + startedAt: Date.now(), + status: 'running', + taskCount: p.task_count ?? 1, + thinking: [], + toolCount: p.tool_count ?? 0, + tools: [], + toolsets: p.toolsets + } + + // Map snake_case payload keys onto camelCase state. Only overwrite + // when the event actually carries the field; `??` preserves prior + // values across streaming events that emit partial payloads. + const outputTail = p.output_tail + ? p.output_tail.map(e => ({ + isError: Boolean(e.is_error), + preview: String(e.preview ?? ''), + tool: String(e.tool ?? 'tool') + })) + : base.outputTail + + return { + ...base, + agentType: p.subagent_type ?? base.agentType, + apiCalls: p.api_calls ?? base.apiCalls, + costUsd: p.cost_usd ?? base.costUsd, + depth: p.depth ?? base.depth, + filesRead: p.files_read ?? base.filesRead, + filesWritten: p.files_written ?? base.filesWritten, + goal: p.goal || base.goal, + inputTokens: p.input_tokens ?? base.inputTokens, + iteration: p.iteration ?? base.iteration, + model: p.model ?? base.model, + name: p.name ?? base.name, + outputTail, + outputTokens: p.output_tokens ?? base.outputTokens, + parentId: p.parent_id ?? base.parentId, + reasoningTokens: p.reasoning_tokens ?? base.reasoningTokens, + taskCount: p.task_count ?? base.taskCount, + toolCount: p.tool_count ?? base.toolCount, + toolsets: p.toolsets ?? base.toolsets, + ...patch(base) + } + } } export const turnController = new TurnController() diff --git a/ui-tui/src/app/turnStore.ts b/ui-tui/src/app/turnStore.ts index d2122409d..7a36432b8 100644 --- a/ui-tui/src/app/turnStore.ts +++ b/ui-tui/src/app/turnStore.ts @@ -26,6 +26,14 @@ const buildTurnState = (): TurnState => ({ export const $turnState = atom(buildTurnState()) +// Session-scoped counterpart of `subagents`: the latest state of every agent +// announced this session, by id. Turn state is turn-scoped on purpose — +// startMessage() and idle() clear `subagents`, and the idle layout keys off +// it — but a persistent teammate or a background agent outlives the turn that +// spawned it. The agents overlay merges the ones still running back in from +// here (withCarriedAgents). +export const $sessionAgents = atom>>({}) + export const getTurnState = () => $turnState.get() const subscribeTurn = (cb: () => void) => $turnState.listen(() => cb()) diff --git a/ui-tui/src/app/useMainApp.ts b/ui-tui/src/app/useMainApp.ts index c66bd7609..7aad77181 100644 --- a/ui-tui/src/app/useMainApp.ts +++ b/ui-tui/src/app/useMainApp.ts @@ -959,6 +959,7 @@ export function useMainApp(gw: GatewayClient) { const exitHandler = () => { turnController.reset() + turnController.forgetSessionAgents() // A still-owned child dying while the TUI is alive is an *unexpected* // death — a user /quit exits Node before this fires, and a replaced child diff --git a/ui-tui/src/components/agentsOverlay.tsx b/ui-tui/src/components/agentsOverlay.tsx index 3996baf1d..430f26b68 100644 --- a/ui-tui/src/components/agentsOverlay.tsx +++ b/ui-tui/src/components/agentsOverlay.tsx @@ -10,7 +10,7 @@ import { } from '../app/delegationStore.js' import { patchOverlayState } from '../app/overlayStore.js' import { $spawnDiff, $spawnHistory, clearDiffPair, type SpawnSnapshot } from '../app/spawnHistoryStore.js' -import { useTurnSelector } from '../app/turnStore.js' +import { $sessionAgents, useTurnSelector } from '../app/turnStore.js' import { INLINE_MODE } from '../config/env.js' import type { GatewayClient } from '../gatewayClient.js' import type { DelegationPauseResponse, DelegationStatusResponse, SubagentInterruptResponse } from '../gatewayTypes.js' @@ -25,9 +25,13 @@ import { hotnessBucket, peakHotness, sparkline, + subagentAgentLabel, + subagentIdentity, + subagentTitle, topLevelSubagents, treeTotals, - widthByDepth + widthByDepth, + withCarriedAgents } from '../lib/subagentTree.js' import { compactPreview } from '../lib/text.js' import type { Theme } from '../theme.js' @@ -430,6 +434,7 @@ function Detail({ id, node, t }: { id?: string; node: SubagentNode; t: Theme }) + {subagentAgentLabel(item) ? : null} {item.model ? : null} {item.toolsets?.length ? : null} @@ -545,7 +550,9 @@ function ListRow({ const heatIdx = hotnessBucket(node.aggregate.hotness, peak, palette.length) const heatMarker = heatIdx >= 2 ? palette[heatIdx]! : null - const goal = compactPreview(node.item.goal || 'subagent', width - 28 - node.item.depth * 2) + const identity = compactPreview(subagentIdentity(node.item), 24) + const goalBudget = width - 28 - node.item.depth * 2 - (identity ? identity.length + 3 : 0) + const goal = compactPreview(node.item.goal || 'subagent', Math.max(8, goalBudget)) const toolsCount = node.aggregate.totalTools > 0 ? ` ·${node.aggregate.totalTools}t` : '' const kids = node.children.length ? ` ·${node.children.length}↓` : '' const line = node.item.status === 'running' ? node.item.tools.at(-1) : undefined @@ -560,7 +567,14 @@ function ListRow({ {formatRowId(index)} {indentFor(node.item.depth)} {heatMarker ? ▍ : null} - {glyph} {goal} + {glyph}{' '} + {identity ? ( + <> + {identity} + · + + ) : null} + {goal} {toolsCount} {kids} @@ -607,7 +621,7 @@ function DiffPane({ return ( - {glyph} {s.goal || 'subagent'} + {glyph} {subagentTitle(s)} ) })} @@ -679,7 +693,11 @@ function DiffView({ // ── Main overlay ───────────────────────────────────────────────────── export function AgentsOverlay({ gw, initialHistoryIndex = 0, onClose, t }: AgentsOverlayProps) { - const liveSubagents = useTurnSelector(state => state.subagents) + const turnSubagents = useTurnSelector(state => state.subagents) + const sessionAgents = useStore($sessionAgents) + // Teammates and background agents outlive the turn that spawned them, and + // the turn-scoped list forgets them at the next turn boundary. + const liveSubagents = useMemo(() => withCarriedAgents(turnSubagents, sessionAgents), [turnSubagents, sessionAgents]) const delegation = useStore($delegationState) const history = useStore($spawnHistory) const diffPair = useStore($spawnDiff) diff --git a/ui-tui/src/components/thinking.tsx b/ui-tui/src/components/thinking.tsx index ade112035..bbe606062 100644 --- a/ui-tui/src/components/thinking.tsx +++ b/ui-tui/src/components/thinking.tsx @@ -12,6 +12,7 @@ import { hotnessBucket, peakHotness, sparkline, + subagentTitle, treeTotals, widthByDepth } from '../lib/subagentTree.js' @@ -387,7 +388,7 @@ function SubagentAccordion({ : 'dim' const prefix = item.taskCount > 1 ? `[${item.index + 1}/${item.taskCount}] ` : '' - const goalLabel = item.goal || `Subagent ${item.index + 1}` + const goalLabel = subagentTitle(item, `Subagent ${item.index + 1}`) const title = `${prefix}${open ? goalLabel : compactPreview(goalLabel, 60)}` const summary = compactPreview((item.summary || '').replace(/\s+/g, ' ').trim(), 72) diff --git a/ui-tui/src/gatewayClient.ts b/ui-tui/src/gatewayClient.ts index da7babc6a..2f3a31679 100644 --- a/ui-tui/src/gatewayClient.ts +++ b/ui-tui/src/gatewayClient.ts @@ -2572,14 +2572,20 @@ export class GatewayClient extends EventEmitter { if (aid) { const payload: any = { depth: msg.depth ?? 0, - goal: msg.description || msg.name || 'subagent', + goal: msg.description || 'subagent', // The model the subagent actually runs on. Without this the // agents overlay falls back to 'inherit' — which the // per-provider default-subagent-model change makes actively // wrong (spawns default to e.g. claude-haiku-4-5 now). model: msg.model, + // Who the agent IS, kept apart from `goal` (the task + // description). Folding `name` into `goal` only as a fallback + // lost it on every spawn — the Agent tool requires a + // description — so named teammates and custom agent types + // showed up in the agents overlay as bare task text. + name: msg.name || undefined, subagent_id: aid, - subagent_type: msg.subagent_type + subagent_type: msg.subagent_type || undefined } if (!this.seenSubagents.has(aid)) { @@ -2601,7 +2607,12 @@ export class GatewayClient extends EventEmitter { const status = String(msg.status ?? '') - if (status === 'completed' || status === 'failed' || status === 'killed') { + // Every status but a live one ends the run — including the + // `interrupted` the backend reports for an ESC or overlay kill, a + // killed teammate, and an aborted workflow agent. Publishing only + // completed/failed/killed left those rows `running`, and the + // session roster would carry them into every later turn. + if (status && status !== 'running' && status !== 'queued') { this.publish({ payload: { ...payload, status }, type: 'subagent.complete' }) } } diff --git a/ui-tui/src/gatewayTypes.ts b/ui-tui/src/gatewayTypes.ts index f395af276..6ad0dd101 100644 --- a/ui-tui/src/gatewayTypes.ts +++ b/ui-tui/src/gatewayTypes.ts @@ -663,12 +663,14 @@ export interface SubagentEventPayload { input_tokens?: number iteration?: number model?: string + name?: string output_tail?: { is_error?: boolean; preview?: string; tool?: string }[] output_tokens?: number parent_id?: null | string reasoning_tokens?: number status?: SubagentStatus subagent_id?: string + subagent_type?: string summary?: string task_count?: number task_index: number diff --git a/ui-tui/src/lib/subagentTree.ts b/ui-tui/src/lib/subagentTree.ts index 3770bd200..e161f2eed 100644 --- a/ui-tui/src/lib/subagentTree.ts +++ b/ui-tui/src/lib/subagentTree.ts @@ -308,6 +308,67 @@ export function fmtDuration(seconds: number): string { return s === 0 ? `${m}m` : `${m}m ${s}s` } +// The types every unnamed spawn runs say nothing about the agent, so labels +// hide them — the reference's userFacingName (AgentTool/UI.tsx:648) does the +// same for general-purpose and worker. The detail pane still shows the type. +const DEFAULT_AGENT_TYPES = new Set(['fork', 'general-purpose', 'worker']) + +/** + * Who a subagent is, for labels: the addressable name the spawn gave it (a + * teammate's `name`), else the agent definition it runs (`Explore`, a custom + * `.clawcodex/agents/.md`). Empty for a default type, and when neither + * is known (older gateways carry only the goal). + */ +export function subagentIdentity(item: Pick): string { + if (item.name) { + return item.name + } + + return item.agentType && !DEFAULT_AGENT_TYPES.has(item.agentType) ? item.agentType : '' +} + +/** `identity · goal` for single-string labels; the bare goal without one. */ +export function subagentTitle( + item: Pick, + fallback = 'subagent' +): string { + const goal = item.goal || fallback + const identity = subagentIdentity(item) + + return identity ? `${identity} · ${goal}` : goal +} + +/** Still working (or waiting for a slot) — not finished in any way. */ +export function isSubagentAlive(status: SubagentProgress['status']): boolean { + return status === 'queued' || status === 'running' +} + +/** + * The agents overlay's live list: this turn's subagents, plus agents from + * earlier turns that are still running (persistent teammates, background + * agents) but that the turn-scoped list no longer holds. + */ +export function withCarriedAgents( + turn: SubagentProgress[], + session: Readonly> +): SubagentProgress[] { + const inTurn = new Set(turn.map(s => s.id)) + const carried = Object.values(session).filter(s => !inTurn.has(s.id) && isSubagentAlive(s.status)) + + return carried.length ? [...turn, ...carried] : turn +} + +/** The detail pane's `agent` field: name and type (`nl-sketcher (math-nl-sketcher)`), or whichever is known. */ +export function subagentAgentLabel(item: Pick): string { + const { agentType, name } = item + + if (name && agentType && agentType !== name) { + return `${name} (${agentType})` + } + + return name || agentType || '' +} + /** * A subagent is top-level if it has no `parentId`, or its parent isn't in * the same snapshot (orphaned by a pruned mid-flight root). Same rule diff --git a/ui-tui/src/types.ts b/ui-tui/src/types.ts index bbd5181f7..4815bf816 100644 --- a/ui-tui/src/types.ts +++ b/ui-tui/src/types.ts @@ -26,6 +26,10 @@ export interface ActivityItem { export type SubagentStatus = 'completed' | 'error' | 'failed' | 'interrupted' | 'queued' | 'running' | 'timeout' export interface SubagentProgress { + // Which agent this is: the definition it runs (`Explore`, a custom + // `.clawcodex/agents/.md`) and the addressable name the spawn gave it + // (a teammate's `name`). `goal` is only the task description. + agentType?: string apiCalls?: number costUsd?: number depth: number @@ -38,6 +42,7 @@ export interface SubagentProgress { inputTokens?: number iteration?: number model?: string + name?: string notes: string[] outputTail?: SubagentOutputEntry[] outputTokens?: number