From cab6447d5831c583257b2e7233cf51930fa32399 Mon Sep 17 00:00:00 2001 From: jonny Date: Sat, 11 Apr 2026 06:35:00 +0000 Subject: [PATCH] fix(tui): render tool trail consistently between live and resume MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resumed sessions showed raw JSON tool output in content boxes instead of the compact trail lines seen during live use. The root cause was two separate rendering paths with no shared code. Extract buildToolTrailLine() into lib/text.ts as the single source of truth for formatting tool trail lines. Both the live tool.complete handler and toTranscriptMessages now call it. Server-side, reconstruct tool name and args from the assistant message's tool_calls field (tool_name column is unpopulated) and pass them through _tool_ctx/build_tool_preview — the same path the live tool.start callback uses. --- tui_gateway/server.py | 35 ++++++++++++++++++++++++----- ui-tui/src/app.tsx | 50 ++++++++++++++++++++++++++++-------------- ui-tui/src/lib/text.ts | 9 +++++++- 3 files changed, 70 insertions(+), 24 deletions(-) diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 3c9120113..5f50ab630 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -484,12 +484,35 @@ def _(rid, params: dict) -> dict: try: db.reopen_session(target) history = db.get_messages_as_conversation(target) - messages = [ - {"role": m["role"], "text": m.get("content") or ""} - for m in history - if m.get("role") in ("user", "assistant", "tool", "system") - and (m.get("content") or "").strip() - ] + messages = [] + tool_call_args = {} + for m in history: + role = m.get("role") + if role not in ("user", "assistant", "tool", "system"): + continue + if role == "assistant" and m.get("tool_calls"): + for tc in m["tool_calls"]: + fn = tc.get("function", {}) + tc_id = tc.get("id", "") + if tc_id and fn.get("name"): + try: + args = json.loads(fn.get("arguments", "{}")) + except (json.JSONDecodeError, TypeError): + args = {} + tool_call_args[tc_id] = (fn["name"], args) + if not (m.get("content") or "").strip(): + continue + if role == "tool": + tc_id = m.get("tool_call_id", "") + tc_info = tool_call_args.get(tc_id) if tc_id else None + 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 {} + ctx = _tool_ctx(name, args) + messages.append({"role": "tool", "name": name, "context": ctx}) + continue + if not (m.get("content") or "").strip(): + continue + messages.append({"role": role, "text": m.get("content") or ""}) agent = _make_agent(sid, target, session_id=target) _init_session(sid, target, agent, history, cols=int(params.get("cols", 80))) except Exception as e: diff --git a/ui-tui/src/app.tsx b/ui-tui/src/app.tsx index 91f45eabf..e6eba6209 100644 --- a/ui-tui/src/app.tsx +++ b/ui-tui/src/app.tsx @@ -20,7 +20,7 @@ import { useCompletion } from './hooks/useCompletion.js' import { useInputHistory } from './hooks/useInputHistory.js' import { useQueue } from './hooks/useQueue.js' import { writeOsc52Clipboard } from './lib/osc52.js' -import { compactPreview, fmtK, hasInterpolation, isToolTrailResultLine, pick, sameToolTrailGroup } from './lib/text.js' +import { buildToolTrailLine, compactPreview, fmtK, hasInterpolation, isToolTrailResultLine, pick, sameToolTrailGroup } from './lib/text.js' import { DEFAULT_THEME, fromSkin, type Theme } from './theme.js' import type { ActiveTool, @@ -107,24 +107,41 @@ const toTranscriptMessages = (rows: unknown): Msg[] => { return [] } - return rows.flatMap(row => { - if (!row || typeof row !== 'object') { - return [] - } + const result: Msg[] = [] + let pendingTools: string[] = [] + + for (const row of rows) { + if (!row || typeof row !== 'object') continue const role = (row as any).role const text = (row as any).text - if ( - (role !== 'assistant' && role !== 'system' && role !== 'tool' && role !== 'user') || - typeof text !== 'string' || - !text.trim() - ) { - return [] + if (role === 'tool') { + const name = (row as any).name ?? 'tool' + const ctx = (row as any).context ?? '' + pendingTools.push(buildToolTrailLine(name, ctx)) + continue } - return [{ role, text }] - }) + if (typeof text !== 'string' || !text.trim()) continue + + if (role === 'assistant') { + const msg: Msg = { role, text } + if (pendingTools.length) { + msg.tools = pendingTools + pendingTools = [] + } + result.push(msg) + continue + } + + if (role === 'user' || role === 'system') { + pendingTools = [] + result.push({ role, text }) + } + } + + return result } // ── StatusRule ──────────────────────────────────────────────────────── @@ -1155,14 +1172,13 @@ export function App({ gw }: { gw: GatewayClient }) { break case 'tool.complete': { - const mark = p.error ? '✗' : '✓' - toolCompleteRibbonRef.current = null setTools(prev => { const done = prev.find(t => t.id === p.tool_id) - const label = TOOL_VERBS[done?.name ?? p.name] ?? done?.name ?? p.name + const name = done?.name ?? p.name const ctx = (p.error as string) || done?.context || '' - const line = `${label}${ctx ? ': ' + compactPreview(ctx, 72) : ''} ${mark}` + const label = TOOL_VERBS[name] ?? name + const line = buildToolTrailLine(name, ctx, !!p.error) toolCompleteRibbonRef.current = { label, line } const remaining = prev.filter(t => t.id !== p.tool_id) diff --git a/ui-tui/src/lib/text.ts b/ui-tui/src/lib/text.ts index 7f835c0cd..e1364d8de 100644 --- a/ui-tui/src/lib/text.ts +++ b/ui-tui/src/lib/text.ts @@ -1,4 +1,4 @@ -import { INTERPOLATION_RE, LONG_MSG } from '../constants.js' +import { INTERPOLATION_RE, LONG_MSG, TOOL_VERBS } from '../constants.js' // eslint-disable-next-line no-control-regex const ANSI_RE = /\x1b\[[0-9;]*m/g @@ -35,6 +35,13 @@ export const compactPreview = (s: string, max: number) => { return !one ? '' : one.length > max ? one.slice(0, max - 1) + '…' : one } +/** Build a single tool trail line — used by both live tool.complete and resume replay. */ +export const buildToolTrailLine = (name: string, context: string, error?: boolean): string => { + const label = TOOL_VERBS[name] ?? name + const mark = error ? '✗' : '✓' + return `${label}${context ? ': ' + compactPreview(context, 72) : ''} ${mark}` +} + /** Tool completed / failed row in the inline trail (not CoT prose). */ export const isToolTrailResultLine = (line: string) => line.endsWith(' ✓') || line.endsWith(' ✗')