fix(tui): render tool trail consistently between live and resume

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.
This commit is contained in:
jonny 2026-04-11 06:35:00 +00:00
parent 57e8d44af8
commit cab6447d58
3 changed files with 70 additions and 24 deletions

View file

@ -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:

View file

@ -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)

View file

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