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:
Rob Lourens
2026-03-30 22:46:14 -07:00
committed by GitHub
parent ff96816525
commit 513b43f0b7
24 changed files with 569 additions and 32 deletions

View File

@@ -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);

View File

@@ -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');
});
});
});

View File

@@ -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 () => { },
};

View File

@@ -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'));
});
});
});