Skip to content

Commit 3c23952

Browse files
authored
Merge pull request #2897 from QwenLM/feat/thinking-cross-turn-retention-idle-cleanup
feat(core): thinking block cross-turn retention with idle cleanup
2 parents db7488f + 6a55a9a commit 3c23952

9 files changed

Lines changed: 434 additions & 2 deletions

File tree

docs/users/configuration/settings.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,7 @@ The `extra_body` field allows you to add custom parameters to the request body s
206206
| `context.fileFiltering.respectQwenIgnore` | boolean | Respect .qwenignore files when searching. | `true` |
207207
| `context.fileFiltering.enableRecursiveFileSearch` | boolean | Whether to enable searching recursively for filenames under the current tree when completing `@` prefixes in the prompt. | `true` |
208208
| `context.fileFiltering.enableFuzzySearch` | boolean | When `true`, enables fuzzy search capabilities when searching for files. Set to `false` to improve performance on projects with a large number of files. | `true` |
209+
| `context.gapThresholdMinutes` | number | Minutes of inactivity after which retained thinking blocks are cleared to free context tokens. Aligns with typical provider prompt-cache TTL. Set higher if your provider has a longer cache TTL. | `5` |
209210

210211
#### Troubleshooting File Search Performance
211212

packages/cli/src/config/config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1069,6 +1069,7 @@ export async function loadCliConfig(
10691069
telemetry: telemetrySettings,
10701070
usageStatisticsEnabled: settings.privacy?.usageStatisticsEnabled ?? true,
10711071
fileFiltering: settings.context?.fileFiltering,
1072+
thinkingIdleThresholdMinutes: settings.context?.gapThresholdMinutes,
10721073
checkpointing:
10731074
argv.checkpointing || settings.general?.checkpointing?.enabled,
10741075
proxy:

packages/cli/src/config/settingsSchema.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -924,6 +924,16 @@ const SETTINGS_SCHEMA = {
924924
},
925925
},
926926
},
927+
gapThresholdMinutes: {
928+
type: 'number',
929+
label: 'Thinking Block Idle Threshold (minutes)',
930+
category: 'Context',
931+
requiresRestart: false,
932+
default: 5,
933+
description:
934+
'Minutes of inactivity after which retained thinking blocks are cleared to free context tokens. Aligns with provider prompt-cache TTL.',
935+
showInDialog: false,
936+
},
927937
},
928938
},
929939

packages/core/src/config/config.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -371,6 +371,8 @@ export interface ConfigParameters {
371371
model?: string;
372372
outputLanguageFilePath?: string;
373373
maxSessionTurns?: number;
374+
/** Minutes of inactivity before clearing retained thinking blocks. */
375+
thinkingIdleThresholdMinutes?: number;
374376
sessionTokenLimit?: number;
375377
experimentalZedIntegration?: boolean;
376378
cronEnabled?: boolean;
@@ -559,6 +561,7 @@ export class Config {
559561
private ideMode: boolean;
560562

561563
private readonly maxSessionTurns: number;
564+
private readonly thinkingIdleThresholdMs: number;
562565
private readonly sessionTokenLimit: number;
563566
private readonly listExtensions: boolean;
564567
private readonly overrideExtensions?: string[];
@@ -685,6 +688,8 @@ export class Config {
685688
this.fileDiscoveryService = params.fileDiscoveryService ?? null;
686689
this.bugCommand = params.bugCommand;
687690
this.maxSessionTurns = params.maxSessionTurns ?? -1;
691+
this.thinkingIdleThresholdMs =
692+
(params.thinkingIdleThresholdMinutes ?? 5) * 60 * 1000;
688693
this.sessionTokenLimit = params.sessionTokenLimit ?? -1;
689694
this.experimentalZedIntegration =
690695
params.experimentalZedIntegration ?? false;
@@ -1331,6 +1336,10 @@ export class Config {
13311336
return this.maxSessionTurns;
13321337
}
13331338

1339+
getThinkingIdleThresholdMs(): number {
1340+
return this.thinkingIdleThresholdMs;
1341+
}
1342+
13341343
getSessionTokenLimit(): number {
13351344
return this.sessionTokenLimit;
13361345
}

packages/core/src/core/client.test.ts

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -323,6 +323,7 @@ describe('Gemini Client (client.ts)', () => {
323323
getWorkingDir: vi.fn().mockReturnValue('/test/dir'),
324324
getFileService: vi.fn().mockReturnValue(fileService),
325325
getMaxSessionTurns: vi.fn().mockReturnValue(0),
326+
getThinkingIdleThresholdMs: vi.fn().mockReturnValue(5 * 60 * 1000),
326327
getSessionTokenLimit: vi.fn().mockReturnValue(32000),
327328
getNoBrowser: vi.fn().mockReturnValue(false),
328329
getUsageStatisticsEnabled: vi.fn().mockReturnValue(true),
@@ -427,6 +428,119 @@ describe('Gemini Client (client.ts)', () => {
427428
});
428429
});
429430

431+
describe('thinking block idle cleanup and latch', () => {
432+
let mockChat: Partial<GeminiChat>;
433+
434+
beforeEach(() => {
435+
const mockStream = (async function* () {
436+
yield {
437+
type: GeminiEventType.Content,
438+
value: 'response',
439+
};
440+
})();
441+
mockTurnRunFn.mockReturnValue(mockStream);
442+
443+
mockChat = {
444+
addHistory: vi.fn(),
445+
getHistory: vi.fn().mockReturnValue([]),
446+
stripThoughtsFromHistory: vi.fn(),
447+
stripThoughtsFromHistoryKeepRecent: vi.fn(),
448+
};
449+
client['chat'] = mockChat as GeminiChat;
450+
});
451+
452+
it('should not strip thoughts on active session (< 5min idle)', async () => {
453+
// Simulate a recent API completion (2 minutes ago — within default 5 min threshold)
454+
client['lastApiCompletionTimestamp'] = Date.now() - 2 * 60 * 1000;
455+
client['thinkingClearLatched'] = false;
456+
457+
const gen = client.sendMessageStream(
458+
[{ text: 'Hello' }],
459+
new AbortController().signal,
460+
'prompt-1',
461+
{ type: SendMessageType.UserQuery },
462+
);
463+
for await (const _ of gen) {
464+
/* drain */
465+
}
466+
467+
expect(
468+
mockChat.stripThoughtsFromHistoryKeepRecent,
469+
).not.toHaveBeenCalled();
470+
});
471+
472+
it('should latch and strip thoughts after > 5min idle', async () => {
473+
// Simulate an old API completion (10 minutes ago — exceeds default 5 min threshold)
474+
client['lastApiCompletionTimestamp'] = Date.now() - 10 * 60 * 1000;
475+
client['thinkingClearLatched'] = false;
476+
477+
const gen = client.sendMessageStream(
478+
[{ text: 'Hello' }],
479+
new AbortController().signal,
480+
'prompt-2',
481+
{ type: SendMessageType.UserQuery },
482+
);
483+
for await (const _ of gen) {
484+
/* drain */
485+
}
486+
487+
expect(client['thinkingClearLatched']).toBe(true);
488+
expect(mockChat.stripThoughtsFromHistoryKeepRecent).toHaveBeenCalledWith(
489+
1,
490+
);
491+
});
492+
493+
it('should keep stripping once latched even if idle < 5min', async () => {
494+
// Pre-set latch with a recent timestamp (2 minutes ago — within threshold)
495+
client['lastApiCompletionTimestamp'] = Date.now() - 2 * 60 * 1000;
496+
client['thinkingClearLatched'] = true;
497+
498+
const gen = client.sendMessageStream(
499+
[{ text: 'Hello' }],
500+
new AbortController().signal,
501+
'prompt-3',
502+
{ type: SendMessageType.UserQuery },
503+
);
504+
for await (const _ of gen) {
505+
/* drain */
506+
}
507+
508+
expect(client['thinkingClearLatched']).toBe(true);
509+
expect(mockChat.stripThoughtsFromHistoryKeepRecent).toHaveBeenCalledWith(
510+
1,
511+
);
512+
});
513+
514+
it('should update lastApiCompletionTimestamp after API call', async () => {
515+
client['lastApiCompletionTimestamp'] = null;
516+
517+
const before = Date.now();
518+
const gen = client.sendMessageStream(
519+
[{ text: 'Hello' }],
520+
new AbortController().signal,
521+
'prompt-4',
522+
{ type: SendMessageType.UserQuery },
523+
);
524+
for await (const _ of gen) {
525+
/* drain */
526+
}
527+
528+
expect(client['lastApiCompletionTimestamp']).toBeGreaterThanOrEqual(
529+
before,
530+
);
531+
});
532+
533+
it('should reset latch and timestamp on resetChat', async () => {
534+
client['lastApiCompletionTimestamp'] = Date.now();
535+
client['thinkingClearLatched'] = true;
536+
537+
await client.resetChat();
538+
539+
expect(client['thinkingClearLatched']).toBe(false);
540+
expect(client['lastApiCompletionTimestamp']).toBeNull();
541+
});
542+
});
543+
430544
describe('tryCompressChat', () => {
431545
const mockGetHistory = vi.fn();
432546

@@ -436,6 +550,7 @@ describe('Gemini Client (client.ts)', () => {
436550
addHistory: vi.fn(),
437551
setHistory: vi.fn(),
438552
stripThoughtsFromHistory: vi.fn(),
553+
stripThoughtsFromHistoryKeepRecent: vi.fn(),
439554
} as unknown as GeminiChat;
440555
});
441556

@@ -457,6 +572,7 @@ describe('Gemini Client (client.ts)', () => {
457572
getHistory: vi.fn((_curated?: boolean) => chatHistory),
458573
setHistory: vi.fn(),
459574
stripThoughtsFromHistory: vi.fn(),
575+
stripThoughtsFromHistoryKeepRecent: vi.fn(),
460576
};
461577
client['chat'] = mockOriginalChat as GeminiChat;
462578

@@ -1149,6 +1265,7 @@ describe('Gemini Client (client.ts)', () => {
11491265
addHistory: vi.fn(),
11501266
getHistory: vi.fn().mockReturnValue([]),
11511267
stripThoughtsFromHistory: vi.fn(),
1268+
stripThoughtsFromHistoryKeepRecent: vi.fn(),
11521269
} as unknown as GeminiChat;
11531270
client['chat'] = mockChat;
11541271

@@ -1204,6 +1321,7 @@ Other open files:
12041321
addHistory: vi.fn(),
12051322
getHistory: vi.fn().mockReturnValue([]),
12061323
stripThoughtsFromHistory: vi.fn(),
1324+
stripThoughtsFromHistoryKeepRecent: vi.fn(),
12071325
};
12081326
client['chat'] = mockChat as GeminiChat;
12091327

@@ -1260,6 +1378,7 @@ Other open files:
12601378
addHistory: vi.fn(),
12611379
getHistory: vi.fn().mockReturnValue([]),
12621380
stripThoughtsFromHistory: vi.fn(),
1381+
stripThoughtsFromHistoryKeepRecent: vi.fn(),
12631382
};
12641383
client['chat'] = mockChat as GeminiChat;
12651384

@@ -1326,6 +1445,7 @@ hello
13261445
addHistory: vi.fn(),
13271446
getHistory: vi.fn().mockReturnValue([]),
13281447
stripThoughtsFromHistory: vi.fn(),
1448+
stripThoughtsFromHistoryKeepRecent: vi.fn(),
13291449
};
13301450
client['chat'] = mockChat as GeminiChat;
13311451

@@ -1365,6 +1485,7 @@ Other open files:
13651485
addHistory: vi.fn(),
13661486
getHistory: vi.fn().mockReturnValue([]),
13671487
stripThoughtsFromHistory: vi.fn(),
1488+
stripThoughtsFromHistoryKeepRecent: vi.fn(),
13681489
};
13691490
client['chat'] = mockChat as GeminiChat;
13701491

@@ -1410,6 +1531,7 @@ Other open files:
14101531
addHistory: vi.fn(),
14111532
getHistory: vi.fn().mockReturnValue([]),
14121533
stripThoughtsFromHistory: vi.fn(),
1534+
stripThoughtsFromHistoryKeepRecent: vi.fn(),
14131535
};
14141536
client['chat'] = mockChat as GeminiChat;
14151537

@@ -1498,6 +1620,7 @@ Other open files:
14981620
addHistory: vi.fn(),
14991621
getHistory: vi.fn().mockReturnValue([]),
15001622
stripThoughtsFromHistory: vi.fn(),
1623+
stripThoughtsFromHistoryKeepRecent: vi.fn(),
15011624
};
15021625
client['chat'] = mockChat as GeminiChat;
15031626

@@ -1555,6 +1678,7 @@ Other open files:
15551678
addHistory: vi.fn(),
15561679
getHistory: vi.fn().mockReturnValue([]),
15571680
stripThoughtsFromHistory: vi.fn(),
1681+
stripThoughtsFromHistoryKeepRecent: vi.fn(),
15581682
};
15591683
client['chat'] = mockChat as GeminiChat;
15601684

@@ -1636,6 +1760,7 @@ Other open files:
16361760
{ role: 'user', parts: [{ text: 'previous message' }] },
16371761
]),
16381762
stripThoughtsFromHistory: vi.fn(),
1763+
stripThoughtsFromHistoryKeepRecent: vi.fn(),
16391764
};
16401765
client['chat'] = mockChat as GeminiChat;
16411766
});
@@ -1889,6 +2014,7 @@ Other open files:
18892014
getHistory: vi.fn().mockReturnValue([]), // Default empty history
18902015
setHistory: vi.fn(),
18912016
stripThoughtsFromHistory: vi.fn(),
2017+
stripThoughtsFromHistoryKeepRecent: vi.fn(),
18922018
};
18932019
client['chat'] = mockChat as GeminiChat;
18942020

@@ -2228,6 +2354,7 @@ Other open files:
22282354
addHistory: vi.fn(),
22292355
getHistory: vi.fn().mockReturnValue([]),
22302356
stripThoughtsFromHistory: vi.fn(),
2357+
stripThoughtsFromHistoryKeepRecent: vi.fn(),
22312358
};
22322359
client['chat'] = mockChat as GeminiChat;
22332360

@@ -2265,6 +2392,7 @@ Other open files:
22652392
addHistory: vi.fn(),
22662393
getHistory: vi.fn().mockReturnValue([]),
22672394
stripThoughtsFromHistory: vi.fn(),
2395+
stripThoughtsFromHistoryKeepRecent: vi.fn(),
22682396
};
22692397
client['chat'] = mockChat as GeminiChat;
22702398

@@ -2305,6 +2433,7 @@ Other open files:
23052433
addHistory: vi.fn(),
23062434
getHistory: vi.fn().mockReturnValue([]),
23072435
stripThoughtsFromHistory: vi.fn(),
2436+
stripThoughtsFromHistoryKeepRecent: vi.fn(),
23082437
};
23092438
client['chat'] = mockChat as GeminiChat;
23102439

@@ -2329,6 +2458,7 @@ Other open files:
23292458
getHistory: vi.fn().mockReturnValue([]),
23302459
setHistory: vi.fn(),
23312460
stripThoughtsFromHistory: vi.fn(),
2461+
stripThoughtsFromHistoryKeepRecent: vi.fn(),
23322462
stripOrphanedUserEntriesFromHistory: vi.fn(),
23332463
};
23342464
client['chat'] = mockChat as GeminiChat;
@@ -2361,6 +2491,7 @@ Other open files:
23612491
getHistory: vi.fn().mockReturnValue([]),
23622492
setHistory: vi.fn(),
23632493
stripThoughtsFromHistory: vi.fn(),
2494+
stripThoughtsFromHistoryKeepRecent: vi.fn(),
23642495
stripOrphanedUserEntriesFromHistory: vi.fn(),
23652496
};
23662497
client['chat'] = mockChat as GeminiChat;
@@ -2405,6 +2536,7 @@ Other open files:
24052536
addHistory: vi.fn(),
24062537
getHistory: vi.fn().mockReturnValue([]),
24072538
stripThoughtsFromHistory: vi.fn(),
2539+
stripThoughtsFromHistoryKeepRecent: vi.fn(),
24082540
};
24092541
client['chat'] = mockChat as GeminiChat;
24102542
});

0 commit comments

Comments
 (0)