Skip to content

Commit fcb706a

Browse files
committed
fix(cli): share compression rewind markers
1 parent 0e10bb2 commit fcb706a

8 files changed

Lines changed: 438 additions & 25 deletions

File tree

packages/cli/src/acp-integration/session/Session.test.ts

Lines changed: 158 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,11 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
88
import * as fs from 'node:fs/promises';
99
import * as os from 'node:os';
1010
import * as path from 'node:path';
11-
import { computeInitialTurnFromHistory, Session } from './Session.js';
11+
import {
12+
computeInitialModelFacingUserTurnCountFromHistory,
13+
computeInitialTurnFromHistory,
14+
Session,
15+
} from './Session.js';
1216
import type { Content } from '@google/genai';
1317
import type { ChatRecord, Config, GeminiChat } from '@qwen-code/qwen-code-core';
1418
import { ApprovalMode, AuthType } from '@qwen-code/qwen-code-core';
@@ -123,6 +127,41 @@ describe('computeInitialTurnFromHistory', () => {
123127
});
124128
});
125129

130+
describe('computeInitialModelFacingUserTurnCountFromHistory', () => {
131+
it('counts model-facing user text records without slash-only records', () => {
132+
expect(
133+
computeInitialModelFacingUserTurnCountFromHistory(
134+
[
135+
chatRecord({
136+
uuid: 'user-1',
137+
message: { parts: [{ text: 'first' }] },
138+
}),
139+
chatRecord({
140+
uuid: 'slash-1',
141+
message: { parts: [{ text: '/help' }] },
142+
}),
143+
chatRecord({
144+
uuid: 'cron-1',
145+
subtype: 'cron',
146+
message: { parts: [{ text: 'cron prompt' }] },
147+
}),
148+
chatRecord({
149+
uuid: 'notification-1',
150+
subtype: 'notification',
151+
message: { parts: [{ text: 'FYI' }] },
152+
}),
153+
chatRecord({
154+
uuid: 'other-session',
155+
sessionId: 'other-session-id',
156+
message: { parts: [{ text: 'other' }] },
157+
}),
158+
],
159+
'test-session-id',
160+
),
161+
).toBe(2);
162+
});
163+
});
164+
126165
// Helper to create empty async generator (avoids memory leak from inline generators)
127166
function createEmptyStream() {
128167
return (async function* () {})();
@@ -153,6 +192,13 @@ function expectCompressBeforeSend(
153192
);
154193
}
155194

195+
function setSessionTurnCounters(
196+
targetSession: Session,
197+
counters: { turn?: number; modelFacingUserTurnCount?: number },
198+
) {
199+
Object.assign(targetSession as unknown as Record<string, number>, counters);
200+
}
201+
156202
describe('Session', () => {
157203
let mockChat: GeminiChat;
158204
let mockConfig: Config;
@@ -351,6 +397,117 @@ describe('Session', () => {
351397
expect(mockChat.truncateHistory).toHaveBeenCalledWith(2);
352398
});
353399

400+
it('maps ACP rewind to the uncompressed tail after chat compression', () => {
401+
setSessionTurnCounters(session, {
402+
turn: 3,
403+
modelFacingUserTurnCount: 3,
404+
});
405+
const history: Content[] = [
406+
{ role: 'user', parts: [{ text: 'summary of first two turns' }] },
407+
{
408+
role: 'model',
409+
parts: [{ text: core.COMPRESSION_SUMMARY_MODEL_ACK }],
410+
},
411+
{ role: 'user', parts: [{ text: 'third' }] },
412+
{ role: 'model', parts: [{ text: 'third reply' }] },
413+
];
414+
vi.mocked(mockChat.getHistory).mockReturnValue(history);
415+
416+
const result = session.rewindToTurn(2);
417+
418+
expect(result).toEqual({ targetTurnIndex: 2, apiTruncateIndex: 2 });
419+
expect(mockChat.truncateHistory).toHaveBeenCalledWith(2);
420+
});
421+
422+
it('uses model-facing turn count when slash commands advance prompt ids', () => {
423+
setSessionTurnCounters(session, {
424+
turn: 4,
425+
modelFacingUserTurnCount: 3,
426+
});
427+
vi.mocked(mockChat.getHistory).mockReturnValue([
428+
{ role: 'user', parts: [{ text: 'summary of first two turns' }] },
429+
{
430+
role: 'model',
431+
parts: [{ text: core.COMPRESSION_SUMMARY_MODEL_ACK }],
432+
},
433+
{ role: 'user', parts: [{ text: 'third' }] },
434+
{ role: 'model', parts: [{ text: 'third reply' }] },
435+
]);
436+
437+
const result = session.rewindToTurn(2);
438+
439+
expect(result).toEqual({ targetTurnIndex: 2, apiTruncateIndex: 2 });
440+
expect(mockChat.truncateHistory).toHaveBeenCalledWith(2);
441+
});
442+
443+
it('uses model-facing turn count when cron adds user text entries', () => {
444+
setSessionTurnCounters(session, {
445+
turn: 2,
446+
modelFacingUserTurnCount: 3,
447+
});
448+
vi.mocked(mockChat.getHistory).mockReturnValue([
449+
{ role: 'user', parts: [{ text: 'summary of first two turns' }] },
450+
{
451+
role: 'model',
452+
parts: [{ text: core.COMPRESSION_SUMMARY_MODEL_ACK }],
453+
},
454+
{ role: 'user', parts: [{ text: 'cron prompt' }] },
455+
{ role: 'model', parts: [{ text: 'cron reply' }] },
456+
]);
457+
458+
expect(() => session.rewindToTurn(1)).toThrow(
459+
'Cannot rewind to the requested turn',
460+
);
461+
expect(mockChat.truncateHistory).not.toHaveBeenCalled();
462+
});
463+
464+
it('rejects ACP rewind to the first turn after compression absorbed it', () => {
465+
setSessionTurnCounters(session, {
466+
turn: 3,
467+
modelFacingUserTurnCount: 3,
468+
});
469+
vi.mocked(mockChat.getHistory).mockReturnValue([
470+
{ role: 'user', parts: [{ text: 'summary of first two turns' }] },
471+
{
472+
role: 'model',
473+
parts: [{ text: core.COMPRESSION_SUMMARY_MODEL_ACK }],
474+
},
475+
{ role: 'user', parts: [{ text: 'third' }] },
476+
{ role: 'model', parts: [{ text: 'third reply' }] },
477+
]);
478+
479+
expect(() => session.rewindToTurn(0)).toThrow(
480+
'Cannot rewind to the requested turn',
481+
);
482+
expect(mockChat.truncateHistory).not.toHaveBeenCalled();
483+
});
484+
485+
it('does not treat the compression bridge as an ACP rewind target', () => {
486+
setSessionTurnCounters(session, {
487+
turn: 3,
488+
modelFacingUserTurnCount: 3,
489+
});
490+
vi.mocked(mockChat.getHistory).mockReturnValue([
491+
{ role: 'user', parts: [{ text: 'summary of first two turns' }] },
492+
{
493+
role: 'model',
494+
parts: [{ text: core.COMPRESSION_SUMMARY_MODEL_ACK }],
495+
},
496+
{
497+
role: 'user',
498+
parts: [{ text: core.COMPRESSION_CONTINUATION_BRIDGE }],
499+
},
500+
{ role: 'model', parts: [{ text: 'continued response' }] },
501+
{ role: 'user', parts: [{ text: 'third' }] },
502+
{ role: 'model', parts: [{ text: 'third reply' }] },
503+
]);
504+
505+
expect(() => session.rewindToTurn(1)).toThrow(
506+
'Cannot rewind to the requested turn',
507+
);
508+
expect(mockChat.truncateHistory).not.toHaveBeenCalled();
509+
});
510+
354511
it('rejects unreachable user turns', () => {
355512
vi.mocked(mockChat.getHistory).mockReturnValue([
356513
{ role: 'user', parts: [{ text: 'first' }] },

packages/cli/src/acp-integration/session/Session.ts

Lines changed: 124 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,8 @@ import {
5555
getSubagentSystemReminder,
5656
getArenaSystemReminder,
5757
STARTUP_CONTEXT_MODEL_ACK,
58+
COMPRESSION_CONTINUATION_BRIDGE,
59+
COMPRESSION_SUMMARY_MODEL_ACK,
5860
evaluatePermissionFlow,
5961
needsConfirmation,
6062
isPlanModeBlocked,
@@ -151,6 +153,16 @@ export function computeInitialTurnFromHistory(
151153
return maxPromptTurn > 0 ? maxPromptTurn : userMessageCount;
152154
}
153155

156+
export function computeInitialModelFacingUserTurnCountFromHistory(
157+
records: ChatRecord[],
158+
sessionId: string,
159+
): number {
160+
return records.filter(
161+
(record) =>
162+
record.sessionId === sessionId && isModelFacingUserPromptRecord(record),
163+
).length;
164+
}
165+
154166
function getRecordPromptIds(record: ChatRecord): string[] {
155167
const promptIds: string[] = [];
156168
const recordPromptId = (record as { promptId?: unknown }).promptId;
@@ -187,6 +199,31 @@ function isUserPromptRecord(record: ChatRecord): boolean {
187199
);
188200
}
189201

202+
function isModelFacingUserPromptRecord(record: ChatRecord): boolean {
203+
if (record.type !== 'user') {
204+
return false;
205+
}
206+
if (record.subtype === 'notification') {
207+
return false;
208+
}
209+
const textParts =
210+
record.message?.parts
211+
?.filter(
212+
(part): part is { text: string } & Part =>
213+
'text' in part &&
214+
typeof part.text === 'string' &&
215+
part.text.trim().length > 0,
216+
)
217+
.map((part) => part.text.trim()) ?? [];
218+
if (textParts.length === 0) {
219+
return false;
220+
}
221+
if (record.subtype === 'cron') {
222+
return true;
223+
}
224+
return !isSlashCommand(textParts.join(' '));
225+
}
226+
190227
/**
191228
* Session represents an active conversation session with the AI model.
192229
* It uses modular components for consistent event emission:
@@ -206,6 +243,7 @@ export class Session implements SessionContext {
206243
*/
207244
private pendingPromptCompletion: Promise<void> | null = null;
208245
private turn: number = 0;
246+
private modelFacingUserTurnCount: number = 0;
209247
private readonly runtimeBaseDir: string;
210248

211249
// Cron scheduling state
@@ -278,6 +316,13 @@ export class Session implements SessionContext {
278316
this.turn,
279317
computeInitialTurnFromHistory(records, this.config.getSessionId()),
280318
);
319+
this.modelFacingUserTurnCount = Math.max(
320+
this.modelFacingUserTurnCount,
321+
computeInitialModelFacingUserTurnCountFromHistory(
322+
records,
323+
this.config.getSessionId(),
324+
),
325+
);
281326
await this.historyReplayer.replay(records);
282327
}
283328

@@ -347,24 +392,76 @@ export class Session implements SessionContext {
347392
): number {
348393
const startIndex = this.#hasStartupContext(apiHistory) ? 2 : 0;
349394

395+
if (this.#hasCompressionSummaryPair(apiHistory, startIndex)) {
396+
const apiTailUserIndices = this.#getApiUserTextIndices(
397+
apiHistory,
398+
startIndex + 2,
399+
true,
400+
);
401+
const totalUserTurns = Math.max(
402+
this.modelFacingUserTurnCount,
403+
targetTurnIndex + 1,
404+
);
405+
const compressedTurnCount = Math.max(
406+
0,
407+
totalUserTurns - apiTailUserIndices.length,
408+
);
409+
410+
if (targetTurnIndex < compressedTurnCount) {
411+
return -1;
412+
}
413+
414+
// Defensive: the guard above should keep this index in range.
415+
return apiTailUserIndices[targetTurnIndex - compressedTurnCount] ?? -1;
416+
}
417+
350418
if (targetTurnIndex === 0) {
351419
return startIndex;
352420
}
353421

354-
let realUserPromptCount = 0;
422+
return (
423+
this.#getApiUserTextIndices(apiHistory, startIndex, false)[
424+
targetTurnIndex
425+
] ?? -1
426+
);
427+
}
428+
429+
#getApiUserTextIndices(
430+
apiHistory: Content[],
431+
startIndex: number,
432+
skipContinuationBridge: boolean,
433+
): number[] {
434+
const indices: number[] = [];
435+
355436
for (let i = startIndex; i < apiHistory.length; i++) {
356-
if (!this.#isUserTextContent(apiHistory[i]!)) {
437+
const content = apiHistory[i]!;
438+
if (!this.#isUserTextContent(content)) continue;
439+
if (
440+
skipContinuationBridge &&
441+
this.#hasTextPart(content, COMPRESSION_CONTINUATION_BRIDGE)
442+
) {
357443
continue;
358444
}
359445

360-
if (realUserPromptCount === targetTurnIndex) {
361-
return i;
362-
}
363-
364-
realUserPromptCount += 1;
446+
indices.push(i);
365447
}
366448

367-
return -1;
449+
return indices;
450+
}
451+
452+
#hasCompressionSummaryPair(
453+
apiHistory: Content[],
454+
startIndex: number,
455+
): boolean {
456+
const summary = apiHistory[startIndex];
457+
return (
458+
!!summary &&
459+
this.#isUserTextContent(summary) &&
460+
this.#hasModelTextPart(
461+
apiHistory[startIndex + 1],
462+
COMPRESSION_SUMMARY_MODEL_ACK,
463+
)
464+
);
368465
}
369466

370467
#hasStartupContext(apiHistory: Content[]): boolean {
@@ -379,6 +476,17 @@ export class Session implements SessionContext {
379476
);
380477
}
381478

479+
#hasTextPart(content: Content | undefined, text: string): boolean {
480+
return (
481+
content?.parts?.some((part) => 'text' in part && part.text === text) ??
482+
false
483+
);
484+
}
485+
486+
#hasModelTextPart(content: Content | undefined, text: string): boolean {
487+
return content?.role === 'model' && this.#hasTextPart(content, text);
488+
}
489+
382490
#isUserTextContent(content: Content): boolean {
383491
if (content.role !== 'user') return false;
384492
if (!content.parts || content.parts.length === 0) return false;
@@ -603,6 +711,7 @@ export class Session implements SessionContext {
603711
}
604712

605713
let nextMessage: Content | null = { role: 'user', parts };
714+
this.#recordModelFacingUserTurn(nextMessage);
606715

607716
while (nextMessage !== null) {
608717
if (pendingSend.signal.aborted) {
@@ -1006,6 +1115,12 @@ export class Session implements SessionContext {
10061115
return this.config.getGeminiClient()!.getChat();
10071116
}
10081117

1118+
#recordModelFacingUserTurn(message: Content): void {
1119+
if (this.#isUserTextContent(message)) {
1120+
this.modelFacingUserTurnCount += 1;
1121+
}
1122+
}
1123+
10091124
/**
10101125
* Mirrors the core send path for ACP model sends.
10111126
*
@@ -1324,6 +1439,7 @@ export class Session implements SessionContext {
13241439
role: 'user',
13251440
parts: [...cronReminders, { text: prompt }],
13261441
};
1442+
this.#recordModelFacingUserTurn(nextMessage);
13271443

13281444
while (nextMessage !== null) {
13291445
if (ac.signal.aborted) return;

0 commit comments

Comments
 (0)