diff --git a/src/vs/platform/agentHost/common/sessionDataService.ts b/src/vs/platform/agentHost/common/sessionDataService.ts index 171cc7d6ebf71..7f78220de4d1c 100644 --- a/src/vs/platform/agentHost/common/sessionDataService.ts +++ b/src/vs/platform/agentHost/common/sessionDataService.ts @@ -290,6 +290,12 @@ export interface ISessionDatabase extends IDisposable { */ setMetadataValues(values: Readonly>): Promise; + /** + * Atomically stores metadata values only when `key` is absent. Values named + * by `copies` are read from their source keys and copied when present. + */ + setMetadataValuesIfAbsent(key: string, values: Readonly>, copies?: Readonly>): Promise; + /** * Store or clear the draft for a chat in this session. */ diff --git a/src/vs/platform/agentHost/node/agentHostStateManager.ts b/src/vs/platform/agentHost/node/agentHostStateManager.ts index 82303835f28c6..97c9d377a92de 100644 --- a/src/vs/platform/agentHost/node/agentHostStateManager.ts +++ b/src/vs/platform/agentHost/node/agentHostStateManager.ts @@ -280,6 +280,8 @@ export class AgentHostStateManager extends Disposable { private readonly _onDidChangeSessionTitle = this._register(new Emitter<{ session: string; title: string }>()); readonly onDidChangeSessionTitle: Event<{ session: string; title: string }> = this._onDidChangeSessionTitle.event; + private readonly _onDidSnapshotDefaultChatTitle = this._register(new Emitter<{ session: string; chat: string; title: string }>()); + readonly onDidSnapshotDefaultChatTitle: Event<{ session: string; chat: string; title: string }> = this._onDidSnapshotDefaultChatTitle.event; private readonly _onDidChangeSessionConfig = this._register(new Emitter<{ session: URI; previous: SessionConfigState | undefined; current: SessionConfigState | undefined; clientContext?: IAgentHostClientTelemetryContext }>()); readonly onDidChangeSessionConfig: Event<{ session: URI; previous: SessionConfigState | undefined; current: SessionConfigState | undefined; clientContext?: IAgentHostClientTelemetryContext }> = this._onDidChangeSessionConfig.event; @@ -1021,11 +1023,7 @@ export class AgentHostStateManager extends Disposable { // titles become fully independent. Without this the default chat keeps // an empty title (= inherit the session title), so renaming the session // would also move the default chat tab and vice-versa. - const defaultChatUri = sessionState.defaultChat ?? buildDefaultChatUri(session); - const defaultEntry = sessionState.chats.find(c => c.resource === defaultChatUri); - if (defaultEntry && !defaultEntry.title && sessionState.title) { - this.updateChatTitle(session, defaultChatUri, sessionState.title); - } + this._snapshotDefaultChatTitle(session, sessionState); const chatSummary: ChatSummary = { ...createDefaultChatSummary(this._toSummary(session, entry), chatUri), @@ -1067,6 +1065,7 @@ export class AgentHostStateManager extends Disposable { } return existing; } + this._snapshotDefaultChatTitle(session, sessionState); const chatSummary: ChatSummary = { ...createDefaultChatSummary(this._toSummary(session, entry), chatUri), title: options.title ?? '', @@ -1077,7 +1076,7 @@ export class AgentHostStateManager extends Disposable { ...(options.origin ? { origin: options.origin } : {}), interactivity: options.interactivity, }; - sessionState.chats = [...sessionState.chats, chatSummary]; + entry.state.chats = [...entry.state.chats, chatSummary]; this._chatEntries.set(chatUri, { session, summary: chatSummary, @@ -1089,6 +1088,15 @@ export class AgentHostStateManager extends Disposable { return chatSummary; } + private _snapshotDefaultChatTitle(session: URI, state: SessionState): void { + const defaultChat = buildDefaultChatUri(session); + const summary = state.chats.find(chat => chat.resource === defaultChat); + if (summary && !summary.title && state.title) { + this.updateChatTitle(session, defaultChat, state.title); + this._onDidSnapshotDefaultChatTitle.fire({ session, chat: defaultChat, title: state.title }); + } + } + /** * Removes an additional chat from a session. Deletes its * {@link ChatState}, dispatches {@link ActionType.SessionChatRemoved}, and diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index 865413420411f..151c156e1a2aa 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -1297,17 +1297,6 @@ export class AgentService extends Disposable implements IAgentService { private async _renameChatFromTool(session: URI, chat: URI, title: string): Promise { validateRenameTitle(title, SessionServerToolName.RenameChat); const isDefaultChat = isDefaultChatUri(chat.toString()); - if (isDefaultChat && await this._isOnlySessionChat(session)) { - await persistSessionMetadataValues(this._sessionDataService, session.toString(), { - [SESSION_CUSTOM_TITLE_KEY]: title, - [SESSION_CUSTOM_TITLE_SOURCE_KEY]: AGENT_HOST_TITLE_SOURCE_AGENT, - }); - if (this._stateManager.getSessionState(session.toString())?.title !== title) { - this._stateManager.dispatchServerAction(session.toString(), { type: ActionType.SessionTitleChanged, title }); - } - this._sideEffects.markTitleRenamed(session.toString()); - return { title }; - } if (!isDefaultChat && !await this._peerChatExists(session, chat)) { throw new Error(`Invalid ${SessionServerToolName.RenameChat} input: chat must match a known non-default chat.`); } @@ -1315,23 +1304,25 @@ export class AgentService extends Disposable implements IAgentService { await persistSessionMetadataValues(this._sessionDataService, session.toString(), { [customChatTitleMetadataKey(chat.toString())]: title, [customChatTitleSourceMetadataKey(chat.toString())]: AGENT_HOST_TITLE_SOURCE_AGENT, + ...(isDefaultChat ? { + [SESSION_CUSTOM_TITLE_KEY]: title, + [SESSION_CUSTOM_TITLE_SOURCE_KEY]: AGENT_HOST_TITLE_SOURCE_AGENT, + } : {}), }); - if (this._stateManager.getSessionState(session.toString())) { + const state = this._stateManager.getSessionState(session.toString()); + if (state) { + if (isDefaultChat && state.title !== title) { + this._stateManager.dispatchServerAction(session.toString(), { type: ActionType.SessionTitleChanged, title }); + } this._stateManager.updateChatTitle(session.toString(), chat.toString(), title); } + if (isDefaultChat) { + this._sideEffects.markTitleRenamed(session.toString()); + } this._sideEffects.markTitleRenamed(session.toString(), chat.toString()); return { title }; } - private async _isOnlySessionChat(session: URI): Promise { - const state = this._stateManager.getSessionState(session.toString()); - if (state) { - return state.chats.length === 1; - } - const persisted = await this._readPersistedPeerChatCatalog(session); - return persisted?.length === 0; - } - private async _peerChatExists(session: URI, chat: URI): Promise { if (this._stateManager.getSessionState(session.toString())?.chats.some(candidate => candidate.resource === chat.toString())) { return true; diff --git a/src/vs/platform/agentHost/node/agentSideEffects.ts b/src/vs/platform/agentHost/node/agentSideEffects.ts index 2f9928c9d6e50..8681a1aa7839b 100644 --- a/src/vs/platform/agentHost/node/agentSideEffects.ts +++ b/src/vs/platform/agentHost/node/agentSideEffects.ts @@ -304,6 +304,7 @@ export class AgentSideEffects extends Disposable { copilotApiService: this._options.copilotApiService, isActiveAgentTitleGenerationEnabled: () => this._agentConfigService.getRootValue(platformRootSchema, AgentHostActiveAgentTitleGenerationConfigKey) === true, })); + this._register(this._stateManager.onDidSnapshotDefaultChatTitle(event => this._persistDefaultChatTitleSnapshot(event.session, event.chat, event.title))); this._localCommands = this._register(instantiationService.createInstance( AgentHostLocalCommands, this._stateManager, @@ -1719,13 +1720,16 @@ export class AgentSideEffects extends Disposable { } case ActionType.SessionTitleChanged: { if (chatChannel) { - // The rename targeted a specific chat (default or additional), - // not the whole session. Route it to a per-chat title update so - // the session title stays independent. this._stateManager.updateChatTitle(sessionChannel, chatChannel, action.title); this._persistSessionFlag(sessionChannel, customChatTitleMetadataKey(chatChannel), action.title); this._persistSessionFlag(sessionChannel, customChatTitleSourceMetadataKey(chatChannel), AGENT_HOST_TITLE_SOURCE_USER); this._titleController.markTitleRenamed(sessionChannel, chatChannel); + if (isDefaultChatUri(chatChannel)) { + this._stateManager.dispatchServerAction(sessionChannel, action); + this._persistSessionFlag(sessionChannel, SESSION_CUSTOM_TITLE_KEY, action.title); + this._persistSessionFlag(sessionChannel, SESSION_CUSTOM_TITLE_SOURCE_KEY, AGENT_HOST_TITLE_SOURCE_USER); + this._titleController.markTitleRenamed(sessionChannel); + } break; } this._persistSessionFlag(channel, SESSION_CUSTOM_TITLE_KEY, action.title); @@ -1926,6 +1930,34 @@ export class AgentSideEffects extends Disposable { persistSessionMetadata(this._options.sessionDataService, this._logService, session, key, value); } + private _persistDefaultChatTitleSnapshot(session: ProtocolURI, chat: ProtocolURI, title: string): void { + const ref = (() => { + try { + return this._options.sessionDataService.openDatabase(URI.parse(session)); + } catch (error) { + this._logService.warn('[AgentSideEffects] Failed to open session database for default chat title snapshot', error); + return undefined; + } + })(); + if (!ref) { + return; + } + const persist = async () => { + if (this._stateManager.getChatState(chat)?.title !== title) { + return; + } + const titleKey = customChatTitleMetadataKey(chat); + await ref.object.setMetadataValuesIfAbsent( + titleKey, + { [titleKey]: title }, + { [customChatTitleSourceMetadataKey(chat)]: SESSION_CUSTOM_TITLE_SOURCE_KEY }, + ); + }; + void persist().catch(error => { + this._logService.warn('[AgentSideEffects] Failed to persist default chat title snapshot', error); + }).finally(() => ref.dispose()); + } + /** * Persists the usage reported for a chat's turn. * diff --git a/src/vs/platform/agentHost/node/localCommands/renameLocalCommand.ts b/src/vs/platform/agentHost/node/localCommands/renameLocalCommand.ts index d3e4ea5cca2f1..f3d222b79f4ba 100644 --- a/src/vs/platform/agentHost/node/localCommands/renameLocalCommand.ts +++ b/src/vs/platform/agentHost/node/localCommands/renameLocalCommand.ts @@ -40,15 +40,19 @@ export class RenameLocalCommand extends Disposable implements ILocalChatCommand // completes the turn. return; } - const isAdditional = (uri: ProtocolURI): boolean => isAhpChatChannel(uri) && !isDefaultChatUri(uri); - const chatTarget = isAdditional(channel) ? channel : undefined; + const chatTarget = isAhpChatChannel(channel) ? channel : undefined; const sessionChannel = isAhpChatChannel(channel) ? parseRequiredSessionUriFromChatUri(channel) : channel; if (chatTarget) { - // Rename only this chat, independently of the session title. this._context.updateChatTitle(sessionChannel, chatTarget, title); this._context.markTitleRenamed(sessionChannel, chatTarget); this._context.persistSessionFlag(sessionChannel, customChatTitleMetadataKey(chatTarget), title); this._context.persistSessionFlag(sessionChannel, customChatTitleSourceMetadataKey(chatTarget), AGENT_HOST_TITLE_SOURCE_USER); + if (isDefaultChatUri(chatTarget)) { + this._context.dispatch(sessionChannel, { type: ActionType.SessionTitleChanged, title }); + this._context.markTitleRenamed(sessionChannel); + this._context.persistSessionFlag(sessionChannel, SESSION_CUSTOM_TITLE_KEY, title); + this._context.persistSessionFlag(sessionChannel, SESSION_CUSTOM_TITLE_SOURCE_KEY, AGENT_HOST_TITLE_SOURCE_USER); + } } else { this._context.dispatch(sessionChannel, { type: ActionType.SessionTitleChanged, title }); this._context.markTitleRenamed(sessionChannel); diff --git a/src/vs/platform/agentHost/node/sessionDatabase.ts b/src/vs/platform/agentHost/node/sessionDatabase.ts index b476bd8bb58be..df9a7fa8708d1 100644 --- a/src/vs/platform/agentHost/node/sessionDatabase.ts +++ b/src/vs/platform/agentHost/node/sessionDatabase.ts @@ -700,6 +700,33 @@ export class SessionDatabase implements ISessionDatabase { })); } + setMetadataValuesIfAbsent(key: string, values: Readonly>, copies: Readonly> = {}): Promise { + return this._track(() => this._metadataSequencer.queue(async () => { + const db = await this._ensureDb(); + return this._transactionSequencer.queue(async () => { + await dbExec(db, 'BEGIN TRANSACTION'); + try { + const existing = await dbGet(db, 'SELECT 1 FROM session_metadata WHERE key = ?', [key]); + if (existing) { + await dbExec(db, 'COMMIT'); + return false; + } + for (const [targetKey, value] of Object.entries(values)) { + await dbRun(db, 'INSERT OR REPLACE INTO session_metadata (key, value) VALUES (?, ?)', [targetKey, value]); + } + for (const [targetKey, sourceKey] of Object.entries(copies)) { + await dbRun(db, 'INSERT OR REPLACE INTO session_metadata (key, value) SELECT ?, value FROM session_metadata WHERE key = ?', [targetKey, sourceKey]); + } + await dbExec(db, 'COMMIT'); + return true; + } catch (err) { + await dbExec(db, 'ROLLBACK'); + throw err; + } + }); + })); + } + setChatDraft(chat: URI, draft: Message | undefined): Promise { const chatUri = chat.toString(); return this._track(async () => { diff --git a/src/vs/platform/agentHost/node/shared/sessionServerTools.ts b/src/vs/platform/agentHost/node/shared/sessionServerTools.ts index a435fc24175a9..78623c6277f8f 100644 --- a/src/vs/platform/agentHost/node/shared/sessionServerTools.ts +++ b/src/vs/platform/agentHost/node/shared/sessionServerTools.ts @@ -168,7 +168,7 @@ export const sessionServerToolDefinitions: ToolDefinition[] = [ { name: SessionServerToolName.RenameChat, title: 'Rename Chat', - description: 'Rename one specific chat so it is easy to find later. When a session has only its default chat, renaming that chat also names the session. Once the session has multiple chats, only the targeted chat is renamed. Use a short, human-friendly chat name in sentence case (1-4 words). Pass an `agent-host-session://` session or chat link to target another chat, or omit `chat` to rename the chat in which this tool is running. Name a fresh chat once its scope is clear, typically soon after `create_chat` or early in that chat. Call this tool again whenever the user explicitly asks to rename the chat; every invocation replaces the current title.', + description: 'Rename one specific chat so it is easy to find later. Renaming the default chat also names its owning session, while peer-chat titles remain independent. Use a short, human-friendly chat name in sentence case (1-4 words). Pass an `agent-host-session://` session or chat link to target another chat, or omit `chat` to rename the chat in which this tool is running. Name a fresh chat once its scope is clear, typically soon after `create_chat` or early in that chat. Call this tool again whenever the user explicitly asks to rename the chat; every invocation replaces the current title.', inputSchema: renameChatInputSchema, annotations: { readOnlyHint: false }, }, diff --git a/src/vs/platform/agentHost/test/common/sessionTestHelpers.ts b/src/vs/platform/agentHost/test/common/sessionTestHelpers.ts index 85aca98b3b31b..206b8b62224e7 100644 --- a/src/vs/platform/agentHost/test/common/sessionTestHelpers.ts +++ b/src/vs/platform/agentHost/test/common/sessionTestHelpers.ts @@ -89,6 +89,24 @@ export class TestSessionDatabase implements ISessionDatabase { } } + async setMetadataValuesIfAbsent(key: string, values: Readonly>, copies: Readonly> = {}): Promise { + if (this._metadata.has(key)) { + return false; + } + for (const [targetKey, value] of Object.entries(values)) { + this.setMetadataCalls.push({ key: targetKey, value }); + this._metadata.set(targetKey, value); + } + for (const [targetKey, sourceKey] of Object.entries(copies)) { + const value = this._metadata.get(sourceKey); + if (value !== undefined) { + this.setMetadataCalls.push({ key: targetKey, value }); + this._metadata.set(targetKey, value); + } + } + return true; + } + async setChatDraft(chat: URI, draft: Message | undefined): Promise { const key = chat.toString(); if (draft) { diff --git a/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts b/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts index 5cf4090d768f9..cb923d143d994 100644 --- a/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts @@ -1136,6 +1136,43 @@ suite('AgentHostStateManager', () => { ); }); + test('restored peer chat snapshots the inherited default chat title', () => { + manager.restoreSession(makeSessionSummary(), []); + const defaultChat = buildDefaultChatUri(sessionUri); + const beforeRestore = manager.getSessionState(sessionUri)?.chats.find(chat => chat.resource === defaultChat)?.title; + + manager.registerRestoredChatSummary(sessionUri, peerChat, { title: 'Peer' }); + + assert.deepStrictEqual({ + beforeRestore, + afterRestore: manager.getSessionState(sessionUri)?.chats.find(chat => chat.resource === defaultChat)?.title, + }, { + beforeRestore: '', + afterRestore: 'Test', + }); + }); + + test('adding a chat snapshots the canonical default when routing defaults to a peer', () => { + manager.createSession(makeSessionSummary()); + const canonicalDefault = buildDefaultChatUri(sessionUri); + const peer2 = buildChatUri(sessionUri, 'peer-2'); + manager.addChat(sessionUri, peerChat, { title: 'Peer' }); + manager.updateChatTitle(sessionUri, canonicalDefault, ''); + manager.updateChatTitle(sessionUri, peerChat, ''); + manager.dispatchServerAction(sessionUri, { type: ActionType.SessionDefaultChatChanged, defaultChat: peerChat }); + + manager.addChat(sessionUri, peer2, { title: 'Peer 2' }); + + const state = manager.getSessionState(sessionUri); + assert.deepStrictEqual({ + canonicalDefaultTitle: state?.chats.find(chat => chat.resource === canonicalDefault)?.title, + routingDefaultTitle: state?.chats.find(chat => chat.resource === peerChat)?.title, + }, { + canonicalDefaultTitle: 'Test', + routingDefaultTitle: '', + }); + }); + test('addChat is idempotent for an existing chat URI', () => { manager.createSession(makeSessionSummary()); const first = manager.addChat(sessionUri, peerChat, { title: 'Peer' }); diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index c5d1e6d771a6e..41f1e7a870946 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -9739,6 +9739,16 @@ suite('AgentService (node dispatcher)', () => { return []; } + async function waitForMetadata(db: TestSessionDatabase, key: string, expected: string): Promise { + for (let i = 0; i < 50; i++) { + if (await db.getMetadata(key) === expected) { + return; + } + await timeout(0); + } + assert.fail(`Metadata '${key}' did not become '${expected}'`); + } + test('rolls back a new peer chat when its catalog entry cannot be persisted', async () => { class FailingPeerCatalogDatabase extends TestSessionDatabase { failPeerCatalogWrites = false; @@ -9821,6 +9831,39 @@ suite('AgentService (node dispatcher)', () => { assert.ok(!registered.includes(AgentSession.uri('copilot', 'restored-peer-backing-sdk-id').toString()), 'the backing session must not leak into the registered session list'); }); + test('restores the snapshotted default chat title after the session is renamed', async () => { + class MultiChatAgent extends MockAgent { + override async createChat(): Promise { } + } + const db = new TestSessionDatabase(); + const localService = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const agent = disposables.add(new MultiChatAgent('copilot')); + localService.registerProvider(agent); + const session = await localService.createSession({ provider: 'copilot' }); + const sessionUri = session.toString(); + const defaultChat = buildDefaultChatUri(session); + const peerChat = URI.parse(buildChatUri(session, 'peer')); + localService.dispatchAction(sessionUri, { type: ActionType.SessionTitleChanged, title: 'Default A' }, 'test-client', 1); + await waitForMetadata(db, 'customTitle', 'Default A'); + + await localService.createChat(session, peerChat); + await waitForMetadata(db, `customChatTitle:${defaultChat}`, 'Default A'); + localService.dispatchAction(sessionUri, { type: ActionType.SessionTitleChanged, title: 'Session B' }, 'test-client', 2); + await waitForMetadata(db, 'customTitle', 'Session B'); + + localService.stateManager.deleteSession(sessionUri); + await localService.restoreSession(session); + + const restored = localService.stateManager.getSessionState(sessionUri); + assert.deepStrictEqual({ + sessionTitle: restored?.title, + defaultChatTitle: restored?.chats.find(chat => chat.resource === defaultChat)?.title, + }, { + sessionTitle: 'Session B', + defaultChatTitle: 'Default A', + }); + }); + test('restore registers peer-chat metadata in catalog order and loads history on first access', async () => { const calls: { call: string; uri: string; providerData?: string }[] = []; class MultiChatAgent extends MockAgent { @@ -10932,6 +10975,14 @@ suite('AgentService (node dispatcher)', () => { await db.setMetadata('customTitleSource', 'user'); await db.setMetadata(`customChatTitle:${peerChat}`, 'Previous peer title'); await db.setMetadata(`customChatTitleSource:${peerChat}`, 'user'); + await timeout(0); + localService.stateManager.prepareSessionSummariesForListing([localService.stateManager.getSessionSummary(sessionUri)!]); + const summaryTitleChanged = new DeferredPromise(); + disposables.add(localService.onDidNotification(notification => { + if (notification.type === NotificationType.SessionSummaryChanged && notification.changes.title) { + void summaryTitleChanged.complete(notification.changes.title); + } + })); const multiChatDefaultResult = await agent.serverToolHost!.executeTool(defaultChat, SessionServerToolName.RenameChat, { title: 'Complete replacement default chat title', @@ -10941,7 +10992,7 @@ suite('AgentService (node dispatcher)', () => { title: 'Complete replacement peer chat title', }); await db.finalRenamePersisted.p; - await timeout(0); + const summaryTitleChange = await summaryTitleChanged.p; assert.deepStrictEqual({ singleChatResult, @@ -10956,19 +11007,21 @@ suite('AgentService (node dispatcher)', () => { persistedDefaultChatSource: await db.getMetadata(`customChatTitleSource:${defaultChat}`), persistedChatTitle: await db.getMetadata(`customChatTitle:${peerChat}`), persistedChatSource: await db.getMetadata(`customChatTitleSource:${peerChat}`), + summaryTitleChange, }, { singleChatResult: 'Renamed chat to "Single-chat title".', multiChatDefaultResult: 'Renamed chat to "Complete replacement default chat title".', chatResult: 'Renamed chat to "Complete replacement peer chat title".', - liveSessionTitle: 'Multi-chat session title', + liveSessionTitle: 'Complete replacement default chat title', liveDefaultChatTitle: 'Complete replacement default chat title', liveChatTitle: 'Complete replacement peer chat title', - persistedSessionTitle: 'Multi-chat session title', - persistedSessionSource: 'user', + persistedSessionTitle: 'Complete replacement default chat title', + persistedSessionSource: 'agent', persistedDefaultChatTitle: 'Complete replacement default chat title', persistedDefaultChatSource: 'agent', persistedChatTitle: 'Complete replacement peer chat title', persistedChatSource: 'agent', + summaryTitleChange: 'Complete replacement default chat title', }); }); diff --git a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts index c90696623859e..452e033a39681 100644 --- a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts +++ b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts @@ -50,6 +50,7 @@ import { withChatSurfaceMeta } from '../../common/meta/agentChatSurfaceMeta.js'; import { AgentHostCustomizationEnablementService, IAgentHostCustomizationEnablementService } from '../../node/agentHostCustomizationEnablementService.js'; import { AgentHostStorageService } from '../../node/agentHostStorageService.js'; import { applyMcpServerEnablement } from '../../node/shared/mcpCustomizationController.js'; +import { customChatTitleMetadataKey, customChatTitleSourceMetadataKey, SESSION_CUSTOM_TITLE_KEY, SESSION_CUSTOM_TITLE_SOURCE_KEY } from '../../node/shared/persistSessionMetadata.js'; import { createNoopGitService, createNullSessionDataService, createSessionDataService, TestSessionDatabase } from '../common/sessionTestHelpers.js'; import { MockAgent } from './mockAgent.js'; import { TestAgentHostTerminalManager } from './testAgentHostTerminalManager.js'; @@ -1513,6 +1514,30 @@ suite('AgentSideEffects', () => { assert.deepStrictEqual(agent.sendMessageCalls, []); const state = stateManager.getSessionState(sessionUri.toString()); assert.strictEqual(state?.title, 'Test'); + }); + + test('/rename updates both the session and default chat title once multi-chat', async () => { + setupSession(); + const renameSideEffects = createRenameSideEffects(); + stateManager.addChat(sessionUri.toString(), buildChatUri(sessionUri.toString(), 'peer'), { title: 'Peer' }); + const action: ChatAction = { + type: ActionType.ChatTurnStarted, + turnId: 'turn-rename', + startedAt: '2025-01-01T00:00:00.000Z', + message: { text: '/rename Renamed Default', origin: { kind: MessageKind.User } }, + }; + stateManager.dispatchClientAction(defaultChatUri, action, { clientId: 'test', clientSeq: 1 }); + renameSideEffects.handleAction(defaultChatUri, action); + await timeout(10); + + const state = stateManager.getSessionState(sessionUri.toString()); + assert.deepStrictEqual({ + sessionTitle: state?.title, + defaultChatTitle: state?.chats.find(chat => chat.resource === defaultChatUri)?.title, + }, { + sessionTitle: 'Renamed Default', + defaultChatTitle: 'Renamed Default', + }); assert.strictEqual(stateManager.getActiveTurnId(sessionUri.toString()), undefined); }); @@ -4913,6 +4938,151 @@ suite('AgentSideEffects', () => { assert.strictEqual(await waitForMetadata('customTitle'), 'Custom Title'); }); + test('default chat title change updates and persists the session title', async () => { + const sessionDataService = createSessionDataService(sessionDb); + const localStateManager = disposables.add(new AgentHostStateManager(new NullLogService())); + const localAgent = new MockAgent(); + disposables.add(toDisposable(() => localAgent.dispose())); + const localSideEffects = createTestSideEffects(disposables, localStateManager, { + getAgent: () => localAgent, + agents: observableValue('agents', [localAgent]), + sessionDataService, + onTurnComplete: () => { }, + }); + const defaultChat = buildDefaultChatUri(sessionUri); + localStateManager.createSession({ + resource: sessionUri.toString(), + provider: 'mock', + title: 'Initial', + status: SessionStatus.Idle, + createdAt: new Date().toISOString(), + modifiedAt: new Date().toISOString(), + }); + localStateManager.addChat(sessionUri.toString(), buildChatUri(sessionUri.toString(), 'peer'), { title: 'Peer' }); + + localSideEffects.handleAction(defaultChat, { + type: ActionType.SessionTitleChanged, + title: 'Renamed Default', + }); + + assert.deepStrictEqual({ + sessionTitle: localStateManager.getSessionState(sessionUri.toString())?.title, + defaultChatTitle: localStateManager.getChatState(defaultChat)?.title, + persistedSessionTitle: await waitForMetadata(SESSION_CUSTOM_TITLE_KEY), + persistedSessionSource: await waitForMetadata(SESSION_CUSTOM_TITLE_SOURCE_KEY), + persistedChatTitle: await waitForMetadata(customChatTitleMetadataKey(defaultChat)), + persistedChatSource: await waitForMetadata(customChatTitleSourceMetadataKey(defaultChat)), + }, { + sessionTitle: 'Renamed Default', + defaultChatTitle: 'Renamed Default', + persistedSessionTitle: 'Renamed Default', + persistedSessionSource: 'user', + persistedChatTitle: 'Renamed Default', + persistedChatSource: 'user', + }); + }); + + test('first peer persists the inherited default chat title and provenance', async () => { + await sessionDb.setMetadata(SESSION_CUSTOM_TITLE_KEY, 'Initial'); + await sessionDb.setMetadata(SESSION_CUSTOM_TITLE_SOURCE_KEY, 'auto'); + const sessionDataService = createSessionDataService(sessionDb); + const localStateManager = disposables.add(new AgentHostStateManager(new NullLogService())); + const localAgent = new MockAgent(); + disposables.add(toDisposable(() => localAgent.dispose())); + createTestSideEffects(disposables, localStateManager, { + getAgent: () => localAgent, + agents: observableValue('agents', [localAgent]), + sessionDataService, + onTurnComplete: () => { }, + }); + const defaultChat = buildDefaultChatUri(sessionUri); + localStateManager.createSession({ + resource: sessionUri.toString(), + provider: 'mock', + title: 'Initial', + status: SessionStatus.Idle, + createdAt: new Date().toISOString(), + modifiedAt: new Date().toISOString(), + }); + + localStateManager.addChat(sessionUri.toString(), buildChatUri(sessionUri.toString(), 'peer'), { title: 'Peer' }); + + assert.deepStrictEqual({ + title: await waitForMetadata(customChatTitleMetadataKey(defaultChat)), + source: await waitForMetadata(customChatTitleSourceMetadataKey(defaultChat)), + }, { + title: 'Initial', + source: 'auto', + }); + }); + + test('default chat title snapshot does not overwrite an existing persisted title', async () => { + const defaultChat = buildDefaultChatUri(sessionUri); + await sessionDb.setMetadata(customChatTitleMetadataKey(defaultChat), 'Existing'); + const localStateManager = disposables.add(new AgentHostStateManager(new NullLogService())); + const localAgent = new MockAgent(); + disposables.add(toDisposable(() => localAgent.dispose())); + createTestSideEffects(disposables, localStateManager, { + getAgent: () => localAgent, + agents: observableValue('agents', [localAgent]), + sessionDataService: createSessionDataService(sessionDb), + onTurnComplete: () => { }, + }); + localStateManager.createSession({ + resource: sessionUri.toString(), + provider: 'mock', + title: 'Initial', + status: SessionStatus.Idle, + createdAt: new Date().toISOString(), + modifiedAt: new Date().toISOString(), + }); + + localStateManager.addChat(sessionUri.toString(), buildChatUri(sessionUri.toString(), 'peer'), { title: 'Peer' }); + await timeout(10); + + assert.strictEqual(await sessionDb.getMetadata(customChatTitleMetadataKey(defaultChat)), 'Existing'); + }); + + test('a same-turn default chat rename wins after the inherited title snapshot', async () => { + const defaultChat = buildDefaultChatUri(sessionUri); + await sessionDb.setMetadata(SESSION_CUSTOM_TITLE_SOURCE_KEY, 'auto'); + const localStateManager = disposables.add(new AgentHostStateManager(new NullLogService())); + const localAgent = new MockAgent(); + disposables.add(toDisposable(() => localAgent.dispose())); + const localSideEffects = createTestSideEffects(disposables, localStateManager, { + getAgent: () => localAgent, + agents: observableValue('agents', [localAgent]), + sessionDataService: createSessionDataService(sessionDb), + onTurnComplete: () => { }, + }); + localStateManager.createSession({ + resource: sessionUri.toString(), + provider: 'mock', + title: 'Initial', + status: SessionStatus.Idle, + createdAt: new Date().toISOString(), + modifiedAt: new Date().toISOString(), + }); + + localStateManager.addChat(sessionUri.toString(), buildChatUri(sessionUri.toString(), 'peer'), { title: 'Peer' }); + localSideEffects.handleAction(defaultChat, { + type: ActionType.SessionTitleChanged, + title: 'Newer', + }); + + assert.deepStrictEqual({ + chatTitle: await waitForMetadata(customChatTitleMetadataKey(defaultChat)), + chatSource: await waitForMetadata(customChatTitleSourceMetadataKey(defaultChat)), + sessionTitle: await waitForMetadata(SESSION_CUSTOM_TITLE_KEY), + sessionSource: await waitForMetadata(SESSION_CUSTOM_TITLE_SOURCE_KEY), + }, { + chatTitle: 'Newer', + chatSource: 'user', + sessionTitle: 'Newer', + sessionSource: 'user', + }); + }); + test('handleListSessions returns persisted custom title', async () => { const sessionDataService = createSessionDataService(sessionDb); const localAgent = new MockAgent(); diff --git a/src/vs/platform/agentHost/test/node/sessionDatabase.test.ts b/src/vs/platform/agentHost/test/node/sessionDatabase.test.ts index 0629fda731fb4..94827add02e1c 100644 --- a/src/vs/platform/agentHost/test/node/sessionDatabase.test.ts +++ b/src/vs/platform/agentHost/test/node/sessionDatabase.test.ts @@ -756,6 +756,60 @@ suite('SessionDatabase', () => { }); }); + test('setMetadataValuesIfAbsent atomically copies source metadata', async () => { + db = disposables.add(await SessionDatabase.open(':memory:')); + await db.setMetadata('customTitleSource', 'auto'); + + const stored = await db.setMetadataValuesIfAbsent('customChatTitle:default', { + 'customChatTitle:default': 'Inherited title', + }, { + 'customChatTitleSource:default': 'customTitleSource', + }); + + assert.deepStrictEqual({ + stored, + metadata: await db.getMetadataObject({ + 'customChatTitle:default': true, + 'customChatTitleSource:default': true, + }), + }, { + stored: true, + metadata: { + 'customChatTitle:default': 'Inherited title', + 'customChatTitleSource:default': 'auto', + }, + }); + }); + + test('setMetadataValuesIfAbsent preserves existing metadata', async () => { + db = disposables.add(await SessionDatabase.open(':memory:')); + await db.setMetadataValues({ + 'customChatTitle:default': 'Existing title', + 'customChatTitleSource:default': 'user', + customTitleSource: 'auto', + }); + + const stored = await db.setMetadataValuesIfAbsent('customChatTitle:default', { + 'customChatTitle:default': 'Replacement title', + }, { + 'customChatTitleSource:default': 'customTitleSource', + }); + + assert.deepStrictEqual({ + stored, + metadata: await db.getMetadataObject({ + 'customChatTitle:default': true, + 'customChatTitleSource:default': true, + }), + }, { + stored: false, + metadata: { + 'customChatTitle:default': 'Existing title', + 'customChatTitleSource:default': 'user', + }, + }); + }); + test('setMetadataValues serializes with turn ID remapping transactions', async () => { db = disposables.add(await SessionDatabase.open(':memory:')); await db.createTurn('old-1'); diff --git a/src/vs/platform/agentHost/test/node/sessionServerTools.test.ts b/src/vs/platform/agentHost/test/node/sessionServerTools.test.ts index 959b3098ce8fc..dc22633185c56 100644 --- a/src/vs/platform/agentHost/test/node/sessionServerTools.test.ts +++ b/src/vs/platform/agentHost/test/node/sessionServerTools.test.ts @@ -93,6 +93,9 @@ suite('SessionServerTools', () => { assert.deepStrictEqual(sessionServerToolDefinitions.slice(4, 5).map(def => def.inputSchema?.properties?.title), [ { type: 'string', maxLength: 200, description: 'Short, descriptive chat title, ideally 1-4 words.' }, ]); + const renameDescription = sessionServerToolDefinitions.find(def => def.name === SessionServerToolName.RenameChat)?.description; + assert.ok(renameDescription?.includes('Renaming the default chat also names its owning session')); + assert.ok(renameDescription?.includes('peer-chat titles remain independent')); }); test('new sessions use the current setting while materialized sessions keep their advertised tools', async () => { 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 e56ef2ce84918..76bc2460b5285 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts @@ -45,7 +45,7 @@ import { CompletionItemKind as AhpCompletionItemKind, ContentEncoding, type Comp import { ConfirmationOptionKind, CustomizationType, JsonPrimitive, McpServerAuthRequiredState, McpServerStatus, SessionInputRequestKind, TerminalClaimKind, ToolCallContributorKind, ToolResultContentType, type ConfirmationOption, type ProtectedResourceMetadata, type SessionActiveClient, type SessionInputRequest, type SessionToolClientExecutionRequest } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; import { ActionType, ChatTurnStartedAction, isChatAction, type ClientChatAction, type ClientSessionAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; import { AHP_AUTH_REQUIRED, ProtocolError } from '../../../../../../platform/agentHost/common/state/sessionProtocol.js'; -import { buildSubagentChatUri, ChatOriginKind, getInlineToolInput, getToolSubagentContent, isChatReadOnly, isMessageHiddenFromTranscript, MessageAttachmentKind, MessageKind, PendingMessageKind, ResponsePartKind, ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputResponseKind, SessionStatus, StateComponents, ToolCallCancellationReason, ToolCallConfirmationReason, ToolCallStatus, TurnState, parseChatUri, mergeSessionWithDefaultChat, readUsageInfoMeta, withMessageHiddenFromTranscript, type ChatState, type ISessionWithDefaultChat, type ICompletedToolCall, type InputRequestResponsePart, type MarkdownResponsePart, type Message, type MessageAttachment, type MessageAnnotationsAttachment, type MessageChatAttachment, type MessageResourceAttachment, type MessageEmbeddedResourceAttachment, type ModelSelection, type PendingMessage, type ReasoningResponsePart, type RootState, type ChatInputAnswer, type ChatInputQuestion, type ChatInputRequest, type SessionState, type StringOrMarkdown, type ToolCallResponsePart, type ToolCallState, type ToolInput, type Turn } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { buildChatUri, buildDefaultChatUri, buildSubagentChatUri, ChatOriginKind, getInlineToolInput, getToolSubagentContent, isChatReadOnly, isDefaultChatUri, isMessageHiddenFromTranscript, MessageAttachmentKind, MessageKind, PendingMessageKind, ResponsePartKind, ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputResponseKind, SessionStatus, StateComponents, ToolCallCancellationReason, ToolCallConfirmationReason, ToolCallStatus, TurnState, parseChatUri, mergeSessionWithDefaultChat, readUsageInfoMeta, withMessageHiddenFromTranscript, type ChatState, type ISessionWithDefaultChat, type ICompletedToolCall, type InputRequestResponsePart, type MarkdownResponsePart, type Message, type MessageAttachment, type MessageAnnotationsAttachment, type MessageChatAttachment, type MessageResourceAttachment, type MessageEmbeddedResourceAttachment, type ModelSelection, type PendingMessage, type ReasoningResponsePart, type RootState, type ChatInputAnswer, type ChatInputQuestion, type ChatInputRequest, type SessionState, type StringOrMarkdown, type ToolCallResponsePart, type ToolCallState, type ToolInput, type Turn } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { ExtensionIdentifier } from '../../../../../../platform/extensions/common/extensions.js'; import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; @@ -609,6 +609,14 @@ function inputRequestResponsePartKey(part: InputRequestResponsePart): string { return `ir:${part.request.id}:${JSON.stringify({ ...part.request, answers: undefined })}`; } +function getChatTitle(state: Pick, chatURI: string): string | undefined { + const chat = state.chats.find(chat => chat.resource === chatURI); + if (!chat) { + return undefined; + } + return chat.title || (isDefaultChatUri(chatURI) ? state.title : undefined); +} + /** * The live invocation the reconnect snapshot emitted for this tool call, if * any. A tool call the snapshot rendered as a serialized part has no live @@ -1313,7 +1321,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC let initialProgress: IChatProgress[] | undefined; let initialResponsePartCount = 0; let activeTurnId: string | undefined; - let sessionTitle: string | undefined; + let chatTitle: string | undefined; let draftInputState: ISerializableChatModelInputState | undefined; let sessionSubscription: IAgentSubscription | undefined; let chatSubscription: IAgentSubscription | undefined; @@ -1355,7 +1363,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC await this._whenSubscriptionHydrated(chatSub, token); const sessionState = this._getSessionState(resolvedSession.toString(), chatURI); if (sessionState) { - sessionTitle = sessionState.title; + chatTitle = getChatTitle(sessionState, chatURI); const draft = sessionState.draft ?? emptyDraftFromLastTurn(sessionState); draftInputState = this._draftToInputState(sessionResource, draft); if (!sessionState.draft && draft) { @@ -1477,7 +1485,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC AgentHostChatSession, sessionResource, history, - sessionTitle, + chatTitle, sessionSubscription, chatSubscription, this._config.promptCacheNotification, @@ -1489,7 +1497,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC return this._forkSession(sessionResource, resolvedSession, request, token); }, (title: string, _token: CancellationToken) => { - this._config.connection.dispatch(resolvedSession.toString(), { + this._config.connection.dispatch(this._getRenameChatURI(sessionResource, resolvedSession), { type: ActionType.SessionTitleChanged, title, }); @@ -2053,6 +2061,18 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC return chatURI; } + private _getRenameChatURI(sessionResource: URI, session: URI): string { + const mapped = this._chatURIsBySessionResource.get(sessionResource); + if (mapped) { + return mapped; + } + if (!sessionResource.fragment) { + return buildDefaultChatUri(session); + } + const explicitChat = new URLSearchParams(sessionResource.query).get(CHAT_SUBAGENT_RESOURCE_QUERY_PARAM); + return explicitChat ?? buildChatUri(session, sessionResource.fragment); + } + private _getCurrentActiveClient(sessionResource: URI): SessionActiveClient { const entry = this._activeClientEntries.get(sessionResource); if (entry) { @@ -2135,7 +2155,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC let lastSeenTurnId: string | undefined = currentState?.activeTurn?.id; let previousQueuedIds: Set | undefined; let previousSteeringId: string | undefined = currentState?.steeringMessage?.id; - let previousTitle: string | undefined = currentState?.title; + let previousTitle: string | undefined = currentState ? getChatTitle(currentState, chatURI) : undefined; const disposables = new DisposableStore(); @@ -2145,9 +2165,8 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC const sessionSub = this._ensureSessionSubscription(sessionStr); const chatSub = this._ensureChatSubscription(sessionStr, chatURI); - // Conversation contents now live on the default chat, while title and - // other session-scoped fields stay on the session. Re-evaluate on a - // change to either channel, reading the merged view. + // Conversation contents live on the chat, while its catalog title and + // other session-scoped fields live on the session. Re-evaluate on either. const onChange = () => { const state = this._getSessionState(sessionStr, chatURI); if (!state) { @@ -2165,7 +2184,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC } previousSteeringId = currentSteeringId; - const currentTitle = e.state.title; + const currentTitle = getChatTitle(e.state, chatURI); if (currentTitle && currentTitle !== previousTitle) { this._chatService.setChatSessionTitle(sessionResource, currentTitle); } 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 52732493b884e..e1d8be2dcc1c8 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 @@ -823,6 +823,10 @@ function createTestServices(disposables: DisposableStore, workingDirectoryResolv removePendingRequest(sessionResource: URI, requestId: string) { this.removePendingRequestCalls.push({ sessionResource, requestId }); }, + setChatSessionTitleCalls: [] as { sessionResource: URI; title: string }[], + async setChatSessionTitle(sessionResource: URI, title: string) { + this.setChatSessionTitleCalls.push({ sessionResource, title }); + }, syncPendingRequestsFromRemoteCalls: [] as { sessionResource: URI; requests: readonly IRemotePendingRequest[] }[], /** Set by tests that want to mirror remote pending messages into their fake chat model. */ applyRemotePendingRequests: undefined as ((sessionResource: URI, requests: readonly IRemotePendingRequest[]) => void) | undefined, @@ -10124,6 +10128,209 @@ suite('AgentHostChatContribution', () => { }; } + test('uses independent default chat titles for the editor', async () => { + const { sessionHandler, agentHostService, chatService } = createContribution(disposables); + const backendSession = AgentSession.uri('copilot', 'independent-chat-title'); + const sessionResource = URI.from({ scheme: 'agent-host-copilot', path: '/independent-chat-title' }); + const defaultChat = buildDefaultChatUri(backendSession.toString()); + const peerChat = buildChatUri(backendSession.toString(), 'peer'); + const summary: SessionSummary = { + resource: backendSession.toString(), + provider: 'copilot', + title: 'Session title', + status: SessionStatus.Idle, + createdAt: new Date().toISOString(), + modifiedAt: new Date().toISOString(), + }; + agentHostService.sessionStates.set(backendSession.toString(), { + ...createSessionState(summary), + lifecycle: SessionLifecycle.Ready, + defaultChat, + chats: [ + { ...createDefaultChatSummary(summary, defaultChat), title: 'Default chat title' }, + { ...createDefaultChatSummary(summary, peerChat), title: 'Peer chat title' }, + ], + }); + + const chatSession = await sessionHandler.provideChatSessionContent(sessionResource, CancellationToken.None); + disposables.add(toDisposable(() => chatSession.dispose())); + + agentHostService.fireAction({ + channel: backendSession.toString(), + action: { + type: ActionType.SessionChatUpdated, + chat: defaultChat, + changes: { title: 'Renamed default chat' }, + }, + serverSeq: 1, + origin: undefined, + }); + + assert.deepStrictEqual({ + initialTitle: chatSession.title, + titleChanges: chatService.setChatSessionTitleCalls.map(call => ({ + sessionResource: call.sessionResource.toString(), + title: call.title, + })), + }, { + initialTitle: 'Default chat title', + titleChanges: [{ + sessionResource: sessionResource.toString(), + title: 'Renamed default chat', + }], + }); + }); + + test('inherits the session title for an untitled default chat in a multi-chat session', async () => { + const { sessionHandler, agentHostService } = createContribution(disposables); + const backendSession = AgentSession.uri('copilot', 'inherited-default-chat-title'); + const sessionResource = URI.from({ scheme: 'agent-host-copilot', path: '/inherited-default-chat-title' }); + const defaultChat = buildDefaultChatUri(backendSession.toString()); + const peerChat = buildChatUri(backendSession.toString(), 'peer'); + const summary: SessionSummary = { + resource: backendSession.toString(), + provider: 'copilot', + title: 'Session title', + status: SessionStatus.Idle, + createdAt: new Date().toISOString(), + modifiedAt: new Date().toISOString(), + }; + agentHostService.sessionStates.set(backendSession.toString(), { + ...createSessionState(summary), + lifecycle: SessionLifecycle.Ready, + defaultChat, + chats: [ + { ...createDefaultChatSummary(summary, defaultChat), title: '' }, + { ...createDefaultChatSummary(summary, peerChat), title: 'Peer chat title' }, + ], + }); + + const chatSession = await sessionHandler.provideChatSessionContent(sessionResource, CancellationToken.None); + disposables.add(toDisposable(() => chatSession.dispose())); + + assert.strictEqual(chatSession.title, 'Session title'); + }); + + test('does not inherit the session title for an untitled peer selected as the routing default', async () => { + const { sessionHandler, agentHostService } = createContribution(disposables); + const backendSession = AgentSession.uri('copilot', 'peer-routing-title'); + const sessionResource = URI.from({ scheme: 'agent-host-copilot', path: '/peer-routing-title' }); + const canonicalDefaultChat = buildDefaultChatUri(backendSession.toString()); + const peerChat = buildChatUri(backendSession.toString(), 'peer'); + const summary: SessionSummary = { + resource: backendSession.toString(), + provider: 'copilot', + title: 'Session title', + status: SessionStatus.Idle, + createdAt: new Date().toISOString(), + modifiedAt: new Date().toISOString(), + }; + agentHostService.sessionStates.set(backendSession.toString(), { + ...createSessionState(summary), + lifecycle: SessionLifecycle.Ready, + defaultChat: peerChat, + chats: [ + { ...createDefaultChatSummary(summary, canonicalDefaultChat), title: 'Canonical default title' }, + { ...createDefaultChatSummary(summary, peerChat), title: '' }, + ], + }); + + const chatSession = await sessionHandler.provideChatSessionContent(sessionResource, CancellationToken.None); + disposables.add(toDisposable(() => chatSession.dispose())); + + assert.strictEqual(chatSession.title, undefined); + }); + + test('routes editor renames through the addressed default and peer chat channels', async () => { + const { sessionHandler, agentHostService } = createContribution(disposables); + const backendSession = AgentSession.uri('copilot', 'editor-rename-routing'); + const defaultChat = buildDefaultChatUri(backendSession.toString()); + const peerChat = buildChatUri(backendSession.toString(), 'peer'); + const summary: SessionSummary = { + resource: backendSession.toString(), + provider: 'copilot', + title: 'Session title', + status: SessionStatus.Idle, + createdAt: new Date().toISOString(), + modifiedAt: new Date().toISOString(), + }; + agentHostService.sessionStates.set(backendSession.toString(), { + ...createSessionState(summary), + lifecycle: SessionLifecycle.Ready, + defaultChat, + chats: [ + { ...createDefaultChatSummary(summary, defaultChat), title: 'Default title' }, + { ...createDefaultChatSummary(summary, peerChat), title: 'Peer title' }, + ], + }); + const defaultResource = URI.from({ scheme: 'agent-host-copilot', path: '/editor-rename-routing' }); + const peerResource = defaultResource.with({ fragment: 'peer' }); + const defaultSession = await sessionHandler.provideChatSessionContent(defaultResource, CancellationToken.None); + const peerSession = await sessionHandler.provideChatSessionContent(peerResource, CancellationToken.None); + disposables.add(toDisposable(() => defaultSession.dispose())); + disposables.add(toDisposable(() => peerSession.dispose())); + agentHostService.dispatchedActions.length = 0; + + await defaultSession.renameSession?.('Renamed default', CancellationToken.None); + await peerSession.renameSession?.('Renamed peer', CancellationToken.None); + + assert.deepStrictEqual(agentHostService.dispatchedActions.map(({ channel, action }) => ({ channel, action })), [{ + channel: defaultChat, + action: { type: ActionType.SessionTitleChanged, title: 'Renamed default' }, + }, { + channel: peerChat, + action: { type: ActionType.SessionTitleChanged, title: 'Renamed peer' }, + }]); + }); + + test('uses the session title for a sole default chat', async () => { + const { sessionHandler, agentHostService, chatService } = createContribution(disposables); + const backendSession = AgentSession.uri('copilot', 'sole-chat-title'); + const sessionResource = URI.from({ scheme: 'agent-host-copilot', path: '/sole-chat-title' }); + const defaultChat = buildDefaultChatUri(backendSession.toString()); + const summary: SessionSummary = { + resource: backendSession.toString(), + provider: 'copilot', + title: 'Original session title', + status: SessionStatus.Idle, + createdAt: new Date().toISOString(), + modifiedAt: new Date().toISOString(), + }; + agentHostService.sessionStates.set(backendSession.toString(), { + ...createSessionState(summary), + lifecycle: SessionLifecycle.Ready, + defaultChat, + chats: [{ ...createDefaultChatSummary(summary, defaultChat), title: '' }], + }); + + const chatSession = await sessionHandler.provideChatSessionContent(sessionResource, CancellationToken.None); + disposables.add(toDisposable(() => chatSession.dispose())); + + agentHostService.fireAction({ + channel: backendSession.toString(), + action: { + type: ActionType.SessionTitleChanged, + title: 'Renamed session', + }, + serverSeq: 1, + origin: undefined, + }); + + assert.deepStrictEqual({ + initialTitle: chatSession.title, + titleChanges: chatService.setChatSessionTitleCalls.map(call => ({ + sessionResource: call.sessionResource.toString(), + title: call.title, + })), + }, { + initialTitle: 'Original session title', + titleChanges: [{ + sessionResource: sessionResource.toString(), + title: 'Renamed session', + }], + }); + }); + test('syncs queued messages added to restored active sessions idempotently', async () => { const { sessionHandler, agentHostService, chatService } = createContribution(disposables);