mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-07-09 17:19:02 +00:00
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:
parent
132801b739
commit
ce00e932ae
5 changed files with 370 additions and 13 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)', () => {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue