@theia/ai-chat 1.67.0 → 1.68.0-next.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/lib/browser/chat-session-store-impl.d.ts +3 -0
  2. package/lib/browser/chat-session-store-impl.d.ts.map +1 -1
  3. package/lib/browser/chat-session-store-impl.js +46 -7
  4. package/lib/browser/chat-session-store-impl.js.map +1 -1
  5. package/lib/browser/chat-session-store-impl.spec.d.ts +2 -0
  6. package/lib/browser/chat-session-store-impl.spec.d.ts.map +1 -0
  7. package/lib/browser/chat-session-store-impl.spec.js +383 -0
  8. package/lib/browser/chat-session-store-impl.spec.js.map +1 -0
  9. package/lib/common/ai-chat-preferences.d.ts +1 -0
  10. package/lib/common/ai-chat-preferences.d.ts.map +1 -1
  11. package/lib/common/ai-chat-preferences.js +10 -1
  12. package/lib/common/ai-chat-preferences.js.map +1 -1
  13. package/lib/common/chat-agents.d.ts +6 -1
  14. package/lib/common/chat-agents.d.ts.map +1 -1
  15. package/lib/common/chat-agents.js +17 -5
  16. package/lib/common/chat-agents.js.map +1 -1
  17. package/lib/common/chat-model-serialization.d.ts +2 -0
  18. package/lib/common/chat-model-serialization.d.ts.map +1 -1
  19. package/lib/common/chat-model-serialization.js.map +1 -1
  20. package/lib/common/chat-model.d.ts +13 -0
  21. package/lib/common/chat-model.d.ts.map +1 -1
  22. package/lib/common/chat-model.js +17 -0
  23. package/lib/common/chat-model.js.map +1 -1
  24. package/package.json +9 -9
  25. package/src/browser/chat-session-store-impl.spec.ts +491 -0
  26. package/src/browser/chat-session-store-impl.ts +49 -7
  27. package/src/common/ai-chat-preferences.ts +10 -0
  28. package/src/common/chat-agents.ts +31 -3
  29. package/src/common/chat-model-serialization.ts +2 -0
  30. package/src/common/chat-model.ts +28 -0
@@ -0,0 +1,491 @@
1
+ // *****************************************************************************
2
+ // Copyright (C) 2025 EclipseSource GmbH.
3
+ //
4
+ // This program and the accompanying materials are made available under the
5
+ // terms of the Eclipse Public License v. 2.0 which is available at
6
+ // http://www.eclipse.org/legal/epl-2.0.
7
+ //
8
+ // This Source Code may also be made available under the following Secondary
9
+ // Licenses when the conditions for such availability set forth in the Eclipse
10
+ // Public License v. 2.0 are satisfied: GNU General Public License, version 2
11
+ // with the GNU Classpath Exception which is available at
12
+ // https://www.gnu.org/software/classpath/license.html.
13
+ //
14
+ // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
15
+ // *****************************************************************************
16
+
17
+ import { enableJSDOM } from '@theia/core/lib/browser/test/jsdom';
18
+ let disableJSDOM = enableJSDOM();
19
+ import { FrontendApplicationConfigProvider } from '@theia/core/lib/browser/frontend-application-config-provider';
20
+ FrontendApplicationConfigProvider.set({});
21
+
22
+ import { expect } from 'chai';
23
+ import * as sinon from 'sinon';
24
+ import { Container } from '@theia/core/shared/inversify';
25
+ import { ChatSessionStoreImpl } from './chat-session-store-impl';
26
+ import { FileService } from '@theia/filesystem/lib/browser/file-service';
27
+ import { WorkspaceService } from '@theia/workspace/lib/browser';
28
+ import { EnvVariablesServer } from '@theia/core/lib/common/env-variables';
29
+ import { PreferenceService } from '@theia/core/lib/common';
30
+ import { StorageService } from '@theia/core/lib/browser';
31
+ import { ILogger } from '@theia/core/lib/common/logger';
32
+ import { URI } from '@theia/core';
33
+ import { BinaryBuffer } from '@theia/core/lib/common/buffer';
34
+ import { ChatSessionIndex, ChatSessionMetadata } from '../common/chat-session-store';
35
+ import { PERSISTED_SESSION_LIMIT_PREF } from '../common/ai-chat-preferences';
36
+ import { ChatAgentLocation } from '../common/chat-agents';
37
+
38
+ disableJSDOM();
39
+
40
+ describe('ChatSessionStoreImpl', () => {
41
+ let sandbox: sinon.SinonSandbox;
42
+ let container: Container;
43
+ let chatSessionStore: ChatSessionStoreImpl;
44
+ let mockFileService: sinon.SinonStubbedInstance<FileService>;
45
+ let mockPreferenceService: sinon.SinonStubbedInstance<PreferenceService>;
46
+ let mockEnvServer: sinon.SinonStubbedInstance<EnvVariablesServer>;
47
+ let deletedFiles: string[];
48
+
49
+ const STORAGE_ROOT = 'file:///config/chatSessions';
50
+
51
+ function createMockSessionMetadata(id: string, saveDate: number): ChatSessionMetadata {
52
+ return {
53
+ sessionId: id,
54
+ title: `Session ${id}`,
55
+ saveDate,
56
+ location: ChatAgentLocation.Panel
57
+ };
58
+ }
59
+
60
+ function createMockIndex(sessions: ChatSessionMetadata[]): ChatSessionIndex {
61
+ const index: ChatSessionIndex = {};
62
+ for (const session of sessions) {
63
+ index[session.sessionId] = session;
64
+ }
65
+ return index;
66
+ }
67
+
68
+ before(() => {
69
+ disableJSDOM = enableJSDOM();
70
+ });
71
+
72
+ after(() => {
73
+ disableJSDOM();
74
+ });
75
+
76
+ beforeEach(() => {
77
+ sandbox = sinon.createSandbox();
78
+ deletedFiles = [];
79
+
80
+ container = new Container();
81
+
82
+ mockFileService = {
83
+ readFile: sandbox.stub(),
84
+ writeFile: sandbox.stub().resolves(),
85
+ delete: sandbox.stub().callsFake(async (uri: URI) => {
86
+ deletedFiles.push(uri.toString());
87
+ }),
88
+ createFolder: sandbox.stub().resolves()
89
+ } as unknown as sinon.SinonStubbedInstance<FileService>;
90
+
91
+ mockPreferenceService = {
92
+ get: sandbox.stub()
93
+ } as unknown as sinon.SinonStubbedInstance<PreferenceService>;
94
+
95
+ mockEnvServer = {
96
+ getConfigDirUri: sandbox.stub().resolves('file:///config')
97
+ } as unknown as sinon.SinonStubbedInstance<EnvVariablesServer>;
98
+
99
+ const mockWorkspaceService = {} as WorkspaceService;
100
+ const mockStorageService = {} as StorageService;
101
+ const mockLogger = {
102
+ debug: sandbox.stub(),
103
+ info: sandbox.stub(),
104
+ warn: sandbox.stub(),
105
+ error: sandbox.stub()
106
+ } as unknown as ILogger;
107
+
108
+ container.bind(FileService).toConstantValue(mockFileService as unknown as FileService);
109
+ container.bind(PreferenceService).toConstantValue(mockPreferenceService as unknown as PreferenceService);
110
+ container.bind(EnvVariablesServer).toConstantValue(mockEnvServer as unknown as EnvVariablesServer);
111
+ container.bind(WorkspaceService).toConstantValue(mockWorkspaceService);
112
+ container.bind(StorageService).toConstantValue(mockStorageService);
113
+ container.bind('ChatSessionStore').toConstantValue(mockLogger);
114
+ container.bind(ILogger).toConstantValue(mockLogger).whenTargetNamed('ChatSessionStore');
115
+
116
+ container.bind(ChatSessionStoreImpl).toSelf().inSingletonScope();
117
+
118
+ chatSessionStore = container.get(ChatSessionStoreImpl);
119
+ });
120
+
121
+ afterEach(() => {
122
+ sandbox.restore();
123
+ });
124
+
125
+ describe('trimSessions', () => {
126
+ describe('when persistedSessionLimit is -1 (unlimited)', () => {
127
+ beforeEach(() => {
128
+ mockPreferenceService.get.withArgs(PERSISTED_SESSION_LIMIT_PREF, 25).returns(-1);
129
+ });
130
+
131
+ it('should not delete any sessions regardless of count', async () => {
132
+ const sessions = [
133
+ createMockSessionMetadata('session-1', 1000),
134
+ createMockSessionMetadata('session-2', 2000),
135
+ createMockSessionMetadata('session-3', 3000),
136
+ createMockSessionMetadata('session-4', 4000),
137
+ createMockSessionMetadata('session-5', 5000)
138
+ ];
139
+ const index = createMockIndex(sessions);
140
+
141
+ mockFileService.readFile.resolves({
142
+ value: BinaryBuffer.fromString(JSON.stringify(index))
143
+ } as never);
144
+
145
+ // Access protected method via any
146
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
147
+ await (chatSessionStore as any).trimSessions();
148
+
149
+ expect(deletedFiles).to.be.empty;
150
+ expect(mockFileService.delete.called).to.be.false;
151
+ });
152
+
153
+ it('should not delete sessions even with 100 sessions', async () => {
154
+ const sessions = Array.from({ length: 100 }, (_, i) =>
155
+ createMockSessionMetadata(`session-${i}`, (i + 1) * 1000)
156
+ );
157
+ const index = createMockIndex(sessions);
158
+
159
+ mockFileService.readFile.resolves({
160
+ value: BinaryBuffer.fromString(JSON.stringify(index))
161
+ } as never);
162
+
163
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
164
+ await (chatSessionStore as any).trimSessions();
165
+
166
+ expect(deletedFiles).to.be.empty;
167
+ });
168
+ });
169
+
170
+ describe('when persistedSessionLimit is 0 (no persistence)', () => {
171
+ beforeEach(() => {
172
+ mockPreferenceService.get.withArgs(PERSISTED_SESSION_LIMIT_PREF, 25).returns(0);
173
+ });
174
+
175
+ it('should delete all sessions and clear index', async () => {
176
+ const sessions = [
177
+ createMockSessionMetadata('session-1', 1000),
178
+ createMockSessionMetadata('session-2', 2000),
179
+ createMockSessionMetadata('session-3', 3000)
180
+ ];
181
+ const index = createMockIndex(sessions);
182
+
183
+ mockFileService.readFile.resolves({
184
+ value: BinaryBuffer.fromString(JSON.stringify(index))
185
+ } as never);
186
+
187
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
188
+ await (chatSessionStore as any).trimSessions();
189
+
190
+ expect(deletedFiles).to.have.lengthOf(3);
191
+ expect(deletedFiles).to.include(`${STORAGE_ROOT}/session-1.json`);
192
+ expect(deletedFiles).to.include(`${STORAGE_ROOT}/session-2.json`);
193
+ expect(deletedFiles).to.include(`${STORAGE_ROOT}/session-3.json`);
194
+
195
+ const savedIndexCall = mockFileService.writeFile.lastCall;
196
+ expect(savedIndexCall).to.not.be.null;
197
+ const savedIndex = JSON.parse(savedIndexCall.args[1].toString());
198
+ expect(Object.keys(savedIndex)).to.have.lengthOf(0);
199
+ });
200
+
201
+ it('should handle empty index gracefully', async () => {
202
+ const index: ChatSessionIndex = {};
203
+
204
+ mockFileService.readFile.resolves({
205
+ value: BinaryBuffer.fromString(JSON.stringify(index))
206
+ } as never);
207
+
208
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
209
+ await (chatSessionStore as any).trimSessions();
210
+
211
+ expect(deletedFiles).to.be.empty;
212
+ });
213
+ });
214
+
215
+ describe('when persistedSessionLimit is positive', () => {
216
+ it('should not trim when session count is within limit', async () => {
217
+ mockPreferenceService.get.withArgs(PERSISTED_SESSION_LIMIT_PREF, 25).returns(5);
218
+
219
+ const sessions = [
220
+ createMockSessionMetadata('session-1', 1000),
221
+ createMockSessionMetadata('session-2', 2000),
222
+ createMockSessionMetadata('session-3', 3000)
223
+ ];
224
+ const index = createMockIndex(sessions);
225
+
226
+ mockFileService.readFile.resolves({
227
+ value: BinaryBuffer.fromString(JSON.stringify(index))
228
+ } as never);
229
+
230
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
231
+ await (chatSessionStore as any).trimSessions();
232
+
233
+ expect(deletedFiles).to.be.empty;
234
+ });
235
+
236
+ it('should trim oldest sessions when count exceeds limit', async () => {
237
+ mockPreferenceService.get.withArgs(PERSISTED_SESSION_LIMIT_PREF, 25).returns(3);
238
+
239
+ const sessions = [
240
+ createMockSessionMetadata('session-1', 1000),
241
+ createMockSessionMetadata('session-2', 2000),
242
+ createMockSessionMetadata('session-3', 3000),
243
+ createMockSessionMetadata('session-4', 4000),
244
+ createMockSessionMetadata('session-5', 5000)
245
+ ];
246
+ const index = createMockIndex(sessions);
247
+
248
+ mockFileService.readFile.resolves({
249
+ value: BinaryBuffer.fromString(JSON.stringify(index))
250
+ } as never);
251
+
252
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
253
+ await (chatSessionStore as any).trimSessions();
254
+
255
+ expect(deletedFiles).to.have.lengthOf(2);
256
+ expect(deletedFiles).to.include(`${STORAGE_ROOT}/session-1.json`);
257
+ expect(deletedFiles).to.include(`${STORAGE_ROOT}/session-2.json`);
258
+ expect(deletedFiles).to.not.include(`${STORAGE_ROOT}/session-3.json`);
259
+ expect(deletedFiles).to.not.include(`${STORAGE_ROOT}/session-4.json`);
260
+ expect(deletedFiles).to.not.include(`${STORAGE_ROOT}/session-5.json`);
261
+ });
262
+
263
+ it('should delete sessions in order of saveDate (oldest first)', async () => {
264
+ mockPreferenceService.get.withArgs(PERSISTED_SESSION_LIMIT_PREF, 25).returns(2);
265
+
266
+ const sessions = [
267
+ createMockSessionMetadata('session-newest', 5000),
268
+ createMockSessionMetadata('session-middle', 3000),
269
+ createMockSessionMetadata('session-oldest', 1000),
270
+ createMockSessionMetadata('session-second-oldest', 2000)
271
+ ];
272
+ const index = createMockIndex(sessions);
273
+
274
+ mockFileService.readFile.resolves({
275
+ value: BinaryBuffer.fromString(JSON.stringify(index))
276
+ } as never);
277
+
278
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
279
+ await (chatSessionStore as any).trimSessions();
280
+
281
+ expect(deletedFiles).to.have.lengthOf(2);
282
+ expect(deletedFiles).to.include(`${STORAGE_ROOT}/session-oldest.json`);
283
+ expect(deletedFiles).to.include(`${STORAGE_ROOT}/session-second-oldest.json`);
284
+ expect(deletedFiles).to.not.include(`${STORAGE_ROOT}/session-newest.json`);
285
+ expect(deletedFiles).to.not.include(`${STORAGE_ROOT}/session-middle.json`);
286
+ });
287
+
288
+ it('should update index after trimming', async () => {
289
+ mockPreferenceService.get.withArgs(PERSISTED_SESSION_LIMIT_PREF, 25).returns(2);
290
+
291
+ const sessions = [
292
+ createMockSessionMetadata('session-1', 1000),
293
+ createMockSessionMetadata('session-2', 2000),
294
+ createMockSessionMetadata('session-3', 3000),
295
+ createMockSessionMetadata('session-4', 4000)
296
+ ];
297
+ const index = createMockIndex(sessions);
298
+
299
+ mockFileService.readFile.resolves({
300
+ value: BinaryBuffer.fromString(JSON.stringify(index))
301
+ } as never);
302
+
303
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
304
+ await (chatSessionStore as any).trimSessions();
305
+
306
+ const savedIndexCall = mockFileService.writeFile.lastCall;
307
+ expect(savedIndexCall).to.not.be.null;
308
+
309
+ const savedIndex = JSON.parse(savedIndexCall.args[1].toString());
310
+ expect(Object.keys(savedIndex)).to.have.lengthOf(2);
311
+ expect(savedIndex['session-3']).to.not.be.undefined;
312
+ expect(savedIndex['session-4']).to.not.be.undefined;
313
+ expect(savedIndex['session-1']).to.be.undefined;
314
+ expect(savedIndex['session-2']).to.be.undefined;
315
+ });
316
+
317
+ it('should trim to exactly the limit (42 session bug scenario)', async () => {
318
+ mockPreferenceService.get.withArgs(PERSISTED_SESSION_LIMIT_PREF, 25).returns(25);
319
+
320
+ const sessions = Array.from({ length: 42 }, (_, i) =>
321
+ createMockSessionMetadata(`session-${i}`, (i + 1) * 1000)
322
+ );
323
+ const index = createMockIndex(sessions);
324
+
325
+ mockFileService.readFile.resolves({
326
+ value: BinaryBuffer.fromString(JSON.stringify(index))
327
+ } as never);
328
+
329
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
330
+ await (chatSessionStore as any).trimSessions();
331
+
332
+ expect(deletedFiles).to.have.lengthOf(17);
333
+
334
+ for (let i = 0; i < 17; i++) {
335
+ expect(deletedFiles).to.include(`${STORAGE_ROOT}/session-${i}.json`);
336
+ }
337
+
338
+ for (let i = 17; i < 42; i++) {
339
+ expect(deletedFiles).to.not.include(`${STORAGE_ROOT}/session-${i}.json`);
340
+ }
341
+ });
342
+ });
343
+
344
+ describe('edge cases', () => {
345
+ it('should handle empty session index', async () => {
346
+ mockPreferenceService.get.withArgs(PERSISTED_SESSION_LIMIT_PREF, 25).returns(5);
347
+
348
+ const index: ChatSessionIndex = {};
349
+
350
+ mockFileService.readFile.resolves({
351
+ value: BinaryBuffer.fromString(JSON.stringify(index))
352
+ } as never);
353
+
354
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
355
+ await (chatSessionStore as any).trimSessions();
356
+
357
+ expect(deletedFiles).to.be.empty;
358
+ });
359
+
360
+ it('should handle sessions exactly at limit', async () => {
361
+ mockPreferenceService.get.withArgs(PERSISTED_SESSION_LIMIT_PREF, 25).returns(3);
362
+
363
+ const sessions = [
364
+ createMockSessionMetadata('session-1', 1000),
365
+ createMockSessionMetadata('session-2', 2000),
366
+ createMockSessionMetadata('session-3', 3000)
367
+ ];
368
+ const index = createMockIndex(sessions);
369
+
370
+ mockFileService.readFile.resolves({
371
+ value: BinaryBuffer.fromString(JSON.stringify(index))
372
+ } as never);
373
+
374
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
375
+ await (chatSessionStore as any).trimSessions();
376
+
377
+ expect(deletedFiles).to.be.empty;
378
+ });
379
+
380
+ it('should handle limit of 1', async () => {
381
+ mockPreferenceService.get.withArgs(PERSISTED_SESSION_LIMIT_PREF, 25).returns(1);
382
+
383
+ const sessions = [
384
+ createMockSessionMetadata('session-1', 1000),
385
+ createMockSessionMetadata('session-2', 2000),
386
+ createMockSessionMetadata('session-3', 3000)
387
+ ];
388
+ const index = createMockIndex(sessions);
389
+
390
+ mockFileService.readFile.resolves({
391
+ value: BinaryBuffer.fromString(JSON.stringify(index))
392
+ } as never);
393
+
394
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
395
+ await (chatSessionStore as any).trimSessions();
396
+
397
+ expect(deletedFiles).to.have.lengthOf(2);
398
+ expect(deletedFiles).to.include(`${STORAGE_ROOT}/session-1.json`);
399
+ expect(deletedFiles).to.include(`${STORAGE_ROOT}/session-2.json`);
400
+ expect(deletedFiles).to.not.include(`${STORAGE_ROOT}/session-3.json`);
401
+ });
402
+
403
+ it('should handle file deletion errors gracefully', async () => {
404
+ mockPreferenceService.get.withArgs(PERSISTED_SESSION_LIMIT_PREF, 25).returns(2);
405
+
406
+ const sessions = [
407
+ createMockSessionMetadata('session-1', 1000),
408
+ createMockSessionMetadata('session-2', 2000),
409
+ createMockSessionMetadata('session-3', 3000)
410
+ ];
411
+ const index = createMockIndex(sessions);
412
+
413
+ mockFileService.readFile.resolves({
414
+ value: BinaryBuffer.fromString(JSON.stringify(index))
415
+ } as never);
416
+
417
+ mockFileService.delete.rejects(new Error('File not found'));
418
+
419
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
420
+ await (chatSessionStore as any).trimSessions();
421
+
422
+ const savedIndexCall = mockFileService.writeFile.lastCall;
423
+ const savedIndex = JSON.parse(savedIndexCall.args[1].toString());
424
+ expect(Object.keys(savedIndex)).to.have.lengthOf(2);
425
+ });
426
+
427
+ it('should handle sessions with equal save dates', async () => {
428
+ mockPreferenceService.get.withArgs(PERSISTED_SESSION_LIMIT_PREF, 25).returns(2);
429
+
430
+ const sessions = [
431
+ createMockSessionMetadata('session-a', 1000),
432
+ createMockSessionMetadata('session-b', 1000),
433
+ createMockSessionMetadata('session-c', 2000),
434
+ createMockSessionMetadata('session-d', 2000)
435
+ ];
436
+ const index = createMockIndex(sessions);
437
+
438
+ mockFileService.readFile.resolves({
439
+ value: BinaryBuffer.fromString(JSON.stringify(index))
440
+ } as never);
441
+
442
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
443
+ await (chatSessionStore as any).trimSessions();
444
+
445
+ expect(deletedFiles).to.have.lengthOf(2);
446
+
447
+ const savedIndexCall = mockFileService.writeFile.lastCall;
448
+ const savedIndex = JSON.parse(savedIndexCall.args[1].toString());
449
+ expect(Object.keys(savedIndex)).to.have.lengthOf(2);
450
+ });
451
+ });
452
+ });
453
+
454
+ describe('getPersistedSessionLimit', () => {
455
+ it('should return -1 for unlimited sessions', () => {
456
+ mockPreferenceService.get.withArgs(PERSISTED_SESSION_LIMIT_PREF, 25).returns(-1);
457
+
458
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
459
+ const result = (chatSessionStore as any).getPersistedSessionLimit();
460
+
461
+ expect(result).to.equal(-1);
462
+ });
463
+
464
+ it('should return 0 for no persistence', () => {
465
+ mockPreferenceService.get.withArgs(PERSISTED_SESSION_LIMIT_PREF, 25).returns(0);
466
+
467
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
468
+ const result = (chatSessionStore as any).getPersistedSessionLimit();
469
+
470
+ expect(result).to.equal(0);
471
+ });
472
+
473
+ it('should return default value of 25', () => {
474
+ mockPreferenceService.get.withArgs(PERSISTED_SESSION_LIMIT_PREF, 25).returns(25);
475
+
476
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
477
+ const result = (chatSessionStore as any).getPersistedSessionLimit();
478
+
479
+ expect(result).to.equal(25);
480
+ });
481
+
482
+ it('should return custom positive value', () => {
483
+ mockPreferenceService.get.withArgs(PERSISTED_SESSION_LIMIT_PREF, 25).returns(100);
484
+
485
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
486
+ const result = (chatSessionStore as any).getPersistedSessionLimit();
487
+
488
+ expect(result).to.equal(100);
489
+ });
490
+ });
491
+ });
@@ -18,15 +18,16 @@ import { inject, injectable, named } from '@theia/core/shared/inversify';
18
18
  import { FileService } from '@theia/filesystem/lib/browser/file-service';
19
19
  import { WorkspaceService } from '@theia/workspace/lib/browser';
20
20
  import { EnvVariablesServer } from '@theia/core/lib/common/env-variables';
21
+ import { PreferenceService } from '@theia/core/lib/common';
21
22
  import { StorageService } from '@theia/core/lib/browser';
22
23
  import { URI } from '@theia/core';
23
24
  import { BinaryBuffer } from '@theia/core/lib/common/buffer';
24
25
  import { ILogger } from '@theia/core/lib/common/logger';
25
26
  import { ChatModel } from '../common/chat-model';
26
27
  import { ChatSessionIndex, ChatSessionStore, ChatModelWithMetadata, ChatSessionMetadata } from '../common/chat-session-store';
28
+ import { PERSISTED_SESSION_LIMIT_PREF } from '../common/ai-chat-preferences';
27
29
  import { SerializedChatData, CHAT_DATA_VERSION } from '../common/chat-model-serialization';
28
30
 
29
- const MAX_SESSIONS = 25;
30
31
  const INDEX_FILE = 'index.json';
31
32
 
32
33
  @injectable()
@@ -46,6 +47,9 @@ export class ChatSessionStoreImpl implements ChatSessionStore {
46
47
  @inject(ILogger) @named('ChatSessionStore')
47
48
  protected readonly logger: ILogger;
48
49
 
50
+ @inject(PreferenceService)
51
+ protected readonly preferenceService: PreferenceService;
52
+
49
53
  protected storageRoot?: URI;
50
54
  protected indexCache?: ChatSessionIndex;
51
55
  protected storePromise: Promise<void> = Promise.resolve();
@@ -215,25 +219,63 @@ export class ChatSessionStoreImpl implements ChatSessionStore {
215
219
  await this.saveIndex(index);
216
220
  }
217
221
 
222
+ protected getPersistedSessionLimit(): number {
223
+ return this.preferenceService.get<number>(PERSISTED_SESSION_LIMIT_PREF, 25);
224
+ }
225
+
218
226
  protected async trimSessions(): Promise<void> {
227
+ const maxSessions = this.getPersistedSessionLimit();
228
+
229
+ // -1 means unlimited, skip trimming
230
+ if (maxSessions === -1) {
231
+ return;
232
+ }
233
+
219
234
  const index = await this.loadIndex();
220
235
  const sessions = Object.values(index);
221
236
 
222
- if (sessions.length <= MAX_SESSIONS) {
237
+ // 0 means no persistence - delete all sessions
238
+ if (maxSessions === 0) {
239
+ this.logger.debug('Session persistence disabled, deleting all sessions', { sessionCount: sessions.length });
240
+ for (const session of sessions) {
241
+ const root = await this.getStorageRoot();
242
+ const sessionFile = root.resolve(`${session.sessionId}.json`);
243
+ try {
244
+ await this.fileService.delete(sessionFile);
245
+ } catch (e) {
246
+ this.logger.debug('Failed to delete session file', { sessionId: session.sessionId, error: e });
247
+ }
248
+ delete index[session.sessionId];
249
+ }
250
+ await this.saveIndex(index);
223
251
  return;
224
252
  }
225
253
 
226
- this.logger.debug('Trimming sessions', { currentCount: sessions.length, maxSessions: MAX_SESSIONS });
254
+ if (sessions.length <= maxSessions) {
255
+ return;
256
+ }
257
+
258
+ this.logger.debug('Trimming sessions', { currentCount: sessions.length, maxSessions });
227
259
 
228
- // Sort by save date
260
+ // Sort by save date (oldest first)
229
261
  sessions.sort((a, b) => a.saveDate - b.saveDate);
230
262
 
231
- // Delete oldest sessions
232
- const sessionsToDelete = sessions.slice(0, sessions.length - MAX_SESSIONS);
263
+ // Delete oldest sessions beyond the limit
264
+ const sessionsToDelete = sessions.slice(0, sessions.length - maxSessions);
233
265
  this.logger.debug('Deleting oldest sessions', { deleteCount: sessionsToDelete.length, sessionIds: sessionsToDelete.map(s => s.sessionId) });
266
+
234
267
  for (const session of sessionsToDelete) {
235
- await this.deleteSession(session.sessionId);
268
+ const root = await this.getStorageRoot();
269
+ const sessionFile = root.resolve(`${session.sessionId}.json`);
270
+ try {
271
+ await this.fileService.delete(sessionFile);
272
+ } catch (e) {
273
+ this.logger.debug('Failed to delete session file', { sessionId: session.sessionId, error: e });
274
+ }
275
+ delete index[session.sessionId];
236
276
  }
277
+
278
+ await this.saveIndex(index);
237
279
  }
238
280
 
239
281
  protected async loadIndex(): Promise<ChatSessionIndex> {
@@ -20,6 +20,7 @@ import { nls, PreferenceSchema } from '@theia/core';
20
20
  export const DEFAULT_CHAT_AGENT_PREF = 'ai-features.chat.defaultChatAgent';
21
21
  export const PIN_CHAT_AGENT_PREF = 'ai-features.chat.pinChatAgent';
22
22
  export const BYPASS_MODEL_REQUIREMENT_PREF = 'ai-features.chat.bypassModelRequirement';
23
+ export const PERSISTED_SESSION_LIMIT_PREF = 'ai-features.chat.persistedSessionLimit';
23
24
 
24
25
  export const aiChatPreferences: PreferenceSchema = {
25
26
  properties: {
@@ -44,6 +45,15 @@ export const aiChatPreferences: PreferenceSchema = {
44
45
  'Bypass the language model requirement check. Enable this if you are using external agents (e.g., Claude Code) that do not require Theia language models.'),
45
46
  default: false,
46
47
  title: AI_CORE_PREFERENCES_TITLE,
48
+ },
49
+ [PERSISTED_SESSION_LIMIT_PREF]: {
50
+ type: 'number',
51
+ description: nls.localize('theia/ai/chat/persistedSessionLimit/description',
52
+ 'Maximum number of chat sessions to persist. Use -1 for unlimited sessions, 0 to disable session persistence. ' +
53
+ 'When the limit is reduced, the oldest sessions exceeding the new limit are automatically removed on the next save.'),
54
+ default: 25,
55
+ minimum: -1,
56
+ title: AI_CORE_PREFERENCES_TITLE,
47
57
  }
48
58
  }
49
59
  };
@@ -24,6 +24,7 @@ import {
24
24
  AIVariableContext,
25
25
  AIVariableResolutionRequest,
26
26
  getTextOfResponse,
27
+ isCustomizedPromptFragment,
27
28
  isLanguageModelStreamResponsePart,
28
29
  isTextResponsePart,
29
30
  isThinkingResponsePart,
@@ -76,12 +77,22 @@ export interface SystemMessageDescription {
76
77
  text: string;
77
78
  /** All functions references in the system message. */
78
79
  functionDescriptions?: Map<string, ToolRequest>;
80
+ /** The prompt variant ID used */
81
+ promptVariantId?: string;
82
+ /** Whether the prompt variant is customized */
83
+ isPromptVariantEdited?: boolean;
79
84
  }
80
85
  export namespace SystemMessageDescription {
81
- export function fromResolvedPromptFragment(resolvedPrompt: ResolvedPromptFragment): SystemMessageDescription {
86
+ export function fromResolvedPromptFragment(
87
+ resolvedPrompt: ResolvedPromptFragment,
88
+ promptVariantId?: string,
89
+ isPromptVariantEdited?: boolean
90
+ ): SystemMessageDescription {
82
91
  return {
83
92
  text: resolvedPrompt.text,
84
- functionDescriptions: resolvedPrompt.functionDescriptions
93
+ functionDescriptions: resolvedPrompt.functionDescriptions,
94
+ promptVariantId,
95
+ isPromptVariantEdited
85
96
  };
86
97
  }
87
98
  }
@@ -192,6 +203,14 @@ export abstract class AbstractChatAgent implements ChatAgent {
192
203
  throw new Error(nls.localize('theia/ai/chat/couldNotFindMatchingLM', 'Couldn\'t find a matching language model. Please check your setup!'));
193
204
  }
194
205
  const systemMessageDescription = await this.getSystemMessageDescription({ model: request.session, request } satisfies ChatSessionContext);
206
+
207
+ if (systemMessageDescription?.promptVariantId) {
208
+ request.response.setPromptVariantInfo(
209
+ systemMessageDescription.promptVariantId,
210
+ systemMessageDescription.isPromptVariantEdited ?? false
211
+ );
212
+ }
213
+
195
214
  const messages = await this.getMessages(request.session);
196
215
 
197
216
  if (systemMessageDescription) {
@@ -255,8 +274,17 @@ export abstract class AbstractChatAgent implements ChatAgent {
255
274
  if (this.systemPromptId === undefined) {
256
275
  return undefined;
257
276
  }
277
+
278
+ const effectiveVariantId = this.promptService.getEffectiveVariantId(this.systemPromptId) ?? this.systemPromptId;
279
+ const isEdited = this.isPromptVariantCustomized(effectiveVariantId);
280
+
258
281
  const resolvedPrompt = await this.promptService.getResolvedPromptFragment(this.systemPromptId, undefined, context);
259
- return resolvedPrompt ? SystemMessageDescription.fromResolvedPromptFragment(resolvedPrompt) : undefined;
282
+ return resolvedPrompt ? SystemMessageDescription.fromResolvedPromptFragment(resolvedPrompt, effectiveVariantId, isEdited) : undefined;
283
+ }
284
+
285
+ protected isPromptVariantCustomized(fragmentId: string): boolean {
286
+ const fragment = this.promptService.getRawPromptFragment(fragmentId);
287
+ return fragment ? isCustomizedPromptFragment(fragment) : false;
260
288
  }
261
289
 
262
290
  protected async getMessages(