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
1 change: 1 addition & 0 deletions docs/users/configuration/settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,7 @@ The `extra_body` field allows you to add custom parameters to the request body s
| `context.fileFiltering.respectQwenIgnore` | boolean | Respect .qwenignore files when searching. | `true` |
| `context.fileFiltering.enableRecursiveFileSearch` | boolean | Whether to enable searching recursively for filenames under the current tree when completing `@` prefixes in the prompt. | `true` |
| `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` |
| `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` |

#### Troubleshooting File Search Performance

Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1069,6 +1069,7 @@ export async function loadCliConfig(
telemetry: telemetrySettings,
usageStatisticsEnabled: settings.privacy?.usageStatisticsEnabled ?? true,
fileFiltering: settings.context?.fileFiltering,
thinkingIdleThresholdMinutes: settings.context?.gapThresholdMinutes,
checkpointing:
argv.checkpointing || settings.general?.checkpointing?.enabled,
proxy:
Expand Down
10 changes: 10 additions & 0 deletions packages/cli/src/config/settingsSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -914,6 +914,16 @@ const SETTINGS_SCHEMA = {
},
},
},
gapThresholdMinutes: {
type: 'number',
label: 'Thinking Block Idle Threshold (minutes)',
category: 'Context',
requiresRestart: false,
default: 5,
description:
'Minutes of inactivity after which retained thinking blocks are cleared to free context tokens. Aligns with provider prompt-cache TTL.',
showInDialog: false,
},
},
},

Expand Down
9 changes: 9 additions & 0 deletions packages/core/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -370,6 +370,8 @@ export interface ConfigParameters {
model?: string;
outputLanguageFilePath?: string;
maxSessionTurns?: number;
/** Minutes of inactivity before clearing retained thinking blocks. */
thinkingIdleThresholdMinutes?: number;
sessionTokenLimit?: number;
experimentalZedIntegration?: boolean;
cronEnabled?: boolean;
Expand Down Expand Up @@ -557,6 +559,7 @@ export class Config {
private ideMode: boolean;

private readonly maxSessionTurns: number;
private readonly thinkingIdleThresholdMs: number;
private readonly sessionTokenLimit: number;
private readonly listExtensions: boolean;
private readonly overrideExtensions?: string[];
Expand Down Expand Up @@ -683,6 +686,8 @@ export class Config {
this.fileDiscoveryService = params.fileDiscoveryService ?? null;
this.bugCommand = params.bugCommand;
this.maxSessionTurns = params.maxSessionTurns ?? -1;
this.thinkingIdleThresholdMs =
(params.thinkingIdleThresholdMinutes ?? 5) * 60 * 1000;
this.sessionTokenLimit = params.sessionTokenLimit ?? -1;
this.experimentalZedIntegration =
params.experimentalZedIntegration ?? false;
Expand Down Expand Up @@ -1329,6 +1334,10 @@ export class Config {
return this.maxSessionTurns;
}

getThinkingIdleThresholdMs(): number {
return this.thinkingIdleThresholdMs;
}

getSessionTokenLimit(): number {
return this.sessionTokenLimit;
}
Expand Down
132 changes: 132 additions & 0 deletions packages/core/src/core/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,7 @@ describe('Gemini Client (client.ts)', () => {
getWorkingDir: vi.fn().mockReturnValue('/test/dir'),
getFileService: vi.fn().mockReturnValue(fileService),
getMaxSessionTurns: vi.fn().mockReturnValue(0),
getThinkingIdleThresholdMs: vi.fn().mockReturnValue(5 * 60 * 1000),
getSessionTokenLimit: vi.fn().mockReturnValue(32000),
getNoBrowser: vi.fn().mockReturnValue(false),
getUsageStatisticsEnabled: vi.fn().mockReturnValue(true),
Expand Down Expand Up @@ -427,6 +428,119 @@ describe('Gemini Client (client.ts)', () => {
});
});

describe('thinking block idle cleanup and latch', () => {
let mockChat: Partial<GeminiChat>;

beforeEach(() => {
const mockStream = (async function* () {
yield {
type: GeminiEventType.Content,
value: 'response',
};
})();
mockTurnRunFn.mockReturnValue(mockStream);

mockChat = {
addHistory: vi.fn(),
getHistory: vi.fn().mockReturnValue([]),
stripThoughtsFromHistory: vi.fn(),
stripThoughtsFromHistoryKeepRecent: vi.fn(),
};
client['chat'] = mockChat as GeminiChat;
});

it('should not strip thoughts on active session (< 5min idle)', async () => {
// Simulate a recent API completion (2 minutes ago — within default 5 min threshold)
client['lastApiCompletionTimestamp'] = Date.now() - 2 * 60 * 1000;
client['thinkingClearLatched'] = false;

const gen = client.sendMessageStream(
[{ text: 'Hello' }],
new AbortController().signal,
'prompt-1',
{ type: SendMessageType.UserQuery },
);
for await (const _ of gen) {
/* drain */
}

expect(
mockChat.stripThoughtsFromHistoryKeepRecent,
).not.toHaveBeenCalled();
});

it('should latch and strip thoughts after > 5min idle', async () => {
// Simulate an old API completion (10 minutes ago — exceeds default 5 min threshold)
client['lastApiCompletionTimestamp'] = Date.now() - 10 * 60 * 1000;
client['thinkingClearLatched'] = false;

const gen = client.sendMessageStream(
[{ text: 'Hello' }],
new AbortController().signal,
'prompt-2',
{ type: SendMessageType.UserQuery },
);
for await (const _ of gen) {
/* drain */
}

expect(client['thinkingClearLatched']).toBe(true);
expect(mockChat.stripThoughtsFromHistoryKeepRecent).toHaveBeenCalledWith(
1,
);
});

it('should keep stripping once latched even if idle < 5min', async () => {
// Pre-set latch with a recent timestamp (2 minutes ago — within threshold)
client['lastApiCompletionTimestamp'] = Date.now() - 2 * 60 * 1000;
client['thinkingClearLatched'] = true;

const gen = client.sendMessageStream(
[{ text: 'Hello' }],
new AbortController().signal,
'prompt-3',
{ type: SendMessageType.UserQuery },
);
for await (const _ of gen) {
/* drain */
}

expect(client['thinkingClearLatched']).toBe(true);
expect(mockChat.stripThoughtsFromHistoryKeepRecent).toHaveBeenCalledWith(
1,
);
});

it('should update lastApiCompletionTimestamp after API call', async () => {
client['lastApiCompletionTimestamp'] = null;

const before = Date.now();
const gen = client.sendMessageStream(
[{ text: 'Hello' }],
new AbortController().signal,
'prompt-4',
{ type: SendMessageType.UserQuery },
);
for await (const _ of gen) {
/* drain */
}

expect(client['lastApiCompletionTimestamp']).toBeGreaterThanOrEqual(
before,
);
});

it('should reset latch and timestamp on resetChat', async () => {
client['lastApiCompletionTimestamp'] = Date.now();
client['thinkingClearLatched'] = true;

await client.resetChat();

expect(client['thinkingClearLatched']).toBe(false);
expect(client['lastApiCompletionTimestamp']).toBeNull();
});
});

describe('tryCompressChat', () => {
const mockGetHistory = vi.fn();

Expand All @@ -436,6 +550,7 @@ describe('Gemini Client (client.ts)', () => {
addHistory: vi.fn(),
setHistory: vi.fn(),
stripThoughtsFromHistory: vi.fn(),
stripThoughtsFromHistoryKeepRecent: vi.fn(),
} as unknown as GeminiChat;
});

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

Expand Down Expand Up @@ -1149,6 +1265,7 @@ describe('Gemini Client (client.ts)', () => {
addHistory: vi.fn(),
getHistory: vi.fn().mockReturnValue([]),
stripThoughtsFromHistory: vi.fn(),
stripThoughtsFromHistoryKeepRecent: vi.fn(),
} as unknown as GeminiChat;
client['chat'] = mockChat;

Expand Down Expand Up @@ -1204,6 +1321,7 @@ Other open files:
addHistory: vi.fn(),
getHistory: vi.fn().mockReturnValue([]),
stripThoughtsFromHistory: vi.fn(),
stripThoughtsFromHistoryKeepRecent: vi.fn(),
};
client['chat'] = mockChat as GeminiChat;

Expand Down Expand Up @@ -1260,6 +1378,7 @@ Other open files:
addHistory: vi.fn(),
getHistory: vi.fn().mockReturnValue([]),
stripThoughtsFromHistory: vi.fn(),
stripThoughtsFromHistoryKeepRecent: vi.fn(),
};
client['chat'] = mockChat as GeminiChat;

Expand Down Expand Up @@ -1326,6 +1445,7 @@ hello
addHistory: vi.fn(),
getHistory: vi.fn().mockReturnValue([]),
stripThoughtsFromHistory: vi.fn(),
stripThoughtsFromHistoryKeepRecent: vi.fn(),
};
client['chat'] = mockChat as GeminiChat;

Expand Down Expand Up @@ -1365,6 +1485,7 @@ Other open files:
addHistory: vi.fn(),
getHistory: vi.fn().mockReturnValue([]),
stripThoughtsFromHistory: vi.fn(),
stripThoughtsFromHistoryKeepRecent: vi.fn(),
};
client['chat'] = mockChat as GeminiChat;

Expand Down Expand Up @@ -1410,6 +1531,7 @@ Other open files:
addHistory: vi.fn(),
getHistory: vi.fn().mockReturnValue([]),
stripThoughtsFromHistory: vi.fn(),
stripThoughtsFromHistoryKeepRecent: vi.fn(),
};
client['chat'] = mockChat as GeminiChat;

Expand Down Expand Up @@ -1498,6 +1620,7 @@ Other open files:
addHistory: vi.fn(),
getHistory: vi.fn().mockReturnValue([]),
stripThoughtsFromHistory: vi.fn(),
stripThoughtsFromHistoryKeepRecent: vi.fn(),
};
client['chat'] = mockChat as GeminiChat;

Expand Down Expand Up @@ -1555,6 +1678,7 @@ Other open files:
addHistory: vi.fn(),
getHistory: vi.fn().mockReturnValue([]),
stripThoughtsFromHistory: vi.fn(),
stripThoughtsFromHistoryKeepRecent: vi.fn(),
};
client['chat'] = mockChat as GeminiChat;

Expand Down Expand Up @@ -1636,6 +1760,7 @@ Other open files:
{ role: 'user', parts: [{ text: 'previous message' }] },
]),
stripThoughtsFromHistory: vi.fn(),
stripThoughtsFromHistoryKeepRecent: vi.fn(),
};
client['chat'] = mockChat as GeminiChat;
});
Expand Down Expand Up @@ -1889,6 +2014,7 @@ Other open files:
getHistory: vi.fn().mockReturnValue([]), // Default empty history
setHistory: vi.fn(),
stripThoughtsFromHistory: vi.fn(),
stripThoughtsFromHistoryKeepRecent: vi.fn(),
};
client['chat'] = mockChat as GeminiChat;

Expand Down Expand Up @@ -2228,6 +2354,7 @@ Other open files:
addHistory: vi.fn(),
getHistory: vi.fn().mockReturnValue([]),
stripThoughtsFromHistory: vi.fn(),
stripThoughtsFromHistoryKeepRecent: vi.fn(),
};
client['chat'] = mockChat as GeminiChat;

Expand Down Expand Up @@ -2265,6 +2392,7 @@ Other open files:
addHistory: vi.fn(),
getHistory: vi.fn().mockReturnValue([]),
stripThoughtsFromHistory: vi.fn(),
stripThoughtsFromHistoryKeepRecent: vi.fn(),
};
client['chat'] = mockChat as GeminiChat;

Expand Down Expand Up @@ -2305,6 +2433,7 @@ Other open files:
addHistory: vi.fn(),
getHistory: vi.fn().mockReturnValue([]),
stripThoughtsFromHistory: vi.fn(),
stripThoughtsFromHistoryKeepRecent: vi.fn(),
};
client['chat'] = mockChat as GeminiChat;

Expand All @@ -2329,6 +2458,7 @@ Other open files:
getHistory: vi.fn().mockReturnValue([]),
setHistory: vi.fn(),
stripThoughtsFromHistory: vi.fn(),
stripThoughtsFromHistoryKeepRecent: vi.fn(),
stripOrphanedUserEntriesFromHistory: vi.fn(),
};
client['chat'] = mockChat as GeminiChat;
Expand Down Expand Up @@ -2361,6 +2491,7 @@ Other open files:
getHistory: vi.fn().mockReturnValue([]),
setHistory: vi.fn(),
stripThoughtsFromHistory: vi.fn(),
stripThoughtsFromHistoryKeepRecent: vi.fn(),
stripOrphanedUserEntriesFromHistory: vi.fn(),
};
client['chat'] = mockChat as GeminiChat;
Expand Down Expand Up @@ -2405,6 +2536,7 @@ Other open files:
addHistory: vi.fn(),
getHistory: vi.fn().mockReturnValue([]),
stripThoughtsFromHistory: vi.fn(),
stripThoughtsFromHistoryKeepRecent: vi.fn(),
};
client['chat'] = mockChat as GeminiChat;
});
Expand Down
Loading
Loading