From c9fb24dc4df4c110b8d70f81a1543a955bd2df03 Mon Sep 17 00:00:00 2001 From: Lilly Date: Fri, 23 Oct 2026 22:57:40 +0000 Subject: [PATCH 1/6] Preserve Claude routing annotations for parallel subagents --- docs/claude-mod.md | 3 + .../agents/claude_mods/subagent-routing.ts | 71 ++++++++-------- tests/README.md | 4 + tests/integration/README.md | 5 ++ .../mod_tests/subagent-routing.test.ts | 80 +++++++++++++++++++ 5 files changed, 130 insertions(+), 33 deletions(-) diff --git a/docs/claude-mod.md b/docs/claude-mod.md index 01147b7a8..f93b1afd9 100644 --- a/docs/claude-mod.md +++ b/docs/claude-mod.md @@ -20,6 +20,9 @@ selected model, routing time, and rationale. Results live in memory by `tool_use plugin reload drops them. Disabled routing, command failures, and invalid responses keep the original call; failures also log a diagnostic. +Collapsed parallel-agent groups show each task's selected model and rationale, including +when routing completes out of order. Expanded groups use the individual tool annotations. + 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 response and plain-text notice. diff --git a/src/ucode/agents/claude_mods/subagent-routing.ts b/src/ucode/agents/claude_mods/subagent-routing.ts index 8442cf0b1..f180d3c74 100644 --- a/src/ucode/agents/claude_mods/subagent-routing.ts +++ b/src/ucode/agents/claude_mods/subagent-routing.ts @@ -73,46 +73,51 @@ export const registerSubagentRouting = (on: On, options: PluginOptions): void => return next({ ...route.updatedInput, tool, tool_use_id, agentId }) }) - on('ui.render', { component: 'ToolUse' }, async ($, e, next) => { + on('ui.render', { component: ['ToolUse', 'ToolGroup'] }, async ($, e, next) => { const original = await next(e) - const record = records.get(e.requestId) - if (!record && !pending.has(e.requestId)) return original + const grouped = e.component === 'ToolGroup' + if (grouped && e.props.isExpanded) return original + const calls = grouped ? e.props.calls : [e.props] const { Box, Text } = $.ui.resolve(e) - if (!record) { - const glyph = SPINNER[Math.floor(((await now($)) ?? 0) / SPINNER_INTERVAL_MS) % SPINNER.length] - const seconds = await secondsSince($, pending.get(e.requestId)) - const elapsed = seconds === undefined ? '' : ` ${Math.floor(seconds)}s` - return Box({ flexDirection: 'column', children: [original, Box({ + const children = [original] + for (const call of calls) { + const record = records.get(call.tool_use_id) + if (!record && !pending.has(call.tool_use_id)) continue + if (grouped) { + children.push(Text({ bold: true, children: [` ${call.input.description || call.input.subagent_type || call.tool}`] })) + } + if (!record) { + const glyph = SPINNER[Math.floor(((await now($)) ?? 0) / SPINNER_INTERVAL_MS) % SPINNER.length] + const seconds = await secondsSince($, pending.get(call.tool_use_id)) + const elapsed = seconds === undefined ? '' : ` ${Math.floor(seconds)}s` + children.push(Box({ + flexDirection: 'row', + children: [ + Text({ dimColor: true, children: [' ↳ '] }), + Text({ color: 'green', children: [`${glyph} `] }), + Text({ dimColor: true, children: [`Selecting a model…${elapsed}`] }), + ], + })) + continue + } + children.push(Box({ flexDirection: 'row', children: [ - Text({ dimColor: true, children: [' ↳ '] }), - Text({ color: 'green', children: [`${glyph} `] }), - Text({ dimColor: true, children: [`Selecting a model…${elapsed}`] }), + Text({ dimColor: true, children: [' ↳ 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)`] })]), ], - })] }) - } - // 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 (record.rationale) { + children.push(Box({ + paddingLeft: 4, + children: [Text({ children: [Text({ dimColor: true, children: ['Reason: '] }), record.rationale] })], + })) + } } - return Box({ flexDirection: 'column', children }) + return children.length === 1 ? original : Box({ flexDirection: 'column', children }) }) } diff --git a/tests/README.md b/tests/README.md index 46384cfe6..f53bd165c 100644 --- a/tests/README.md +++ b/tests/README.md @@ -43,6 +43,10 @@ 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 collapsed parallel-agent groups with task-labelled models +and rationales, out-of-order routing, pending/completed states, and expanded/unrouted +passthrough. These render-event checks do not establish live 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 9e1deca02..c61c29be4 100644 --- a/tests/integration/README.md +++ b/tests/integration/README.md @@ -1,5 +1,10 @@ # Integration tests +Native mod component tests in `../native_claude/test_mod.py` cover task-labelled model +and rationale annotations in collapsed parallel-agent groups, out-of-order routing, +pending/completed states, and expanded/unrouted passthrough. These are render-event +checks, not live parallel-agent TUI or gateway coverage. + The spend-status client's workspace authentication, optional percentage handling, pagination, validation, and failure propagation are covered by component tests in `../test_budget.py`, importing the client from `src/ucode/budget.py`. diff --git a/tests/native_claude/mod_tests/subagent-routing.test.ts b/tests/native_claude/mod_tests/subagent-routing.test.ts index 6b8612af8..19474e36d 100644 --- a/tests/native_claude/mod_tests/subagent-routing.test.ts +++ b/tests/native_claude/mod_tests/subagent-routing.test.ts @@ -56,6 +56,86 @@ test('routing preserves distinct results for identical tasks and forwards each c await b.unmount(); }); +test('collapsed parallel agents retain task-labelled routing while finishing out of order', async ($, on) => { + const releases = new Map void>(); + const calls = ['call-a', 'call-b', 'call-unrouted'].map((id) => ({ + ...row(id).props, + input: { ...originalInput, description: `Task ${id}` }, + })); + const group = { + plugin: 'ug-smart-router', + component: 'ToolGroup' as const, + surface: 'terminal' as const, + requestId: 'group-not-a-tool-id', + viewport: { columns: 100, rows: 30 }, + props: { calls, isExpanded: false, isActive: true, isCallsOnly: true }, + }; + on('clock.now', () => ({ value: 10_000 })); + on('session.id', () => ({ value: 'session-1' })); + on('process.run', async ($, e) => { + const { tool_use_id } = JSON.parse(e.init.stdin); + await new Promise((resolve) => { releases.set(tool_use_id, resolve); }); + 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_use_id}`, + updatedInput: { ...originalInput, subagent_type: `ug-smart-router:${tool_use_id}` }, + }) } }; + }); + on('tool.call', () => ({ result: 'ok' })); + on('ui.render', () => ({ type: 'Text', props: {}, children: ['original group'] })); + + const first = $.tool.call({ tool: 'Agent', tool_use_id: 'call-a', ...originalInput }); + const second = $.tool.call({ tool: 'Task', tool_use_id: 'call-b', ...originalInput }); + const pending = await $.ui.mount(group); + expect(await pending.find({ type: 'Text', text: ' Task call-a' })).toBeDefined(); + expect(await pending.find({ type: 'Text', text: ' Task call-b' })).toBeDefined(); + expect(await pending.find({ type: 'Text', text: /Selecting a model/ })).toBeDefined(); + await pending.unmount(); + + releases.get('call-b')!(); + await second; + const partial = await $.ui.mount(group); + expect(await partial.find({ type: 'Text', text: /Selecting a model/ })).toBeDefined(); + expect(await partial.find({ type: 'Text', text: 'claude-opus-4-8' })).toBeDefined(); + expect(await partial.find({ type: 'Text', text: /Selected for call-b/ })).toBeDefined(); + expect(await partial.find({ type: 'Text', text: 'claude-sonnet-5' })).toBeUndefined(); + await partial.unmount(); + + releases.get('call-a')!(); + await first; + const done = await $.ui.mount({ ...group, props: { ...group.props, isActive: false } }); + expect(await done.find({ type: 'Text', text: 'original group' })).toBeDefined(); + for (const id of ['call-a', 'call-b']) { + expect(await done.find({ type: 'Text', text: ` Task ${id}` })).toBeDefined(); + expect(await done.find({ type: 'Text', text: `Selected for ${id}` })).toBeDefined(); + } + expect(await done.find({ type: 'Text', text: 'claude-sonnet-5' })).toBeDefined(); + expect(await done.find({ type: 'Text', text: 'claude-opus-4-8' })).toBeDefined(); + expect(await done.find({ type: 'Text', text: /Selecting a model|Task call-unrouted/ })).toBeUndefined(); + await done.unmount(); + + for (const [index, model, otherModel] of [ + [0, 'claude-sonnet-5', 'claude-opus-4-8'], + [1, 'claude-opus-4-8', 'claude-sonnet-5'], + ] as const) { + const isolated = await $.ui.mount({ ...group, props: { ...group.props, calls: [calls[index]] } }); + expect(await isolated.find({ type: 'Text', text: model })).toBeDefined(); + expect(await isolated.find({ type: 'Text', text: `Selected for ${calls[index].tool_use_id}` })).toBeDefined(); + expect(await isolated.find({ type: 'Text', text: otherModel })).toBeUndefined(); + await isolated.unmount(); + } + + for (const props of [ + { ...group.props, isExpanded: true }, + { ...group.props, calls: [calls[2]] }, + ]) { + const unchanged = await $.ui.mount({ ...group, props }); + expect(await unchanged.find({ type: 'Text', text: 'original group' })).toBeDefined(); + expect(await unchanged.find({ type: 'Text', text: /Selected Model|Selected for/ })).toBeUndefined(); + await unchanged.unmount(); + } +}); + for (const [name, result, warningCount] of [ ['disabled', { value: { exitCode: 0, stdout: '', stderr: '' } }, 0], ['command failure', { value: { exitCode: 1, stdout: '', stderr: 'failure' } }, 1], From 9401140af444427c08419c18b0beaed1eb5f9434 Mon Sep 17 00:00:00 2001 From: Lilly Date: Fri, 23 Oct 2026 23:05:18 +0000 Subject: [PATCH 2/6] Emit durable Claude routing notices outside grouped renderers --- docs/claude-mod.md | 15 +- .../agents/claude_mods/subagent-routing.ts | 65 +++------ tests/README.md | 7 +- tests/integration/README.md | 6 +- .../mod_tests/subagent-routing.test.ts | 136 +++++++----------- 5 files changed, 85 insertions(+), 144 deletions(-) diff --git a/docs/claude-mod.md b/docs/claude-mod.md index f93b1afd9..391347a4a 100644 --- a/docs/claude-mod.md +++ b/docs/claude-mod.md @@ -15,13 +15,14 @@ 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. - -Collapsed parallel-agent groups show each task's selected model and rationale, including -when routing completes out of order. Expanded groups use the individual tool annotations. +matching Agent row shows a spinner and elapsed seconds while the router answers. +Each successful decision emits a transcript notice with the task description, `tool_use_id`, +selected model, routing time, and rationale before continuing the call. Claude's native +parallel-agent summary bypasses both `ToolUse` and `ToolGroup` render hooks, so completed +decisions do not depend on either renderer. Notices remain visible when background agents +are grouped or routing completes out of order, without duplicating decisions on tool rows. +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/subagent-routing.ts b/src/ucode/agents/claude_mods/subagent-routing.ts index f180d3c74..559077ecb 100644 --- a/src/ucode/agents/claude_mods/subagent-routing.ts +++ b/src/ucode/agents/claude_mods/subagent-routing.ts @@ -1,7 +1,5 @@ import type { On, PluginOptions } from 'claude-code' -type RoutingRecord = { 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. const SPINNER_GLYPHS = ['·', '✢', '✳', '✶', '✻', '✽'] @@ -22,7 +20,6 @@ const secondsSince = async ($: any, start: number | undefined): Promise { - const records = new Map() // Calls waiting on the router, by when routing started, shown with a spinner and timer. const pending = new Map() @@ -62,62 +59,34 @@ 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 }) $.ui.invalidate('ui.render') } if (!route) return next(e) + const task = `${input.description || input.subagent_type || tool}${tool_use_id ? ` [${tool_use_id}]` : ''}` + const elapsed = seconds === undefined ? '' : ` (routed in ${seconds.toFixed(1)}s)` + $.ui.log(`Smart Router: ${task}\n ↳ Selected Model: ${shortModel(route.model)}${elapsed}${route.rationale ? `\n Reason: ${route.rationale}` : ''}`) // Preserve Claude's permission checks and every other mod's tool handlers. return next({ ...route.updatedInput, tool, tool_use_id, agentId }) }) - on('ui.render', { component: ['ToolUse', 'ToolGroup'] }, async ($, e, next) => { + on('ui.render', { component: 'ToolUse' }, async ($, e, next) => { const original = await next(e) - const grouped = e.component === 'ToolGroup' - if (grouped && e.props.isExpanded) return original - const calls = grouped ? e.props.calls : [e.props] + if (!pending.has(e.requestId)) return original const { Box, Text } = $.ui.resolve(e) - const children = [original] - for (const call of calls) { - const record = records.get(call.tool_use_id) - if (!record && !pending.has(call.tool_use_id)) continue - if (grouped) { - children.push(Text({ bold: true, children: [` ${call.input.description || call.input.subagent_type || call.tool}`] })) - } - if (!record) { - const glyph = SPINNER[Math.floor(((await now($)) ?? 0) / SPINNER_INTERVAL_MS) % SPINNER.length] - const seconds = await secondsSince($, pending.get(call.tool_use_id)) - const elapsed = seconds === undefined ? '' : ` ${Math.floor(seconds)}s` - children.push(Box({ - flexDirection: 'row', - children: [ - Text({ dimColor: true, children: [' ↳ '] }), - Text({ color: 'green', children: [`${glyph} `] }), - Text({ dimColor: true, children: [`Selecting a model…${elapsed}`] }), - ], - })) - continue - } - children.push(Box({ - flexDirection: 'row', - children: [ - Text({ dimColor: true, children: [' ↳ 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) { - children.push(Box({ - paddingLeft: 4, - children: [Text({ children: [Text({ dimColor: true, children: ['Reason: '] }), record.rationale] })], - })) - } - } - return children.length === 1 ? original : Box({ flexDirection: 'column', children }) + const glyph = SPINNER[Math.floor(((await now($)) ?? 0) / SPINNER_INTERVAL_MS) % SPINNER.length] + const seconds = await secondsSince($, pending.get(e.requestId)) + const elapsed = seconds === undefined ? '' : ` ${Math.floor(seconds)}s` + return Box({ flexDirection: 'column', children: [original, Box({ + flexDirection: 'row', + children: [ + Text({ dimColor: true, children: [' ↳ '] }), + Text({ color: 'green', children: [`${glyph} `] }), + Text({ dimColor: true, children: [`Selecting a model…${elapsed}`] }), + ], + })] }) }) } diff --git a/tests/README.md b/tests/README.md index f53bd165c..2f510ef2f 100644 --- a/tests/README.md +++ b/tests/README.md @@ -43,9 +43,10 @@ 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 collapsed parallel-agent groups with task-labelled models -and rationales, out-of-order routing, pending/completed states, and expanded/unrouted -passthrough. These render-event checks do not establish live TUI or gateway coverage. +Native mod component tests cover per-task routing notices without any tool render events, +including parallel background agents finishing out of order, distinct IDs for identical +tasks, pending spinners, and disabled/failed routing. These checks 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 diff --git a/tests/integration/README.md b/tests/integration/README.md index c61c29be4..3b4eb6646 100644 --- a/tests/integration/README.md +++ b/tests/integration/README.md @@ -1,9 +1,9 @@ # Integration tests Native mod component tests in `../native_claude/test_mod.py` cover task-labelled model -and rationale annotations in collapsed parallel-agent groups, out-of-order routing, -pending/completed states, and expanded/unrouted passthrough. These are render-event -checks, not live parallel-agent TUI or gateway coverage. +and rationale notices without tool render events, parallel background routing finishing +out of order, distinct IDs for identical tasks, pending spinners, and disabled/failed +routing. These are not live parallel-agent TUI or gateway checks. The spend-status client's workspace authentication, optional percentage handling, pagination, validation, and failure propagation are covered by component tests in diff --git a/tests/native_claude/mod_tests/subagent-routing.test.ts b/tests/native_claude/mod_tests/subagent-routing.test.ts index 19474e36d..c428574b5 100644 --- a/tests/native_claude/mod_tests/subagent-routing.test.ts +++ b/tests/native_claude/mod_tests/subagent-routing.test.ts @@ -14,6 +14,8 @@ const row = (id: string) => ({ test('routing preserves distinct results for identical tasks and forwards each call once', async ($, on) => { const calls: any[] = []; + const notices: string[] = []; + on('ui.log', ($, e) => { notices.push(e.text); return { value: undefined }; }); on('session.id', () => ({ value: 'session-1' })); on('process.run', ($, e) => { expect(e.argv).toEqual(command); @@ -38,102 +40,68 @@ test('routing preserves distinct results for identical tasks and forwards each c expect(calls[0].model).toBeUndefined(); expect(calls[1].subagent_type).toBe('ug-smart-router:call-b'); - 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(); + expect(notices.length).toBe(2); + expect(notices[0]).toContain('Smart Router: Inspect parser [call-a]'); + expect(notices[0]).toContain('↳ Selected Model: claude-sonnet-5'); + expect(notices[0]).toContain('Reason: Selected for call-a'); + expect(notices[0]).not.toContain('claude-opus-4-8'); + expect(notices[1]).toContain('Smart Router: Inspect parser [call-b]'); + expect(notices[1]).toContain('↳ Selected Model: claude-opus-4-8'); + expect(notices[1]).toContain('Reason: Selected for call-b'); + const done = await $.ui.mount(row('call-a')); + expect(await done.find({ type: 'Text', text: 'original Agent row' })).toBeDefined(); + expect(await done.find({ type: 'Text', text: /Selected Model|Reason:/ })).toBeUndefined(); + await done.unmount(); }); -test('collapsed parallel agents retain task-labelled routing while finishing out of order', async ($, on) => { +test('parallel background agents emit distinct notices without any tool render events', async ($, on) => { const releases = new Map void>(); - const calls = ['call-a', 'call-b', 'call-unrouted'].map((id) => ({ - ...row(id).props, - input: { ...originalInput, description: `Task ${id}` }, - })); - const group = { - plugin: 'ug-smart-router', - component: 'ToolGroup' as const, - surface: 'terminal' as const, - requestId: 'group-not-a-tool-id', - viewport: { columns: 100, rows: 30 }, - props: { calls, isExpanded: false, isActive: true, isCallsOnly: true }, - }; + let started: () => void = () => {}; + const ready = new Promise((resolve) => { started = resolve; }); + const notices: string[] = []; + const forwarded: string[] = []; + on('ui.log', ($, e) => { notices.push(e.text); return { value: undefined }; }); on('clock.now', () => ({ value: 10_000 })); on('session.id', () => ({ value: 'session-1' })); on('process.run', async ($, e) => { - const { tool_use_id } = JSON.parse(e.init.stdin); - await new Promise((resolve) => { releases.set(tool_use_id, resolve); }); + const { tool_use_id, tool_input } = JSON.parse(e.init.stdin); + await new Promise((resolve) => { + releases.set(tool_use_id, resolve); + if (releases.size === 2) 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_use_id}`, - updatedInput: { ...originalInput, subagent_type: `ug-smart-router:${tool_use_id}` }, + updatedInput: { ...tool_input, subagent_type: `ug-smart-router:${tool_use_id}` }, }) } }; }); - on('tool.call', () => ({ result: 'ok' })); - on('ui.render', () => ({ type: 'Text', props: {}, children: ['original group'] })); - - const first = $.tool.call({ tool: 'Agent', tool_use_id: 'call-a', ...originalInput }); - const second = $.tool.call({ tool: 'Task', tool_use_id: 'call-b', ...originalInput }); - const pending = await $.ui.mount(group); - expect(await pending.find({ type: 'Text', text: ' Task call-a' })).toBeDefined(); - expect(await pending.find({ type: 'Text', text: ' Task call-b' })).toBeDefined(); - expect(await pending.find({ type: 'Text', text: /Selecting a model/ })).toBeDefined(); - await pending.unmount(); + on('tool.call', ($, e) => { + expect(notices[notices.length - 1]).toContain(`[${e.tool_use_id}]`); + expect(e.run_in_background).toBe(true); + forwarded.push(e.tool_use_id); + return { result: 'ok' }; + }); + const input = { ...originalInput, run_in_background: true }; + 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)' }); + await ready; + expect(notices.length).toBe(0); releases.get('call-b')!(); await second; - const partial = await $.ui.mount(group); - expect(await partial.find({ type: 'Text', text: /Selecting a model/ })).toBeDefined(); - expect(await partial.find({ type: 'Text', text: 'claude-opus-4-8' })).toBeDefined(); - expect(await partial.find({ type: 'Text', text: /Selected for call-b/ })).toBeDefined(); - expect(await partial.find({ type: 'Text', text: 'claude-sonnet-5' })).toBeUndefined(); - await partial.unmount(); - + expect(notices.length).toBe(1); + expect(notices[0]).toContain('Compute 1+1 (agent B) [call-b]'); + expect(notices[0]).toContain('Selected Model: claude-opus-4-8'); + expect(notices[0]).toContain('Reason: Selected for call-b'); + expect(notices[0]).not.toContain('call-a'); releases.get('call-a')!(); await first; - const done = await $.ui.mount({ ...group, props: { ...group.props, isActive: false } }); - expect(await done.find({ type: 'Text', text: 'original group' })).toBeDefined(); - for (const id of ['call-a', 'call-b']) { - expect(await done.find({ type: 'Text', text: ` Task ${id}` })).toBeDefined(); - expect(await done.find({ type: 'Text', text: `Selected for ${id}` })).toBeDefined(); - } - expect(await done.find({ type: 'Text', text: 'claude-sonnet-5' })).toBeDefined(); - expect(await done.find({ type: 'Text', text: 'claude-opus-4-8' })).toBeDefined(); - expect(await done.find({ type: 'Text', text: /Selecting a model|Task call-unrouted/ })).toBeUndefined(); - await done.unmount(); - - for (const [index, model, otherModel] of [ - [0, 'claude-sonnet-5', 'claude-opus-4-8'], - [1, 'claude-opus-4-8', 'claude-sonnet-5'], - ] as const) { - const isolated = await $.ui.mount({ ...group, props: { ...group.props, calls: [calls[index]] } }); - expect(await isolated.find({ type: 'Text', text: model })).toBeDefined(); - expect(await isolated.find({ type: 'Text', text: `Selected for ${calls[index].tool_use_id}` })).toBeDefined(); - expect(await isolated.find({ type: 'Text', text: otherModel })).toBeUndefined(); - await isolated.unmount(); - } - - for (const props of [ - { ...group.props, isExpanded: true }, - { ...group.props, calls: [calls[2]] }, - ]) { - const unchanged = await $.ui.mount({ ...group, props }); - expect(await unchanged.find({ type: 'Text', text: 'original group' })).toBeDefined(); - expect(await unchanged.find({ type: 'Text', text: /Selected Model|Selected for/ })).toBeUndefined(); - await unchanged.unmount(); - } + expect(notices.length).toBe(2); + expect(notices[1]).toContain('Compute 1+1 (agent A) [call-a]'); + expect(notices[1]).toContain('Selected Model: claude-sonnet-5'); + expect(notices[1]).toContain('Reason: Selected for call-a'); + expect(notices[1]).not.toContain('call-b'); + expect(forwarded).toEqual(['call-b', 'call-a']); }); for (const [name, result, warningCount] of [ @@ -167,6 +135,8 @@ for (const [name, result, warningCount] of [ } test('a call waiting on the router shows a spinner until its model is selected', async ($, on) => { + const notices: string[] = []; + on('ui.log', ($, e) => { notices.push(e.text); return { value: undefined }; }); let release: (value: unknown) => void = () => {}; const routed = new Promise((resolve) => { release = resolve; }); let now = 10_000; @@ -204,8 +174,8 @@ test('a call waiting on the router shows a spinner until its model is selected', 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: /Selected Model/ })).toBeUndefined(); + expect(notices.length).toBe(2); + expect(notices[0]).toContain('claude-sonnet-5 (routed in 3.2s)'); await done.unmount(); }); From 58556871d0371aae102a4152b70ae6677ad3e440 Mon Sep 17 00:00:00 2001 From: Lilly Date: Fri, 23 Oct 2026 23:24:00 +0000 Subject: [PATCH 3/6] Render Claude subagent routing decisions as clean prompt cards --- docs/claude-mod.md | 17 ++- src/ucode/agents/claude_mods/register.ts | 6 +- .../claude_mods/smart-routing-status.ts | 35 ++++- .../agents/claude_mods/subagent-routing.ts | 16 +- tests/README.md | 9 +- tests/integration/README.md | 7 +- .../mod_tests/subagent-routing.test.ts | 138 +++++++++++------- tests/native_claude/routing_preview.ts | 21 +++ tests/native_claude/test_mod.py | 61 ++++++++ 9 files changed, 239 insertions(+), 71 deletions(-) create mode 100644 tests/native_claude/routing_preview.ts diff --git a/docs/claude-mod.md b/docs/claude-mod.md index 391347a4a..9d08bfbee 100644 --- a/docs/claude-mod.md +++ b/docs/claude-mod.md @@ -16,11 +16,18 @@ The same entry point routes Agent/Task calls in a native `tool.call` hook. It pi 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. -Each successful decision emits a transcript notice with the task description, `tool_use_id`, -selected model, routing time, and rationale before continuing the call. Claude's native -parallel-agent summary bypasses both `ToolUse` and `ToolGroup` render hooks, so completed -decisions do not depend on either renderer. Notices remain visible when background agents -are grouped or routing completes out of order, without duplicating decisions on tool rows. +Successful decisions appear as task-labelled cards in the existing above-prompt panel: +bold task heading, green selected model, small routing timer, and indented, wrapping +rationale. Tool IDs remain internal. There are no successful-routing log messages, +plugin prefixes, or duplicate tool-row annotations. Claude's native parallel-agent +summary and log messages bypass the relevant transcript render hooks; the panel does +not depend on them. + +The panel retains every successful route for the current prompt, including background +agents whose routing completes out of order. Cards remain after the parent's turn ends +and clear on the next submitted prompt. A late result from an earlier prompt cannot +repopulate the new panel. Plugin reload clears the in-memory cards; the existing routing +decision audit log is unchanged. Disabled routing, command failures, and invalid responses keep the original call; failures also log a diagnostic. diff --git a/src/ucode/agents/claude_mods/register.ts b/src/ucode/agents/claude_mods/register.ts index 8cf646767..764892890 100644 --- a/src/ucode/agents/claude_mods/register.ts +++ b/src/ucode/agents/claude_mods/register.ts @@ -1,6 +1,7 @@ import type { EngineInterface, On, PluginOptions } from 'claude-code'; import { registerStatusBand, showStatusBand } from './smart-routing-status.ts'; import { registerSubagentRouting } from './subagent-routing.ts'; +import type { SubagentRoutingState } from './subagent-routing.ts'; import { CLI_AGENT, CLI_MODULE, @@ -129,6 +130,7 @@ export function register(on: On, options: PluginOptions): void { } return {text: `Smart Router is ${action}`}; }); - registerStatusBand(on); - registerSubagentRouting(on, options); + const subagents: SubagentRoutingState = {records: new Map()}; + registerSubagentRouting(on, options, subagents); + registerStatusBand(on, subagents); } diff --git a/src/ucode/agents/claude_mods/smart-routing-status.ts b/src/ucode/agents/claude_mods/smart-routing-status.ts index e7ab2040b..b8649722b 100644 --- a/src/ucode/agents/claude_mods/smart-routing-status.ts +++ b/src/ucode/agents/claude_mods/smart-routing-status.ts @@ -1,6 +1,7 @@ import type { On } from 'claude-code' import { SMART_ROUTING_KEY, SUBAGENT_ONLY_KEY } from './constants.ts' import { isRoutingEnabled } from './routing-state.ts' +import type { SubagentRoutingState } from './subagent-routing.ts' const BANNER = 'unity gateway smart router' @@ -44,8 +45,10 @@ async function readRoutingState($: any): Promise { } } -export const registerStatusBand = (on: On): void => { +export const registerStatusBand = (on: On, subagents: SubagentRoutingState): void => { on('prompt.submit', ($, e, next) => { + subagents.records = new Map() + $.ui.invalidate('ui.render') if (band.isVisible) band.isHideArmed = true return next(e) }) @@ -70,7 +73,7 @@ export const registerStatusBand = (on: On): void => { on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => { const theirs = await next(e) - if (!band.isVisible) return theirs + if (!band.isVisible && !subagents.records.size) return theirs const { Box, Text } = $.ui.resolve(e) const { enabled, firstPrompt } = await readRoutingState($) @@ -90,7 +93,33 @@ export const registerStatusBand = (on: On): void => { columnGap: 1, children: [Text({ bold: true, children: [BANNER] }), pill, trailer], }) - const children = theirs ? [line, theirs] : [line] + const children = band.isVisible ? [line] : [] + for (const record of subagents.records.values()) { + const card = [ + Text({ bold: true, children: [record.task] }), + Box({ + flexDirection: 'row', + children: [ + Text({ dimColor: true, children: ['↳ Selected Model: '] }), + Text({ bold: true, color: 'green', children: [record.model] }), + ...(record.seconds === undefined ? [] : [ + Text({ dimColor: true, children: [` · ${record.seconds.toFixed(1)}s`] }), + ]), + ], + }), + ] + if (record.rationale) { + card.push(Box({ + paddingLeft: 2, + children: [Text({ children: [ + Text({ dimColor: true, children: ['Reason: '] }), + record.rationale, + ] })], + })) + } + children.push(Box({ flexDirection: 'column', marginTop: 1, paddingLeft: 2, children: card })) + } + if (theirs) children.push(theirs) return Box({ flexDirection: 'column', children }) }) } diff --git a/src/ucode/agents/claude_mods/subagent-routing.ts b/src/ucode/agents/claude_mods/subagent-routing.ts index 559077ecb..db2ec201a 100644 --- a/src/ucode/agents/claude_mods/subagent-routing.ts +++ b/src/ucode/agents/claude_mods/subagent-routing.ts @@ -1,5 +1,9 @@ import type { On, PluginOptions } from 'claude-code' +export type SubagentRoutingState = { + records: Map +} + const shortModel = (model: string): string => model.replace(/^system\.ai\./, '').replace(/^databricks-/, '') // Claude's own spinner glyphs, played forwards then back as Claude does. const SPINNER_GLYPHS = ['·', '✢', '✳', '✶', '✻', '✽'] @@ -19,13 +23,14 @@ const secondsSince = async ($: any, start: number | undefined): Promise { +export const registerSubagentRouting = (on: On, options: PluginOptions, state: SubagentRoutingState): void => { // Calls waiting on the router, by when routing started, shown with a spinner and timer. const pending = new Map() on('tool.call', { tool: ['Agent', 'Task'] }, async ($, e, next) => { const command = options.routeCommand as readonly string[] if (!command.length) return next(e) + const records = state.records const { tool, tool_use_id, agentId, ...input } = e let route let spinner: { cancel: () => void } | undefined @@ -62,12 +67,15 @@ export const registerSubagentRouting = (on: On, options: PluginOptions): void => const seconds = tool_use_id ? await secondsSince($, pending.get(tool_use_id)) : undefined if (tool_use_id) { pending.delete(tool_use_id) + 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) - const task = `${input.description || input.subagent_type || tool}${tool_use_id ? ` [${tool_use_id}]` : ''}` - const elapsed = seconds === undefined ? '' : ` (routed in ${seconds.toFixed(1)}s)` - $.ui.log(`Smart Router: ${task}\n ↳ Selected Model: ${shortModel(route.model)}${elapsed}${route.rationale ? `\n Reason: ${route.rationale}` : ''}`) // Preserve Claude's permission checks and every other mod's tool handlers. return next({ ...route.updatedInput, tool, tool_use_id, agentId }) }) diff --git a/tests/README.md b/tests/README.md index 2f510ef2f..40b7d2106 100644 --- a/tests/README.md +++ b/tests/README.md @@ -43,10 +43,13 @@ 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 per-task routing notices without any tool render events, +Native mod component tests cover task-labelled routing cards without any tool render events, including parallel background agents finishing out of order, distinct IDs for identical -tasks, pending spinners, and disabled/failed routing. These checks do not establish live -parallel-agent TUI or gateway coverage. +tasks, pending spinners, disabled/failed routing, prompt reset, and late-result isolation. +The native TUI preview drives a real Claude terminal with fixture routing decisions, +checks wrapping and model emphasis, and rejects raw IDs, log prefixes, and replacement +characters. It makes no model request and does not establish live parallel-agent routing +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 diff --git a/tests/integration/README.md b/tests/integration/README.md index 3b4eb6646..a970c50df 100644 --- a/tests/integration/README.md +++ b/tests/integration/README.md @@ -1,9 +1,12 @@ # Integration tests Native mod component tests in `../native_claude/test_mod.py` cover task-labelled model -and rationale notices without tool render events, parallel background routing finishing +and rationale cards without tool render events, parallel background routing finishing out of order, distinct IDs for identical tasks, pending spinners, and disabled/failed -routing. These are not live parallel-agent TUI or gateway checks. +routing, prompt reset, and late-result isolation. A native TUI fixture preview checks +wrapping, model emphasis, and the absence of raw IDs, log prefixes, and replacement +characters. These are not live parallel-agent routing or gateway checks; the preview +makes no model request. The spend-status client's workspace authentication, optional percentage handling, pagination, validation, and failure propagation are covered by component tests in diff --git a/tests/native_claude/mod_tests/subagent-routing.test.ts b/tests/native_claude/mod_tests/subagent-routing.test.ts index c428574b5..36d22952b 100644 --- a/tests/native_claude/mod_tests/subagent-routing.test.ts +++ b/tests/native_claude/mod_tests/subagent-routing.test.ts @@ -2,20 +2,19 @@ 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) => { +test('routing preserves distinct results for identical tasks and forwards each call once without logging', async ($, on) => { const calls: any[] = []; - const notices: string[] = []; - on('ui.log', ($, e) => { notices.push(e.text); return { value: undefined }; }); + 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); @@ -39,28 +38,32 @@ 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([]); - expect(notices.length).toBe(2); - expect(notices[0]).toContain('Smart Router: Inspect parser [call-a]'); - expect(notices[0]).toContain('↳ Selected Model: claude-sonnet-5'); - expect(notices[0]).toContain('Reason: Selected for call-a'); - expect(notices[0]).not.toContain('claude-opus-4-8'); - expect(notices[1]).toContain('Smart Router: Inspect parser [call-b]'); - expect(notices[1]).toContain('↳ Selected Model: claude-opus-4-8'); - expect(notices[1]).toContain('Reason: Selected for call-b'); + for (const columns of [40, 100]) { + const cards = await $.ui.mount({ ...panel, viewport: { columns, rows: 30 } }); + expect(await cards.find({ type: 'Text', text: 'Inspect parser' })).toBeDefined(); + expect(await cards.find({ type: 'Text', text: 'claude-sonnet-5' })).toBeDefined(); + expect(await cards.find({ type: 'Text', text: 'claude-opus-4-8' })).toBeDefined(); + expect(await cards.find({ type: 'Text', text: /Selected for call-a/ })).toBeDefined(); + expect(await cards.find({ type: 'Text', text: /Selected for call-b/ })).toBeDefined(); + expect(await cards.find({ type: 'Text', text: /ug-smart-router:|\[call-|�/ })).toBeUndefined(); + expect(JSON.stringify(await cards.drawn())).toContain('"color":"green"'); + await cards.unmount(); + } const done = await $.ui.mount(row('call-a')); expect(await done.find({ type: 'Text', text: 'original Agent row' })).toBeDefined(); expect(await done.find({ type: 'Text', text: /Selected Model|Reason:/ })).toBeUndefined(); await done.unmount(); }); -test('parallel background agents emit distinct notices without any tool render events', async ($, on) => { +test('parallel background routes remain task-labelled without tool render events', async ($, on) => { const releases = new Map void>(); let started: () => void = () => {}; const ready = new Promise((resolve) => { started = resolve; }); - const notices: string[] = []; + const logs: string[] = []; const forwarded: string[] = []; - on('ui.log', ($, e) => { notices.push(e.text); return { value: undefined }; }); + on('ui.log', ($, e) => { logs.push(e.text); return { value: undefined }; }); on('clock.now', () => ({ value: 10_000 })); on('session.id', () => ({ value: 'session-1' })); on('process.run', async ($, e) => { @@ -71,37 +74,76 @@ test('parallel background agents emit distinct notices without any tool render e }); 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_use_id}`, + rationale: `Selected for ${tool_input.description}`, updatedInput: { ...tool_input, subagent_type: `ug-smart-router:${tool_use_id}` }, }) } }; }); on('tool.call', ($, e) => { - expect(notices[notices.length - 1]).toContain(`[${e.tool_use_id}]`); expect(e.run_in_background).toBe(true); forwarded.push(e.tool_use_id); return { result: 'ok' }; }); - + on('ui.render', () => ({ type: 'Text', props: {}, children: ['other mod'] })); const input = { ...originalInput, run_in_background: true }; 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)' }); await ready; - expect(notices.length).toBe(0); releases.get('call-b')!(); await second; - expect(notices.length).toBe(1); - expect(notices[0]).toContain('Compute 1+1 (agent B) [call-b]'); - expect(notices[0]).toContain('Selected Model: claude-opus-4-8'); - expect(notices[0]).toContain('Reason: Selected for call-b'); - expect(notices[0]).not.toContain('call-a'); + const partial = await $.ui.mount(panel); + expect(await partial.find({ type: 'Text', text: 'Compute 1+1 (agent B)' })).toBeDefined(); + expect(await partial.find({ type: 'Text', text: 'claude-opus-4-8' })).toBeDefined(); + expect(await partial.find({ type: 'Text', text: /Selected for Compute 1\+1 \(agent B\)/ })).toBeDefined(); + expect(await partial.find({ type: 'Text', text: 'claude-sonnet-5' })).toBeUndefined(); + await partial.unmount(); releases.get('call-a')!(); await first; - expect(notices.length).toBe(2); - expect(notices[1]).toContain('Compute 1+1 (agent A) [call-a]'); - expect(notices[1]).toContain('Selected Model: claude-sonnet-5'); - expect(notices[1]).toContain('Reason: Selected for call-a'); - expect(notices[1]).not.toContain('call-b'); + const cards = await $.ui.mount(panel); + expect(await cards.find({ type: 'Text', text: 'Compute 1+1 (agent A)' })).toBeDefined(); + expect(await cards.find({ type: 'Text', text: 'claude-sonnet-5' })).toBeDefined(); + expect(await cards.find({ type: 'Text', text: /Selected for Compute 1\+1 \(agent A\)/ })).toBeDefined(); + expect(await cards.find({ type: 'Text', text: 'claude-opus-4-8' })).toBeDefined(); + expect(await cards.find({ type: 'Text', text: 'other mod' })).toBeDefined(); + expect(logs).toEqual([]); expect(forwarded).toEqual(['call-b', 'call-a']); + await cards.unmount(); +}); + +test('cards persist after the turn and clear on the next prompt without late routes returning', async ($, on) => { + let started: () => void = () => {}; + let release: () => void = () => {}; + const ready = new Promise((resolve) => { started = resolve; }); + const waiting = new Promise((resolve) => { release = resolve; }); + on('session.id', () => ({ value: 'session-1' })); + on('process.run', async ($, e) => { + const { tool_use_id } = JSON.parse(e.init.stdin); + if (tool_use_id === 'late') { started(); await waiting; } + return { value: { exitCode: 0, stderr: '', stdout: JSON.stringify({ + model: 'system.ai.claude-haiku-5-5', rationale: '', + updatedInput: { subagent_type: 'ug-smart-router:haiku', prompt: 'inspect' }, + }) } }; + }); + on('tool.call', () => ({ result: 'ok' })); + on('prompt.submit', ($, e) => ({ text: e.text })); + on('turn.complete', () => ({ text: '' })); + on('ui.render', () => ({ type: 'Text', props: {}, children: ['other mod'] })); + await $.prompt.submit({ text: 'first prompt' }); + await $.tool.call({ tool: 'Agent', tool_use_id: 'first', prompt: 'inspect', subagent_type: 'Explore' }); + await $.turn.complete({ answer: 'done' }); + const completed = await $.ui.mount(panel); + expect(await completed.find({ type: 'Text', text: 'claude-haiku-5-5' })).toBeDefined(); + expect(await completed.find({ type: 'Text', text: 'Explore' })).toBeDefined(); + expect(await completed.find({ type: 'Text', text: /Reason:| · / })).toBeUndefined(); + await completed.unmount(); + const late = $.tool.call({ tool: 'Agent', tool_use_id: 'late', ...originalInput }); + await ready; + await $.prompt.submit({ text: 'next prompt' }); + release(); + await late; + const cleared = await $.ui.mount(panel); + expect(await cleared.find({ type: 'Text', text: /Selected Model|claude-haiku/ })).toBeUndefined(); + expect(await cleared.find({ type: 'Text', text: 'other mod' })).toBeDefined(); + await cleared.unmount(); }); for (const [name, result, warningCount] of [ @@ -119,24 +161,20 @@ 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')); + const ui = await $.ui.mount(panel); 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(); + 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) => { - const notices: string[] = []; - on('ui.log', ($, e) => { notices.push(e.text); return { value: undefined }; }); +test('a waiting call shows a spinner and the completed card retains its routing time', async ($, on) => { let release: (value: unknown) => void = () => {}; const routed = new Promise((resolve) => { release = resolve; }); let now = 10_000; @@ -145,21 +183,17 @@ 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'] })); - 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)); @@ -167,15 +201,15 @@ 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(); } - 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: /Selected Model/ })).toBeUndefined(); - expect(notices.length).toBe(2); - expect(notices[0]).toContain('claude-sonnet-5 (routed in 3.2s)'); + expect(await done.find({ type: 'Text', text: /Selecting a model|Selected Model/ })).toBeUndefined(); await done.unmount(); + const cards = await $.ui.mount(panel); + expect(await cards.find({ type: 'Text', text: 'claude-sonnet-5' })).toBeDefined(); + expect(await cards.find({ type: 'Text', text: ' · 3.2s' })).toBeDefined(); + await cards.unmount(); }); diff --git a/tests/native_claude/routing_preview.ts b/tests/native_claude/routing_preview.ts new file mode 100644 index 000000000..b49abe2ca --- /dev/null +++ b/tests/native_claude/routing_preview.ts @@ -0,0 +1,21 @@ +import { registerStatusBand } from './smart-routing-status.ts'; +import type { SubagentRoutingState } from './subagent-routing.ts'; + +export const register = (on) => { + const subagents: SubagentRoutingState = {records: new Map()}; + registerStatusBand(on, subagents); + on('session.start', {isInteractive: true}, async ($, event, next) => { + await $.command.register({name: 'routing-card-preview', description: 'Preview routing cards without inference', immediate: true}); + return next(event); + }); + on('command.run', {command: 'routing-card-preview'}, ($) => { + for (const [task, seconds] of [['Compute 1+1 (agent B)', 2.8], ['Compute 1+1 (agent A)', 3.6]] as const) { + subagents.records.set(`toolu_preview_${seconds}`, { + task, seconds, model: 'claude-haiku-5-5', + rationale: 'The task requires only a direct arithmetic operation and a strictly constrained numeric response, with no external information or tools. Selected claude-haiku-5-5 based on predicted quality and cost.', + }); + } + $.ui.invalidate('ui.render'); + return {text: 'Preview only; no model request.'}; + }); +}; diff --git a/tests/native_claude/test_mod.py b/tests/native_claude/test_mod.py index 166db82a4..346066e75 100644 --- a/tests/native_claude/test_mod.py +++ b/tests/native_claude/test_mod.py @@ -8,10 +8,12 @@ import subprocess import sys from pathlib import Path +from types import SimpleNamespace import pytest from session import Session +from tests.integration.utils.terminal import AgentTerminal from ucode.smart_routing import config from ucode.smart_routing.v2 import _claude_mod_launch_options, _write_routed_claude_plugin @@ -89,3 +91,62 @@ def test_native_process_inherits_settings_and_runs_python_cli(native_binary, tmp result = session.observe()["route"] assert result["exitCode"] == 0, result assert result["stdout"] == result["stderr"] == "", result + + +def test_native_tui_routing_cards_are_clean_and_wrapped(native_binary, tmp_path): + plugin = tmp_path / "plugin" + _write_routed_claude_plugin(plugin, []) + shutil.copyfile( + Path(__file__).with_name("routing_preview.ts"), plugin / "hooks/routing-preview.ts" + ) + (plugin / "hooks/hooks.json").write_text('{"modules":["./routing-preview.ts"]}') + env = { + "PATH": os.environ["PATH"], + "HOME": str(tmp_path), + "CLAUDE_CONFIG_DIR": str(tmp_path / "config"), + "ANTHROPIC_BASE_URL": "http://127.0.0.1:1", + "ANTHROPIC_API_KEY": "fixture-dummy-key", + "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1", + "DISABLE_AUTOUPDATER": "1", + } + command = [ + native_binary, + "--setting-sources", + "user", + "--plugin-dir", + str(plugin), + "--model", + "claude-sonnet-4-6", + ] + session = SimpleNamespace(cwd=tmp_path, home=tmp_path, env=env) + terminal = AgentTerminal(session, "claude", command, "routing-card-preview") + try: + terminal.boot(timeout=45) + if "Do you want to use this API key?" in terminal.visible: + terminal.choose("Do you want to use this API key?", "Yes") + terminal.boot(timeout=45) + terminal.submit("/routing-card-preview") + terminal.wait_for( + lambda screen: ( + "Compute 1+1 (agent A)" in screen + and "Compute 1+1 (agent B)" in screen + and "Preview only; no model request." in screen + ), + "both routing cards", + timeout=20, + ) + cards = terminal.visible[terminal.visible.index("Compute 1+1 (agent B)") :] + assert cards.count("↳ Selected Model: claude-haiku-5-5") == 2, cards + assert " · 2.8s" in cards and " · 3.6s" in cards, cards + assert " ".join(cards.split()).count("based on predicted quality and cost.") == 2 + for hidden in ("ug-smart-router:", "toolu_", "�"): + assert hidden not in cards, cards + for line_index, line in enumerate(terminal.screen.display): + if "↳ Selected Model: claude-haiku-5-5" in line: + style = terminal.screen.buffer[line_index][line.index("claude-haiku-5-5")] + assert style.bold and style.fg != "default", style + terminal.exit_normally() + finally: + (tmp_path / "routing-card-screen.txt").write_text(terminal.visible) + (tmp_path / "routing-card-terminal.ansi").write_text("".join(terminal.output)) + terminal.child.kill() From 9af3f5af907ba1810c8a95bed4e24afe89e3b500 Mon Sep 17 00:00:00 2001 From: "lilly.luo" Date: Sat, 24 Oct 2026 02:50:05 +0000 Subject: [PATCH 4/6] Draw Claude subagent routing cards above the parent's next reply Claude folds parallel Agent rows into its own "Running N agents" block, which raises no render hook, so the above-prompt panel and row annotations could not show parallel decisions in place. Draw every decision, single or parallel, as a card above the parent's first reply after routing, in call order, and show routing progress on the turn spinner while calls wait. Remove the above-prompt panel, its prompt-reset handling, and the TUI preview that exercised it. Co-authored-by: Isaac --- docs/claude-mod.md | 26 +-- src/ucode/agents/claude_mods/register.ts | 6 +- .../claude_mods/smart-routing-status.ts | 35 +--- .../agents/claude_mods/subagent-routing.ts | 81 +++++++-- tests/README.md | 12 +- tests/integration/README.md | 11 +- .../mod_tests/subagent-routing.test.ts | 158 +++++++++--------- tests/native_claude/routing_preview.ts | 21 --- tests/native_claude/test_mod.py | 61 ------- 9 files changed, 176 insertions(+), 235 deletions(-) delete mode 100644 tests/native_claude/routing_preview.ts diff --git a/docs/claude-mod.md b/docs/claude-mod.md index 9d08bfbee..57742175e 100644 --- a/docs/claude-mod.md +++ b/docs/claude-mod.md @@ -15,19 +15,19 @@ 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. -Successful decisions appear as task-labelled cards in the existing above-prompt panel: -bold task heading, green selected model, small routing timer, and indented, wrapping -rationale. Tool IDs remain internal. There are no successful-routing log messages, -plugin prefixes, or duplicate tool-row annotations. Claude's native parallel-agent -summary and log messages bypass the relevant transcript render hooks; the panel does -not depend on them. - -The panel retains every successful route for the current prompt, including background -agents whose routing completes out of order. Cards remain after the parent's turn ends -and clear on the next submitted prompt. A late result from an earlier prompt cannot -repopulate the new panel. Plugin reload clears the in-memory cards; the existing routing -decision audit log is unchanged. +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. +Each successful decision is drawn as a card in the transcript: bold task, green selected +model with its routing time, and the indented rationale. Claude folds parallel Agent rows +into its own "Running N agents" block, which raises no render hook, so single and parallel +calls alike draw their cards above the parent's first reply after routing, in call order. +A reply drawn before routing finished is never used, a call still routing waits for a +later reply, and a call whose routing failed draws no card. If the parent writes no reply +in that turn, no card is drawn. Nothing is drawn above the prompt and nothing is logged. +Cards 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. diff --git a/src/ucode/agents/claude_mods/register.ts b/src/ucode/agents/claude_mods/register.ts index 764892890..8cf646767 100644 --- a/src/ucode/agents/claude_mods/register.ts +++ b/src/ucode/agents/claude_mods/register.ts @@ -1,7 +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 type { SubagentRoutingState } from './subagent-routing.ts'; import { CLI_AGENT, CLI_MODULE, @@ -130,7 +129,6 @@ export function register(on: On, options: PluginOptions): void { } return {text: `Smart Router is ${action}`}; }); - const subagents: SubagentRoutingState = {records: new Map()}; - registerSubagentRouting(on, options, subagents); - registerStatusBand(on, subagents); + registerStatusBand(on); + registerSubagentRouting(on, options); } diff --git a/src/ucode/agents/claude_mods/smart-routing-status.ts b/src/ucode/agents/claude_mods/smart-routing-status.ts index b8649722b..e7ab2040b 100644 --- a/src/ucode/agents/claude_mods/smart-routing-status.ts +++ b/src/ucode/agents/claude_mods/smart-routing-status.ts @@ -1,7 +1,6 @@ import type { On } from 'claude-code' import { SMART_ROUTING_KEY, SUBAGENT_ONLY_KEY } from './constants.ts' import { isRoutingEnabled } from './routing-state.ts' -import type { SubagentRoutingState } from './subagent-routing.ts' const BANNER = 'unity gateway smart router' @@ -45,10 +44,8 @@ async function readRoutingState($: any): Promise { } } -export const registerStatusBand = (on: On, subagents: SubagentRoutingState): void => { +export const registerStatusBand = (on: On): void => { on('prompt.submit', ($, e, next) => { - subagents.records = new Map() - $.ui.invalidate('ui.render') if (band.isVisible) band.isHideArmed = true return next(e) }) @@ -73,7 +70,7 @@ export const registerStatusBand = (on: On, subagents: SubagentRoutingState): voi on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => { const theirs = await next(e) - if (!band.isVisible && !subagents.records.size) return theirs + if (!band.isVisible) return theirs const { Box, Text } = $.ui.resolve(e) const { enabled, firstPrompt } = await readRoutingState($) @@ -93,33 +90,7 @@ export const registerStatusBand = (on: On, subagents: SubagentRoutingState): voi columnGap: 1, children: [Text({ bold: true, children: [BANNER] }), pill, trailer], }) - const children = band.isVisible ? [line] : [] - for (const record of subagents.records.values()) { - const card = [ - Text({ bold: true, children: [record.task] }), - Box({ - flexDirection: 'row', - children: [ - Text({ dimColor: true, children: ['↳ Selected Model: '] }), - Text({ bold: true, color: 'green', children: [record.model] }), - ...(record.seconds === undefined ? [] : [ - Text({ dimColor: true, children: [` · ${record.seconds.toFixed(1)}s`] }), - ]), - ], - }), - ] - if (record.rationale) { - card.push(Box({ - paddingLeft: 2, - children: [Text({ children: [ - Text({ dimColor: true, children: ['Reason: '] }), - record.rationale, - ] })], - })) - } - children.push(Box({ flexDirection: 'column', marginTop: 1, paddingLeft: 2, children: card })) - } - if (theirs) children.push(theirs) + const children = theirs ? [line, theirs] : [line] return Box({ flexDirection: 'column', children }) }) } diff --git a/src/ucode/agents/claude_mods/subagent-routing.ts b/src/ucode/agents/claude_mods/subagent-routing.ts index db2ec201a..37dde1d18 100644 --- a/src/ucode/agents/claude_mods/subagent-routing.ts +++ b/src/ucode/agents/claude_mods/subagent-routing.ts @@ -1,8 +1,6 @@ import type { On, PluginOptions } from 'claude-code' -export type SubagentRoutingState = { - records: Map -} +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. @@ -23,19 +21,52 @@ const secondsSince = async ($: any, start: number | undefined): Promise { +// One routing decision: the task, the selected model and routing time, then the reason. +const card = ($: any, e: any, record: RoutingRecord) => { + const { Box, Text } = $.ui.resolve(e) + const lines = [ + Text({ bold: true, children: [record.task] }), + Box({ + flexDirection: 'row', + children: [ + Text({ dimColor: true, children: ['↳ Selected Model: '] }), + Text({ color: 'green', bold: true, children: [record.model] }), + ...(record.seconds === undefined ? [] : [ + Text({ dimColor: true, children: [` (routed in ${record.seconds.toFixed(1)}s)`] }), + ]), + ], + }), + ] + if (record.rationale) { + lines.push(Box({ + paddingLeft: 2, + children: [Text({ children: [Text({ dimColor: true, children: ['Reason: '] }), record.rationale] })], + })) + } + return Box({ flexDirection: 'column', paddingLeft: 2, marginBottom: 1, children: lines }) +} + +export const registerSubagentRouting = (on: On, options: PluginOptions): void => { // 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 every + // decision is drawn above the parent's next reply instead, single calls included. + const records = new Map() + // Calls not yet drawn, in call order, and the reply each batch is drawn above. + let unplaced: string[] = [] + 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[] if (!command.length) return next(e) - const records = state.records const { tool, tool_use_id, agentId, ...input } = e let route let spinner: { cancel: () => void } | undefined if (tool_use_id) { pending.set(tool_use_id, await now($)) + unplaced.push(tool_use_id) // 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. try { @@ -67,12 +98,14 @@ export const registerSubagentRouting = (on: On, options: PluginOptions, state: S const seconds = tool_use_id ? await secondsSince($, pending.get(tool_use_id)) : undefined if (tool_use_id) { pending.delete(tool_use_id) - if (route) records.set(tool_use_id, { - task: input.description || input.subagent_type || tool, - model: shortModel(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) @@ -97,4 +130,30 @@ export const registerSubagentRouting = (on: On, options: PluginOptions, state: S ], })] }) }) + + // 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 = unplaced.filter((id) => records.has(id)) + unplaced = unplaced.filter((id) => pending.has(id)) + if (ready.length) anchors.set(e.requestId, ready) + } + 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 40b7d2106..332fc43c0 100644 --- a/tests/README.md +++ b/tests/README.md @@ -43,13 +43,11 @@ 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 task-labelled routing cards without any tool render events, -including parallel background agents finishing out of order, distinct IDs for identical -tasks, pending spinners, disabled/failed routing, prompt reset, and late-result isolation. -The native TUI preview drives a real Claude terminal with fixture routing decisions, -checks wrapping and model emphasis, and rejects raw IDs, log prefixes, and replacement -characters. It makes no model request and does not establish live parallel-agent routing -or gateway coverage. +Native mod component tests cover routing cards drawn above the parent's next reply in call +order, replies drawn before routing never used, calls still routing waiting for a later +reply, distinct IDs for identical tasks, nothing above the prompt or on the Agent row once +routed, 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 diff --git a/tests/integration/README.md b/tests/integration/README.md index a970c50df..fd2fc7a17 100644 --- a/tests/integration/README.md +++ b/tests/integration/README.md @@ -1,12 +1,9 @@ # Integration tests -Native mod component tests in `../native_claude/test_mod.py` cover task-labelled model -and rationale cards without tool render events, parallel background routing finishing -out of order, distinct IDs for identical tasks, pending spinners, and disabled/failed -routing, prompt reset, and late-result isolation. A native TUI fixture preview checks -wrapping, model emphasis, and the absence of raw IDs, log prefixes, and replacement -characters. These are not live parallel-agent routing or gateway checks; the preview -makes no model request. +Native mod component tests in `../native_claude/test_mod.py` cover routing 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. The spend-status client's workspace authentication, optional percentage handling, pagination, validation, and failure propagation are covered by component tests in diff --git a/tests/native_claude/mod_tests/subagent-routing.test.ts b/tests/native_claude/mod_tests/subagent-routing.test.ts index 36d22952b..cb25c3567 100644 --- a/tests/native_claude/mod_tests/subagent-routing.test.ts +++ b/tests/native_claude/mod_tests/subagent-routing.test.ts @@ -10,8 +10,12 @@ const row = (id: string) => ({ ...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 without logging', async ($, on) => { +// The parent's reply, which draws the routing cards above itself. +const reply = (id: string) => ({ + ...panel, component: 'AssistantMessage' as const, requestId: id, + props: { text: 'reply', isFirstOfReply: true }, +}); +test('routing keeps distinct results for identical tasks as cards above the next reply', async ($, on) => { const calls: any[] = []; const logs: string[] = []; on('ui.log', ($, e) => { logs.push(e.text); return { value: undefined }; }); @@ -40,37 +44,41 @@ test('routing preserves distinct results for identical tasks and forwards each c expect(calls[1].subagent_type).toBe('ug-smart-router:call-b'); expect(logs).toEqual([]); - for (const columns of [40, 100]) { - const cards = await $.ui.mount({ ...panel, viewport: { columns, rows: 30 } }); - expect(await cards.find({ type: 'Text', text: 'Inspect parser' })).toBeDefined(); - expect(await cards.find({ type: 'Text', text: 'claude-sonnet-5' })).toBeDefined(); - expect(await cards.find({ type: 'Text', text: 'claude-opus-4-8' })).toBeDefined(); - expect(await cards.find({ type: 'Text', text: /Selected for call-a/ })).toBeDefined(); - expect(await cards.find({ type: 'Text', text: /Selected for call-b/ })).toBeDefined(); - expect(await cards.find({ type: 'Text', text: /ug-smart-router:|\[call-|�/ })).toBeUndefined(); - expect(JSON.stringify(await cards.drawn())).toContain('"color":"green"'); - await cards.unmount(); + for (const id of ['call-a', 'call-b']) { + const done = await $.ui.mount(row(id)); + expect(await done.find({ type: 'Text', text: /Selected Model|claude-/ })).toBeUndefined(); + await done.unmount(); } - const done = await $.ui.mount(row('call-a')); - expect(await done.find({ type: 'Text', text: 'original Agent row' })).toBeDefined(); - expect(await done.find({ type: 'Text', text: /Selected Model|Reason:/ })).toBeUndefined(); - await done.unmount(); + const cards = await $.ui.mount(reply('after')); + expect(await cards.find({ type: 'Text', text: 'original Agent row' })).toBeDefined(); + expect(await cards.find({ type: 'Text', text: 'claude-sonnet-5' })).toBeDefined(); + expect(await cards.find({ type: 'Text', text: 'claude-opus-4-8' })).toBeDefined(); + expect(await cards.find({ type: 'Text', text: /Selected for call-a/ })).toBeDefined(); + expect(await cards.find({ type: 'Text', text: /Selected for call-b/ })).toBeDefined(); + expect(await cards.find({ type: 'Text', text: /ug-smart-router:|�/ })).toBeUndefined(); + expect(JSON.stringify(await cards.drawn())).toContain('"color":"green"'); + await cards.unmount(); + // The batch stays on that reply; a later reply draws no cards. + const later = await $.ui.mount(reply('later')); + expect(await later.find({ type: 'Text', text: /Selected Model/ })).toBeUndefined(); + await later.unmount(); + const prompt = await $.ui.mount(panel); + expect(await prompt.find({ type: 'Text', text: /Selected Model|claude-/ })).toBeUndefined(); + await prompt.unmount(); }); -test('parallel background routes remain task-labelled without tool render events', async ($, on) => { +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 logs: string[] = []; - const forwarded: string[] = []; - on('ui.log', ($, e) => { logs.push(e.text); return { value: undefined }; }); + 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 === 2) started(); + 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', @@ -80,70 +88,46 @@ test('parallel background routes remain task-labelled without tool render events }); on('tool.call', ($, e) => { expect(e.run_in_background).toBe(true); - forwarded.push(e.tool_use_id); + events.push(`start ${e.tool_use_id}`); return { result: 'ok' }; }); - on('ui.render', () => ({ type: 'Text', props: {}, children: ['other mod'] })); + 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; - const partial = await $.ui.mount(panel); - expect(await partial.find({ type: 'Text', text: 'Compute 1+1 (agent B)' })).toBeDefined(); - expect(await partial.find({ type: 'Text', text: 'claude-opus-4-8' })).toBeDefined(); - expect(await partial.find({ type: 'Text', text: /Selected for Compute 1\+1 \(agent B\)/ })).toBeDefined(); - expect(await partial.find({ type: 'Text', text: 'claude-sonnet-5' })).toBeUndefined(); - await partial.unmount(); releases.get('call-a')!(); await first; - const cards = await $.ui.mount(panel); - expect(await cards.find({ type: 'Text', text: 'Compute 1+1 (agent A)' })).toBeDefined(); + 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: /Selected for Compute 1\+1 \(agent A\)/ })).toBeDefined(); expect(await cards.find({ type: 'Text', text: 'claude-opus-4-8' })).toBeDefined(); - expect(await cards.find({ type: 'Text', text: 'other mod' })).toBeDefined(); - expect(logs).toEqual([]); - expect(forwarded).toEqual(['call-b', 'call-a']); await cards.unmount(); -}); -test('cards persist after the turn and clear on the next prompt without late routes returning', async ($, on) => { - let started: () => void = () => {}; - let release: () => void = () => {}; - const ready = new Promise((resolve) => { started = resolve; }); - const waiting = new Promise((resolve) => { release = resolve; }); - on('session.id', () => ({ value: 'session-1' })); - on('process.run', async ($, e) => { - const { tool_use_id } = JSON.parse(e.init.stdin); - if (tool_use_id === 'late') { started(); await waiting; } - return { value: { exitCode: 0, stderr: '', stdout: JSON.stringify({ - model: 'system.ai.claude-haiku-5-5', rationale: '', - updatedInput: { subagent_type: 'ug-smart-router:haiku', prompt: 'inspect' }, - }) } }; - }); - on('tool.call', () => ({ result: 'ok' })); - on('prompt.submit', ($, e) => ({ text: e.text })); - on('turn.complete', () => ({ text: '' })); - on('ui.render', () => ({ type: 'Text', props: {}, children: ['other mod'] })); - await $.prompt.submit({ text: 'first prompt' }); - await $.tool.call({ tool: 'Agent', tool_use_id: 'first', prompt: 'inspect', subagent_type: 'Explore' }); - await $.turn.complete({ answer: 'done' }); - const completed = await $.ui.mount(panel); - expect(await completed.find({ type: 'Text', text: 'claude-haiku-5-5' })).toBeDefined(); - expect(await completed.find({ type: 'Text', text: 'Explore' })).toBeDefined(); - expect(await completed.find({ type: 'Text', text: /Reason:| · / })).toBeUndefined(); - await completed.unmount(); - const late = $.tool.call({ tool: 'Agent', tool_use_id: 'late', ...originalInput }); - await ready; - await $.prompt.submit({ text: 'next prompt' }); - release(); - await late; - const cleared = await $.ui.mount(panel); - expect(await cleared.find({ type: 'Text', text: /Selected Model|claude-haiku/ })).toBeUndefined(); - expect(await cleared.find({ type: 'Text', text: 'other mod' })).toBeDefined(); - await cleared.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(); }); for (const [name, result, warningCount] of [ @@ -167,14 +151,16 @@ for (const [name, result, warningCount] of [ 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(panel); - 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(); + 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 waiting call shows a spinner and the completed card retains its routing time', 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; @@ -188,7 +174,12 @@ test('a waiting call shows a spinner and the completed card retains its routing }) } }; }); 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(); @@ -201,6 +192,12 @@ test('a waiting call shows a spinner and the completed card retains its routing 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; @@ -208,8 +205,11 @@ test('a waiting call shows a spinner and the completed card retains its routing const done = await $.ui.mount(row('call-a')); expect(await done.find({ type: 'Text', text: /Selecting a model|Selected Model/ })).toBeUndefined(); await done.unmount(); - const cards = await $.ui.mount(panel); + 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: ' · 3.2s' })).toBeDefined(); + expect(await cards.find({ type: 'Text', text: ' (routed in 3.2s)' })).toBeDefined(); await cards.unmount(); }); diff --git a/tests/native_claude/routing_preview.ts b/tests/native_claude/routing_preview.ts deleted file mode 100644 index b49abe2ca..000000000 --- a/tests/native_claude/routing_preview.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { registerStatusBand } from './smart-routing-status.ts'; -import type { SubagentRoutingState } from './subagent-routing.ts'; - -export const register = (on) => { - const subagents: SubagentRoutingState = {records: new Map()}; - registerStatusBand(on, subagents); - on('session.start', {isInteractive: true}, async ($, event, next) => { - await $.command.register({name: 'routing-card-preview', description: 'Preview routing cards without inference', immediate: true}); - return next(event); - }); - on('command.run', {command: 'routing-card-preview'}, ($) => { - for (const [task, seconds] of [['Compute 1+1 (agent B)', 2.8], ['Compute 1+1 (agent A)', 3.6]] as const) { - subagents.records.set(`toolu_preview_${seconds}`, { - task, seconds, model: 'claude-haiku-5-5', - rationale: 'The task requires only a direct arithmetic operation and a strictly constrained numeric response, with no external information or tools. Selected claude-haiku-5-5 based on predicted quality and cost.', - }); - } - $.ui.invalidate('ui.render'); - return {text: 'Preview only; no model request.'}; - }); -}; diff --git a/tests/native_claude/test_mod.py b/tests/native_claude/test_mod.py index 346066e75..166db82a4 100644 --- a/tests/native_claude/test_mod.py +++ b/tests/native_claude/test_mod.py @@ -8,12 +8,10 @@ import subprocess import sys from pathlib import Path -from types import SimpleNamespace import pytest from session import Session -from tests.integration.utils.terminal import AgentTerminal from ucode.smart_routing import config from ucode.smart_routing.v2 import _claude_mod_launch_options, _write_routed_claude_plugin @@ -91,62 +89,3 @@ def test_native_process_inherits_settings_and_runs_python_cli(native_binary, tmp result = session.observe()["route"] assert result["exitCode"] == 0, result assert result["stdout"] == result["stderr"] == "", result - - -def test_native_tui_routing_cards_are_clean_and_wrapped(native_binary, tmp_path): - plugin = tmp_path / "plugin" - _write_routed_claude_plugin(plugin, []) - shutil.copyfile( - Path(__file__).with_name("routing_preview.ts"), plugin / "hooks/routing-preview.ts" - ) - (plugin / "hooks/hooks.json").write_text('{"modules":["./routing-preview.ts"]}') - env = { - "PATH": os.environ["PATH"], - "HOME": str(tmp_path), - "CLAUDE_CONFIG_DIR": str(tmp_path / "config"), - "ANTHROPIC_BASE_URL": "http://127.0.0.1:1", - "ANTHROPIC_API_KEY": "fixture-dummy-key", - "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1", - "DISABLE_AUTOUPDATER": "1", - } - command = [ - native_binary, - "--setting-sources", - "user", - "--plugin-dir", - str(plugin), - "--model", - "claude-sonnet-4-6", - ] - session = SimpleNamespace(cwd=tmp_path, home=tmp_path, env=env) - terminal = AgentTerminal(session, "claude", command, "routing-card-preview") - try: - terminal.boot(timeout=45) - if "Do you want to use this API key?" in terminal.visible: - terminal.choose("Do you want to use this API key?", "Yes") - terminal.boot(timeout=45) - terminal.submit("/routing-card-preview") - terminal.wait_for( - lambda screen: ( - "Compute 1+1 (agent A)" in screen - and "Compute 1+1 (agent B)" in screen - and "Preview only; no model request." in screen - ), - "both routing cards", - timeout=20, - ) - cards = terminal.visible[terminal.visible.index("Compute 1+1 (agent B)") :] - assert cards.count("↳ Selected Model: claude-haiku-5-5") == 2, cards - assert " · 2.8s" in cards and " · 3.6s" in cards, cards - assert " ".join(cards.split()).count("based on predicted quality and cost.") == 2 - for hidden in ("ug-smart-router:", "toolu_", "�"): - assert hidden not in cards, cards - for line_index, line in enumerate(terminal.screen.display): - if "↳ Selected Model: claude-haiku-5-5" in line: - style = terminal.screen.buffer[line_index][line.index("claude-haiku-5-5")] - assert style.bold and style.fg != "default", style - terminal.exit_normally() - finally: - (tmp_path / "routing-card-screen.txt").write_text(terminal.visible) - (tmp_path / "routing-card-terminal.ansi").write_text("".join(terminal.output)) - terminal.child.kill() From e628159d0bd2b60f6db1252b9512e06aa8e40dee Mon Sep 17 00:00:00 2001 From: "lilly.luo" Date: Mon, 26 Oct 2026 22:40:10 +0000 Subject: [PATCH 5/6] Drop undrawn routing cards when a turn ends A routed subagent card waited for whatever reply rendered next, so a turn interrupted before the parent replied left its cards above the reply to an unrelated later prompt. The plugin's single turn.complete handler, now in register.ts and shared with the status band, clears the undrawn cards. Co-authored-by: Isaac --- docs/claude-mod.md | 3 +- src/ucode/agents/claude_mods/register.ts | 10 ++++- .../claude_mods/smart-routing-status.ts | 18 ++++---- .../agents/claude_mods/subagent-routing.ts | 18 +++++--- tests/README.md | 3 +- .../mod_tests/subagent-routing.test.ts | 44 +++++++++++++++++++ 6 files changed, 78 insertions(+), 18 deletions(-) diff --git a/docs/claude-mod.md b/docs/claude-mod.md index 57742175e..f35593ae8 100644 --- a/docs/claude-mod.md +++ b/docs/claude-mod.md @@ -25,7 +25,8 @@ into its own "Running N agents" block, which raises no render hook, so single an calls alike draw their cards above the parent's first reply after routing, in call order. A reply drawn before routing finished is never used, a call still routing waits for a later reply, and a call whose routing failed draws no card. If the parent writes no reply -in that turn, no card is drawn. Nothing is drawn above the prompt and nothing is logged. +in that turn, its cards are dropped when the turn ends, not drawn in a later turn. +Nothing is drawn above the prompt and nothing is logged. Cards 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; diff --git a/src/ucode/agents/claude_mods/register.ts b/src/ucode/agents/claude_mods/register.ts index 8cf646767..e8e672b4a 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(); + 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 37dde1d18..f0e76044e 100644 --- a/src/ucode/agents/claude_mods/subagent-routing.ts +++ b/src/ucode/agents/claude_mods/subagent-routing.ts @@ -46,14 +46,22 @@ const card = ($: any, e: any, record: RoutingRecord) => { return Box({ flexDirection: 'column', paddingLeft: 2, marginBottom: 1, children: lines }) } +// 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[] } + +// A turn that ends without a reply draws no cards, so its calls never reach a later turn. +export function endSubagentRoutingTurn(): void { + cards.unplaced = [] +} + export const registerSubagentRouting = (on: On, options: PluginOptions): void => { // 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 every // decision is drawn above the parent's next reply instead, single calls included. const records = new Map() - // Calls not yet drawn, in call order, and the reply each batch is drawn above. - let unplaced: string[] = [] + // 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() @@ -66,7 +74,7 @@ export const registerSubagentRouting = (on: On, options: PluginOptions): void => let spinner: { cancel: () => void } | undefined if (tool_use_id) { pending.set(tool_use_id, await now($)) - unplaced.push(tool_use_id) + cards.unplaced.push(tool_use_id) // 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. try { @@ -147,8 +155,8 @@ export const registerSubagentRouting = (on: On, options: PluginOptions): void => 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 = unplaced.filter((id) => records.has(id)) - unplaced = unplaced.filter((id) => pending.has(id)) + 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) } const ids = anchors.get(e.requestId) diff --git a/tests/README.md b/tests/README.md index ca72c4c39..39aeb906c 100644 --- a/tests/README.md +++ b/tests/README.md @@ -45,7 +45,8 @@ See [the mod guide](../docs/claude-mod.md). Native mod component tests cover routing cards drawn above the parent's next reply in call order, replies drawn before routing never used, calls still routing waiting for a later -reply, distinct IDs for identical tasks, nothing above the prompt or on the Agent row once +reply, cards from a turn that ended without a reply never drawn in a later turn, distinct IDs +for identical tasks, nothing above the prompt or on the Agent row once routed, pending row and turn-spinner routing timers, and disabled/failed routing. They do not establish live parallel-agent TUI or gateway coverage. diff --git a/tests/native_claude/mod_tests/subagent-routing.test.ts b/tests/native_claude/mod_tests/subagent-routing.test.ts index cb25c3567..5be929d88 100644 --- a/tests/native_claude/mod_tests/subagent-routing.test.ts +++ b/tests/native_claude/mod_tests/subagent-routing.test.ts @@ -130,6 +130,50 @@ test('parallel cards keep call order, skip replies drawn earlier, and wait for r await prompt.unmount(); }); +test('a turn that ends without a reply leaves no card for the next turn', async ($, on) => { + let release: () => void = () => {}; + let started: () => void = () => {}; + const routed = new Promise((resolve) => { release = resolve; }); + const routing = 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); + if (tool_use_id === 'call-b') { + started(); + await routed; + } + 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'] })); + + // One call is routed and another is still routing when the user interrupts the turn. + await $.prompt.submit({ text: 'inspect the parser' }); + await $.tool.call({ tool: 'Agent', tool_use_id: 'call-a', ...originalInput }); + const waiting = $.tool.call({ tool: 'Agent', tool_use_id: 'call-b', ...originalInput, description: 'Inspect lexer' }); + await routing; + await $.turn.complete({ answer: '' }); + release(); + await waiting; + + await $.prompt.submit({ text: 'something unrelated' }); + const unrelated = await $.ui.mount(reply('unrelated')); + expect(await unrelated.find({ type: 'Text', text: /Selected Model|Inspect parser|Inspect lexer/ })).toBeUndefined(); + await unrelated.unmount(); + + // The next turn's own calls still draw above its reply. + await $.tool.call({ tool: 'Agent', tool_use_id: 'call-c', ...originalInput, description: 'Inspect tokens' }); + const cards = await $.ui.mount(reply('after')); + expect(await cards.find({ type: 'Text', text: 'Inspect tokens' })).toBeDefined(); + expect(await cards.find({ type: 'Text', text: /Inspect parser|Inspect lexer/ })).toBeUndefined(); + await cards.unmount(); +}); + for (const [name, result, warningCount] of [ ['disabled', { value: { exitCode: 0, stdout: '', stderr: '' } }, 0], ['command failure', { value: { exitCode: 1, stdout: '', stderr: 'failure' } }, 1], From e69c2ad671510ce671305c17a53de1a3f0b94760 Mon Sep 17 00:00:00 2001 From: "lilly.luo" Date: Tue, 27 Oct 2026 14:09:27 +0000 Subject: [PATCH 6/6] Show a lone Claude subagent's routing under its Agent row again Cards anchored to the parent's next AssistantMessage never reached the screen in the live Claude TUI: the reply text is drawn in the same message as the Agent call, before routing finishes, and the turn ends (clearing the queued cards) while a backgrounded agent runs. Keep the Agent-row rendering for a lone call and use cards only for calls routed in parallel, whose rows Claude folds away. Cards now survive a normal turn end and are dropped only on abort. Co-authored-by: Isaac --- docs/claude-mod.md | 19 +-- src/ucode/agents/claude_mods/register.ts | 2 +- .../agents/claude_mods/subagent-routing.ts | 104 ++++++++++------ tests/README.md | 11 +- tests/integration/README.md | 5 +- .../mod_tests/subagent-routing.test.ts | 114 ++++++++++++------ 6 files changed, 161 insertions(+), 94 deletions(-) diff --git a/docs/claude-mod.md b/docs/claude-mod.md index f35593ae8..5203af21d 100644 --- a/docs/claude-mod.md +++ b/docs/claude-mod.md @@ -19,15 +19,16 @@ matching Agent row shows a spinner and elapsed seconds while the router answers. 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. -Each successful decision is drawn as a card in the transcript: bold task, green selected -model with its routing time, and the indented rationale. Claude folds parallel Agent rows -into its own "Running N agents" block, which raises no render hook, so single and parallel -calls alike draw their cards above the parent's first reply after routing, in call order. -A reply drawn before routing finished is never used, a call still routing waits for a -later reply, and a call whose routing failed draws no card. If the parent writes no reply -in that turn, its cards are dropped when the turn ends, not drawn in a later turn. -Nothing is drawn above the prompt and nothing is logged. -Cards are kept for the session; a plugin reload clears them from the screen, and the +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. diff --git a/src/ucode/agents/claude_mods/register.ts b/src/ucode/agents/claude_mods/register.ts index e8e672b4a..4a939389e 100644 --- a/src/ucode/agents/claude_mods/register.ts +++ b/src/ucode/agents/claude_mods/register.ts @@ -133,7 +133,7 @@ export function register(on: On, options: PluginOptions): void { registerSubagentRouting(on, options); // Claude allows one unmatched turn.complete handler per plugin, so both modules share it. on('turn.complete', ($, e, next) => { - endSubagentRoutingTurn(); + endSubagentRoutingTurn(e.isAborted); if (endStatusBandTurn()) $.ui.invalidate('ui.render'); return next(e); }); diff --git a/src/ucode/agents/claude_mods/subagent-routing.ts b/src/ucode/agents/claude_mods/subagent-routing.ts index f0e76044e..5546c9d28 100644 --- a/src/ucode/agents/claude_mods/subagent-routing.ts +++ b/src/ucode/agents/claude_mods/subagent-routing.ts @@ -21,45 +21,57 @@ const secondsSince = async ($: any, start: number | undefined): Promise { +// The selected model with its routing time, then the reason. Labels recede so the routed +// model, in the status band's ON green, stands out; the time is omitted without a clock. +const selection = ($: any, e: any, record: RoutingRecord, arrow: string, reasonPadding: number) => { const { Box, Text } = $.ui.resolve(e) - const lines = [ - Text({ bold: true, children: [record.task] }), - Box({ - flexDirection: 'row', - children: [ - Text({ dimColor: true, children: ['↳ Selected Model: '] }), - Text({ color: 'green', bold: true, children: [record.model] }), - ...(record.seconds === undefined ? [] : [ - Text({ dimColor: true, children: [` (routed in ${record.seconds.toFixed(1)}s)`] }), - ]), - ], - }), - ] + 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: 2, + paddingLeft: reasonPadding, children: [Text({ children: [Text({ dimColor: true, children: ['Reason: '] }), record.rationale] })], })) } - return Box({ flexDirection: 'column', paddingLeft: 2, marginBottom: 1, children: lines }) + return lines } -// Calls not yet drawn, in call order. They live outside registration so the register -// module's one turn.complete handler can drop them. +// 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[] } -// A turn that ends without a reply draws no cards, so its calls never reach a later turn. -export function endSubagentRoutingTurn(): void { - cards.unplaced = [] +// 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 => { // 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 every - // decision is drawn above the parent's next reply instead, single calls included. + // 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() @@ -73,8 +85,19 @@ 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($)) - cards.unplaced.push(tool_use_id) // 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. try { @@ -123,20 +146,27 @@ export const registerSubagentRouting = (on: On, options: PluginOptions): void => on('ui.render', { component: 'ToolUse' }, async ($, e, next) => { const original = await next(e) - if (!pending.has(e.requestId)) return original + const record = records.get(e.requestId) + if (!record && !pending.has(e.requestId)) return original const { Box, Text } = $.ui.resolve(e) - const glyph = SPINNER[Math.floor(((await now($)) ?? 0) / SPINNER_INTERVAL_MS) % SPINNER.length] - const seconds = await secondsSince($, pending.get(e.requestId)) - const elapsed = seconds === undefined ? '' : ` ${Math.floor(seconds)}s` - return Box({ flexDirection: 'column', children: [original, Box({ - flexDirection: 'row', - children: [ - Text({ dimColor: true, children: [' ↳ '] }), - Text({ color: 'green', children: [`${glyph} `] }), - Text({ dimColor: true, children: [`Selecting a model…${elapsed}`] }), - ], - })] }) + if (!record) { + const glyph = SPINNER[Math.floor(((await now($)) ?? 0) / SPINNER_INTERVAL_MS) % SPINNER.length] + const seconds = await secondsSince($, pending.get(e.requestId)) + const elapsed = seconds === undefined ? '' : ` ${Math.floor(seconds)}s` + return Box({ flexDirection: 'column', children: [original, Box({ + flexDirection: 'row', + children: [ + Text({ dimColor: true, children: [' ↳ '] }), + Text({ color: 'green', children: [`${glyph} `] }), + Text({ dimColor: true, children: [`Selecting a model…${elapsed}`] }), + ], + })] }) + } + 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 diff --git a/tests/README.md b/tests/README.md index 39aeb906c..a73eb3a18 100644 --- a/tests/README.md +++ b/tests/README.md @@ -43,11 +43,12 @@ 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 routing cards drawn above the parent's next reply in call -order, replies drawn before routing never used, calls still routing waiting for a later -reply, cards from a turn that ended without a reply never drawn in a later turn, distinct IDs -for identical tasks, nothing above the prompt or on the Agent row once -routed, pending row and turn-spinner routing timers, and disabled/failed routing. They do not establish live +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` diff --git a/tests/integration/README.md b/tests/integration/README.md index 956dd76e9..064c6b701 100644 --- a/tests/integration/README.md +++ b/tests/integration/README.md @@ -1,7 +1,8 @@ # Integration tests -Native mod component tests in `../native_claude/test_mod.py` cover routing cards above the -parent's next reply in call order, calls still routing waiting for a later reply, distinct +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. diff --git a/tests/native_claude/mod_tests/subagent-routing.test.ts b/tests/native_claude/mod_tests/subagent-routing.test.ts index 5be929d88..9f05c4c21 100644 --- a/tests/native_claude/mod_tests/subagent-routing.test.ts +++ b/tests/native_claude/mod_tests/subagent-routing.test.ts @@ -10,12 +10,13 @@ const row = (id: string) => ({ ...panel, component: 'ToolUse' as const, requestId: id, props: { tool_use_id: id, tool: 'Agent', input: originalInput, isRunning: true, isErrored: false, isInterrupted: false }, }); -// The parent's reply, which draws the routing cards above itself. +// 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('routing keeps distinct results for identical tasks as cards above the next reply', async ($, on) => { +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 }; }); @@ -44,24 +45,22 @@ test('routing keeps distinct results for identical tasks as cards above the next expect(calls[1].subagent_type).toBe('ug-smart-router:call-b'); expect(logs).toEqual([]); - for (const id of ['call-a', 'call-b']) { + 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: /Selected Model|claude-/ })).toBeUndefined(); + 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(); } - const cards = await $.ui.mount(reply('after')); - expect(await cards.find({ type: 'Text', text: 'original Agent row' })).toBeDefined(); - expect(await cards.find({ type: 'Text', text: 'claude-sonnet-5' })).toBeDefined(); - expect(await cards.find({ type: 'Text', text: 'claude-opus-4-8' })).toBeDefined(); - expect(await cards.find({ type: 'Text', text: /Selected for call-a/ })).toBeDefined(); - expect(await cards.find({ type: 'Text', text: /Selected for call-b/ })).toBeDefined(); - expect(await cards.find({ type: 'Text', text: /ug-smart-router:|�/ })).toBeUndefined(); - expect(JSON.stringify(await cards.drawn())).toContain('"color":"green"'); - await cards.unmount(); - // The batch stays on that reply; a later reply draws no cards. - const later = await $.ui.mount(reply('later')); - expect(await later.find({ type: 'Text', text: /Selected Model/ })).toBeUndefined(); - await later.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(); @@ -130,18 +129,17 @@ test('parallel cards keep call order, skip replies drawn earlier, and wait for r await prompt.unmount(); }); -test('a turn that ends without a reply leaves no card for the next turn', async ($, on) => { - let release: () => void = () => {}; +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 routed = new Promise((resolve) => { release = resolve; }); - const routing = new Promise((resolve) => { started = resolve; }); + 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); - if (tool_use_id === 'call-b') { - started(); - await routed; - } + 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}` }, @@ -151,27 +149,63 @@ test('a turn that ends without a reply leaves no card for the next turn', async 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); + }; - // One call is routed and another is still routing when the user interrupts the turn. + // The user interrupts the turn after its parallel calls were routed. await $.prompt.submit({ text: 'inspect the parser' }); - await $.tool.call({ tool: 'Agent', tool_use_id: 'call-a', ...originalInput }); - const waiting = $.tool.call({ tool: 'Agent', tool_use_id: 'call-b', ...originalInput, description: 'Inspect lexer' }); - await routing; - await $.turn.complete({ answer: '' }); - release(); - await waiting; - + 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|Inspect parser|Inspect lexer/ })).toBeUndefined(); + expect(await unrelated.find({ type: 'Text', text: /Selected Model|call-a|call-b/ })).toBeUndefined(); await unrelated.unmount(); +}); - // The next turn's own calls still draw above its reply. - await $.tool.call({ tool: 'Agent', tool_use_id: 'call-c', ...originalInput, description: 'Inspect tokens' }); - const cards = await $.ui.mount(reply('after')); - expect(await cards.find({ type: 'Text', text: 'Inspect tokens' })).toBeDefined(); - expect(await cards.find({ type: 'Text', text: /Inspect parser|Inspect lexer/ })).toBeUndefined(); - await cards.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 [