agentHostSessionTitleController.ts ×25

Frontier kind: Code frontier

unlabeled · c_a02e63d65f00

521 tests · 19474 LOC · 86 files · introduces 0 tests · 179 LOC · 1 file

Introduces — evidence that enters the hierarchy at this concept

Code
25 ranges179 lines · 1 files
Tests
0 tests

Contains — complete concept membership

All code (extent)
1822 ranges19474 lines · 86 files · Browse complete extent
All tests (intent)
521 testsBrowse complete intent

Neighbourhood graph

The orange circle is the focus. Violet and green circles are every ancestor and descendant, broader and narrower, at any distance; blue squares and pink diamonds are the introduced files and exact introduced tests of every visible concept, not only the focus's. Arrows point from broader to narrower concepts and bridge only concepts omitted from this view. Undirected links show source or test introduction. Concept and file size follows LOC; exact test nodes use test-count units.

Introduced files, introduced tests, and structurally relevant concept specialization

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 native relationship evidence on this page.

Graph controls are ready.

Interactive rendering requires JavaScript and WebGL. Use the native relationship evidence on this page while the interactive map is unavailable.

Native relationship evidence

Every exact file and test below is linked only from the concept that introduces it.

Introduced tests

Every collected test enters the hierarchy at exactly one concept.

No tests are introduced at this concept. Its intent tests are introduced by other concepts.

Introduced code

Every collected source range enters the hierarchy at exactly one concept.

1 file ranked by introduced lines: 179 introduced LOC across 25 ranges. Expand a file to inspect source; the > gutter marks introduced lines.

src/vs/platform/agentHost/node/agentHostSessionTitleController.ts 179 introduced LOC · 25 ranges

Open complete file

1 > /*--------------------------------------------------------------------------------------------- agentHostSessionTitleController.ts
2 > * Copyright (c) Microsoft Corporation. All rights reserved.
3 > * Licensed under the MIT License. See License.txt in the project root for license information.
4 > *--------------------------------------------------------------------------------------------*/
5 >
6 > import { CancellationToken, CancellationTokenSource } from '../../../base/common/cancellation.js';
7 > import { Disposable } from '../../../base/common/lifecycle.js';
8 > import { URI } from '../../../base/common/uri.js';
9 > import { ILogService } from '../../log/common/log.js';
10 > import { ISessionDataService } from '../common/sessionDataService.js';
11 > import { ActionType } from '../common/state/sessionActions.js';
12 > import { isAhpChatChannel, isDefaultChatUri, type Turn, type URI as ProtocolURI } from '../common/state/sessionState.js';
13 > import { buildConversationContext, renderResponseMarkdown, truncateMiddle } from '../common/agentHostConversationContext.js';
14 > import { AgentHostStateManager } from './agentHostStateManager.js';
15 > import { ICopilotApiService, type ICopilotUtilityChatMessage } from './shared/copilotApiService.js';
16 >
17 > const MAX_TITLE_LENGTH = 200;
18 >
19 > /**
20 > * Soft upper bound, in characters, for the first-turn context fed to the
21 > * utility model when refining a session title. Sized to stay well within the
22 > * small model's context window while leaving room for the prompt scaffolding.
23 > */
24 > const MAX_TITLE_CONTEXT_CHARS = 20000;
25 >
26 > export interface IAgentHostSessionTitleControllerOptions {
27 > readonly sessionDataService: ISessionDataService;
28 > readonly getGitHubCopilotToken?: () => string | undefined;
29 > readonly copilotApiService?: ICopilotApiService;
30 > }
31 >
32 > export class AgentHostSessionTitleController extends Disposable {
33 >
34 > private readonly _titleGenerationCancellationSources = new Map<ProtocolURI, CancellationTokenSource>();
35 >
36 > /**
37 > * The most recent title this controller applied for a given session/chat
38 > * key. Used to detect whether the title was changed (e.g. a manual
39 > * `/rename` or user edit) since we last set it, so we never clobber a
40 > * deliberate title with an auto-generated one.
41 > */
42 > private readonly _lastAppliedTitle = new Map<ProtocolURI, string>();
43 >
44 > /**
45 > * Session/chat keys whose current title is a provisional placeholder set by
46 > * {@link seedProvisionalTitle} (e.g. from a `!command`). Such a title does
47 > * not describe the session's topic, so the first subsequent request that
48 > * carries real intent replaces it with a generated title via
49 > * {@link seedTitleFromFirstMessage}.
50 > */
51 > private readonly _provisionalTitles = new Set<ProtocolURI>();
52 >
53 > constructor(
54 private readonly _stateManager: AgentHostStateManager,
55 private readonly _options: IAgentHostSessionTitleControllerOptions,
58 super();
59 }
61 > seedTitleFromFirstMessage(channel: ProtocolURI, userPrompt: string, chatChannel?: ProtocolURI): void {
62 const fallbackTitle = this._normalizeTitle(userPrompt);
63 if (!fallbackTitle) {
87 );
88 }
90 > /** Seeds and persists a provisional title suggested by a locally handled command. */
91 > seedProvisionalTitle(channel: ProtocolURI, suggestedTitle: string, chatChannel?: ProtocolURI): void {
92 const title = this._normalizeTitle(suggestedTitle);
93 if (!title) {
105 this._persistSeedTitle(channel, additionalChat, title);
106 }
108 > /** Trims, collapses whitespace, and length-caps a candidate title. */
109 > private _normalizeTitle(text: string): string {
110 return text.trim().replace(/\s+/g, ' ').slice(0, MAX_TITLE_LENGTH);
111 }
113 > /**
114 > * The peer (additional) chat a seed should title, or `undefined` to title
115 > * the session itself. The default chat maps to the session.
116 > */
117 > private _additionalChatChannel(chatChannel?: ProtocolURI): ProtocolURI | undefined {
118 return !!chatChannel && isAhpChatChannel(chatChannel) && !isDefaultChatUri(chatChannel) ? chatChannel : undefined;
119 }
121 > /**
122 > * Applies `title` to the addressed peer chat (`additionalChat`) or, when
123 > * that is `undefined`, to the session itself, recording it as last-applied.
124 > */
125 > private _applySeedTitle(channel: ProtocolURI, additionalChat: ProtocolURI | undefined, title: string): void {
126 if (additionalChat) {
127 this._applyTitle(additionalChat, title, t => this._stateManager.updateChatTitle(channel, additionalChat, t));
133 }
134 }
136 > /** Persists `title` as the custom title of the addressed peer chat or session. */
137 > private _persistSeedTitle(channel: ProtocolURI, additionalChat: ProtocolURI | undefined, title: string): void {
138 this._persistSessionFlag(channel, additionalChat ? `customChatTitle:${additionalChat}` : 'customTitle', title);
139 }
141 > /** The live title of the addressed peer chat or session. */
142 > private _currentSeedTitle(channel: ProtocolURI, additionalChat: ProtocolURI | undefined): string | undefined {
143 return additionalChat ? this._stateManager.getChatState(additionalChat)?.title : this._stateManager.getSessionState(channel)?.title;
144 }
146 > /**
147 > * Whether {@link seedTitleFromFirstMessage} may (re)title `key`: true for a
148 > * fresh, untitled target (its first message) or when its title is a
149 > * provisional placeholder we applied and no one has changed it since — the
150 > * first real request supersedes the placeholder.
151 > */
152 > private _canSeedFirstMessageTitle(key: ProtocolURI, turnsLength: number, currentTitle: string | undefined): boolean {
153 if (turnsLength === 0 && !currentTitle) {
154 return true;
156 return this._provisionalTitles.has(key) && !!currentTitle && currentTitle === this._lastAppliedTitle.get(key);
157 }
159 > /**
160 > * Whether {@link seedProvisionalTitle} may (re)title `key`: true when it is
161 > * untitled (the first message carried a suggestion) or when its title is a
162 > * provisional placeholder we applied and no one has changed it since —
163 > * successive suggestions keep the newest one visible without clobbering a
164 > * manual rename.
165 > */
166 > private _canSeedProvisionalTitle(key: ProtocolURI, currentTitle: string | undefined): boolean {
167 if (!currentTitle) {
168 return true;
170 return this._provisionalTitles.has(key) && currentTitle === this._lastAppliedTitle.get(key);
171 }
173 > /**
174 > * Re-generates the title once the first turn has completed, this time
175 > * using the full first-turn context (the user request plus the agent's
176 > * textual response) rather than just the opening message. This only runs
177 > * for the very first turn and only when the current title is still the one
178 > * this controller last applied — a manual `/rename`, a user edit, or a
179 > * forked session's inherited title all suppress it.
180 > *
181 > * Only normal text response parts are considered (tool calls, reasoning,
182 > * and other parts are ignored). If the context still exceeds the budget
183 > * the middle is removed (marked with `...`). The user's first request is
184 > * always preserved.
185 > */
186 > refineTitleFromFirstTurn(channel: ProtocolURI, chatChannel?: ProtocolURI): void {
187 const isAdditionalChat = !!chatChannel && isAhpChatChannel(chatChannel) && !isDefaultChatUri(chatChannel);
188 if (isAdditionalChat) {
238 );
239 }
241 > /**
242 > * Generates a title for a freshly forked session or chat from its
243 > * inherited conversation context. Forks copy the source history up to the
244 > * fork point, so neither {@link seedTitleFromFirstMessage} nor
245 > * {@link refineTitleFromFirstTurn} (which require an empty / single-turn
246 > * state) ever fire for them. This is the fork equivalent, run once at fork
247 > * time over the kept turns, so the new chat gets a content-derived title
248 > * instead of permanently inheriting the source's `Forked: …` title.
249 > *
250 > * `fallbackTitle` is the title the caller already applied to the new
251 > * session/chat (e.g. `Forked: <source>`); it is recorded as the
252 > * last-applied title so a concurrent manual rename suppresses the
253 > * generated title, and stays visible until generation completes. The
254 > * context is bounded to {@link MAX_TITLE_CONTEXT_CHARS} (middle-truncated),
255 > * so generation costs at most a single small-model call.
256 > */
257 > generateForkedTitle(channel: ProtocolURI, chatChannel: ProtocolURI | undefined, turns: readonly Turn[], fallbackTitle: string, sourceTitle?: string): void {
258 const context = this._buildConversationContext(turns, sourceTitle);
259 if (!context) {
351 persist(generatedTitle);
352 }
354 > private async _generateTitleFromPrompt(promptContent: string, isConversation: boolean, token: CancellationToken): Promise<string | undefined> {
355 if (token.isCancellationRequested) {
356 return undefined;
382 }
383 }
385 > private _buildTitlePrompt(promptContent: string, isConversation: boolean): ICopilotUtilityChatMessage[] {
386 const userInstruction = isConversation
387 ? `Please write a brief title for the following conversation:\n\n${promptContent}`
409 ];
410 }
412 > private _cleanTitle(rawTitle: string): string | undefined {
413 let title = rawTitle.trim();
414 const firstLine = title.split(/\r?\n/).map(line => line.trim()).find(line => line.length > 0);
424 return title.slice(0, MAX_TITLE_LENGTH);
425 }
427 > /**
428 > * Builds the first-turn context string for title refinement. The user's
429 > * request is always kept (truncated in the middle only if it alone exceeds
430 > * half the budget). Only normal text (markdown) response parts are
431 > * considered — tool calls, reasoning, and other parts are ignored. If the
432 > * combined text is over budget, the middle of the response is removed.
433 > *
434 > * @returns the context string, or `undefined` when the turn has no text
435 > * response worth refining from (the opening message already produced a
436 > * title in that case).
437 > */
438 > private _buildFirstTurnContext(turn: Turn): string | undefined {
439 const response = renderResponseMarkdown(turn.responseParts);
440 if (!response) {
455 return trimmedResponse ? `${userBlock}${responseLabel}${trimmedResponse}` : userBlock;
456 }
458 > /**
459 > * Builds a conversation context string for forked-title generation by
460 > * concatenating each kept turn's user request and textual response. Only
461 > * normal text (markdown) response parts are considered — tool calls,
462 > * reasoning, and other parts are ignored, mirroring
463 > * {@link _buildFirstTurnContext}. When the fork's `sourceTitle` is known, a
464 > * short framing note is prepended so the model understands the conversation
465 > * is a branch continued from an earlier chat. The conversation is
466 > * middle-truncated to {@link MAX_TITLE_CONTEXT_CHARS} to bound model cost;
467 > * the framing note is always preserved in full.
468 > *
469 > * @returns the context string, or `undefined` when no turn carries any
470 > * text worth titling from.
471 > */
472 > private _buildConversationContext(turns: readonly Turn[], sourceTitle?: string): string | undefined {
473 const framedTitle = sourceTitle?.trim();
474 const framing = framedTitle
477 return buildConversationContext(turns, { maxChars: MAX_TITLE_CONTEXT_CHARS, framing });
478 }
480 > private _persistSessionFlag(session: ProtocolURI, key: string, value: string): void {
481 const ref = this._options.sessionDataService.openDatabase(URI.parse(session));
482 ref.object.setMetadata(key, value).catch(err => {
486 });
487 }
489 > private _cancelTitleGeneration(session: ProtocolURI): void {
490 const source = this._titleGenerationCancellationSources.get(session);
491 if (!source) {
495 this._titleGenerationCancellationSources.delete(session);
496 }
498 > override dispose(): void {
499 for (const source of this._titleGenerationCancellationSources.values()) {
500 source.dispose(true);