Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions packages/cli/src/acp-integration/session/Session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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++) {
Expand Down Expand Up @@ -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;
Expand Down
82 changes: 82 additions & 0 deletions packages/cli/src/ui/utils/historyMapping.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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)', () => {
Expand Down
31 changes: 26 additions & 5 deletions packages/cli/src/ui/utils/historyMapping.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand Down
180 changes: 180 additions & 0 deletions packages/core/src/utils/environmentContext.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Comment thread
yiliang114 marked this conversation as resolved.
const history: Content[] = [
Comment thread
yiliang114 marked this conversation as resolved.
{
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);
Comment thread
yiliang114 marked this conversation as resolved.
});

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', () => {
Expand Down
Loading
Loading