@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.
- package/lib/browser/chat-session-store-impl.d.ts +3 -0
- package/lib/browser/chat-session-store-impl.d.ts.map +1 -1
- package/lib/browser/chat-session-store-impl.js +46 -7
- package/lib/browser/chat-session-store-impl.js.map +1 -1
- package/lib/browser/chat-session-store-impl.spec.d.ts +2 -0
- package/lib/browser/chat-session-store-impl.spec.d.ts.map +1 -0
- package/lib/browser/chat-session-store-impl.spec.js +383 -0
- package/lib/browser/chat-session-store-impl.spec.js.map +1 -0
- package/lib/common/ai-chat-preferences.d.ts +1 -0
- package/lib/common/ai-chat-preferences.d.ts.map +1 -1
- package/lib/common/ai-chat-preferences.js +10 -1
- package/lib/common/ai-chat-preferences.js.map +1 -1
- package/lib/common/chat-agents.d.ts +6 -1
- package/lib/common/chat-agents.d.ts.map +1 -1
- package/lib/common/chat-agents.js +17 -5
- package/lib/common/chat-agents.js.map +1 -1
- package/lib/common/chat-model-serialization.d.ts +2 -0
- package/lib/common/chat-model-serialization.d.ts.map +1 -1
- package/lib/common/chat-model-serialization.js.map +1 -1
- package/lib/common/chat-model.d.ts +13 -0
- package/lib/common/chat-model.d.ts.map +1 -1
- package/lib/common/chat-model.js +17 -0
- package/lib/common/chat-model.js.map +1 -1
- package/package.json +9 -9
- package/src/browser/chat-session-store-impl.spec.ts +491 -0
- package/src/browser/chat-session-store-impl.ts +49 -7
- package/src/common/ai-chat-preferences.ts +10 -0
- package/src/common/chat-agents.ts +31 -3
- package/src/common/chat-model-serialization.ts +2 -0
- 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
|
-
|
|
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
|
-
|
|
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 -
|
|
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.
|
|
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(
|
|
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(
|