Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
6 changes: 6 additions & 0 deletions src/vs/platform/agentHost/common/sessionDataService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,12 @@ export interface ISessionDatabase extends IDisposable {
*/
setMetadataValues(values: Readonly<Record<string, string>>): Promise<void>;

/**
* 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<Record<string, string>>, copies?: Readonly<Record<string, string>>): Promise<boolean>;

/**
* Store or clear the draft for a chat in this session.
*/
Expand Down
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 @@ -1297,41 +1297,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
38 changes: 35 additions & 3 deletions src/vs/platform/agentHost/node/agentSideEffects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 },
Comment thread
dmitrivMS marked this conversation as resolved.
);
};
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
27 changes: 27 additions & 0 deletions src/vs/platform/agentHost/node/sessionDatabase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -700,6 +700,33 @@ export class SessionDatabase implements ISessionDatabase {
}));
}

setMetadataValuesIfAbsent(key: string, values: Readonly<Record<string, string>>, copies: Readonly<Record<string, string>> = {}): Promise<boolean> {
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<void> {
const chatUri = chat.toString();
return this._track(async () => {
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
18 changes: 18 additions & 0 deletions src/vs/platform/agentHost/test/common/sessionTestHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,24 @@ export class TestSessionDatabase implements ISessionDatabase {
}
}

async setMetadataValuesIfAbsent(key: string, values: Readonly<Record<string, string>>, copies: Readonly<Record<string, string>> = {}): Promise<boolean> {
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<void> {
const key = chat.toString();
if (draft) {
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
Loading
Loading