Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
1 change: 1 addition & 0 deletions packages/cli/src/ui/commands/compressCommand.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ describe('compressCommand', () => {
compressionStatus: CompressionStatus.COMPRESSED,
originalTokenCount: 200,
newTokenCount: 100,
compressionKind: 'summarize',
},
},
expect.any(Number),
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/ui/commands/compressCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,7 @@ export const compressCommand: SlashCommand = {
originalTokenCount: compressed.originalTokenCount,
newTokenCount: compressed.newTokenCount,
compressionStatus: compressed.compressionStatus,
compressionKind: 'summarize',
},
} as HistoryItemCompression,
Date.now(),
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/ui/commands/compressFastCommand.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,7 @@ describe('compressFastCommand', () => {
originalTokenCount: 200,
newTokenCount: 100,
compressionStatus: CompressionStatus.COMPRESSED,
compressionKind: 'fast',
},
},
expect.any(Number),
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/ui/commands/compressFastCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ export const compressFastCommand: SlashCommand = {
originalTokenCount: compressed.originalTokenCount,
newTokenCount: compressed.newTokenCount,
compressionStatus: compressed.compressionStatus,
compressionKind: 'fast',
},
} as HistoryItemCompression,
Date.now(),
Expand Down
8 changes: 8 additions & 0 deletions packages/cli/src/ui/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'.
Comment thread
yiliang114 marked this conversation as resolved.
Resolved
*/
compressionKind?: 'summarize' | 'fast';
}

export interface SummaryProps {
Expand Down
216 changes: 216 additions & 0 deletions packages/cli/src/ui/utils/historyMapping.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ function geminiItem(id: number): HistoryItem {
function compressionItem(
id: number,
compressionStatus = CompressionStatus.COMPRESSED,
compressionKind: 'summarize' | 'fast' = 'summarize',
): HistoryItem {
return {
type: 'compression',
Expand All @@ -72,6 +73,7 @@ function compressionItem(
originalTokenCount: 100,
newTokenCount: 40,
compressionStatus,
compressionKind,
},
} as HistoryItem;
}
Expand Down Expand Up @@ -350,6 +352,220 @@ describe('computeApiTruncationIndex', () => {
});
});

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('<state_snapshot>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('<state_snapshot>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: <mime>]'). 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);
});
});

describe('mid-turn user messages (notification type)', () => {
it('skips notification items so btw merged into functionResponse does not cause mismatch', () => {
// Mid-turn messages are type 'notification' in UI (not counted by
Expand Down
26 changes: 24 additions & 2 deletions packages/cli/src/ui/utils/historyMapping.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
CompressionStatus,
getStartupContextLength,
isSystemReminderContent,
MICROCOMPACT_CLEARED_IMAGE_PREFIX,
} from '@qwen-code/qwen-code-core';
import { isSlashCommand } from './commandUtils.js';

Expand Down Expand Up @@ -56,14 +57,35 @@ 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
Comment thread
yiliang114 marked this conversation as resolved.
Resolved
// microcompaction replaces the top-level inlineData/fileData parts of
// user entries with text placeholders. A media-only user entry (e.g. an
// image-only ACP prompt) 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. An entry that mixes
// placeholders with real prompt text still counts (it IS a real turn).
return content.parts.some(
(part) =>
'text' in part &&
!!part.text &&
!part.text.startsWith(MICROCOMPACT_CLEARED_IMAGE_PREFIX),
Comment thread
yiliang114 marked this conversation as resolved.
Resolved
);
Comment thread
yiliang114 marked this conversation as resolved.
Resolved
}

/**
* 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',
Comment thread
yiliang114 marked this conversation as resolved.
Resolved
Comment thread
yiliang114 marked this conversation as resolved.
Resolved
Comment thread
yiliang114 marked this conversation as resolved.
Resolved
Comment thread
yiliang114 marked this conversation as resolved.
Resolved
Comment thread
yiliang114 marked this conversation as resolved.
Resolved
);
}

Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,10 @@ export {
resolveSlimmingConfig,
type ResolvedSlimmingConfig,
} from './services/compactionInputSlimming.js';
export {
MICROCOMPACT_CLEARED_MESSAGE,
MICROCOMPACT_CLEARED_IMAGE_PREFIX,
} from './services/microcompaction/microcompact.js';
Comment thread
yiliang114 marked this conversation as resolved.
Resolved
export * from './services/chatRecordingService.js';
export * from './services/branch-points.js';
export * from './services/cronScheduler.js';
Expand Down
9 changes: 9 additions & 0 deletions packages/core/src/services/chatRecordingService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -460,6 +460,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 */
Expand Down
Loading