Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
17 changes: 11 additions & 6 deletions src/tool_system/tools/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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]

Expand Down Expand Up @@ -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,
)
Expand Down Expand Up @@ -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,
Expand Down
51 changes: 51 additions & 0 deletions tests/test_r5_ch13_terminal_progress_round5.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
146 changes: 146 additions & 0 deletions ui-tui/src/__tests__/agentsOverlayIdentity.test.tsx
Original file line number Diff line number Diff line change
@@ -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<SubagentProgress> & Pick<SubagentProgress, 'goal' | 'id' | 'index'>
): 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(<AgentsOverlay gw={gw} onClose={() => {}} 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(<AgentsOverlay gw={gw} onClose={() => {}} 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')
})
})
96 changes: 95 additions & 1 deletion ui-tui/src/__tests__/createGatewayEventHandler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -64,6 +65,8 @@ describe('createGatewayEventHandler', () => {
resetUiState()
resetTurnState()
turnController.fullReset()
// fullReset() keeps the session roster (/clear keeps teammates running).
turnController.forgetSessionAgents()
patchUiState({ showReasoning: true })
})

Expand Down Expand Up @@ -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))
Expand Down
Loading
Loading