From 57e8d44af8d7e6db6eb26dec6f8c0c1d27746d4b Mon Sep 17 00:00:00 2001 From: jonny Date: Sat, 11 Apr 2026 05:23:44 +0000 Subject: [PATCH 1/2] fix(tui): preserve tool metadata in resumed session history session.resume was building conversation history with only role and content, stripping tool_call_id, tool_calls, and tool_name. The API requires tool messages to reference their parent tool_call, so resumed sessions with tool history would fail with HTTP 500. Use get_messages_as_conversation() which already preserves the full message structure including tool metadata and reasoning fields. --- tui_gateway/server.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/tui_gateway/server.py b/tui_gateway/server.py index e53694d1d..3c9120113 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -483,14 +483,13 @@ def _(rid, params: dict) -> dict: os.environ["HERMES_INTERACTIVE"] = "1" try: db.reopen_session(target) + history = db.get_messages_as_conversation(target) messages = [ - {"role": m["role"], "text": m["content"] or ""} - for m in db.get_messages(target) + {"role": m["role"], "text": m.get("content") or ""} + for m in history if m.get("role") in ("user", "assistant", "tool", "system") - and isinstance(m.get("content"), str) and (m.get("content") or "").strip() ] - history = [{"role": m["role"], "content": m["text"]} for m in messages] agent = _make_agent(sid, target, session_id=target) _init_session(sid, target, agent, history, cols=int(params.get("cols", 80))) except Exception as e: From cab6447d5831c583257b2e7233cf51930fa32399 Mon Sep 17 00:00:00 2001 From: jonny Date: Sat, 11 Apr 2026 06:35:00 +0000 Subject: [PATCH 2/2] 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(' ✗')