From ee211d087ba8b98894a76b9be29ac14994f524ca Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Tue, 9 Jun 2026 03:32:52 +0000 Subject: [PATCH] opentui(v5/item1): resumed tools render like live (collapsible + output) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resumed tool calls were flat `⚡name arg` rows — no output, not collapsible — because the resume snapshot (_history_to_messages) dropped each tool's result. Now the native engine passes `with_tool_output: true` on session.resume and the gateway folds the tool's redacted+capped result + args into its row, so resumed turns show `▶ name arg (N lines)` collapsible blocks identical to a live turn. The flag is OPT-IN: Ink doesn't pass it, so _history_to_messages stays byte-for- byte unchanged for the Ink path (its expanded verbose-trail render OOM'd on big output, #34095; the native engine renders tools collapsed, so the capped tail is safe there). resume.ts maps context→argsPreview, result_text→resultText (label peeled + envelope stripped), args→argsText — same shape as the live tool part. py_compile OK. resume tests updated + 1 added (79 pass). --- tui_gateway/server.py | 22 +++++++++++++----- ui-tui-opentui-v2/src/entry/main.tsx | 5 ++++- ui-tui-opentui-v2/src/logic/resume.ts | 27 ++++++++++++++++++++--- ui-tui-opentui-v2/src/test/resume.test.ts | 25 +++++++++++++++++++-- 4 files changed, 68 insertions(+), 11 deletions(-) diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 4dd9592a77b..1a55bfc652b 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -2931,7 +2931,13 @@ def _coerce_message_text(content: Any) -> str: return str(content) -def _history_to_messages(history: list[dict]) -> list[dict]: +def _history_to_messages(history: list[dict], include_tool_output: bool = False) -> list[dict]: + # ``include_tool_output`` (opt-in; only the native/opentui engine passes it via + # session.resume) folds each tool's redacted+capped result + args into its row so + # a resumed transcript renders collapsible tool blocks identical to a live turn. + # OFF by default so the Ink path is byte-for-byte unchanged (its render tree showed + # the verbose trail expanded and OOM'd on big output — #34095; the native engine + # renders tools collapsed, so shipping the same capped tail is safe there). messages = [] tool_call_args = {} @@ -2959,9 +2965,13 @@ def _history_to_messages(history: list[dict]) -> list[dict]: 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 {} - messages.append( - {"role": "tool", "name": name, "context": _tool_ctx(name, args)} - ) + tool_msg = {"role": "tool", "name": name, "context": _tool_ctx(name, args)} + if include_tool_output: + if args: + tool_msg["args"] = args + if content_text.strip(): + tool_msg["result_text"] = _redact_tui_verbose_text(content_text) + messages.append(tool_msg) continue if not content_text.strip(): continue @@ -3338,7 +3348,9 @@ def _(rid, params: dict) -> dict: display_history_prefix = display_history[ : max(0, len(display_history) - len(history)) ] - messages = _history_to_messages(display_history) + messages = _history_to_messages( + display_history, include_tool_output=bool(params.get("with_tool_output")) + ) tokens = _set_session_context(target) try: # Pass the profile's db so the agent persists turns to the right diff --git a/ui-tui-opentui-v2/src/entry/main.tsx b/ui-tui-opentui-v2/src/entry/main.tsx index bc1de60b462..9787ab0e37e 100644 --- a/ui-tui-opentui-v2/src/entry/main.tsx +++ b/ui-tui-opentui-v2/src/entry/main.tsx @@ -66,7 +66,10 @@ const resumeInto = (gateway: GatewayServiceShape, store: SessionStore, sid: stri const t0 = Date.now() const resumed = yield* gateway.request<{ messages?: unknown; info?: Record }>('session.resume', { cols, - session_id: sid + session_id: sid, + // native engine renders tools collapsed → safe to fold each tool's capped + // result into the resume snapshot so resumed turns render like live (item 1). + with_tool_output: true }) const t1 = Date.now() const snapshot = mapResumeHistory(resumed?.messages) diff --git a/ui-tui-opentui-v2/src/logic/resume.ts b/ui-tui-opentui-v2/src/logic/resume.ts index 0543f4861a5..f1f01ff4b31 100644 --- a/ui-tui-opentui-v2/src/logic/resume.ts +++ b/ui-tui-opentui-v2/src/logic/resume.ts @@ -9,7 +9,8 @@ * a live one. Resumed assistant text is given a single text part so it renders * through the native markdown path. IDs are `r*` (distinct from live `p*`). */ -import type { Message, Part, SessionItem } from './store.ts' +import type { Message, Part, SessionItem, ToolPartState } from './store.ts' +import { stripOmittedNote, stripToolEnvelope } from './toolOutput.ts' function readStr(value: unknown, key: string): string | undefined { if (!value || typeof value !== 'object') return undefined @@ -55,8 +56,28 @@ export function mapResumeHistory(history: unknown): Message[] { if (role === 'tool') { const name = readStr(raw, 'name') ?? 'tool' const context = readStr(raw, 'context') - const tool: Part = { type: 'tool', id: id(), name, state: 'complete' } - if (context) tool.summary = context + const tool: ToolPartState = { type: 'tool', id: id(), name, state: 'complete' } + // Match the live tool part exactly (item 1): primary-arg preview in the + // header, plus the (capped) output so resumed tools are collapsible too. + if (context) tool.argsPreview = context + const rawResult = readStr(raw, 'result_text') + if (rawResult) { + const { body, omittedNote } = stripOmittedNote(rawResult) + const resultText = stripToolEnvelope(body) + if (resultText) { + tool.resultText = resultText + tool.lineCount = resultText.replace(/\s+$/, '').split('\n').length + } + if (omittedNote) tool.omittedNote = omittedNote + } + const args = (raw as { args?: unknown }).args + if (args && typeof args === 'object') { + try { + tool.argsText = JSON.stringify(args, null, 2) + } catch { + /* unstringifiable — leave unset */ + } + } if (!currentAssistant) { currentAssistant = { role: 'assistant', text: '', parts: [] } out.push(currentAssistant) diff --git a/ui-tui-opentui-v2/src/test/resume.test.ts b/ui-tui-opentui-v2/src/test/resume.test.ts index df76f93b04f..4aef5c8e37e 100644 --- a/ui-tui-opentui-v2/src/test/resume.test.ts +++ b/ui-tui-opentui-v2/src/test/resume.test.ts @@ -22,7 +22,8 @@ describe('mapResumeHistory (Phase 4b)', () => { expect(a1.parts?.map(p => p.type)).toEqual(['text', 'tool']) // text + folded tool, inline const tool = a1.parts![1]! if (tool.type === 'tool') { - expect(tool).toMatchObject({ name: 'terminal', state: 'complete', summary: 'ls -la' }) + // context → argsPreview (same field as a live tool part, so it renders identically) + expect(tool).toMatchObject({ name: 'terminal', state: 'complete', argsPreview: 'ls -la' }) } else { throw new Error('expected a folded tool part') } @@ -33,7 +34,27 @@ describe('mapResumeHistory (Phase 4b)', () => { const msgs = mapResumeHistory([{ role: 'tool', name: 'read_file', context: 'foo.ts' }]) expect(msgs).toHaveLength(1) expect(msgs[0]!.role).toBe('assistant') - expect(msgs[0]!.parts?.[0]).toMatchObject({ type: 'tool', name: 'read_file', summary: 'foo.ts' }) + expect(msgs[0]!.parts?.[0]).toMatchObject({ type: 'tool', name: 'read_file', argsPreview: 'foo.ts' }) + }) + + test('folds result_text + args so resumed tools render collapsible like live (item 1)', () => { + const msgs = mapResumeHistory([ + { role: 'assistant', text: 'Running.' }, + { + role: 'tool', + name: 'terminal', + context: 'ls /usr/bin', + args: { command: 'ls /usr/bin' }, + result_text: '[showing verbose tail; omitted 90 chars]\n{"output":"a\\nb\\nc","exit_code":0}' + } + ]) + const tool = msgs[0]!.parts![1]! + if (tool.type !== 'tool') throw new Error('expected a folded tool part') + expect(tool.argsPreview).toBe('ls /usr/bin') + expect(tool.resultText).toBe('a\nb\nc') // label peeled + envelope stripped → collapsible + expect(tool.lineCount).toBe(3) + expect(tool.omittedNote).toBe('90 chars') + expect(tool.argsText).toContain('"command"') }) test('ignores non-arrays and unknown roles', () => {