mirror of
https://github.com/microsoft/vscode.git
synced 2026-04-02 16:25:00 +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:
@@ -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<IAgentHostService>('agentHostService');
|
||||
|
||||
@@ -82,6 +82,19 @@ export interface ISessionDatabase extends IDisposable {
|
||||
*/
|
||||
readFileEditContent(toolCallId: string, filePath: string): Promise<IFileEditContent | undefined>;
|
||||
|
||||
// ---- Session metadata ------------------------------------------------
|
||||
|
||||
/**
|
||||
* Read a metadata value by key.
|
||||
* Returns `undefined` if no value has been stored for the key.
|
||||
*/
|
||||
getMetadata(key: string): Promise<string | undefined>;
|
||||
|
||||
/**
|
||||
* Store a metadata key-value pair. Overwrites any existing value for the key.
|
||||
*/
|
||||
setMetadata(key: string, value: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* 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<ISessionDatabase>;
|
||||
|
||||
/**
|
||||
* 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<IReference<ISessionDatabase> | undefined>;
|
||||
|
||||
/**
|
||||
* Recursively deletes the data directory for a session, if it exists.
|
||||
*/
|
||||
|
||||
@@ -1 +1 @@
|
||||
95cbb57
|
||||
2743bf6
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<INotification>());
|
||||
readonly onDidReceiveNotification: Event<INotification> = 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;
|
||||
|
||||
@@ -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<IBrowseDirectoryResult> {
|
||||
return this._proxy.browseDirectory(uri);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<ISessionDatabase> {
|
||||
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<IReference<ISessionDatabase> | 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<void> {
|
||||
|
||||
@@ -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<string | undefined> {
|
||||
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<void> {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
// Agent host sessions don't support renaming
|
||||
async renameSession(chatId: string, title: string): Promise<void> {
|
||||
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;
|
||||
|
||||
@@ -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<IAgentConnection>() {
|
||||
override readonly clientId = 'test-client-1';
|
||||
private readonly _sessions = new Map<string, IAgentSessionMetadata>();
|
||||
public disposedSessions: URI[] = [];
|
||||
public dispatchedActions: { action: ISessionAction; clientId: string; clientSeq: number }[] = [];
|
||||
|
||||
private _nextSeq = 0;
|
||||
|
||||
override nextClientSeq(): number {
|
||||
return this._nextSeq++;
|
||||
}
|
||||
|
||||
override async listSessions(): Promise<IAgentSessionMetadata[]> {
|
||||
return [...this._sessions.values()];
|
||||
@@ -49,6 +57,10 @@ class MockAgentConnection extends mock<IAgentConnection>() {
|
||||
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 () => {
|
||||
|
||||
@@ -50,5 +50,3 @@ Registry.as<IViewsRegistry>(ViewContainerExtensions.ViewsRegistry).registerViews
|
||||
registerSingleton(ISessionsManagementService, SessionsManagementService, InstantiationType.Delayed);
|
||||
|
||||
registerWorkbenchContribution2(SessionsTitleBarContribution.ID, SessionsTitleBarContribution, WorkbenchPhase.AfterRestored);
|
||||
|
||||
registerSingleton(ISessionsManagementService, SessionsManagementService, InstantiationType.Delayed);
|
||||
|
||||
@@ -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<void> {
|
||||
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({
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<IBrowseDirectoryResult> {
|
||||
return this._logCall('browseDirectory', uri, () => this._inner.browseDirectory(uri));
|
||||
}
|
||||
|
||||
@@ -120,6 +120,10 @@ class MockAgentHostService extends mock<IAgentHostService>() {
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user