agentSubscription.ts ×81

Frontier kind: Code frontier

unlabeled · c_47946a59fe47

123 tests · 15211 LOC · 93 files · introduces 0 tests · 576 LOC · 1 file

Introduces — evidence that enters the hierarchy at this concept

Code
81 ranges576 lines · 1 files
Tests
0 tests

Contains — complete concept membership

All code (extent)
1260 ranges15211 lines · 93 files · Browse complete extent
All tests (intent)
123 testsBrowse complete intent

Neighbourhood graph

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

Introduced files, introduced tests, and structurally relevant concept specialization

In the embedded map, ordinary wheel input scrolls the page; use the visible controls to zoom and drag to pan. Open the full-screen map for canvas navigation: wheel pans, Ctrl/Command plus wheel zooms, and arrow keys pan when this region is focused. On touch screens, open the full-screen map to pan or pinch. If JavaScript or WebGL is unavailable, use the native relationship evidence on this page.

Graph controls are ready.

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

Native relationship evidence

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

Introduced tests

Every collected test enters the hierarchy at exactly one concept.

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

Introduced code

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

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

src/vs/platform/agentHost/common/state/agentSubscription.ts 576 introduced LOC · 81 ranges

Open complete file

1 > /*--------------------------------------------------------------------------------------------- agentSubscription.ts
2 > * Copyright (c) Microsoft Corporation. All rights reserved.
3 > * Licensed under the MIT License. See License.txt in the project root for license information.
4 > *--------------------------------------------------------------------------------------------*/
5 >
6 > import { assertNever } from '../../../../base/common/assert.js';
7 > import { Emitter, Event } from '../../../../base/common/event.js';
8 > import { Disposable, IReference } from '../../../../base/common/lifecycle.js';
9 > import { ResourceMap } from '../../../../base/common/map.js';
10 > import { IObservable, observableFromEvent } from '../../../../base/common/observable.js';
11 > import { URI } from '../../../../base/common/uri.js';
12 > import { ActionEnvelope, ActionType, ChangesetAction, ChatAction, AnnotationsAction, ClientAnnotationsAction, ClientChangesetAction, IRootConfigChangedAction, SessionAction, StateAction, isChangesetAction, isChatAction, isAnnotationsAction, isSessionAction } from './sessionActions.js';
13 > import { changesetReducer, chatReducer, annotationsReducer, rootReducer, sessionReducer } from './sessionReducers.js';
14 > import { terminalReducer } from './protocol/reducers.js';
15 > import type { RootAction, SessionAction as IProtocolSessionAction, ChatAction as IProtocolChatAction, TerminalAction } from './protocol/action-origin.generated.js';
16 > import type { AnnotationsState, ChangesetState, ChatState, RootState, SessionState, TerminalState } from './protocol/state.js';
17 > import type { IStateSnapshot } from './sessionProtocol.js';
18 > import { isAhpRootChannel, ROOT_STATE_URI, StateComponents } from './sessionState.js';
19 >
20 > // --- Public API --------------------------------------------------------------
21 >
22 > /**
23 > * A read-only subscription to an agent host resource (root, session, or terminal).
24 > *
25 > * Subscriptions are hydrated from an initial server snapshot and kept in sync
26 > * via action envelopes. Session subscriptions support write-ahead
27 > * reconciliation — optimistic state is layered on top of confirmed state.
28 > */
29 > export interface IAgentSubscription<T> {
30 > /**
31 > * The current state value. For write-ahead subscriptions (sessions) this
32 > * reflects the optimistic state (confirmed + pending replayed). For
33 > * server-only subscriptions (root, terminal) this equals `verifiedValue`.
34 > *
35 > * `undefined` until the first snapshot arrives. An `Error` if subscription
36 > * failed.
37 > */
38 > readonly value: T | Error | undefined;
39 >
40 > /**
41 > * The server-confirmed state with no pending optimistic actions applied.
42 > * `undefined` until the first snapshot arrives.
43 > */
44 > readonly verifiedValue: T | undefined;
45 >
46 > /** Fires when {@link value} changes (optimistic or confirmed). */
47 > readonly onDidChange: Event<T>;
48 >
49 > /** Fires when the subscription enters an error state. */
50 > readonly onDidError?: Event<Error>;
51 >
52 > /** Fires before a server-originated action is applied to this subscription's state. */
53 > readonly onWillApplyAction: Event<ActionEnvelope>;
54 >
55 > /** Fires after a server-originated action is applied to this subscription's state. */
56 > readonly onDidApplyAction: Event<ActionEnvelope>;
57 > }
58 >
59 > /**
60 > * Read-only snapshot describing a single active resource subscription. Used by
61 > * inspection/debug surfaces that enumerate everything a connection is currently
62 > * subscribed to. Does not include the always-live root state.
63 > */
64 > export interface IActiveSubscriptionInfo {
65 > /** The protocol resource URI subscribed to. */
66 > readonly resource: URI;
67 > /** Which state component this subscription tracks. */
68 > readonly kind: StateComponents;
69 > /** Number of outstanding {@link IReference} holders. */
70 > readonly refCount: number;
71 > /**
72 > * The named owners currently holding a reference to this subscription,
73 > * with how many references each holds. Names come from the `owner`
74 > * argument passed to {@link AgentSubscriptionManager.getSubscription}.
75 > */
76 > readonly holders: readonly IActiveSubscriptionHolder[];
77 > /**
78 > * Lifecycle status derived from the subscription's value:
79 > * `pending` before the first snapshot, `error` if it failed, otherwise
80 > * `snapshot`.
81 > */
82 > readonly status: 'pending' | 'snapshot' | 'error';
83 > }
84 >
85 > /** A named owner holding one or more references to a subscription. */
86 > export interface IActiveSubscriptionHolder {
87 > readonly owner: string;
88 > readonly count: number;
89 > }
90 >
91 > // --- Base Implementation -----------------------------------------------------
92 >
93 > /**
94 > * Base class for agent subscriptions. Handles envelope reception, confirmed
95 > * state management, and action event emission.
96 > *
97 > * Subclasses provide the reducer and optionally override reconciliation
98 > * behavior.
99 > */
100 > abstract class BaseAgentSubscription<T> extends Disposable implements IAgentSubscription<T> {
101 >
102 > protected _confirmedState: T | undefined;
103 > private _error: Error | undefined;
104 > private _bufferedEnvelopes: ActionEnvelope[] | undefined;
105 >
106 > protected readonly _onDidChange = this._register(new Emitter<T>());
107 > readonly onDidChange: Event<T> = this._onDidChange.event;
108 >
109 > protected readonly _onDidError = this._register(new Emitter<Error>());
110 > readonly onDidError: Event<Error> = this._onDidError.event;
111 >
112 > protected readonly _onWillApplyAction = this._register(new Emitter<ActionEnvelope>());
113 > readonly onWillApplyAction: Event<ActionEnvelope> = this._onWillApplyAction.event;
114 >
115 > protected readonly _onDidApplyAction = this._register(new Emitter<ActionEnvelope>());
116 > readonly onDidApplyAction: Event<ActionEnvelope> = this._onDidApplyAction.event;
117 >
118 > protected readonly _clientId: string;
119 > protected readonly _log: (msg: string) => void;
120 >
121 > constructor(clientId: string, log: (msg: string) => void) {
122 super();
123 this._clientId = clientId;
124 this._log = log;
125 }
127 > get value(): T | Error | undefined {
128 if (this._error) {
129 return this._error;
131 return this._getOptimisticState() ?? this._confirmedState;
132 }
134 > get verifiedValue(): T | undefined {
135 return this._confirmedState;
136 }
138 > /**
139 > * Apply an initial snapshot from the server.
140 > */
141 > handleSnapshot(state: T, fromSeq: number): void {
142 this._confirmedState = state;
143 this._error = undefined;
145 this._onDidChange.fire(this.value as T);
146 }
148 > /**
149 > * Mark this subscription as failed.
150 > */
151 > setError(error: Error): void {
152 this._error = error;
153 this._onDidError.fire(error);
154 }
156 > /**
157 > * Process an incoming action envelope. The subscription determines
158 > * whether the action is relevant via {@link _isRelevantEnvelope}.
159 > */
160 > receiveEnvelope(envelope: ActionEnvelope): void {
161 if (!this._isRelevantEnvelope(envelope)) {
162 return;
180 this._onDidApplyAction.fire(envelope);
181 }
183 > /** Apply the reducer to confirmed state. Subclasses must implement. */
184 > protected abstract _applyReducer(state: T, action: StateAction): T;
185 >
186 > /** Whether the given envelope targets this subscription. */
187 > protected abstract _isRelevantEnvelope(envelope: ActionEnvelope): boolean;
188 >
189 > /** Return optimistic state if write-ahead is active, otherwise `undefined`. */
190 > protected _getOptimisticState(): T | undefined {
191 return undefined; // No write-ahead by default
192 }
194 > /** Hook called after a snapshot is applied. Replays buffered actions. */
195 > protected _onSnapshotApplied(_fromSeq: number): void {
196 // Replay any actions that arrived before the snapshot
197 const buffered = this._bufferedEnvelopes;
207 }
208 }
210 > /**
211 > * Default reconciliation: apply to confirmed, fire change event.
212 > * Session subscriptions override this for write-ahead.
213 > */
214 > protected _reconcile(envelope: ActionEnvelope, _isOwnAction: boolean): void {
215 this._confirmedState = this._applyReducer(this._confirmedState!, envelope.action);
216 this._onDidChange.fire(this.value as T);
217 }
219 >
220 > // --- Root State Subscription -------------------------------------------------
221 >
222 > /**
223 > * Subscription to the root state at `agenthost:/root`.
224 > * Server-only mutations — no write-ahead.
225 > */
226 > export class RootStateSubscription extends BaseAgentSubscription<RootState> {
227 >
228 > protected override _applyReducer(state: RootState, action: StateAction): RootState {
229 return rootReducer(state, action as RootAction, this._log);
230 }
232 > protected override _isRelevantEnvelope(envelope: ActionEnvelope): boolean {
233 return isAhpRootChannel(envelope.channel) && envelope.action.type.startsWith('root/');
234 }
236 >
237 > // --- Session State Subscription ----------------------------------------------
238 >
239 > interface IPendingAction {
240 > readonly clientSeq: number;
241 > readonly action: SessionAction;
242 > }
243 >
244 > /**
245 > * A pending optimistic action awaiting server confirmation, paired with the
246 > * channel it was dispatched to so it can be replayed across a reconnect. The
247 > * channel is a session channel for {@link SessionStateSubscription} actions and
248 > * a chat channel for {@link ChatStateSubscription} actions.
249 > */
250 > export interface IPendingDispatchAction {
251 > readonly clientSeq: number;
252 > /** The optimistic action awaiting confirmation. */
253 > readonly action: SessionAction | ChatAction;
254 > /** URI of the channel this action targets, as stored on the subscription. */
255 > readonly channel: string;
256 > }
257 >
258 > /**
259 > * Subscription to a session at `copilot:/<uuid>`.
260 > * Supports write-ahead reconciliation for client-dispatchable actions.
261 > */
262 > export class SessionStateSubscription extends BaseAgentSubscription<SessionState> {
263 >
264 > private readonly _pendingActions: IPendingAction[] = [];
265 > private _optimisticState: SessionState | undefined;
266 > private readonly _sessionUri: string;
267 > private readonly _seqAllocator: () => number;
268 >
269 > constructor(
270 sessionUri: string,
271 clientId: string,
277 this._seqAllocator = seqAllocator;
278 }
280 > /**
281 > * Optimistically apply a session action. Returns the clientSeq to send
282 > * to the server so it can echo back for reconciliation.
283 > */
284 > applyOptimistic(action: SessionAction): number {
285 const clientSeq = this._seqAllocator();
286 this._pendingActions.push({ clientSeq, action });
293 return clientSeq;
294 }
296 > protected override _getOptimisticState(): SessionState | undefined {
297 return this._optimisticState;
298 }
300 > protected override _applyReducer(state: SessionState, action: StateAction): SessionState {
301 return sessionReducer(state, action as IProtocolSessionAction, this._log);
302 }
304 > protected override _isRelevantEnvelope(envelope: ActionEnvelope): boolean {
305 return isSessionAction(envelope.action) && envelope.channel === this._sessionUri;
306 }
308 > protected override _onSnapshotApplied(fromSeq: number): void {
309 // Replay buffered actions first
310 super._onSnapshotApplied(fromSeq);
312 this._recomputeOptimistic();
313 }
315 > protected override _reconcile(envelope: ActionEnvelope, isOwnAction: boolean): void {
316 // A rejected envelope must never mutate confirmed state — it only rolls
317 // back the originating client's matching optimistic action. Guarding all
333 this._recomputeOptimistic();
334 }
336 > private _confirmedApply(action: StateAction): void {
337 if (this._confirmedState) {
338 this._confirmedState = this._applyReducer(this._confirmedState, action);
339 }
340 }
342 > private _recomputeOptimistic(): void {
343 const confirmed = this._confirmedState;
344 if (!confirmed) {
360 this._onDidChange.fire(state);
361 }
363 > /**
364 > * Clear pending actions for this session (e.g., on unsubscribe).
365 > */
366 > clearPending(): void {
367 this._pendingActions.length = 0;
368 this._optimisticState = undefined;
369 }
371 > /**
372 > * Snapshot of the currently-pending optimistic actions, with the session
373 > * URI included so callers can re-issue them across a reconnect. The
374 > * actions remain in the subscription so the optimistic state continues
375 > * to reflect them — the client must explicitly drop entries echoed back
376 > * by the server.
377 > */
378 > getPendingActions(): IPendingDispatchAction[] {
379 return this._pendingActions.map(p => ({ clientSeq: p.clientSeq, action: p.action, channel: this._sessionUri }));
380 }
382 > /**
383 > * Drop the pending entry whose `clientSeq` matches the supplied value.
384 > * Used during reconnect to evict actions the server already echoed back
385 > * in the replay buffer so they're not resent.
386 > */
387 > dropPendingByClientSeq(clientSeq: number): boolean {
388 const idx = this._pendingActions.findIndex(p => p.clientSeq === clientSeq);
389 if (idx === -1) {
393 return true;
394 }
396 >
397 > // --- Chat State Subscription -------------------------------------------------
398 >
399 > interface IPendingChatAction {
400 > readonly clientSeq: number;
401 > readonly action: ChatAction;
402 > }
403 >
404 > /**
405 > * Subscription to a chat channel (e.g. a session's default chat URI). Turns,
406 > * tool calls and pending/input state moved off the session onto the chat
407 > * channel in the multi-chat protocol, so this subscription carries the
408 > * conversation contents. Supports write-ahead reconciliation for
409 > * client-dispatchable chat actions (turn starts, confirmations, etc.).
410 > */
411 > export class ChatStateSubscription extends BaseAgentSubscription<ChatState> {
412 >
413 > private readonly _pendingActions: IPendingChatAction[] = [];
414 > private _optimisticState: ChatState | undefined;
415 > private readonly _chatUri: string;
416 > private readonly _seqAllocator: () => number;
417 >
418 > constructor(
419 chatUri: string,
420 clientId: string,
426 this._seqAllocator = seqAllocator;
427 }
429 > /**
430 > * Optimistically apply a chat action. Returns the clientSeq to send to
431 > * the server so it can echo back for reconciliation.
432 > */
433 > applyOptimistic(action: ChatAction): number {
434 const clientSeq = this._seqAllocator();
435 this._pendingActions.push({ clientSeq, action });
441 return clientSeq;
442 }
444 > protected override _getOptimisticState(): ChatState | undefined {
445 return this._optimisticState;
446 }
448 > protected override _applyReducer(state: ChatState, action: StateAction): ChatState {
449 return chatReducer(state, action as IProtocolChatAction, this._log);
450 }
452 > protected override _isRelevantEnvelope(envelope: ActionEnvelope): boolean {
453 return isChatAction(envelope.action) && envelope.channel === this._chatUri;
454 }
456 > protected override _onSnapshotApplied(fromSeq: number): void {
457 super._onSnapshotApplied(fromSeq);
458 this._recomputeOptimistic();
459 }
461 > protected override _reconcile(envelope: ActionEnvelope, isOwnAction: boolean): void {
462 // A rejected envelope must never mutate confirmed state — it only rolls
463 // back the originating client's matching optimistic action. Guarding all
480 this._recomputeOptimistic();
481 }
483 > private _promotePendingTurnStartIfTerminal(action: StateAction): void {
484 // A backend-originated terminal turn action may arrive without the clientSeq
485 // that would normally confirm our optimistic turn start. Promote that start
500 }
501 }
503 > private _confirmedApply(action: StateAction): void {
504 if (this._confirmedState) {
505 this._confirmedState = this._applyReducer(this._confirmedState, action);
506 }
507 }
509 > private _recomputeOptimistic(): void {
510 const confirmed = this._confirmedState;
511 if (!confirmed) {
525 this._onDidChange.fire(state);
526 }
528 > clearPending(): void {
529 this._pendingActions.length = 0;
530 this._optimisticState = undefined;
531 }
533 > getPendingActions(): IPendingDispatchAction[] {
534 return this._pendingActions.map(p => ({ clientSeq: p.clientSeq, action: p.action, channel: this._chatUri }));
535 }
537 > dropPendingByClientSeq(clientSeq: number): boolean {
538 const idx = this._pendingActions.findIndex(p => p.clientSeq === clientSeq);
539 if (idx === -1) {
543 return true;
544 }
546 >
547 > // --- Terminal State Subscription ---------------------------------------------
548 >
549 > /**
550 > * Subscription to a terminal at an agent-host terminal URI.
551 > * Server-only mutations — no write-ahead (terminal I/O is side-effect-only).
552 > */
553 > export class TerminalStateSubscription extends BaseAgentSubscription<TerminalState> {
554 >
555 > private readonly _terminalUri: string;
556 >
557 > constructor(terminalUri: string, clientId: string, log: (msg: string) => void) {
558 super(clientId, log);
559 this._terminalUri = terminalUri;
560 }
562 > protected override _applyReducer(state: TerminalState, action: StateAction): TerminalState {
563 return terminalReducer(state, action as TerminalAction, this._log);
564 }
566 > protected override _isRelevantEnvelope(envelope: ActionEnvelope): boolean {
567 return envelope.action.type.startsWith('terminal/') && envelope.channel === this._terminalUri;
568 }
570 >
571 > // --- Changeset State Subscription --------------------------------------------
572 >
573 > /**
574 > * Subscription to a changeset at an expanded changeset URI (e.g.
575 > * `<sessionUri>/changeset/session`).
576 > *
577 > * Changeset review actions are client-dispatchable, so this subscription
578 > * supports write-ahead reconciliation.
579 > */
580 > export class ChangesetStateSubscription extends BaseAgentSubscription<ChangesetState> {
581 >
582 > private readonly _pendingActions: { readonly clientSeq: number; readonly action: ClientChangesetAction }[] = [];
583 > private _optimisticState: ChangesetState | undefined;
584 > private readonly _changesetUri: string;
585 > private readonly _seqAllocator: () => number;
586 >
587 > constructor(changesetUri: string, clientId: string, seqAllocator: () => number, log: (msg: string) => void) {
588 super(clientId, log);
589 this._changesetUri = changesetUri;
590 this._seqAllocator = seqAllocator;
591 }
593 > /**
594 > * Optimistically apply a changeset action and return its client sequence.
595 > */
596 > applyOptimistic(action: ClientChangesetAction): number {
597 const clientSeq = this._seqAllocator();
598 this._pendingActions.push({ clientSeq, action });
604 return clientSeq;
605 }
607 > protected override _getOptimisticState(): ChangesetState | undefined {
608 return this._optimisticState;
609 }
611 > protected override _applyReducer(state: ChangesetState, action: StateAction): ChangesetState {
612 return changesetReducer(state, action as ChangesetAction, this._log);
613 }
615 > protected override _isRelevantEnvelope(envelope: ActionEnvelope): boolean {
616 return isChangesetAction(envelope.action) && envelope.channel === this._changesetUri;
617 }
619 > protected override _onSnapshotApplied(fromSeq: number): void {
620 super._onSnapshotApplied(fromSeq);
621 this._recomputeOptimistic();
622 }
624 > protected override _reconcile(envelope: ActionEnvelope, isOwnAction: boolean): void {
625 if (isOwnAction && envelope.origin) {
626 const index = this._pendingActions.findIndex(pending => pending.clientSeq === envelope.origin!.clientSeq);
638 this._recomputeOptimistic();
639 }
641 > private _confirmedApply(action: StateAction): void {
642 if (this._confirmedState) {
643 this._confirmedState = this._applyReducer(this._confirmedState, action);
644 }
645 }
647 > private _recomputeOptimistic(): void {
648 const confirmed = this._confirmedState;
649 if (!confirmed) {
665 this._onDidChange.fire(state);
666 }
668 >
669 > type ManagedSubscription = SessionStateSubscription | ChatStateSubscription | TerminalStateSubscription | ChangesetStateSubscription | AnnotationsStateSubscription;
670 >
671 > // --- Annotations State Subscription ------------------------------------------
672 >
673 > interface IPendingAnnotationsAction {
674 > readonly clientSeq: number;
675 > readonly action: AnnotationsAction;
676 > }
677 >
678 > /**
679 > * Subscription to a session's annotations channel (e.g.
680 > * `<sessionUri>/annotations`).
681 > *
682 > * Annotations actions are client-dispatchable, so this subscription supports
683 > * write-ahead reconciliation: optimistic state is layered on top of confirmed
684 > * state and reconciled as the server echoes the client's own actions back.
685 > *
686 > * Like {@link ChangesetStateSubscription}, the subscription does NOT
687 > * self-tear-down on lifecycle events; cleanup is driven externally by the
688 > * holder releasing its `IReference`.
689 > */
690 > export class AnnotationsStateSubscription extends BaseAgentSubscription<AnnotationsState> {
691 >
692 > private readonly _pendingActions: IPendingAnnotationsAction[] = [];
693 > private _optimisticState: AnnotationsState | undefined;
694 > private readonly _annotationsUri: string;
695 > private readonly _seqAllocator: () => number;
696 >
697 > constructor(annotationsUri: string, clientId: string, seqAllocator: () => number, log: (msg: string) => void) {
698 super(clientId, log);
699 this._annotationsUri = annotationsUri;
700 this._seqAllocator = seqAllocator;
701 }
703 > /**
704 > * Optimistically apply an annotations action. Returns the clientSeq to
705 > * send to the server so it can echo back for reconciliation.
706 > */
707 > applyOptimistic(action: AnnotationsAction): number {
708 const clientSeq = this._seqAllocator();
709 this._pendingActions.push({ clientSeq, action });
715 return clientSeq;
716 }
718 > protected override _getOptimisticState(): AnnotationsState | undefined {
719 return this._optimisticState;
720 }
722 > protected override _applyReducer(state: AnnotationsState, action: StateAction): AnnotationsState {
723 return annotationsReducer(state, action as AnnotationsAction, this._log);
724 }
726 > protected override _isRelevantEnvelope(envelope: ActionEnvelope): boolean {
727 return isAnnotationsAction(envelope.action) && envelope.channel === this._annotationsUri;
728 }
730 > protected override _onSnapshotApplied(fromSeq: number): void {
731 super._onSnapshotApplied(fromSeq);
732 this._recomputeOptimistic();
733 }
735 > protected override _reconcile(envelope: ActionEnvelope, isOwnAction: boolean): void {
736 if (isOwnAction && envelope.origin) {
737 const idx = this._pendingActions.findIndex(p => p.clientSeq === envelope.origin!.clientSeq);
749 this._recomputeOptimistic();
750 }
752 > private _confirmedApply(action: StateAction): void {
753 if (this._confirmedState) {
754 this._confirmedState = this._applyReducer(this._confirmedState, action);
755 }
756 }
758 > private _recomputeOptimistic(): void {
759 const confirmed = this._confirmedState;
760 if (!confirmed) {
776 this._onDidChange.fire(state);
777 }
779 >
780 > type ManagedSubscriptionEntry = { sub: ManagedSubscription; kind: StateComponents; refCount: number; holders: Map<number, string> };
781 >
782 > // --- Subscription Manager ----------------------------------------------------
783 >
784 >
785 > /**
786 > * Manages the lifecycle of resource subscriptions for an agent connection.
787 > *
788 > * Provides refcounted access via {@link getSubscription} — the subscription
789 > * is created on first acquire, subscribes to the server, and stays alive
790 > * until the last reference is disposed.
791 > *
792 > * The connection feeds action envelopes to all active subscriptions via
793 > * {@link receiveEnvelope}.
794 > */
795 > export class AgentSubscriptionManager extends Disposable {
796 >
797 > private readonly _subscriptions = new ResourceMap<ManagedSubscriptionEntry>();
798 > private readonly _inflightCreates = new ResourceMap<Promise<unknown>>();
799 > private _referenceOwnerIds = 0;
800 > private readonly _rootState: RootStateSubscription;
801 > private readonly _clientId: string;
802 > private readonly _seqAllocator: () => number;
803 > private readonly _log: (msg: string) => void;
804 > private readonly _subscribe: (resource: URI) => Promise<IStateSnapshot>;
805 > private readonly _unsubscribe: (resource: URI) => void;
806 >
807 > constructor(
808 clientId: string,
809 seqAllocator: () => number,
820 this._rootState = this._register(new RootStateSubscription(clientId, log));
821 }
823 > /** The always-live root state subscription. */
824 > get rootState(): IAgentSubscription<RootState> {
825 return this._rootState;
826 }
828 > /**
829 > * Initialize the root state from a snapshot received during the
830 > * connection handshake.
831 > */
832 > handleRootSnapshot(state: RootState, fromSeq: number): void {
833 this._rootState.handleSnapshot(state, fromSeq);
834 }
836 > /**
837 > * Returns an existing subscription without affecting its refcount.
838 > * Returns `undefined` if no subscription is active for the given resource.
839 > */
840 > getSubscriptionUnmanaged<T>(resource: URI): IAgentSubscription<T> | undefined {
841 const entry = this._subscriptions.get(resource);
842 return entry?.sub as IAgentSubscription<T> | undefined;
843 }
845 > /**
846 > * Returns the in-flight `createSession` Promise for this URI, or `undefined` if no create is pending. Used by
847 > * callers that need to gate their own work on a still-running eager `createSession` (e.g. the chat handler awaits
848 > * this before deciding whether the sessions provider's eager-create raced first send).
849 > */
850 > getInflightSessionCreate(resource: URI): Promise<unknown> | undefined {
851 return this._inflightCreates.get(resource);
852 }
854 > /**
855 > * Register an in-flight `createSession` Promise for a session URI. Any
856 > * subscribe issued for this resource while the create is pending waits
857 > * for the Promise before issuing the wire-level subscribe.
858 > */
859 > trackSessionCreate(resource: URI, promise: Promise<unknown>): void {
860 this._inflightCreates.set(resource, promise);
861 // This branch only observes settlement to evict the inflight entry; the
870 }).catch(() => { });
871 }
873 > /**
874 > * Get or create a refcounted subscription to any resource. Disposing
875 > * the returned reference decrements the refcount; when it reaches zero
876 > * the subscription is torn down and the server is notified.
877 > *
878 > * `owner` names the caller holding the reference so inspection surfaces
879 > * (see {@link getActiveSubscriptions}) can attribute who is retaining a
880 > * subscription. Use a stable, human-readable identifier such as the
881 > * acquiring class name.
882 > */
883 > getSubscription<T>(kind: StateComponents, resource: URI, owner: string): IReference<IAgentSubscription<T>> {
884 const existing = this._subscriptions.get(resource);
885 if (existing) {
929 return this._acquireReference<T>(resource, entry, owner);
930 }
932 > /**
933 > * Register `owner` as a holder of `entry` and return a reference whose
934 > * disposal removes that holder and releases the subscription. The
935 > * caller is responsible for the matching refcount increment (a fresh
936 > * entry starts at 1; an existing entry is bumped before calling this).
937 > */
938 > private _acquireReference<T>(resource: URI, entry: ManagedSubscriptionEntry, owner: string): IReference<IAgentSubscription<T>> {
939 const ownerId = ++this._referenceOwnerIds;
940 entry.holders.set(ownerId, owner);
953 };
954 }
956 > private _disposeSubscriptionEntry(resource: URI, entry: ManagedSubscriptionEntry): void {
957 this._tryUnsubscribe(resource);
958 if (entry.sub instanceof SessionStateSubscription || entry.sub instanceof ChatStateSubscription) {
961 entry.sub.dispose();
962 }
964 > private _tryUnsubscribe(resource: URI): void {
965 try {
966 this._unsubscribe(resource);
970 }
971 }
973 > /**
974 > * Route an incoming action envelope to all active subscriptions.
975 > */
976 > receiveEnvelope(envelope: ActionEnvelope): void {
977 // Root state gets all root actions
978 this._rootState.receiveEnvelope(envelope);
982 }
983 }
985 > /**
986 > * Dispatch a client action. Applies optimistically to the relevant
987 > * subscription if applicable, then returns the clientSeq.
988 > *
989 > * `channel` is the protocol URI string identifying the channel the
990 > * action targets (a session URI for session actions, etc.).
991 > */
992 > dispatchOptimistic(channel: string, action: SessionAction | ChatAction | TerminalAction | ClientChangesetAction | ClientAnnotationsAction | IRootConfigChangedAction): number {
993 if (isSessionAction(action)) {
994 const entry = this._subscriptions.get(URI.parse(channel));
1014 return this._seqAllocator();
1015 }
1017 > /**
1018 > * URIs currently subscribed to via {@link getSubscription}. Used to
1019 > * build the `subscriptions` payload for a `reconnect` RPC so the
1020 > * server can restore them in one round-trip.
1021 > *
1022 > * Does NOT include the always-live root state, which the protocol
1023 > * client manages separately.
1024 > */
1025 > currentSubscriptionUris(): URI[] {
1026 return [...this._subscriptions.keys()];
1027 }
1029 > /**
1030 > * Read-only descriptors of every active resource subscription, for
1031 > * inspection/debug surfaces. Does NOT include the always-live root
1032 > * state, which the connection exposes separately via {@link rootState}.
1033 > */
1034 > getActiveSubscriptions(): readonly IActiveSubscriptionInfo[] {
1035 const out: IActiveSubscriptionInfo[] = [];
1036 for (const [resource, entry] of this._subscriptions) {
1041 return out;
1042 }
1044 > /** Group an entry's holders by owner name, sorted by descending count. */
1045 > private _summarizeHolders(entry: ManagedSubscriptionEntry): IActiveSubscriptionHolder[] {
1046 const counts = new Map<string, number>();
1047 for (const owner of entry.holders.values()) {
1052 .sort((a, b) => b.count - a.count);
1053 }
1055 > /**
1056 > * Snapshot of every pending optimistic action across all session
1057 > * subscriptions. Callers use this to replay actions after a transport
1058 > * reconnect; entries are kept on their subscriptions until they're
1059 > * either echoed back by the server or explicitly dropped via
1060 > * {@link dropPendingSessionAction}.
1061 > */
1062 > getPendingSessionActions(): IPendingDispatchAction[] {
1063 const out: IPendingDispatchAction[] = [];
1064 for (const { sub } of this._subscriptions.values()) {
1069 return out;
1070 }
1072 > /**
1073 > * Remove a single pending optimistic action for a session by its
1074 > * `clientSeq`. Used during reconnect to evict actions the server
1075 > * already processed (and replayed back to us) so they're not resent.
1076 > */
1077 > dropPendingSessionAction(sessionUri: string, clientSeq: number): void {
1078 const entry = this._subscriptions.get(URI.parse(sessionUri));
1079 if (entry?.sub instanceof SessionStateSubscription || entry?.sub instanceof ChatStateSubscription) {
1081 }
1082 }
1084 > /**
1085 > * Apply a fresh snapshot to a subscribed resource — used when the server
1086 > * responds to a `reconnect` request with `type: 'snapshot'` because the
1087 > * replay buffer no longer covers the client's gap. Routes to the root
1088 > * subscription when {@link ROOT_STATE_URI} matches, otherwise reseats the
1089 > * matching entry in {@link _subscriptions}. Unknown resources are ignored.
1090 > */
1091 > applyReconnectSnapshot(resource: string, state: unknown, fromSeq: number): void {
1092 if (isAhpRootChannel(resource)) {
1093 this._rootState.handleSnapshot(state as RootState, fromSeq);
1106 entry.sub.handleSnapshot(state as never, fromSeq);
1107 }
1109 > /**
1110 > * Mark a set of subscriptions as no longer resumable on the server
1111 > * (reported via `ReconnectReplayResult.missing`). The subscriptions
1112 > * themselves stay alive so consumers continue to hold valid references,
1113 > * but their value transitions to an `Error` until they're recreated.
1114 > */
1115 > markSubscriptionsMissing(missing: readonly URI[]): void {
1116 for (const resource of missing) {
1117 const entry = this._subscriptions.get(resource);
1124 }
1125 }
1127 > private _createSubscription(kind: StateComponents, key: string): ManagedSubscription {
1128 switch (kind) {
1129 case StateComponents.Session:
1143 }
1144 }
1146 > private _releaseSubscription(resource: URI, expected?: ManagedSubscriptionEntry): void {
1147 const entry = this._subscriptions.get(resource);
1148 // A failed subscription can be evicted and replaced while old references
1157 }
1158 }
1160 > override dispose(): void {
1161 for (const [resource, entry] of this._subscriptions) {
1162 this._tryUnsubscribe(resource);
1166 super.dispose();
1167 }
1169 >
1170 > /** Returns whether an action envelope targets one of the subscribed channel URIs. */
1171 > export function isActionEnvelopeRelevantToSubscriptionUris(envelope: ActionEnvelope, subscribedUris: Iterable<string>): boolean {
1172 if (isAhpRootChannel(envelope.channel)) {
1173 for (const uri of subscribedUris) {
1185 return false;
1186 }
1188 > // --- Observable Adapter ------------------------------------------------------
1189 >
1190 > /**
1191 > * Adapts an {@link IAgentSubscription} into an {@link IObservable} of the
1192 > * subscription's value. Errors and the pre-snapshot phase are surfaced as
1193 > * `undefined`; consumers that need the error itself should read
1194 > * {@link IAgentSubscription.value} directly.
1195 > */
1196 > export function observableFromSubscription<T>(owner: object | undefined, sub: IAgentSubscription<T>): IObservable<T | undefined> {
1197 return observableFromEvent(owner, sub.onDidChange, () => {
1198 const v = sub.value;