diff --git a/apps/desktop/src/components/assistant-ui/tool/fallback-model.test.ts b/apps/desktop/src/components/assistant-ui/tool/fallback-model.test.ts index 275cc13fb4f6..19a8cd61447c 100644 --- a/apps/desktop/src/components/assistant-ui/tool/fallback-model.test.ts +++ b/apps/desktop/src/components/assistant-ui/tool/fallback-model.test.ts @@ -340,6 +340,28 @@ describe('buildToolView title actions', () => { expect(view.titleAction).toEqual({ prefix: '', text: 'Running', suffix: ' pnpm run lint' }) }) + it('never stutters the verb or echoes the command when the backend context is a phrased label', () => { + // Older backends stamped tool.start with a *phrased* label + // ("Running sleep 70 + 2 commands") rather than a raw arg preview, and the + // desktop merges that into args.context. The row must still prepend its own + // verb exactly once, show the real command in the `$` transcript, and not + // repeat either string as detail. + const command = 'sleep 70; echo "a"; echo "b"' + + const view = buildToolView( + part({ + args: { command, context: 'Running sleep 70 + 2 commands' }, + result: { exit_code: 0 }, + toolName: 'terminal' + }), + '' + ) + + expect(view.title).toBe('Ran sleep 70 + 2 commands') + expect(view.terminalCommand).toBe(command) + expect(view.detail).toBe('') + }) + it('uses the runtime locale for title text and action placement', () => { setRuntimeI18nLocale('ja') diff --git a/apps/desktop/src/components/assistant-ui/tool/fallback-model/index.ts b/apps/desktop/src/components/assistant-ui/tool/fallback-model/index.ts index 5c6fd8c9b034..6bcd06a63a93 100644 --- a/apps/desktop/src/components/assistant-ui/tool/fallback-model/index.ts +++ b/apps/desktop/src/components/assistant-ui/tool/fallback-model/index.ts @@ -128,9 +128,14 @@ function readFileDisplayTarget(args: Record, result: Record): string { return ( - firstStringField(args, ['context', 'preview']) || firstStringField(args, ['command', 'code']) || contextValue(args) + firstStringField(args, ['command', 'code']) || firstStringField(args, ['context', 'preview']) || contextValue(args) ) } @@ -1079,6 +1084,13 @@ function toolDetailText( if (output || lines) { return [output, lines].filter(Boolean).join('\n') } + + // A terminal row with no output already shows its command in the `$` + // transcript above; the generic fallback would print the same string a + // second time. `execute_code` has no transcript, so it keeps the fallback. + if (part.toolName === 'terminal') { + return '' + } } if (part.toolName === 'web_extract') { diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index cd751aaa9f10..4d86c18ba48f 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -1551,12 +1551,24 @@ 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", "name": "search_files", "role": "tool"}, {"role": "assistant", "text": "first answer"}, {"role": "user", "text": "second prompt"}, ] +def test_tool_ctx_sends_an_arg_preview_not_a_phrased_label(): + # Clients phrase their own verb around this string: the TUI renders + # `Terminal("")` and the desktop prepends "Running"/"Ran". Sending a + # pre-phrased label made both stutter ("Ran Running sleep 70 + 2 commands") + # and stood in for the real command in the desktop's `$` transcript. + assert server._tool_ctx("terminal", {"command": 'sleep 70; echo "a"; echo "b"'}) == ( + "sleep 70 + 2 commands" + ) + assert server._tool_ctx("read_file", {"path": "/tmp/demo/package.json"}) == "package.json" + assert server._tool_ctx("web_search", {"query": "weather in NYC"}) == "weather in NYC" + + def test_history_to_messages_keeps_reasoning_only_assistant_turn(): # A thinking-only assistant turn (reasoning present, no visible text) is # persisted and recallable, but was dropped from the resumed session view diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 0d769f343430..bd39477aa6b9 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -4505,10 +4505,20 @@ def _session_info(agent, session: dict | None = None) -> dict: def _tool_ctx(name: str, args: dict) -> str: - try: - from agent.display import build_tool_label + """Argument preview for a tool row — never a phrased label. - return build_tool_label(name, args, max_len=80) or "" + Clients own their own phrasing: the TUI wraps this as ``Terminal("...")`` + and the desktop prepends its own localized verb ("Running"/"Ran"). Sending + ``build_tool_label`` here instead of the raw preview stutters the verb on + both surfaces ("Running Running sleep 70 + 2 commands") and leaks a display + label into the desktop's ``args.context``, where it stands in for the real + command. The friendly labels belong on the CLI spinner, which builds them + from ``build_tool_label`` at its own call sites. + """ + try: + from agent.display import build_tool_preview + + return build_tool_preview(name, args, max_len=80) or "" except Exception: return ""