From 7f6d7a577a8a2f89f16d89edb500e27619e39491 Mon Sep 17 00:00:00 2001 From: Muspi Merol Date: Sun, 12 Jul 2026 02:55:39 +0800 Subject: [PATCH 1/2] fix(tui): avoid double-formatting friendly tool labels --- tests/test_tui_gateway_server.py | 20 +++++++++++++++- tui_gateway/server.py | 21 ++++++++++------ .../createGatewayEventHandler.test.ts | 24 +++++++++++++++++++ ui-tui/src/__tests__/messages.test.ts | 10 ++++++-- ui-tui/src/__tests__/text.test.ts | 11 +++++++++ ui-tui/src/app/createGatewayEventHandler.ts | 1 + ui-tui/src/app/turnController.ts | 10 ++++---- ui-tui/src/components/thinking.tsx | 2 +- ui-tui/src/domain/messages.ts | 5 ++-- ui-tui/src/gatewayTypes.ts | 3 ++- ui-tui/src/lib/text.ts | 18 ++++++++++---- ui-tui/src/types.ts | 1 + 12 files changed, 103 insertions(+), 23 deletions(-) diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index 6b19000aad51b..36a616d35a7c6 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -424,6 +424,24 @@ def fail_redaction(*_args, **_kwargs): assert "result_text" not in events[1][2] +def test_tui_tool_display_fields_separate_raw_context_from_final_label(): + from agent.display import get_friendly_tool_labels, set_friendly_tool_labels + + previous = get_friendly_tool_labels() + try: + set_friendly_tool_labels(True) + assert server._tool_display_fields("read_file", {"path": "package.json", "offset": 1, "limit": 300}) == { + "context": "package.json L1-300", + "label": "Reading package.json L1-300", + } + assert server._tool_display_fields("custom_tool", {"query": "alpha"}) == {"context": "alpha"} + + set_friendly_tool_labels(False) + assert server._tool_display_fields("read_file", {"path": "package.json"}) == {"context": "package.json"} + finally: + set_friendly_tool_labels(previous) + + def test_tui_tool_output_risk_event_exposes_metadata_without_raw_output(monkeypatch): events: list[tuple[str, str, dict]] = [] monkeypatch.setattr( @@ -935,7 +953,7 @@ def test_history_to_messages_preserves_tool_calls_for_resume_display(): assert server._history_to_messages(history) == [ {"role": "user", "text": "first prompt"}, - {"context": "Searching files for resume", "name": "search_files", "role": "tool"}, + {"context": "resume", "label": "Searching files for resume", "name": "search_files", "role": "tool"}, {"role": "assistant", "text": "first answer"}, {"role": "user", "text": "second prompt"}, ] diff --git a/tui_gateway/server.py b/tui_gateway/server.py index cf168a688ad4b..5105912eb24b1 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -3435,13 +3435,19 @@ def _session_info(agent, session: dict | None = None) -> dict: return info -def _tool_ctx(name: str, args: dict) -> str: +def _tool_display_fields(name: str, args: dict) -> dict[str, str]: try: - from agent.display import build_tool_label + from agent.display import build_tool_label, build_tool_preview - return build_tool_label(name, args, max_len=80) or "" + context = build_tool_preview(name, args, max_len=80) or "" + label = build_tool_label(name, args, max_len=80) or "" + # Keep context raw for clients that add the tool name themselves; label is final display text. + fields = {"context": context} + if label and label != context: + fields["label"] = label + return fields except Exception: - return "" + return {"context": ""} def _emit_session_info_for_session(sid: str, session: dict) -> None: @@ -3593,7 +3599,7 @@ def _on_tool_start(sid: str, tool_call_id: str, name: str, args: dict): payload = { "tool_id": tool_call_id, "name": name, - "context": _tool_ctx(name, args), + **_tool_display_fields(name, args), } if _session_verbose(sid): args_text = _tool_args_text(args) @@ -4289,7 +4295,8 @@ def progress(message: str, level: str = "info") -> None: def tool_start(tool_call_id: str, name: str, args: dict) -> None: started_at[tool_call_id] = time.time() - ctx = _tool_ctx(name, args) + fields = _tool_display_fields(name, args) + ctx = fields.get("label") or fields["context"] progress(f"Running {name}{f': {ctx}' if ctx else ''}") def tool_complete(tool_call_id: str, name: str, _args: dict, result: str) -> None: @@ -4940,7 +4947,7 @@ def _history_to_messages(history: list[dict]) -> list[dict]: name = (tc_info[0] if tc_info else None) or m.get("tool_name") or "tool" args = (tc_info[1] if tc_info else None) or {} messages.append( - {"role": "tool", "name": name, "context": _tool_ctx(name, args)} + {"role": "tool", "name": name, **_tool_display_fields(name, args)} ) continue # An assistant turn may carry only reasoning/thinking content with no diff --git a/ui-tui/src/__tests__/createGatewayEventHandler.test.ts b/ui-tui/src/__tests__/createGatewayEventHandler.test.ts index fad5f6b4564ff..3e7a1937ce545 100644 --- a/ui-tui/src/__tests__/createGatewayEventHandler.test.ts +++ b/ui-tui/src/__tests__/createGatewayEventHandler.test.ts @@ -274,6 +274,30 @@ describe('createGatewayEventHandler', () => { expect(toolTrails[0]?.tools?.[1]).toContain('Read File') }) + it('keeps final labels separate from raw context through live and completed tool state', () => { + const onEvent = createGatewayEventHandler(buildCtx([])) + + onEvent({ + payload: { + context: 'package.json L1-300', + label: 'Reading package.json L1-300', + name: 'read_file', + tool_id: 'tool-1' + }, + type: 'tool.start' + } as any) + + expect(getTurnState().tools[0]).toMatchObject({ + context: 'package.json L1-300', + label: 'Reading package.json L1-300', + name: 'read_file' + }) + + onEvent({ payload: { duration_s: 0.5, name: 'read_file', tool_id: 'tool-1' }, type: 'tool.complete' } as any) + + expect(getTurnState().streamPendingTools[0]).toBe('Reading package.json L1-300 (0.5s) ✓') + }) + it('keeps tool tokens across handler recreation mid-turn', () => { const appended: Msg[] = [] diff --git a/ui-tui/src/__tests__/messages.test.ts b/ui-tui/src/__tests__/messages.test.ts index fc577ab5804b2..3d5799c9794c2 100644 --- a/ui-tui/src/__tests__/messages.test.ts +++ b/ui-tui/src/__tests__/messages.test.ts @@ -14,7 +14,13 @@ describe('toTranscriptMessages', () => { it('preserves assistant tool-call rows so resume does not drop prior turns', () => { const rows = [ { role: 'user', text: 'first prompt' }, - { role: 'tool', context: 'repo', name: 'search_files', text: 'ignored raw result' }, + { + role: 'tool', + context: 'repo', + label: 'Searching files for repo', + name: 'search_files', + text: 'ignored raw result' + }, { role: 'assistant', text: 'first answer' }, { role: 'user', text: 'second prompt' } ] @@ -24,7 +30,7 @@ describe('toTranscriptMessages', () => { ['assistant', 'first answer'], ['user', 'second prompt'] ]) - expect(toTranscriptMessages(rows)[1]?.tools?.[0]).toContain('Search Files') + expect(toTranscriptMessages(rows)[1]?.tools?.[0]).toBe('Searching files for repo ✓') }) }) diff --git a/ui-tui/src/__tests__/text.test.ts b/ui-tui/src/__tests__/text.test.ts index 2202b219944a5..8d8150b78e1b1 100644 --- a/ui-tui/src/__tests__/text.test.ts +++ b/ui-tui/src/__tests__/text.test.ts @@ -8,6 +8,7 @@ import { estimateRows, estimateTokensRough, fmtK, + formatToolCall, hasAnsi, isToolTrailResultLine, lastCotTrailIndex, @@ -36,6 +37,16 @@ describe('buildToolTrailLine', () => { expect(parseToolTrailResultLine(line)).toEqual({ call: 'Read File("x") (0.9s)', detail: '', mark: '✓' }) expect(splitToolDuration('Read File("x") (0.9s)')).toEqual({ label: 'Read File("x")', duration: ' (0.9s)' }) }) + + it('renders a final friendly label without formatting it as context', () => { + expect(formatToolCall('read_file', 'package.json L1-300', 'Reading package.json L1-300')).toBe( + 'Reading package.json L1-300' + ) + expect(buildToolTrailLine('read_file', 'package.json L1-300', false, '', 0.5, 'Reading package.json L1-300')).toBe( + 'Reading package.json L1-300 (0.5s) ✓' + ) + expect(formatToolCall('read_file', 'package.json L1-300')).toBe('Read File("package.json L1-300")') + }) }) describe('buildVerboseToolTrailLine', () => { diff --git a/ui-tui/src/app/createGatewayEventHandler.ts b/ui-tui/src/app/createGatewayEventHandler.ts index 5bb819790d4f1..cafcc5ee29cb2 100644 --- a/ui-tui/src/app/createGatewayEventHandler.ts +++ b/ui-tui/src/app/createGatewayEventHandler.ts @@ -731,6 +731,7 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: ev.payload.tool_id, ev.payload.name ?? 'tool', ev.payload.context ?? '', + ev.payload.label, ev.payload.args_text ? stripAnsi(String(ev.payload.args_text)) : undefined ) diff --git a/ui-tui/src/app/turnController.ts b/ui-tui/src/app/turnController.ts index 89b565d4381b3..75722c36f7349 100644 --- a/ui-tui/src/app/turnController.ts +++ b/ui-tui/src/app/turnController.ts @@ -803,14 +803,16 @@ class TurnController { Boolean(error), duration ?? fallbackDuration, done?.verboseArgs, - error || resultText || summary || '' + error || resultText || summary || '', + done?.label ) : buildToolTrailLine( name, done?.context || '', Boolean(error), error || summary || '', - duration ?? fallbackDuration + duration ?? fallbackDuration, + done?.label ) this.activeTools = this.activeTools.filter(tool => tool.id !== toolId) @@ -857,7 +859,7 @@ class TurnController { }, STREAM_BATCH_MS) } - recordToolStart(toolId: string, name: string, context: string, verboseArgs?: string) { + recordToolStart(toolId: string, name: string, context: string, label?: string, verboseArgs?: string) { if (this.interrupted) { return } @@ -870,7 +872,7 @@ class TurnController { const sample = `${name} ${context}`.trim() this.toolTokenAcc += sample ? estimateTokensRough(sample) : 0 - this.activeTools = [...this.activeTools, { context, id: toolId, name, startedAt: Date.now(), verboseArgs }] + this.activeTools = [...this.activeTools, { context, id: toolId, label, name, startedAt: Date.now(), verboseArgs }] patchTurnState({ toolTokens: this.toolTokenAcc, tools: this.activeTools }) } diff --git a/ui-tui/src/components/thinking.tsx b/ui-tui/src/components/thinking.tsx index 016c99138aff4..a170d829ade11 100644 --- a/ui-tui/src/components/thinking.tsx +++ b/ui-tui/src/components/thinking.tsx @@ -837,7 +837,7 @@ export const ToolTrail = memo(function ToolTrail({ } for (const tool of tools) { - const label = formatToolCall(tool.name, tool.context || '') + const label = formatToolCall(tool.name, tool.context || '', tool.label) groups.push({ color: t.color.text, diff --git a/ui-tui/src/domain/messages.ts b/ui-tui/src/domain/messages.ts index 73f86c3e068f4..37ec257a468f1 100644 --- a/ui-tui/src/domain/messages.ts +++ b/ui-tui/src/domain/messages.ts @@ -44,10 +44,10 @@ export const toTranscriptMessages = (rows: unknown): Msg[] => { continue } - const { context, name, role, text } = row as TranscriptRow + const { context, label, name, role, text } = row as TranscriptRow if (role === 'tool') { - pending.push(buildToolTrailLine(name ?? 'tool', context ?? '')) + pending.push(buildToolTrailLine(name ?? 'tool', context ?? '', false, undefined, undefined, label ?? '')) continue } @@ -85,6 +85,7 @@ interface ImageMeta { interface TranscriptRow { context?: string + label?: string name?: string role?: string text?: string diff --git a/ui-tui/src/gatewayTypes.ts b/ui-tui/src/gatewayTypes.ts index 6eb0317fc2f7c..659fb11020d3a 100644 --- a/ui-tui/src/gatewayTypes.ts +++ b/ui-tui/src/gatewayTypes.ts @@ -17,6 +17,7 @@ export interface GatewayCompletionItem { export interface GatewayTranscriptMessage { context?: string + label?: string name?: string role: 'assistant' | 'system' | 'tool' | 'user' text?: string @@ -668,7 +669,7 @@ export type GatewayEvent = | { payload: { name?: string; preview?: string }; session_id?: string; type: 'tool.progress' } | { payload: { name?: string }; session_id?: string; type: 'tool.generating' } | { - payload: { args_text?: string; context?: string; name?: string; tool_id: string; todos?: unknown[] } + payload: { args_text?: string; context?: string; label?: string; name?: string; tool_id: string; todos?: unknown[] } session_id?: string type: 'tool.start' } diff --git a/ui-tui/src/lib/text.ts b/ui-tui/src/lib/text.ts index dff15e21df5f3..de4e94ce65670 100644 --- a/ui-tui/src/lib/text.ts +++ b/ui-tui/src/lib/text.ts @@ -195,7 +195,13 @@ export const toolTrailLabel = (name: string) => .map(p => p[0]!.toUpperCase() + p.slice(1)) .join(' ') || name -export const formatToolCall = (name: string, context = '') => { +export const formatToolCall = (name: string, context = '', displayLabel = '') => { + const friendlyLabel = displayLabel.trim() + + if (friendlyLabel) { + return friendlyLabel + } + const label = toolTrailLabel(name) const preview = compactPreview(context, 64) @@ -207,12 +213,13 @@ export const buildToolTrailLine = ( context: string, error?: boolean, note?: string, - duration?: number + duration?: number, + displayLabel?: string ) => { const detail = compactPreview(note ?? '', 72) const took = duration !== undefined ? ` (${duration.toFixed(1)}s)` : '' - return `${formatToolCall(name, context)}${took}${detail ? ` :: ${detail}` : ''} ${error ? '✗' : '✓'}` + return `${formatToolCall(name, context, displayLabel)}${took}${detail ? ` :: ${detail}` : ''} ${error ? '✗' : '✓'}` } const verboseToolBlock = (label: string, text?: string) => { @@ -236,7 +243,8 @@ export const buildVerboseToolTrailLine = ( error?: boolean, duration?: number, argsText?: string, - resultText?: string + resultText?: string, + displayLabel?: string ) => { const detail = [verboseToolBlock('Args', argsText), verboseToolBlock(error ? 'Error' : 'Result', resultText)] .filter(Boolean) @@ -244,7 +252,7 @@ export const buildVerboseToolTrailLine = ( const took = duration !== undefined ? ` (${duration.toFixed(1)}s)` : '' - return `${formatToolCall(name, context)}${took}${detail ? ` :: ${detail}` : ''} ${error ? '✗' : '✓'}` + return `${formatToolCall(name, context, displayLabel)}${took}${detail ? ` :: ${detail}` : ''} ${error ? '✗' : '✓'}` } export const isToolTrailResultLine = (line: string) => line.endsWith(' ✓') || line.endsWith(' ✗') diff --git a/ui-tui/src/types.ts b/ui-tui/src/types.ts index 14ab98ca18ddd..6f930ba5fc1c9 100644 --- a/ui-tui/src/types.ts +++ b/ui-tui/src/types.ts @@ -1,6 +1,7 @@ export interface ActiveTool { context?: string id: string + label?: string name: string verboseArgs?: string startedAt?: number From 82d2567d96ba2cd94a507f54c4968f9e4801e2d6 Mon Sep 17 00:00:00 2001 From: Muspi Merol Date: Sun, 12 Jul 2026 03:51:17 +0800 Subject: [PATCH 2/2] fix(tui): preserve delegate grouping for friendly labels --- ui-tui/src/__tests__/messages.test.ts | 85 +++++++++++++++++++-------- ui-tui/src/components/thinking.tsx | 7 ++- 2 files changed, 67 insertions(+), 25 deletions(-) diff --git a/ui-tui/src/__tests__/messages.test.ts b/ui-tui/src/__tests__/messages.test.ts index 3d5799c9794c2..a11d5f9a589a6 100644 --- a/ui-tui/src/__tests__/messages.test.ts +++ b/ui-tui/src/__tests__/messages.test.ts @@ -5,10 +5,38 @@ import React from 'react' import { describe, expect, it } from 'vitest' import { MessageLine } from '../components/messageLine.js' +import { ToolTrail } from '../components/thinking.js' import { toTranscriptMessages } from '../domain/messages.js' import { upsert } from '../lib/messages.js' import { stripAnsi } from '../lib/text.js' import { DEFAULT_THEME } from '../theme.js' +import type { SubagentProgress } from '../types.js' + +const renderText = (element: React.ReactElement, columns = 80) => { + const stdout = new PassThrough() + const stdin = new PassThrough() + const stderr = new PassThrough() + let output = '' + + Object.assign(stdout, { columns, isTTY: false, rows: 24 }) + Object.assign(stdin, { isTTY: false }) + Object.assign(stderr, { isTTY: false }) + stdout.on('data', chunk => { + output += chunk.toString() + }) + + const instance = renderSync(element, { + patchConsole: false, + stderr: stderr as NodeJS.WriteStream, + stdin: stdin as NodeJS.ReadStream, + stdout: stdout as NodeJS.WriteStream + }) + + instance.unmount() + instance.cleanup() + + return stripAnsi(output) +} describe('toTranscriptMessages', () => { it('preserves assistant tool-call rows so resume does not drop prior turns', () => { @@ -36,40 +64,19 @@ describe('toTranscriptMessages', () => { describe('MessageLine', () => { it('preserves a separator after compound user prompt glyphs in transcript rows', () => { - const stdout = new PassThrough() - const stdin = new PassThrough() - const stderr = new PassThrough() - let output = '' - - Object.assign(stdout, { columns: 80, isTTY: false, rows: 24 }) - Object.assign(stdin, { isTTY: false }) - Object.assign(stderr, { isTTY: false }) - stdout.on('data', chunk => { - output += chunk.toString() - }) - const t = { ...DEFAULT_THEME, brand: { ...DEFAULT_THEME.brand, prompt: 'Ψ >' } } - const instance = renderSync( + const output = renderText( React.createElement(MessageLine, { cols: 80, msg: { role: 'user', text: 'Okay' }, t - }), - { - patchConsole: false, - stderr: stderr as NodeJS.WriteStream, - stdin: stdin as NodeJS.ReadStream, - stdout: stdout as NodeJS.WriteStream - } + }) ) - instance.unmount() - instance.cleanup() - const renderedLine = stripAnsi(output) .split('\n') .find(line => line.includes('Okay')) @@ -78,6 +85,38 @@ describe('MessageLine', () => { }) }) +describe('ToolTrail', () => { + it('keeps friendly delegate labels attached to their inline subagents', () => { + const subagent: SubagentProgress = { + depth: 0, + goal: 'Inspect regression', + id: 'agent-1', + index: 0, + notes: [], + parentId: null, + status: 'running', + taskCount: 1, + thinking: [], + toolCount: 0, + tools: [] + } + + const output = renderText( + React.createElement(ToolTrail, { + detailsMode: 'expanded', + subagents: [subagent], + t: DEFAULT_THEME, + tools: [{ id: 'delegate-1', label: 'Delegating inspect regression', name: 'delegate_task' }] + }) + ) + + expect(output).toContain('Delegating inspect regression') + expect(output).toContain('/agents to monitor') + expect(output).toContain('Inspect regression') + expect(output).not.toContain('Spawn tree') + }) +}) + describe('upsert', () => { it('appends when last role differs', () => { expect(upsert([{ role: 'user', text: 'hi' }], 'assistant', 'hello')).toHaveLength(2) diff --git a/ui-tui/src/components/thinking.tsx b/ui-tui/src/components/thinking.tsx index a170d829ade11..9917eb0677271 100644 --- a/ui-tui/src/components/thinking.tsx +++ b/ui-tui/src/components/thinking.tsx @@ -46,6 +46,9 @@ const fmtElapsed = (ms: number) => { return sec < 10 ? `${sec.toFixed(1)}s` : `${Math.round(sec)}s` } +const isDelegateToolLabel = (label: string) => + label.startsWith('Delegate Task') || label === 'Delegating' || label.startsWith('Delegating ') + type TreeBranch = 'mid' | 'last' type TreeRails = readonly boolean[] @@ -886,7 +889,7 @@ export const ToolTrail = memo(function ToolTrail({ const toolTokensLabel = toolTokens !== undefined && toolTokens > 0 ? `~${fmtK(toolTokens)} tokens` : undefined const totalTokensLabel = tokenCount > 0 && toolTokenCount > 0 ? `~${fmtK(totalTokenCount)} total` : null - const delegateGroups = groups.filter(g => g.label.startsWith('Delegate Task')) + const delegateGroups = groups.filter(g => isDelegateToolLabel(g.label)) const inlineDelegateKey = hasSubagents && delegateGroups.length === 1 ? delegateGroups[0]!.key : null const toolLabel = (group: Group) => { @@ -1063,7 +1066,7 @@ export const ToolTrail = memo(function ToolTrail({ // Surface the /agents hint the moment a delegate group appears — // while it's still in-flight and before any subagent has // registered — so users can open the live monitor immediately. - const isDelegateGroup = group.label.startsWith('Delegate Task') + const isDelegateGroup = isDelegateToolLabel(group.label) return (