From 513b43f0b7b4fb09d5cebf9d55ce99be45a4b0ef Mon Sep 17 00:00:00 2001 From: Rob Lourens Date: Mon, 30 Mar 2026 22:46:14 -0700 Subject: [PATCH] Renaming agent host sessions (#306204) * Renaming agent host sessions Co-authored-by: Copilot * Update * Resolve comments Co-authored-by: Copilot * Clean up Co-authored-by: Copilot * Fix Co-authored-by: Copilot * fix Co-authored-by: Copilot * fixes Co-authored-by: Copilot * Update version * Cleanup Co-authored-by: Copilot --------- Co-authored-by: Copilot --- .../platform/agentHost/common/agentService.ts | 3 + .../agentHost/common/sessionDataService.ts | 21 +++ .../common/state/protocol/.ahp-version | 2 +- .../state/protocol/action-origin.generated.ts | 4 +- .../common/state/protocol/actions.ts | 4 +- .../common/state/sessionClientState.ts | 7 +- .../electron-browser/agentHostService.ts | 4 + .../platform/agentHost/node/agentService.ts | 49 +++++- .../agentHost/node/agentSideEffects.ts | 13 ++ .../agentHost/node/sessionDataService.ts | 16 +- .../agentHost/node/sessionDatabase.ts | 20 +++ .../agentHost/test/node/agentService.test.ts | 51 ++++++ .../test/node/agentSideEffects.test.ts | 132 +++++++++++++++- .../test/node/copilotAgentSession.test.ts | 3 + .../test/node/sessionDatabase.test.ts | 38 +++++ .../browser/remoteAgentHost.contribution.ts | 2 +- .../remoteAgentHostSessionsProvider.ts | 29 +++- .../remoteAgentHostSessionsProvider.test.ts | 148 +++++++++++++++++- .../sessions/browser/sessions.contribution.ts | 2 - .../browser/views/sessionsViewActions.ts | 41 ++++- .../agentHost/agentHostChatContribution.ts | 2 +- .../agentHost/agentHostSessionHandler.ts | 2 +- .../agentHost/loggingAgentConnection.ts | 4 + .../agentHostChatContribution.test.ts | 4 + 24 files changed, 569 insertions(+), 32 deletions(-) diff --git a/src/vs/platform/agentHost/common/agentService.ts b/src/vs/platform/agentHost/common/agentService.ts index f044baa13e1..baf51f3810f 100644 --- a/src/vs/platform/agentHost/common/agentService.ts +++ b/src/vs/platform/agentHost/common/agentService.ts @@ -481,6 +481,9 @@ export interface IAgentService { export interface IAgentConnection extends IAgentService { /** Unique identifier for this client connection, used as the origin in action envelopes. */ readonly clientId: string; + + /** Allocate the next client sequence number for action dispatch on this connection. */ + nextClientSeq(): number; } export const IAgentHostService = createDecorator('agentHostService'); diff --git a/src/vs/platform/agentHost/common/sessionDataService.ts b/src/vs/platform/agentHost/common/sessionDataService.ts index 61bc8ba757b..4a1b2463ee3 100644 --- a/src/vs/platform/agentHost/common/sessionDataService.ts +++ b/src/vs/platform/agentHost/common/sessionDataService.ts @@ -82,6 +82,19 @@ export interface ISessionDatabase extends IDisposable { */ readFileEditContent(toolCallId: string, filePath: string): Promise; + // ---- Session metadata ------------------------------------------------ + + /** + * Read a metadata value by key. + * Returns `undefined` if no value has been stored for the key. + */ + getMetadata(key: string): Promise; + + /** + * Store a metadata key-value pair. Overwrites any existing value for the key. + */ + setMetadata(key: string, value: string): Promise; + /** * Close the database connection. After calling this method, the object is * considered disposed and all other methods will reject with an error. @@ -126,6 +139,14 @@ export interface ISessionDataService { */ openDatabase(session: URI): IReference; + /** + * Opens an existing per-session database **only if the database file + * already exists on disk**. Returns `undefined` when no database has + * been created yet, avoiding the side effect of materializing empty + * database files during read-only operations like listing sessions. + */ + tryOpenDatabase(session: URI): Promise | undefined>; + /** * Recursively deletes the data directory for a session, if it exists. */ diff --git a/src/vs/platform/agentHost/common/state/protocol/.ahp-version b/src/vs/platform/agentHost/common/state/protocol/.ahp-version index eda0ecd4b8b..ddbe3aef6b3 100644 --- a/src/vs/platform/agentHost/common/state/protocol/.ahp-version +++ b/src/vs/platform/agentHost/common/state/protocol/.ahp-version @@ -1 +1 @@ -95cbb57 +2743bf6 diff --git a/src/vs/platform/agentHost/common/state/protocol/action-origin.generated.ts b/src/vs/platform/agentHost/common/state/protocol/action-origin.generated.ts index e06342c661b..f5b81d88e23 100644 --- a/src/vs/platform/agentHost/common/state/protocol/action-origin.generated.ts +++ b/src/vs/platform/agentHost/common/state/protocol/action-origin.generated.ts @@ -57,6 +57,7 @@ export type IClientSessionAction = | ISessionToolCallCompleteAction | ISessionToolCallResultConfirmedAction | ISessionTurnCancelledAction + | ISessionTitleChangedAction | ISessionModelChangedAction | ISessionActiveClientChangedAction | ISessionActiveClientToolsChangedAction @@ -77,7 +78,6 @@ export type IServerSessionAction = | ISessionToolCallReadyAction | ISessionTurnCompleteAction | ISessionErrorAction - | ISessionTitleChangedAction | ISessionUsageAction | ISessionReasoningAction | ISessionServerToolsChangedAction @@ -107,7 +107,7 @@ export const IS_CLIENT_DISPATCHABLE: { readonly [K in IStateAction['type']]: boo [ActionType.SessionTurnComplete]: false, [ActionType.SessionTurnCancelled]: true, [ActionType.SessionError]: false, - [ActionType.SessionTitleChanged]: false, + [ActionType.SessionTitleChanged]: true, [ActionType.SessionUsage]: false, [ActionType.SessionReasoning]: false, [ActionType.SessionModelChanged]: true, diff --git a/src/vs/platform/agentHost/common/state/protocol/actions.ts b/src/vs/platform/agentHost/common/state/protocol/actions.ts index 27f726af24a..9c454210617 100644 --- a/src/vs/platform/agentHost/common/state/protocol/actions.ts +++ b/src/vs/platform/agentHost/common/state/protocol/actions.ts @@ -401,9 +401,11 @@ export interface ISessionErrorAction { } /** - * Session title updated (typically auto-generated from conversation). + * Session title updated. Fired by the server when the title is auto-generated + * from conversation, or dispatched by a client to rename a session. * * @category Session Actions + * @clientDispatchable * @version 1 */ export interface ISessionTitleChangedAction { diff --git a/src/vs/platform/agentHost/common/state/sessionClientState.ts b/src/vs/platform/agentHost/common/state/sessionClientState.ts index f40695bcf05..722382508e4 100644 --- a/src/vs/platform/agentHost/common/state/sessionClientState.ts +++ b/src/vs/platform/agentHost/common/state/sessionClientState.ts @@ -51,7 +51,7 @@ export class SessionClientState extends Disposable { private readonly _clientId: string; private readonly _log: (msg: string) => void; - private _nextClientSeq = 1; + private readonly _seqAllocator: () => number; private _lastSeenServerSeq = 0; // Confirmed state — reflects only what the server has acknowledged @@ -74,10 +74,11 @@ export class SessionClientState extends Disposable { private readonly _onDidReceiveNotification = this._register(new Emitter()); readonly onDidReceiveNotification: Event = this._onDidReceiveNotification.event; - constructor(clientId: string, logService: ILogService) { + constructor(clientId: string, logService: ILogService, seqAllocator: () => number) { super(); this._clientId = clientId; this._log = msg => logService.warn(`[SessionClientState] ${msg}`); + this._seqAllocator = seqAllocator; } get clientId(): string { @@ -160,7 +161,7 @@ export class SessionClientState extends Disposable { * Only session actions can be write-ahead (root actions are server-only). */ applyOptimistic(action: ISessionAction): number { - const clientSeq = this._nextClientSeq++; + const clientSeq = this._seqAllocator(); this._pendingActions.push({ clientSeq, action }); this._applySessionToOptimistic(action); return clientSeq; diff --git a/src/vs/platform/agentHost/electron-browser/agentHostService.ts b/src/vs/platform/agentHost/electron-browser/agentHostService.ts index 3e887b8bcc8..2f885e0b51e 100644 --- a/src/vs/platform/agentHost/electron-browser/agentHostService.ts +++ b/src/vs/platform/agentHost/electron-browser/agentHostService.ts @@ -116,6 +116,10 @@ class AgentHostServiceClient extends Disposable implements IAgentHostService { dispatchAction(action: ISessionAction, clientId: string, clientSeq: number): void { this._proxy.dispatchAction(action, clientId, clientSeq); } + private _nextSeq = 1; + nextClientSeq(): number { + return this._nextSeq++; + } browseDirectory(uri: URI): Promise { return this._proxy.browseDirectory(uri); } diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index 4850837a841..a5c017323db 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -127,8 +127,30 @@ export class AgentService extends Disposable implements IAgentService { [...this._providers.values()].map(p => p.listSessions()) ); const flat = results.flat(); - this._logService.trace(`[AgentService] listSessions returned ${flat.length} sessions`); - return flat; + + // Overlay persisted custom titles from per-session databases. + const result = await Promise.all(flat.map(async s => { + try { + const ref = await this._sessionDataService.tryOpenDatabase(s.session); + if (!ref) { + return s; + } + try { + const customTitle = await ref.object.getMetadata('customTitle'); + if (customTitle) { + return { ...s, summary: customTitle }; + } + } finally { + ref.dispose(); + } + } catch { + // ignore — title overlay is best-effort + } + return s; + })); + + this._logService.trace(`[AgentService] listSessions returned ${result.length} sessions`); + return result; } /** @@ -269,10 +291,31 @@ export class AgentService extends Disposable implements IAgentService { } const turns = this._buildTurnsFromMessages(messages); + // Check for a persisted custom title in the session database + let title = meta.summary ?? 'Session'; + const ref = this._sessionDataService.tryOpenDatabase?.(session); + if (ref) { + try { + const db = await ref; + if (db) { + try { + const customTitle = await db.object.getMetadata('customTitle'); + if (customTitle) { + title = customTitle; + } + } finally { + db.dispose(); + } + } + } catch { + // Best-effort: fall back to agent-provided title + } + } + const summary: ISessionSummary = { resource: sessionStr, provider: agent.id, - title: meta.summary ?? 'Session', + title, status: SessionStatus.Idle, createdAt: meta.startTime, modifiedAt: meta.modifiedTime, diff --git a/src/vs/platform/agentHost/node/agentSideEffects.ts b/src/vs/platform/agentHost/node/agentSideEffects.ts index 843e9ca32db..ede6b69acd9 100644 --- a/src/vs/platform/agentHost/node/agentSideEffects.ts +++ b/src/vs/platform/agentHost/node/agentSideEffects.ts @@ -242,6 +242,10 @@ export class AgentSideEffects extends Disposable { }); break; } + case ActionType.SessionTitleChanged: { + this._persistTitle(action.session, action.title); + break; + } case ActionType.SessionPendingMessageSet: case ActionType.SessionPendingMessageRemoved: case ActionType.SessionQueuedMessagesReordered: { @@ -251,6 +255,15 @@ export class AgentSideEffects extends Disposable { } } + private _persistTitle(session: ProtocolURI, title: string): void { + const ref = this._options.sessionDataService.openDatabase(URI.parse(session)); + ref.object.setMetadata('customTitle', title).catch(err => { + this._logService.warn('[AgentSideEffects] Failed to persist session title', err); + }).finally(() => { + ref.dispose(); + }); + } + /** * Pushes the current pending message state from the session to the agent. * The server controls queued message consumption; only steering messages diff --git a/src/vs/platform/agentHost/node/sessionDataService.ts b/src/vs/platform/agentHost/node/sessionDataService.ts index 007be7ee4f9..73f149f95cf 100644 --- a/src/vs/platform/agentHost/node/sessionDataService.ts +++ b/src/vs/platform/agentHost/node/sessionDataService.ts @@ -61,9 +61,21 @@ export class SessionDataService implements ISessionDataService { return URI.joinPath(this._basePath, sanitized); } + private _sanitizedSessionKey(session: URI): string { + return AgentSession.id(session).replace(/[^a-zA-Z0-9_.-]/g, '-'); + } + openDatabase(session: URI): IReference { - const sanitized = AgentSession.id(session).replace(/[^a-zA-Z0-9_.-]/g, '-'); - return this._databases.acquire(sanitized); + return this._databases.acquire(this._sanitizedSessionKey(session)); + } + + async tryOpenDatabase(session: URI): Promise | undefined> { + const key = this._sanitizedSessionKey(session); + const dbPath = URI.joinPath(this._basePath, key, 'session.db'); + if (!await this._fileService.exists(dbPath)) { + return undefined; + } + return this._databases.acquire(key); } async deleteSessionData(session: URI): Promise { diff --git a/src/vs/platform/agentHost/node/sessionDatabase.ts b/src/vs/platform/agentHost/node/sessionDatabase.ts index 8c9e51c106d..77b72f26a4a 100644 --- a/src/vs/platform/agentHost/node/sessionDatabase.ts +++ b/src/vs/platform/agentHost/node/sessionDatabase.ts @@ -44,6 +44,13 @@ export const sessionDatabaseMigrations: readonly ISessionDatabaseMigration[] = [ )`, ].join(';\n'), }, + { + version: 2, + sql: `CREATE TABLE IF NOT EXISTS session_metadata ( + key TEXT PRIMARY KEY NOT NULL, + value TEXT NOT NULL + )`, + }, ]; // ---- Promise wrappers around callback-based @vscode/sqlite3 API ----------- @@ -293,6 +300,19 @@ export class SessionDatabase implements ISessionDatabase { }); } + // ---- Session metadata ----------------------------------------------- + + async getMetadata(key: string): Promise { + const db = await this._ensureDb(); + const row = await dbGet(db, 'SELECT value FROM session_metadata WHERE key = ?', [key]); + return row?.value as string | undefined; + } + + async setMetadata(key: string, value: string): Promise { + const db = await this._ensureDb(); + await dbRun(db, 'INSERT OR REPLACE INTO session_metadata (key, value) VALUES (?, ?)', [key, value]); + } + async close() { await (this._closed ??= this._dbPromise?.then(db => db.close()).catch(() => { }) || true); } diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index 81c9038f86b..cec34d9738b 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -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 })._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); diff --git a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts index 3995b6caef6..97ba1881a40 100644 --- a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts +++ b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts @@ -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 => ({ + object: sessionDb, + dispose: () => { /* ref-counted; the suite teardown closes the DB */ }, + }), + tryOpenDatabase: async (): Promise | 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('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'); + }); + }); }); diff --git a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts index 8664bb984fa..030fce812f2 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts @@ -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 () => { }, }; diff --git a/src/vs/platform/agentHost/test/node/sessionDatabase.test.ts b/src/vs/platform/agentHost/test/node/sessionDatabase.test.ts index f71a9f2ee86..fbf66f35f7b 100644 --- a/src/vs/platform/agentHost/test/node/sessionDatabase.test.ts +++ b/src/vs/platform/agentHost/test/node/sessionDatabase.test.ts @@ -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')); + }); + }); }); diff --git a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHost.contribution.ts b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHost.contribution.ts index fa9c3658474..c167b8ebadd 100644 --- a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHost.contribution.ts +++ b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHost.contribution.ts @@ -46,7 +46,7 @@ class ConnectionState extends Disposable { loggedConnection: LoggingAgentConnection, ) { super(); - this.clientState = this.store.add(new SessionClientState(clientId, logService)); + this.clientState = this.store.add(new SessionClientState(clientId, logService, () => loggedConnection.nextClientSeq())); this.loggedConnection = this.store.add(loggedConnection); } } diff --git a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts index 5411f02f28b..226abe28731 100644 --- a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts @@ -19,7 +19,7 @@ import { agentHostUri } from '../../../../platform/agentHost/common/agentHostFil import { AGENT_HOST_SCHEME, agentHostAuthority, toAgentHostUri } from '../../../../platform/agentHost/common/agentHostUri.js'; import { AgentSession, type IAgentConnection, type IAgentSessionMetadata } from '../../../../platform/agentHost/common/agentService.js'; import { RemoteAgentHostConnectionStatus } from '../../../../platform/agentHost/common/remoteAgentHostService.js'; -import { isSessionAction } from '../../../../platform/agentHost/common/state/sessionActions.js'; +import { ActionType, isSessionAction } from '../../../../platform/agentHost/common/state/sessionActions.js'; import { IFileDialogService } from '../../../../platform/dialogs/common/dialogs.js'; import { INotificationService } from '../../../../platform/notification/common/notification.js'; import { ChatViewPaneTarget, IChatWidgetService } from '../../../../workbench/contrib/chat/browser/chat.js'; @@ -222,10 +222,13 @@ export class RemoteAgentHostSessionsProvider extends Disposable implements ISess } })); - this._connectionListeners.add(connection.onDidAction(e => { - if (e.action.type === 'session/turnComplete' && isSessionAction(e.action)) { + // Handle session state changes from the server + this._connectionListeners.add(this._connection.onDidAction(e => { + if (e.action.type === ActionType.SessionTurnComplete && isSessionAction(e.action)) { const cts = new CancellationTokenSource(); this._refreshSessions(cts.token).finally(() => cts.dispose()); + } else if (e.action.type === ActionType.SessionTitleChanged && isSessionAction(e.action)) { + this._handleTitleChanged(e.action.session, e.action.title); } })); @@ -385,8 +388,15 @@ export class RemoteAgentHostSessionsProvider extends Disposable implements ISess } } - async renameSession(_sessionId: string, _title: string): Promise { - // Agent host sessions don't support renaming + async renameSession(chatId: string, title: string): Promise { + const rawId = this._rawIdFromChatId(chatId); + const cached = rawId ? this._sessionCache.get(rawId) : undefined; + if (cached && rawId && this._connection) { + cached.title.set(title, undefined); + this._onDidChangeSessions.fire({ added: [], removed: [], changed: [cached] }); + const action = { type: ActionType.SessionTitleChanged as const, session: AgentSession.uri(cached.agentProvider, rawId).toString(), title }; + this._connection.dispatchAction(action, this._connection.clientId, this._connection.nextClientSeq()); + } } setRead(chatId: string, read: boolean): void { @@ -611,6 +621,15 @@ export class RemoteAgentHostSessionsProvider extends Disposable implements ISess } } + private _handleTitleChanged(session: string, title: string): void { + const rawId = AgentSession.id(session); + const cached = this._sessionCache.get(rawId); + if (cached) { + cached.title.set(title, undefined); + this._onDidChangeSessions.fire({ added: [], removed: [], changed: [cached] }); + } + } + private _rawIdFromChatId(chatId: string): string | undefined { const prefix = `${this.id}:`; const resourceStr = chatId.startsWith(prefix) ? chatId.substring(prefix.length) : chatId; diff --git a/src/vs/sessions/contrib/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts b/src/vs/sessions/contrib/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts index 9b2a39469e1..990a746d9f0 100644 --- a/src/vs/sessions/contrib/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts +++ b/src/vs/sessions/contrib/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts @@ -9,20 +9,21 @@ import { DisposableStore, toDisposable } from '../../../../../base/common/lifecy import { URI } from '../../../../../base/common/uri.js'; import { mock } from '../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; -import { IFileDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; -import { INotificationService } from '../../../../../platform/notification/common/notification.js'; -import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { AgentSession, type IAgentConnection, type IAgentSessionMetadata } from '../../../../../platform/agentHost/common/agentService.js'; -import type { IActionEnvelope, INotification } from '../../../../../platform/agentHost/common/state/sessionActions.js'; +import type { ISessionAction } from '../../../../../platform/agentHost/common/state/protocol/action-origin.generated.js'; import { NotificationType } from '../../../../../platform/agentHost/common/state/protocol/notifications.js'; +import { ActionType, type IActionEnvelope, type INotification } from '../../../../../platform/agentHost/common/state/sessionActions.js'; import { SessionStatus as ProtocolSessionStatus } from '../../../../../platform/agentHost/common/state/sessionState.js'; -import { IChatSessionsService } from '../../../../../workbench/contrib/chat/common/chatSessionsService.js'; -import { IChatService, type ChatSendResult } from '../../../../../workbench/contrib/chat/common/chatService/chatService.js'; +import { IFileDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; +import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { INotificationService } from '../../../../../platform/notification/common/notification.js'; import { IChatWidgetService } from '../../../../../workbench/contrib/chat/browser/chat.js'; +import { IChatService, type ChatSendResult } from '../../../../../workbench/contrib/chat/common/chatService/chatService.js'; +import { IChatSessionsService } from '../../../../../workbench/contrib/chat/common/chatSessionsService.js'; import { ILanguageModelsService } from '../../../../../workbench/contrib/chat/common/languageModels.js'; -import { SessionStatus } from '../../../sessions/common/sessionData.js'; import { ISessionChangeEvent } from '../../../sessions/browser/sessionsProvider.js'; import { CopilotCLISessionType } from '../../../sessions/browser/sessionTypes.js'; +import { SessionStatus } from '../../../sessions/common/sessionData.js'; import { RemoteAgentHostSessionsProvider, type IRemoteAgentHostSessionsProviderConfig } from '../../browser/remoteAgentHostSessionsProvider.js'; // ---- Mock connection -------------------------------------------------------- @@ -38,6 +39,13 @@ class MockAgentConnection extends mock() { override readonly clientId = 'test-client-1'; private readonly _sessions = new Map(); public disposedSessions: URI[] = []; + public dispatchedActions: { action: ISessionAction; clientId: string; clientSeq: number }[] = []; + + private _nextSeq = 0; + + override nextClientSeq(): number { + return this._nextSeq++; + } override async listSessions(): Promise { return [...this._sessions.values()]; @@ -49,6 +57,10 @@ class MockAgentConnection extends mock() { this._sessions.delete(rawId); } + override dispatchAction(action: ISessionAction, clientId: string, clientSeq: number): void { + this.dispatchedActions.push({ action, clientId, clientSeq }); + } + // Test helpers addSession(meta: IAgentSessionMetadata): void { this._sessions.set(AgentSession.id(meta.session), meta); @@ -351,6 +363,128 @@ suite('RemoteAgentHostSessionsProvider', () => { assert.strictEqual(target!.isRead.get(), false); }); + // ---- Rename ------- + + test('renameSession dispatches SessionTitleChanged action with correct session URI', async () => { + const provider = createProvider(disposables, connection); + fireSessionAdded(connection, 'rename-sess', { title: 'Old Title' }); + + const sessions = provider.getSessions(); + const target = sessions.find((s) => s.title.get() === 'Old Title'); + assert.ok(target, 'Session should exist'); + + await provider.renameSession(target!.id, 'New Title'); + + assert.strictEqual(connection.dispatchedActions.length, 1); + const dispatched = connection.dispatchedActions[0]; + assert.strictEqual(dispatched.action.type, ActionType.SessionTitleChanged); + assert.strictEqual((dispatched.action as { title: string }).title, 'New Title'); + // The session URI in the action must be the backend agent session URI + const actionSession = (dispatched.action as { session: string }).session; + assert.strictEqual(AgentSession.provider(actionSession), 'copilot'); + assert.strictEqual(AgentSession.id(actionSession), 'rename-sess'); + assert.strictEqual(dispatched.clientId, 'test-client-1'); + }); + + test('renameSession updates local title optimistically', async () => { + const provider = createProvider(disposables, connection); + fireSessionAdded(connection, 'rename-opt', { title: 'Before' }); + + const sessions = provider.getSessions(); + const target = sessions.find((s) => s.title.get() === 'Before'); + assert.ok(target); + + await provider.renameSession(target!.id, 'After'); + + assert.strictEqual(target!.title.get(), 'After'); + }); + + test('renameSession is no-op for unknown chatId', async () => { + const provider = createProvider(disposables, connection); + await provider.renameSession('nonexistent-id', 'Ignored'); + + assert.strictEqual(connection.dispatchedActions.length, 0); + }); + + test('renameSession increments clientSeq on successive calls', async () => { + connection.addSession(createSession('seq-sess', { summary: 'Seq Test' })); + const provider = createProvider(disposables, connection); + provider.getSessions(); + await new Promise(resolve => setTimeout(resolve, 50)); + + const sessions = provider.getSessions(); + const target = sessions.find((s) => s.title.get() === 'Seq Test'); + assert.ok(target); + + const chatId = target!.id; + await provider.renameSession(chatId, 'Title 1'); + await provider.renameSession(chatId, 'Title 2'); + + assert.strictEqual(connection.dispatchedActions.length, 2); + assert.strictEqual(connection.dispatchedActions[0].clientSeq, 0); + assert.strictEqual(connection.dispatchedActions[1].clientSeq, 1); + }); + + test('server-echoed SessionTitleChanged updates cached title', () => { + const provider = createProvider(disposables, connection); + fireSessionAdded(connection, 'echo-sess', { title: 'Original' }); + + const sessions = provider.getSessions(); + const target = sessions.find((s) => s.title.get() === 'Original'); + assert.ok(target); + + const changes: ISessionChangeEvent[] = []; + disposables.add(provider.onDidChangeSessions((e: ISessionChangeEvent) => changes.push(e))); + + // Simulate the server echoing a title change (from auto-generation or another client) + connection.fireAction({ + action: { + type: ActionType.SessionTitleChanged, + session: AgentSession.uri('copilot', 'echo-sess').toString(), + title: 'Server Title', + }, + serverSeq: 1, + origin: undefined, + } as IActionEnvelope); + + assert.strictEqual(target!.title.get(), 'Server Title'); + assert.strictEqual(changes.length, 1); + assert.strictEqual(changes[0].changed.length, 1); + }); + + test('renamed title survives session refresh from listSessions', async () => { + // Simulate server persisting the renamed title: after rename, listSessions + // returns the updated summary + connection.addSession(createSession('persist-sess', { summary: 'Original Title' })); + const provider = createProvider(disposables, connection); + provider.getSessions(); + await new Promise(resolve => setTimeout(resolve, 50)); + + // Verify initial title + let sessions = provider.getSessions(); + let target = sessions.find((s) => s.title.get() === 'Original Title'); + assert.ok(target, 'Session should exist with original title'); + + // Simulate server updating the summary (as would happen after persist + reload) + connection.addSession(createSession('persist-sess', { summary: 'Renamed Title', modifiedTime: 5000 })); + + // Trigger refresh via turnComplete action (simulates what happens on reload) + connection.fireAction({ + action: { + type: 'session/turnComplete', + session: AgentSession.uri('copilot', 'persist-sess').toString(), + }, + serverSeq: 1, + origin: undefined, + } as IActionEnvelope); + + await new Promise(resolve => setTimeout(resolve, 50)); + + sessions = provider.getSessions(); + target = sessions.find((s) => s.title.get() === 'Renamed Title'); + assert.ok(target, 'Session should have renamed title after refresh'); + }); + // ---- Send ------- test('sendRequest throws for unknown session', async () => { diff --git a/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts b/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts index eff055f6291..c1ba5566cc9 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts @@ -50,5 +50,3 @@ Registry.as(ViewContainerExtensions.ViewsRegistry).registerViews registerSingleton(ISessionsManagementService, SessionsManagementService, InstantiationType.Delayed); registerWorkbenchContribution2(SessionsTitleBarContribution.ID, SessionsTitleBarContribution, WorkbenchPhase.AfterRestored); - -registerSingleton(ISessionsManagementService, SessionsManagementService, InstantiationType.Delayed); diff --git a/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts b/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts index 731640aaf29..b7744053e92 100644 --- a/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts +++ b/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts @@ -18,9 +18,9 @@ import { EditorsVisibleContext, IsAuxiliaryWindowContext, IsSessionsWindowContex import { IChatWidgetService } from '../../../../../workbench/contrib/chat/browser/chat.js'; import { AUX_WINDOW_GROUP } from '../../../../../workbench/services/editor/common/editorService.js'; import { SessionsCategories } from '../../../../common/categories.js'; +import { ChatSessionProviderIdContext, IsNewChatSessionContext, SessionsWelcomeVisibleContext } from '../../../../common/contextkeys.js'; import { SessionItemToolbarMenuId, SessionItemContextMenuId, SessionSectionToolbarMenuId, SessionSectionTypeContext, IsSessionPinnedContext, IsSessionArchivedContext, IsSessionReadContext, SessionsGrouping, SessionsSorting, ISessionSection } from './sessionsList.js'; import { ISessionsManagementService } from '../sessionsManagementService.js'; -import { IsNewChatSessionContext, SessionsWelcomeVisibleContext } from '../../../../common/contextkeys.js'; import { ISession, SessionStatus } from '../../common/sessionData.js'; import { IsWorkspaceGroupCappedContext, SessionsViewFilterOptionsSubMenu, SessionsViewFilterSubMenu, SessionsViewGroupingContext, SessionsViewId, SessionsView, SessionsViewSortingContext } from './sessionsView.js'; import { SessionsViewId as NewChatViewId, NewChatViewPane } from '../../../chat/browser/newChatViewPane.js'; @@ -513,6 +513,45 @@ registerAction2(class UnarchiveSessionAction extends Action2 { } }); +registerAction2(class RenameSessionAction extends Action2 { + constructor() { + super({ + id: 'sessionsViewPane.renameSession', + title: localize2('renameSession', "Rename..."), + menu: [{ + id: SessionItemContextMenuId, + group: '1_edit', + order: 1, + when: ContextKeyExpr.regex(ChatSessionProviderIdContext.key, /^agenthost-/), + }] + }); + } + async run(accessor: ServicesAccessor, context?: ISession | ISession[]): Promise { + const session = Array.isArray(context) ? context[0] : context; + if (!session) { + return; + } + const quickInputService = accessor.get(IQuickInputService); + const sessionsManagementService = accessor.get(ISessionsManagementService); + const newTitle = await quickInputService.input({ + value: session.title.get(), + prompt: localize('renameSession.prompt', "New agent session title"), + validateInput: async value => { + if (!value.trim()) { + return localize('renameSession.empty', "Title cannot be empty"); + } + return undefined; + } + }); + if (newTitle) { + const trimmedTitle = newTitle.trim(); + if (trimmedTitle) { + await sessionsManagementService.renameChat(session.mainChat, trimmedTitle); + } + } + } +}); + registerAction2(class MarkSessionReadAction extends Action2 { constructor() { super({ diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.ts index ce0aae0d174..eb91bcc9797 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.ts @@ -71,7 +71,7 @@ export class AgentHostContribution extends Disposable implements IWorkbenchContr this._register(_agentHostFileSystemService.registerAuthority('local', this._agentHostService)); // Shared client state for protocol reconciliation - this._clientState = this._register(new SessionClientState(this._agentHostService.clientId, this._logService)); + this._clientState = this._register(new SessionClientState(this._agentHostService.clientId, this._logService, () => this._agentHostService.nextClientSeq())); // Forward action envelopes from the host to client state this._register(this._loggedConnection.onDidAction(envelope => { diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts index bd95c6e5a6b..12758f614c7 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts @@ -194,7 +194,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC this._config = config; // Create shared client state manager for this handler instance - this._clientState = this._register(new SessionClientState(config.connection.clientId, this._logService)); + this._clientState = this._register(new SessionClientState(config.connection.clientId, this._logService, () => config.connection.nextClientSeq())); // Register an editing session provider for this handler's session type this._register(this._chatEditingService.registerEditingSessionProvider( diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/loggingAgentConnection.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/loggingAgentConnection.ts index da92b77f45f..c33486b56a6 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/loggingAgentConnection.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/loggingAgentConnection.ts @@ -147,6 +147,10 @@ export class LoggingAgentConnection extends Disposable implements IAgentConnecti this._inner.dispatchAction(action, clientId, clientSeq); } + nextClientSeq(): number { + return this._inner.nextClientSeq(); + } + async browseDirectory(uri: URI): Promise { return this._logCall('browseDirectory', uri, () => this._inner.browseDirectory(uri)); } diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts index 5a77a9eb50b..37c17285e18 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts @@ -120,6 +120,10 @@ class MockAgentHostService extends mock() { override dispatchAction(action: ISessionAction, clientId: string, clientSeq: number): void { this.dispatchedActions.push({ action, clientId, clientSeq }); } + private _nextSeq = 1; + override nextClientSeq(): number { + return this._nextSeq++; + } // Test helpers fireAction(envelope: IActionEnvelope): void {