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(' ✗')