src/vs/platform/agentHost/node/agentHostStateManager.ts

1465 LOC · 1421 covered · 44 uncovered · 263 ranges · 3131 concepts · 117 introducers · 1399 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 > /*--------------------------------------------------------------------------------------------- agentHostStateManager.ts ×60
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 { RunOnceScheduler } from '../../../base/common/async.js';
7 > import { Emitter, Event } from '../../../base/common/event.js';
8 > import { Disposable } from '../../../base/common/lifecycle.js';
9 > import { equals } from '../../../base/common/objects.js';
10 > import { ILogService } from '../../log/common/log.js';
11 > import { createDecorator } from '../../instantiation/common/instantiation.js';
12 > import { TelemetryLevel } from '../../telemetry/common/telemetry.js';
13 > import { ActionType, ActionEnvelope, ActionOrigin, INotification, IRootConfigChangedAction, SessionAction, ChatAction, RootAction, StateAction, TerminalAction, ChangesetAction, ClientChangesetAction, AnnotationsAction, ClientAnnotationsAction, isRootAction, isSessionAction, isChatAction, isChangesetAction, isAnnotationsAction, type AuthRequiredParams, type ProgressParams } from '../common/state/sessionActions.js';
14 > import type { IStateSnapshot } from '../common/state/sessionProtocol.js';
15 > import { rootReducer, sessionReducer, chatReducer, changesetReducer, annotationsReducer } from '../common/state/sessionReducers.js';
16 > import { createRootState, createSessionState, createChatState, createDefaultChatSummary, chatSummaryFromState, buildDefaultChatUri, parseDefaultChatUri, parseRequiredSessionUriFromChatUri, isAhpChatChannel, isDefaultChatUri, mergeSessionWithDefaultChat, isAhpRootChannel, SessionLifecycle, withHostBuildInfo, type Changeset, type ChangesetState, type AnnotationsState, type ChatState, type ChatSummary, type Customization, type ISessionWithDefaultChat, type Message, type RootState, type SessionConfigState, type SessionMeta, type SessionState, type SessionSummary, type Turn, type URI, ROOT_STATE_URI, ChangesetStatus, IHostBuildInfo, SessionStatus } from '../common/state/sessionState.js';
17 > import { AgentHostTelemetryLevelConfigKey, IPermissionsValue, platformRootSchema, telemetryLevelToAgentHostConfigValue } from '../common/agentHostSchema.js';
18 > import { SessionConfigKey } from '../common/sessionConfigKeys.js';
19 > import { parseChangesetUri } from '../common/changesetUri.js';
20 > import { buildAnnotationsUri, isAnnotationsUri } from '../common/annotationsUri.js';
21 > import { AgentHostChangesetStateCache, type IAgentHostChangesetStateRetentionOptions } from './agentHostChangesetStateCache.js';
22 > import { ChangesSummary, ChatInteractivity, type ChatOrigin } from '../common/state/protocol/state.js';
23 > import { arrayEquals, structuralEquals } from '../../../base/common/equals.js';
24 > import { preserveProviderBackedRootConfigValues } from '../common/agentCustomizationSettings.js';
25 >
26 > export interface IAgentHostStateManagerOptions {
27 > readonly changesetStateRetention?: IAgentHostChangesetStateRetentionOptions;
28 > /**
29 > * Build information about the program hosting the agent host. When
30 > * provided, it is published on {@link RootState._meta} so clients can see
31 > * which build is hosting them.
32 > */
33 > readonly hostBuildInfo?: IHostBuildInfo;
34 > }
35 >
36 > /**
37 > * Authoritative per-session record held by the state manager. Bundles the flat
38 > * {@link SessionState} with the {@link SessionSummary} catalog-only fields that
39 > * do not live on the state. The session URI (catalog `resource`) is the map
40 > * key, and the catalog `_meta` is the same object as {@link SessionState._meta},
41 > * so the only extra fields the record carries are the timestamps and the
42 > * aggregate change counts.
43 > */
44 > interface ISessionEntry {
45 > state: SessionState;
46 > /** Creation timestamp (ISO 8601). Catalog-only; immutable after creation. */
47 > readonly createdAt: string;
48 > /** Last modification timestamp (ISO 8601). Catalog-only; derived from chat aggregation. */
49 > modifiedAt: string;
50 > /** Aggregate file-change counts for the session-wide changeset. Catalog-only. */
51 > changes?: ChangesSummary;
52 > }
53 >
54 > /**
55 > * Encapsulates the root-channel summary-notification bookkeeping for the
56 > * {@link AgentHostStateManager}: the last {@link SessionSummary} announced to
57 > * clients per session (the diff baseline) and the set of sessions whose summary
58 > * changed since the last debounced flush. The snapshot map and the dirty set
59 > * are always mutated in lockstep, so keeping them together — rather than as two
60 > * loose fields on the manager — keeps the diffing state cohesive.
61 > *
62 > * The current summary for a session is sourced via the injected `getSummary`
63 > * callback; diff-based `root/sessionSummaryChanged` notifications are emitted
64 > * through `emit`.
65 > */
66 > class SessionSummaryNotifier extends Disposable {
67 >
68 > /** Last summary announced to clients (via sessionAdded or sessionSummaryChanged). */
69 > private readonly _lastNotified = new Map<string, SessionSummary>();
70 >
71 > /** Sessions whose summary changed since the last flush. */
72 > private readonly _dirty = new Set<string>();
73 >
74 > private readonly _scheduler = this._register(new RunOnceScheduler(() => this._flushAll(), 100));
75 >
76 > constructor(
77 > private readonly _getSummary: (session: string) => SessionSummary | undefined, agentHostStateManager.ts ×4
78 > private readonly _emit: (session: string, changes: Partial<SessionSummary>) => void,
79 > ) {
80 > super();
81 > }
83 > /** Records `summary` as the last value announced to clients for `session`. */
84 > announce(session: string, summary: SessionSummary): void {
85 > this._lastNotified.set(session, summary); agentHostStateManager.ts ×1
86 > }
88 > /** Whether `session` has already been announced to clients. */
89 > isAnnounced(session: string): boolean {
90 > return this._lastNotified.has(session); agentHostStateManager.ts ×1
91 > }
93 > /** Marks `session` dirty and schedules a debounced flush. */
94 > markDirty(session: string): void {
95 > this._dirty.add(session); agentHostStateManager.ts ×1
96 > this._scheduler.schedule();
97 > }
99 > /** Whether `session` has a pending (unflushed) summary change. */
100 > isDirty(session: string): boolean {
101 > return this._dirty.has(session); agentHostStateManager.ts ×5
102 > }
104 > /** Drops the pending dirty flag for `session` without flushing it. */
105 > clearDirty(session: string): void {
106 > this._dirty.delete(session); agentHostStateManager.ts ×7
107 > }
109 > /** Drops all notification bookkeeping for `session`. */
110 > remove(session: string): void {
111 > this._lastNotified.delete(session); agentHostStateManager.ts ×5
112 > this._dirty.delete(session);
113 > }
115 > private _flushAll(): void {
116 > for (const session of this._dirty) { agentHostStateManager.ts ×2
117 > this.flush(session); agentHostStateManager.ts ×1
118 > }
119 > this._dirty.clear(); agentHostStateManager.ts ×2
120 > }
122 > /**
123 > * Emits a `root/sessionSummaryChanged` notification for `session` if its
124 > * current summary differs from the last announced one, then advances the
125 > * snapshot. Does NOT clear the dirty flag — callers own that bookkeeping.
126 > */
127 > flush(session: string): void {
128 > const current = this._getSummary(session); agentHostStateManager.ts ×3
129 > const lastNotified = this._lastNotified.get(session);
130 > if (!current || !lastNotified) {
132 > }
134 > const changes: Partial<SessionSummary> = {};
135 > if (current.title !== lastNotified.title) { changes.title = current.title; }
136 > if (current.status !== lastNotified.status) { changes.status = current.status; }
137 > if (current.activity !== lastNotified.activity) { changes.activity = current.activity; }
138 > if (current.modifiedAt !== lastNotified.modifiedAt) { changes.modifiedAt = current.modifiedAt; }
139 > if (current.project !== lastNotified.project) { changes.project = current.project; }
140 > if (current.changes !== lastNotified.changes) { changes.changes = current.changes; }
141 > if (current.workingDirectories !== lastNotified.workingDirectories) { changes.workingDirectories = current.workingDirectories; }
142 > if (current._meta !== lastNotified._meta) { changes._meta = current._meta; }
143 >
144 > this._lastNotified.set(session, current);
145 >
146 > if (Object.keys(changes).length > 0) {
147 > this._emit(session, changes); agentHostStateManager.ts ×2
148 > }
151 >
152 > /**
153 > * Server-side state manager for the sessions process protocol.
154 > *
155 > * Maintains the authoritative state tree (root + per-session), applies actions
156 > * through pure reducers, assigns monotonic sequence numbers, and emits
157 > * {@link ActionEnvelope}s for subscribed clients.
158 > */
159 > export const IAgentHostStateManager = createDecorator<AgentHostStateManager>('agentHostStateManager');
160 >
161 > export class AgentHostStateManager extends Disposable {
162 > declare readonly _serviceBrand: undefined;
163 >
164 > private _serverSeq = 0;
165 >
166 > private _rootState: RootState;
167 >
168 > /**
169 > * Authoritative per-session state, keyed by session URI string. Each entry
170 > * bundles the flat {@link SessionState} with the catalog-only fields that
171 > * are not part of the state (`createdAt`, `modifiedAt`, `changes`). The
172 > * root-channel {@link SessionSummary} catalog view is derived on demand from
173 > * an entry via {@link getSessionSummary} (its `_meta` is the same object as
174 > * {@link SessionState._meta}); the host streams catalog deltas via
175 > * `root/sessionSummaryChanged`.
176 > */
177 > private readonly _sessionStates = new Map<string, ISessionEntry>();
178 >
179 > /**
180 > * Authoritative per-chat conversation state, keyed by chat channel URI.
181 > * The protocol moved turns/activeTurn/pending state off the session and
182 > * onto a per-chat channel. VS Code currently models every session as
183 > * having exactly one chat — its default chat — whose URI is derived
184 > * deterministically from the session URI via {@link buildDefaultChatUri}.
185 > */
186 > private readonly _chatStates = new Map<string, ChatState>();
187 >
188 > /**
189 > * Opaque, agent-owned `providerData` blobs keyed by peer-chat channel URI.
190 > *
191 > * Each entry is the verbatim token the owning agent produced for a peer
192 > * chat (see {@link IAgentCreateChatResult.providerData}). The orchestrator
193 > * persists it with the session and hands it back to the agent on restore so
194 > * the agent can re-materialize its SDK conversation; the StateManager itself
195 > * **never parses, validates, or mutates it** — it stores and returns the
196 > * string as-is. The map is kept separate from the protocol-visible
197 > * {@link ChatState}/{@link ChatSummary} catalog so the private blob is not
198 > * streamed to clients. The default chat carries no `providerData`, so it
199 > * never appears here.
200 > */
201 > private readonly _chatProviderData = new Map<string, string>();
202 >
203 > /** Expanded changeset states, separated from protocol sequencing so cache policy stays local. */
204 > private readonly _changesets: AgentHostChangesetStateCache;
205 >
206 > /**
207 > * Per-channel annotation states for the `<session>/annotations` channel.
208 > * Unlike changesets (server-owned), annotation actions are
209 > * client-dispatchable and lazily create their state on first write.
210 > */
211 > private readonly _annotations = new Map<string, AnnotationsState>();
212 >
213 > /**
214 > * Active turns per session, keyed by session URI string with the value
215 > * being the set of that session's chat channel URIs that currently have an
216 > * active turn. A session is "active" while at least one of its chats is
217 > * streaming — this stays correct for multi-chat sessions whose chats can run
218 > * concurrent turns (e.g. agent-team / sub-agent workers), where the previous
219 > * single-flag-per-session model would clear too early. Active state is
220 > * derived from `state.activeTurn` (the source of truth maintained by the
221 > * session reducer) — never from raw action turn-ids — so that mismatched or
222 > * out-of-order turn lifecycle actions can't desync it from reality. The
223 > * session count (`size`) drives `RootActiveSessionsChanged` and
224 > * `hasActiveSessions`, which together gate `--enable-remote-auto-shutdown`.
225 > */
226 > private readonly _sessionsWithActiveTurn = new Map<string, Set<string>>();
227 >
228 > /**
229 > * Root-channel summary notification bookkeeping: the diff baseline (last
230 > * announced summary per session) and the dirty set, debounced into
231 > * `root/sessionSummaryChanged` notifications. Assigned in the constructor
232 > * since it closes over {@link _toSummary} and {@link _onDidEmitNotification}.
233 > */
234 > private readonly _summaryNotifier: SessionSummaryNotifier;
235 >
236 > private readonly _onDidEmitEnvelope = this._register(new Emitter<ActionEnvelope>());
237 > readonly onDidEmitEnvelope: Event<ActionEnvelope> = this._onDidEmitEnvelope.event;
238 >
239 > private readonly _onDidEmitNotification = this._register(new Emitter<INotification>());
240 > readonly onDidEmitNotification: Event<INotification> = this._onDidEmitNotification.event;
241 > private readonly _onDidChangeSessionActiveTurn = this._register(new Emitter<{ session: string; active: boolean }>());
242 > readonly onDidChangeSessionActiveTurn: Event<{ session: string; active: boolean }> = this._onDidChangeSessionActiveTurn.event;
243 >
244 > constructor(
245 > @ILogService private readonly _logService: ILogService, agentHostStateManager.ts ×4
246 > options: IAgentHostStateManagerOptions = {},
247 > ) {
248 > super();
249 > this._changesets = new AgentHostChangesetStateCache(options.changesetStateRetention);
250 > this._rootState = createRootState();
251 > // Seed the host-level configuration schema + default values so that
252 > // RootConfigChanged actions can merge into it, and clients see the
253 > // schema immediately upon subscribing to `agenthost:/root`. See
254 > // `platformRootSchema` for the set of platform-owned properties.
255 > this._rootState = {
256 > ...this._rootState,
257 > config: {
258 > schema: platformRootSchema.toProtocol(),
259 > values: platformRootSchema.validateOrDefault({}, {
260 > [SessionConfigKey.Permissions]: { allow: [], deny: [] } satisfies IPermissionsValue,
261 > [AgentHostTelemetryLevelConfigKey]: telemetryLevelToAgentHostConfigValue(TelemetryLevel.USAGE),
262 > }),
263 > },
264 > _meta: withHostBuildInfo(this._rootState._meta, options.hostBuildInfo),
265 > };
266 > this._summaryNotifier = this._register(new SessionSummaryNotifier(
267 > session => {
268 > const entry = this._sessionStates.get(session); agentHostStateManager.ts ×3
269 > return entry ? this._toSummary(session, entry) : undefined;
270 > },
271 > (session, changes) => this._onDidEmitNotification.fire({ agentHostStateManager.ts ×4
272 > type: 'root/sessionSummaryChanged', agentHostStateManager.ts ×2
273 > channel: ROOT_STATE_URI,
274 > session,
275 > changes,
276 > }),
278 > }
279 > private readonly _log = (msg: string) => this._logService.warn(`[AgentHostStateManager] ${msg}`); agentHostStateManager.ts ×60
280 >
281 > get hasActiveSessions(): boolean {
282 > return this._sessionsWithActiveTurn.size > 0; agentHostStateManager.ts ×1
283 > }
285 > /**
286 > * Whether the given session currently has an active turn — i.e. a request is
287 > * in progress on any of its chats. Stays `true` while at least one chat is
288 > * streaming, so it remains correct for multi-chat sessions running
289 > * concurrent turns.
290 > */
291 > hasActiveTurn(sessionKey: string): boolean {
292 > return this._sessionsWithActiveTurn.has(sessionKey); agentHostStateManager.ts ×1
293 > }
295 > // ---- State accessors ----------------------------------------------------
296 >
297 > get rootState(): RootState {
298 > return this._rootState; agentHostStateManager.ts ×1
299 > }
301 > getSessionState(sessionOrChat: URI): ISessionWithDefaultChat | undefined {
302 > // Accept either a session URI or one of its chat channel URIs. When a agentHostStateManager.ts ×3
303 > // chat URI is given the conversation contents are taken from that chat,
304 > // while the session summary/config come from the owning session.
305 > const isChat = isAhpChatChannel(sessionOrChat);
306 > const session = isChat ? parseDefaultChatUri(sessionOrChat) : sessionOrChat;
307 > if (session === undefined) {
308 return undefined;
309 }
310 > const entry = this._sessionStates.get(session); agentHostStateManager.ts ×3
311 > if (!entry) {
312 > return undefined; agentHostStateManager.ts ×1
313 > }
314 > const chatUri = isChat ? sessionOrChat : buildDefaultChatUri(session); agentHostStateManager.ts ×3
315 > return mergeSessionWithDefaultChat(entry.state, this._chatStates.get(chatUri));
316 > }
318 > /**
319 > * Returns the root-channel {@link SessionSummary} catalog entry for a
320 > * session, or `undefined` when the session is unknown. The summary is
321 > * derived on demand from the session's {@link ISessionEntry}: its metadata
322 > * fields and `_meta` come straight off the live {@link SessionState}, while
323 > * the catalog-only `resource` / `createdAt` / `modifiedAt` / `changes` come
324 > * from the entry.
325 > */
326 > getSessionSummary(session: URI): SessionSummary | undefined {
327 > const entry = this._sessionStates.get(session); agentHostStateManager.ts ×1
328 > return entry ? this._toSummary(session, entry) : undefined;
329 > }
331 > /**
332 > * Projects an {@link ISessionEntry} into its root-channel
333 > * {@link SessionSummary}. The summary's `_meta` is the same object as
334 > * {@link SessionState._meta} — the host treats the two as identical.
335 > */
336 > private _toSummary(session: string, entry: ISessionEntry): SessionSummary {
337 > const { state } = entry; agentHostStateManager.ts ×1
338 > const summary: SessionSummary = {
339 > resource: session,
340 > provider: state.provider,
341 > title: state.title,
342 > status: state.status,
343 > createdAt: entry.createdAt,
344 > modifiedAt: entry.modifiedAt,
345 > };
346 > if (state.activity !== undefined) { summary.activity = state.activity; }
347 > if (state.project !== undefined) { summary.project = state.project; }
348 > if (state.workingDirectories !== undefined) { summary.workingDirectories = state.workingDirectories; }
349 > if (state.annotations !== undefined) { summary.annotations = state.annotations; }
350 > if (entry.changes !== undefined) { summary.changes = entry.changes; }
351 > if (state._meta !== undefined) { summary._meta = state._meta; }
352 > return summary;
353 > }
355 > /**
356 > * Whether the {@link SessionSummary}-relevant fields of two session states
357 > * are field-equal. Used to decide whether a session action mutated anything
358 > * the root-channel catalog cares about.
359 > */
360 > private _summaryFieldsEqual(a: SessionState, b: SessionState): boolean {
361 > return a.title === b.title agentHostStateManager.ts ×4
362 > && a.status === b.status agentHostStateManager.ts ×1
363 > && a.activity === b.activity
364 > && a.project === b.project
365 > && a.workingDirectories === b.workingDirectories
366 > && a.annotations === b.annotations
367 > && a._meta === b._meta;
370 > /**
371 > * Returns the authoritative {@link ChatState} for a session's default
372 > * chat, or `undefined` when the session is unknown. Use this when the
373 > * caller specifically needs conversation contents (turns, activeTurn,
374 > * pending/input state) rather than the session summary.
375 > */
376 > getDefaultChatState(session: URI): ChatState | undefined {
377 > return this._chatStates.get(buildDefaultChatUri(session)); agentHostStateManager.ts ×1
378 > }
380 > /** Returns the authoritative {@link ChatState} for a chat channel URI. */
381 > getChatState(chat: URI): ChatState | undefined {
382 > return this._chatStates.get(chat); agentHostStateManager.ts ×1
383 > }
385 > /**
386 > * Returns the opaque, agent-owned `providerData` blob previously recorded
387 > * for a peer chat via {@link addChat} or {@link restoreChat}, or `undefined`
388 > * when none was stored (e.g. the default chat, or a peer chat the agent had
389 > * nothing resumable to persist for). The value is returned verbatim — the
390 > * StateManager never interprets it; callers persist it with the session and
391 > * hand it back to the owning agent on restore.
392 > */
393 > getChatProviderData(chat: URI): string | undefined {
394 > return this._chatProviderData.get(chat); agentHostStateManager.ts ×1
395 > }
397 > /**
398 > * Seeds the conversation contents (turns) of a session's default chat.
399 > * Used by the fork flow, which materializes a new session pre-populated
400 > * with a slice of the source session's turns.
401 > */
402 > seedDefaultChatTurns(session: URI, turns: Turn[]): void {
403 > const chatState = this._chatStates.get(buildDefaultChatUri(session)); agentHostStateManager.ts ×1
404 > if (chatState) {
405 > chatState.turns = turns;
406 > }
407 > }
409 > get serverSeq(): number {
410 > return this._serverSeq; agentHostStateManager.ts ×1
411 > }
413 > getSessionUris(): string[] {
414 > return [...this._sessionStates.keys()]; agentHostStateManager.ts ×1
415 > }
417 > /**
418 > * Summaries eligible to be overlaid onto a provider's `listSessions`
419 > * snapshot when that snapshot is missing them. A session qualifies if it
420 > * has materialized (lifecycle !== {@link SessionLifecycle.Creating}) — this
421 > * covers the transient-drop case where a provider briefly omits a
422 > * just-materialized session — or if it is still provisional but has had any
423 > * turn activity (an in-flight turn, or a completed turn whose materialize
424 > * event has not landed yet; the first turn can start before materialization
425 > * completes). Idle provisional sessions (created but not yet materialized
426 > * and with no turn activity, e.g. the new-session composer's eagerly-created
427 > * session before its first message) are excluded so they don't leak into
428 > * the session list (#321269).
429 > */
430 > getOverlaySessionSummaries(): SessionSummary[] {
431 > const summaries: SessionSummary[] = []; agentService.ts ×4
432 > for (const [key, entry] of this._sessionStates) {
433 > // Turn activity lives on the session's default chat after the agentHostStateManager.ts ×2
434 > // multi-chat protocol move, so consult that chat's turns/activeTurn.
435 > const chat = this._chatStates.get(buildDefaultChatUri(key));
436 > if (entry.state.lifecycle === SessionLifecycle.Creating && !chat?.activeTurn && (chat?.turns.length ?? 0) === 0) {
438 > }
439 > summaries.push(this._toSummary(key, entry)); agentHostStateManager.ts ×2
440 > }
441 > return summaries; agentService.ts ×4
442 > }
444 > /**
445 > * Returns all session URIs whose keys start with the given prefix.
446 > * Used to discover subagent sessions for a given parent.
447 > */
448 > getSessionUrisWithPrefix(prefix: string): string[] {
449 > const result: string[] = []; agentService.ts ×10
450 > for (const key of this._sessionStates.keys()) {
451 > if (key.startsWith(prefix)) {
452 > result.push(key); agentService.ts ×4
453 > }
455 > return result;
456 > }
458 > // ---- Snapshots ----------------------------------------------------------
459 >
460 > /**
461 > * Returns a state snapshot for a given resource URI.
462 > * The `fromSeq` in the snapshot is the current serverSeq at snapshot time;
463 > * the client should process subsequent envelopes with serverSeq > fromSeq.
464 > */
465 > getSnapshot(resource: URI): IStateSnapshot | undefined {
466 > if (isAhpRootChannel(resource)) { agentHostStateManager.ts ×2
468 > resource: ROOT_STATE_URI,
469 > state: this._rootState,
470 > fromSeq: this._serverSeq,
471 > };
472 > }
474 > // Changeset URIs are nested under their session URI; check them
475 > // before falling back to the session map so a session whose URI
476 > // happens to share a prefix with a changeset never collides.
477 > const changesetState = this._changesets.get(resource);
478 > if (changesetState) {
480 > resource,
481 > state: changesetState,
482 > fromSeq: this._serverSeq,
483 > };
484 > }
486 > // Chat channel URIs resolve to per-chat conversation state.
487 > if (isAhpChatChannel(resource)) {
488 > const chatState = this._chatStates.get(resource); agentHostStateManager.ts ×1
489 > if (!chatState) {
490 > return undefined; agentHostStateManager.ts ×1
491 > }
493 > resource,
494 > state: chatState,
495 > fromSeq: this._serverSeq,
496 > };
497 > }
499 > // Annotation URIs are nested under their session URI as well. They are
500 > // client-dispatchable and lazily created, so return an empty state for
501 > // a well-formed annotations URI even before the first write.
502 > if (isAnnotationsUri(resource)) {
503 > return { reducer.ts ×7
504 > resource,
505 > state: this._annotations.get(resource) ?? { annotations: [] },
506 > fromSeq: this._serverSeq,
507 > };
508 > }
510 > const entry = this._sessionStates.get(resource);
511 > if (!entry) {
512 > return undefined; agentHostStateManager.ts ×1
513 > }
515 > return {
516 > resource,
517 > state: entry.state,
518 > fromSeq: this._serverSeq,
519 > };
522 > /** Read-only accessor for callers that only need to inspect a changeset (not subscribe). */
523 > getChangesetState(changeset: URI): ChangesetState | undefined {
524 > return this._changesets.get(changeset); agentHostStateManager.ts ×1
525 > }
527 > /** Reconsiders changeset state retention after subscribers or computes release their pins. */
528 > onChangesetLivenessChanged(): void {
529 > this._changesets.trimEvictableEntries(); agentHostChangesetStateCache.ts ×1
530 > }
532 > // ---- Session lifecycle --------------------------------------------------
533 >
534 > /**
535 > * Creates a new session in state with `lifecycle: 'creating'`.
536 > * Returns the initial session state.
537 > *
538 > * By default a {@link NotificationType.SessionAdded} notification is
539 > * emitted so clients see the new session immediately. Pass
540 > * `options.emitNotification: false` to defer the notification — a typical
541 > * use is for **provisional** sessions that exist on the server but should
542 > * not appear in client session lists until they have been persisted by
543 > * the agent (e.g. on the first message that materializes an SDK session
544 > * and writes its on-disk metadata). Call {@link markSessionPersisted}
545 > * afterwards to fire the deferred notification.
546 > */
547 > createSession(summary: SessionSummary, options?: { readonly emitNotification?: boolean }): SessionState {
548 > const key = summary.resource; agentHostStateManager.ts ×3
549 > const existing = this._sessionStates.get(key);
550 > if (existing) {
551 > this._logService.warn(`[AgentHostStateManager] Session already exists: ${key}`); agentHostStateManager.ts ×1
552 > return existing.state;
553 > }
555 > const state = createSessionState(summary);
556 > this._sessionStates.set(key, this._newEntry(state, summary));
557 > this._ensureDefaultChat(key, summary);
558 >
559 > this._logService.trace(`[AgentHostStateManager] Created session: ${key}`);
560 >
561 > if (options?.emitNotification !== false) {
562 > // Announcing the summary to the notifier is what makes agentHostStateManager.ts ×1
563 > // its later flush emit incremental updates and what makes
564 > // `markSessionPersisted` a no-op. Provisional sessions
565 > // intentionally skip both until they are persisted.
566 > this._summaryNotifier.announce(key, summary);
567 > this._onDidEmitNotification.fire({
568 > type: 'root/sessionAdded',
569 > channel: ROOT_STATE_URI,
570 > summary,
571 > });
572 > }
574 > return state;
575 > }
577 > /** Builds the authoritative {@link ISessionEntry} for a freshly seeded state. */
578 > private _newEntry(state: SessionState, summary: SessionSummary): ISessionEntry {
579 > return { state, createdAt: summary.createdAt, modifiedAt: summary.modifiedAt, changes: summary.changes }; agentHostStateManager.ts ×2
580 > }
582 > /**
583 > * Fire a {@link NotificationType.SessionAdded} notification for a session
584 > * whose creation was deferred via `createSession({ emitNotification: false })`.
585 > *
586 > * Propagates the materialization-resolved catalog fields (`project`,
587 > * `workingDirectory`, `modifiedAt`, `changes`) from the supplied summary
588 > * onto the session entry so subscribers see them. The reducer-owned metadata
589 > * (`title`, `status`, `activity`) is intentionally NOT copied back — the live
590 > * state is authoritative for those. No-ops for sessions that were already
591 > * announced (idempotent).
592 > */
593 > markSessionPersisted(session: URI, summary: SessionSummary): void {
594 > const key = session.toString(); agentHostStateManager.ts ×3
595 > const entry = this._sessionStates.get(key);
596 > if (!entry) {
597 this._logService.warn(`[AgentHostStateManager] markSessionPersisted: unknown session ${key}`);
598 return;
599 }
600 > // The notifier records a session's announced summary whenever it has agentHostStateManager.ts ×3
601 > // been surfaced to clients (either through `createSession` or here);
602 > // using it as the idempotency check keeps us from firing `SessionAdded`
603 > // twice for a session whose creation was not deferred.
604 > if (this._summaryNotifier.isAnnounced(key)) {
606 > }
607 > // Propagate the materialization-resolved fields so subscribers calling agentHostStateManager.ts ×1
608 > // `getSessionState` / `getSessionSummary` see the resolved working
609 > // directory / project. We don't need to schedule a
610 > // `SessionSummaryChanged` flush because the upcoming `SessionAdded`
611 > // notification carries the complete summary already.
612 > entry.state = { ...entry.state, project: summary.project, workingDirectories: summary.workingDirectories };
613 > entry.modifiedAt = summary.modifiedAt;
614 > entry.changes = summary.changes;
615 > const full = this._toSummary(key, entry);
616 > this._summaryNotifier.announce(key, full);
617 > this._onDidEmitNotification.fire({
618 > type: 'root/sessionAdded',
619 > channel: ROOT_STATE_URI,
620 > summary: full,
621 > });
624 > /**
625 > * Restores a session from a previous server lifetime into the state manager
626 > * with pre-populated turns. The session is created in `ready` lifecycle
627 > * state since it already exists on the backend.
628 > *
629 > * Unlike {@link createSession}, this does NOT emit a `sessionAdded`
630 > * notification because the session is already known to clients via
631 > * `listSessions`.
632 > */
633 > restoreSession(summary: SessionSummary, turns: Turn[], options?: { readonly draft?: Message; readonly defaultChatTitle?: string }): SessionState {
634 > const key = summary.resource; agentHostStateManager.ts ×2
635 > const existing = this._sessionStates.get(key);
636 > if (existing) {
637 > this._logService.warn(`[AgentHostStateManager] Session already exists (restore): ${key}`); agentHostStateManager.ts ×1
638 > return existing.state;
639 > }
641 > const state: SessionState = {
642 > ...createSessionState(summary),
643 > lifecycle: SessionLifecycle.Ready,
644 > };
645 > this._sessionStates.set(key, this._newEntry(state, summary));
646 > this._ensureDefaultChat(key, summary, turns, options?.draft, options?.defaultChatTitle); agentHostStateManager.ts ×2
647 > this._summaryNotifier.announce(key, summary);
648 >
649 > this._logService.trace(`[AgentHostStateManager] Restored session: ${key} (${turns.length} turns)`);
650 >
651 > return state;
652 > }
654 > /**
655 > * Creates the default {@link ChatState} for a session and records it as
656 > * the session's single chat. VS Code models every session as having
657 > * exactly one chat — its default chat — whose URI is derived
658 > * deterministically from the session URI. The chat is seeded with any
659 > * pre-populated `turns` (used by {@link restoreSession}).
660 > *
661 > * The session's `chats` catalog and `defaultChat` pointer are updated
662 > * in place rather than via dispatched actions: there are no subscribers
663 > * at creation/restore time, so the snapshot a client later receives on
664 > * subscribe already reflects the default chat.
665 > */
666 > private _ensureDefaultChat(sessionKey: string, summary: SessionSummary, turns?: Turn[], draft?: Message, defaultChatTitle?: string): void {
667 > const chatUri = buildDefaultChatUri(sessionKey); agentHostStateManager.ts ×2
668 > // Empty title means "inherit the session title"; a persisted independent
669 > // rename (`defaultChatTitle`) is seeded back here so it survives restore.
670 > const chatSummary: ChatSummary = { ...createDefaultChatSummary(summary, chatUri), title: defaultChatTitle ?? '' };
671 > this._chatStates.set(chatUri, { ...createChatState(chatSummary), turns: turns ?? [], draft });
672 > const entry = this._sessionStates.get(sessionKey);
673 > if (entry) {
674 > // Update the session's chat catalog in place so the object
675 > // identity returned by `createSession`/`restoreSession` stays
676 > // live in the map. Callers (e.g. `AgentService.createSession`)
677 > // mutate the returned state directly (`state.config = …`), so
678 > // replacing the map entry with a fresh clone here would strand
679 > // those mutations on a detached object.
680 > entry.state.chats = [chatSummary];
681 > entry.state.defaultChat = chatUri;
682 > }
683 > }
685 > /**
686 > * Adds an additional (non-default) chat to an existing session. Creates
687 > * the chat's authoritative {@link ChatState}, registers it in the session's
688 > * catalog via a dispatched {@link ActionType.SessionChatAdded} action (so
689 > * live subscribers refresh), and returns the new chat's summary.
690 > *
691 > * The chat inherits the session's model/agent/working-directory scope. It
692 > * is a no-op (returning the existing summary) when a chat with the same URI
693 > * already exists.
694 > *
695 > * When `options.providerData` is supplied it is recorded verbatim as the
696 > * peer chat's opaque, agent-owned restore blob (see
697 > * {@link getChatProviderData}); the StateManager never parses it. The
698 > * default chat never carries `providerData`.
699 > */
700 > addChat(session: URI, chatUri: URI, options?: { readonly title?: string; readonly turns?: Turn[]; readonly origin?: ChatOrigin; readonly providerData?: string; readonly interactivity?: ChatInteractivity }): ChatSummary | undefined {
701 > const entry = this._sessionStates.get(session); agentHostStateManager.ts ×4
702 > if (!entry) {
703 > this._logService.warn(`[AgentHostStateManager] addChat for unknown session: ${session}`); agentHostStateManager.ts ×1
704 > return undefined;
705 > }
706 > const sessionState = entry.state; agentHostStateManager.ts ×4
707 > const existing = sessionState.chats.find(c => c.resource === chatUri);
708 > if (existing) {
709 > return existing; agentHostStateManager.ts ×1
710 > }
712 > // A session gains its first additional chat here: snapshot the current
713 > // session title onto the still-inheriting default chat so the two
714 > // titles become fully independent. Without this the default chat keeps
715 > // an empty title (= inherit the session title), so renaming the session
716 > // would also move the default chat tab and vice-versa.
717 > const defaultChatUri = sessionState.defaultChat ?? buildDefaultChatUri(session);
718 > const defaultEntry = sessionState.chats.find(c => c.resource === defaultChatUri); agentHostStateManager.ts ×4
719 > if (defaultEntry && !defaultEntry.title && sessionState.title) {
720 > this.updateChatTitle(session, defaultChatUri, sessionState.title); agentHostStateManager.ts ×1
721 > }
723 > const chatSummary: ChatSummary = {
724 > ...createDefaultChatSummary(this._toSummary(session, entry), chatUri),
725 > title: options?.title ?? '', agentHostStateManager.ts ×4
726 > status: SessionStatus.Idle,
727 > origin: options?.origin,
728 > interactivity: options?.interactivity,
729 > };
730 > this._chatStates.set(chatUri, { ...createChatState(chatSummary), turns: options?.turns ?? [] });
731 > if (options?.providerData !== undefined) {
732 > this._chatProviderData.set(chatUri, options.providerData); agentHostStateManager.ts ×1
733 > }
734 > this.dispatchServerAction(session, { type: ActionType.SessionChatAdded, summary: chatSummary }); agentHostStateManager.ts ×4
735 > return chatSummary;
738 > /**
739 > * Re-registers an additional (non-default) peer chat when a session is
740 > * restored from persistent storage, seeding its {@link ChatState} with the
741 > * supplied turns. Unlike {@link addChat} this does not snapshot the session
742 > * title onto the default chat (the default chat's persisted title is
743 > * restored independently) and it seeds history. The catalog entry is added
744 > * in place so the object identity returned by {@link restoreSession} stays
745 > * live; no {@link ActionType.SessionChatAdded} is dispatched because restore
746 > * runs before clients subscribe.
747 > *
748 > * When `options.providerData` is supplied it is recorded verbatim as the
749 > * peer chat's opaque, agent-owned restore blob (see
750 > * {@link getChatProviderData}); the StateManager never parses it.
751 > */
752 > restoreChat(session: URI, chatUri: URI, options: { readonly title?: string; readonly turns: Turn[]; readonly draft?: Message; readonly providerData?: string; readonly origin?: ChatOrigin }): void {
753 > const entry = this._sessionStates.get(session); agentHostStateManager.ts ×3
754 > if (!entry) {
755 > this._logService.warn(`[AgentHostStateManager] restoreChat for unknown session: ${session}`); agentHostStateManager.ts ×1
756 > return;
757 > }
758 > const sessionState = entry.state; agentHostStateManager.ts ×1
759 > if (sessionState.chats.some(c => c.resource === chatUri)) {
761 > }
762 > const chatSummary: ChatSummary = { agentHostStateManager.ts ×2
763 > ...createDefaultChatSummary(this._toSummary(session, entry), chatUri),
764 > title: options.title ?? '',
765 > status: SessionStatus.Idle, agentHostStateManager.ts ×3
766 > origin: options.origin,
767 > };
768 > this._chatStates.set(chatUri, { ...createChatState(chatSummary), turns: options.turns, draft: options.draft });
769 > if (options.providerData !== undefined) {
770 > this._chatProviderData.set(chatUri, options.providerData); agentHostStateManager.ts ×1
771 > }
772 > sessionState.chats = [...sessionState.chats, chatSummary]; agentHostStateManager.ts ×2
775 > /**
776 > * Removes an additional chat from a session. Deletes its
777 > * {@link ChatState}, dispatches {@link ActionType.SessionChatRemoved}, and
778 > * — if the removed chat was the default — repoints `defaultChat` to the
779 > * first remaining chat. The default chat itself cannot be removed in
780 > * isolation; it lives and dies with its session.
781 > */
782 > removeChat(session: URI, chatUri: URI): void {
783 > const entry = this._sessionStates.get(session); agentHostStateManager.ts ×3
784 > if (!entry || !entry.state.chats.some(c => c.resource === chatUri)) {
786 > }
787 > const sessionState = entry.state; reducer.ts ×3
788 > if (chatUri === sessionState.defaultChat || isDefaultChatUri(chatUri)) { agentHostStateManager.ts ×3
789 > this._logService.warn(`[AgentHostStateManager] refusing to remove default chat: ${chatUri}`); agentHostStateManager.ts ×1
790 > return;
791 > }
792 > // Drop the chat from its session's active-turn set before deleting its reducer.ts ×3
793 > // state. A peer chat can be removed while it still has an active turn;
794 > // because active-turn tracking is driven by chat state transitions,
795 > // deleting the ChatState here without this would strand the chat URI in
796 > // the active set forever, keeping the session permanently "active"
797 > // (activeSessions > 0) and leaving changeset operations disabled.
798 > this._removeChatActiveTurn(session, chatUri);
799 > this._chatStates.delete(chatUri);
800 > this._chatProviderData.delete(chatUri);
801 > this.dispatchServerAction(session, { type: ActionType.SessionChatRemoved, chat: chatUri });
804 > /**
805 > * Renames a single chat within a session independently of the session
806 > * title. Updates the chat's authoritative {@link ChatState} title (so
807 > * later `chatSummaryFromState` projections stay consistent) and dispatches
808 > * a {@link ActionType.SessionChatUpdated} so the session's catalog entry and
809 > * live subscribers reflect the new title. Works for the default chat too —
810 > * giving it a non-empty title that no longer inherits the session title.
811 > */
812 > updateChatTitle(session: URI, chatUri: URI, title: string): void {
813 > const chatState = this._chatStates.get(chatUri); agentHostStateManager.ts ×1
814 > if (chatState) {
815 > this._chatStates.set(chatUri, { ...chatState, title });
816 > }
817 > this.dispatchServerAction(session, { type: ActionType.SessionChatUpdated, chat: chatUri, changes: { title } });
818 > }
820 > /**
821 > * Removes a session from in-memory state without emitting a
822 > * {@link NotificationType.SessionRemoved} notification.
823 > * Use {@link deleteSession} when the session is being permanently deleted
824 > * and clients need to be notified of its removal.
825 > *
826 > * Any pending summary change is flushed synchronously before the session is
827 > * torn down, so clients receive the final status (e.g. Idle after a turn
828 > * completes) even when the session is evicted before the scheduler fires.
829 > * A {@link NotificationType.SessionSummaryChanged} notification may therefore
830 > * be emitted as a side-effect of this call.
831 > *
832 > * Per-session changesets are intentionally NOT torn down here: this method
833 > * is also used as an idle-eviction (LRU) hook (see
834 > * `AgentService._maybeEvictIdleSession`) and the session list view keeps a
835 > * changeset subscription open per visible row to render the diff chip.
836 > * Tearing down on eviction would clear the chip on the list while the row
837 > * is still on screen. Permanent-delete paths (`deleteSession`,
838 > * `removeSubagentSessions`) call `disposeSessionChangesets` explicitly
839 > * before invoking `removeSession`.
840 > */
841 > removeSession(session: URI): void {
842 > const entry = this._sessionStates.get(session); agentHostStateManager.ts ×2
843 > if (!entry) {
845 > }
847 > // Flush any pending summary notification before tearing down state so
848 > // that the final status (e.g. Idle) reaches clients even if the session
849 > // is evicted within the scheduler's debounce window.
850 > if (this._summaryNotifier.isDirty(session)) {
851 > this._summaryNotifier.flush(session); agentHostStateManager.ts ×1
852 > }
854 > // Clean up active turn tracking. We must dispatch
855 > // `RootActiveSessionsChanged` if the count actually changes so that
856 > // downstream consumers (e.g. the server lifetime tracker driving
857 > // `--enable-remote-auto-shutdown`) release their hold on the process.
858 > // Without this, evicting a session that still has an active turn
859 > // silently strands the active-sessions count above zero forever.
860 > if (this._sessionsWithActiveTurn.delete(session)) {
861 > this._onDidChangeSessionActiveTurn.fire({ session, active: false }); agentHostStateManager.ts ×1
862 > this.dispatchServerAction(ROOT_STATE_URI, { type: ActionType.RootActiveSessionsChanged, activeSessions: this._sessionsWithActiveTurn.size });
863 > }
865 > // Tear down every chat owned by the session, not just the default
866 > // chat: additional peer chats each hold their own ChatState.
867 > for (const chat of entry.state.chats) {
868 > this._chatStates.delete(chat.resource);
869 > this._chatProviderData.delete(chat.resource);
870 > }
871 > this._chatStates.delete(buildDefaultChatUri(session));
872 > this._sessionStates.delete(session);
873 > this._summaryNotifier.remove(session);
874 > this._logService.trace(`[AgentHostStateManager] Removed session: ${session}`);
877 > /**
878 > * Permanently deletes a session from state and emits a
879 > * {@link NotificationType.SessionRemoved} notification so that clients
880 > * know the session is no longer accessible.
881 > *
882 > * Sessions whose creation was deferred via
883 > * `createSession({ emitNotification: false })` and never persisted via
884 > * {@link markSessionPersisted} are removed silently — no client knows
885 > * about them, so a `SessionRemoved` would be noise (or worse, would
886 > * cause clients to drop a session URI they had eagerly subscribed to).
887 > */
888 > deleteSession(session: URI): void {
889 > const wasAnnounced = this._summaryNotifier.isAnnounced(session); agentHostStateManager.ts ×7
890 > // Drop any pending summary diff: the forthcoming SessionRemoved notification
891 > // supersedes it and we don't want to emit spurious SessionSummaryChanged
892 > // events just before the session disappears from the client's view.
893 > this._summaryNotifier.clearDirty(session);
894 > // Tear down per-session changesets first so subscribers see the
895 > // final `changeset/cleared` envelope before the session itself goes
896 > // away. The envelopes flow through the same emitter as everything
897 > // else, so callers observing `onDidEmitEnvelope` get a deterministic
898 > // order: changeset/cleared (per changeset) → session removal.
899 > this.disposeSessionChangesets(session);
900 > this.disposeSessionAnnotations(session);
901 > this.removeSession(session);
902 > if (wasAnnounced) {
903 > this._onDidEmitNotification.fire({ agentHostStateManager.ts ×1
904 > type: 'root/sessionRemoved',
905 > channel: ROOT_STATE_URI,
906 > session,
907 > });
908 > }
911 > // ---- Session meta -------------------------------------------------------
912 >
913 > /**
914 > * Replaces `state._meta` on a session by dispatching a
915 > * {@link ActionType.SessionMetaChanged} action so the change flows
916 > * through the action envelope (and thus to all live subscribers).
917 > *
918 > * The full `_meta` object is replaced (not merged) so callers stay in
919 > * control of the convention for their own keys; use the `withSessionXxx`
920 > * helpers in `sessionState.ts` to combine slots.
921 > */
922 > setSessionMeta(session: URI, meta: SessionMeta | undefined): void {
923 > this.dispatchServerAction(session, { type: ActionType.SessionMetaChanged, _meta: meta }); agentHostStateManager.ts ×1
924 > }
926 > /**
927 > * Seeds or replaces a session's resolved {@link SessionConfigState} on the
928 > * live session state. Unlike mid-session {@link ActionType.SessionConfigChanged}
929 > * updates (which merge values onto an existing config), this establishes
930 > * the initial config and is therefore an in-place mutation of the
931 > * authoritative state object so the value is present in the first snapshot
932 > * a subscriber receives. Use this from create/restore flows where the
933 > * config is resolved asynchronously after the session state already exists
934 > * in the map — reading back through {@link getSessionState} would return a
935 > * detached composite copy and stranding the mutation there.
936 > */
937 > setSessionConfig(session: URI, config: SessionConfigState | undefined): void {
938 > const entry = this._sessionStates.get(session); agentHostStateManager.ts ×2
939 > if (!entry) {
940 this._logService.warn(`[AgentHostStateManager] setSessionConfig: unknown session ${session}`);
941 return;
942 }
943 > entry.state.config = config; agentHostStateManager.ts ×2
944 > }
946 > /**
947 > * Seeds or replaces the session's effective customizations directly on the
948 > * authoritative in-memory state. Used by create/restore flows to ensure the
949 > * first snapshot already contains customizations.
950 > */
951 > setSessionCustomizations(session: URI, customizations: readonly Customization[] | undefined): void {
952 > const entry = this._sessionStates.get(session); agentService.ts ×3
953 > if (!entry) {
954 this._logService.warn(`[AgentHostStateManager] setSessionCustomizations: unknown session ${session}`);
955 return;
956 }
957 > entry.state.customizations = customizations ? [...customizations] : undefined; agentService.ts ×3
958 > }
960 > // ---- Changeset registry -------------------------------------------------
961 >
962 > /**
963 > * Registers a server-side changeset so that subscribers can attach to its
964 > * URI. The changeset is created with the supplied initial status (default
965 > * {@link ChangesetStatus.Computing}); subsequent file/operation/status
966 > * mutations flow through {@link dispatchChangesetAction} on the
967 > * canonical `<sessionUri>/changeset/<changesetId>` URI.
968 > *
969 > * Idempotent: a second call with the same URI is a no-op so producers
970 > * can safely re-register on session resume without double-creating
971 > * state.
972 > *
973 > * Callers construct `changesetUri` via {@link buildSessionChangesetUri}
974 > * for the session-wide entry, or {@link buildChangesetUri} for any
975 > * other catalogue entry.
976 > *
977 > * Returns the supplied changeset URI for caller convenience.
978 > */
979 > registerChangeset(changesetUri: URI, initialStatus: ChangesetStatus = ChangesetStatus.Computing): URI {
980 > this._changesets.register(changesetUri, initialStatus); agentHostChangesetStateCache.ts ×6
981 > return changesetUri;
982 > }
984 > /**
985 > * Updates the aggregate `changes` for a session.
986 > *
987 > * There is no dedicated action for this field: the value is purely
988 > * informational (chip rendering on the session list), so the write
989 > * piggybacks on the existing `sessionSummaryChanged` notification
990 > * path. We update the session entry, mark the session dirty, and let
991 > * the summary notifier's flush pick the new value up via its
992 > * `current.changes !== lastNotified.changes` diff.
993 > */
994 > setSessionSummaryChanges(session: URI, changes: ChangesSummary | undefined): void {
995 const entry = this._sessionStates.get(session);
996 if (!entry) {
997 this._logService.warn(`[AgentHostStateManager] setSessionSummaryChanges: unknown session ${session}`);
998 return;
999 }
1000 if (structuralEquals(entry.changes, changes)) {
1001 return;
1002 }
1003
1004 entry.changes = changes;
1005
1006 this._summaryNotifier.markDirty(session);
1007 }
1009 > /**
1010 > * Replaces the catalogue entries on `state.changesets` for `session` by
1011 > * dispatching a {@link ActionType.SessionChangesetsChanged} action.
1012 > * Subscribers see the mutation in the standard session action stream —
1013 > * the catalogue lives on session state and is not its own subscribable
1014 > * resource. Aggregate `changes` counts (additions / deletions /
1015 > * files) are propagated separately via {@link setSessionSummaryChanges}.
1016 > *
1017 > * Producers call this after each compute pass to keep the list of
1018 > * available changesets (with their `changeKind`) in sync so observers
1019 > * can render the correct entries without subscribing to each one.
1020 > */
1021 > setSessionChangesets(session: URI, changesets: readonly Changeset[] | undefined): void {
1022 > const entry = this._sessionStates.get(session); agentHostStateManager.ts ×3
1023 > if (!entry) {
1024 this._logService.warn(`[AgentHostStateManager] setSessionChangesets: unknown session ${session}`);
1025 return;
1026 }
1027 > const state = entry.state; agentHostStateManager.ts ×3
1028 >
1029 > // Skip dispatch when the catalogue is field-equal to the existing one.
1030 > // Producers call this after every compute pass, so duplicate calls
1031 > // are common and would otherwise broadcast a redundant envelope to
1032 > // every subscriber.
1033 > if (arrayEquals(state.changesets ?? [], changesets ?? [], structuralEquals)) {
1035 > }
1036 > // Take a defensive copy so callers can't mutate the catalogue array reducer.ts ×2
1037 > // after dispatch; the reducer otherwise stores the reference as-is.
1038 > const next = changesets ? changesets.slice() : undefined; agentHostStateManager.ts ×3
1039 > this.dispatchServerAction(session, {
1040 > type: ActionType.SessionChangesetsChanged,
1041 > changesets: next,
1042 > });
1043 > }
1045 > /**
1046 > * Tear down a changeset. Dispatches {@link ActionType.ChangesetCleared}
1047 > * so subscribers see an empty file list, then deletes the local state
1048 > * so a fresh `getChangesetState` returns `undefined` and forces the
1049 > * producer to re-create the changeset on next subscribe.
1050 > *
1051 > * Per the spec, the server SHOULD also unsubscribe its clients after
1052 > * dispatching this action; for VS Code-internal clients that happens
1053 > * via the `notify/sessionRemoved` notification, which the workbench-side
1054 > * provider correlates to release any held subscriptions.
1055 > *
1056 > * Safe to call for a URI that was never registered: producers typically
1057 > * iterate over a candidate set on session disposal and emit dispose
1058 > * actions defensively.
1059 > */
1060 > disposeChangeset(changeset: URI): void {
1061 > if (!this._changesets.has(changeset)) { agentHostChangesetStateCache.ts ×2
1062 return;
1063 }
1064 > this.dispatchServerAction(changeset, { agentHostChangesetStateCache.ts ×2
1065 > type: ActionType.ChangesetCleared,
1066 > });
1067 > this._changesets.delete(changeset);
1068 > }
1070 > /**
1071 > * Disposes every changeset whose URI is nested under `session` (i.e.
1072 > * matches `<session>/changeset/...`). Used to cascade cleanup when a
1073 > * session itself is removed.
1074 > */
1075 > disposeSessionChangesets(session: URI): void {
1076 > // Collect first because `disposeChangeset` mutates the underlying agentHostStateManager.ts ×7
1077 > // map via its envelope handler.
1078 > const toDispose: URI[] = [];
1079 > for (const uri of this._changesets.keys()) {
1080 > const parsed = parseChangesetUri(uri); agentHostStateManager.ts ×2
1081 > if (parsed && parsed.sessionUri === session) {
1082 > toDispose.push(uri); agentHostStateManager.ts ×2
1083 > }
1085 > for (const uri of toDispose) { agentHostStateManager.ts ×7
1086 > this.disposeChangeset(uri); agentHostStateManager.ts ×2
1087 > }
1090 > /**
1091 > * Drops the annotation state nested under `session` (i.e. the
1092 > * `<session>/annotations` channel). Used to cascade cleanup when a
1093 > * session itself is removed. Subscriptions are released via the
1094 > * forthcoming `sessionRemoved` notification.
1095 > */
1096 > disposeSessionAnnotations(session: URI): void {
1097 > this._annotations.delete(buildAnnotationsUri(session)); agentHostStateManager.ts ×7
1098 > }
1100 > // ---- Turn tracking ------------------------------------------------------
1101 >
1102 > /**
1103 > * Registers a mapping from turnId to session URI so that incoming
1104 > * provider events (which carry only session URI) can be associated
1105 > * with the correct active turn.
1106 > */
1107 > getActiveTurnId(sessionOrChat: URI): string | undefined {
1108 > const chatUri = isAhpChatChannel(sessionOrChat) ? sessionOrChat : buildDefaultChatUri(sessionOrChat); agentHostStateManager.ts ×1
1109 > return this._chatStates.get(chatUri)?.activeTurn?.id;
1110 > }
1112 > // ---- Action dispatch ----------------------------------------------------
1113 >
1114 > /**
1115 > * Dispatch a server-originated action (from the agent backend).
1116 > * The action is applied to state via the reducer and emitted as an
1117 > * envelope with no origin (server-produced).
1118 > *
1119 > * `channel` identifies the channel the action targets — `ROOT_STATE_URI`
1120 > * for root actions, a session URI for session actions, a terminal URI
1121 > * for terminal actions, an expanded changeset URI for changeset actions.
1122 > */
1123 > dispatchServerAction(channel: URI, action: StateAction): void {
1124 > this._applyAndEmit(channel, action, undefined); agentHostStateManager.ts ×1
1125 > }
1127 > /**
1128 > * Dispatch a client-originated action (write-ahead from a renderer).
1129 > * The action is applied to state and emitted with the client's origin
1130 > * so the originating client can reconcile.
1131 > */
1132 > dispatchClientAction(channel: URI, action: SessionAction | ChatAction | TerminalAction | ClientChangesetAction | ClientAnnotationsAction | IRootConfigChangedAction, origin: ActionOrigin): unknown {
1133 > return this._applyAndEmit(channel, action, origin); agentHostStateManager.ts ×1
1134 > }
1136 > /**
1137 > * Reject a client-originated action without applying it to state. Emits an
1138 > * {@link ActionEnvelope} that carries the original {@link ActionOrigin} and a
1139 > * {@link ActionEnvelope.rejectionReason | rejectionReason} so the originating
1140 > * client can reconcile (roll back) its optimistic write-ahead action through
1141 > * the normal path instead of leaving it pending until reconnect. The reducer
1142 > * is deliberately NOT run, so no synchronized state changes.
1143 > */
1144 > rejectClientAction(channel: URI, action: StateAction, origin: ActionOrigin, reason: string): void {
1145 > const envelope: ActionEnvelope = { agentHostStateManager.ts ×1
1146 > channel,
1147 > action,
1148 > serverSeq: ++this._serverSeq,
1149 > origin,
1150 > rejectionReason: reason,
1151 > };
1152 > this._logService.trace(`[AgentHostStateManager] Emitting rejection envelope: seq=${envelope.serverSeq}, channel=${envelope.channel}, type=${action.type}, origin=${origin.clientId}:${origin.clientSeq}, reason=${reason}`);
1153 > this._onDidEmitEnvelope.fire(envelope);
1154 > }
1156 > // ---- Internal -----------------------------------------------------------
1157 >
1158 > private _applyAndEmit(channel: URI, action: StateAction, origin: ActionOrigin | undefined): unknown {
1159 > let resultingState: unknown = undefined; agentHostStateManager.ts ×7
1160 > if (action.type === ActionType.RootConfigChanged && action.replace) {
1162 > ...action,
1163 > config: preserveProviderBackedRootConfigValues(this._rootState, action.config),
1164 > };
1165 > }
1166 > // Apply to state agentHostStateManager.ts ×7
1167 > if (isRootAction(action)) {
1168 > // `RootConfigChanged` can be a true no-op: the reducer merges/replaces agentHostStateManager.ts ×2
1169 > // values even when the patch matches the current state, and re-emitting
1170 > // it would cause clients observing rootState.onDidChange to react and
1171 > // potentially re-dispatch in a loop. Check the action's own patch
1172 > // against current values before running the reducer so we avoid
1173 > // allocating a new state object at all.
1174 > if (action.type === ActionType.RootConfigChanged && this._rootState.config) {
1175 > const current = this._rootState.config.values; agentHostStateManager.ts ×3
1176 > const patch = action.config;
1177 > const isNoOp = action.replace
1178 > ? equals(current, patch) agentCustomizationSettings.ts ×2
1179 > : equals({ ...current, ...patch }, current); agentHostStateManager.ts ×1
1180 > if (isNoOp) { agentHostStateManager.ts ×3
1181 > return this._rootState; agentHostStateManager.ts ×1
1182 > }
1184 > this._rootState = rootReducer(this._rootState, action as RootAction, this._log); agentHostStateManager.ts ×2
1185 > resultingState = this._rootState;
1186 > }
1188 > if (isSessionAction(action)) {
1189 > const sessionAction = action as SessionAction; agentHostStateManager.ts ×3
1190 > const key = channel;
1191 > const entry = this._sessionStates.get(key);
1192 > if (entry) {
1193 > const newState = sessionReducer(entry.state, sessionAction, this._log); agentHostStateManager.ts ×4
1194 > const summaryChanged = !this._summaryFieldsEqual(entry.state, newState);
1195 > entry.state = newState;
1196 >
1197 > // When the reducer touched a summary-relevant field, notify
1198 > // root-channel clients of the derived-summary delta.
1199 > if (summaryChanged) {
1200 > this._summaryNotifier.markDirty(key); agentHostStateManager.ts ×1
1201 > }
1203 > resultingState = newState;
1204 > } else if (!isAhpChatChannel(key)) { agentHostStateManager.ts ×3
1205 > this._logService.warn(`[AgentHostStateManager] Action for unknown session: ${key}, type=${action.type}`); copilotAgentSession.ts ×15
1206 > }
1209 > if (isChatAction(action)) {
1210 > if (!isAhpChatChannel(channel)) { agentHostStateManager.ts ×16
1211 throw new Error(`[AgentHostStateManager] Chat action dispatched to non-chat channel: ${channel}, type=${action.type}`);
1212 }
1214 > const chatAction = action as ChatAction;
1215 > const sessionKey = parseRequiredSessionUriFromChatUri(channel);
1216 > const chat = this._chatStates.get(channel);
1217 > if (chat && sessionKey !== undefined) {
1218 > const newChat = chatReducer(chat, chatAction, this._log);
1219 > this._chatStates.set(channel, newChat);
1220 > this._onChatStateChanged(sessionKey, channel, chat, newChat);
1221 > resultingState = newChat;
1222 > } else {
1223 this._logService.warn(`[AgentHostStateManager] Action for unknown chat: ${channel}, type=${action.type}`);
1224 }
1227 > if (isChangesetAction(action)) {
1228 > const changesetAction = action as ChangesetAction; agentHostStateManager.ts ×3
1229 > const key = channel;
1230 > const state = this._changesets.get(key);
1231 > if (!state) {
1232 > // Unknown changeset: log and bail before envelope creation. agentHostStateManager.ts ×1
1233 > // Routing the action to subscribers (Issue 1) makes
1234 > // orphan envelopes client-visible, so we must drop them
1235 > // here rather than letting them advance `_serverSeq`.
1236 > this._logService.warn(`[AgentHostStateManager] Action for unknown changeset: ${key}, type=${action.type}`);
1237 > return undefined;
1238 > }
1239 > const newState = changesetReducer(state, changesetAction, this._log); agentHostStateManager.ts ×3
1240 > if (newState !== state) {
1241 > this._changesets.set(key, newState); agentHostStateManager.ts ×1
1242 > }
1243 > resultingState = newState; agentHostStateManager.ts ×3
1244 > }
1246 > if (isAnnotationsAction(action)) {
1247 > const annotationsAction = action as AnnotationsAction; reducer.ts ×7
1248 > const key = channel;
1249 > // Annotations are client-dispatchable and lazily created: seed an
1250 > // empty state on first write rather than dropping the action.
1251 > const state = this._annotations.get(key) ?? { annotations: [] };
1252 > const newState = annotationsReducer(state, annotationsAction, this._log);
1253 > if (newState !== state) {
1254 > this._annotations.set(key, newState);
1255 > }
1256 > resultingState = newState;
1257 > }
1259 > // Emit envelope
1260 > const envelope: ActionEnvelope = {
1261 > channel,
1262 > action,
1263 > serverSeq: ++this._serverSeq,
1264 > origin,
1265 > };
1266 >
1267 > this._logService.trace(`[AgentHostStateManager] Emitting envelope: seq=${envelope.serverSeq}, channel=${envelope.channel}, type=${action.type}${origin ? `, origin=${origin.clientId}:${origin.clientSeq}` : ''}`);
1268 > this._onDidEmitEnvelope.fire(envelope);
1269 >
1270 > return resultingState;
1271 > }
1273 > /**
1274 > * Removes a single chat from its session's active-turn set, firing the
1275 > * session-level active flip ({@link onDidChangeSessionActiveTurn} +
1276 > * {@link ActionType.RootActiveSessionsChanged}) when this clears the
1277 > * session's last active chat. Safe to call for chats that aren't currently
1278 > * tracked as active — it is a no-op in that case. Used both when a turn
1279 > * ends and when a chat is removed mid-turn, so the session can't be
1280 > * stranded as permanently "active".
1281 > */
1282 > private _removeChatActiveTurn(sessionKey: string, chatUri: string): void {
1283 > const activeChats = this._sessionsWithActiveTurn.get(sessionKey); agentHostStateManager.ts ×2
1284 > if (!activeChats || !activeChats.delete(chatUri)) {
1286 > }
1288 > if (activeChats.size === 0) {
1289 > this._sessionsWithActiveTurn.delete(sessionKey); agentHostStateManager.ts ×1
1290 > this._onDidChangeSessionActiveTurn.fire({ session: sessionKey, active: false });
1291 > this.dispatchServerAction(ROOT_STATE_URI, { type: ActionType.RootActiveSessionsChanged, activeSessions: this._sessionsWithActiveTurn.size });
1292 > }
1295 > /**
1296 > * Bridges a default-chat state transition back onto its owning session.
1297 > *
1298 > * The protocol moved turn lifecycle (and therefore the derived
1299 > * activity status) onto the chat channel. To preserve VS Code's
1300 > * single-chat behaviour we:
1301 > * - track active-turn transitions (driving `RootActiveSessionsChanged`
1302 > * and `hasActiveSessions`, which gate `--enable-remote-auto-shutdown`),
1303 > * keyed by the owning session URI;
1304 > * - mirror the chat's denormalized `status`/`activity`/`modifiedAt`
1305 > * onto the session summary so the session list reflects progress;
1306 > * - forward the chat's own `status` to the session `chats` catalog (via a
1307 > * {@link ActionType.SessionChatUpdated}) so per-chat tabs reflect that
1308 > * chat's progress, not just the aggregated session summary; and
1309 > * - keep the session's `chats` catalog entry in sync.
1310 > */
1311 > private _onChatStateChanged(sessionKey: string, chatUri: string, prev: ChatState, next: ChatState): void {
1312 > // Active turn tracking — derive from the reducer's view of state, agentHostStateManager.ts ×16
1313 > // never from raw action turn-ids, so out-of-order lifecycle actions
1314 > // can't desync the count from reality. Track active turns per chat so a
1315 > // session stays active until ALL of its concurrent chat turns finish;
1316 > // only notify when the session's overall active state actually flips.
1317 > const hadActive = !!prev.activeTurn;
1318 > const hasActive = !!next.activeTurn;
1319 > if (hadActive !== hasActive) {
1320 > if (hasActive) { agentHostStateManager.ts ×5
1321 > let activeChats = this._sessionsWithActiveTurn.get(sessionKey);
1322 > const wasSessionActive = !!activeChats?.size;
1323 > if (!activeChats) {
1324 > activeChats = new Set<string>();
1325 > this._sessionsWithActiveTurn.set(sessionKey, activeChats);
1326 > }
1327 > activeChats.add(chatUri);
1328 > if (!wasSessionActive) {
1329 > this._onDidChangeSessionActiveTurn.fire({ session: sessionKey, active: true });
1330 > this.dispatchServerAction(ROOT_STATE_URI, { type: ActionType.RootActiveSessionsChanged, activeSessions: this._sessionsWithActiveTurn.size });
1331 > }
1332 > } else {
1333 > this._removeChatActiveTurn(sessionKey, chatUri); agentHostStateManager.ts ×1
1334 > }
1337 > const entry = this._sessionStates.get(sessionKey);
1338 > if (!entry) {
1339 return;
1340 }
1341 > const sessionState = entry.state; agentHostStateManager.ts ×16
1342 >
1343 > // Mirror denormalized chat summary fields onto the session, aggregating
1344 > // across the whole chat catalog per the SessionSummary rules.
1345 > const nextEntry = chatSummaryFromState(next);
1346 > const prevEntry = sessionState.chats.find(c => c.resource === chatUri);
1347 > const chats = sessionState.chats.map(c => c.resource === chatUri ? nextEntry : c);
1348 >
1349 > // Forward the chat's own status to the session catalog so full
1350 > // SessionState subscribers (the per-chat tabs) reflect this chat's
1351 > // progress — not just the aggregated session summary. Status changes
1352 > // at most a couple of times per turn, so this won't flood the channel.
1353 > if (prevEntry?.status !== nextEntry.status) {
1354 > this.dispatchServerAction(sessionKey, { agentHostStateManager.ts ×5
1355 > type: ActionType.SessionChatUpdated,
1356 > chat: chatUri,
1357 > changes: { status: nextEntry.status, activity: nextEntry.activity },
1358 > });
1359 > }
1361 > const aggregate = this._aggregateChatSummaries(chats, sessionState.defaultChat);
1362 > const newStatus = aggregate.status !== undefined ? this._mergeSessionStatus(sessionState.status, aggregate.status) : sessionState.status;
1363 > const statusChanged = newStatus !== sessionState.status;
1364 > const activityChanged = aggregate.activity !== sessionState.activity;
1365 > entry.state = {
1366 > ...sessionState,
1367 > chats,
1368 > ...(statusChanged ? { status: newStatus } : undefined),
1369 > ...(activityChanged ? { activity: aggregate.activity } : undefined),
1370 > };
1371 >
1372 > // Roll the aggregated `modifiedAt` into the catalog-only timestamp.
1373 > const newModifiedAt = aggregate.modifiedAt !== undefined ? new Date(aggregate.modifiedAt).toISOString() : undefined;
1374 > const modifiedAtChanged = newModifiedAt !== undefined && newModifiedAt !== entry.modifiedAt;
1375 > if (modifiedAtChanged) {
1376 > entry.modifiedAt = newModifiedAt; agentHostStateManager.ts ×1
1377 > }
1379 > if (statusChanged || activityChanged || modifiedAtChanged) {
1380 > this._summaryNotifier.markDirty(sessionKey); agentHostStateManager.ts ×5
1381 > }
1384 > /**
1385 > * Aggregates a session's chat catalog into the derived session-summary
1386 > * fields per the protocol rules: activity bits come from the default chat
1387 > * (else the most recently modified chat) with `InputNeeded`/`Error`/
1388 > * `InProgress` promoted whenever any chat raises them; the `activity` string
1389 > * follows the chat driving the resulting status; `modifiedAt` is the max
1390 > * across chats. Promotion precedence is `InputNeeded` > `Error` >
1391 > * `InProgress`, so a running peer (sub) chat surfaces as `InProgress` on the
1392 > * session even when the default chat is idle.
1393 > */
1394 > private _aggregateChatSummaries(chats: readonly ChatSummary[], defaultChat: URI | undefined): { status?: SessionStatus; activity?: string; modifiedAt?: number } {
1395 > if (chats.length === 0) { agentHostStateManager.ts ×16
1396 return {};
1397 }
1398 > const activityMask = ~(SessionStatus.IsRead | SessionStatus.IsArchived); agentHostStateManager.ts ×16
1399 > const base = (defaultChat !== undefined ? chats.find(c => c.resource === defaultChat) : undefined)
1400 ?? chats.reduce((a, b) => Date.parse(b.modifiedAt) > Date.parse(a.modifiedAt) ? b : a);
1401 > let status = base.status & activityMask; agentHostStateManager.ts ×16
1402 > let driver = base;
1403 > const errorChat = chats.find(c => (c.status & SessionStatus.Error) === SessionStatus.Error);
1404 > const inputChat = chats.find(c => (c.status & SessionStatus.InputNeeded) === SessionStatus.InputNeeded);
1405 > // `InputNeeded` is a superset of the `InProgress` bit, so exclude
1406 > // input-needed chats here to find one that is purely streaming.
1407 > const inProgressChat = chats.find(c => (c.status & SessionStatus.InputNeeded) === SessionStatus.InProgress);
1408 > if (inputChat) {
1409 > status = SessionStatus.InputNeeded; agentHostStateManager.ts ×1
1410 > driver = inputChat;
1411 > } else if (errorChat) { agentHostStateManager.ts ×16
1412 > status = SessionStatus.Error; reducer.ts ×1
1413 > driver = errorChat;
1414 > } else if (inProgressChat) { agentHostStateManager.ts ×16
1415 > status = SessionStatus.InProgress; agentHostStateManager.ts ×5
1416 > driver = inProgressChat;
1417 > }
1418 > const modifiedAt = chats.reduce((max, c) => Math.max(max, Date.parse(c.modifiedAt)), 0); agentHostStateManager.ts ×16
1419 > return { status, activity: driver.activity, modifiedAt };
1420 > }
1422 > /**
1423 > * Combines the chat's activity status bits with the session summary's
1424 > * own metadata flags (IsRead / IsArchived) which live in the high bits
1425 > * of {@link SessionStatus} and are owned by the session, not the chat.
1426 > */
1427 > private _mergeSessionStatus(sessionStatus: SessionStatus, chatStatus: SessionStatus): SessionStatus {
1428 > const metaFlags = sessionStatus & (SessionStatus.IsRead | SessionStatus.IsArchived); agentHostStateManager.ts ×16
1429 > const activityBits = chatStatus & ~(SessionStatus.IsRead | SessionStatus.IsArchived);
1430 > return activityBits | metaFlags;
1431 > }
1433 > /**
1434 > * Emit a generic progress notification on the root channel, correlated to
1435 > * the originating request by {@link ProgressParams.progressToken}. Routed to
1436 > * clients through the same {@link onDidEmitNotification} path as session
1437 > * notifications, so both the local (IPC proxy) and remote (WebSocket
1438 > * {@link ProtocolServerHandler}) renderers receive it without any
1439 > * transport-specific special casing. Progress for host-level work (e.g. a
1440 > * shared SDK download) rides the root channel rather than a per-session one.
1441 > */
1442 > emitProgress(progress: Omit<ProgressParams, 'channel'>): void {
1443 > this._onDidEmitNotification.fire({ agentHostStateManager.ts ×1
1444 > type: 'root/progress',
1445 > channel: ROOT_STATE_URI,
1446 > ...progress,
1447 > });
1448 > }
1450 > /**
1451 > * Emit an `auth/required` notification on the root channel, asking the
1452 > * client to obtain a fresh token and push it via `authenticate`. Rides the
1453 > * same {@link onDidEmitNotification} path as {@link emitProgress}, so both
1454 > * local (IPC proxy) and remote (WebSocket) renderers receive it. Used for
1455 > * host-level auth requirements (e.g. an agent whose transport flip makes a
1456 > * credential newly required) rather than a per-session one.
1457 > */
1458 > emitAuthRequired(params: Omit<AuthRequiredParams, 'channel'>): void {
1459 this._onDidEmitNotification.fire({
1460 type: 'auth/required',
1461 channel: ROOT_STATE_URI,
1462 ...params,
1463 });
1464 }