Path: blob/main/src/vs/platform/agentHost/test/node/sessionDatabase.test.ts
13399 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import assert from 'assert';6import { tmpdir } from 'os';7import * as fs from 'fs/promises';8import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js';9import { DisposableStore } from '../../../../base/common/lifecycle.js';10import { SessionDatabase, runMigrations, sessionDatabaseMigrations, type ISessionDatabaseMigration } from '../../node/sessionDatabase.js';11import { FileEditKind } from '../../common/state/sessionState.js';12import type { Database } from '@vscode/sqlite3';13import { generateUuid } from '../../../../base/common/uuid.js';14import { join } from '../../../../base/common/path.js';1516suite('SessionDatabase', () => {1718const disposables = new DisposableStore();19let db: SessionDatabase | undefined;20let db2: SessionDatabase | undefined;2122teardown(async () => {23disposables.clear();24await Promise.all([db?.close(), db2?.close()]);25});26ensureNoDisposablesAreLeakedInTestSuite();2728/**29* Extends SessionDatabase to allow ejecting/injecting the raw sqlite330* Database instance, enabling reopen tests with :memory: databases.31*/32class TestableSessionDatabase extends SessionDatabase {33static override async open(path: string, migrations: readonly ISessionDatabaseMigration[] = sessionDatabaseMigrations): Promise<TestableSessionDatabase> {34const inst = new TestableSessionDatabase(path, migrations);35await inst._ensureDb();36return inst;37}3839/** Extract the raw db connection; this instance becomes inert. */40async ejectDb(): Promise<Database> {41const rawDb = await this._ensureDb();42this._dbPromise = undefined;43this._closed = true;44return rawDb;45}4647/** Create a TestableSessionDatabase wrapping an existing raw db. */48static async fromDb(49rawDb: Database,50migrations: readonly ISessionDatabaseMigration[] = sessionDatabaseMigrations,51): Promise<TestableSessionDatabase> {52await runMigrations(rawDb, migrations);53const inst = new TestableSessionDatabase(':memory:', migrations);54inst._dbPromise = Promise.resolve(rawDb);55return inst;56}57}5859// ---- Migration system -----------------------------------------------6061suite('migrations', () => {6263test('applies all migrations on a fresh database', async () => {64const migrations: ISessionDatabaseMigration[] = [65{ version: 1, sql: 'CREATE TABLE t1 (id INTEGER PRIMARY KEY)' },66{ version: 2, sql: 'CREATE TABLE t2 (id INTEGER PRIMARY KEY)' },67];6869db = disposables.add(await SessionDatabase.open(':memory:', migrations));7071const tables = (await db.getAllTables()).sort();72assert.deepStrictEqual(tables, ['t1', 't2']);73});7475test('reopening with same migrations is a no-op', async () => {76const migrations: ISessionDatabaseMigration[] = [77{ version: 1, sql: 'CREATE TABLE t1 (id INTEGER PRIMARY KEY)' },78];7980const db1 = await TestableSessionDatabase.open(':memory:', migrations);81const rawDb = await db1.ejectDb();8283// Reopen — should not throw (table already exists, migration skipped)84db2 = disposables.add(await TestableSessionDatabase.fromDb(rawDb, migrations));85assert.deepStrictEqual(await db2.getAllTables(), ['t1']);86});8788test('only applies new migrations on reopen', async () => {89const v1: ISessionDatabaseMigration[] = [90{ version: 1, sql: 'CREATE TABLE t1 (id INTEGER PRIMARY KEY)' },91];92const db1 = await TestableSessionDatabase.open(':memory:', v1);93const rawDb = await db1.ejectDb();9495const v2: ISessionDatabaseMigration[] = [96...v1,97{ version: 2, sql: 'CREATE TABLE t2 (id INTEGER PRIMARY KEY)' },98];99db2 = disposables.add(await TestableSessionDatabase.fromDb(rawDb, v2));100101const tables = (await db2.getAllTables()).sort();102assert.deepStrictEqual(tables, ['t1', 't2']);103});104105test('rolls back on migration failure', async () => {106const migrations: ISessionDatabaseMigration[] = [107{ version: 1, sql: 'CREATE TABLE t1 (id INTEGER PRIMARY KEY)' },108{ version: 2, sql: 'THIS IS INVALID SQL' },109];110111await assert.rejects(() => SessionDatabase.open(':memory:', migrations));112113// A fresh :memory: open with valid migrations succeeds114db = disposables.add(await SessionDatabase.open(':memory:', [115{ version: 1, sql: 'CREATE TABLE t1 (id INTEGER PRIMARY KEY)' },116]));117assert.deepStrictEqual(await db.getAllTables(), ['t1']);118});119});120121// ---- File edits -----------------------------------------------------122123suite('file edits', () => {124125test('store and retrieve a file edit', async () => {126db = disposables.add(await SessionDatabase.open(':memory:'));127128await db.createTurn('turn-1');129await db.storeFileEdit({130turnId: 'turn-1',131toolCallId: 'tc-1',132kind: FileEditKind.Edit,133filePath: '/workspace/file.ts',134beforeContent: new TextEncoder().encode('before'),135afterContent: new TextEncoder().encode('after'),136addedLines: 5,137removedLines: 2,138});139140const edits = await db.getFileEdits(['tc-1']);141assert.deepStrictEqual(edits, [{142turnId: 'turn-1',143toolCallId: 'tc-1',144kind: FileEditKind.Edit,145filePath: '/workspace/file.ts',146originalPath: undefined,147addedLines: 5,148removedLines: 2,149}]);150});151152test('retrieve multiple edits for a single tool call', async () => {153db = disposables.add(await SessionDatabase.open(':memory:'));154155await db.createTurn('turn-1');156await db.storeFileEdit({157turnId: 'turn-1',158toolCallId: 'tc-1',159kind: FileEditKind.Edit,160filePath: '/workspace/a.ts',161beforeContent: new TextEncoder().encode('a-before'),162afterContent: new TextEncoder().encode('a-after'),163addedLines: undefined,164removedLines: undefined,165});166await db.storeFileEdit({167turnId: 'turn-1',168toolCallId: 'tc-1',169kind: FileEditKind.Edit,170filePath: '/workspace/b.ts',171beforeContent: new TextEncoder().encode('b-before'),172afterContent: new TextEncoder().encode('b-after'),173addedLines: 1,174removedLines: 0,175});176177const edits = await db.getFileEdits(['tc-1']);178assert.strictEqual(edits.length, 2);179assert.strictEqual(edits[0].filePath, '/workspace/a.ts');180assert.strictEqual(edits[1].filePath, '/workspace/b.ts');181});182183test('retrieve edits across multiple tool calls', async () => {184db = disposables.add(await SessionDatabase.open(':memory:'));185186await db.createTurn('turn-1');187await db.storeFileEdit({188turnId: 'turn-1',189toolCallId: 'tc-1',190kind: FileEditKind.Edit,191filePath: '/workspace/a.ts',192beforeContent: new Uint8Array(0),193afterContent: new TextEncoder().encode('hello'),194addedLines: undefined,195removedLines: undefined,196});197await db.storeFileEdit({198turnId: 'turn-1',199toolCallId: 'tc-2',200kind: FileEditKind.Edit,201filePath: '/workspace/b.ts',202beforeContent: new Uint8Array(0),203afterContent: new TextEncoder().encode('world'),204addedLines: undefined,205removedLines: undefined,206});207208const edits = await db.getFileEdits(['tc-1', 'tc-2']);209assert.strictEqual(edits.length, 2);210211// Only tc-2212const edits2 = await db.getFileEdits(['tc-2']);213assert.strictEqual(edits2.length, 1);214assert.strictEqual(edits2[0].toolCallId, 'tc-2');215});216217test('returns empty array for unknown tool call IDs', async () => {218db = disposables.add(await SessionDatabase.open(':memory:'));219const edits = await db.getFileEdits(['nonexistent']);220assert.deepStrictEqual(edits, []);221});222223test.skip('returns empty array when given empty array' /* Flaky https://github.com/microsoft/vscode/issues/306057 */, async () => {224db = disposables.add(await SessionDatabase.open(':memory:'));225const edits = await db.getFileEdits([]);226assert.deepStrictEqual(edits, []);227});228229test('replace on conflict (same toolCallId + filePath)', async () => {230db = disposables.add(await SessionDatabase.open(':memory:'));231232await db.createTurn('turn-1');233await db.storeFileEdit({234turnId: 'turn-1',235toolCallId: 'tc-1',236kind: FileEditKind.Edit,237filePath: '/workspace/file.ts',238beforeContent: new TextEncoder().encode('v1'),239afterContent: new TextEncoder().encode('v1-after'),240addedLines: 1,241removedLines: 0,242});243await db.storeFileEdit({244turnId: 'turn-1',245toolCallId: 'tc-1',246kind: FileEditKind.Edit,247filePath: '/workspace/file.ts',248beforeContent: new TextEncoder().encode('v2'),249afterContent: new TextEncoder().encode('v2-after'),250addedLines: 3,251removedLines: 1,252});253254const edits = await db.getFileEdits(['tc-1']);255assert.strictEqual(edits.length, 1);256assert.strictEqual(edits[0].addedLines, 3);257258const content = await db.readFileEditContent('tc-1', '/workspace/file.ts');259assert.ok(content);260assert.deepStrictEqual(new TextDecoder().decode(content.beforeContent), 'v2');261});262263test('readFileEditContent returns content on demand', async () => {264db = disposables.add(await SessionDatabase.open(':memory:'));265266await db.createTurn('turn-1');267await db.storeFileEdit({268turnId: 'turn-1',269toolCallId: 'tc-1',270kind: FileEditKind.Edit,271filePath: '/workspace/file.ts',272beforeContent: new TextEncoder().encode('before'),273afterContent: new TextEncoder().encode('after'),274addedLines: undefined,275removedLines: undefined,276});277278const content = await db.readFileEditContent('tc-1', '/workspace/file.ts');279assert.ok(content);280assert.deepStrictEqual(content.beforeContent, new TextEncoder().encode('before'));281assert.deepStrictEqual(content.afterContent, new TextEncoder().encode('after'));282});283284test('readFileEditContent returns undefined for missing edit', async () => {285db = disposables.add(await SessionDatabase.open(':memory:'));286const content = await db.readFileEditContent('tc-missing', '/no/such/file');287assert.strictEqual(content, undefined);288});289290test('persists binary content correctly', async () => {291db = disposables.add(await SessionDatabase.open(':memory:'));292const binary = new Uint8Array([0, 1, 2, 255, 128, 64]);293294await db.createTurn('turn-1');295await db.storeFileEdit({296turnId: 'turn-1',297toolCallId: 'tc-bin',298kind: FileEditKind.Edit,299filePath: '/workspace/image.png',300beforeContent: new Uint8Array(0),301afterContent: binary,302addedLines: undefined,303removedLines: undefined,304});305306const content = await db.readFileEditContent('tc-bin', '/workspace/image.png');307assert.ok(content);308assert.deepStrictEqual(content.afterContent, binary);309});310311test('auto-creates turn if it does not exist', async () => {312db = disposables.add(await SessionDatabase.open(':memory:'));313314// storeFileEdit should succeed even without a prior createTurn call315await db.storeFileEdit({316turnId: 'auto-turn',317toolCallId: 'tc-1',318kind: FileEditKind.Edit,319filePath: '/x',320beforeContent: new Uint8Array(0),321afterContent: new Uint8Array(0),322addedLines: undefined,323removedLines: undefined,324});325326const edits = await db.getFileEdits(['tc-1']);327assert.strictEqual(edits.length, 1);328assert.strictEqual(edits[0].turnId, 'auto-turn');329});330});331332// ---- Turns ----------------------------------------------------------333334suite('turns', () => {335336test('createTurn is idempotent', async () => {337db = disposables.add(await SessionDatabase.open(':memory:'));338await db.createTurn('turn-1');339await db.createTurn('turn-1'); // should not throw340});341342test('deleteTurn cascades to file edits', async () => {343db = disposables.add(await SessionDatabase.open(':memory:'));344345await db.createTurn('turn-1');346await db.storeFileEdit({347turnId: 'turn-1',348toolCallId: 'tc-1',349kind: FileEditKind.Edit,350filePath: '/workspace/a.ts',351beforeContent: new TextEncoder().encode('before'),352afterContent: new TextEncoder().encode('after'),353addedLines: undefined,354removedLines: undefined,355});356357// Edits exist358assert.strictEqual((await db.getFileEdits(['tc-1'])).length, 1);359360// Delete the turn — edits should be gone361await db.deleteTurn('turn-1');362assert.deepStrictEqual(await db.getFileEdits(['tc-1']), []);363});364365test('deleteTurn only removes its own edits', async () => {366db = disposables.add(await SessionDatabase.open(':memory:'));367368await db.createTurn('turn-1');369await db.createTurn('turn-2');370await db.storeFileEdit({371turnId: 'turn-1',372toolCallId: 'tc-1',373kind: FileEditKind.Edit,374filePath: '/workspace/a.ts',375beforeContent: new Uint8Array(0),376afterContent: new TextEncoder().encode('a'),377addedLines: undefined,378removedLines: undefined,379});380await db.storeFileEdit({381turnId: 'turn-2',382toolCallId: 'tc-2',383kind: FileEditKind.Edit,384filePath: '/workspace/b.ts',385beforeContent: new Uint8Array(0),386afterContent: new TextEncoder().encode('b'),387addedLines: undefined,388removedLines: undefined,389});390391await db.deleteTurn('turn-1');392393assert.deepStrictEqual(await db.getFileEdits(['tc-1']), []);394assert.strictEqual((await db.getFileEdits(['tc-2'])).length, 1);395});396397test('deleteTurn is a no-op for unknown turn', async () => {398db = disposables.add(await SessionDatabase.open(':memory:'));399await db.deleteTurn('nonexistent'); // should not throw400});401});402403// ---- Dispose --------------------------------------------------------404405suite('dispose', () => {406407test('methods throw after dispose', async () => {408db = await SessionDatabase.open(':memory:');409db.close();410411await assert.rejects(412() => db!.createTurn('turn-1'),413/disposed/,414);415});416417test('double dispose is safe', async () => {418db = await SessionDatabase.open(':memory:');419await db.close();420await db.close(); // should not throw421});422});423424// ---- Lazy open ------------------------------------------------------425426suite('lazy open', () => {427428test('constructor does not open the database', () => {429db = new SessionDatabase(':memory:');430disposables.add(db);431// No error — the database is not opened until first use432});433434test('first async call opens and migrates the database', async () => {435db = disposables.add(new SessionDatabase(':memory:'));436await db.createTurn('turn-1');437const edits = await db.getFileEdits(['nonexistent']);438assert.deepStrictEqual(edits, []);439});440441test('multiple concurrent calls share the same open promise', async () => {442db = disposables.add(new SessionDatabase(':memory:'));443// Fire multiple calls concurrently — all should succeed444await Promise.all([445db.createTurn('turn-1'),446db.createTurn('turn-2'),447db.getFileEdits([]),448]);449});450451test('dispose during open rejects subsequent calls', async () => {452db = new SessionDatabase(':memory:');453await db.close();454await assert.rejects(() => db!.createTurn('turn-1'), /disposed/);455});456});457458// ---- Session metadata -----------------------------------------------459460suite('session metadata', () => {461462test('getMetadata returns undefined for missing key', async () => {463db = disposables.add(await SessionDatabase.open(':memory:'));464assert.strictEqual(await db.getMetadata('nonexistent'), undefined);465});466467test('setMetadata and getMetadata round-trip', async () => {468db = disposables.add(await SessionDatabase.open(':memory:'));469await db.setMetadata('customTitle', 'My Session');470assert.strictEqual(await db.getMetadata('customTitle'), 'My Session');471});472473test('setMetadata overwrites existing value', async () => {474db = disposables.add(await SessionDatabase.open(':memory:'));475await db.setMetadata('customTitle', 'First');476await db.setMetadata('customTitle', 'Second');477assert.strictEqual(await db.getMetadata('customTitle'), 'Second');478});479480test('metadata persists across reopen', async () => {481const db1 = disposables.add(await TestableSessionDatabase.open(':memory:'));482await db1.setMetadata('customTitle', 'Persistent Title');483const rawDb = await db1.ejectDb();484485db = disposables.add(await TestableSessionDatabase.fromDb(rawDb));486assert.strictEqual(await db.getMetadata('customTitle'), 'Persistent Title');487});488489test('migration v2 creates session_metadata table', async () => {490db = disposables.add(await SessionDatabase.open(':memory:'));491const tables = await db.getAllTables();492assert.ok(tables.includes('session_metadata'));493});494});495496// ---- vacuumInto -----------------------------------------------------497498suite('vacuumInto', () => {499500let tmpDir: string;501502setup(async () => {503tmpDir = await fs.mkdtemp(join(tmpdir(), 'session-db-test-' + generateUuid()));504});505506teardown(async () => {507await Promise.all([db?.close(), db2?.close()]);508db = db2 = undefined;509await fs.rm(tmpDir, { recursive: true, force: true });510});511512test('produces a copy with the same data', async () => {513db = disposables.add(await SessionDatabase.open(':memory:'));514await db.createTurn('turn-1');515await db.setTurnEventId('turn-1', 'evt-1');516await db.setMetadata('key', 'value');517518const targetPath = join(tmpDir, 'copy.db');519await db.vacuumInto(targetPath);520521db2 = disposables.add(await SessionDatabase.open(targetPath));522assert.strictEqual(await db2.getTurnEventId('turn-1'), 'evt-1');523assert.strictEqual(await db2.getMetadata('key'), 'value');524});525});526});527528529