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:
parent
aec752faa3
commit
ee211d087b
@ -2931,7 +2931,13 @@ def _coerce_message_text(content: Any) -> str:
|
|||||||
return str(content)
|
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 = []
|
messages = []
|
||||||
tool_call_args = {}
|
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
|
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"
|
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 {}
|
args = (tc_info[1] if tc_info else None) or {}
|
||||||
messages.append(
|
tool_msg = {"role": "tool", "name": name, "context": _tool_ctx(name, args)}
|
||||||
{"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
|
continue
|
||||||
if not content_text.strip():
|
if not content_text.strip():
|
||||||
continue
|
continue
|
||||||
@ -3338,7 +3348,9 @@ def _(rid, params: dict) -> dict:
|
|||||||
display_history_prefix = display_history[
|
display_history_prefix = display_history[
|
||||||
: max(0, len(display_history) - len(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)
|
tokens = _set_session_context(target)
|
||||||
try:
|
try:
|
||||||
# Pass the profile's db so the agent persists turns to the right
|
# Pass the profile's db so the agent persists turns to the right
|
||||||
|
|||||||
@ -66,7 +66,10 @@ const resumeInto = (gateway: GatewayServiceShape, store: SessionStore, sid: stri
|
|||||||
const t0 = Date.now()
|
const t0 = Date.now()
|
||||||
const resumed = yield* gateway.request<{ messages?: unknown; info?: Record<string, unknown> }>('session.resume', {
|
const resumed = yield* gateway.request<{ messages?: unknown; info?: Record<string, unknown> }>('session.resume', {
|
||||||
cols,
|
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 t1 = Date.now()
|
||||||
const snapshot = mapResumeHistory(resumed?.messages)
|
const snapshot = mapResumeHistory(resumed?.messages)
|
||||||
|
|||||||
@ -9,7 +9,8 @@
|
|||||||
* a live one. Resumed assistant text is given a single text part so it renders
|
* 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*`).
|
* 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 {
|
function readStr(value: unknown, key: string): string | undefined {
|
||||||
if (!value || typeof value !== 'object') return undefined
|
if (!value || typeof value !== 'object') return undefined
|
||||||
@ -55,8 +56,28 @@ export function mapResumeHistory(history: unknown): Message[] {
|
|||||||
if (role === 'tool') {
|
if (role === 'tool') {
|
||||||
const name = readStr(raw, 'name') ?? 'tool'
|
const name = readStr(raw, 'name') ?? 'tool'
|
||||||
const context = readStr(raw, 'context')
|
const context = readStr(raw, 'context')
|
||||||
const tool: Part = { type: 'tool', id: id(), name, state: 'complete' }
|
const tool: ToolPartState = { type: 'tool', id: id(), name, state: 'complete' }
|
||||||
if (context) tool.summary = context
|
// 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) {
|
if (!currentAssistant) {
|
||||||
currentAssistant = { role: 'assistant', text: '', parts: [] }
|
currentAssistant = { role: 'assistant', text: '', parts: [] }
|
||||||
out.push(currentAssistant)
|
out.push(currentAssistant)
|
||||||
|
|||||||
@ -22,7 +22,8 @@ describe('mapResumeHistory (Phase 4b)', () => {
|
|||||||
expect(a1.parts?.map(p => p.type)).toEqual(['text', 'tool']) // text + folded tool, inline
|
expect(a1.parts?.map(p => p.type)).toEqual(['text', 'tool']) // text + folded tool, inline
|
||||||
const tool = a1.parts![1]!
|
const tool = a1.parts![1]!
|
||||||
if (tool.type === 'tool') {
|
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 {
|
} else {
|
||||||
throw new Error('expected a folded tool part')
|
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' }])
|
const msgs = mapResumeHistory([{ role: 'tool', name: 'read_file', context: 'foo.ts' }])
|
||||||
expect(msgs).toHaveLength(1)
|
expect(msgs).toHaveLength(1)
|
||||||
expect(msgs[0]!.role).toBe('assistant')
|
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', () => {
|
test('ignores non-arrays and unknown roles', () => {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user