src/vs/platform/agentHost/node/agentHostSessionTitleController.ts
507 LOC · 473 covered · 34 uncovered · 124 ranges · 1112 concepts · 56 introducers · 521 tests
File neighbourhood
The centred file is linked to every concept that introduces one of its ranges, every test that runs code from the file, and the gray connector concepts standing between those tests and the file's own introducer concepts. Undirected links join concepts to every file where they introduce source and concepts to the tests they introduce; arrows show specialization between the displayed concepts and bridge only concepts omitted from this view. Concept colors match the source ranges below; connector concepts have no source color and are shown in gray.
Focused file, its introducer and connector concepts, their introduced files, and tests that run code from the file
In the embedded map, ordinary wheel input scrolls the page; use the visible controls to zoom and drag to pan. Open the full-screen map for canvas navigation: wheel pans, Ctrl/Command plus wheel zooms, and arrow keys pan when this region is focused. On touch screens, open the full-screen map to pan or pinch. If JavaScript or WebGL is unavailable, use the related-file, concept, and source links on this page.
Graph controls are ready.
Interactive rendering requires JavaScript and WebGL. Use the related-file, concept, and source links on this page while the interactive map is unavailable.
/*---------------------------------------------------------------------------------------------
agentHostSessionTitleController.ts ×25
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { CancellationToken, CancellationTokenSource } from '../../../base/common/cancellation.js';
import { Disposable } from '../../../base/common/lifecycle.js';
import { URI } from '../../../base/common/uri.js';
import { ILogService } from '../../log/common/log.js';
import { ISessionDataService } from '../common/sessionDataService.js';
import { ActionType } from '../common/state/sessionActions.js';
import { isAhpChatChannel, isDefaultChatUri, type Turn, type URI as ProtocolURI } from '../common/state/sessionState.js';
import { buildConversationContext, renderResponseMarkdown, truncateMiddle } from '../common/agentHostConversationContext.js';
import { AgentHostStateManager } from './agentHostStateManager.js';
import { ICopilotApiService, type ICopilotUtilityChatMessage } from './shared/copilotApiService.js';
const MAX_TITLE_LENGTH = 200;
/**
* Soft upper bound, in characters, for the first-turn context fed to the
* utility model when refining a session title. Sized to stay well within the
* small model's context window while leaving room for the prompt scaffolding.
*/
const MAX_TITLE_CONTEXT_CHARS = 20000;
export interface IAgentHostSessionTitleControllerOptions {
readonly sessionDataService: ISessionDataService;
readonly getGitHubCopilotToken?: () => string | undefined;
readonly copilotApiService?: ICopilotApiService;
}
export class AgentHostSessionTitleController extends Disposable {
private readonly _titleGenerationCancellationSources = new Map<ProtocolURI, CancellationTokenSource>();
/**
* The most recent title this controller applied for a given session/chat
* key. Used to detect whether the title was changed (e.g. a manual
* `/rename` or user edit) since we last set it, so we never clobber a
* deliberate title with an auto-generated one.
*/
private readonly _lastAppliedTitle = new Map<ProtocolURI, string>();
/**
* Session/chat keys whose current title is a provisional placeholder set by
* {@link seedProvisionalTitle} (e.g. from a `!command`). Such a title does
* not describe the session's topic, so the first subsequent request that
* carries real intent replaces it with a generated title via
* {@link seedTitleFromFirstMessage}.
*/
private readonly _provisionalTitles = new Set<ProtocolURI>();
constructor(
private readonly _options: IAgentHostSessionTitleControllerOptions,
@ILogService private readonly _logService: ILogService,
) {
super();
}
seedTitleFromFirstMessage(channel: ProtocolURI, userPrompt: string, chatChannel?: ProtocolURI): void {
if (!fallbackTitle) {
}
const additionalChat = this._additionalChatChannel(chatChannel);
const key = additionalChat ?? channel;
const state = additionalChat ? this._stateManager.getChatState(additionalChat) : this._stateManager.getSessionState(channel);
agentHostSessionTitleController.ts ×3
if (!state || !this._canSeedFirstMessageTitle(key, state.turns.length, state.title)) {
}
const replacesProvisionalTitle = this._provisionalTitles.has(key);
agentHostSessionTitleController.ts ×2
this._provisionalTitles.delete(key);
this._applySeedTitle(channel, additionalChat, fallbackTitle);
if (replacesProvisionalTitle) {
this._persistSeedTitle(channel, additionalChat, fallbackTitle);
agentHostSessionTitleController.ts ×1
}
key,
userPrompt,
false,
fallbackTitle,
title => this._applySeedTitle(channel, additionalChat, title),
() => this._currentSeedTitle(channel, additionalChat) === this._lastAppliedTitle.get(key),
title => this._persistSeedTitle(channel, additionalChat, title),
);
/** Seeds and persists a provisional title suggested by a locally handled command. */
seedProvisionalTitle(channel: ProtocolURI, suggestedTitle: string, chatChannel?: ProtocolURI): void {
if (!title) {
}
const additionalChat = this._additionalChatChannel(chatChannel);
const key = additionalChat ?? channel;
const state = additionalChat ? this._stateManager.getChatState(additionalChat) : this._stateManager.getSessionState(channel);
agentHostSessionTitleController.ts ×3
if (!state || !this._canSeedProvisionalTitle(key, state.title)) {
}
this._applySeedTitle(channel, additionalChat, title);
this._persistSeedTitle(channel, additionalChat, title);
/** Trims, collapses whitespace, and length-caps a candidate title. */
private _normalizeTitle(text: string): string {
return text.trim().replace(/\s+/g, ' ').slice(0, MAX_TITLE_LENGTH);
agentHostSessionTitleController.ts ×1
}
/**
* The peer (additional) chat a seed should title, or `undefined` to title
* the session itself. The default chat maps to the session.
*/
private _additionalChatChannel(chatChannel?: ProtocolURI): ProtocolURI | undefined {
return !!chatChannel && isAhpChatChannel(chatChannel) && !isDefaultChatUri(chatChannel) ? chatChannel : undefined;
agentHostSessionTitleController.ts ×1
}
/**
* Applies `title` to the addressed peer chat (`additionalChat`) or, when
* that is `undefined`, to the session itself, recording it as last-applied.
*/
private _applySeedTitle(channel: ProtocolURI, additionalChat: ProtocolURI | undefined, title: string): void {
this._applyTitle(additionalChat, title, t => this._stateManager.updateChatTitle(channel, additionalChat, t));
agentHostSessionTitleController.ts ×1
this._applyTitle(channel, title, t => this._stateManager.dispatchServerAction(channel, {
agentHostSessionTitleController.ts ×1
type: ActionType.SessionTitleChanged,
title: t,
}));
}
/** Persists `title` as the custom title of the addressed peer chat or session. */
private _persistSeedTitle(channel: ProtocolURI, additionalChat: ProtocolURI | undefined, title: string): void {
this._persistSessionFlag(channel, additionalChat ? `customChatTitle:${additionalChat}` : 'customTitle', title);
agentHostSessionTitleController.ts ×1
}
/** The live title of the addressed peer chat or session. */
private _currentSeedTitle(channel: ProtocolURI, additionalChat: ProtocolURI | undefined): string | undefined {
return additionalChat ? this._stateManager.getChatState(additionalChat)?.title : this._stateManager.getSessionState(channel)?.title;
agentHostSessionTitleController.ts ×1
}
/**
* Whether {@link seedTitleFromFirstMessage} may (re)title `key`: true for a
* fresh, untitled target (its first message) or when its title is a
* provisional placeholder we applied and no one has changed it since — the
* first real request supersedes the placeholder.
*/
private _canSeedFirstMessageTitle(key: ProtocolURI, turnsLength: number, currentTitle: string | undefined): boolean {
}
return this._provisionalTitles.has(key) && !!currentTitle && currentTitle === this._lastAppliedTitle.get(key);
agentHostSessionTitleController.ts ×2
}
/**
* Whether {@link seedProvisionalTitle} may (re)title `key`: true when it is
* untitled (the first message carried a suggestion) or when its title is a
* provisional placeholder we applied and no one has changed it since —
* successive suggestions keep the newest one visible without clobbering a
* manual rename.
*/
private _canSeedProvisionalTitle(key: ProtocolURI, currentTitle: string | undefined): boolean {
}
return this._provisionalTitles.has(key) && currentTitle === this._lastAppliedTitle.get(key);
agentHostSessionTitleController.ts ×1
/**
* Re-generates the title once the first turn has completed, this time
* using the full first-turn context (the user request plus the agent's
* textual response) rather than just the opening message. This only runs
* for the very first turn and only when the current title is still the one
* this controller last applied — a manual `/rename`, a user edit, or a
* forked session's inherited title all suppress it.
*
* Only normal text response parts are considered (tool calls, reasoning,
* and other parts are ignored). If the context still exceeds the budget
* the middle is removed (marked with `...`). The user's first request is
* always preserved.
*/
refineTitleFromFirstTurn(channel: ProtocolURI, chatChannel?: ProtocolURI): void {
const isAdditionalChat = !!chatChannel && isAhpChatChannel(chatChannel) && !isDefaultChatUri(chatChannel);
agentHostSessionTitleController.ts ×4
if (isAdditionalChat) {
const chatState = this._stateManager.getChatState(chatChannel);
agentHostSessionTitleController.ts ×2
if (!chatState || chatState.turns.length !== 1) {
return;
}
const lastApplied = this._lastAppliedTitle.get(chatChannel);
agentHostSessionTitleController.ts ×2
if (lastApplied === undefined || chatState.title !== lastApplied) {
return;
}
const context = this._buildFirstTurnContext(chatState.turns[0]);
if (!context) {
return;
}
const apply = (title: string) => this._applyTitle(chatChannel, title, t => this._stateManager.updateChatTitle(channel, chatChannel, t));
this._generateTitleSoon(
chatChannel,
context,
true,
lastApplied,
apply,
() => this._stateManager.getChatState(chatChannel)?.title === this._lastAppliedTitle.get(chatChannel),
title => this._persistSessionFlag(channel, `customChatTitle:${chatChannel}`, title),
);
return;
}
const state = this._stateManager.getSessionState(channel);
}
const lastApplied = this._lastAppliedTitle.get(channel);
agentHostSessionTitleController.ts ×1
if (lastApplied === undefined || state.title !== lastApplied) {
agentHostSessionTitleController.ts ×4
}
const context = this._buildFirstTurnContext(state.turns[0]);
agentHostSessionTitleController.ts ×3
if (!context) {
}
const apply = (title: string) => this._applyTitle(channel, title, t => this._stateManager.dispatchServerAction(channel, {
agentHostSessionTitleController.ts ×3
type: ActionType.SessionTitleChanged,
title: t,
}));
this._generateTitleSoon(
channel,
context,
true,
lastApplied,
apply,
() => this._stateManager.getSessionState(channel)?.title === this._lastAppliedTitle.get(channel),
title => this._persistSessionFlag(channel, 'customTitle', title),
);
/**
* Generates a title for a freshly forked session or chat from its
* inherited conversation context. Forks copy the source history up to the
* fork point, so neither {@link seedTitleFromFirstMessage} nor
* {@link refineTitleFromFirstTurn} (which require an empty / single-turn
* state) ever fire for them. This is the fork equivalent, run once at fork
* time over the kept turns, so the new chat gets a content-derived title
* instead of permanently inheriting the source's `Forked: …` title.
*
* `fallbackTitle` is the title the caller already applied to the new
* session/chat (e.g. `Forked: <source>`); it is recorded as the
* last-applied title so a concurrent manual rename suppresses the
* generated title, and stays visible until generation completes. The
* context is bounded to {@link MAX_TITLE_CONTEXT_CHARS} (middle-truncated),
* so generation costs at most a single small-model call.
*/
generateForkedTitle(channel: ProtocolURI, chatChannel: ProtocolURI | undefined, turns: readonly Turn[], fallbackTitle: string, sourceTitle?: string): void {
const context = this._buildConversationContext(turns, sourceTitle);
agentHostSessionTitleController.ts ×5
if (!context) {
return;
}
const isAdditionalChat = !!chatChannel && isAhpChatChannel(chatChannel) && !isDefaultChatUri(chatChannel);
if (isAdditionalChat) {
this._lastAppliedTitle.set(key, fallbackTitle);
const apply = (title: string) => this._applyTitle(key, title, t => this._stateManager.updateChatTitle(channel, key, t));
this._generateTitleSoon(
key,
context,
true,
fallbackTitle,
apply,
() => this._stateManager.getChatState(key)?.title === this._lastAppliedTitle.get(key),
title => this._persistSessionFlag(channel, `customChatTitle:${key}`, title),
);
return;
}
this._lastAppliedTitle.set(channel, fallbackTitle);
const apply = (title: string) => this._applyTitle(channel, title, t => this._stateManager.dispatchServerAction(channel, {
title: t,
}));
channel,
context,
true,
fallbackTitle,
apply,
() => this._stateManager.getSessionState(channel)?.title === this._lastAppliedTitle.get(channel),
title => this._persistSessionFlag(channel, 'customTitle', title),
);
private _applyTitle(key: ProtocolURI, title: string, dispatch: (title: string) => void): void {
dispatch(title);
}
cancelTitleGeneration(session: ProtocolURI): void {
}
private _generateTitleSoon(
promptContent: string,
isConversation: boolean,
fallbackTitle: string,
apply: (title: string) => void,
currentTitleMatchesFallback: () => boolean,
persist: (title: string) => void,
): void {
this._cancelTitleGeneration(key);
const source = new CancellationTokenSource();
this._titleGenerationCancellationSources.set(key, source);
void this._generateTitle(key, promptContent, isConversation, fallbackTitle, apply, currentTitleMatchesFallback, persist, source.token).catch(err => {
if (!source.token.isCancellationRequested) {
this._logService.warn(`[AgentHostSessionTitleController] Failed to apply generated title for ${key}`, err);
}
if (this._titleGenerationCancellationSources.get(key) === source) {
source.dispose();
}
}
private async _generateTitle(
promptContent: string,
isConversation: boolean,
fallbackTitle: string,
apply: (title: string) => void,
currentTitleMatchesFallback: () => boolean,
persist: (title: string) => void,
token: CancellationToken,
): Promise<void> {
const generatedTitle = await this._generateTitleFromPrompt(promptContent, isConversation, token);
if (token.isCancellationRequested || !generatedTitle) {
}
if (!currentTitleMatchesFallback()) {
}
if (generatedTitle !== fallbackTitle) {
apply(generatedTitle);
}
persist(generatedTitle);
private async _generateTitleFromPrompt(promptContent: string, isConversation: boolean, token: CancellationToken): Promise<string | undefined> {
return undefined;
}
const githubToken = this._options.getGitHubCopilotToken?.();
const copilotApiService = this._options.copilotApiService;
if (!githubToken || !copilotApiService) {
}
const abortController = new AbortController();
const cancellationListener = token.onCancellationRequested(() => abortController.abort());
try {
const rawTitle = await copilotApiService.utilityChatCompletion(githubToken, {
messages: this._buildTitlePrompt(promptContent, isConversation),
}, {
signal: abortController.signal,
});
return undefined;
}
this._logService.warn('[AgentHostSessionTitleController] Failed to generate session title', err);
agentHostSessionTitleController.ts ×2
return undefined;
cancellationListener.dispose();
}
private _buildTitlePrompt(promptContent: string, isConversation: boolean): ICopilotUtilityChatMessage[] {
? `Please write a brief title for the following conversation:\n\n${promptContent}`
agentHostSessionTitleController.ts ×1
: `Please write a brief title for the following request:\n\n${promptContent}`;
agentHostSessionTitleController.ts ×1
{
role: 'system',
content: [
'You are an expert in crafting ultra-compact titles for chatbot conversations.',
'You are presented with a chat request or conversation, and you reply with only a brief title that captures the main topic.',
'Write the title in sentence case, not title case.',
'Preserve product names, abbreviations, code symbols, and proper nouns.',
'Aim for 3-6 words. Prefer the shortest accurate title.',
'Drop articles like "a", "an", and "the" unless needed for clarity.',
'Drop filler and generic framing like "help with", "question about", "request for", or "issue with".',
'Never describe the chat itself as forked, branched, or continued — title only the underlying topic.',
'Prefer short, concrete synonyms and omit unnecessary words.',
'Do not wrap the title in quotes or add trailing punctuation.',
].join(' '),
},
{
role: 'user',
content: userInstruction,
},
];
}
private _cleanTitle(rawTitle: string): string | undefined {
const firstLine = title.split(/\r?\n/).map(line => line.trim()).find(line => line.length > 0);
title = firstLine ?? '';
if (title.startsWith('"') && title.endsWith('"') && title.length > 1) {
}
if (!title || title.includes('can\'t assist with that')) {
return undefined;
}
}
/**
* Builds the first-turn context string for title refinement. The user's
* request is always kept (truncated in the middle only if it alone exceeds
* half the budget). Only normal text (markdown) response parts are
* considered — tool calls, reasoning, and other parts are ignored. If the
* combined text is over budget, the middle of the response is removed.
*
* @returns the context string, or `undefined` when the turn has no text
* response worth refining from (the opening message already produced a
* title in that case).
*/
private _buildFirstTurnContext(turn: Turn): string | undefined {
const response = renderResponseMarkdown(turn.responseParts);
agentHostSessionTitleController.ts ×3
if (!response) {
}
const userBudget = Math.floor(MAX_TITLE_CONTEXT_CHARS / 2);
let userRequest = turn.message.text.trim();
if (userRequest.length > userBudget) {
userRequest = truncateMiddle(userRequest, userBudget);
}
const responseLabel = '\n\nAgent response:\n';
const responseBudget = Math.max(0, MAX_TITLE_CONTEXT_CHARS - userBlock.length - responseLabel.length);
const trimmedResponse = response.length > responseBudget ? truncateMiddle(response, responseBudget) : response;
agentHostSessionTitleController.ts ×3
return trimmedResponse ? `${userBlock}${responseLabel}${trimmedResponse}` : userBlock;
}
/**
* Builds a conversation context string for forked-title generation by
* concatenating each kept turn's user request and textual response. Only
* normal text (markdown) response parts are considered — tool calls,
* reasoning, and other parts are ignored, mirroring
* {@link _buildFirstTurnContext}. When the fork's `sourceTitle` is known, a
* short framing note is prepended so the model understands the conversation
* is a branch continued from an earlier chat. The conversation is
* middle-truncated to {@link MAX_TITLE_CONTEXT_CHARS} to bound model cost;
* the framing note is always preserved in full.
*
* @returns the context string, or `undefined` when no turn carries any
* text worth titling from.
*/
private _buildConversationContext(turns: readonly Turn[], sourceTitle?: string): string | undefined {
const framing = framedTitle
? `This conversation was branched from an earlier chat titled "${framedTitle}". The turns below, oldest first, are the inherited history up to the branch point.\n\n`
agentHostSessionTitleController.ts ×1
return buildConversationContext(turns, { maxChars: MAX_TITLE_CONTEXT_CHARS, framing });
agentHostSessionTitleController.ts ×5
}
private _persistSessionFlag(session: ProtocolURI, key: string, value: string): void {
const ref = this._options.sessionDataService.openDatabase(URI.parse(session));
agentHostSessionTitleController.ts ×2
ref.object.setMetadata(key, value).catch(err => {
this._logService.warn(`[AgentHostSessionTitleController] Failed to persist ${key}`, err);
ref.dispose();
});
}
private _cancelTitleGeneration(session: ProtocolURI): void {
const source = this._titleGenerationCancellationSources.get(session);
agentHostSessionTitleController.ts ×2
if (!source) {
return;
}
this._titleGenerationCancellationSources.delete(session);
override dispose(): void {
for (const source of this._titleGenerationCancellationSources.values()) {
agentHostSessionTitleController.ts ×3
source.dispose(true);
}
this._lastAppliedTitle.clear();
this._provisionalTitles.clear();
super.dispose();
}