@@ -8,7 +8,11 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
88import * as fs from 'node:fs/promises' ;
99import * as os from 'node:os' ;
1010import * as path from 'node:path' ;
11- import { computeInitialTurnFromHistory , Session } from './Session.js' ;
11+ import {
12+ computeInitialModelFacingUserTurnCountFromHistory ,
13+ computeInitialTurnFromHistory ,
14+ Session ,
15+ } from './Session.js' ;
1216import type { Content } from '@google/genai' ;
1317import type { ChatRecord , Config , GeminiChat } from '@qwen-code/qwen-code-core' ;
1418import { 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)
127166function 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+
156202describe ( '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' } ] } ,
0 commit comments