diff --git a/docs/claude-mod.md b/docs/claude-mod.md index 01147b7a8..5203af21d 100644 --- a/docs/claude-mod.md +++ b/docs/claude-mod.md @@ -15,10 +15,23 @@ cleans up the files when Claude exits. The same entry point routes Agent/Task calls in a native `tool.call` hook. It pipes the call as JSON to `ug claude-router-hook route-subagent-mod`, whose argument list arrives in the `routeCommand` plugin option, and continues the call with the returned input. The -matching Agent row shows a spinner and elapsed seconds while the router answers, then the -selected model, routing time, and rationale. Results live in memory by `tool_use_id`, so a -plugin reload drops them. Disabled routing, command failures, and invalid responses keep -the original call; failures also log a diagnostic. +matching Agent row shows a spinner and elapsed seconds while the router answers. Claude +hides that row once a second Agent call joins it, so while any call is routing, the turn's +spinner line also reads `Selecting a model… Ns` or `Selecting models for N agents… Ns`, +timed from the oldest waiting call; Claude's own glyph and counters stay. +A lone call's decision is drawn under its Agent row: `↳ Selected Model:` in green with its +routing time, then the indented `Reason:`. Claude folds parallel Agent rows into its own +"Running N agents" block, which raises no render hook, so calls that were routing at the same +time are drawn instead as cards (bold task, selected model, rationale) above the parent's +next reply, in call order. A reply is claimed by its message id on its first block drawn after +routing; the message that issued the calls is drawn before routing finishes and is never used, +a call still routing waits for a later reply, and a call whose routing failed draws no card. +Cards survive a turn that ends while the reply waits on a background agent and are dropped +only when the turn is aborted. Nothing is drawn above the prompt and nothing is logged. +Decisions are kept for the session; a plugin reload clears them from the screen, and the +routing decision audit log is unchanged. +Disabled routing, command failures, and invalid responses keep the original call; +failures also log a diagnostic. The old routing PreToolUse hook is no longer written, so a call is not routed twice. A `route-subagent` hook that an earlier UG version persisted still gets its PreToolUse diff --git a/src/ucode/agents/claude_mods/register.ts b/src/ucode/agents/claude_mods/register.ts index 8cf646767..4a939389e 100644 --- a/src/ucode/agents/claude_mods/register.ts +++ b/src/ucode/agents/claude_mods/register.ts @@ -1,6 +1,6 @@ import type { EngineInterface, On, PluginOptions } from 'claude-code'; -import { registerStatusBand, showStatusBand } from './smart-routing-status.ts'; -import { registerSubagentRouting } from './subagent-routing.ts'; +import { endStatusBandTurn, registerStatusBand, showStatusBand } from './smart-routing-status.ts'; +import { endSubagentRoutingTurn, registerSubagentRouting } from './subagent-routing.ts'; import { CLI_AGENT, CLI_MODULE, @@ -131,4 +131,10 @@ export function register(on: On, options: PluginOptions): void { }); registerStatusBand(on); registerSubagentRouting(on, options); + // Claude allows one unmatched turn.complete handler per plugin, so both modules share it. + on('turn.complete', ($, e, next) => { + endSubagentRoutingTurn(e.isAborted); + if (endStatusBandTurn()) $.ui.invalidate('ui.render'); + return next(e); + }); } diff --git a/src/ucode/agents/claude_mods/smart-routing-status.ts b/src/ucode/agents/claude_mods/smart-routing-status.ts index e7ab2040b..94ec8ab56 100644 --- a/src/ucode/agents/claude_mods/smart-routing-status.ts +++ b/src/ucode/agents/claude_mods/smart-routing-status.ts @@ -13,6 +13,15 @@ export function showStatusBand(): void { band.isHideArmed = false } +// Hides the band at the end of an armed turn; true when the band changed and needs a redraw. +// The register module owns the one turn.complete handler, which the routing cards share. +export function endStatusBandTurn(): boolean { + if (!band.isHideArmed) return false + band.isVisible = false + band.isHideArmed = false + return true +} + type RoutingState = { enabled: boolean; firstPrompt: boolean } // Claude's capability scanner requires literal names in $.env.get calls. @@ -50,15 +59,6 @@ export const registerStatusBand = (on: On): void => { return next(e) }) - on('turn.complete', ($, e, next) => { - if (band.isHideArmed) { - band.isVisible = false - band.isHideArmed = false - $.ui.invalidate('ui.render') - } - return next(e) - }) - // The prompt hint row always carries the state as a plain tail, so it stays visible after // the band hides and flips as soon as a toggle succeeds. on('ui.render', { component: 'PromptHint' }, async ($, e, next) => { diff --git a/src/ucode/agents/claude_mods/subagent-routing.ts b/src/ucode/agents/claude_mods/subagent-routing.ts index 8442cf0b1..5546c9d28 100644 --- a/src/ucode/agents/claude_mods/subagent-routing.ts +++ b/src/ucode/agents/claude_mods/subagent-routing.ts @@ -1,6 +1,6 @@ import type { On, PluginOptions } from 'claude-code' -type RoutingRecord = { model: string; rationale: string; seconds?: number } +type RoutingRecord = { task: string; model: string; rationale: string; seconds?: number } const shortModel = (model: string): string => model.replace(/^system\.ai\./, '').replace(/^databricks-/, '') // Claude's own spinner glyphs, played forwards then back as Claude does. @@ -21,10 +21,62 @@ const secondsSince = async ($: any, start: number | undefined): Promise { + const { Box, Text } = $.ui.resolve(e) + const lines = [Box({ + flexDirection: 'row', + children: [ + Text({ dimColor: true, children: [`${arrow}Selected Model: `] }), + Text({ color: 'green', bold: true, children: [shortModel(record.model)] }), + ...(record.seconds === undefined ? [] : [ + Text({ dimColor: true, children: [` (routed in ${record.seconds.toFixed(1)}s)`] }), + ]), + ], + })] + if (record.rationale) { + lines.push(Box({ + paddingLeft: reasonPadding, + children: [Text({ children: [Text({ dimColor: true, children: ['Reason: '] }), record.rationale] })], + })) + } + return lines +} + +// A grouped call's decision: its task, then the selection. +const card = ($: any, e: any, record: RoutingRecord) => { + const { Box, Text } = $.ui.resolve(e) + return Box({ + flexDirection: 'column', + paddingLeft: 2, + marginBottom: 1, + children: [Text({ bold: true, children: [record.task] }), ...selection($, e, record, '↳ ', 2)], + }) +} + +// Grouped calls not yet drawn, in call order. They live outside registration so the +// register module's one turn.complete handler can drop them. +const cards = { unplaced: [] as string[] } + +// An aborted turn draws no reply, so its calls must not reach a later turn. A turn that +// merely ends, as when the reply waits on a background agent, keeps them. +export function endSubagentRoutingTurn(aborted: boolean): void { + if (aborted) cards.unplaced = [] +} + export const registerSubagentRouting = (on: On, options: PluginOptions): void => { - const records = new Map() // Calls waiting on the router, by when routing started, shown with a spinner and timer. const pending = new Map() + // Claude folds parallel Agent rows into its own block, which no hook reaches, so a call + // that was routing alongside another is drawn as a card above the parent's next reply. + // A lone call keeps its decision under its own row. + const grouped = new Set() + const records = new Map() + // The reply each batch is drawn above. + const anchors = new Map() + // Replies already drawn before a batch was routed are never its anchor. + const seen = new Set() on('tool.call', { tool: ['Agent', 'Task'] }, async ($, e, next) => { const command = options.routeCommand as readonly string[] @@ -33,6 +85,18 @@ export const registerSubagentRouting = (on: On, options: PluginOptions): void => let route let spinner: { cancel: () => void } | undefined if (tool_use_id) { + // Parallel calls start together, so one still routing means this call is not alone. + if (pending.size) { + for (const id of pending.keys()) { + if (!grouped.has(id)) { + grouped.add(id) + cards.unplaced.push(id) + } + } + grouped.add(tool_use_id) + cards.unplaced.push(tool_use_id) + } + pending.set(tool_use_id, undefined) pending.set(tool_use_id, await now($)) // Mods have no global timers; $.clock redraws the spinner while the router answers. // The frame comes from the clock, so parallel calls do not speed it up. @@ -62,10 +126,17 @@ export const registerSubagentRouting = (on: On, options: PluginOptions): void => } finally { spinner?.cancel() } + const seconds = tool_use_id ? await secondsSince($, pending.get(tool_use_id)) : undefined if (tool_use_id) { - const seconds = await secondsSince($, pending.get(tool_use_id)) pending.delete(tool_use_id) - if (route) records.set(tool_use_id, { model: route.model, rationale: route.rationale, seconds }) + if (route) { + records.set(tool_use_id, { + task: input.description || input.subagent_type || tool, + model: shortModel(route.model), + rationale: route.rationale, + seconds, + }) + } $.ui.invalidate('ui.render') } if (!route) return next(e) @@ -92,27 +163,35 @@ export const registerSubagentRouting = (on: On, options: PluginOptions): void => ], })] }) } - // Claude's row can name only the agent type, so the routed model is always shown. - const children = [original, Box({ - flexDirection: 'row', - children: [ - // Labels recede so the routed model stands out. - Text({ dimColor: true, children: [' ↳ Selected Model: '] }), - // The same green as the status band's ON pill. - Text({ color: 'green', bold: true, children: [shortModel(record.model)] }), - // How long routing took; omitted when no clock was available. - ...(record.seconds === undefined - ? [] - : [Text({ dimColor: true, children: [` (routed in ${record.seconds.toFixed(1)}s)`] })]), - ], - })] - // Align the reason, including its wrapped lines, under "Selected Model" after the arrow. - if (record.rationale) { - children.push(Box({ - paddingLeft: 4, - children: [Text({ children: [Text({ dimColor: true, children: ['Reason: '] }), record.rationale] })], - })) + if (grouped.has(e.requestId)) return original + // Claude's row can name only the agent type, so the routed model is always shown, + // with the reason aligned under "Selected Model" after the arrow. + return Box({ flexDirection: 'column', children: [original, ...selection($, e, record, ' ↳ ', 4)] }) + }) + + // Claude's parallel-agent block hides the row spinner, so the turn's own spinner line + // also says routing is under way, timed from the oldest call still waiting. + on('ui.render', { component: 'Spinner' }, async ($, e, next) => { + if (!pending.size) return next(e) + const starts = [...pending.values()].filter((start) => start !== undefined) as number[] + const seconds = starts.length ? await secondsSince($, Math.min(...starts)) : undefined + const elapsed = seconds === undefined ? '' : ` ${Math.floor(seconds)}s` + const what = pending.size > 1 ? `Selecting models for ${pending.size} agents` : 'Selecting a model' + return next({ ...e, props: { ...e.props, message: `${what}…${elapsed}`, suffix: '' } }) + }) + + on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => { + const original = await next(e) + if (!seen.has(e.requestId)) { + seen.add(e.requestId) + // A call still routing waits for a later reply; one that failed routing is dropped. + const ready = cards.unplaced.filter((id) => records.has(id)) + cards.unplaced = cards.unplaced.filter((id) => pending.has(id)) + if (ready.length) anchors.set(e.requestId, ready) } - return Box({ flexDirection: 'column', children }) + const ids = anchors.get(e.requestId) + if (!ids) return original + const { Box } = $.ui.resolve(e) + return Box({ flexDirection: 'column', children: [...ids.map((id) => card($, e, records.get(id)!)), original] }) }) } diff --git a/tests/README.md b/tests/README.md index 77eed653f..b37896cca 100644 --- a/tests/README.md +++ b/tests/README.md @@ -43,6 +43,14 @@ TypeScript event-forwarding and launch-option tests in CI. The existing Claude skill-toggle integration journey checks the live recipe payload field across on/off/on. See [the mod guide](../docs/claude-mod.md). +Native mod component tests cover a lone call's decision under its Agent row, parallel calls +drawn as cards above the parent's next reply in call order (replies drawn before routing +never used, calls still routing waiting for a later reply, cards kept across a turn that +ends while a background agent runs and dropped on abort), distinct IDs for identical tasks, +nothing above the prompt, pending row and turn-spinner routing timers, and disabled/failed +routing. They do not establish live +parallel-agent TUI or gateway coverage. + `test_ug_headers.py` covers the `UG_HEADERS` parser and the `--header` flag, `test_databricks.py` ug's own requests carrying them, and `test_agent_codex.py` `UG_HEADERS` reaching a Codex launch's `-c` headers (plain and smart-routed) without writing its profile. No automated test checks the diff --git a/tests/integration/README.md b/tests/integration/README.md index 4a074bfbe..270f0ad93 100644 --- a/tests/integration/README.md +++ b/tests/integration/README.md @@ -1,5 +1,11 @@ # Integration tests +Native mod component tests in `../native_claude/test_mod.py` cover a lone call's decision under +its Agent row, parallel calls as cards above the parent's next reply in call order, calls +still routing waiting for a later reply, distinct +IDs for identical tasks, pending row and turn-spinner routing timers, and disabled/failed routing. These are not live +parallel-agent TUI or gateway checks. + `../test_claude_launch_settings.py` covers oversized Windows Claude launch settings with controlled process boundaries: UTF-16 command length, merged settings, literal prompts, temporary-file lifetime and cleanup, launch failures, diff --git a/tests/native_claude/mod_tests/subagent-routing.test.ts b/tests/native_claude/mod_tests/subagent-routing.test.ts index 6b8612af8..9f05c4c21 100644 --- a/tests/native_claude/mod_tests/subagent-routing.test.ts +++ b/tests/native_claude/mod_tests/subagent-routing.test.ts @@ -2,18 +2,24 @@ import { expect, test } from 'claude-code/testing'; const command = ['/bin/ug', 'claude-router-hook', 'route-subagent-mod']; const originalInput = { prompt: 'inspect the parser', description: 'Inspect parser', subagent_type: 'Explore', model: 'sonnet' }; - +const panel = { + plugin: 'ug-smart-router', component: 'AbovePrompt', surface: 'terminal', + requestId: 'routing-panel', viewport: { columns: 100, rows: 30 }, props: {}, +} as const; const row = (id: string) => ({ - plugin: 'ug-smart-router', - component: 'ToolUse' as const, - surface: 'terminal' as const, - requestId: id, - viewport: { columns: 100, rows: 30 }, + ...panel, component: 'ToolUse' as const, requestId: id, props: { tool_use_id: id, tool: 'Agent', input: originalInput, isRunning: true, isErrored: false, isInterrupted: false }, }); - -test('routing preserves distinct results for identical tasks and forwards each call once', async ($, on) => { +// One block of the parent's reply: Claude calls the hook once per block, and every block of +// a message shares its requestId. +const reply = (id: string) => ({ + ...panel, component: 'AssistantMessage' as const, requestId: id, + props: { text: 'reply', isFirstOfReply: true }, +}); +test('a lone call keeps its decision under its own row and draws no card', async ($, on) => { const calls: any[] = []; + const logs: string[] = []; + on('ui.log', ($, e) => { logs.push(e.text); return { value: undefined }; }); on('session.id', () => ({ value: 'session-1' })); on('process.run', ($, e) => { expect(e.argv).toEqual(command); @@ -37,23 +43,169 @@ test('routing preserves distinct results for identical tasks and forwards each c expect(calls[0].subagent_type).toBe('ug-smart-router:call-a'); expect(calls[0].model).toBeUndefined(); expect(calls[1].subagent_type).toBe('ug-smart-router:call-b'); + expect(logs).toEqual([]); - const a = await $.ui.mount(row('call-a')); - expect(await a.find({ type: 'Text', text: 'original Agent row' })).toBeDefined(); - expect(await a.find({ type: 'Text', text: ' ↳ Selected Model: ' })).toBeDefined(); - expect(await a.find({ type: 'Text', text: 'claude-sonnet-5' })).toBeDefined(); - expect(await a.find({ type: 'Text', text: 'Reason: ' })).toBeDefined(); - expect(await a.find({ type: 'Text', text: /Selected for call-a/ })).toBeDefined(); - expect(await a.find({ type: 'Text', text: 'claude-opus-4-8' })).toBeUndefined(); - // UG's routed agent name only repeats the model, and the requested type is never shown. - expect(await a.find({ type: 'Text', text: /ug-smart-router:/ })).toBeUndefined(); - expect(await a.find({ type: 'Text', text: /Explore/ })).toBeUndefined(); - await a.unmount(); - const b = await $.ui.mount(row('call-b')); - expect(await b.find({ type: 'Text', text: 'claude-opus-4-8' })).toBeDefined(); - expect(await b.find({ type: 'Text', text: /Selected for call-b/ })).toBeDefined(); - expect(await b.find({ type: 'Text', text: /ug-smart-router:/ })).toBeUndefined(); - await b.unmount(); + for (const [id, model] of [['call-a', 'claude-sonnet-5'], ['call-b', 'claude-opus-4-8']]) { + const done = await $.ui.mount(row(id)); + expect(await done.find({ type: 'Text', text: 'original Agent row' })).toBeDefined(); + expect(await done.find({ type: 'Text', text: ' ↳ Selected Model: ' })).toBeDefined(); + expect(await done.find({ type: 'Text', text: model })).toBeDefined(); + expect(await done.find({ type: 'Text', text: new RegExp(`Selected for ${id}`) })).toBeDefined(); + expect(await done.find({ type: 'Text', text: /ug-smart-router:|�/ })).toBeUndefined(); + expect(JSON.stringify(await done.drawn())).toContain('"color":"green"'); + await done.unmount(); + } + // Each reply block, whatever its message, draws no card for a call its row already shows. + for (const block of [reply('first'), reply('first'), reply('second')]) { + const ui = await $.ui.mount(block); + expect(await ui.find({ type: 'Text', text: /Selected Model/ })).toBeUndefined(); + await ui.unmount(); + } + const prompt = await $.ui.mount(panel); + expect(await prompt.find({ type: 'Text', text: /Selected Model|claude-/ })).toBeUndefined(); + await prompt.unmount(); +}); + +test('parallel cards keep call order, skip replies drawn earlier, and wait for routes still running', async ($, on) => { + const releases = new Map void>(); + let started: () => void = () => {}; + const ready = new Promise((resolve) => { started = resolve; }); + const events: string[] = []; + on('clock.now', () => ({ value: 10_000 })); + on('session.id', () => ({ value: 'session-1' })); + on('process.run', async ($, e) => { + const { tool_use_id, tool_input } = JSON.parse(e.init.stdin); + await new Promise((resolve) => { + releases.set(tool_use_id, resolve); + if (releases.size === 3) started(); + }); + return { value: { exitCode: 0, stderr: '', stdout: JSON.stringify({ + model: tool_use_id === 'call-a' ? 'system.ai.claude-sonnet-5' : 'system.ai.claude-opus-4-8', + rationale: `Selected for ${tool_input.description}`, + updatedInput: { ...tool_input, subagent_type: `ug-smart-router:${tool_use_id}` }, + }) } }; + }); + on('tool.call', ($, e) => { + expect(e.run_in_background).toBe(true); + events.push(`start ${e.tool_use_id}`); + return { result: 'ok' }; + }); + on('ui.render', () => ({ type: 'Text', props: {}, children: ['other row'] })); + const input = { ...originalInput, run_in_background: true }; + const before = await $.ui.mount(reply('before')); + await before.unmount(); + const first = $.tool.call({ tool: 'Agent', tool_use_id: 'call-a', ...input, description: 'Compute 1+1 (agent A)' }); + const second = $.tool.call({ tool: 'Task', tool_use_id: 'call-b', ...input, description: 'Compute 1+1 (agent B)' }); + const third = $.tool.call({ tool: 'Agent', tool_use_id: 'call-c', ...input, description: 'Compute 1+1 (agent C)' }); + await ready; + releases.get('call-b')!(); + await second; + releases.get('call-a')!(); + await first; + expect(events).toEqual(['start call-b', 'start call-a']); + + // A reply drawn before routing is never an anchor. + const old = await $.ui.mount(reply('before')); + expect(await old.find({ type: 'Text', text: /Selected Model/ })).toBeUndefined(); + await old.unmount(); + const cards = await $.ui.mount(reply('after')); + const tree = JSON.stringify(await cards.drawn()); + expect(tree.indexOf('Compute 1+1 (agent A)')).toBeGreaterThan(-1); + expect(tree.indexOf('Compute 1+1 (agent A)')).toBeLessThan(tree.indexOf('Compute 1+1 (agent B)')); + expect(tree.indexOf('Compute 1+1 (agent B)')).toBeLessThan(tree.indexOf('other row')); + expect(tree).not.toContain('agent C'); + expect(await cards.find({ type: 'Text', text: 'claude-sonnet-5' })).toBeDefined(); + expect(await cards.find({ type: 'Text', text: 'claude-opus-4-8' })).toBeDefined(); + await cards.unmount(); + + releases.get('call-c')!(); + await third; + const next = await $.ui.mount(reply('next')); + expect(await next.find({ type: 'Text', text: 'Compute 1+1 (agent C)' })).toBeDefined(); + expect(await next.find({ type: 'Text', text: 'Compute 1+1 (agent A)' })).toBeUndefined(); + await next.unmount(); + const prompt = await $.ui.mount(panel); + expect(await prompt.find({ type: 'Text', text: /Selected Model|claude-/ })).toBeUndefined(); + await prompt.unmount(); +}); + +test('an aborted turn leaves no card for the next turn, but a finished one keeps them for the reply', async ($, on) => { + const gates = new Map void>(); + let started: () => void = () => {}; + const bothStarted = new Promise((resolve) => { started = resolve; }); + on('session.id', () => ({ value: 'session-1' })); + on('process.run', async ($, e) => { + const { tool_use_id, tool_input } = JSON.parse(e.init.stdin); + await new Promise((resolve) => { + gates.set(tool_use_id, resolve); + if (gates.size === 2) started(); + }); + return { value: { exitCode: 0, stderr: '', stdout: JSON.stringify({ + model: 'system.ai.claude-sonnet-5', rationale: `Selected for ${tool_use_id}`, + updatedInput: { ...tool_input, subagent_type: `ug-smart-router:${tool_use_id}` }, + }) } }; + }); + on('tool.call', () => ({ result: 'ok' })); + on('prompt.submit', ($, e) => ({ text: e.text })); + on('turn.complete', () => ({ text: '' })); + on('ui.render', () => ({ type: 'Text', props: {}, children: ['other row'] })); + const parallel = async (ids: string[]) => { + gates.clear() + const calls = ids.map((id) => $.tool.call({ tool: 'Agent', tool_use_id: id, ...originalInput, description: `Inspect ${id}` })); + await bothStarted; + for (const id of ids) gates.get(id)!(); + await Promise.all(calls); + }; + + // The user interrupts the turn after its parallel calls were routed. + await $.prompt.submit({ text: 'inspect the parser' }); + await parallel(['call-a', 'call-b']); + await $.turn.complete({ answer: '', isAborted: true }); + await $.prompt.submit({ text: 'something unrelated' }); + const unrelated = await $.ui.mount(reply('unrelated')); + expect(await unrelated.find({ type: 'Text', text: /Selected Model|call-a|call-b/ })).toBeUndefined(); + await unrelated.unmount(); +}); + +test('a turn that ends while the reply waits on a background agent keeps its cards for that reply', async ($, on) => { + const gates = new Map void>(); + let started: () => void = () => {}; + const bothStarted = new Promise((resolve) => { started = resolve; }); + on('session.id', () => ({ value: 'session-1' })); + on('process.run', async ($, e) => { + const { tool_use_id, tool_input } = JSON.parse(e.init.stdin); + await new Promise((resolve) => { + gates.set(tool_use_id, resolve); + if (gates.size === 2) started(); + }); + return { value: { exitCode: 0, stderr: '', stdout: JSON.stringify({ + model: 'system.ai.claude-sonnet-5', rationale: `Selected for ${tool_use_id}`, + updatedInput: { ...tool_input, subagent_type: `ug-smart-router:${tool_use_id}` }, + }) } }; + }); + on('tool.call', () => ({ result: 'ok' })); + on('turn.complete', () => ({ text: '' })); + on('ui.render', () => ({ type: 'Text', props: {}, children: ['other row'] })); + + // The message that issued the calls is drawn before they are routed, in two blocks. + for (let block = 0; block < 2; block++) { + const issuing = await $.ui.mount(reply('issuing')); + await issuing.unmount(); + } + const calls = ['call-a', 'call-b'].map((id) => $.tool.call({ tool: 'Agent', tool_use_id: id, ...originalInput, run_in_background: true, description: `Inspect ${id}` })); + await bothStarted; + for (const gate of gates.values()) gate(); + await Promise.all(calls); + await $.turn.complete({ answer: 'The subagents are running.', isAborted: false }); + + const issuing = await $.ui.mount(reply('issuing')); + expect(await issuing.find({ type: 'Text', text: /Selected Model/ })).toBeUndefined(); + await issuing.unmount(); + const final = await $.ui.mount(reply('final')); + expect(await final.findAll({ type: 'Text', text: 'claude-sonnet-5' })).toHaveLength(2); + expect(await final.find({ type: 'Text', text: 'Inspect call-a' })).toBeDefined(); + expect(await final.find({ type: 'Text', text: 'Inspect call-b' })).toBeDefined(); + await final.unmount(); }); for (const [name, result, warningCount] of [ @@ -71,22 +223,22 @@ for (const [name, result, warningCount] of [ on('ui.log', ($, e) => { warnings.push(e.text); return { value: undefined }; }); on('tool.call', ($, e) => { calls.push(e); return { deny: 'user policy' }; }); on('ui.render', () => ({ type: 'Text', props: {}, children: ['original Agent row'] })); - const answer = await $.tool.call({ tool: 'Agent', tool_use_id: 'call-a', ...originalInput }); expect(answer).toEqual({ deny: 'user policy' }); expect(calls.length).toBe(1); expect(calls[0].subagent_type).toBe(originalInput.subagent_type); expect(calls[0].model).toBe(originalInput.model); expect(warnings.length).toBe(warningCount); - const ui = await $.ui.mount(row('call-a')); - expect(await ui.find({ type: 'Text', text: 'original Agent row' })).toBeDefined(); - expect(await ui.find({ type: 'Text', text: /Reason:/ })).toBeUndefined(); - expect(await ui.find({ type: 'Text', text: /Selected Model/ })).toBeUndefined(); - await ui.unmount(); + for (const mount of [row('call-a'), reply('after')]) { + const ui = await $.ui.mount(mount); + expect(await ui.find({ type: 'Text', text: 'original Agent row' })).toBeDefined(); + expect(await ui.find({ type: 'Text', text: /Reason:|Selected Model/ })).toBeUndefined(); + await ui.unmount(); + } }); } -test('a call waiting on the router shows a spinner until its model is selected', async ($, on) => { +test('a waiting call shows a spinner on its row and its card keeps the routing time', async ($, on) => { let release: (value: unknown) => void = () => {}; const routed = new Promise((resolve) => { release = resolve; }); let now = 10_000; @@ -95,21 +247,22 @@ test('a call waiting on the router shows a spinner until its model is selected', on('process.run', async () => { await routed; return { value: { exitCode: 0, stderr: '', stdout: JSON.stringify({ - model: 'system.ai.claude-sonnet-5', rationale: 'Selected for call-a', - updatedInput: { prompt: originalInput.prompt, subagent_type: 'ug-smart-router:call-a' }, + model: 'system.ai.claude-sonnet-5', rationale: 'Small task', + updatedInput: { prompt: originalInput.prompt, subagent_type: 'ug-smart-router:haiku' }, }) } }; }); on('tool.call', () => ({ result: 'ok' })); - on('ui.render', () => ({ type: 'Text', props: {}, children: ['original Agent row'] })); - + let seenSpinner: any; + on('ui.render', ($, e) => { + if (e.component !== 'Spinner') return { type: 'Text', props: {}, children: ['original Agent row'] }; + seenSpinner = e.props; + return { type: 'Text', props: {}, children: [e.props.message ?? e.props.word] }; + }); const call = $.tool.call({ tool: 'Agent', tool_use_id: 'call-a', ...originalInput }); const waiting = await $.ui.mount(row('call-a')); expect(await waiting.find({ type: 'Text', text: 'Selecting a model… 0s' })).toBeDefined(); - expect(await waiting.find({ type: 'Text', text: /Selected Model/ })).toBeUndefined(); await waiting.unmount(); - // The timer counts whole seconds since routing started. now += 2_500; - // A parallel call starts its own ticker, but both rows show the frame the clock gives. const parallel = $.tool.call({ tool: 'Agent', tool_use_id: 'call-b', ...originalInput }); for (const id of ['call-a', 'call-b']) { const later = await $.ui.mount(row(id)); @@ -117,15 +270,24 @@ test('a call waiting on the router shows a spinner until its model is selected', if (id === 'call-a') expect(await later.find({ type: 'Text', text: 'Selecting a model… 2s' })).toBeDefined(); await later.unmount(); } - + // The turn's spinner line counts the calls still routing, timed from the oldest. + const spinner = { ...panel, component: 'Spinner' as const, requestId: 'spinner', props: { word: 'Genieing', message: null, suffix: '…', mode: 'tool-use' as const } }; + const line = await $.ui.mount(spinner); + expect(await line.find({ type: 'Text', text: 'Selecting models for 2 agents… 2s' })).toBeDefined(); + expect(seenSpinner.suffix).toBe(''); + await line.unmount(); now += 700; release(undefined); await call; await parallel; const done = await $.ui.mount(row('call-a')); - expect(await done.find({ type: 'Text', text: /Selecting a model/ })).toBeUndefined(); - expect(await done.find({ type: 'Text', text: 'claude-sonnet-5' })).toBeDefined(); - // The selected row keeps how long routing took. - expect(await done.find({ type: 'Text', text: ' (routed in 3.2s)' })).toBeDefined(); + expect(await done.find({ type: 'Text', text: /Selecting a model|Selected Model/ })).toBeUndefined(); await done.unmount(); + const idle = await $.ui.mount(spinner); + expect(await idle.find({ type: 'Text', text: 'Genieing' })).toBeDefined(); + await idle.unmount(); + const cards = await $.ui.mount(reply('after')); + expect(await cards.find({ type: 'Text', text: 'claude-sonnet-5' })).toBeDefined(); + expect(await cards.find({ type: 'Text', text: ' (routed in 3.2s)' })).toBeDefined(); + await cards.unmount(); });