src/vs/platform/agentHost/common/state/sessionState.ts

1405 LOC · 1204 covered · 201 uncovered · 171 ranges · 6287 concepts · 73 introducers · 3170 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.

1 > /*--------------------------------------------------------------------------------------------- sessionState.ts ×57
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 > // Immutable state types for the sessions process protocol.
7 > // See protocol.md for the full design rationale.
8 > //
9 > // Most types are imported from the auto-generated protocol layer
10 > // (synced from the agent-host-protocol repo). This file adds VS Code-specific
11 > // helpers and re-exports.
12 >
13 > import { decodeBase64, encodeBase64, VSBuffer } from '../../../../base/common/buffer.js';
14 > import { hasKey, type Mutable } from '../../../../base/common/types.js';
15 > import { URI as ResourceURI } from '../../../../base/common/uri.js';
16 > import type { IProductService } from '../../../product/common/productService.js';
17 > import { readToolCallMeta } from '../meta/agentToolCallMeta.js';
18 > import {
19 > ResponsePartKind,
20 > SessionStatus,
21 > ToolCallStatus,
22 > SessionLifecycle,
23 > TerminalState,
24 > ToolResultContentType,
25 > ToolResultFileEditContent,
26 > ChatOriginKind,
27 > ChatInteractivity,
28 > type ActiveTurn,
29 > type ChangesetState,
30 > type ChatState,
31 > type ChatSummary,
32 > type PendingMessage,
33 > type Turn,
34 > type AnnotationsState,
35 > type URI as ProtocolURI,
36 > type RootState,
37 > type SessionState,
38 > type SessionSummary,
39 > type TextRange,
40 > type ToolCallCancelledState,
41 > type ToolCallCompletedState,
42 > type ToolCallResult,
43 > type ToolCallState,
44 > type ToolResultContent,
45 > type ToolResultSubagentContent,
46 > type ToolResultTextContent,
47 > type UsageInfo,
48 > type Message,
49 > } from './protocol/state.js';
50 >
51 > // Re-export everything from the protocol state module
52 > export {
53 > ChangesetOperationScope, ChangesetOperationStatus, ChangesetStatus, CustomizationLoadStatus,
54 > CustomizationType, MessageAttachmentKind, MessageKind,
55 > PendingMessageKind,
56 > PolicyState,
57 > ResponsePartKind,
58 > ChatInputAnswerState as SessionInputAnswerState,
59 > ChatInputAnswerValueKind as SessionInputAnswerValueKind,
60 > ChatInputQuestionKind as SessionInputQuestionKind,
61 > ChatInputResponseKind as SessionInputResponseKind,
62 > ChatInteractivity,
63 > ChatOriginKind,
64 > SessionLifecycle,
65 > SessionStatus, ToolCallCancellationReason, ToolCallConfirmationReason, ToolCallContributorKind, ToolCallRiskAssessmentKind, ToolCallRiskAssessmentStatus, ToolCallStatus,
66 > ToolResultContentType,
67 > TurnState, type ActiveTurn, type AgentCustomization, type AgentCapabilities, type AgentInfo, type AgentSelection, type Annotation, type AnnotationEntry, type AnnotationsState, type AnnotationsSummary, type Changeset, type ChangesetFile,
68 > type ChangesetOperation, type ChangesetState, type ChatState, type ChatSummary, type ChatOrigin, type ChildCustomization, type ClientPluginCustomization, type ConfigPropertySchema,
69 > type ConfigSchema,
70 > type ContentRef, type Customization, type CustomizationDegradedState,
71 > type CustomizationErrorState, type CustomizationLoadedState, type CustomizationLoadingState, type CustomizationLoadState, type DirectoryCustomization, type ErrorInfo, type HookCustomization, type FileEdit as ISessionFileDiff, type ToolResultEmbeddedResourceContent as IToolResultBinaryContent, type MarkdownResponsePart, type McpServerCustomization, type MessageAttachment,
72 > type MessageResourceAttachment, type MessageEmbeddedResourceAttachment, type MessageAnnotationsAttachment, type ModelSelection, type PendingMessage, type PluginCustomization, type ProjectInfo, type PromptCustomization, type ReasoningResponsePart,
73 > type ResponsePart,
74 > type RootState, type RuleCustomization, type SessionActiveClient,
75 > type SessionConfigState, type ChatInputAnswer as SessionInputAnswer,
76 > type ChatInputOption as SessionInputOption, type ChatInputQuestion as SessionInputQuestion, type ChatInputRequest as SessionInputRequest, type SessionModelInfo,
77 > type SessionState,
78 > type SessionSummary, type SkillCustomization, type Snapshot, type StringOrMarkdown, type TerminalState, type TextRange,
79 > type ToolAnnotations,
80 > type ToolCallCancelledState,
81 > type ToolCallCompletedState,
82 > type ToolCallPendingConfirmationState,
83 > type ToolCallPendingResultConfirmationState,
84 > type ToolCallResponsePart,
85 > type ToolCallResult,
86 > type ToolCallRiskAssessment,
87 > type ToolCallRiskAssessmentCompleteState,
88 > type ToolCallRiskAssessmentLoadingState,
89 > type ToolCallRunningState,
90 > type ToolCallState,
91 > type ToolCallStreamingState,
92 > type ToolCallContributor,
93 > type ToolDefinition, type ToolResultContent,
94 > type ToolResultFileEditContent,
95 > type TerminalCommandResult,
96 > type ToolResultSubagentContent,
97 > type ToolResultTerminalContent,
98 > type ToolResultTextContent,
99 > type Turn, type URI, type UsageInfo,
100 > type Message
101 > } from './protocol/state.js';
102 >
103 > /**
104 > * Well-known keys that may appear on {@link UsageInfo._meta}.
105 > * Clients MAY read these to provide enhanced UI (e.g. credit cost display).
106 > */
107 > export interface UsageInfoMeta {
108 > /** Per-turn credit cost reported by the backend. */
109 > cost?: number;
110 > /** The concrete model selected by Copilot Auto and the routing explanation. */
111 > autoModeResolved?: IAutoModeResolvedInfo;
112 > /** Copilot-specific usage breakdown, including nano-AIU totals. */
113 > copilotUsage?: {
114 > totalNanoAiu?: number;
115 > [key: string]: unknown;
116 > };
117 > /**
118 > * Per-category account quota snapshots reported by the backend on the
119 > * model-call usage event, keyed by quota type (e.g. `chat`,
120 > * `premium_interactions`). Clients MAY use these to keep the account quota
121 > * UI current without a separate quota fetch.
122 > */
123 > quotaSnapshots?: {
124 > [quotaType: string]: {
125 > readonly isUnlimitedEntitlement?: boolean;
126 > readonly entitlementRequests?: number;
127 > readonly usedRequests?: number;
128 > readonly remainingPercentage?: number;
129 > readonly overage?: number;
130 > readonly overageAllowedWithExhaustedQuota?: boolean;
131 > /** ISO 8601 date when the quota resets, if applicable. */
132 > readonly resetDate?: string;
133 > } | undefined;
134 > };
135 > /**
136 > * Per-source context-window attribution breakdown reported by the SDK's
137 > * `session.rpc.metadata.getContextAttribution()`. Populated asynchronously
138 > * after each usage event and piped to the context-usage widget as
139 > * `promptTokenDetails`.
140 > */
141 > contextAttribution?: IContextAttributionData;
142 > [key: string]: unknown;
143 > }
144 >
145 > export interface IAutoModeResolvedInfo {
146 > readonly chosenModel: string;
147 > readonly reasoningBucket?: 'low' | 'medium' | 'high';
148 > readonly categoryScores?: Readonly<Record<string, number | undefined>>;
149 > readonly predictedLabel?: string;
150 > readonly confidence?: number;
151 > readonly candidateModels?: readonly string[];
152 > }
153 >
154 > /**
155 > * Mirrors the SDK's `SessionContextAttribution` shape — a flat list of
156 > * per-source entries describing what occupies the session's context window.
157 > */
158 > export interface IContextAttributionData {
159 > readonly totalTokens: number;
160 > readonly entries: readonly IContextAttributionEntry[];
161 > readonly compactions: { readonly count: number };
162 > }
163 >
164 > export interface IContextAttributionEntry {
165 > readonly kind: string;
166 > readonly id: string;
167 > readonly label: string;
168 > readonly tokens: number;
169 > readonly parentId?: string;
170 > readonly attributes?: Readonly<Record<string, string | undefined>>;
171 > }
172 >
173 > type AccountQuotaSnapshot = NonNullable<NonNullable<UsageInfoMeta['quotaSnapshots']>[string]>;
174 >
175 function readAccountQuotaSnapshot(value: unknown): AccountQuotaSnapshot | undefined {
176 if (!value || typeof value !== 'object' || Array.isArray(value)) {
177 return undefined;
178 }
179 const raw = value as Record<string, unknown>;
180 const snapshot: Mutable<AccountQuotaSnapshot> = {};
181 if (typeof raw['isUnlimitedEntitlement'] === 'boolean') { snapshot.isUnlimitedEntitlement = raw['isUnlimitedEntitlement']; }
182 if (typeof raw['entitlementRequests'] === 'number') { snapshot.entitlementRequests = raw['entitlementRequests']; }
183 if (typeof raw['usedRequests'] === 'number') { snapshot.usedRequests = raw['usedRequests']; }
184 if (typeof raw['remainingPercentage'] === 'number') { snapshot.remainingPercentage = raw['remainingPercentage']; }
185 if (typeof raw['overage'] === 'number') { snapshot.overage = raw['overage']; }
186 if (typeof raw['overageAllowedWithExhaustedQuota'] === 'boolean') { snapshot.overageAllowedWithExhaustedQuota = raw['overageAllowedWithExhaustedQuota']; }
187 if (typeof raw['resetDate'] === 'string') { snapshot.resetDate = raw['resetDate']; }
188 return snapshot;
189 }
191 > /**
192 > * Reads the well-known {@link UsageInfoMeta} keys from a usage report's open
193 > * `_meta` bag, ignoring unrelated provider-specific keys and validating each
194 > * field's type. Always read {@link UsageInfo._meta} through this helper rather
195 > * than casting the bag to {@link UsageInfoMeta}, so a malformed or partial bag
196 > * degrades to absent fields instead of producing values of the wrong runtime
197 > * type. Returns an empty object when the bag is absent.
198 > */
199 > export function readUsageInfoMeta(usage: UsageInfo | undefined): UsageInfoMeta {
200 > const meta = usage?._meta; sessionState.ts ×12
201 > if (!meta) {
202 return {};
203 }
204 > const result: Mutable<UsageInfoMeta> = {}; sessionState.ts ×12
205 > if (typeof meta['cost'] === 'number') { result.cost = meta['cost']; }
206 > const autoModeResolved = readAutoModeResolvedInfo(meta['autoModeResolved']);
207 > if (autoModeResolved) { result.autoModeResolved = autoModeResolved; }
208 > const copilotUsage = meta['copilotUsage'];
209 > if (copilotUsage && typeof copilotUsage === 'object' && !Array.isArray(copilotUsage)) {
210 const rawUsage = copilotUsage as Record<string, unknown>;
211 const usage: Mutable<NonNullable<UsageInfoMeta['copilotUsage']>> = {};
212 if (typeof rawUsage['totalNanoAiu'] === 'number') { usage.totalNanoAiu = rawUsage['totalNanoAiu']; }
213 result.copilotUsage = usage;
214 }
215 > const quotaSnapshots = meta['quotaSnapshots']; sessionState.ts ×12
216 > if (quotaSnapshots && typeof quotaSnapshots === 'object' && !Array.isArray(quotaSnapshots)) {
217 const snapshots: Mutable<NonNullable<UsageInfoMeta['quotaSnapshots']>> = {};
218 for (const [quotaType, value] of Object.entries(quotaSnapshots as Record<string, unknown>)) {
219 snapshots[quotaType] = readAccountQuotaSnapshot(value);
220 }
221 result.quotaSnapshots = snapshots;
222 }
223 > const contextAttribution = readContextAttribution(meta['contextAttribution']); sessionState.ts ×12
224 > if (contextAttribution) {
225 result.contextAttribution = contextAttribution;
226 }
227 > return result; sessionState.ts ×12
228 > }
230 > function readAutoModeResolvedInfo(value: unknown): IAutoModeResolvedInfo | undefined { sessionState.ts ×12
231 > if (!value || typeof value !== 'object' || Array.isArray(value)) {
232 return undefined;
233 }
234 > const raw = value as Record<string, unknown>; sessionState.ts ×12
235 > if (typeof raw['chosenModel'] !== 'string') {
236 return undefined;
237 }
238 > const result: Mutable<IAutoModeResolvedInfo> = { chosenModel: raw['chosenModel'] }; sessionState.ts ×12
239 > const reasoningBucket = raw['reasoningBucket'];
240 > if (reasoningBucket === 'low' || reasoningBucket === 'medium' || reasoningBucket === 'high') {
241 > result.reasoningBucket = reasoningBucket;
242 > }
243 > const categoryScores = raw['categoryScores'];
244 > if (categoryScores && typeof categoryScores === 'object' && !Array.isArray(categoryScores)) {
245 > const scores: Record<string, number> = {};
246 > for (const [category, score] of Object.entries(categoryScores as Record<string, unknown>)) {
247 > if (typeof score === 'number') {
248 > scores[category] = score;
249 > }
250 > }
251 > result.categoryScores = scores;
252 > }
253 > if (typeof raw['predictedLabel'] === 'string') { result.predictedLabel = raw['predictedLabel']; }
254 > if (typeof raw['confidence'] === 'number') { result.confidence = raw['confidence']; }
255 > if (Array.isArray(raw['candidateModels']) && raw['candidateModels'].every(candidate => typeof candidate === 'string')) {
256 > result.candidateModels = raw['candidateModels'];
257 > }
258 > return result;
259 > }
261 > function readContextAttribution(value: unknown): IContextAttributionData | undefined { sessionState.ts ×12
262 > if (!value || typeof value !== 'object' || Array.isArray(value)) {
263 > return undefined;
264 > }
265 const raw = value as Record<string, unknown>;
266 > if (typeof raw['totalTokens'] !== 'number' || !Array.isArray(raw['entries'])) { sessionState.ts ×12
267 return undefined;
268 }
269 const entries: IContextAttributionEntry[] = [];
270 for (const item of raw['entries']) {
271 if (!item || typeof item !== 'object' || Array.isArray(item)) {
272 continue;
273 }
274 const entry = item as Record<string, unknown>;
275 if (typeof entry['kind'] !== 'string' || typeof entry['id'] !== 'string'
276 || typeof entry['label'] !== 'string' || typeof entry['tokens'] !== 'number') {
277 continue;
278 }
279 entries.push({
280 kind: entry['kind'],
281 id: entry['id'],
282 label: entry['label'],
283 tokens: entry['tokens'],
284 parentId: typeof entry['parentId'] === 'string' ? entry['parentId'] : undefined,
285 attributes: entry['attributes'] && typeof entry['attributes'] === 'object' && !Array.isArray(entry['attributes'])
286 ? filterStringAttributes(entry['attributes'] as Record<string, unknown>)
287 : undefined,
288 });
289 }
290 const compactionsRaw = raw['compactions'];
291 > const compactions = compactionsRaw && typeof compactionsRaw === 'object' && !Array.isArray(compactionsRaw) sessionState.ts ×12
292 && typeof (compactionsRaw as Record<string, unknown>)['count'] === 'number'
293 ? { count: (compactionsRaw as Record<string, unknown>)['count'] as number }
294 : { count: 0 };
295 > return { totalTokens: raw['totalTokens'] as number, entries, compactions }; sessionState.ts ×12
296 > }
298 function filterStringAttributes(raw: Record<string, unknown>): Record<string, string | undefined> {
299 const result: Record<string, string | undefined> = {};
300 for (const [key, value] of Object.entries(raw)) {
301 if (typeof value === 'string' || value === undefined) {
302 result[key] = value;
303 }
304 }
305 return result;
306 }
308 > export {
309 > ChangesetOperationTargetKind, type ChangesetOperationFollowUp, type ChangesetOperationTarget
310 > } from './protocol/commands.js';
311 >
312 > // Canonical chat-input type names (the protocol renamed the former
313 > // `SessionInput*` types to `ChatInput*` when input requests moved onto the
314 > // chat channel). Re-exported here so consumers can import them from the glue
315 > // layer alongside the legacy `SessionInput*` aliases above.
316 > export {
317 > ChatInputAnswerState,
318 > ChatInputAnswerValueKind,
319 > ChatInputQuestionKind,
320 > ChatInputResponseKind,
321 > type ChatInputAnswer,
322 > type ChatInputOption,
323 > type ChatInputQuestion,
324 > type ChatInputRequest,
325 > type InputRequestResponsePart,
326 > } from './protocol/state.js';
327 >
328 > // ---- File edit kind ---------------------------------------------------------
329 >
330 > /**
331 > * The kind of file edit operation. Derived from the presence/absence of
332 > * `before`/`after` in {@link ToolResultFileEditContent}.
333 > */
334 > export const enum FileEditKind {
335 > /** Content edit (same file URI, different content). */
336 > Edit = 'edit',
337 > /** File creation (no before state). */
338 > Create = 'create',
339 > /** File deletion (no after state). */
340 > Delete = 'delete',
341 > /** File rename/move (different before and after URIs). */
342 > Rename = 'rename',
343 > }
344 >
345 > // ---- Well-known URIs --------------------------------------------------------
346 >
347 > /** URI for the root state subscription. */
348 > export const ROOT_STATE_URI = 'ahp-root://';
349 >
350 > /** Scheme used by {@link ROOT_STATE_URI}. */
351 > export const AHP_ROOT_SCHEME = 'ahp-root';
352 >
353 > /** Scheme used by resource-watch channel URIs (`ahp-resource-watch:/<encoded>`). */
354 > export const AHP_RESOURCE_WATCH_SCHEME = 'ahp-resource-watch';
355 >
356 > /**
357 > * Encode a resource-watch descriptor into its canonical channel URI. The
358 > * descriptor is serialised into the URI path so the receiver can recover
359 > * the watch parameters without any server-side bookkeeping — subscribe is
360 > * the only point where state is materialised (an `IFileService` watcher
361 > * is attached on the first subscriber and held through a grace window
362 > * after the last drops).
363 > */
364 > export function buildResourceWatchChannelUri(descriptor: {
365 readonly root: string;
366 readonly recursive?: boolean;
367 readonly excludes?: { items: readonly string[] };
368 readonly includes?: { items: readonly string[] };
369 }): string {
370 const payload: Record<string, unknown> = { root: descriptor.root };
371 if (descriptor.recursive) { payload.recursive = true; }
372 if (descriptor.excludes && descriptor.excludes.items.length > 0) {
373 payload.excludes = [...descriptor.excludes.items];
374 }
375 if (descriptor.includes && descriptor.includes.items.length > 0) {
376 payload.includes = [...descriptor.includes.items];
377 }
378
379 const json = encodeBase64(VSBuffer.fromString(JSON.stringify(payload)), false, true);
380 return `${AHP_RESOURCE_WATCH_SCHEME}://r/${json}`;
381 }
383 > /**
384 > * Inverse of {@link buildResourceWatchChannelUri}. Returns `undefined` if
385 > * `uri` is not a well-formed `ahp-resource-watch:` URI — callers should
386 > * surface that as a not-found error to the client.
387 > */
388 > export function parseResourceWatchChannelUri(uri: string): {
389 root: string;
390 recursive: boolean;
391 excludes?: { items: string[] };
392 includes?: { items: string[] };
393 } | undefined {
394 let parsed: ResourceURI;
395 try {
396 parsed = ResourceURI.parse(uri);
397 } catch {
398 return undefined;
399 }
400 if (parsed.scheme !== AHP_RESOURCE_WATCH_SCHEME) {
401 return undefined;
402 }
403 const encoded = parsed.path.replace(/^\//, '');
404 if (!encoded) {
405 return undefined;
406 }
407 try {
408 const payload = JSON.parse(decodeBase64(encoded).toString()) as { root?: unknown; recursive?: unknown; excludes?: unknown; includes?: unknown };
409 if (typeof payload.root !== 'string') {
410 return undefined;
411 }
412
413 return {
414 root: payload.root,
415 recursive: payload.recursive === true,
416 ...(Array.isArray(payload.excludes) ? { excludes: { items: payload.excludes.filter((x): x is string => typeof x === 'string') } } : {}),
417 ...(Array.isArray(payload.includes) ? { includes: { items: payload.includes.filter((x): x is string => typeof x === 'string') } } : {}),
418 };
419 } catch {
420 return undefined;
421 }
422 }
424 > /** Returns `true` when `uri` identifies a resource-watch channel. */
425 > export function isAhpResourceWatchChannel(uri: string): boolean {
426 > try { sessionState.ts ×2
427 > return ResourceURI.parse(uri).scheme === AHP_RESOURCE_WATCH_SCHEME;
428 > } catch {
429 return false;
430 }
433 > /**
434 > * Returns `true` when `uri` identifies the root channel, regardless of
435 > * whether the caller passes the canonical wire form (`'ahp-root://'`) or a
436 > * variant that has been round-tripped through the workbench {@link URI} class
437 > * (which normalizes the authority-less form to `'ahp-root:'`). Always prefer
438 > * this helper over a direct `=== ROOT_STATE_URI` comparison so the two
439 > * spellings stay interchangeable.
440 > */
441 > export function isAhpRootChannel(uri: string): boolean {
442 > if (uri === ROOT_STATE_URI) { sessionState.ts ×2
443 > return true; sessionState.ts ×1
444 > }
445 > try { sessionState.ts ×1
446 > return ResourceURI.parse(uri).scheme === AHP_ROOT_SCHEME;
447 > } catch {
448 return false;
449 }
452 > /**
453 > * Mints a session-unique opaque id for a customization, derived from its
454 > * source URI and (when present) its `range` within the source. Plugins MAY
455 > * declare multiple children (e.g. MCP servers, hooks) inside the same
456 > * manifest file; including the range disambiguates them without an extra
457 > * mapping table.
458 > *
459 > * The range is appended as a reserved `#range=` query-style suffix; any
460 > * existing `#` in the URI is percent-encoded first so a source URI that
461 > * already contains a fragment cannot collide with a ranged id.
462 > */
463 > export function customizationId(uri: string, range?: TextRange): string {
464 > if (!range) { sessionState.ts ×1
465 > return uri;
466 > }
467 const safeUri = uri.replace(/#/g, '%23');
468 return `${safeUri}#range=${range.start.line}:${range.start.character}-${range.end.line}:${range.end.character}`;
469 }
471 > // ---- VS Code-specific derived types -----------------------------------------
472 >
473 > /**
474 > * A tool call in a terminal state, stored in completed turns.
475 > */
476 > export type ICompletedToolCall = ToolCallCompletedState | ToolCallCancelledState;
477 >
478 > /**
479 > * Derived status type for the tool call lifecycle.
480 > */
481 > export type ToolCallStatusString = ToolCallState['status'];
482 >
483 > // ---- Tool output helper -----------------------------------------------------
484 >
485 > /**
486 > * Extracts a plain-text tool output string from a tool call result's `content`
487 > * array. Joins all text-type content parts into a single string.
488 > *
489 > * Returns `undefined` if there are no text content parts.
490 > */
491 > export function getToolOutputText(result: ToolCallResult): string | undefined {
492 if (!result.content || result.content.length === 0) {
493 return undefined;
494 }
495 const textParts: ToolResultTextContent[] = [];
496 for (const c of result.content) {
497 if (hasKey(c, { type: true }) && c.type === ToolResultContentType.Text) {
498 textParts.push(c);
499 }
500 }
501 if (textParts.length === 0) {
502 return undefined;
503 }
504 return textParts.map(p => p.text).join('\n');
505 }
507 > /**
508 > * Extracts file edit content entries from a tool call result's `content` array.
509 > * Returns an empty array if there are no file edit content parts.
510 > */
511 > export function getToolFileEdits(result: ToolCallResult): ToolResultFileEditContent[] {
512 > if (!result.content || result.content.length === 0) { agentSideEffects.ts ×4
513 > return []; sessionState.ts ×1
514 > }
515 > const edits: ToolResultFileEditContent[] = []; sessionState.ts ×2
516 > for (const c of result.content) {
517 > if (hasKey(c, { type: true }) && c.type === ToolResultContentType.FileEdit) {
518 > edits.push(c); sessionState.ts ×1
519 > }
521 > return edits;
522 > }
524 > /**
525 > * Extracts the first subagent content entry from a tool call's `content` array.
526 > * Works with both completed tool call results and running tool call states.
527 > * Returns `undefined` if there are no subagent content parts.
528 > */
529 > export function getToolSubagentContent(result: { content?: readonly ToolResultContent[] }): ToolResultSubagentContent | undefined {
530 if (!result.content || result.content.length === 0) {
531 return undefined;
532 }
533 for (const c of result.content) {
534 if (hasKey(c, { type: true }) && c.type === ToolResultContentType.Subagent) {
535 return c as ToolResultSubagentContent;
536 }
537 }
538 return undefined;
539 }
541 > // ---- Subagent URI helpers ---------------------------------------------------
542 >
543 > const SUBAGENT_URI_SEGMENT = 'subagent';
544 > const SUBAGENT_URI_MARKER = `/${SUBAGENT_URI_SEGMENT}/`;
545 > const SUBAGENT_URI_PATH_REGEX = /^(?<parentPath>.+)\/subagent\/(?<toolCallId>.+)$/;
546 >
547 > function asResourceUri(uri: ProtocolURI | ResourceURI): ResourceURI { sessionState.ts ×1
548 > return typeof uri === 'string' ? ResourceURI.parse(uri) : uri;
549 > }
551 > function getSubagentBasePath(parentSession: ProtocolURI | ResourceURI): { parent: ResourceURI; path: string } { sessionState.ts ×1
552 > const parent = asResourceUri(parentSession);
553 > const parentPath = parent.path.endsWith('/') ? parent.path.slice(0, -1) : parent.path;
554 > return { parent, path: `${parentPath}${SUBAGENT_URI_MARKER}` };
555 > }
557 > /**
558 > * Builds a subagent session URI from a parent session URI and tool call ID.
559 > * Convention: `{parentSessionUri}/subagent/{toolCallId}`
560 > */
561 > export function buildSubagentSessionUri(parentSession: ProtocolURI | ResourceURI, toolCallId: string): string {
562 > const { parent, path } = getSubagentBasePath(parentSession); sessionState.ts ×1
563 > return parent.with({ path: `${path}${toolCallId}` }).toString();
564 > }
566 > /**
567 > * Parses a subagent session URI into its parent session URI and tool call ID.
568 > * Returns `undefined` if the URI does not follow the subagent convention.
569 > */
570 > export function parseSubagentSessionUri(uri: ProtocolURI | ResourceURI): { parentSession: ResourceURI; toolCallId: string } | undefined {
571 > const resource = asResourceUri(uri); sessionState.ts ×1
572 > const match = SUBAGENT_URI_PATH_REGEX.exec(resource.path);
573 > if (!match?.groups) {
574 > return undefined; sessionState.ts ×1
575 > }
576 > return { sessionState.ts ×1
577 > parentSession: resource.with({ path: match.groups.parentPath }),
578 > toolCallId: match.groups.toolCallId,
579 > };
580 > }
582 > /**
583 > * Returns whether a session URI represents a subagent session.
584 > */
585 > export function isSubagentSession(uri: ProtocolURI | ResourceURI): boolean {
586 > return parseSubagentSessionUri(uri) !== undefined; sessionState.ts ×1
587 > }
589 > /**
590 > * Builds the string prefix used by the state manager for cached subagent sessions.
591 > */
592 > export function buildSubagentSessionUriPrefix(parentSession: ProtocolURI | ResourceURI): string {
593 > const { parent, path } = getSubagentBasePath(parentSession); sessionState.ts ×1
594 > return parent.with({ path }).toString();
595 > }
597 > // ---- Factory helpers --------------------------------------------------------
598 >
599 > export function createRootState(): RootState {
601 > agents: [],
602 > activeSessions: 0,
603 > };
604 > }
606 > /**
607 > * Creates the initial flat {@link SessionState} for a session from its
608 > * root-channel {@link SessionSummary} catalog entry. Session metadata
609 > * ({@link SessionMetadata}) — and the shared `_meta` bag — are inlined directly
610 > * onto the state.
611 > */
612 > export function createSessionState(summary: SessionSummary): SessionState {
613 > const state: SessionState = { sessionState.ts ×1
614 > provider: summary.provider,
615 > title: summary.title,
616 > status: summary.status,
617 > lifecycle: SessionLifecycle.Creating,
618 > activeClients: [],
619 > chats: [],
620 > defaultChat: undefined,
621 > };
622 > if (summary.activity !== undefined) { state.activity = summary.activity; }
623 > if (summary.project !== undefined) { state.project = summary.project; }
624 > if (summary.workingDirectories !== undefined) { state.workingDirectories = summary.workingDirectories; }
625 > if (summary.annotations !== undefined) { state.annotations = summary.annotations; }
626 > if (summary._meta !== undefined) { state._meta = summary._meta; }
627 > return state;
628 > }
630 > /**
631 > * Creates an empty {@link ChatState} for a chat. The summary fields are
632 > * denormalized onto the chat state per the protocol contract; callers pass
633 > * the chat's catalog summary and this seeds an empty conversation.
634 > */
635 > export function createChatState(summary: ChatSummary): ChatState {
636 > return { sessionState.ts ×2
637 > resource: summary.resource,
638 > title: summary.title,
639 > status: summary.status,
640 > activity: summary.activity,
641 > modifiedAt: summary.modifiedAt,
642 > origin: summary.origin,
643 > interactivity: summary.interactivity,
644 > workingDirectories: summary.workingDirectories,
645 > primaryWorkingDirectory: summary.primaryWorkingDirectory,
646 > turns: [],
647 > activeTurn: undefined,
648 > };
649 > }
651 > /**
652 > * Derives the default-chat {@link ChatSummary} for a session from its
653 > * {@link SessionSummary}. The default chat inherits the session's title,
654 > * status, activity and working directory, and is marked as a
655 > * {@link ChatOriginKind.User | user-originated} chat. Both the session and
656 > * chat `modifiedAt` are ISO-8601 strings, so it is carried over directly.
657 > */
658 > export function createDefaultChatSummary(session: SessionSummary, chatUri: ProtocolURI): ChatSummary {
659 > const summary: ChatSummary = { sessionState.ts ×2
660 > resource: chatUri,
661 > title: session.title,
662 > status: session.status,
663 > modifiedAt: session.modifiedAt,
664 > origin: { kind: ChatOriginKind.User },
665 > };
666 > if (session.activity !== undefined) { summary.activity = session.activity; }
667 > // `workingDirectories` is deliberately NOT copied: per the protocol it is a
668 > // per-chat SUBSET override and, when absent, the chat inherits the session's
669 > // full set of working directories (see `mergeSessionWithDefaultChat`).
670 > // Seeding it here would denormalize the session default onto every chat as a
671 > // fake override, which then goes stale when the session's working
672 > // directories are resolved later (e.g. a worktree resolved at
673 > // materialization). `primaryWorkingDirectory` is per-chat and fixed at chat
674 > // creation (the session has no primary), so it is likewise not seeded here.
675 > return summary;
676 > }
678 > /** Activity bits (0-4) of {@link SessionStatus}; the high bits carry orthogonal flags (IsRead / IsArchived). */
679 > const STATUS_ACTIVITY_MASK = (1 << 5) - 1;
680 >
681 > /** Whether the active turn has a `PendingConfirmation` tool call auto-approved by the session's bypass setting. */
682 > function hasAutoApprovedPendingConfirmation(state: ChatState): boolean { sessionState.ts ×3
683 > return !!state.activeTurn?.responseParts.some(part =>
684 > part.kind === ResponsePartKind.ToolCall sessionState.ts ×1
685 > && part.toolCall.status === ToolCallStatus.PendingConfirmation sessionState.ts ×1
686 > && readToolCallMeta(part.toolCall).autoApproveBySetting === true, sessionState.ts ×1
688 > }
690 > /** Whether the chat is genuinely blocked on user input (an open input request, an auth-required tool, or a non-auto-approved confirmation gate). */
691 > function chatAwaitsUserInput(state: ChatState): boolean { sessionState.ts ×4
692 > return !!state.activeTurn?.responseParts.some(part => {
693 > // An open elicitation always awaits the user until it is answered.
694 > if (part.kind === ResponsePartKind.InputRequest) {
695 return part.response === undefined;
696 }
697 > if (part.kind !== ResponsePartKind.ToolCall) { sessionState.ts ×4
698 return false;
699 }
700 > const status = part.toolCall.status; sessionState.ts ×4
701 > // Result-confirmation and auth-required gates always require the user; a
702 > // parameter-confirmation gate only when it was not auto-approved.
703 > if (status === ToolCallStatus.PendingResultConfirmation || status === ToolCallStatus.AuthRequired) {
704 return true;
705 }
706 > return status === ToolCallStatus.PendingConfirmation sessionState.ts ×4
707 > && readToolCallMeta(part.toolCall).autoApproveBySetting !== true;
708 > });
709 > }
711 > /**
712 > * Projects a chat's status for session-summary aggregation, demoting an
713 > * `InputNeeded` back to `InProgress` only when it is caused solely by an
714 > * auto-approved confirmation — otherwise a session with bypass approvals flashes
715 > * "input needed" in the sessions list while an auto-approved tool runs.
716 > */
717 > function chatSummaryStatus(state: ChatState): SessionStatus { sessionState.ts ×3
718 > const status = state.status;
719 > if ((status & SessionStatus.InputNeeded) !== SessionStatus.InputNeeded) {
720 > return status; sessionState.ts ×1
721 > }
722 > // Only demote when we can positively attribute the InputNeeded to an sessionState.ts ×3
723 > // auto-approved confirmation with no genuine blocker present; otherwise (e.g.
724 > // a restored summary whose activeTurn is not loaded) preserve the status.
725 > if (hasAutoApprovedPendingConfirmation(state) && !chatAwaitsUserInput(state)) { sessionState.ts ×3
726 > return (status & ~STATUS_ACTIVITY_MASK) | SessionStatus.InProgress; sessionState.ts ×1
727 > }
728 > return status; sessionState.ts ×1
729 > }
731 > /**
732 > * Derives a {@link ChatSummary} from a fully-populated {@link ChatState} by
733 > * projecting out the denormalized summary fields. Used to keep the parent
734 > * session's `chats` catalog in sync with a chat's denormalized state.
735 > */
736 > export function chatSummaryFromState(state: ChatState): ChatSummary {
737 > const summary: ChatSummary = { sessionState.ts ×3
738 > resource: state.resource,
739 > title: state.title,
740 > status: chatSummaryStatus(state),
741 > modifiedAt: state.modifiedAt,
742 > };
743 > if (state.activity !== undefined) { summary.activity = state.activity; }
744 > if (state.origin !== undefined) { summary.origin = state.origin; }
745 > if (state.interactivity !== undefined) { summary.interactivity = state.interactivity; }
746 > if (state.workingDirectories !== undefined) { summary.workingDirectories = state.workingDirectories; }
747 > if (state.primaryWorkingDirectory !== undefined) { summary.primaryWorkingDirectory = state.primaryWorkingDirectory; }
748 > return summary;
749 > }
751 > /**
752 > * The effective interactivity of a chat given its session's archived state.
753 > *
754 > * `interactivity` is the general read-only mechanism (e.g. subagent worker
755 > * chats are `ReadOnly`). An archived session is read-only too, so its
756 > * interactive chats are downgraded to `ReadOnly`. `Hidden` chats stay hidden —
757 > * archiving only downgrades `Full` chats. Absent interactivity defaults to
758 > * `Full` for backward compatibility.
759 > *
760 > * The host uses this to enforce read-only turns off a single signal
761 > * ({@link isChatReadOnly}) rather than special-casing archived; the same rule
762 > * is mirrored client-side to hide the composer.
763 > */
764 > export function effectiveChatInteractivity(interactivity: ChatInteractivity | undefined, sessionArchived: boolean): ChatInteractivity {
765 > if (interactivity === ChatInteractivity.Hidden) { agentSideEffects.ts ×5
766 return ChatInteractivity.Hidden;
767 }
768 > if (sessionArchived) { agentSideEffects.ts ×5
769 > return ChatInteractivity.ReadOnly; reducer.ts ×1
770 > }
771 > return interactivity ?? ChatInteractivity.Full; sessionState.ts ×1
774 > /**
775 > * Whether a chat rejects user-dispatched turns, given its own interactivity and
776 > * its session's archived state. `true` for `ReadOnly` chats (including archived
777 > * sessions' interactive chats). See {@link effectiveChatInteractivity}.
778 > */
779 > export function isChatReadOnly(interactivity: ChatInteractivity | undefined, sessionArchived: boolean): boolean {
780 > return effectiveChatInteractivity(interactivity, sessionArchived) === ChatInteractivity.ReadOnly; agentSideEffects.ts ×5
781 > }
783 > export function createActiveTurn(id: string, message: Message, startedAt: string): ActiveTurn {
784 return {
785 id,
786 startedAt,
787 message,
788 responseParts: [],
789 usage: undefined,
790 };
791 }
793 > export const enum StateComponents {
794 > Root,
795 > Session,
796 > Chat,
797 > Terminal,
798 > Changeset,
799 > Annotations,
800 > }
801 >
802 > export type ComponentToState = {
803 > [StateComponents.Root]: RootState;
804 > [StateComponents.Session]: SessionState;
805 > [StateComponents.Chat]: ChatState;
806 > [StateComponents.Terminal]: TerminalState;
807 > [StateComponents.Changeset]: ChangesetState;
808 > [StateComponents.Annotations]: AnnotationsState;
809 > };
810 >
811 > // ---- Default chat URI helpers ----------------------------------------------
812 >
813 > /** Scheme used by chat channel URIs (`ahp-chat://...`). */
814 > export const AHP_CHAT_SCHEME = 'ahp-chat';
815 >
816 > /** Chat id of the default chat that every session owns. */
817 > export const DEFAULT_CHAT_ID = 'default';
818 >
819 > /**
820 > * Derives the deterministic channel URI for a chat within a session. Every chat
821 > * — the default chat and any additional peer chats — encodes its owning session
822 > * URI into the path so producers and consumers can recover the session without a
823 > * lookup table (see {@link parseChatUri}). The chat id is carried in the URI
824 > * authority.
825 > *
826 > * `ahp-chat://<chatId>/<base64(sessionUri)>`
827 > */
828 > export function buildChatUri(sessionUri: ProtocolURI | ResourceURI, chatId: string): string {
829 > const session = typeof sessionUri === 'string' ? sessionUri : sessionUri.toString(); sessionState.ts ×1
830 > const encoded = encodeBase64(VSBuffer.fromString(session), false, true);
831 > return `${AHP_CHAT_SCHEME}://${chatId}/${encoded}`;
832 > }
834 > /**
835 > * Derives the deterministic default-chat channel URI for a session. While the
836 > * protocol allows a session to contain many chats, every session always owns a
837 > * default chat whose URI is derived from the owning session URI so producers and
838 > * consumers can compute it without a lookup table.
839 > *
840 > * The session URI is encoded into the path so {@link parseChatUri} can recover
841 > * it.
842 > */
843 > export function buildDefaultChatUri(sessionUri: ProtocolURI | ResourceURI): string {
844 > return buildChatUri(sessionUri, DEFAULT_CHAT_ID); sessionState.ts ×1
845 > }
847 > const SUBAGENT_CHAT_ID = 'subagent';
848 >
849 > export function isSubagentChatUri(uri: ProtocolURI | ResourceURI): boolean {
850 > const parsed = typeof uri === 'string' ? ResourceURI.parse(uri) : uri; sessionState.ts ×1
851 > return parsed.scheme === AHP_CHAT_SCHEME && parsed.authority === SUBAGENT_CHAT_ID;
852 > }
854 > export function buildSubagentChatUri(sessionUri: ProtocolURI | ResourceURI, toolCallId: string): string {
855 > const session = typeof sessionUri === 'string' ? sessionUri : sessionUri.toString(); sessionState.ts ×1
856 > const encoded = encodeBase64(VSBuffer.fromString(session), false, true);
857 > return `${AHP_CHAT_SCHEME}://${SUBAGENT_CHAT_ID}/${encoded}/${encodeURIComponent(toolCallId)}`;
858 > }
860 > /**
861 > * Inverse of {@link buildChatUri}: recovers the owning session URI and chat id
862 > * from any chat channel URI. Returns `undefined` when `uri` is not a well-formed
863 > * chat URI.
864 > */
865 > export function parseChatUri(uri: ProtocolURI | ResourceURI): { session: string; chatId: string } | undefined {
866 > let parsed: ResourceURI; sessionState.ts ×3
867 > try {
868 > parsed = typeof uri === 'string' ? ResourceURI.parse(uri) : uri;
869 > } catch {
870 return undefined;
871 }
872 > if (parsed.scheme !== AHP_CHAT_SCHEME || !parsed.authority) { sessionState.ts ×3
873 > return undefined; sessionState.ts ×1
874 > }
875 > const encoded = parsed.path.replace(/^\//, ''); sessionState.ts ×3
876 > if (!encoded) {
877 return undefined;
878 }
879 > try { sessionState.ts ×3
880 > if (parsed.authority === SUBAGENT_CHAT_ID) {
881 > const [sessionPart, ...toolCallIdParts] = encoded.split('/'); sessionState.ts ×2
882 > const toolCallId = toolCallIdParts.join('/');
883 > if (!sessionPart || !toolCallId) {
884 return undefined;
885 }
886 > return { session: decodeBase64(sessionPart).toString(), chatId: `${SUBAGENT_CHAT_ID}/${decodeURIComponent(toolCallId)}` }; sessionState.ts ×2
887 > }
888 > return { session: decodeBase64(encoded).toString(), chatId: parsed.authority }; sessionState.ts ×3
889 > } catch {
890 return undefined;
891 }
894 > /**
895 > * Inverse of {@link buildDefaultChatUri}: recovers the owning session URI from a
896 > * chat channel URI. Returns `undefined` when `uri` is not a well-formed chat URI.
897 > * Accepts any chat URI (default or additional) so callers that only need the
898 > * parent session can use it uniformly.
899 > */
900 > export function parseDefaultChatUri(uri: ProtocolURI | ResourceURI): string | undefined {
901 > return parseChatUri(uri)?.session; sessionState.ts ×1
902 > }
904 > export function parseRequiredSessionUriFromChatUri(uri: ProtocolURI | ResourceURI): string {
905 > const session = parseDefaultChatUri(uri); sessionState.ts ×2
906 > if (session === undefined) {
907 throw new Error(`Malformed AHP chat URI: ${typeof uri === 'string' ? uri : uri.toString()}`);
908 }
909 > return session; sessionState.ts ×2
910 > }
912 > /** Returns `true` when `uri` is the default chat of its session. */
913 > export function isDefaultChatUri(uri: ProtocolURI | ResourceURI): boolean {
914 > return parseChatUri(uri)?.chatId === DEFAULT_CHAT_ID; sessionState.ts ×1
915 > }
917 > /**
918 > * Resolves a feature-level `(session, chat)` pair to the single chat URI used by
919 > * the agent session/chat surface. A session always owns a DEFAULT chat addressed
920 > * by the session URI itself; additional (peer) chats are addressed by their own
921 > * chat channel URIs. This is the one place default-chat resolution lives so
922 > * agents never re-derive "is this the default chat?".
923 > */
924 > export function resolveChatUri(session: ResourceURI, chat: ResourceURI): ResourceURI {
925 > return isDefaultChatUri(chat) ? session : chat; sessionState.ts ×1
926 > }
928 > /** Returns `true` when `uri` identifies a chat channel. */
929 > export function isAhpChatChannel(uri: string): boolean {
930 > try { sessionState.ts ×2
931 > return ResourceURI.parse(uri).scheme === AHP_CHAT_SCHEME;
932 > } catch {
933 return false;
934 }
937 > // ---- Session + default-chat composite --------------------------------------
938 >
939 > /**
940 > * A single chat's effective session context: the shared {@link SessionState}
941 > * (working directories, active clients, config, customizations/MCP scope, …)
942 > * resolved for one chat and merged with that chat's conversation contents.
943 > *
944 > * The protocol moved turns and pending state off the session and onto a
945 > * per-chat channel, and lets a chat override the session's working directories
946 > * with a subset (e.g. {@link ChatState.workingDirectories}) and carry its own
947 > * read-only {@link ChatState.primaryWorkingDirectory | primary} (fixed at chat
948 > * creation — the session has no primary). This composite recombines the session
949 > * with one of its chats — default or peer — so consumers read the chat's
950 > * effective context and conversation through one object without walking back to
951 > * the session to re-derive shared state. The {@link ISessionWithDefaultChat.workingDirectories}
952 > * carry the chat's *effective* working directories (its own subset override when
953 > * present, else the session's full set); {@link ISessionWithDefaultChat.primaryWorkingDirectory}
954 > * is the chat's own primary.
955 > */
956 > export interface ISessionWithDefaultChat extends SessionState {
957 > /** The chat's read-only primary working directory (fixed at chat creation). */
958 > primaryWorkingDirectory?: ProtocolURI;
959 > /** Completed turns of this chat. */
960 > turns: Turn[];
961 > /** Currently in-progress turn of this chat. */
962 > activeTurn?: ActiveTurn;
963 > /** Steering message pending on this chat. */
964 > steeringMessage?: PendingMessage;
965 > /** Queued messages pending on this chat. */
966 > queuedMessages?: PendingMessage[];
967 > /** Draft input of this chat. */
968 > draft?: Message;
969 > }
970 >
971 > /**
972 > * Projects a {@link SessionState} and one of its {@link ChatState | chats}
973 > * (default or peer) into that chat's {@link ISessionWithDefaultChat | effective
974 > * session context}. Per-chat overrides (the working-directories subset and the
975 > * chat's own primary) are layered over the session defaults, and the
976 > * conversation fields are taken from the chat. When the chat state is absent
977 > * (e.g. not yet hydrated) the conversation fields default to empty and the
978 > * session defaults apply.
979 > */
980 > export function mergeSessionWithDefaultChat(session: SessionState, chat: ChatState | undefined): ISessionWithDefaultChat {
981 > return { sessionState.ts ×1
982 > ...session,
983 > workingDirectories: chat?.workingDirectories ?? session.workingDirectories,
984 > primaryWorkingDirectory: chat?.primaryWorkingDirectory,
985 > turns: chat?.turns ?? [],
986 > activeTurn: chat?.activeTurn,
987 > steeringMessage: chat?.steeringMessage,
988 > queuedMessages: chat?.queuedMessages,
989 > draft: chat?.draft,
990 > };
991 > }
993 > /**
994 > * Resolves the active turn of a session's default chat, if any.
995 > */
996 > export function getActiveTurn(chat: ChatState | undefined): ActiveTurn | undefined {
997 return chat?.activeTurn;
998 }
1000 > /**
1001 > * Resolves the default chat's catalog summary from a session, if present.
1002 > */
1003 > export function getDefaultChat(session: SessionState): ChatSummary | undefined {
1004 if (session.defaultChat !== undefined) {
1005 const match = session.chats.find(c => c.resource === session.defaultChat);
1006 if (match) {
1007 return match;
1008 }
1009 }
1010 return session.chats[0];
1011 }
1013 > // ---- SessionMeta accessors -------------------------------------------------
1014 >
1015 > /**
1016 > * VS Code-side alias for the protocol's open `_meta` property bag on
1017 > * {@link SessionState}. Keys SHOULD be namespaced (e.g. `git`, `vscode.foo`)
1018 > * to avoid collisions; values MUST be JSON-serializable.
1019 > */
1020 > export type SessionMeta = Record<string, unknown>;
1021 >
1022 > /**
1023 > * VS Code-side alias for the protocol's open `_meta` property bag on
1024 > * {@link SessionSummary}. Keys SHOULD be namespaced (e.g. `git`, `vscode.foo`)
1025 > * to avoid collisions; values MUST be JSON-serializable.
1026 > */
1027 > export type SessionSummaryMeta = Record<string, unknown>;
1028 >
1029 > /**
1030 > * Reserved key under {@link SessionMeta} for the well-known git-state
1031 > * payload. Value at this key, when present, MUST be shaped like
1032 > * {@link ISessionGitState}. This is a VS Code-specific convention layered
1033 > * on top of the protocol's generic `_meta` bag — the protocol itself does
1034 > * not know about git state.
1035 > */
1036 > export const SESSION_META_GIT_KEY = 'git';
1037 >
1038 > /**
1039 > * Reserved key under {@link SessionMeta} for the well-known GitHub-state
1040 > * payload. Value at this key, when present, MUST be shaped like
1041 > * {@link ISessionGitHubState}. This is a VS Code-specific convention layered
1042 > * on top of the protocol's generic `_meta` bag — the protocol itself does
1043 > * not know about GitHub state.
1044 > */
1045 > export const SESSION_META_GITHUB_KEY = 'github';
1046 >
1047 > export const SESSION_META_PROMPT_CACHE_KEY = 'vscode.promptCache';
1048 >
1049 > /** Latest known prompt-cache state for the model active in an agent session. */
1050 > export interface ISessionPromptCacheState {
1051 > readonly modelId: string;
1052 > readonly cacheExpiresAt: string;
1053 > }
1054 >
1055 > /** Reads the latest known prompt-cache state from session metadata. */
1056 > export function readSessionPromptCacheState(meta: SessionMeta | undefined): ISessionPromptCacheState | undefined {
1057 > const value = meta?.[SESSION_META_PROMPT_CACHE_KEY]; sessionState.ts ×4
1058 > if (!value || typeof value !== 'object' || Array.isArray(value)) {
1059 > return undefined; sessionState.ts ×1
1060 > }
1061 > const raw = value as Record<string, unknown>; sessionState.ts ×4
1062 > return typeof raw['modelId'] === 'string' && typeof raw['cacheExpiresAt'] === 'string'
1063 > ? { modelId: raw['modelId'], cacheExpiresAt: raw['cacheExpiresAt'] }
1064 : undefined;
1067 > /** Returns session metadata with the prompt-cache slot updated or removed. */
1068 > export function withSessionPromptCacheState(meta: SessionMeta | undefined, promptCache: ISessionPromptCacheState | undefined): SessionMeta | undefined {
1069 > const next: SessionMeta = { ...meta }; copilotAgentSession.ts ×15
1070 > if (promptCache) {
1071 > next[SESSION_META_PROMPT_CACHE_KEY] = promptCache; sessionState.ts ×4
1072 > } else { copilotAgentSession.ts ×15
1073 > delete next[SESSION_META_PROMPT_CACHE_KEY]; sessionState.ts ×1
1074 > }
1075 > return Object.keys(next).length > 0 ? next : undefined; copilotAgentSession.ts ×15
1076 > }
1078 > /**
1079 > * Git state of a session's working directory, carried under
1080 > * {@link SessionMeta} at {@link SESSION_META_GIT_KEY}. Used by clients to
1081 > * drive source-control affordances (e.g. PR/merge buttons in the Agents
1082 > * app).
1083 > *
1084 > * All fields are optional — agents that do not track a particular field
1085 > * should omit it rather than send a placeholder, so clients can distinguish
1086 > * "unknown" from "known to be zero".
1087 > */
1088 > export interface ISessionGitState {
1089 > /** Whether the working directory has a `github.com` git remote. */
1090 > readonly hasGitHubRemote?: boolean;
1091 > /** Current branch name. */
1092 > readonly branchName?: string;
1093 > /** Base branch the work targets (e.g. `main`). */
1094 > readonly baseBranchName?: string;
1095 > /** Upstream tracking branch (e.g. `origin/feature`). */
1096 > readonly upstreamBranchName?: string;
1097 > /** Number of commits the upstream branch has ahead of the local branch. */
1098 > readonly incomingChanges?: number;
1099 > /** Number of commits the local branch has ahead of the upstream branch. */
1100 > readonly outgoingChanges?: number;
1101 > /** Number of files with uncommitted changes. */
1102 > readonly uncommittedChanges?: number;
1103 > /** GitHub repository owner parsed from the working copy's GitHub remote (preferring `origin`, falling back to the first GitHub remote). */
1104 > readonly githubOwner?: string;
1105 > /** GitHub repository name parsed from the working copy's GitHub remote (preferring `origin`, falling back to the first GitHub remote). */
1106 > readonly githubRepo?: string;
1107 > }
1108 >
1109 > /**
1110 > * GitHub state of a session, carried under {@link SessionMeta} at
1111 > * {@link SESSION_META_GITHUB_KEY}. Used by clients to drive GitHub-specific
1112 > * affordances (e.g. PR/merge buttons in the Agents app).
1113 > *
1114 > * All fields are optional — agents that do not track a particular field
1115 > * should omit it rather than send a placeholder, so clients can distinguish
1116 > * "unknown" from "known to be zero".
1117 > */
1118 > export interface ISessionGitHubState {
1119 > /** The owner of the GitHub repository. */
1120 > readonly owner?: string;
1121 > /** The name of the GitHub repository. */
1122 > readonly repo?: string;
1123 > /** The URL of the GitHub pull request. */
1124 > readonly pullRequestUrl?: string;
1125 > }
1126 >
1127 > /**
1128 > * Reads the well-known git-state payload from {@link SessionMeta}, if
1129 > * present. Returns `undefined` when the meta bag is absent or the value at
1130 > * the git key is not a plain object (e.g. an array or a primitive).
1131 > * Individual fields with wrong types are silently dropped so partial state
1132 > * still propagates.
1133 > *
1134 > * Unlike the other typed readers, this takes the raw {@link SessionMeta} value
1135 > * rather than its parent {@link SessionState}: the sessions provider stores and
1136 > * reads a detached meta snapshot without retaining the owning state.
1137 > */
1138 > export function readSessionGitState(meta: SessionMeta | undefined): ISessionGitState | undefined {
1139 > const value = meta?.[SESSION_META_GIT_KEY]; sessionState.ts ×1
1140 > if (!value || typeof value !== 'object' || Array.isArray(value)) {
1141 > return undefined; sessionState.ts ×1
1142 > }
1143 > const raw = value as Record<string, unknown>; sessionState.ts ×1
1144 > const result: {
1145 > hasGitHubRemote?: boolean;
1146 > branchName?: string;
1147 > baseBranchName?: string;
1148 > upstreamBranchName?: string;
1149 > incomingChanges?: number;
1150 > outgoingChanges?: number;
1151 > uncommittedChanges?: number;
1152 > githubOwner?: string;
1153 > githubRepo?: string;
1154 > } = {};
1155 > if (typeof raw['hasGitHubRemote'] === 'boolean') { result.hasGitHubRemote = raw['hasGitHubRemote']; }
1156 > if (typeof raw['branchName'] === 'string') { result.branchName = raw['branchName']; }
1157 > if (typeof raw['baseBranchName'] === 'string') { result.baseBranchName = raw['baseBranchName']; }
1158 > if (typeof raw['upstreamBranchName'] === 'string') { result.upstreamBranchName = raw['upstreamBranchName']; }
1159 > if (typeof raw['incomingChanges'] === 'number') { result.incomingChanges = raw['incomingChanges']; }
1160 > if (typeof raw['outgoingChanges'] === 'number') { result.outgoingChanges = raw['outgoingChanges']; }
1161 > if (typeof raw['uncommittedChanges'] === 'number') { result.uncommittedChanges = raw['uncommittedChanges']; }
1162 > if (typeof raw['githubOwner'] === 'string') { result.githubOwner = raw['githubOwner']; }
1163 > if (typeof raw['githubRepo'] === 'string') { result.githubRepo = raw['githubRepo']; }
1164 > return result;
1165 > }
1167 > /**
1168 > * Returns a new {@link SessionMeta} with the git-state payload set to
1169 > * `gitState`, or with the git slot removed if `gitState` is `undefined`.
1170 > * Returns `undefined` if the result would be empty.
1171 > */
1172 > export function withSessionGitState(meta: SessionMeta | undefined, gitState: ISessionGitState | undefined): SessionMeta | undefined {
1173 > const next: { [key: string]: unknown } = { ...meta }; sessionState.ts ×2
1174 > if (gitState !== undefined) {
1175 > next[SESSION_META_GIT_KEY] = gitState;
1176 > } else {
1177 delete next[SESSION_META_GIT_KEY];
1178 }
1179 > return Object.keys(next).length > 0 ? next : undefined; sessionState.ts ×2
1180 > }
1182 > /**
1183 > * Reads the well-known GitHub state payload from {@link SessionSummaryMeta}, if
1184 > * present. Returns `undefined` when the meta bag is absent or the value at the
1185 > * GitHub key is not a plain object (e.g. an array or a primitive).
1186 > * Individual fields with wrong types are silently dropped so partial state
1187 > * still propagates.
1188 > *
1189 > * Unlike the other typed readers, this takes the raw {@link SessionSummaryMeta}
1190 > * value rather than its parent {@link SessionState}: the sessions provider stores and
1191 > * reads a detached meta snapshot without retaining the owning state.
1192 > */
1193 > export function readSessionGitHubState(meta: SessionSummaryMeta | undefined): ISessionGitHubState | undefined {
1194 > const value = meta?.[SESSION_META_GITHUB_KEY]; sessionState.ts ×1
1195 > if (!value || typeof value !== 'object' || Array.isArray(value)) {
1196 > return undefined; sessionState.ts ×1
1197 > }
1198 > const raw = value as Record<string, unknown>; sessionState.ts ×1
1199 > const result: {
1200 > owner?: string;
1201 > repo?: string;
1202 > pullRequestUrl?: string;
1203 > } = {};
1204 >
1205 > if (typeof raw['owner'] === 'string') { result.owner = raw['owner']; }
1206 > if (typeof raw['repo'] === 'string') { result.repo = raw['repo']; }
1207 > if (typeof raw['pullRequestUrl'] === 'string') { result.pullRequestUrl = raw['pullRequestUrl']; }
1208 > return result;
1209 > }
1211 > /**
1212 > * Returns a new {@link SessionSummaryMeta} with the GitHub-state payload set to
1213 > * `gitHubState`, or with the GitHub slot removed if `gitHubState` is `undefined`.
1214 > * Returns `undefined` if the result would be empty.
1215 > */
1216 > export function withSessionGitHubState(meta: SessionSummaryMeta | undefined, gitHubState: ISessionGitHubState | undefined): SessionSummaryMeta | undefined {
1217 > const next: { [key: string]: unknown } = { ...meta }; sessionState.ts ×2
1218 > if (gitHubState !== undefined) {
1219 > next[SESSION_META_GITHUB_KEY] = gitHubState;
1220 > } else {
1221 delete next[SESSION_META_GITHUB_KEY];
1222 }
1223 > return Object.keys(next).length > 0 ? next : undefined; sessionState.ts ×2
1224 > }
1226 > /**
1227 > * Reserved key under {@link SessionSummaryMeta} recording how deeply a session
1228 > * was spawned via the `create_session` host tool (0 for a top-level, user-created
1229 > * session). Used to bound recursive session creation. VS Code-specific convention
1230 > * layered on top of the protocol's generic `_meta` bag.
1231 > */
1232 > export const SESSION_META_SPAWN_DEPTH_KEY = 'agentHost/sessionSpawnDepth';
1233 >
1234 > /**
1235 > * Reads the `create_session` spawn depth from a {@link SessionSummaryMeta} bag,
1236 > * returning `0` when the key is absent or not a finite number.
1237 > */
1238 > export function readSessionSpawnDepth(meta: SessionSummaryMeta | undefined): number {
1239 const value = meta?.[SESSION_META_SPAWN_DEPTH_KEY];
1240 return typeof value === 'number' && Number.isFinite(value) ? value : 0;
1241 }
1243 > /**
1244 > * Returns a new {@link SessionSummaryMeta} with the `create_session` spawn depth
1245 > * set to `depth`, preserving any other keys in the bag.
1246 > */
1247 > export function withSessionSpawnDepth(meta: SessionSummaryMeta | undefined, depth: number): SessionSummaryMeta {
1248 return { ...meta, [SESSION_META_SPAWN_DEPTH_KEY]: depth };
1249 }
1251 > /**
1252 > * Reserved key under {@link SessionSummaryMeta} marking a session as
1253 > * workspace-less: a session with no workspace/folder binding (surfaced in the
1254 > * UI as a "Quick Chat"). Carried on the summary bag (not the full state) so
1255 > * clients can group/style such sessions in session lists without subscribing to
1256 > * full session state. VS Code-specific convention layered on the protocol's
1257 > * generic `_meta` bag.
1258 > */
1259 > export const SESSION_META_WORKSPACELESS_KEY = 'workspaceless';
1260 >
1261 > /**
1262 > * Session-database metadata key recording whether a session is workspace-less (a
1263 > * workspace-less chat). Owned by the AH service: `AgentService` writes it centrally at
1264 > * create/materialize and overlays it onto every agent's summary `_meta` in
1265 > * `listSessions`; agents only read it (e.g. to pick the workspace-less system prompt
1266 > * on resume) and never persist it themselves.
1267 > */
1268 > export const AH_META_WORKSPACELESS_DB_KEY = 'agentHost.workspaceless';
1269 >
1270 > /**
1271 > * Session-database metadata key recording whether a session is archived. Written by
1272 > * the AH orchestrator (`AgentSideEffects` on `SessionIsArchivedChanged`) and read by
1273 > * both the orchestrator (`AgentService` restore/list) and agents (e.g. `CopilotAgent`
1274 > * decides whether to recreate a missing worktree vs. resume read-only for history).
1275 > * {@link AH_META_IS_DONE_DB_KEY} is the legacy name kept for sessions persisted before
1276 > * the rename; readers fall back to it when {@link AH_META_IS_ARCHIVED_DB_KEY} is absent.
1277 > */
1278 > export const AH_META_IS_ARCHIVED_DB_KEY = 'isArchived';
1279 >
1280 > /** Legacy metadata key for the archived flag; see {@link AH_META_IS_ARCHIVED_DB_KEY}. */
1281 > export const AH_META_IS_DONE_DB_KEY = 'isDone';
1282 >
1283 > /**
1284 > * Reads the workspace-less marker from {@link SessionSummaryMeta}. Returns
1285 > * `true` only when the well-known key is present and set to boolean `true`.
1286 > */
1287 > export function readSessionWorkspaceless(meta: SessionSummaryMeta | undefined): boolean {
1288 > return meta?.[SESSION_META_WORKSPACELESS_KEY] === true; sessionState.ts ×1
1289 > }
1291 > /**
1292 > * Returns a new {@link SessionSummaryMeta} with the workspace-less marker set,
1293 > * or with the slot removed when `workspaceless` is `false`. Returns `undefined`
1294 > * if the result would be empty.
1295 > */
1296 > export function withSessionWorkspaceless(meta: SessionSummaryMeta | undefined, workspaceless: boolean): SessionSummaryMeta | undefined {
1297 > const next: { [key: string]: unknown } = { ...meta }; sessionState.ts ×2
1298 > if (workspaceless) {
1299 > next[SESSION_META_WORKSPACELESS_KEY] = true;
1300 > } else {
1301 > delete next[SESSION_META_WORKSPACELESS_KEY]; sessionState.ts ×1
1302 > }
1303 > return Object.keys(next).length > 0 ? next : undefined; sessionState.ts ×2
1304 > }
1306 > // ---- RootState _meta accessors ---------------------------------------------
1307 >
1308 > /**
1309 > * VS Code-side alias for the protocol's open `_meta` property bag on
1310 > * {@link RootState}. Keys SHOULD be namespaced to avoid collisions; values MUST
1311 > * be JSON-serializable.
1312 > */
1313 > export type RootMeta = Record<string, unknown>;
1314 >
1315 > /**
1316 > * Reserved key under {@link RootMeta} for the well-known host-build payload.
1317 > * Value at this key, when present, MUST be shaped like {@link IHostBuildInfo}.
1318 > * This is a VS Code-specific convention layered on top of the protocol's
1319 > * generic `_meta` bag — the protocol itself does not know about build info.
1320 > */
1321 > export const ROOT_META_HOST_BUILD_KEY = 'hostBuild';
1322 >
1323 > /**
1324 > * Build information about the program hosting the agent host (the VS Code CLI),
1325 > * carried under {@link RootMeta} at {@link ROOT_META_HOST_BUILD_KEY}. Lets a
1326 > * client see which build is hosting it — useful when inspecting the output of a
1327 > * remote agent host.
1328 > *
1329 > * All fields except {@link version} are optional — a build that does not track
1330 > * a particular field should omit it.
1331 > */
1332 > export interface IHostBuildInfo {
1333 > /** Product version (e.g. `1.96.0`). */
1334 > readonly version: string;
1335 > /** Commit SHA of the build, if known. */
1336 > readonly commit?: string;
1337 > /** Build date (ISO 8601), if known. */
1338 > readonly date?: string;
1339 > /** Release quality (e.g. `stable`, `insider`), if known. */
1340 > readonly quality?: string;
1341 > }
1342 >
1343 > /**
1344 > * Derives {@link IHostBuildInfo} from the host's {@link IProductService}.
1345 > */
1346 > export function hostBuildInfoFromProduct(productService: IProductService): IHostBuildInfo {
1347 > return { agentService.ts ×10
1348 > version: productService.version,
1349 > commit: productService.commit,
1350 > date: productService.date,
1351 > quality: productService.quality,
1352 > };
1353 > }
1355 > /**
1356 > * Reads the well-known host-build payload from {@link RootMeta}, if present.
1357 > * Returns `undefined` when the meta bag is absent or the value at the host-build
1358 > * key is not a plain object with a string `version`. Optional fields with wrong
1359 > * types are silently dropped.
1360 > */
1361 > export function readHostBuildInfo(state: RootState | undefined): IHostBuildInfo | undefined {
1362 > const meta = state?._meta; sessionState.ts ×1
1363 > const value = meta?.[ROOT_META_HOST_BUILD_KEY];
1364 > if (!value || typeof value !== 'object' || Array.isArray(value)) {
1365 > return undefined; sessionState.ts ×1
1366 > }
1367 > const raw = value as Record<string, unknown>; sessionState.ts ×1
1368 > if (typeof raw['version'] !== 'string') {
1369 > return undefined; sessionState.ts ×1
1370 > }
1371 > const result: { version: string; commit?: string; date?: string; quality?: string } = { sessionState.ts ×1
1372 > version: raw['version'],
1373 > };
1374 > if (typeof raw['commit'] === 'string') { result.commit = raw['commit']; }
1375 > if (typeof raw['date'] === 'string') { result.date = raw['date']; }
1376 > if (typeof raw['quality'] === 'string') { result.quality = raw['quality']; }
1377 > return result;
1378 > }
1380 > /**
1381 > * Returns a new {@link RootMeta} with the host-build payload set to
1382 > * `buildInfo`, or with the slot removed if `buildInfo` is `undefined`. Returns
1383 > * `undefined` if the result would be empty.
1384 > */
1385 > export function withHostBuildInfo(meta: RootMeta | undefined, buildInfo: IHostBuildInfo | undefined): RootMeta | undefined {
1386 > const next: { [key: string]: unknown } = { ...meta }; sessionState.ts ×3
1387 > if (buildInfo !== undefined) {
1388 > next[ROOT_META_HOST_BUILD_KEY] = buildInfo; sessionState.ts ×1
1389 > } else { sessionState.ts ×3
1390 > delete next[ROOT_META_HOST_BUILD_KEY]; sessionState.ts ×1
1391 > }
1392 > return Object.keys(next).length > 0 ? next : undefined; sessionState.ts ×3
1393 > }
1395 > /**
1396 > * Formats {@link IHostBuildInfo} as a short single-line human-readable string,
1397 > * e.g. `1.96.0 (commit abc1234, 2024-01-02T03:04:05Z, insider)`.
1398 > */
1399 > export function formatHostBuildInfo(info: IHostBuildInfo): string {
1400 > const details: string[] = []; sessionState.ts ×1
1401 > if (info.commit) { details.push(`commit ${info.commit}`); }
1402 > if (info.date) { details.push(info.date); }
1403 > if (info.quality) { details.push(info.quality); }
1404 > return details.length > 0 ? `${info.version} (${details.join(', ')})` : info.version;
1405 > }