diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b0f5e82..6a6e9437 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 78729505..5e6fc8f4 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 02cfc31c..d64bc002 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 00000000..3896b4ca --- /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 32dcf933..5abb00ce 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 0a0b22f4..2d33534a 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 544280e5..9f94c18f 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 863646a8..de37619c 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 ec361484..61aa1be9 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 c5cf230f..caa5224a 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 d2122409..7a36432b 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 c66bd760..7aad7718 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 3996baf1..430f26b6 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 ade11203..bbe60606 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 da7babc6..2f3a3167 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 f395af27..6ad0dd10 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 3770bd20..e161f2ee 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 bbd5181f..4815bf81 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