fix(history): preserve git status output as text

This commit is contained in:
Steven Chong
2026-05-26 11:05:59 -04:00
parent 09fb339b39
commit 63a7810b52
2 changed files with 95 additions and 5 deletions
+60 -5
View File
@@ -113,6 +113,48 @@ export interface HistoryCollapseInfo {
droppedCodepoints: Map<number, number>;
}
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;
+35
View File
@@ -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<Record<string, unknown>>;
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