Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/platform/agentHost/test/node/sessionDatabase.test.ts
13399 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import assert from 'assert';
7
import { tmpdir } from 'os';
8
import * as fs from 'fs/promises';
9
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js';
10
import { DisposableStore } from '../../../../base/common/lifecycle.js';
11
import { SessionDatabase, runMigrations, sessionDatabaseMigrations, type ISessionDatabaseMigration } from '../../node/sessionDatabase.js';
12
import { FileEditKind } from '../../common/state/sessionState.js';
13
import type { Database } from '@vscode/sqlite3';
14
import { generateUuid } from '../../../../base/common/uuid.js';
15
import { join } from '../../../../base/common/path.js';
16
17
suite('SessionDatabase', () => {
18
19
const disposables = new DisposableStore();
20
let db: SessionDatabase | undefined;
21
let db2: SessionDatabase | undefined;
22
23
teardown(async () => {
24
disposables.clear();
25
await Promise.all([db?.close(), db2?.close()]);
26
});
27
ensureNoDisposablesAreLeakedInTestSuite();
28
29
/**
30
* Extends SessionDatabase to allow ejecting/injecting the raw sqlite3
31
* Database instance, enabling reopen tests with :memory: databases.
32
*/
33
class TestableSessionDatabase extends SessionDatabase {
34
static override async open(path: string, migrations: readonly ISessionDatabaseMigration[] = sessionDatabaseMigrations): Promise<TestableSessionDatabase> {
35
const inst = new TestableSessionDatabase(path, migrations);
36
await inst._ensureDb();
37
return inst;
38
}
39
40
/** Extract the raw db connection; this instance becomes inert. */
41
async ejectDb(): Promise<Database> {
42
const rawDb = await this._ensureDb();
43
this._dbPromise = undefined;
44
this._closed = true;
45
return rawDb;
46
}
47
48
/** Create a TestableSessionDatabase wrapping an existing raw db. */
49
static async fromDb(
50
rawDb: Database,
51
migrations: readonly ISessionDatabaseMigration[] = sessionDatabaseMigrations,
52
): Promise<TestableSessionDatabase> {
53
await runMigrations(rawDb, migrations);
54
const inst = new TestableSessionDatabase(':memory:', migrations);
55
inst._dbPromise = Promise.resolve(rawDb);
56
return inst;
57
}
58
}
59
60
// ---- Migration system -----------------------------------------------
61
62
suite('migrations', () => {
63
64
test('applies all migrations on a fresh database', async () => {
65
const migrations: ISessionDatabaseMigration[] = [
66
{ version: 1, sql: 'CREATE TABLE t1 (id INTEGER PRIMARY KEY)' },
67
{ version: 2, sql: 'CREATE TABLE t2 (id INTEGER PRIMARY KEY)' },
68
];
69
70
db = disposables.add(await SessionDatabase.open(':memory:', migrations));
71
72
const tables = (await db.getAllTables()).sort();
73
assert.deepStrictEqual(tables, ['t1', 't2']);
74
});
75
76
test('reopening with same migrations is a no-op', async () => {
77
const migrations: ISessionDatabaseMigration[] = [
78
{ version: 1, sql: 'CREATE TABLE t1 (id INTEGER PRIMARY KEY)' },
79
];
80
81
const db1 = await TestableSessionDatabase.open(':memory:', migrations);
82
const rawDb = await db1.ejectDb();
83
84
// Reopen — should not throw (table already exists, migration skipped)
85
db2 = disposables.add(await TestableSessionDatabase.fromDb(rawDb, migrations));
86
assert.deepStrictEqual(await db2.getAllTables(), ['t1']);
87
});
88
89
test('only applies new migrations on reopen', async () => {
90
const v1: ISessionDatabaseMigration[] = [
91
{ version: 1, sql: 'CREATE TABLE t1 (id INTEGER PRIMARY KEY)' },
92
];
93
const db1 = await TestableSessionDatabase.open(':memory:', v1);
94
const rawDb = await db1.ejectDb();
95
96
const v2: ISessionDatabaseMigration[] = [
97
...v1,
98
{ version: 2, sql: 'CREATE TABLE t2 (id INTEGER PRIMARY KEY)' },
99
];
100
db2 = disposables.add(await TestableSessionDatabase.fromDb(rawDb, v2));
101
102
const tables = (await db2.getAllTables()).sort();
103
assert.deepStrictEqual(tables, ['t1', 't2']);
104
});
105
106
test('rolls back on migration failure', async () => {
107
const migrations: ISessionDatabaseMigration[] = [
108
{ version: 1, sql: 'CREATE TABLE t1 (id INTEGER PRIMARY KEY)' },
109
{ version: 2, sql: 'THIS IS INVALID SQL' },
110
];
111
112
await assert.rejects(() => SessionDatabase.open(':memory:', migrations));
113
114
// A fresh :memory: open with valid migrations succeeds
115
db = disposables.add(await SessionDatabase.open(':memory:', [
116
{ version: 1, sql: 'CREATE TABLE t1 (id INTEGER PRIMARY KEY)' },
117
]));
118
assert.deepStrictEqual(await db.getAllTables(), ['t1']);
119
});
120
});
121
122
// ---- File edits -----------------------------------------------------
123
124
suite('file edits', () => {
125
126
test('store and retrieve a file edit', async () => {
127
db = disposables.add(await SessionDatabase.open(':memory:'));
128
129
await db.createTurn('turn-1');
130
await db.storeFileEdit({
131
turnId: 'turn-1',
132
toolCallId: 'tc-1',
133
kind: FileEditKind.Edit,
134
filePath: '/workspace/file.ts',
135
beforeContent: new TextEncoder().encode('before'),
136
afterContent: new TextEncoder().encode('after'),
137
addedLines: 5,
138
removedLines: 2,
139
});
140
141
const edits = await db.getFileEdits(['tc-1']);
142
assert.deepStrictEqual(edits, [{
143
turnId: 'turn-1',
144
toolCallId: 'tc-1',
145
kind: FileEditKind.Edit,
146
filePath: '/workspace/file.ts',
147
originalPath: undefined,
148
addedLines: 5,
149
removedLines: 2,
150
}]);
151
});
152
153
test('retrieve multiple edits for a single tool call', async () => {
154
db = disposables.add(await SessionDatabase.open(':memory:'));
155
156
await db.createTurn('turn-1');
157
await db.storeFileEdit({
158
turnId: 'turn-1',
159
toolCallId: 'tc-1',
160
kind: FileEditKind.Edit,
161
filePath: '/workspace/a.ts',
162
beforeContent: new TextEncoder().encode('a-before'),
163
afterContent: new TextEncoder().encode('a-after'),
164
addedLines: undefined,
165
removedLines: undefined,
166
});
167
await db.storeFileEdit({
168
turnId: 'turn-1',
169
toolCallId: 'tc-1',
170
kind: FileEditKind.Edit,
171
filePath: '/workspace/b.ts',
172
beforeContent: new TextEncoder().encode('b-before'),
173
afterContent: new TextEncoder().encode('b-after'),
174
addedLines: 1,
175
removedLines: 0,
176
});
177
178
const edits = await db.getFileEdits(['tc-1']);
179
assert.strictEqual(edits.length, 2);
180
assert.strictEqual(edits[0].filePath, '/workspace/a.ts');
181
assert.strictEqual(edits[1].filePath, '/workspace/b.ts');
182
});
183
184
test('retrieve edits across multiple tool calls', async () => {
185
db = disposables.add(await SessionDatabase.open(':memory:'));
186
187
await db.createTurn('turn-1');
188
await db.storeFileEdit({
189
turnId: 'turn-1',
190
toolCallId: 'tc-1',
191
kind: FileEditKind.Edit,
192
filePath: '/workspace/a.ts',
193
beforeContent: new Uint8Array(0),
194
afterContent: new TextEncoder().encode('hello'),
195
addedLines: undefined,
196
removedLines: undefined,
197
});
198
await db.storeFileEdit({
199
turnId: 'turn-1',
200
toolCallId: 'tc-2',
201
kind: FileEditKind.Edit,
202
filePath: '/workspace/b.ts',
203
beforeContent: new Uint8Array(0),
204
afterContent: new TextEncoder().encode('world'),
205
addedLines: undefined,
206
removedLines: undefined,
207
});
208
209
const edits = await db.getFileEdits(['tc-1', 'tc-2']);
210
assert.strictEqual(edits.length, 2);
211
212
// Only tc-2
213
const edits2 = await db.getFileEdits(['tc-2']);
214
assert.strictEqual(edits2.length, 1);
215
assert.strictEqual(edits2[0].toolCallId, 'tc-2');
216
});
217
218
test('returns empty array for unknown tool call IDs', async () => {
219
db = disposables.add(await SessionDatabase.open(':memory:'));
220
const edits = await db.getFileEdits(['nonexistent']);
221
assert.deepStrictEqual(edits, []);
222
});
223
224
test.skip('returns empty array when given empty array' /* Flaky https://github.com/microsoft/vscode/issues/306057 */, async () => {
225
db = disposables.add(await SessionDatabase.open(':memory:'));
226
const edits = await db.getFileEdits([]);
227
assert.deepStrictEqual(edits, []);
228
});
229
230
test('replace on conflict (same toolCallId + filePath)', async () => {
231
db = disposables.add(await SessionDatabase.open(':memory:'));
232
233
await db.createTurn('turn-1');
234
await db.storeFileEdit({
235
turnId: 'turn-1',
236
toolCallId: 'tc-1',
237
kind: FileEditKind.Edit,
238
filePath: '/workspace/file.ts',
239
beforeContent: new TextEncoder().encode('v1'),
240
afterContent: new TextEncoder().encode('v1-after'),
241
addedLines: 1,
242
removedLines: 0,
243
});
244
await db.storeFileEdit({
245
turnId: 'turn-1',
246
toolCallId: 'tc-1',
247
kind: FileEditKind.Edit,
248
filePath: '/workspace/file.ts',
249
beforeContent: new TextEncoder().encode('v2'),
250
afterContent: new TextEncoder().encode('v2-after'),
251
addedLines: 3,
252
removedLines: 1,
253
});
254
255
const edits = await db.getFileEdits(['tc-1']);
256
assert.strictEqual(edits.length, 1);
257
assert.strictEqual(edits[0].addedLines, 3);
258
259
const content = await db.readFileEditContent('tc-1', '/workspace/file.ts');
260
assert.ok(content);
261
assert.deepStrictEqual(new TextDecoder().decode(content.beforeContent), 'v2');
262
});
263
264
test('readFileEditContent returns content on demand', async () => {
265
db = disposables.add(await SessionDatabase.open(':memory:'));
266
267
await db.createTurn('turn-1');
268
await db.storeFileEdit({
269
turnId: 'turn-1',
270
toolCallId: 'tc-1',
271
kind: FileEditKind.Edit,
272
filePath: '/workspace/file.ts',
273
beforeContent: new TextEncoder().encode('before'),
274
afterContent: new TextEncoder().encode('after'),
275
addedLines: undefined,
276
removedLines: undefined,
277
});
278
279
const content = await db.readFileEditContent('tc-1', '/workspace/file.ts');
280
assert.ok(content);
281
assert.deepStrictEqual(content.beforeContent, new TextEncoder().encode('before'));
282
assert.deepStrictEqual(content.afterContent, new TextEncoder().encode('after'));
283
});
284
285
test('readFileEditContent returns undefined for missing edit', async () => {
286
db = disposables.add(await SessionDatabase.open(':memory:'));
287
const content = await db.readFileEditContent('tc-missing', '/no/such/file');
288
assert.strictEqual(content, undefined);
289
});
290
291
test('persists binary content correctly', async () => {
292
db = disposables.add(await SessionDatabase.open(':memory:'));
293
const binary = new Uint8Array([0, 1, 2, 255, 128, 64]);
294
295
await db.createTurn('turn-1');
296
await db.storeFileEdit({
297
turnId: 'turn-1',
298
toolCallId: 'tc-bin',
299
kind: FileEditKind.Edit,
300
filePath: '/workspace/image.png',
301
beforeContent: new Uint8Array(0),
302
afterContent: binary,
303
addedLines: undefined,
304
removedLines: undefined,
305
});
306
307
const content = await db.readFileEditContent('tc-bin', '/workspace/image.png');
308
assert.ok(content);
309
assert.deepStrictEqual(content.afterContent, binary);
310
});
311
312
test('auto-creates turn if it does not exist', async () => {
313
db = disposables.add(await SessionDatabase.open(':memory:'));
314
315
// storeFileEdit should succeed even without a prior createTurn call
316
await db.storeFileEdit({
317
turnId: 'auto-turn',
318
toolCallId: 'tc-1',
319
kind: FileEditKind.Edit,
320
filePath: '/x',
321
beforeContent: new Uint8Array(0),
322
afterContent: new Uint8Array(0),
323
addedLines: undefined,
324
removedLines: undefined,
325
});
326
327
const edits = await db.getFileEdits(['tc-1']);
328
assert.strictEqual(edits.length, 1);
329
assert.strictEqual(edits[0].turnId, 'auto-turn');
330
});
331
});
332
333
// ---- Turns ----------------------------------------------------------
334
335
suite('turns', () => {
336
337
test('createTurn is idempotent', async () => {
338
db = disposables.add(await SessionDatabase.open(':memory:'));
339
await db.createTurn('turn-1');
340
await db.createTurn('turn-1'); // should not throw
341
});
342
343
test('deleteTurn cascades to file edits', async () => {
344
db = disposables.add(await SessionDatabase.open(':memory:'));
345
346
await db.createTurn('turn-1');
347
await db.storeFileEdit({
348
turnId: 'turn-1',
349
toolCallId: 'tc-1',
350
kind: FileEditKind.Edit,
351
filePath: '/workspace/a.ts',
352
beforeContent: new TextEncoder().encode('before'),
353
afterContent: new TextEncoder().encode('after'),
354
addedLines: undefined,
355
removedLines: undefined,
356
});
357
358
// Edits exist
359
assert.strictEqual((await db.getFileEdits(['tc-1'])).length, 1);
360
361
// Delete the turn — edits should be gone
362
await db.deleteTurn('turn-1');
363
assert.deepStrictEqual(await db.getFileEdits(['tc-1']), []);
364
});
365
366
test('deleteTurn only removes its own edits', async () => {
367
db = disposables.add(await SessionDatabase.open(':memory:'));
368
369
await db.createTurn('turn-1');
370
await db.createTurn('turn-2');
371
await db.storeFileEdit({
372
turnId: 'turn-1',
373
toolCallId: 'tc-1',
374
kind: FileEditKind.Edit,
375
filePath: '/workspace/a.ts',
376
beforeContent: new Uint8Array(0),
377
afterContent: new TextEncoder().encode('a'),
378
addedLines: undefined,
379
removedLines: undefined,
380
});
381
await db.storeFileEdit({
382
turnId: 'turn-2',
383
toolCallId: 'tc-2',
384
kind: FileEditKind.Edit,
385
filePath: '/workspace/b.ts',
386
beforeContent: new Uint8Array(0),
387
afterContent: new TextEncoder().encode('b'),
388
addedLines: undefined,
389
removedLines: undefined,
390
});
391
392
await db.deleteTurn('turn-1');
393
394
assert.deepStrictEqual(await db.getFileEdits(['tc-1']), []);
395
assert.strictEqual((await db.getFileEdits(['tc-2'])).length, 1);
396
});
397
398
test('deleteTurn is a no-op for unknown turn', async () => {
399
db = disposables.add(await SessionDatabase.open(':memory:'));
400
await db.deleteTurn('nonexistent'); // should not throw
401
});
402
});
403
404
// ---- Dispose --------------------------------------------------------
405
406
suite('dispose', () => {
407
408
test('methods throw after dispose', async () => {
409
db = await SessionDatabase.open(':memory:');
410
db.close();
411
412
await assert.rejects(
413
() => db!.createTurn('turn-1'),
414
/disposed/,
415
);
416
});
417
418
test('double dispose is safe', async () => {
419
db = await SessionDatabase.open(':memory:');
420
await db.close();
421
await db.close(); // should not throw
422
});
423
});
424
425
// ---- Lazy open ------------------------------------------------------
426
427
suite('lazy open', () => {
428
429
test('constructor does not open the database', () => {
430
db = new SessionDatabase(':memory:');
431
disposables.add(db);
432
// No error — the database is not opened until first use
433
});
434
435
test('first async call opens and migrates the database', async () => {
436
db = disposables.add(new SessionDatabase(':memory:'));
437
await db.createTurn('turn-1');
438
const edits = await db.getFileEdits(['nonexistent']);
439
assert.deepStrictEqual(edits, []);
440
});
441
442
test('multiple concurrent calls share the same open promise', async () => {
443
db = disposables.add(new SessionDatabase(':memory:'));
444
// Fire multiple calls concurrently — all should succeed
445
await Promise.all([
446
db.createTurn('turn-1'),
447
db.createTurn('turn-2'),
448
db.getFileEdits([]),
449
]);
450
});
451
452
test('dispose during open rejects subsequent calls', async () => {
453
db = new SessionDatabase(':memory:');
454
await db.close();
455
await assert.rejects(() => db!.createTurn('turn-1'), /disposed/);
456
});
457
});
458
459
// ---- Session metadata -----------------------------------------------
460
461
suite('session metadata', () => {
462
463
test('getMetadata returns undefined for missing key', async () => {
464
db = disposables.add(await SessionDatabase.open(':memory:'));
465
assert.strictEqual(await db.getMetadata('nonexistent'), undefined);
466
});
467
468
test('setMetadata and getMetadata round-trip', async () => {
469
db = disposables.add(await SessionDatabase.open(':memory:'));
470
await db.setMetadata('customTitle', 'My Session');
471
assert.strictEqual(await db.getMetadata('customTitle'), 'My Session');
472
});
473
474
test('setMetadata overwrites existing value', async () => {
475
db = disposables.add(await SessionDatabase.open(':memory:'));
476
await db.setMetadata('customTitle', 'First');
477
await db.setMetadata('customTitle', 'Second');
478
assert.strictEqual(await db.getMetadata('customTitle'), 'Second');
479
});
480
481
test('metadata persists across reopen', async () => {
482
const db1 = disposables.add(await TestableSessionDatabase.open(':memory:'));
483
await db1.setMetadata('customTitle', 'Persistent Title');
484
const rawDb = await db1.ejectDb();
485
486
db = disposables.add(await TestableSessionDatabase.fromDb(rawDb));
487
assert.strictEqual(await db.getMetadata('customTitle'), 'Persistent Title');
488
});
489
490
test('migration v2 creates session_metadata table', async () => {
491
db = disposables.add(await SessionDatabase.open(':memory:'));
492
const tables = await db.getAllTables();
493
assert.ok(tables.includes('session_metadata'));
494
});
495
});
496
497
// ---- vacuumInto -----------------------------------------------------
498
499
suite('vacuumInto', () => {
500
501
let tmpDir: string;
502
503
setup(async () => {
504
tmpDir = await fs.mkdtemp(join(tmpdir(), 'session-db-test-' + generateUuid()));
505
});
506
507
teardown(async () => {
508
await Promise.all([db?.close(), db2?.close()]);
509
db = db2 = undefined;
510
await fs.rm(tmpDir, { recursive: true, force: true });
511
});
512
513
test('produces a copy with the same data', async () => {
514
db = disposables.add(await SessionDatabase.open(':memory:'));
515
await db.createTurn('turn-1');
516
await db.setTurnEventId('turn-1', 'evt-1');
517
await db.setMetadata('key', 'value');
518
519
const targetPath = join(tmpDir, 'copy.db');
520
await db.vacuumInto(targetPath);
521
522
db2 = disposables.add(await SessionDatabase.open(targetPath));
523
assert.strictEqual(await db2.getTurnEventId('turn-1'), 'evt-1');
524
assert.strictEqual(await db2.getMetadata('key'), 'value');
525
});
526
});
527
});
528
529