diff --git a/packages/cli/src/acp-integration/session/Session.test.ts b/packages/cli/src/acp-integration/session/Session.test.ts index 45c80a347f7..2a417968d88 100644 --- a/packages/cli/src/acp-integration/session/Session.test.ts +++ b/packages/cli/src/acp-integration/session/Session.test.ts @@ -3137,6 +3137,23 @@ describe('Session', () => { expect(session.getRewindableUserTurnCount()).toBe(1); }); + it('counts cleared media placeholders as rewindable prompts (twin divergence)', () => { + // The TUI twin (isUserTextContent in ui/utils/historyMapping.ts) + // excludes microcompaction media-clear placeholders from its rewind + // prompt count. The ACP twin must keep counting them: ACP rewind + // maps against per-prompt file-history snapshots, which ARE created + // for media-only prompts. + const history: Content[] = [ + { + role: 'user', + parts: [{ text: '[Old inline media cleared: image/png]' }], + }, + ]; + vi.mocked(mockChat.getHistoryShallow).mockReturnValue(history); + + expect(session.getRewindableUserTurnCount()).toBe(1); + }); + it('rejects unreachable user turns', () => { const history: Content[] = [{ role: 'user', parts: [{ text: 'first' }] }]; vi.mocked(mockChat.getHistory).mockReturnValue(history); diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index 3ce821bed66..33b71630d03 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -3579,6 +3579,13 @@ export class Session implements SessionContext { return false; } + // Deliberate twin divergence: the TUI twin (isUserTextContent in + // packages/cli/src/ui/utils/historyMapping.ts) excludes microcompaction + // media-clear placeholders ('[Old inline media cleared: ...]') from the + // rewind prompt count because a cleared media-only entry never produced + // a TUI user turn. Here the placeholders MUST stay counted: ACP rewind + // maps against per-prompt file-history snapshots, which ARE created for + // media-only prompts. Do not mirror that exclusion into this twin. return content.parts.some((part) => 'text' in part && part.text); } diff --git a/packages/cli/src/ui/commands/compressCommand.test.ts b/packages/cli/src/ui/commands/compressCommand.test.ts index 79af1b13194..b80fcc30671 100644 --- a/packages/cli/src/ui/commands/compressCommand.test.ts +++ b/packages/cli/src/ui/commands/compressCommand.test.ts @@ -89,6 +89,7 @@ describe('compressCommand', () => { compressionStatus: CompressionStatus.COMPRESSED, originalTokenCount: 200, newTokenCount: 100, + compressionKind: 'summarize', }, }, expect.any(Number), diff --git a/packages/cli/src/ui/commands/compressCommand.ts b/packages/cli/src/ui/commands/compressCommand.ts index 9a5238afb97..b85a3f7ae49 100644 --- a/packages/cli/src/ui/commands/compressCommand.ts +++ b/packages/cli/src/ui/commands/compressCommand.ts @@ -170,6 +170,7 @@ export const compressCommand: SlashCommand = { originalTokenCount: compressed.originalTokenCount, newTokenCount: compressed.newTokenCount, compressionStatus: compressed.compressionStatus, + compressionKind: 'summarize', }, } as HistoryItemCompression, Date.now(), diff --git a/packages/cli/src/ui/commands/compressFastCommand.test.ts b/packages/cli/src/ui/commands/compressFastCommand.test.ts index 38c54af8257..4714fd3bdff 100644 --- a/packages/cli/src/ui/commands/compressFastCommand.test.ts +++ b/packages/cli/src/ui/commands/compressFastCommand.test.ts @@ -155,6 +155,7 @@ describe('compressFastCommand', () => { originalTokenCount: 200, newTokenCount: 100, compressionStatus: CompressionStatus.COMPRESSED, + compressionKind: 'fast', }, }, expect.any(Number), diff --git a/packages/cli/src/ui/commands/compressFastCommand.ts b/packages/cli/src/ui/commands/compressFastCommand.ts index ebe82d9c1f2..ab6b199f56d 100644 --- a/packages/cli/src/ui/commands/compressFastCommand.ts +++ b/packages/cli/src/ui/commands/compressFastCommand.ts @@ -138,6 +138,7 @@ export const compressFastCommand: SlashCommand = { originalTokenCount: compressed.originalTokenCount, newTokenCount: compressed.newTokenCount, compressionStatus: compressed.compressionStatus, + compressionKind: 'fast', }, } as HistoryItemCompression, Date.now(), diff --git a/packages/cli/src/ui/types.ts b/packages/cli/src/ui/types.ts index 1d2153dbcf1..613d2e78d62 100644 --- a/packages/cli/src/ui/types.ts +++ b/packages/cli/src/ui/types.ts @@ -102,6 +102,14 @@ export interface CompressionProps { originalTokenCount: number | null; newTokenCount: number | null; compressionStatus: CompressionStatus | null; + /** + * Which compression path produced this item. 'summarize' replaces the + * pre-marker history with a synthetic summary prefix; 'fast' (rule-based, + * no LLM summary) removes no user prompts from the API history, so its + * marker must not be treated as a rewind boundary. Absent on items from + * older sessions, which are treated as 'summarize'. + */ + compressionKind?: 'summarize' | 'fast'; } export interface SummaryProps { diff --git a/packages/cli/src/ui/utils/historyMapping.test.ts b/packages/cli/src/ui/utils/historyMapping.test.ts index 92dd9a8c7f9..3b82e8f48db 100644 --- a/packages/cli/src/ui/utils/historyMapping.test.ts +++ b/packages/cli/src/ui/utils/historyMapping.test.ts @@ -63,6 +63,7 @@ function geminiItem(id: number): HistoryItem { function compressionItem( id: number, compressionStatus = CompressionStatus.COMPRESSED, + compressionKind: 'summarize' | 'fast' = 'summarize', ): HistoryItem { return { type: 'compression', @@ -72,6 +73,7 @@ function compressionItem( originalTokenCount: 100, newTokenCount: 40, compressionStatus, + compressionKind, }, } as HistoryItem; } @@ -348,6 +350,340 @@ describe('computeApiTruncationIndex', () => { expect(computeApiTruncationIndex(ui, 4, api)).toBe(3); }); + + it('fails loud when marker-less auto-compaction left a compressed prefix', () => { + // Auto-compaction adds no UI compression marker, but leaves the API + // history with a [summary, ack] prefix. Rewinding to the first turn + // must abort (-1) rather than silently truncate to the compressed + // prefix and drop every real turn (R5-1 entrance 3). + const ui: HistoryItem[] = [ + userItem(1, 'pre 1'), + geminiItem(2), + userItem(3, 'pre 2'), + geminiItem(4), + ]; + const api: Content[] = [ + startupEntry(), + userContent('summary\n\nResume the prior task...'), + modelContent('Got it. Thanks for the additional context!'), + ]; + expect(computeApiTruncationIndex(ui, 1, api)).toBe(-1); + }); + }); + + describe('with fast (non-summarizing) compression markers', () => { + // /compress-fast keeps every user prompt in the API history and inserts + // no summary prefix, so its marker must not act as a rewind boundary. + // Shaped after the report in #9320: rewinding to the first post-marker + // turn used to collapse the anchor to the startup entry and silently + // drop the entire pre-marker conversation. + const fastCompressedHistory = () => { + const ui: HistoryItem[] = [ + userItem(1, 'pre 1'), + geminiItem(2), + userItem(3, 'pre 2'), + geminiItem(4), + userItem(5, 'pre 3'), + geminiItem(6), + compressionItem(7, CompressionStatus.COMPRESSED, 'fast'), + userItem(8, 'post 1'), + geminiItem(9), + userItem(10, 'post 2'), + geminiItem(11), + ]; + const api: Content[] = [ + startupEntry(), + userContent('pre 1'), + modelContent('response 1'), + userContent('pre 2'), + modelContent('response 2'), + userContent('pre 3'), + modelContent('response 3'), + userContent('post 1'), + modelContent('response post 1'), + userContent('post 2'), + modelContent('response post 2'), + ]; + return { ui, api }; + }; + + it('keeps the full pre-marker history when rewinding to the first post-marker turn', () => { + const { ui, api } = fastCompressedHistory(); + // Keep startup + all three pre-marker turns, truncate before 'post 1'. + expect(computeApiTruncationIndex(ui, 8, api)).toBe(7); + }); + + it('maps later post-marker turns against the full history', () => { + const { ui, api } = fastCompressedHistory(); + // 4 real user turns precede 'post 2' → truncate before idx 9. + expect(computeApiTruncationIndex(ui, 10, api)).toBe(9); + }); + + it('allows rewinding to turns before a fast-compression marker', () => { + const { ui, api } = fastCompressedHistory(); + // Fast compression absorbs no prompts, so pre-marker turns stay + // reachable (summarizing compression would return -1 here). + expect(computeApiTruncationIndex(ui, 3, api)).toBe(3); + }); + + it('still blocks turns absorbed by a later summarizing compression', () => { + const ui: HistoryItem[] = [ + userItem(1, 'pre fast'), + geminiItem(2), + compressionItem(3, CompressionStatus.COMPRESSED, 'fast'), + userItem(4, 'between compressions'), + geminiItem(5), + compressionItem(6, CompressionStatus.COMPRESSED, 'summarize'), + userItem(7, 'post summarize'), + ]; + const api: Content[] = [ + startupEntry(), + userContent('summary\n\nResume the prior task...'), + modelContent('Got it. Thanks for the additional context!'), + userContent('post summarize'), + ]; + + expect(computeApiTruncationIndex(ui, 4, api)).toBe(-1); + expect(computeApiTruncationIndex(ui, 7, api)).toBe(3); + }); + + it('treats legacy markers without a kind as summarizing', () => { + const legacyMarker: HistoryItem = { + type: 'compression', + id: 3, + compression: { + isPending: false, + originalTokenCount: 100, + newTokenCount: 40, + compressionStatus: CompressionStatus.COMPRESSED, + }, + } as HistoryItem; + const ui: HistoryItem[] = [ + userItem(1, 'pre-compression prompt'), + geminiItem(2), + legacyMarker, + userItem(4, 'post compression'), + ]; + const api: Content[] = [ + startupEntry(), + userContent('summary\n\nResume the prior task...'), + modelContent('Got it. Thanks for the additional context!'), + userContent('post compression'), + ]; + + // Pre-marker turns stay unreachable, matching pre-fix behavior for + // sessions persisted before compressionKind existed. + expect(computeApiTruncationIndex(ui, 1, api)).toBe(-1); + expect(computeApiTruncationIndex(ui, 4, api)).toBe(3); + }); + }); + + describe('with microcompaction media-clear placeholders', () => { + // /compress-fast's forced microcompaction replaces the top-level + // inlineData/fileData parts of user entries with text placeholders + // ('[Old inline media cleared: ]'). A media-only user entry + // (e.g. an image-only ACP prompt) never produced a UI user turn, but + // once cleared it satisfies a naive 'text' in part check — counting it + // desynchronizes the API prompt count from the UI turn count and makes + // the walk truncate one turn early, silently dropping a turn the UI + // still shows (the same hazard this PR's fast-marker change addresses, + // newly reachable through cross-fast-marker rewinds). + + function clearedMediaContent(mime = 'image/png'): Content { + return { + role: 'user', + parts: [{ text: `[Old inline media cleared: ${mime}]` } as Part], + }; + } + + function inlineMediaContent(): Content { + return { + role: 'user', + parts: [{ inlineData: { mimeType: 'image/png', data: 'abc' } } as Part], + }; + } + + it('does not count a cleared media-only entry as a user prompt', () => { + const ui: HistoryItem[] = [ + userItem(1, 'hello'), + geminiItem(2), + userItem(3, 'world'), + geminiItem(4), + ]; + const api: Content[] = [ + startupEntry(), + clearedMediaContent(), // media-only entry, cleared; NOT a UI turn + userContent('hello'), + modelContent('response hello'), + userContent('world'), + modelContent('response world'), + ]; + // Witness: with the uncleared inlineData entry the index is 4; the + // cleared placeholder must land on the same index, not one turn early. + expect(computeApiTruncationIndex(ui, 3, api)).toBe(4); + + const apiUncleared: Content[] = [ + startupEntry(), + inlineMediaContent(), + userContent('hello'), + modelContent('response hello'), + userContent('world'), + modelContent('response world'), + ]; + expect(computeApiTruncationIndex(ui, 3, apiUncleared)).toBe(4); + }); + + it('keeps the full pre-marker history when a cleared entry precedes a fast marker', () => { + const ui: HistoryItem[] = [ + userItem(1, 'pre 1'), + geminiItem(2), + compressionItem(3, CompressionStatus.COMPRESSED, 'fast'), + userItem(4, 'post 1'), + geminiItem(5), + userItem(6, 'post 2'), + geminiItem(7), + ]; + const api: Content[] = [ + startupEntry(), + clearedMediaContent(), // cleared by /compress-fast microcompaction + userContent('pre 1'), + modelContent('response pre 1'), + userContent('post 1'), + modelContent('response post 1'), + userContent('post 2'), + modelContent('response post 2'), + ]; + // 2 real turns precede 'post 2'; the cleared entry must not shift the + // count. Without the exclusion the walk stops at 'post 1' (idx 4). + expect(computeApiTruncationIndex(ui, 6, api)).toBe(6); + }); + + it('still counts an entry mixing a placeholder with real prompt text', () => { + const mixedTurn: Content = { + role: 'user', + parts: [ + { text: '[Old inline media cleared: image/png]' } as Part, + { text: 'check this image' } as Part, + ], + }; + const ui: HistoryItem[] = [ + userItem(1, 'check this image'), + geminiItem(2), + userItem(3, 'world'), + geminiItem(4), + ]; + const api: Content[] = [ + startupEntry(), + mixedTurn, + modelContent('response 1'), + userContent('world'), + modelContent('response world'), + ]; + expect(computeApiTruncationIndex(ui, 3, api)).toBe(3); + }); + + it('still counts a genuine prompt that merely begins with the placeholder prefix', () => { + // Microcompaction never rewrites text parts, so a user prompt that + // starts with '[Old inline media cleared:' (e.g. a pasted + // placeholder) is genuine. A bare-prefix match would drop it from + // the API prompt count: rewinding to a later turn truncated one + // prompt LATE (index 4 instead of 3) and rewinding to the prefix + // turn itself returned -1 with a spurious "compressed" error. + const prefixPromptText = + '[Old inline media cleared: image/png] why is this in my history?'; + const prefixPrompt: Content = { + role: 'user', + parts: [{ text: prefixPromptText } as Part], + }; + const ui: HistoryItem[] = [ + userItem(1, prefixPromptText), + geminiItem(2), + userItem(3, 'world'), + geminiItem(4), + ]; + const api: Content[] = [ + startupEntry(), + prefixPrompt, + modelContent('response 1'), + userContent('world'), + modelContent('response world'), + ]; + // Prefix turn AS the rewind target lands on its own entry… + expect(computeApiTruncationIndex(ui, 1, api)).toBe(1); + // …and a later rewind target is not shifted one turn late. + expect(computeApiTruncationIndex(ui, 3, api)).toBe(3); + }); + + it('pins the exact-match collision corner as a loud block, not silent loss', () => { + // Known limitation, documented next to the exclusion in + // historyMapping.ts: microcompaction never rewrites text parts, so a + // genuine prompt whose entire text equals a generated placeholder is + // indistinguishable from a cleared media-only entry. It is excluded + // from the API prompt count, so every later rewind target returns + // -1 — AppContainer turns that into a loud "Cannot rewind to a turn + // that was compressed" abort. This pins the fail-safe shape: a + // visible error, never silent history loss. A durable fix needs a + // structural sentinel on cleared parts (persisted-format change, out + // of scope here) and would update this expectation. + const exactPlaceholderText = '[Old inline media cleared: image/png]'; + const collidingPrompt: Content = { + role: 'user', + parts: [{ text: exactPlaceholderText } as Part], + }; + const ui: HistoryItem[] = [ + userItem(1, exactPlaceholderText), + geminiItem(2), + userItem(3, 'world'), + geminiItem(4), + ]; + const api: Content[] = [ + startupEntry(), + collidingPrompt, + modelContent('response 1'), + userContent('world'), + modelContent('response world'), + ]; + // Rewinding to the colliding turn itself still works via the + // uiUserTurnCount === 0 shortcut… + expect(computeApiTruncationIndex(ui, 1, api)).toBe(1); + // …while every later target fails loud (-1) instead of truncating + // against a misaligned prompt count. + expect(computeApiTruncationIndex(ui, 3, api)).toBe(-1); + }); + + it('pins the mid-history exact-match collision: own turn one-late, later turns loud', () => { + // Same known limitation as the test above, with the colliding turn in + // a mid-history position (uiUserTurnCount >= 1): the shortcut does + // not apply, so rewinding TO the colliding turn lands on the next + // counted prompt and truncates one turn LATE — the colliding turn's + // prompt+response stays in model context while the UI removes the + // turn (under-deletion, not loss of context the UI keeps). Every + // later target still fails loud (-1). Pinned so a structural fix + // (sentinel on cleared parts) updates both expectations. + const exactPlaceholderText = '[Old inline media cleared: image/png]'; + const ui: HistoryItem[] = [ + userItem(1, 'hello'), + geminiItem(2), + userItem(3, exactPlaceholderText), + geminiItem(4), + userItem(5, 'world'), + geminiItem(6), + ]; + const api: Content[] = [ + startupEntry(), + userContent('hello'), + modelContent('response hello'), + userContent(exactPlaceholderText), + modelContent('response colliding'), + userContent('world'), + modelContent('response world'), + ]; + // Rewinding TO the colliding turn keeps its prompt+response (index 5, + // one turn late)… + expect(computeApiTruncationIndex(ui, 3, api)).toBe(5); + // …while every later target fails loud (-1). + expect(computeApiTruncationIndex(ui, 5, api)).toBe(-1); + }); }); describe('mid-turn user messages (notification type)', () => { diff --git a/packages/cli/src/ui/utils/historyMapping.ts b/packages/cli/src/ui/utils/historyMapping.ts index bfe3df5ca8e..8c81d125b7d 100644 --- a/packages/cli/src/ui/utils/historyMapping.ts +++ b/packages/cli/src/ui/utils/historyMapping.ts @@ -9,6 +9,7 @@ import type { Content } from '@google/genai'; import { CompressionStatus, getStartupContextLength, + isClearedMediaPlaceholder, isSystemReminderContent, } from '@qwen-code/qwen-code-core'; import { isSlashCommand } from './commandUtils.js'; @@ -56,14 +57,61 @@ function isUserTextContent(content: Content): boolean { // is NOT excluded. if (isSystemReminderContent(content)) return false; - return content.parts.some((part) => 'text' in part && part.text); + // Exclude microcompaction media-clear placeholders. `/compress-fast`'s + // microcompaction replaces the top-level inlineData/fileData parts of + // user entries with text placeholders. A media-only user entry never + // produced a UI user turn, but once cleared it carries a text part; + // counting it here desynchronizes the API prompt count from the UI turn + // count and makes the walk below truncate one turn early, silently + // dropping a turn the UI still shows. Match the FULL generated + // placeholder shape, not just its prefix: microcompaction never rewrites + // text parts, so a user prompt that merely begins with the prefix (e.g. + // a pasted placeholder) is genuine and must keep counting. An entry that + // mixes placeholders with real prompt text still counts (it IS a real + // turn). + // + // Known limitation (exact-match collision): a genuine prompt whose ENTIRE + // text equals a generated placeholder shape is indistinguishable from a + // cleared media-only entry once serialized — both carry the identical + // text. Such a prompt is excluded here, leaving the API prompt count one + // behind the UI turn count. Every rewind target AFTER the colliding turn + // then returns -1 and AppContainer surfaces a loud "Cannot rewind to a + // turn that was compressed" abort. Rewinding TO the colliding turn itself + // depends on position: as the first post-compression turn it works via + // the uiUserTurnCount === 0 shortcut; mid-history the walk lands on the + // next counted prompt and truncates one turn LATE, so the colliding + // turn's prompt+response stays in model context while the UI removes the + // turn (under-deletion of context, not loss of context the UI keeps). + // Disambiguating any of this durably needs a structural sentinel on + // cleared parts, which changes the persisted API history shape and is out + // of scope for this fix; see the pinned tests in historyMapping.test.ts. + // + // The ACP session's private `#isUserTextContent` + // (packages/cli/src/acp-integration/session/Session.ts) deliberately + // keeps the bare text-presence check (`'text' in part && part.text`), + // which counts these placeholders: ACP rewind maps against per-prompt + // file-history snapshots, which ARE created for media-only prompts, so + // cleared placeholders must stay counted there. Do not mirror this + // exclusion into that twin. + return content.parts.some( + (part) => + 'text' in part && !!part.text && !isClearedMediaPlaceholder(part.text), + ); } +/** + * Finds the last successful *summarizing* compression marker. Fast + * (rule-based) compression markers are excluded: `/compress-fast` removes no + * user prompts from the API history and inserts no summary prefix, so its + * marker is not a truncation boundary — treating it as one collapses the + * rewind anchor and silently drops the pre-marker history. + */ function findLastSuccessfulCompressionIndex(history: HistoryItem[]): number { return history.findLastIndex( (item) => item.type === 'compression' && - item.compression.compressionStatus === CompressionStatus.COMPRESSED, + item.compression.compressionStatus === CompressionStatus.COMPRESSED && + item.compression.compressionKind !== 'fast', ); } @@ -124,6 +172,16 @@ export function computeApiTruncationIndex( }); if (uiUserTurnCount === 0) { + // Marker-less auto-compaction (entrance 3): the API history carries a + // compressed prefix but the UI has no summarizing compression boundary. + // Rewinding to the first turn would silently truncate to + // [prelude, summary, ack] and drop every real turn — fail loud instead. + if ( + compressionIndex === -1 && + startIndex > getStartupContextLength(apiHistory) + ) { + return -1; + } // Rewinding to the first user turn: keep only startup context (if any) return startIndex; } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 924b7e41f42..e206d04a1b9 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -247,6 +247,7 @@ export { resolveSlimmingConfig, type ResolvedSlimmingConfig, } from './services/compactionInputSlimming.js'; +export { isClearedMediaPlaceholder } from './services/microcompaction/microcompact.js'; export * from './services/chatRecordingService.js'; export * from './services/branch-points.js'; export * from './services/cronScheduler.js'; diff --git a/packages/core/src/services/chatRecordingService.ts b/packages/core/src/services/chatRecordingService.ts index 4b292fb3680..c6b3443edda 100644 --- a/packages/core/src/services/chatRecordingService.ts +++ b/packages/core/src/services/chatRecordingService.ts @@ -478,6 +478,15 @@ export interface AgentRetryRecordPayload { /** * Stored payload for chat compression checkpoints. This allows us to rebuild the * effective chat history on resume while keeping the original UI-visible history. + * + * NOTE: the payload carries `ChatCompressionInfo`, which has no + * `compressionKind` — the 'summarize' vs 'fast' distinction (see + * `CompressionProps.compressionKind` in cli's ui/types.ts) exists only on + * ephemeral UI items today. If resume ever reconstructs compression markers + * from this record, it must re-derive the kind; rebuilding every marker + * kind-less and falling back to 'summarize' would misclassify fast markers + * as truncation boundaries and re-introduce the silent pre-marker history + * drop of #9320 on any session that ran /compress-fast before being resumed. */ export interface ChatCompressionRecordPayload { /** Compression metrics/status returned by the compression service */ diff --git a/packages/core/src/services/microcompaction/microcompact.test.ts b/packages/core/src/services/microcompaction/microcompact.test.ts index 1e34b9b27cc..cd6c5a3a7d0 100644 --- a/packages/core/src/services/microcompaction/microcompact.test.ts +++ b/packages/core/src/services/microcompaction/microcompact.test.ts @@ -10,6 +10,7 @@ import type { ClearContextOnIdleSettings } from '../../config/config.js'; import { evaluateTimeBasedTrigger, + isClearedMediaPlaceholder, microcompactHistory, MICROCOMPACT_CLEARED_MESSAGE, MICROCOMPACT_CLEARED_IMAGE_PREFIX, @@ -143,6 +144,65 @@ describe('evaluateTimeBasedTrigger', () => { }); }); +describe('isClearedMediaPlaceholder', () => { + it('matches the exact placeholder shape microcompaction emits', () => { + expect( + isClearedMediaPlaceholder('[Old inline media cleared: image/png]'), + ).toBe(true); + expect( + isClearedMediaPlaceholder( + '[Old inline media cleared: application/octet-stream]', + ), + ).toBe(true); + }); + + it('matches the empty-mime shape the producer can emit', () => { + // sanitizeMimeForPlaceholder returns '' for empty/whitespace-only/ + // bracket-only mimeTypes, and the producer's `??` fallback only covers + // null/undefined, so a degenerate mimeType yields `[... cleared: ]`. + // The consumer must recognize that shape too, or a cleared media-only + // entry would be counted as a genuine prompt and desynchronize the + // rewind prompt count. + expect(isClearedMediaPlaceholder('[Old inline media cleared: ]')).toBe( + true, + ); + }); + + it('does not match a user prompt that merely begins with the prefix', () => { + expect( + isClearedMediaPlaceholder( + '[Old inline media cleared: image/png] why is this in my history?', + ), + ).toBe(false); + expect(isClearedMediaPlaceholder('[Old inline media cleared:')).toBe(false); + expect(isClearedMediaPlaceholder('hello world')).toBe(false); + expect(isClearedMediaPlaceholder('')).toBe(false); + }); + + it('does not match interiors the producer can never emit (newline/tab/CR)', () => { + // sanitizeMimeForPlaceholder normalizes \r/\n/\t to spaces before + // interpolation, so a generated placeholder never contains them. + // Accepting them would misclassify multi-line user text that starts + // with the prefix as a placeholder. + expect( + isClearedMediaPlaceholder( + '[Old inline media cleared: screenshot\nfrom staging]', + ), + ).toBe(false); + expect(isClearedMediaPlaceholder('[Old inline media cleared: a\tb]')).toBe( + false, + ); + expect(isClearedMediaPlaceholder('[Old inline media cleared: a\rb]')).toBe( + false, + ); + // …while the space-normalized interior the producer DOES emit for + // such a mimeType still matches. + expect(isClearedMediaPlaceholder('[Old inline media cleared: a b]')).toBe( + true, + ); + }); +}); + describe('microcompactHistory', () => { afterEach(clearEnv); @@ -1343,6 +1403,35 @@ describe('microcompactHistory', () => { expect(result.meta!.mediaCleared).toBe(1); }); + it('emits a placeholder the consumer recognizes even for degenerate mimeTypes', () => { + // The producer's `?? 'application/octet-stream'` fallback only covers + // null/undefined; an empty or bracket-only mimeType survives + // sanitizeMimeForPlaceholder as ''. Whatever shape is emitted must + // round-trip through isClearedMediaPlaceholder, or a cleared media-only + // entry would later be counted as a genuine user prompt. + for (const mimeType of ['', ' ', ']', '[]']) { + const history: Content[] = [ + makeUserMessage('look at this'), + makeInlineImage(mimeType, 'OLDOLDOLDOLD'), + makeUserMessage('and this'), + // Recent image so the degenerate one is not the keepRecent newest. + makeInlineImage('image/jpeg', 'NEWNEWNEWNEW'), + ]; + + const result = microcompactHistory( + history, + twoHoursAgo, + DEFAULT_SETTINGS, + ); + + const emitted = result.history[1]!.parts![0]!.text!; + expect( + isClearedMediaPlaceholder(emitted), + `emitted shape for mimeType ${JSON.stringify(mimeType)}: ${JSON.stringify(emitted)}`, + ).toBe(true); + } + }); + it('does not reclear an already-cleared image part', () => { const history: Content[] = [ { diff --git a/packages/core/src/services/microcompaction/microcompact.ts b/packages/core/src/services/microcompaction/microcompact.ts index ed0bd6e7e64..38feb3e0559 100644 --- a/packages/core/src/services/microcompaction/microcompact.ts +++ b/packages/core/src/services/microcompaction/microcompact.ts @@ -14,6 +14,26 @@ import { ToolNames } from '../../tools/tool-names.js'; export const MICROCOMPACT_CLEARED_MESSAGE = '[Old tool result content cleared]'; export const MICROCOMPACT_CLEARED_IMAGE_PREFIX = '[Old inline media cleared:'; +// Matches the FULL placeholder shape this module emits +// (`${MICROCOMPACT_CLEARED_IMAGE_PREFIX} ${mime}]`; the mime is sanitized +// to contain no `]` and may be EMPTY — sanitizeMimeForPlaceholder returns +// '' for empty/whitespace-only/bracket-only mimeTypes, and the producer's +// `??` fallback only covers null/undefined), not just the prefix. The +// interior also rejects \r/\n/\t because sanitizeMimeForPlaceholder +// normalizes them to spaces, so the producer can never emit them inside +// the placeholder — accepting them would let multi-line user text that +// merely starts with the prefix be misclassified as a placeholder. Derived +// from the constant above so producer and consumer cannot drift. A genuine +// user prompt that merely *begins* with the prefix is NOT a placeholder and +// must keep counting as user text wherever this predicate is used. +const CLEARED_MEDIA_PLACEHOLDER_RE = new RegExp( + `^${MICROCOMPACT_CLEARED_IMAGE_PREFIX.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')} [^\\]\\r\\n\\t]*\\]$`, +); + +export function isClearedMediaPlaceholder(text: string): boolean { + return CLEARED_MEDIA_PLACEHOLDER_RE.test(text); +} + // IMPORTANT: any new file-touching tool added here MUST also be added // to FILE_PATH_TOOLS below, or microcompaction will blank its output // without reporting the eviction — silently reintroducing issue #4239.