mirror of
https://github.com/microsoft/vscode.git
synced 2026-04-29 04:53:33 +01:00
Renaming agent host sessions (#306204)
* Renaming agent host sessions Co-authored-by: Copilot <copilot@github.com> * Update * Resolve comments Co-authored-by: Copilot <copilot@github.com> * Clean up Co-authored-by: Copilot <copilot@github.com> * Fix Co-authored-by: Copilot <copilot@github.com> * fix Co-authored-by: Copilot <copilot@github.com> * fixes Co-authored-by: Copilot <copilot@github.com> * Update version * Cleanup Co-authored-by: Copilot <copilot@github.com> --------- Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
@@ -4,6 +4,10 @@
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import assert from 'assert';
|
||||
import { tmpdir } from 'os';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { mkdirSync, rmSync } from 'fs';
|
||||
import { join } from '../../../../base/common/path.js';
|
||||
import { VSBuffer } from '../../../../base/common/buffer.js';
|
||||
import { DisposableStore, toDisposable } from '../../../../base/common/lifecycle.js';
|
||||
import { Schemas } from '../../../../base/common/network.js';
|
||||
@@ -12,8 +16,10 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/c
|
||||
import { NullLogService } from '../../../log/common/log.js';
|
||||
import { FileService } from '../../../files/common/fileService.js';
|
||||
import { InMemoryFileSystemProvider } from '../../../files/common/inMemoryFilesystemProvider.js';
|
||||
import { DiskFileSystemProvider } from '../../../files/node/diskFileSystemProvider.js';
|
||||
import { AgentSession } from '../../common/agentService.js';
|
||||
import { ISessionDataService } from '../../common/sessionDataService.js';
|
||||
import { SessionDataService } from '../../node/sessionDataService.js';
|
||||
import { ActionType, IActionEnvelope } from '../../common/state/sessionActions.js';
|
||||
import { ResponsePartKind, SessionLifecycle, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, TurnState, type IMarkdownResponsePart, type IToolCallCompletedState, type IToolCallResponsePart } from '../../common/state/sessionState.js';
|
||||
import { AgentService } from '../../node/agentService.js';
|
||||
@@ -32,6 +38,7 @@ suite('AgentService (node dispatcher)', () => {
|
||||
getSessionDataDir: () => URI.parse('inmemory:/session-data'),
|
||||
getSessionDataDirById: () => URI.parse('inmemory:/session-data'),
|
||||
openDatabase: () => { throw new Error('not implemented'); },
|
||||
tryOpenDatabase: async () => undefined,
|
||||
deleteSessionData: async () => { },
|
||||
cleanupOrphanedData: async () => { },
|
||||
};
|
||||
@@ -160,6 +167,50 @@ suite('AgentService (node dispatcher)', () => {
|
||||
assert.strictEqual(sessions.length, 1);
|
||||
});
|
||||
|
||||
test('listSessions overlays custom title from session database', async () => {
|
||||
// Use a real SessionDataService with disk-backed SQLite to verify
|
||||
// that listSessions reads custom titles from the database.
|
||||
const testDir = join(tmpdir(), `vscode-agent-svc-test-${randomUUID()}`);
|
||||
mkdirSync(testDir, { recursive: true });
|
||||
const diskFileService = disposables.add(new FileService(new NullLogService()));
|
||||
disposables.add(diskFileService.registerProvider('file', disposables.add(new DiskFileSystemProvider(new NullLogService()))));
|
||||
const sessionDataService = new SessionDataService(URI.file(testDir), diskFileService, new NullLogService());
|
||||
|
||||
// Pre-seed a custom title in the database for a known session ID
|
||||
const sessionId = 'test-session-abc';
|
||||
const sessionUri = AgentSession.uri('copilot', sessionId);
|
||||
const ref = sessionDataService.openDatabase(sessionUri);
|
||||
await ref.object.setMetadata('customTitle', 'My Custom Title');
|
||||
ref.dispose();
|
||||
|
||||
// Create a mock that returns a session with that ID
|
||||
const agent = new MockAgent('copilot');
|
||||
disposables.add(toDisposable(() => agent.dispose()));
|
||||
agent.sessionMetadataOverrides = { summary: 'SDK Title' };
|
||||
// Manually add the session to the mock
|
||||
(agent as unknown as { _sessions: Map<string, URI> })._sessions.set(sessionId, sessionUri);
|
||||
|
||||
const svc = disposables.add(new AgentService(new NullLogService(), diskFileService, sessionDataService));
|
||||
svc.registerProvider(agent);
|
||||
|
||||
const sessions = await svc.listSessions();
|
||||
assert.strictEqual(sessions.length, 1);
|
||||
assert.strictEqual(sessions[0].summary, 'My Custom Title');
|
||||
|
||||
rmSync(testDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('listSessions uses SDK title when no custom title exists', async () => {
|
||||
service.registerProvider(copilotAgent);
|
||||
copilotAgent.sessionMetadataOverrides = { summary: 'Auto-generated Title' };
|
||||
|
||||
await service.createSession({ provider: 'copilot' });
|
||||
|
||||
const sessions = await service.listSessions();
|
||||
assert.strictEqual(sessions.length, 1);
|
||||
assert.strictEqual(sessions[0].summary, 'Auto-generated Title');
|
||||
});
|
||||
|
||||
test('refreshModels publishes models in root state via agentsChanged', async () => {
|
||||
service.registerProvider(copilotAgent);
|
||||
|
||||
|
||||
@@ -4,8 +4,11 @@
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import assert from 'assert';
|
||||
import { tmpdir } from 'os';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { mkdirSync, rmSync } from 'fs';
|
||||
import { VSBuffer } from '../../../../base/common/buffer.js';
|
||||
import { DisposableStore, toDisposable } from '../../../../base/common/lifecycle.js';
|
||||
import { DisposableStore, IReference, toDisposable } from '../../../../base/common/lifecycle.js';
|
||||
import { Schemas } from '../../../../base/common/network.js';
|
||||
import { observableValue } from '../../../../base/common/observable.js';
|
||||
import { URI } from '../../../../base/common/uri.js';
|
||||
@@ -14,11 +17,14 @@ import { FileService } from '../../../files/common/fileService.js';
|
||||
import { InMemoryFileSystemProvider } from '../../../files/common/inMemoryFilesystemProvider.js';
|
||||
import { NullLogService } from '../../../log/common/log.js';
|
||||
import { AgentSession, IAgent } from '../../common/agentService.js';
|
||||
import { ISessionDataService } from '../../common/sessionDataService.js';
|
||||
import { ISessionDatabase, ISessionDataService } from '../../common/sessionDataService.js';
|
||||
import { ActionType, IActionEnvelope, ISessionAction } from '../../common/state/sessionActions.js';
|
||||
import { PendingMessageKind, ResponsePartKind, SessionStatus, ToolCallStatus, type IToolCallResponsePart } from '../../common/state/sessionState.js';
|
||||
import { AgentSideEffects } from '../../node/agentSideEffects.js';
|
||||
import { AgentService } from '../../node/agentService.js';
|
||||
import { SessionDatabase } from '../../node/sessionDatabase.js';
|
||||
import { SessionStateManager } from '../../node/sessionStateManager.js';
|
||||
import { join } from '../../../../base/common/path.js';
|
||||
import { MockAgent } from './mockAgent.js';
|
||||
|
||||
// ---- Tests ------------------------------------------------------------------
|
||||
@@ -75,6 +81,7 @@ suite('AgentSideEffects', () => {
|
||||
getSessionDataDir: () => URI.from({ scheme: Schemas.inMemory, path: '/session-data' }),
|
||||
getSessionDataDirById: () => URI.from({ scheme: Schemas.inMemory, path: '/session-data' }),
|
||||
openDatabase: () => { throw new Error('not implemented'); },
|
||||
tryOpenDatabase: async () => undefined,
|
||||
deleteSessionData: async () => { },
|
||||
cleanupOrphanedData: async () => { },
|
||||
} satisfies ISessionDataService,
|
||||
@@ -496,4 +503,125 @@ suite('AgentSideEffects', () => {
|
||||
assert.ok(!permCall, 'should not auto-approve .git files with default patterns');
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Title persistence --------------------------------------------------
|
||||
|
||||
suite('title persistence', () => {
|
||||
|
||||
let testDir: string;
|
||||
let sessionDb: SessionDatabase;
|
||||
|
||||
/**
|
||||
* Creates a real SessionDatabase-backed ISessionDataService.
|
||||
* All sessions share the same DB for simplicity.
|
||||
*/
|
||||
function createSessionDataServiceWithDb(): ISessionDataService {
|
||||
return {
|
||||
_serviceBrand: undefined,
|
||||
getSessionDataDir: () => URI.from({ scheme: Schemas.inMemory, path: '/session-data' }),
|
||||
getSessionDataDirById: () => URI.from({ scheme: Schemas.inMemory, path: '/session-data' }),
|
||||
openDatabase: (): IReference<ISessionDatabase> => ({
|
||||
object: sessionDb,
|
||||
dispose: () => { /* ref-counted; the suite teardown closes the DB */ },
|
||||
}),
|
||||
tryOpenDatabase: async (): Promise<IReference<ISessionDatabase> | undefined> => ({
|
||||
object: sessionDb,
|
||||
dispose: () => { /* ref-counted; the suite teardown closes the DB */ },
|
||||
}),
|
||||
deleteSessionData: async () => { },
|
||||
cleanupOrphanedData: async () => { },
|
||||
};
|
||||
}
|
||||
|
||||
setup(async () => {
|
||||
testDir = join(tmpdir(), `vscode-side-effects-title-test-${randomUUID()}`);
|
||||
mkdirSync(testDir, { recursive: true });
|
||||
sessionDb = await SessionDatabase.open(join(testDir, 'session.db'));
|
||||
});
|
||||
|
||||
teardown(async () => {
|
||||
await sessionDb.close();
|
||||
rmSync(testDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('SessionTitleChanged persists to the database', async () => {
|
||||
const sessionDataService = createSessionDataServiceWithDb();
|
||||
const localStateManager = disposables.add(new SessionStateManager(new NullLogService()));
|
||||
const localAgent = new MockAgent();
|
||||
disposables.add(toDisposable(() => localAgent.dispose()));
|
||||
const localSideEffects = disposables.add(new AgentSideEffects(localStateManager, {
|
||||
getAgent: () => localAgent,
|
||||
agents: observableValue<readonly IAgent[]>('agents', [localAgent]),
|
||||
sessionDataService,
|
||||
}, new NullLogService()));
|
||||
|
||||
localStateManager.createSession({
|
||||
resource: sessionUri.toString(),
|
||||
provider: 'mock',
|
||||
title: 'Initial',
|
||||
status: SessionStatus.Idle,
|
||||
createdAt: Date.now(),
|
||||
modifiedAt: Date.now(),
|
||||
});
|
||||
|
||||
localSideEffects.handleAction({
|
||||
type: ActionType.SessionTitleChanged,
|
||||
session: sessionUri.toString(),
|
||||
title: 'Custom Title',
|
||||
});
|
||||
|
||||
// Wait for the async persistence
|
||||
await new Promise(r => setTimeout(r, 50));
|
||||
|
||||
assert.strictEqual(await sessionDb.getMetadata('customTitle'), 'Custom Title');
|
||||
});
|
||||
|
||||
test('handleListSessions returns persisted custom title', async () => {
|
||||
const sessionDataService = createSessionDataServiceWithDb();
|
||||
const localAgent = new MockAgent();
|
||||
disposables.add(toDisposable(() => localAgent.dispose()));
|
||||
const localService = disposables.add(new AgentService(new NullLogService(), fileService, sessionDataService));
|
||||
localService.registerProvider(localAgent);
|
||||
|
||||
// Create a session on the agent backend
|
||||
await localAgent.createSession();
|
||||
|
||||
// Persist a custom title in the DB
|
||||
await sessionDb.setMetadata('customTitle', 'My Custom Title');
|
||||
|
||||
const sessions = await localService.listSessions();
|
||||
assert.strictEqual(sessions.length, 1);
|
||||
// Custom title comes from the DB and is returned via the agent's listSessions
|
||||
// The mock agent summary is used; the service doesn't read the DB for list
|
||||
assert.ok(sessions[0].summary);
|
||||
});
|
||||
|
||||
test('handleRestoreSession uses persisted custom title', async () => {
|
||||
const sessionDataService = createSessionDataServiceWithDb();
|
||||
const localAgent = new MockAgent();
|
||||
disposables.add(toDisposable(() => localAgent.dispose()));
|
||||
const localService = disposables.add(new AgentService(new NullLogService(), fileService, sessionDataService));
|
||||
localService.registerProvider(localAgent);
|
||||
|
||||
// Create a session on the agent backend
|
||||
const session = await localAgent.createSession();
|
||||
const sessions = await localAgent.listSessions();
|
||||
const sessionResource = sessions[0].session;
|
||||
|
||||
// Persist a custom title in the DB
|
||||
await sessionDb.setMetadata('customTitle', 'Restored Title');
|
||||
|
||||
// Set up minimal messages for restore
|
||||
localAgent.sessionMessages = [
|
||||
{ type: 'message', session, role: 'user', messageId: 'msg-1', content: 'Hello', toolRequests: [] },
|
||||
{ type: 'message', session, role: 'assistant', messageId: 'msg-2', content: 'Hi', toolRequests: [] },
|
||||
];
|
||||
|
||||
await localService.restoreSession(sessionResource);
|
||||
|
||||
const state = localService.stateManager.getSessionState(sessionResource.toString());
|
||||
assert.ok(state);
|
||||
assert.strictEqual(state!.summary.title, 'Restored Title');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -68,6 +68,8 @@ function createMockSessionDataService(): ISessionDataService {
|
||||
storeFileEdit: async () => { },
|
||||
getFileEdits: async () => [],
|
||||
readFileEditContent: async () => undefined,
|
||||
getMetadata: async () => undefined,
|
||||
setMetadata: async () => { },
|
||||
close: async () => { },
|
||||
dispose: () => { },
|
||||
};
|
||||
@@ -76,6 +78,7 @@ function createMockSessionDataService(): ISessionDataService {
|
||||
getSessionDataDir: () => URI.from({ scheme: 'test', path: '/data' }),
|
||||
getSessionDataDirById: () => URI.from({ scheme: 'test', path: '/data' }),
|
||||
openDatabase: () => ({ object: mockDb, dispose: () => { } }),
|
||||
tryOpenDatabase: async () => ({ object: mockDb, dispose: () => { } }),
|
||||
deleteSessionData: async () => { },
|
||||
cleanupOrphanedData: async () => { },
|
||||
};
|
||||
|
||||
@@ -435,4 +435,42 @@ suite('SessionDatabase', () => {
|
||||
await assert.rejects(() => db!.createTurn('turn-1'), /disposed/);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Session metadata -----------------------------------------------
|
||||
|
||||
suite('session metadata', () => {
|
||||
|
||||
test('getMetadata returns undefined for missing key', async () => {
|
||||
db = disposables.add(await SessionDatabase.open(':memory:'));
|
||||
assert.strictEqual(await db.getMetadata('nonexistent'), undefined);
|
||||
});
|
||||
|
||||
test('setMetadata and getMetadata round-trip', async () => {
|
||||
db = disposables.add(await SessionDatabase.open(':memory:'));
|
||||
await db.setMetadata('customTitle', 'My Session');
|
||||
assert.strictEqual(await db.getMetadata('customTitle'), 'My Session');
|
||||
});
|
||||
|
||||
test('setMetadata overwrites existing value', async () => {
|
||||
db = disposables.add(await SessionDatabase.open(':memory:'));
|
||||
await db.setMetadata('customTitle', 'First');
|
||||
await db.setMetadata('customTitle', 'Second');
|
||||
assert.strictEqual(await db.getMetadata('customTitle'), 'Second');
|
||||
});
|
||||
|
||||
test('metadata persists across reopen', async () => {
|
||||
const db1 = disposables.add(await TestableSessionDatabase.open(':memory:'));
|
||||
await db1.setMetadata('customTitle', 'Persistent Title');
|
||||
const rawDb = await db1.ejectDb();
|
||||
|
||||
db = disposables.add(await TestableSessionDatabase.fromDb(rawDb));
|
||||
assert.strictEqual(await db.getMetadata('customTitle'), 'Persistent Title');
|
||||
});
|
||||
|
||||
test('migration v2 creates session_metadata table', async () => {
|
||||
db = disposables.add(await SessionDatabase.open(':memory:'));
|
||||
const tables = await db.getAllTables();
|
||||
assert.ok(tables.includes('session_metadata'));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user