fix(core): allow rewind after compressed history (#6358)

* fix(core): allow rewind after compressed history

* fix(core): separate compressed prefix rewind handling

* fix(core): handle rewind after restored startup context

* test(core): cover compressed rewind sentinel mismatch

* fix(cli): align rewind mapping after compression
This commit is contained in:
易良 2026-07-07 13:21:16 +08:00 committed by GitHub
parent 132801b739
commit ce00e932ae
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 370 additions and 13 deletions

View file

@ -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;

View file

@ -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('<state_snapshot>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('<state_snapshot>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)', () => {

View file

@ -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)

View file

@ -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: '<system-reminder>\nplan mode\n</system-reminder>' },
],
},
];
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: '<plan-mode-active>\nplan\n</plan-mode-active>' }],
},
{
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: '<system-reminder>\nplan mode active\n</system-reminder>' },
],
},
{
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', () => {

View file

@ -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 <system-reminder> 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 `<system-reminder>…</system-reminder>`, 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('<plan-mode-active>') ||
part.text.startsWith('<background-tasks>') ||
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)
);
}
/**