Skip to content
Draft
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
1 change: 1 addition & 0 deletions src/vs/platform/agentHost/node/agentService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -986,6 +986,7 @@ export class AgentService extends Disposable implements IAgentService {
? { ...(options.title !== undefined ? { title: options.title } : {}), ...(options.model !== undefined ? { model: options.model } : {}) }
: undefined),
renameChat: (session, chat, title) => this._renameChatFromTool(session, chat, title),
reportToolError: (toolName, error) => this._logService.error(`[AgentService] ${toolName} failed after the tool returned: ${toErrorMessage(error)}`),
deleteSession: session => this.disposeSession(session),
getChatContext: (session, chatId) => this._getChatContext(session, chatId),
// Reads the `create_session` spawn depth from a session's `_meta` (0 when absent).
Expand Down
25 changes: 18 additions & 7 deletions src/vs/platform/agentHost/node/shared/sessionServerTools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import { URI } from '../../../../base/common/uri.js';
import { Sequencer } from '../../../../base/common/async.js';
import type { Mutable } from '../../../../base/common/types.js';
import { URI } from '../../../../base/common/uri.js';
import { localize } from '../../../../nls.js';
import { AgentSession, type AgentProvider, type IAgentCreateSessionConfig, type IAgentModelInfo, type IAgentSessionMetadata } from '../../common/agent.js';
import { SessionStatus } from '../../common/state/protocol/channels-session/state.js';
Expand Down Expand Up @@ -161,7 +162,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. 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. The rename is applied asynchronously, so this tool returns before persistence completes.',
inputSchema: renameChatInputSchema,
annotations: { readOnlyHint: false },
},
Expand Down Expand Up @@ -216,6 +217,7 @@ export interface ISessionServerToolAccessor {
readonly startPrompt: (session: URI, chat: URI, prompt: string) => Promise<void>;
readonly createChat: (session: URI, chat: URI, options?: { title?: string; model?: ModelSelection }) => Promise<void>;
readonly renameChat: (session: URI, chat: URI, title: string) => Promise<IRenameTitleResult>;
readonly reportToolError: (toolName: SessionServerToolName, error: unknown) => void;
readonly deleteSession: (session: URI) => Promise<void>;
/** Reads a point-in-time snapshot of a session's chat conversation (default chat, or a specific chat by id). */
readonly getChatContext: (session: URI, chatId?: string) => Promise<IChatContextSnapshot | undefined>;
Expand All @@ -229,6 +231,8 @@ export interface IRenameTitleResult {
readonly title: string;
}

const renameChatSequencers = new WeakMap<ISessionServerToolAccessor, Sequencer>();

export interface ISessionCreationDefaults {
readonly provider?: AgentProvider;
readonly model?: ModelSelection;
Expand Down Expand Up @@ -856,10 +860,17 @@ export function getRenameChatArgs(rawArgs: unknown, sessions: readonly IAgentSes
}

export async function applyRenameChatTool(accessor: ISessionServerToolAccessor, rawArgs: unknown, currentChannel?: ProtocolURI): Promise<string> {
const sessions = await accessor.listSessions();
const { session, chat, title } = getRenameChatArgs(rawArgs, sessions, currentChannel);
const result = await accessor.renameChat(session, chat, title);
return `Renamed chat to "${result.title}".`;
let sequencer = renameChatSequencers.get(accessor);
if (!sequencer) {
sequencer = new Sequencer();
renameChatSequencers.set(accessor, sequencer);
}
void sequencer.queue(async () => {
const sessions = await accessor.listSessions();
const { session, chat, title } = getRenameChatArgs(rawArgs, sessions, currentChannel);
await accessor.renameChat(session, chat, title);
}).catch(error => accessor.reportToolError(SessionServerToolName.RenameChat, error));
return 'Renaming chat.';
}

interface ISendMessageArgs {
Expand Down Expand Up @@ -1158,7 +1169,7 @@ function getSessionToolDisplay(toolName: string, _args: unknown, _result?: IServ
return {
displayName: localize('toolName.renameChat', "Rename Chat"),
invocationMessage: localize('toolInvoke.renameChat', "Renaming chat"),
pastTenseMessage: localize('toolComplete.renameChat', "Updated chat name"),
pastTenseMessage: localize('toolComplete.renameChat', "Requested chat rename"),
};
case SessionServerToolName.SendMessage:
return {
Expand Down
56 changes: 37 additions & 19 deletions src/vs/platform/agentHost/test/node/agentService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9247,6 +9247,17 @@ suite('AgentService (node dispatcher)', () => {

suite('rename server tools', () => {
test('rename_chat replaces live and persisted default and peer chat titles', async () => {
class RecordingTitleDatabase extends TestSessionDatabase {
readonly finalRenamePersisted = new DeferredPromise<void>();
finalRenameKey: string | undefined;

override async setMetadataValues(values: Readonly<Record<string, string>>): Promise<void> {
await super.setMetadataValues(values);
if (this.finalRenameKey && values[this.finalRenameKey] === 'Complete replacement peer chat title') {
await this.finalRenamePersisted.complete();
}
}
}
class ServerToolAgent extends MockAgent {
serverToolHost: IAgentServerToolHost | undefined;

Expand All @@ -9255,7 +9266,7 @@ suite('AgentService (node dispatcher)', () => {
}
}

const db = new TestSessionDatabase();
const db = new RecordingTitleDatabase();
const localService = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService()));
localService.configurationService.updateRootConfig({ [AgentHostActiveAgentTitleGenerationConfigKey]: true });
const agent = disposables.add(new ServerToolAgent('copilot'));
Expand All @@ -9264,6 +9275,7 @@ suite('AgentService (node dispatcher)', () => {
const sessionUri = session.toString();
const defaultChat = buildDefaultChatUri(session);
const peerChat = buildChatUri(sessionUri, 'peer-rename');
db.finalRenameKey = `customChatTitle:${peerChat}`;
localService.stateManager.dispatchServerAction(sessionUri, { type: ActionType.SessionTitleChanged, title: 'Previous user title' });
await db.setMetadata('customTitle', 'Previous user title');
await db.setMetadata('customTitleSource', 'user');
Expand All @@ -9286,6 +9298,8 @@ suite('AgentService (node dispatcher)', () => {
chat: `agent-host-session://copilot/${AgentSession.id(session)}?chat=peer-rename`,
title: 'Complete replacement peer chat title',
});
await db.finalRenamePersisted.p;
await timeout(0);

assert.deepStrictEqual({
singleChatResult,
Expand All @@ -9301,9 +9315,9 @@ suite('AgentService (node dispatcher)', () => {
persistedChatTitle: await db.getMetadata(`customChatTitle:${peerChat}`),
persistedChatSource: await db.getMetadata(`customChatTitleSource:${peerChat}`),
}, {
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".',
singleChatResult: 'Renaming chat.',
multiChatDefaultResult: 'Renaming chat.',
chatResult: 'Renaming chat.',
liveSessionTitle: 'Multi-chat session title',
liveDefaultChatTitle: 'Complete replacement default chat title',
liveChatTitle: 'Complete replacement peer chat title',
Expand All @@ -9318,8 +9332,14 @@ suite('AgentService (node dispatcher)', () => {

test('rename failures preserve live state and both persisted metadata values', async () => {
class FailingTitleDatabase extends TestSessionDatabase {
readonly allFailuresObserved = new DeferredPromise<void>();
private failureCount = 0;

override async setMetadataValues(values: Readonly<Record<string, string>>): Promise<void> {
if (Object.keys(values).some(key => key.startsWith('customTitle') || key.startsWith('customChatTitle'))) {
if (++this.failureCount === 3) {
await this.allFailuresObserved.complete();
}
throw new Error('title persistence failed');
}
return super.setMetadataValues(values);
Expand All @@ -9346,29 +9366,24 @@ suite('AgentService (node dispatcher)', () => {
await db.setMetadata('customTitle', 'Original session');
await db.setMetadata('customTitleSource', 'user');

await assert.rejects(
async () => agent.serverToolHost!.executeTool(defaultChat, SessionServerToolName.RenameChat, { title: 'Session-backed title will fail' }),
/title persistence failed/,
);
const sessionResult = await agent.serverToolHost!.executeTool(defaultChat, SessionServerToolName.RenameChat, { title: 'Session-backed title will fail' });

localService.stateManager.addChat(sessionUri, peerChat, { title: 'Original chat' });
await db.setMetadata(`customChatTitle:${defaultChat}`, 'Original session');
await db.setMetadata(`customChatTitleSource:${defaultChat}`, 'user');
await db.setMetadata(`customChatTitle:${peerChat}`, 'Original chat');
await db.setMetadata(`customChatTitleSource:${peerChat}`, 'user');

await assert.rejects(
async () => agent.serverToolHost!.executeTool(defaultChat, SessionServerToolName.RenameChat, { title: 'Chat-backed title will fail' }),
/title persistence failed/,
);
await assert.rejects(
async () => agent.serverToolHost!.executeTool(buildDefaultChatUri(session), SessionServerToolName.RenameChat, {
chat: `agent-host-session://copilot/${AgentSession.id(session)}?chat=peer-failure`,
title: 'Chat will fail',
}),
/title persistence failed/,
);
const defaultChatResult = await agent.serverToolHost!.executeTool(defaultChat, SessionServerToolName.RenameChat, { title: 'Chat-backed title will fail' });
const peerChatResult = await agent.serverToolHost!.executeTool(buildDefaultChatUri(session), SessionServerToolName.RenameChat, {
chat: `agent-host-session://copilot/${AgentSession.id(session)}?chat=peer-failure`,
title: 'Chat will fail',
});
await db.allFailuresObserved.p;
assert.deepStrictEqual({
sessionResult,
defaultChatResult,
peerChatResult,
liveSession: localService.stateManager.getSessionState(sessionUri)?.title,
sessionTitle: await db.getMetadata('customTitle'),
sessionSource: await db.getMetadata('customTitleSource'),
Expand All @@ -9379,6 +9394,9 @@ suite('AgentService (node dispatcher)', () => {
chatTitle: await db.getMetadata(`customChatTitle:${peerChat}`),
chatSource: await db.getMetadata(`customChatTitleSource:${peerChat}`),
}, {
sessionResult: 'Renaming chat.',
defaultChatResult: 'Renaming chat.',
peerChatResult: 'Renaming chat.',
liveSession: 'Original session',
sessionTitle: 'Original session',
sessionSource: 'user',
Expand Down
Loading
Loading