Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 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
20 changes: 14 additions & 6 deletions src/vs/platform/agentHost/node/agentHostStateManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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 ?? '',
Expand All @@ -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,
Expand All @@ -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);
Comment thread
dmitrivMS marked this conversation as resolved.
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
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
36 changes: 33 additions & 3 deletions src/vs/platform/agentHost/node/agentSideEffects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,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,
Expand Down Expand Up @@ -1703,13 +1704,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 Expand Up @@ -1910,6 +1914,32 @@ 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 (await ref.object.getMetadata(customChatTitleMetadataKey(chat)) !== undefined) {
return;
}
if (this._stateManager.getChatState(chat)?.title !== title) {
return;
}
await ref.object.setMetadata(customChatTitleMetadataKey(chat), title);
Comment thread
dmitrivMS marked this conversation as resolved.
Outdated
};
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.
*
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
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
},
Expand Down
37 changes: 37 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,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' });
Expand Down
61 changes: 57 additions & 4 deletions src/vs/platform/agentHost/test/node/agentService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9345,6 +9345,16 @@ suite('AgentService (node dispatcher)', () => {
return [];
}

async function waitForMetadata(db: TestSessionDatabase, key: string, expected: string): Promise<void> {
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;
Expand Down Expand Up @@ -9427,6 +9437,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<void> { }
}
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 {
Expand Down Expand Up @@ -10538,6 +10581,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 +10598,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 +10613,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
Loading
Loading