opentui(v5/item1): resumed tools render like live (collapsible + output)

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).
This commit is contained in:
alt-glitch 2026-06-09 03:32:52 +00:00
parent aec752faa3
commit ee211d087b
4 changed files with 68 additions and 11 deletions

View file

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

View file

@ -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<string, unknown> }>('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)

View file

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

View file

@ -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', () => {