Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 11 additions & 6 deletions src/vs/platform/agentHost/node/agentHostStateManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1021,11 +1021,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),
Expand Down Expand Up @@ -1067,6 +1063,7 @@ export class AgentHostStateManager extends Disposable {
}
return existing;
}
this._snapshotDefaultChatTitle(session, sessionState);
const chatSummary: ChatSummary = {
...createDefaultChatSummary(this._toSummary(session, entry), chatUri),
title: options.title ?? '',
Expand All @@ -1077,7 +1074,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,
Expand All @@ -1089,6 +1086,14 @@ export class AgentHostStateManager extends Disposable {
return chatSummary;
}

private _snapshotDefaultChatTitle(session: URI, state: SessionState): void {
const defaultChat = state.defaultChat ?? buildDefaultChatUri(session);
Comment thread
dmitrivMS marked this conversation as resolved.
Outdated
const summary = state.chats.find(chat => chat.resource === defaultChat);
if (summary && !summary.title && state.title) {
this.updateChatTitle(session, defaultChat, state.title);
Comment thread
dmitrivMS marked this conversation as resolved.
}
}

/**
* Removes an additional chat from a session. Deletes its
* {@link ChatState}, dispatches {@link ActionType.SessionChatRemoved}, and
Expand Down
33 changes: 12 additions & 21 deletions src/vs/platform/agentHost/node/agentService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1231,41 +1231,32 @@ export class AgentService extends Disposable implements IAgentService {
private async _renameChatFromTool(session: URI, chat: URI, title: string): Promise<IRenameTitleResult> {
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.`);
}

await persistSessionMetadataValues(this._sessionDataService, session.toString(), {
[customChatTitleMetadataKey(chat.toString())]: title,
[customChatTitleSourceMetadataKey(chat.toString())]: AGENT_HOST_TITLE_SOURCE_AGENT,
...(isDefaultChat ? {
[SESSION_CUSTOM_TITLE_KEY]: title,
Comment thread
dmitrivMS marked this conversation as resolved.
[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<boolean> {
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<boolean> {
if (this._stateManager.getSessionState(session.toString())?.chats.some(candidate => candidate.resource === chat.toString())) {
return true;
Expand Down
9 changes: 6 additions & 3 deletions src/vs/platform/agentHost/node/agentSideEffects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1703,13 +1703,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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
16 changes: 16 additions & 0 deletions src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1136,6 +1136,22 @@ 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('addChat is idempotent for an existing chat URI', () => {
manager.createSession(makeSessionSummary());
const first = manager.addChat(sessionUri, peerChat, { title: 'Peer' });
Expand Down
18 changes: 14 additions & 4 deletions src/vs/platform/agentHost/test/node/agentService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10538,6 +10538,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<string>();
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',
Expand All @@ -10547,7 +10555,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,
Expand All @@ -10562,19 +10570,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',
});
});

Expand Down
69 changes: 69 additions & 0 deletions src/vs/platform/agentHost/test/node/agentSideEffects.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
});

Expand Down Expand Up @@ -4913,6 +4938,50 @@ 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<readonly IAgent[]>('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('handleListSessions returns persisted custom title', async () => {
const sessionDataService = createSessionDataService(sessionDb);
const localAgent = new MockAgent();
Expand Down
Loading
Loading