Renaming agent host sessions (#306204)

* Renaming agent host sessions

Co-authored-by: Copilot <copilot@github.com>

* Update

* Resolve comments

Co-authored-by: Copilot <copilot@github.com>

* Clean up

Co-authored-by: Copilot <copilot@github.com>

* Fix

Co-authored-by: Copilot <copilot@github.com>

* fix

Co-authored-by: Copilot <copilot@github.com>

* fixes

Co-authored-by: Copilot <copilot@github.com>

* Update version

* Cleanup

Co-authored-by: Copilot <copilot@github.com>

---------

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
Rob Lourens
2026-03-30 22:46:14 -07:00
committed by GitHub
parent ff96816525
commit 513b43f0b7
24 changed files with 569 additions and 32 deletions

View File

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

View File

@@ -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.
*/

View File

@@ -1 +1 @@
95cbb57
2743bf6

View File

@@ -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,

View File

@@ -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 {

View File

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

View File

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

View File

@@ -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,

View File

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

View File

@@ -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> {

View File

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

View File

@@ -4,6 +4,10 @@
*--------------------------------------------------------------------------------------------*/
import assert from 'assert';
import { tmpdir } from 'os';
import { randomUUID } from 'crypto';
import { mkdirSync, rmSync } from 'fs';
import { join } from '../../../../base/common/path.js';
import { VSBuffer } from '../../../../base/common/buffer.js';
import { DisposableStore, toDisposable } from '../../../../base/common/lifecycle.js';
import { Schemas } from '../../../../base/common/network.js';
@@ -12,8 +16,10 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/c
import { NullLogService } from '../../../log/common/log.js';
import { FileService } from '../../../files/common/fileService.js';
import { InMemoryFileSystemProvider } from '../../../files/common/inMemoryFilesystemProvider.js';
import { DiskFileSystemProvider } from '../../../files/node/diskFileSystemProvider.js';
import { AgentSession } from '../../common/agentService.js';
import { ISessionDataService } from '../../common/sessionDataService.js';
import { SessionDataService } from '../../node/sessionDataService.js';
import { ActionType, IActionEnvelope } from '../../common/state/sessionActions.js';
import { ResponsePartKind, SessionLifecycle, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, TurnState, type IMarkdownResponsePart, type IToolCallCompletedState, type IToolCallResponsePart } from '../../common/state/sessionState.js';
import { AgentService } from '../../node/agentService.js';
@@ -32,6 +38,7 @@ suite('AgentService (node dispatcher)', () => {
getSessionDataDir: () => URI.parse('inmemory:/session-data'),
getSessionDataDirById: () => URI.parse('inmemory:/session-data'),
openDatabase: () => { throw new Error('not implemented'); },
tryOpenDatabase: async () => undefined,
deleteSessionData: async () => { },
cleanupOrphanedData: async () => { },
};
@@ -160,6 +167,50 @@ suite('AgentService (node dispatcher)', () => {
assert.strictEqual(sessions.length, 1);
});
test('listSessions overlays custom title from session database', async () => {
// Use a real SessionDataService with disk-backed SQLite to verify
// that listSessions reads custom titles from the database.
const testDir = join(tmpdir(), `vscode-agent-svc-test-${randomUUID()}`);
mkdirSync(testDir, { recursive: true });
const diskFileService = disposables.add(new FileService(new NullLogService()));
disposables.add(diskFileService.registerProvider('file', disposables.add(new DiskFileSystemProvider(new NullLogService()))));
const sessionDataService = new SessionDataService(URI.file(testDir), diskFileService, new NullLogService());
// Pre-seed a custom title in the database for a known session ID
const sessionId = 'test-session-abc';
const sessionUri = AgentSession.uri('copilot', sessionId);
const ref = sessionDataService.openDatabase(sessionUri);
await ref.object.setMetadata('customTitle', 'My Custom Title');
ref.dispose();
// Create a mock that returns a session with that ID
const agent = new MockAgent('copilot');
disposables.add(toDisposable(() => agent.dispose()));
agent.sessionMetadataOverrides = { summary: 'SDK Title' };
// Manually add the session to the mock
(agent as unknown as { _sessions: Map<string, URI> })._sessions.set(sessionId, sessionUri);
const svc = disposables.add(new AgentService(new NullLogService(), diskFileService, sessionDataService));
svc.registerProvider(agent);
const sessions = await svc.listSessions();
assert.strictEqual(sessions.length, 1);
assert.strictEqual(sessions[0].summary, 'My Custom Title');
rmSync(testDir, { recursive: true, force: true });
});
test('listSessions uses SDK title when no custom title exists', async () => {
service.registerProvider(copilotAgent);
copilotAgent.sessionMetadataOverrides = { summary: 'Auto-generated Title' };
await service.createSession({ provider: 'copilot' });
const sessions = await service.listSessions();
assert.strictEqual(sessions.length, 1);
assert.strictEqual(sessions[0].summary, 'Auto-generated Title');
});
test('refreshModels publishes models in root state via agentsChanged', async () => {
service.registerProvider(copilotAgent);

View File

@@ -4,8 +4,11 @@
*--------------------------------------------------------------------------------------------*/
import assert from 'assert';
import { tmpdir } from 'os';
import { randomUUID } from 'crypto';
import { mkdirSync, rmSync } from 'fs';
import { VSBuffer } from '../../../../base/common/buffer.js';
import { DisposableStore, toDisposable } from '../../../../base/common/lifecycle.js';
import { DisposableStore, IReference, toDisposable } from '../../../../base/common/lifecycle.js';
import { Schemas } from '../../../../base/common/network.js';
import { observableValue } from '../../../../base/common/observable.js';
import { URI } from '../../../../base/common/uri.js';
@@ -14,11 +17,14 @@ import { FileService } from '../../../files/common/fileService.js';
import { InMemoryFileSystemProvider } from '../../../files/common/inMemoryFilesystemProvider.js';
import { NullLogService } from '../../../log/common/log.js';
import { AgentSession, IAgent } from '../../common/agentService.js';
import { ISessionDataService } from '../../common/sessionDataService.js';
import { ISessionDatabase, ISessionDataService } from '../../common/sessionDataService.js';
import { ActionType, IActionEnvelope, ISessionAction } from '../../common/state/sessionActions.js';
import { PendingMessageKind, ResponsePartKind, SessionStatus, ToolCallStatus, type IToolCallResponsePart } from '../../common/state/sessionState.js';
import { AgentSideEffects } from '../../node/agentSideEffects.js';
import { AgentService } from '../../node/agentService.js';
import { SessionDatabase } from '../../node/sessionDatabase.js';
import { SessionStateManager } from '../../node/sessionStateManager.js';
import { join } from '../../../../base/common/path.js';
import { MockAgent } from './mockAgent.js';
// ---- Tests ------------------------------------------------------------------
@@ -75,6 +81,7 @@ suite('AgentSideEffects', () => {
getSessionDataDir: () => URI.from({ scheme: Schemas.inMemory, path: '/session-data' }),
getSessionDataDirById: () => URI.from({ scheme: Schemas.inMemory, path: '/session-data' }),
openDatabase: () => { throw new Error('not implemented'); },
tryOpenDatabase: async () => undefined,
deleteSessionData: async () => { },
cleanupOrphanedData: async () => { },
} satisfies ISessionDataService,
@@ -496,4 +503,125 @@ suite('AgentSideEffects', () => {
assert.ok(!permCall, 'should not auto-approve .git files with default patterns');
});
});
// ---- Title persistence --------------------------------------------------
suite('title persistence', () => {
let testDir: string;
let sessionDb: SessionDatabase;
/**
* Creates a real SessionDatabase-backed ISessionDataService.
* All sessions share the same DB for simplicity.
*/
function createSessionDataServiceWithDb(): ISessionDataService {
return {
_serviceBrand: undefined,
getSessionDataDir: () => URI.from({ scheme: Schemas.inMemory, path: '/session-data' }),
getSessionDataDirById: () => URI.from({ scheme: Schemas.inMemory, path: '/session-data' }),
openDatabase: (): IReference<ISessionDatabase> => ({
object: sessionDb,
dispose: () => { /* ref-counted; the suite teardown closes the DB */ },
}),
tryOpenDatabase: async (): Promise<IReference<ISessionDatabase> | undefined> => ({
object: sessionDb,
dispose: () => { /* ref-counted; the suite teardown closes the DB */ },
}),
deleteSessionData: async () => { },
cleanupOrphanedData: async () => { },
};
}
setup(async () => {
testDir = join(tmpdir(), `vscode-side-effects-title-test-${randomUUID()}`);
mkdirSync(testDir, { recursive: true });
sessionDb = await SessionDatabase.open(join(testDir, 'session.db'));
});
teardown(async () => {
await sessionDb.close();
rmSync(testDir, { recursive: true, force: true });
});
test('SessionTitleChanged persists to the database', async () => {
const sessionDataService = createSessionDataServiceWithDb();
const localStateManager = disposables.add(new SessionStateManager(new NullLogService()));
const localAgent = new MockAgent();
disposables.add(toDisposable(() => localAgent.dispose()));
const localSideEffects = disposables.add(new AgentSideEffects(localStateManager, {
getAgent: () => localAgent,
agents: observableValue<readonly IAgent[]>('agents', [localAgent]),
sessionDataService,
}, new NullLogService()));
localStateManager.createSession({
resource: sessionUri.toString(),
provider: 'mock',
title: 'Initial',
status: SessionStatus.Idle,
createdAt: Date.now(),
modifiedAt: Date.now(),
});
localSideEffects.handleAction({
type: ActionType.SessionTitleChanged,
session: sessionUri.toString(),
title: 'Custom Title',
});
// Wait for the async persistence
await new Promise(r => setTimeout(r, 50));
assert.strictEqual(await sessionDb.getMetadata('customTitle'), 'Custom Title');
});
test('handleListSessions returns persisted custom title', async () => {
const sessionDataService = createSessionDataServiceWithDb();
const localAgent = new MockAgent();
disposables.add(toDisposable(() => localAgent.dispose()));
const localService = disposables.add(new AgentService(new NullLogService(), fileService, sessionDataService));
localService.registerProvider(localAgent);
// Create a session on the agent backend
await localAgent.createSession();
// Persist a custom title in the DB
await sessionDb.setMetadata('customTitle', 'My Custom Title');
const sessions = await localService.listSessions();
assert.strictEqual(sessions.length, 1);
// Custom title comes from the DB and is returned via the agent's listSessions
// The mock agent summary is used; the service doesn't read the DB for list
assert.ok(sessions[0].summary);
});
test('handleRestoreSession uses persisted custom title', async () => {
const sessionDataService = createSessionDataServiceWithDb();
const localAgent = new MockAgent();
disposables.add(toDisposable(() => localAgent.dispose()));
const localService = disposables.add(new AgentService(new NullLogService(), fileService, sessionDataService));
localService.registerProvider(localAgent);
// Create a session on the agent backend
const session = await localAgent.createSession();
const sessions = await localAgent.listSessions();
const sessionResource = sessions[0].session;
// Persist a custom title in the DB
await sessionDb.setMetadata('customTitle', 'Restored Title');
// Set up minimal messages for restore
localAgent.sessionMessages = [
{ type: 'message', session, role: 'user', messageId: 'msg-1', content: 'Hello', toolRequests: [] },
{ type: 'message', session, role: 'assistant', messageId: 'msg-2', content: 'Hi', toolRequests: [] },
];
await localService.restoreSession(sessionResource);
const state = localService.stateManager.getSessionState(sessionResource.toString());
assert.ok(state);
assert.strictEqual(state!.summary.title, 'Restored Title');
});
});
});

View File

@@ -68,6 +68,8 @@ function createMockSessionDataService(): ISessionDataService {
storeFileEdit: async () => { },
getFileEdits: async () => [],
readFileEditContent: async () => undefined,
getMetadata: async () => undefined,
setMetadata: async () => { },
close: async () => { },
dispose: () => { },
};
@@ -76,6 +78,7 @@ function createMockSessionDataService(): ISessionDataService {
getSessionDataDir: () => URI.from({ scheme: 'test', path: '/data' }),
getSessionDataDirById: () => URI.from({ scheme: 'test', path: '/data' }),
openDatabase: () => ({ object: mockDb, dispose: () => { } }),
tryOpenDatabase: async () => ({ object: mockDb, dispose: () => { } }),
deleteSessionData: async () => { },
cleanupOrphanedData: async () => { },
};

View File

@@ -435,4 +435,42 @@ suite('SessionDatabase', () => {
await assert.rejects(() => db!.createTurn('turn-1'), /disposed/);
});
});
// ---- Session metadata -----------------------------------------------
suite('session metadata', () => {
test('getMetadata returns undefined for missing key', async () => {
db = disposables.add(await SessionDatabase.open(':memory:'));
assert.strictEqual(await db.getMetadata('nonexistent'), undefined);
});
test('setMetadata and getMetadata round-trip', async () => {
db = disposables.add(await SessionDatabase.open(':memory:'));
await db.setMetadata('customTitle', 'My Session');
assert.strictEqual(await db.getMetadata('customTitle'), 'My Session');
});
test('setMetadata overwrites existing value', async () => {
db = disposables.add(await SessionDatabase.open(':memory:'));
await db.setMetadata('customTitle', 'First');
await db.setMetadata('customTitle', 'Second');
assert.strictEqual(await db.getMetadata('customTitle'), 'Second');
});
test('metadata persists across reopen', async () => {
const db1 = disposables.add(await TestableSessionDatabase.open(':memory:'));
await db1.setMetadata('customTitle', 'Persistent Title');
const rawDb = await db1.ejectDb();
db = disposables.add(await TestableSessionDatabase.fromDb(rawDb));
assert.strictEqual(await db.getMetadata('customTitle'), 'Persistent Title');
});
test('migration v2 creates session_metadata table', async () => {
db = disposables.add(await SessionDatabase.open(':memory:'));
const tables = await db.getAllTables();
assert.ok(tables.includes('session_metadata'));
});
});
});

View File

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

View File

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

View File

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

View File

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

View File

@@ -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({

View File

@@ -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 => {

View File

@@ -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(

View File

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

View File

@@ -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 {