diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index f89745c292..2260023992 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -1151,7 +1151,9 @@ export class Session implements SessionContext { getRewindableUserTurnCount(): number { const apiHistory = this.captureHistorySnapshot(); - const startIndex = getStartupContextLength(apiHistory); + const startIndex = getStartupContextLength(apiHistory, { + includeCompressed: true, + }); let count = 0; for (let i = startIndex; i < apiHistory.length; i++) { @@ -1187,7 +1189,9 @@ export class Session implements SessionContext { apiHistory: Content[], targetTurnIndex: number, ): number { - const startIndex = getStartupContextLength(apiHistory); + const startIndex = getStartupContextLength(apiHistory, { + includeCompressed: true, + }); if (targetTurnIndex === 0) { return startIndex; diff --git a/packages/cli/src/ui/utils/historyMapping.test.ts b/packages/cli/src/ui/utils/historyMapping.test.ts index 21dd633a7d..92dd9a8c7f 100644 --- a/packages/cli/src/ui/utils/historyMapping.test.ts +++ b/packages/cli/src/ui/utils/historyMapping.test.ts @@ -9,6 +9,7 @@ import { computeApiTruncationIndex, isRealUserTurn } from './historyMapping.js'; import type { HistoryItem } from '../types.js'; import type { Content, Part } from '@google/genai'; import { + CompressionStatus, SYSTEM_REMINDER_OPEN, SYSTEM_REMINDER_CLOSE, } from '@qwen-code/qwen-code-core'; @@ -59,6 +60,22 @@ function geminiItem(id: number): HistoryItem { return { type: 'gemini', id, text: `response ${id}` } as HistoryItem; } +function compressionItem( + id: number, + compressionStatus = CompressionStatus.COMPRESSED, +): HistoryItem { + return { + type: 'compression', + id, + compression: { + isPending: false, + originalTokenCount: 100, + newTokenCount: 40, + compressionStatus, + }, + } as HistoryItem; +} + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -266,6 +283,71 @@ describe('computeApiTruncationIndex', () => { // Rewind to turn 5 → 2 user turns before it, but API only has 1 user text expect(computeApiTruncationIndex(ui, 5, api)).toBe(-1); }); + + it('maps post-compression UI turns from the latest compressed marker', () => { + const ui: HistoryItem[] = [ + userItem(1, 'pre-compression prompt'), + geminiItem(2), + compressionItem(3), + userItem(4, 'post 1'), + geminiItem(5), + userItem(6, 'post 2'), + geminiItem(7), + userItem(8, 'post 3'), + geminiItem(9), + ]; + const api: Content[] = [ + startupEntry(), + userContent('summary\n\nResume the prior task...'), + modelContent('Got it. Thanks for the additional context!'), + userContent('post 1'), + modelContent('response 1'), + userContent('post 2'), + modelContent('response 2'), + userContent('post 3'), + modelContent('response 3'), + ]; + + expect(computeApiTruncationIndex(ui, 4, api)).toBe(3); + expect(computeApiTruncationIndex(ui, 6, api)).toBe(5); + expect(computeApiTruncationIndex(ui, 8, api)).toBe(7); + }); + + it('does not rewind to UI turns before a successful compression marker', () => { + const ui: HistoryItem[] = [ + userItem(1, 'pre-compression prompt'), + geminiItem(2), + compressionItem(3), + 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'), + ]; + + expect(computeApiTruncationIndex(ui, 1, api)).toBe(-1); + }); + + it('does not treat no-op compression markers as collapsed history', () => { + const ui: HistoryItem[] = [ + userItem(1, 'first prompt'), + geminiItem(2), + compressionItem(3, CompressionStatus.NOOP), + userItem(4, 'second prompt'), + geminiItem(5), + ]; + const api: Content[] = [ + startupEntry(), + userContent('first prompt'), + modelContent('response 1'), + userContent('second prompt'), + modelContent('response 2'), + ]; + + expect(computeApiTruncationIndex(ui, 4, api)).toBe(3); + }); }); 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 c86c72ecd8..bfe3df5ca8 100644 --- a/packages/cli/src/ui/utils/historyMapping.ts +++ b/packages/cli/src/ui/utils/historyMapping.ts @@ -7,6 +7,7 @@ import type { HistoryItem, HistoryItemUser } from '../types.js'; import type { Content } from '@google/genai'; import { + CompressionStatus, getStartupContextLength, isSystemReminderContent, } from '@qwen-code/qwen-code-core'; @@ -58,6 +59,14 @@ function isUserTextContent(content: Content): boolean { return content.parts.some((part) => 'text' in part && part.text); } +function findLastSuccessfulCompressionIndex(history: HistoryItem[]): number { + return history.findLastIndex( + (item) => + item.type === 'compression' && + item.compression.compressionStatus === CompressionStatus.COMPRESSED, + ); +} + /** * Computes the number of API Content[] entries to keep when rewinding * to a specific user turn in the UI history. @@ -88,19 +97,31 @@ export function computeApiTruncationIndex( targetUserItemId: number, apiHistory: Content[], ): number { + const targetIndex = uiHistory.findIndex( + (item) => item.id === targetUserItemId, + ); + if (targetIndex === -1) return -1; + + const compressionIndex = findLastSuccessfulCompressionIndex(uiHistory); + if (compressionIndex !== -1 && targetIndex <= compressionIndex) return -1; + // Count how many UI user turns exist before the target let uiUserTurnCount = 0; - for (const item of uiHistory) { - if (item.id === targetUserItemId) { - break; - } + for ( + let i = compressionIndex === -1 ? 0 : compressionIndex + 1; + i < targetIndex; + i++ + ) { + const item = uiHistory[i]!; if (isRealUserTurn(item)) { uiUserTurnCount++; } } // Determine the starting index in the API history (skip startup context) - const startIndex = getStartupContextLength(apiHistory); + const startIndex = getStartupContextLength(apiHistory, { + includeCompressed: true, + }); if (uiUserTurnCount === 0) { // Rewinding to the first user turn: keep only startup context (if any) diff --git a/packages/core/src/utils/environmentContext.test.ts b/packages/core/src/utils/environmentContext.test.ts index 7ce84c3288..cef3e504ac 100644 --- a/packages/core/src/utils/environmentContext.test.ts +++ b/packages/core/src/utils/environmentContext.test.ts @@ -616,6 +616,186 @@ describe('getStartupContextLength', () => { ); expect(getStartupContextLength([merged])).toBe(0); }); + + // Compressed history prefix: composePostCompactHistory produces + // [user(summary), model(ack), user(postAckParts)?, ...]. The ack sentinel + // is distinct from the legacy ack above. + + it('is 0 for compressed prefixes by default', () => { + const history: Content[] = [ + { + role: 'user', + parts: [{ text: 'summary text\n\nResume the prior task...' }], + }, + { + role: 'model', + parts: [{ text: 'Got it. Thanks for the additional context!' }], + }, + ]; + expect(getStartupContextLength(history)).toBe(0); + }); + + it('is 0 for compressed prefixes with trailing prompts by default', () => { + const history: Content[] = [ + { + role: 'user', + parts: [{ text: 'summary text\n\nResume the prior task...' }], + }, + { + role: 'model', + parts: [{ text: 'Got it. Thanks for the additional context!' }], + }, + { + role: 'user', + parts: [{ text: 'Now do something else' }], + }, + ]; + expect(getStartupContextLength(history)).toBe(0); + }); + + it('is 0 for rewind when summary text lacks the resume sentinel', () => { + const history: Content[] = [ + { role: 'user', parts: [{ text: 'unrelated summary text' }] }, + { + role: 'model', + parts: [{ text: 'Got it. Thanks for the additional context!' }], + }, + ]; + expect(getStartupContextLength(history, { includeCompressed: true })).toBe( + 0, + ); + }); + + it('is 0 for rewind when the compression ack text does not match', () => { + const history: Content[] = [ + { + role: 'user', + parts: [{ text: 'summary text\n\nResume the prior task...' }], + }, + { role: 'model', parts: [{ text: 'Understood, resuming now.' }] }, + ]; + expect(getStartupContextLength(history, { includeCompressed: true })).toBe( + 0, + ); + }); + + it('is 2 for rewind when a real prompt follows a compressed prefix', () => { + const history: Content[] = [ + { + role: 'user', + parts: [{ text: 'summary text\n\nResume the prior task...' }], + }, + { + role: 'model', + parts: [{ text: 'Got it. Thanks for the additional context!' }], + }, + { + role: 'user', + parts: [{ text: 'Now do something else' }], + }, + ]; + expect(getStartupContextLength(history, { includeCompressed: true })).toBe( + 2, + ); + }); + + it('includes compressed prefixes after startup reminders for rewind', () => { + const history: Content[] = [ + { role: 'user', parts: [{ text: wrap('env') }] }, + { + role: 'user', + parts: [{ text: 'summary text\n\nResume the prior task...' }], + }, + { + role: 'model', + parts: [{ text: 'Got it. Thanks for the additional context!' }], + }, + { + role: 'user', + parts: [{ text: 'Now do something else' }], + }, + ]; + expect(getStartupContextLength(history, { includeCompressed: true })).toBe( + 3, + ); + }); + + it('is 3 for rewind with post-compact attachments', () => { + const history: Content[] = [ + { + role: 'user', + parts: [{ text: 'summary text\n\nResume the prior task...' }], + }, + { + role: 'model', + parts: [{ text: 'Got it. Thanks for the additional context!' }], + }, + { + role: 'user', + parts: [ + { + text: + 'Recently accessed file (full current content embedded):\n\n' + + '## /repo/file.ts\n\n```ts\nexport const x = 1;\n```', + }, + { text: '\nplan mode\n' }, + ], + }, + ]; + expect(getStartupContextLength(history, { includeCompressed: true })).toBe( + 3, + ); + }); + + it('is 4 for rewind with attachments and a trailing function call', () => { + const history: Content[] = [ + { + role: 'user', + parts: [{ text: 'summary\n\nResume the prior task...' }], + }, + { + role: 'model', + parts: [{ text: 'Got it. Thanks for the additional context!' }], + }, + { + role: 'user', + parts: [{ text: '\nplan\n' }], + }, + { + role: 'model', + parts: [{ functionCall: { name: 'fn', args: {} } }], + }, + ]; + expect(getStartupContextLength(history, { includeCompressed: true })).toBe( + 4, + ); + }); + + it('is 2 for rewind with degraded compression fallback', () => { + const history: Content[] = [ + { + role: 'user', + parts: [ + { text: 'summary\n\nResume the prior task...' }, + { text: '\nplan mode active\n' }, + ], + }, + { + role: 'model', + parts: [ + { text: 'Got it. Thanks for the additional context!' }, + { functionCall: { name: 'fn', args: {} } }, + ], + }, + { + role: 'user', + parts: [{ functionResponse: { name: 'fn', response: {} } }], + }, + ]; + expect(getStartupContextLength(history, { includeCompressed: true })).toBe( + 2, + ); + }); }); describe('buildAvailableSkillsReminder', () => { diff --git a/packages/core/src/utils/environmentContext.ts b/packages/core/src/utils/environmentContext.ts index a1fc01bf82..378e896411 100644 --- a/packages/core/src/utils/environmentContext.ts +++ b/packages/core/src/utils/environmentContext.ts @@ -571,12 +571,23 @@ export async function getInitialChatHistory( } /** - * Returns the number of initial API entries occupied by the startup reminder - * (0 or 1). A single user message wrapped in is the only - * shape getInitialChatHistory currently produces, but routes through this - * helper so detection stays consistent across the CLI and ACP integration. + * Returns the number of initial API entries occupied by structural context + * that should be skipped when counting real user turns: + * + * - The startup reminder prelude (0 or 1 entry) — a single user message + * wrapped in ``, produced by + * `getInitialChatHistory`. + * - The legacy ack-pair prelude (2 entries) — sessions saved before the + * startup context moved into system reminders. + * - The compressed-history prefix (2-4 entries) — summary, ack, and + * optionally a post-compact attachments entry produced by + * `composePostCompactHistory`. These synthetic entries must not be + * counted as real user prompts for rewind indexing. */ -export function getStartupContextLength(history: Content[]): number { +export function getStartupContextLength( + history: Content[], + options: { includeCompressed?: boolean } = {}, +): number { const firstEntry = history[0]; if (firstEntry?.role !== 'user') return 0; const firstText = firstEntry.parts?.[0]?.text; @@ -588,6 +599,10 @@ export function getStartupContextLength(history: Content[]): number { firstText.startsWith(SYSTEM_REMINDER_OPEN) && firstText.trimEnd().endsWith(SYSTEM_REMINDER_CLOSE) ) { + if (options.includeCompressed) { + const compressedLength = detectCompressedPrefixLength(history, 1); + if (compressedLength > 0) return 1 + compressedLength; + } return 1; } // Legacy format (sessions saved before startup context moved into system @@ -601,7 +616,62 @@ export function getStartupContextLength(history: Content[]): number { ) { return 2; } - return 0; + if (!options.includeCompressed) return 0; + + return detectCompressedPrefixLength(history, 0); +} + +function detectCompressedPrefixLength( + history: Content[], + offset: number, +): number { + const firstEntry = history[offset]; + if (firstEntry?.role !== 'user') return 0; + const firstText = firstEntry.parts?.[0]?.text; + // Post-compression prefix for rewind indexing only. The startup-context + // refresh/restore paths need compressed history to look like "no startup + // prelude" so they don't strip or skip the compressed summary. + if ( + typeof firstText !== 'string' || + !firstText.includes('Resume the prior task') || + history[offset + 1]?.role !== 'model' || + history[offset + 1]?.parts?.[0]?.text !== + 'Got it. Thanks for the additional context!' + ) { + return 0; + } + if (isPostCompactAttachmentEntry(history[offset + 2])) { + if (isModelFunctionCallEntry(history[offset + 3])) return 4; + return 3; + } + return 2; +} + +function isPostCompactAttachmentEntry(content: Content | undefined): boolean { + if (content?.role !== 'user') return false; + const parts = content.parts ?? []; + return parts.some( + (part) => + typeof part.text === 'string' && + (part.text.startsWith('') || + part.text.startsWith('') || + part.text.startsWith( + 'The following files were recently accessed before context was compacted.', + ) || + part.text.startsWith( + 'Recently accessed file (full current content embedded):', + ) || + part.text.startsWith( + 'Recent visual snapshots preserved from before context was compacted', + )), + ); +} + +function isModelFunctionCallEntry(content: Content | undefined): boolean { + return ( + content?.role === 'model' && + (content.parts ?? []).some((part) => 'functionCall' in part) + ); } /**