From 63a7810b52341fcf676bec91d2153f604c80d168 Mon Sep 17 00:00:00 2001 From: Steven Chong <25894545+teamchong@users.noreply.github.com> Date: Tue, 26 May 2026 11:05:59 -0400 Subject: [PATCH] fix(history): preserve git status output as text --- src/core/history.ts | 65 +++++++++++++++++++++++++++++++++++++++---- tests/history.test.ts | 35 +++++++++++++++++++++++ 2 files changed, 95 insertions(+), 5 deletions(-) diff --git a/src/core/history.ts b/src/core/history.ts index 4aa8d9c..d3fdd17 100644 --- a/src/core/history.ts +++ b/src/core/history.ts @@ -113,6 +113,48 @@ export interface HistoryCollapseInfo { droppedCodepoints: Map; } +const MAX_VERBATIM_GIT_STATUS_CHARS = 8000; + +function toolResultTextContent(block: ToolResultBlock): string { + const inner = block.content; + if (typeof inner === 'string') return inner; + if (!Array.isArray(inner)) return ''; + const parts: string[] = []; + for (const sub of inner) { + if (sub && (sub as TextBlock).type === 'text') parts.push((sub as TextBlock).text); + } + return parts.join('\n'); +} + +function isGitStatusShortOutput(text: string): boolean { + if (!text || text.length > MAX_VERBATIM_GIT_STATUS_CHARS) return false; + const lines = text.split(/\r?\n/).filter((line) => line.length > 0); + if (lines.length === 0) return false; + return lines.every((line) => /^(?:[ MADRCUT?!]{2}|[?!]{2}) .+/.test(line)); +} + +function collectVerbatimGitStatusOutputs( + messages: Message[], + upToExclusive: number, +): string[] { + const out: string[] = []; + let used = 0; + const limit = Math.min(upToExclusive, messages.length); + for (let i = 0; i < limit; i++) { + const msg = messages[i]!; + if (!Array.isArray(msg.content)) continue; + for (const blk of msg.content) { + if (!blk || (blk as { type?: string }).type !== 'tool_result') continue; + const text = toolResultTextContent(blk as ToolResultBlock); + if (!isGitStatusShortOutput(text)) continue; + if (used + text.length > MAX_VERBATIM_GIT_STATUS_CHARS) return out; + out.push(text); + used += text.length; + } + } + return out; +} + /** * Compute the largest index `i*` in `[0..messages.length - keepTail - 1]` * such that all `tool_use_id`s issued by assistant turns in `[0..i*]` are @@ -367,13 +409,26 @@ export async function collapseHistory( } } // Build the synthetic user message. + const verbatimGitStatusOutputs = collectVerbatimGitStatusOutputs(messages, collapseLen); + const verbatimBlocks: TextBlock[] = verbatimGitStatusOutputs.length > 0 + ? [{ + type: 'text', + text: [ + '[Exact git status --short output preserved as text. Use this block as the source of truth over the rendered history image.]', + ...verbatimGitStatusOutputs.map((out, i) => `--- git status --short #${i + 1} ---\n${out}`), + '[End exact git status --short output.]', + ].join('\n\n'), + }] + : []; + const syntheticContent: ContentBlock[] = [ + { type: 'text', text: '[Earlier in this conversation:]' }, + ...imageBlocks, + ...verbatimBlocks, + { type: 'text', text: '[End of earlier context.]' }, + ]; const syntheticUser: Message = { role: 'user', - content: [ - { type: 'text', text: '[Earlier in this conversation:]' }, - ...imageBlocks, - { type: 'text', text: '[End of earlier context.]' }, - ], + content: syntheticContent, }; const tail = messages.slice(collapseLen); info.collapsedTurns = collapseLen; diff --git a/tests/history.test.ts b/tests/history.test.ts index 0df75d7..4ee1be6 100644 --- a/tests/history.test.ts +++ b/tests/history.test.ts @@ -312,6 +312,41 @@ describe('collapseHistory', () => { expect(out[2]).toBe(msgs[11]); }); + it('preserves git status --short tool output as exact text alongside history images', async () => { + const status = [ + 'M package-lock.json', + 'M package.json', + 'M packages/capnweb-typecheck/DESIGN.md', + 'M packages/capnweb-typecheck/src/index.ts', + ' M packages/capnweb-typecheck/src/plugin.ts', + 'M tsdown.config.ts', + '?? handoff.md', + '?? packages/capnweb-typecheck/src/transform/', + ].join('\n'); + const msgs: Message[] = [ + usr('run git status'), + asst([{ type: 'tool_use', id: 'bash-1', name: 'bash', input: { command: 'git status --short' } }]), + usr([{ type: 'tool_result', tool_use_id: 'bash-1', content: status }]), + asst('noted'), + ]; + + const { messages: out, info } = await collapseHistory(msgs, () => true, { + keepTail: 0, + minCollapsePrefix: 1, + collapseChunk: 0, + }); + + expect(info.reason).toBe(undefined); + const content = out[0]!.content as Array>; + expect(content.some((c) => c.type === 'image')).toBe(true); + const exact = content.find( + (c) => c.type === 'text' && typeof c.text === 'string' && c.text.includes('source of truth'), + ); + expect(exact).toBeTruthy(); + expect((exact!.text as string)).toContain(status); + for (const line of status.split('\n')) expect((exact!.text as string)).toContain(line); + }); + it('preserves a tool_use sequence that straddles the live-tail boundary', async () => { // 14 turns: 10 closed turns, then an open tool_use at index 10 that closes at index 12. // Per-turn body bumped to 4200 chars so the row-aware gate (numCols=1) clears