agentService.ts ×122

Frontier kind: Code frontier

unlabeled · c_b52cb05b7d31

483 tests · 45592 LOC · 285 files · introduces 0 tests · 1518 LOC · 7 files

Introduces — evidence that enters the hierarchy at this concept

Code
218 ranges1518 lines · 7 files
Tests
0 tests

Contains — complete concept membership

All code (extent)
4067 ranges45592 lines · 285 files · Browse complete extent
All tests (intent)
483 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.

7 files ranked by introduced lines: 1518 introduced LOC across 218 ranges. Expand a file to inspect source; the > gutter marks introduced lines.

src/vs/platform/agentHost/node/agentService.ts 975 introduced LOC · 122 ranges

Open complete file

1 > /*--------------------------------------------------------------------------------------------- agentService.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 { open, unlink, type FileHandle } from 'fs/promises';
7 > import { decodeBase64, VSBuffer } from '../../../base/common/buffer.js';
8 > import { DeferredPromise, disposableTimeout, ResourceQueue } from '../../../base/common/async.js';
9 > import { toErrorMessage } from '../../../base/common/errorMessage.js';
10 > import { Emitter } from '../../../base/common/event.js';
11 > import { Disposable, DisposableMap, DisposableResourceMap, DisposableStore, IDisposable, MutableDisposable } from '../../../base/common/lifecycle.js';
12 > import { ResourceMap } from '../../../base/common/map.js';
13 > import { getExtensionForMimeType, getMediaMime } from '../../../base/common/mime.js';
14 > import { Schemas } from '../../../base/common/network.js';
15 > import { IObservable, observableValue } from '../../../base/common/observable.js';
16 > import { dirname as resourcesDirname, extname as resourcesExtname, extUriBiasedIgnorePathCase, isEqual, isEqualOrParent, joinPath } from '../../../base/common/resources.js';
17 > import { URI } from '../../../base/common/uri.js';
18 > import { generateUuid } from '../../../base/common/uuid.js';
19 > import { hasKey } from '../../../base/common/types.js';
20 > import { localize } from '../../../nls.js';
21 > import { FileChangeType, FileOperationResult, IFileChange, IFileService, toFileOperationResult, type FileChangesEvent } from '../../files/common/files.js';
22 > import { InstantiationService } from '../../instantiation/common/instantiationService.js';
23 > import { ServiceCollection } from '../../instantiation/common/serviceCollection.js';
24 > import { ILogService } from '../../log/common/log.js';
25 > import { AgentProvider, AgentSession, AgentSignal, AgentHostSessionReleaseGraceMsEnvVar, IAgent, IAgentChatDataChange, IAgentCreateChatOptions, IAgentCreateChatResult, IAgentCreateChatSideChatSelection, IAgentCreateChatSideChatSource, IAgentCreateSessionConfig, IAgentCreateSessionResult, IAgentHostAuthTokenRequest, IAgentHostManagedSettingsDiagnostics, IAgentHostNetworkDiagnosticsInfo, IAgentHostNetworkEndpoint, IAgentHostNetworkFetchResult, IAgentMaterializeSessionEvent, IAgentModelInfo, IAgentResolveSessionConfigParams, IAgentService, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, IAgentSpawnChatEvent, AuthenticateParams, AuthenticateResult, IMcpNotification, IRestoredSubagentSession, SubagentChatSignal } from '../common/agentService.js';
26 > import { ISessionDataService, SESSION_ATTACHMENTS_DIRNAME } from '../common/sessionDataService.js';
27 > import { SessionConfigKey } from '../common/sessionConfigKeys.js';
28 > import type { IAgentCustomizationSettingsRegistration } from '../common/agentCustomizationSettings.js';
29 > import { parseChangesetUri } from '../common/changesetUri.js';
30 > import { ActionType, ActionEnvelope, AuthRequiredReason, INotification, type ChatAction, type IRootConfigChangedAction, type SessionAction, type TerminalAction, type ClientAnnotationsAction, type ClientChangesetAction } from '../common/state/sessionActions.js';
31 > import type { CompletionsParams, CompletionsResult, CreateTerminalParams, ResolveSessionConfigResult, SessionConfigCompletionsResult, SessionConfigPropertySchema } from '../common/state/protocol/commands.js';
32 > import type { InvokeChangesetOperationParams, InvokeChangesetOperationResult } from '../common/state/protocol/channels-changeset/commands.js';
33 > import { AhpErrorCodes, AHP_SESSION_NOT_FOUND, ContentEncoding, JSON_RPC_INTERNAL_ERROR, ProtocolError, ResourceChangeType, ResourceType, ResourceWriteMode, type CreateResourceWatchParams, type CreateResourceWatchResult, type DirectoryEntry, type ResourceCopyParams, type ResourceCopyResult, type ResourceDeleteParams, type ResourceDeleteResult, type ResourceListResult, type ResourceMkdirParams, type ResourceMkdirResult, type ResourceMoveParams, type ResourceMoveResult, type ResourceReadResult, type ResourceResolveParams, type ResourceResolveResult, type ResourceWatchState, type ResourceWriteParams, type ResourceWriteResult, type IStateSnapshot } from '../common/state/sessionProtocol.js';
34 > import { ChangesSummary, ChatInteractivity, ChatOriginKind, MessageAttachmentKind, type ChatOrigin, type Message, type MessageAttachment, type MessageResourceAttachment } from '../common/state/protocol/state.js';
35 > import type { ChatPendingMessageSetAction, ChatTurnStartedAction } from '../common/state/protocol/actions.js';
36 > import { ISessionGitHubState, ISessionGitState, MessageKind, ResponsePartKind, SESSION_META_GITHUB_KEY, SESSION_META_GIT_KEY, readSessionSpawnDepth, withSessionSpawnDepth, SessionStatus, ToolCallStatus, ToolResultContentType, AH_META_WORKSPACELESS_DB_KEY, AH_META_IS_ARCHIVED_DB_KEY, AH_META_IS_DONE_DB_KEY, buildChatUri, buildDefaultChatUri, buildResourceWatchChannelUri, buildSubagentChatUri, buildSubagentSessionUriPrefix, hostBuildInfoFromProduct, isAhpChatChannel, isDefaultChatUri, isSubagentChatUri, isSubagentSession, parseDefaultChatUri, parseRequiredSessionUriFromChatUri, parseResourceWatchChannelUri, parseSubagentSessionUri, readSessionGitState, readSessionWorkspaceless, withSessionGitHubState, withSessionGitState, withSessionWorkspaceless, type SessionConfigState, type SessionSummary, type ToolResultSubagentContent, type Turn } from '../common/state/sessionState.js';
37 > import { readToolCallMeta } from '../common/meta/agentToolCallMeta.js';
38 > import { IProductService } from '../../product/common/productService.js';
39 > import { buildBoundedSideChatSourceContext, getSideChatPartialResponse } from './agentPeerChats.js';
40 > import { AgentConfigurationService, IAgentConfigurationService } from './agentConfigurationService.js';
41 > import { AgentHostTerminalManager, IAgentHostTerminalManager } from './agentHostTerminalManager.js';
42 > import { ISessionDbUriFields, parseSessionDbUri } from './shared/fileEditTracker.js';
43 > import { IGitBlobUriFields, parseGitBlobUri } from './gitDiffContent.js';
44 > import { AgentHostStateManager, IAgentHostStateManager } from './agentHostStateManager.js';
45 > import { IAgentHostGitService } from '../common/agentHostGitService.js';
46 > import { AgentSideEffects } from './agentSideEffects.js';
47 > import { AgentHostLocalTurns } from './agentHostLocalTurns.js';
48 > import { AgentServerToolHost } from './shared/agentServerToolHost.js';
49 > import { buildServerToolGroups } from './shared/serverToolGroups.js';
50 > import { type IChatContextSnapshot, type ISessionServerToolAccessor } from './shared/sessionServerTools.js';
51 >
52 > import { WorktreeIsolation, WORKTREE_META_REPOSITORY_ROOT, worktreeProjectFromRepositoryRoot } from './shared/worktreeIsolation.js';
53 > import { AgentHostChangesetService } from './agentHostChangesetService.js';
54 > import { AgentHostFileMonitorService, IAgentHostFileMonitorService } from './agentHostFileMonitorService.js';
55 > import { IAgentHostCheckpointService, NULL_CHECKPOINT_SERVICE } from '../common/agentHostCheckpointService.js';
56 > import { IAgentHostReviewService } from '../common/agentHostReviewService.js';
57 > import { AgentHostChangesetCoordinator } from './agentHostChangesetCoordinator.js';
58 > import { AgentHostCompletions, IAgentHostCompletions } from './agentHostCompletions.js';
59 > import { AgentHostFileCompletionProvider } from './agentHostFileCompletionProvider.js';
60 > import { AgentHostRenameCompletionProvider } from './agentHostRenameCommand.js';
61 > import { AgentHostSkillCompletionProvider } from './agentHostSkillCompletionProvider.js';
62 > import { AgentHostWorkspaceFiles } from './agentHostWorkspaceFiles.js';
63 > import { CopilotApiService, ICopilotApiService } from './shared/copilotApiService.js';
64 > import { INetworkDiagnosticsService } from './networkDiagnosticsService.js';
65 > import { parseMcpChannelUri } from './shared/mcpCustomizationController.js';
66 > import { toAgentClientUri } from '../common/agentClientUri.js';
67 > import { AgentHostChangesetOperationService } from './agentHostChangesetOperationService.js';
68 > import { AgentHostGitStateService } from './agentHostGitStateService.js';
69 > import { AgentHostGitHubEndpointService, IAgentHostGitHubEndpointService } from './agentHostGitHubEndpointService.js';
70 > import { ITelemetryService } from '../../telemetry/common/telemetry.js';
71 > import { NullTelemetryService } from '../../telemetry/common/telemetryUtils.js';
72 > import { AgentHostAuthenticationService } from './agentHostAuthenticationService.js';
73 > import { updateAgentHostTelemetryLevelFromConfig } from './agentHostTelemetryService.js';
74 > import { AgentHostOctoKitService, IAgentHostOctoKitService } from './shared/agentHostOctoKitService.js';
75 > import { IAgentHostChangesetService, CHANGESET_DB_METADATA_KEYS, META_CHANGES_SUMMARY } from '../common/agentHostChangesetService.js';
76 > import { IAgentHostChangesetSubscriptionService } from '../common/agentHostChangesetSubscriptionService.js';
77 > import { AgentHostChangesetSubscriptionService } from './agentHostChangesetSubscriptionService.js';
78 > import { GIT_DB_METADATA_KEYS, IAgentHostGitStateService, META_GIT_STATE, META_GITHUB_STATE } from '../common/agentHostGitStateService.js';
79 > import { IAgentHostChangesetOperationService } from '../common/agentHostChangesetOperationService.js';
80 > import { AgentHostCommitOperationContribution } from './agentHostCommitOperationProvider.js';
81 > import { AgentHostDiscardChangesOperationContribution } from './agentHostDiscardChangesOperationProvider.js';
82 > import { AgentHostPullRequestOperationContribution } from './agentHostPullRequestOperationProvider.js';
83 > import { AgentHostSyncOperationContribution } from './agentHostSyncOperationProvider.js';
84 > import { AgentHostReviewService } from './agentHostReviewService.js';
85 >
86 > /**
87 > * Grace period before an empty, unsubscribed session is garbage-collected
88 > * via {@link AgentService._runSessionGc}. Gives a disconnected client time
89 > * to reconnect (or a workspace switch to settle) before we tear down the
90 > * provider-side session, worktree, and on-disk state.
91 > */
92 > const SESSION_GC_GRACE_MS = 30_000;
93 >
94 > const HOST_OWNED_SESSION_CONFIG_KEYS = [
95 > SessionConfigKey.Isolation,
96 > SessionConfigKey.Branch,
97 > SessionConfigKey.WorktreeBranchPrefix,
98 > SessionConfigKey.WorktreeIncludeFiles,
99 > ] as const;
100 >
101 function omitHostOwnedSessionConfig<T>(config: Record<string, T>): Record<string, T> {
102 const result = { ...config };
106 return result;
107 }
109 > /**
110 > * Grace period before an idle resource watch is torn down after its last
111 > * subscriber unsubscribes (mirrors {@link SESSION_GC_GRACE_MS}). Within
112 > * this window, a re-subscribe (or reconnect) reuses the still-running
113 > * {@link IFileService} watcher so transient drop-outs don't miss change
114 > * events. Resource watch action envelopes flow through the normal
115 > * envelope replay buffer for the same reason.
116 > */
117 > const RESOURCE_WATCH_GRACE_MS = 30_000;
118 >
119 > /** Bound on how long {@link AgentService.subscribe} waits for a pending subagent chat to register before giving up. */
120 > const SUBAGENT_CHAT_PENDING_TIMEOUT_MS = 15_000;
121 >
122 > /**
123 > * Grace period before an idle session (one with turns, no remaining
124 > * subscribers) is released from memory via {@link AgentService._maybeEvictIdleSession}.
125 > * Deferring the release aligns it with the client disconnect-grace window: a
126 > * client that disconnects and quickly reconnects (or a rapid unsubscribe/
127 > * re-subscribe) reuses the live provider SDK session instead of forcing an
128 > * immediate {@link IAgent.releaseSession} (SDK `disconnect`) followed by a
129 > * resume-from-disk. Releasing synchronously on every last-unsubscribe churns
130 > * the shared provider runtime and races concurrent session operations.
131 > *
132 > * Overridable via {@link AgentHostSessionReleaseGraceMsEnvVar} (test hook).
133 > */
134 > const SESSION_RELEASE_GRACE_MS = (() => {
135 > const raw = process.env[AgentHostSessionReleaseGraceMsEnvVar];
136 > const parsed = raw !== undefined ? parseInt(raw, 10) : NaN;
137 > return Number.isFinite(parsed) && parsed >= 0 ? parsed : 30_000;
138 > })();
139 >
140 > /**
141 > * Session-database metadata key under which the orchestrator persists its own
142 > * catalog of additional (non-default) peer chats for a session. The value is a
143 > * JSON array of {@link IPersistedPeerChat}. This is the orchestrator's single
144 > * source of truth for peer-chat enumeration on restore. When the key is absent
145 > * the session predates orchestrator-owned persistence and a one-time migration
146 > * drains the agent's legacy `*.chats` (see
147 > * {@link AgentService._migrateLegacyPeerChats}).
148 > */
149 > const PEER_CHATS_METADATA_KEY = 'peerChats';
150 >
151 > /**
152 > * Session-database metadata key written on a peer chat's *backing* SDK session
153 > * (see {@link IAgentCreateChatResult.backingSession}). Its presence marks that
154 > * session as an internal peer-chat backing that must never surface as a
155 > * top-level session; the value is the owning peer chat's channel URI string.
156 > * Persisted, so it survives a host restart without re-stamping.
157 > */
158 > const PEER_CHAT_BACKING_METADATA_KEY = 'peerChatBacking';
159 >
160 > /**
161 > * A single entry in the orchestrator's persisted peer-chat catalog. `uri` is
162 > * the peer chat's channel URI; `providerData` is the opaque, agent-owned blob
163 > * (see {@link IAgentCreateChatResult.providerData}) handed back to the agent on
164 > * restore — the orchestrator never parses it. `providerData` may be omitted,
165 > * in which case the agent recovers its backing from its own persistence on
166 > * {@link IAgent.materializeChat}. `origin` records the chat's provenance
167 > * (currently only {@link ChatOriginKind.SideChat}, carrying the source chat and
168 > * stable source turn id) so it survives a restart; omitted for plain peer chats.
169 > */
170 > interface IPersistedPeerChat {
171 > readonly uri: string;
172 > readonly providerData?: string;
173 > readonly origin?: ChatOrigin;
174 > }
175 >
176 > /**
177 > * The agent service implementation that runs inside the agent-host utility
178 > * process. Dispatches to registered {@link IAgent} instances based
179 > * on the provider identifier in the session configuration.
180 > */
181 > export class AgentService extends Disposable implements IAgentService {
182 > declare readonly _serviceBrand: undefined;
183 >
184 > private readonly _resourceWriteQueue = this._register(new ResourceQueue());
185 >
186 > /** Protocol: fires when state is mutated by an action. */
187 > private readonly _onDidAction = this._register(new Emitter<ActionEnvelope>());
188 > readonly onDidAction = this._onDidAction.event;
189 >
190 > /** Protocol: fires for ephemeral notifications (sessionAdded/Removed). */
191 > private readonly _onDidNotification = this._register(new Emitter<INotification>());
192 > readonly onDidNotification = this._onDidNotification.event;
193 >
194 > /** Protocol: fires for MCP server-originated notifications routed over `mcp://` channels. */
195 > private readonly _onMcpNotification = this._register(new Emitter<IMcpNotification>());
196 > readonly onMcpNotification = this._onMcpNotification.event;
197 >
198 > /** Authoritative state manager for the sessions process protocol. */
199 > private readonly _stateManager: AgentHostStateManager;
200 >
201 > /** Exposes the state manager for co-hosting a WebSocket protocol server. */
202 > get stateManager(): AgentHostStateManager { return this._stateManager; }
203 >
204 > /** Exposes the configuration service so agent providers can share root config plumbing. */
205 > get configurationService(): IAgentConfigurationService { return this._configurationService; }
206 >
207 > /** Exposes the GitHub endpoint service so agent providers share GitHub (Enterprise) resource resolution. */
208 > get gitHubEndpointService(): IAgentHostGitHubEndpointService { return this._gitHubEndpointService; }
209 >
210 > /** Registered providers keyed by their {@link AgentProvider} id. */
211 > private readonly _providers = new Map<AgentProvider, IAgent>();
212 > /** Maps each active session URI (toString) to its owning provider. */
213 > private readonly _sessionToProvider = new Map<string, AgentProvider>();
214 > /**
215 > * Sessions that have opted in to bring-up progress, keyed by provider id.
216 > * A session is added here when its `createSession` carries a
217 > * {@link IAgentCreateSessionConfig.progressToken} and removed once it
218 > * materializes (the SDK is now resolved) or is disposed. The SDK download is
219 > * host-level and shared across every session of a provider, so this only
220 > * records *interest*: as long as one or more sessions of a provider is
221 > * registered, {@link emitDownloadProgress} surfaces that provider's download as a single
222 > * progress stream keyed by the download's own identity (the package id),
223 > * rather than one stream per session.
224 > */
225 > private readonly _downloadProgressInterest = new Map<AgentProvider, Set<string>>();
226 > /** Subscriptions to provider progress events; cleared when providers change. */
227 > private readonly _providerSubscriptions = this._register(new DisposableStore());
228 > /**
229 > * Per-session tail of in-flight persisted peer-chat catalog writes, keyed by
230 > * session URI string. Read-modify-write updates to the {@link
231 > * PEER_CHATS_METADATA_KEY} blob are chained per session so a `createChat`,
232 > * `disposeChat`, and `onDidChangeChatData` racing for the same
233 > * session can't clobber each other's edits.
234 > */
235 > private readonly _peerChatCatalogWrites = new Map<string, Promise<void>>();
236 > private readonly _authService: AgentHostAuthenticationService;
237 > /** Default provider used when no explicit provider is specified. */
238 > private _defaultProvider: AgentProvider | undefined;
239 > /** Observable registered agents, drives `root/agentsChanged` via {@link AgentSideEffects}. */
240 > private readonly _agents = observableValue<readonly IAgent[]>('agents', []);
241 > /** Shared side-effect handler for action dispatch and session lifecycle. */
242 > private readonly _sideEffects: AgentSideEffects;
243 > /** Owns static / per-turn changeset compute, publish, persist, restore. */
244 > private readonly _changesets: IAgentHostChangesetService;
245 > /** Shared active changeset subscription registry. */
246 > private readonly _changesetSubscriptions: IAgentHostChangesetSubscriptionService;
247 > /** Owns changeset operation contributions and handler activation. */
248 > private readonly _changesetOperationService: IAgentHostChangesetOperationService;
249 > private readonly _reviewService: IAgentHostReviewService;
250 > /** Owns AgentService-side orchestration of the changeset feature. */
251 > private readonly _changesetCoordinator: AgentHostChangesetCoordinator;
252 > /** Owns session git-state probing and git-backed catalogue decoration. */
253 > private readonly _gitStateService: IAgentHostGitStateService;
254 > /** Manages PTY-backed terminals for the agent host protocol. */
255 > private readonly _terminalManager: AgentHostTerminalManager;
256 > /** Persists host-injected `/rename` / `!command` turns for restore & fork/truncate. */
257 > private readonly _localTurns: AgentHostLocalTurns;
258 > /** Server-side host for the agent host's server tools. */
259 > private readonly _serverToolHost: AgentServerToolHost;
260 > private readonly _configurationService: AgentConfigurationService;
261 > /**
262 > * Host-owned worktree isolation controller. Set post-construction via
263 > * {@link setWorktreeIsolation} because it depends on the branch-name
264 > * generator, which is wired after this service is built. All worktree
265 > * behavior — schema contribution, first-send resolution, project /
266 > * announcement, archive, and cleanup — is driven from the host so individual
267 > * agents stay unaware of the folder-vs-worktree distinction.
268 > */
269 > private _worktree: WorktreeIsolation | undefined;
270 > /** Single source of truth for GitHub (Enterprise) endpoints and protected resources. */
271 > private readonly _gitHubEndpointService: IAgentHostGitHubEndpointService;
272 > /** Pluggable completion item providers (e.g. workspace file completions, agent-specific @-mentions). */
273 > private readonly _completions: IAgentHostCompletions;
274 > private _skillCompletionProviderRegistered = false;
275 > /** Backs {@link getNetworkDiagnosticsInfo} / {@link diagnosticsFetch}; wired via {@link setNetworkDiagnosticsService}. */
276 > private _networkDiagnostics: INetworkDiagnosticsService | undefined;
277 >
278 > /**
279 > * Authoritative server-side per-resource subscription refcount, keyed by
280 > * resource URI string and valued by the set of subscribed protocol
281 > * client IDs. Populated by {@link subscribe} (or {@link addSubscriber}
282 > * for handshake fast-paths) and drained by {@link unsubscribe}. When a
283 > * resource's set becomes empty, the resource is dropped from the map and
284 > * {@link _maybeEvictIdleSession} is invoked to release any cached state
285 > * for it.
286 > */
287 > private readonly _resourceSubscribers = new ResourceMap<Set<string>>();
288 > private readonly _restoreSessionInFlight = new Map<string, Promise<void>>();
289 > private readonly _restoreSubagentInFlight = new Map<string, Promise<void>>();
290 >
291 > /** Subagent chats armed for a bounded wait (once execution is confirmed); resolved by {@link _onChatSpawned}, awaited by {@link subscribe}. */
292 > private readonly _pendingSubagentChats = new Map<string /* subagentChatUri */, DeferredPromise<void>>();
293 > private readonly _pendingSubagentChatTimeouts = this._register(new DisposableMap<string /* subagentChatUri */, IDisposable>());
294 > /** Subagent chats announced via `_meta.subagentChatUri` but still awaiting confirmation, keyed by `${channel}:${toolCallId}`. */
295 > private readonly _pendingSubagentToolCalls = new Map<string, string /* subagentChatUri */>();
296 >
297 > /**
298 > * Pending {@link _runSessionGc} timers, keyed by session URI. A timer is
299 > * armed when a session loses its last subscriber while still empty (no
300 > * turns, no active turn) — see {@link _maybeScheduleSessionGc}. Cleared
301 > * whenever any client subscribes again or the timer fires.
302 > */
303 > private readonly _pendingSessionGc = this._register(new DisposableResourceMap<IDisposable>());
304 >
305 > /**
306 > * Pending {@link _maybeEvictIdleSession} timers, keyed by session URI. A
307 > * timer is armed when an idle session (with turns) loses its last subscriber
308 > * — see {@link unsubscribe}. Cleared when any client subscribes again
309 > * ({@link addSubscriber}) or the timer fires. Deferring the release avoids
310 > * churning the provider SDK session on rapid disconnect/reconnect cycles.
311 > */
312 > private readonly _pendingSessionRelease = this._register(new DisposableResourceMap<IDisposable>());
313 >
314 > /**
315 > * Active resource watches keyed by the channel URI string
316 > * (`ahp-resource-watch:/<encoded>`).
317 > *
318 > * Each entry owns the {@link IFileService} watcher together with the
319 > * decoded descriptor, the subscriber refcount, and the optional
320 > * grace-window dispose timer. The watch URI itself is fully
321 > * self-describing — {@link createResourceWatch} just encodes the
322 > * caller's params into the URI and returns it. State only exists
323 > * here once at least one client has subscribed.
324 > *
325 > * Lifecycle:
326 > * - First subscriber to a channel: {@link onResourceWatchSubscribed}
327 > * parses the URI, creates the {@link IFileService} watcher, and
328 > * installs the entry with `subscribers = 1`.
329 > * - Subsequent subscribers bump the refcount and cancel any pending
330 > * grace-window dispose timer.
331 > * - {@link onResourceWatchUnsubscribed} drops the refcount; when it
332 > * reaches zero we arm a {@link RESOURCE_WATCH_GRACE_MS} dispose
333 > * timer rather than tearing down immediately, giving disconnected
334 > * clients time to reconnect.
335 > */
336 > private readonly _resourceWatches = this._register(new DisposableMap<string, IActiveResourceWatch>());
337 >
338 > /** Exposes the terminal manager for use by agent providers. */
339 > get terminalManager(): IAgentHostTerminalManager { return this._terminalManager; }
340 >
341 > /** Exposes the completions service for use by agent providers (e.g. to register agent-scoped completion item providers). */
342 > get completionsService(): IAgentHostCompletions { return this._completions; }
343 >
344 > /**
345 > * Trigger characters announced to clients via `InitializeResult.completionTriggerCharacters`.
346 > * Aggregated from all registered {@link IAgentHostCompletionItemProvider}s.
347 > */
348 > get completionTriggerCharacters(): readonly string[] { return this._completions.triggerCharacters; }
349 >
350 > constructor(
351 private readonly _logService: ILogService,
352 private readonly _fileService: IFileService,
510 this._serverToolHost = new AgentServerToolHost(this._stateManager, buildServerToolGroups(this._createSessionServerToolAccessor()));
511 }
513 > /**
514 > * The registered providers. Exposed so process-lifetime background jobs
515 > * (notably {@link AgentModelRefreshScheduler}) can observe registrations
516 > * without this service owning an ambient recurring timer of its own.
517 > */
518 > get agents(): IObservable<readonly IAgent[]> {
519 return this._agents;
520 }
522 > // ---- provider registration ----------------------------------------------
523 >
524 > /**
525 > * Injects the host-owned {@link WorktreeIsolation} controller and forwards it
526 > * to the collaborators that consult it. Called once at startup (from
527 > * agentHostMain / agentHostServerMain) after the branch-name generator has
528 > * been wired.
529 > */
530 > setWorktreeIsolation(worktree: WorktreeIsolation): void {
531 this._worktree = worktree;
532 this._configurationService.setWorktreeIsolation(worktree);
533 this._sideEffects.setWorktreeIsolation(worktree);
534 }
536 > private _toProviderConfig<T extends { readonly config?: Record<string, unknown> }>(request: T): T {
537 if (!this._worktree || !request.config) {
538 return request;
540 return { ...request, config: omitHostOwnedSessionConfig(request.config) };
541 }
543 > /**
544 > * Host-owned first-send hook (invoked by {@link AgentSideEffects} before the
545 > * agent locks its subprocess cwd). Resolves the working directory the session
546 > * will actually run in and hands it to the agent at send time:
547 > * - `worktree` isolation: the isolated worktree, created here on the first
548 > * send (see {@link _resolveWorktreeBeforeSend});
549 > * - `folder` isolation: the picked folder;
550 > * - workspace-less: `undefined` (the agent runs in its own scratch dir).
551 > */
552 > private async _resolveWorkingDirectoryBeforeSend(params: { session: string; chat: string; turnId: string; prompt: string }): Promise<URI | undefined> {
553 const sessionId = AgentSession.id(params.session);
554 const pickedFolder = this._configurationService.getEffectiveWorkingDirectory(params.session);
568 return await this._resolveWorktreeBeforeSend({ ...params, sessionId, pickedFolderUri }) ?? pickedFolderUri;
569 }
571 > private async _resolveChatAttachmentTurns(resource: string): Promise<readonly Turn[]> {
572 const readTurns = () => {
573 const state = this._stateManager.getChatState(resource) ?? this._stateManager.getDefaultChatState(resource);
590 return readTurns() ?? [];
591 }
593 > /**
594 > * Creates the session's isolated worktree on the first send (deferred so the
595 > * user's prompt can name the branch), surfaces the "Created isolated worktree"
596 > * announcement as the first markdown response part of the turn, and returns
597 > * the created worktree URI. Idempotent; safe to call once the worktree exists.
598 > * Returns `undefined` when worktree creation failed. Only invoked for sessions
599 > * whose worktree is still pending (see {@link _resolveWorkingDirectoryBeforeSend}).
600 > */
601 > private async _resolveWorktreeBeforeSend(params: { session: string; chat: string; turnId: string; prompt: string; sessionId: string; pickedFolderUri: URI | undefined }): Promise<URI | undefined> {
602 const { sessionId, pickedFolderUri } = params;
603 const worktree = this._worktree;
630 return worktree.getResolvedWorktree(sessionId);
631 }
633 > registerProvider(provider: IAgent): void {
634 if (this._providers.has(provider.id)) {
635 throw new Error(`Agent provider already registered: ${provider.id}`);
667 this._updateAgents();
668 }
670 > private _registerSkillCompletionProvider(): void {
671 if (this._skillCompletionProviderRegistered) {
672 return;
678 this._register(this._completions.registerProvider(provider));
679 }
681 > // ---- auth ---------------------------------------------------------------
682 >
683 > async authenticate(params: AuthenticateParams): Promise<AuthenticateResult> {
684 return this._authService.authenticate(params, this._providers.values());
685 }
687 > getAuthToken(request: IAgentHostAuthTokenRequest): string | undefined {
688 return this._authService.getAuthToken(request);
689 }
691 > // ---- Changeset operation handlers --------------------------------------
692 >
693 > async invokeChangesetOperation(params: InvokeChangesetOperationParams): Promise<InvokeChangesetOperationResult> {
694 return this._changesetOperationService.invokeChangesetOperation(params);
695 }
697 > // ---- MCP `mcp://` channel routing --------------------------------------
698 >
699 > async handleMcpRequest(channel: string, method: string, params: Record<string, unknown> | undefined): Promise<unknown> {
700 const route = parseMcpChannelUri(channel);
701 if (!route) {
709 return provider.handleMcpRequest(sessionUri, route.serverName, method, params);
710 }
712 > // ---- session management -------------------------------------------------
713 >
714 > /**
715 > * Builds the dependency surface the session server-tool group needs, bound
716 > * to this service so the group stays decoupled from the concrete host.
717 > */
718 > private _createSessionServerToolAccessor(): ISessionServerToolAccessor {
719 return {
720 listSessions: () => this.listSessions(),
742 };
743 }
745 > /**
746 > * Starts the first turn on a freshly-created session by dispatching a
747 > * `ChatTurnStarted` and routing it through the same side-effects path a
748 > * client-initiated turn takes (which sends the message to the provider).
749 > */
750 > private async _startSessionPrompt(session: URI, chat: URI, prompt: string): Promise<void> {
751 const message: Message = { text: prompt, origin: { kind: MessageKind.User } };
752 const action = { type: ActionType.ChatTurnStarted, turnId: generateUuid(), startedAt: new Date().toISOString(), message } as const;
754 this._sideEffects.handleAction(chat.toString(), action);
755 }
757 > /**
758 > * Reads a point-in-time snapshot of a session's chat conversation for the
759 > * `get_session_context` server tool. Targets the session's default chat, or a
760 > * specific peer chat when `chatId` is provided. Returns `undefined` when no
761 > * live conversation state exists (e.g. a cold/unsubscribed session).
762 > */
763 > private _getChatContext(session: URI, chatId?: string): IChatContextSnapshot | undefined {
764 const chatState = chatId
765 ? this._stateManager.getChatState(buildChatUri(session.toString(), chatId))
774 };
775 }
777 > async listSessions(): Promise<IAgentSessionMetadata[]> {
778 this._logService.trace('[AgentService] listSessions called');
779 const results = await Promise.all(
955 return combined;
956 }
958 > async createSession(config?: IAgentCreateSessionConfig): Promise<URI> {
959 const providerId = config?.provider ?? this._defaultProvider;
960 const provider = providerId ? this._providers.get(providerId) : undefined;
1174 return session;
1175 }
1177 > async createChat(session: URI, chat: URI, options?: IAgentCreateChatOptions): Promise<void> {
1178 const sessionKey = session.toString();
1179 const provider = this._findProviderForSession(session);
1285 }
1286 }
1288 > /**
1289 > * Validates a side chat's source and returns its {@link ChatOriginKind.SideChat}
1290 > * origin. Throws when the source chat is not part of `session` or when the
1291 > * referenced completed or active turn is absent.
1292 > */
1293 > private _resolveSideChatOrigin(session: URI, sideChat: IAgentCreateChatSideChatSource): { origin: ChatOrigin; sourceChat: string; selection?: IAgentCreateChatSideChatSelection; providerAnchorTurnId?: string; sourceContext?: string; partialResponse?: string } {
1294 const sessionKey = session.toString();
1295 const sourceKey = sideChat.source.toString();
1332 };
1333 }
1335 > private _resolveSessionSourceChat(session: URI, source: URI): { sourceChatKey: string; sourceSessionKey: string; sourceState: ReturnType<AgentHostStateManager['getChatState']> | undefined } {
1336 const sessionKey = session.toString();
1337 const sourceKey = source.toString();
1353 };
1354 }
1356 > async disposeChat(session: URI, chat: URI): Promise<void> {
1357 const sessionKey = session.toString();
1358 const provider = this._findProviderForSession(session);
1365 }
1366 }
1368 > // ---- Chat dispatch adapter ---------------------------------------------
1369 > //
1370 > // The orchestrator owns the feature-level `(session, chat)` →
1371 > // `(agent, session, chat)` mapping. It dispatches against an agent's
1372 > // chat-addressed surface ({@link IAgent.chats}) and session lifecycle
1373 > // ({@link IAgent.createSession}/{@link IAgent.disposeSession}).
1374 >
1375 > /** Whether `provider` can host additional (peer) chats. */
1376 > private _supportsChats(provider: IAgent): boolean {
1377 return !!provider.chats;
1378 }
1380 > private async _createProviderSession(provider: IAgent, config: IAgentCreateSessionConfig | undefined, deferWorktreeCreation: boolean): Promise<IAgentCreateSessionResult> {
1381 const requestedSessionId = deferWorktreeCreation && config?.session ? AgentSession.id(config.session) : undefined;
1382 if (requestedSessionId) {
1398 }
1399 }
1401 > private async _disposeSession(provider: IAgent, session: URI): Promise<void> {
1402 await provider.disposeSession(session);
1403 }
1405 > /**
1406 > * Reconstruct the turns for a chat. `chat` is the concrete chat channel URI,
1407 > * except for legacy restore paths that still address subagent sessions.
1408 > */
1409 > private async _getChatMessages(provider: IAgent, chat: URI): Promise<readonly Turn[]> {
1410 const turns = await provider.chats.getMessages(chat);
1411 // Host-owned worktree restore announcement: re-inject the "Created isolated
1418 return turns;
1419 }
1421 > /**
1422 > * Merges persisted host-injected local turns (`/rename`, `!command`) for
1423 > * `chatUri` back into that chat's SDK-derived `turns`, positioned after
1424 > * their anchor turn (the concrete turn they were recorded after). Locals
1425 > * anchored before any real turn are prepended; locals whose anchor is absent
1426 > * from the SDK turns (e.g. truncated away) are dropped. Also seeds the
1427 > * in-memory local-turn index so fork/truncate resolve correctly before the
1428 > * next reload.
1429 > */
1430 > private async _interleaveLocalTurns(sessionStr: string, chatUri: string, turns: readonly Turn[]): Promise<Turn[]> {
1431 const records = await this._localTurns.loadForChat(sessionStr, chatUri);
1432 if (records.length === 0) {
1462 return merged;
1463 }
1465 > /**
1466 > * Re-persists forked host-injected local turns (`/rename`, `!command`) into
1467 > * a newly forked chat so they survive reload and anchor future
1468 > * fork/truncate. `originalSlice[i]` and `forkedTurns[i]` are the source turn
1469 > * and its remapped copy (same length, 1:1); `mapping` is the old→new turn id
1470 > * map used to remap each local turn's anchor. `persistSession` owns the
1471 > * destination database; `sourceChatUri` / `newChatUri` key the source and
1472 > * destination local-turn indexes.
1473 > *
1474 > * Shared by the {@link createSession} (default-chat) and {@link createChat}
1475 > * (peer-chat) fork paths.
1476 > */
1477 > private _persistForkedLocalTurns(persistSession: string, sourceChatUri: string, newChatUri: string, originalSlice: readonly Turn[], forkedTurns: readonly Turn[], mapping: ReadonlyMap<string, string>): void {
1478 for (let i = 0; i < originalSlice.length; i++) {
1479 const original = originalSlice[i];
1486 }
1487 }
1489 > /**
1490 > * Create (or fork) the peer chat `chat` within `session`. `chat` is
1491 > * always a peer URI here (the default chat is created implicitly with
1492 > * the session), so no default-chat resolution is needed.
1493 > */
1494 > private _createChat(provider: IAgent, chat: URI, options: IAgentCreateChatOptions | undefined): Promise<IAgentCreateChatResult | void> {
1495 const convOptions: IAgentCreateChatOptions | undefined = options && (options.title !== undefined || options.model !== undefined || options.sideChat !== undefined)
1496 ? {
1504 : provider.chats.createChat(chat, convOptions);
1505 }
1507 > private async _disposeChat(provider: IAgent, chat: URI): Promise<void> {
1508 await provider.chats.disposeChat(chat);
1509 }
1511 > /**
1512 > * Derives a placeholder title for an imported session from its first user
1513 > * turn (imports seed pre-existing turns, so the normal first-message title
1514 > * generation never fires). Deliberately unprefixed: an imported session is a
1515 > * continuation of the source chat, not a distinct kind of session, so it
1516 > * should read like any other. The placeholder is later refined into a
1517 > * generated title (see the `importConversation` branch in `createSession`),
1518 > * but a neutral non-empty fallback is kept so the session still reads like a
1519 > * normal chat when generation is unavailable or fails.
1520 > */
1521 > private _buildImportedTitle(turns: readonly Turn[]): string {
1522 const firstText = turns.find(t => t.message?.text?.trim())?.message.text.trim();
1523 if (!firstText) {
1527 return firstText.length > MAX ? `${firstText.slice(0, MAX)}...` : firstText;
1528 }
1530 > private _buildInitialSummary(provider: IAgent, session: URI, config: IAgentCreateSessionConfig | undefined, created: { project?: { uri: URI; displayName: string }; workingDirectory?: URI }, title: string): SessionSummary {
1531 const now = new Date().toISOString();
1532 const primaryWorkingDir = (created.workingDirectory ?? config?.workingDirectory)?.toString();
1546 };
1547 }
1549 > /**
1550 > * Listen for an agent transitioning a provisional session into a fully
1551 > * materialized SDK session. The agent has already created the worktree
1552 > * (if any) and persisted on-disk metadata; we need to:
1553 > * - Refresh the in-memory summary with the resolved working directory
1554 > * and project metadata.
1555 > * - Persist any config values now that we have a real on-disk session.
1556 > * - Emit the deferred `notify/sessionAdded` so other clients learn of
1557 > * the session.
1558 > * - Dispatch `SessionReady` so subscribers see the lifecycle transition.
1559 > * - Lazily attach git state for the (possibly new) working directory.
1560 > */
1561 > private _onDidMaterializeSession(e: IAgentMaterializeSessionEvent): void {
1562 const sessionKey = e.session.toString();
1563 // The session is now materialized — its SDK is resolved (any cold
1605 this._changesetCoordinator.onSessionMaterialized(sessionKey);
1606 }
1608 > /** Drop a session's download-progress opt-in, if any. */
1609 > private _clearDownloadProgressInterest(sessionKey: string): void {
1610 for (const [provider, sessions] of this._downloadProgressInterest) {
1611 if (sessions.delete(sessionKey) && sessions.size === 0) {
1614 }
1615 }
1617 > /**
1618 > * Surface a host-level SDK download as client progress. The downloader fires
1619 > * process-global frames keyed by package id (which equals the provider id);
1620 > * because the download is shared across every session of that provider, we
1621 > * emit a SINGLE `progress` stream keyed by that package id — not one per
1622 > * session — so the client shows exactly one indicator no matter how many
1623 > * sessions of the provider are awaiting it. Frames are only emitted while at
1624 > * least one session has opted in (supplied a
1625 > * {@link IAgentCreateSessionConfig.progressToken} on `createSession`). A
1626 > * terminal frame reports `total === progress` (using `receivedBytes` when the
1627 > * size was never known) so the client dismisses the indicator deterministically.
1628 > *
1629 > * `displayName` is the provider's brand noun (e.g. `Claude`). It is woven
1630 > * into the notification's localized, human-readable `message` (e.g.
1631 > * "Downloading Claude agent") so a generic client can render the indicator
1632 > * verbatim without knowing the resource is an agent SDK. No trailing
1633 > * ellipsis: clients render progress as "<title>: <percent>", so an ellipsis
1634 > * would read as an unusual "…:" (see #324455).
1635 > */
1636 > emitDownloadProgress(packageId: string, displayName: string, receivedBytes: number, totalBytes: number | undefined, terminal: boolean): void {
1637 const sessions = this._downloadProgressInterest.get(packageId);
1638 if (!sessions || sessions.size === 0) {
1653 }
1654 }
1656 > private _persistWorkspaceless(session: URI, workspaceless: boolean): void {
1657 let ref;
1658 try {
1668 });
1669 }
1671 > private _persistConfigValues(session: URI, values: Record<string, unknown>): void {
1672 let ref;
1673 try {
1683 });
1684 }
1686 > private async _resolveCreatedSessionConfig(provider: IAgent, config: IAgentCreateSessionConfig | undefined): Promise<SessionConfigState | undefined> {
1687 if (!config?.config && !config?.workingDirectory) {
1688 return undefined;
1707 }
1708 }
1710 > async resolveSessionConfig(params: IAgentResolveSessionConfigParams): Promise<ResolveSessionConfigResult> {
1711 const providerId = params.provider ?? this._defaultProvider;
1712 const provider = providerId ? this._providers.get(providerId) : undefined;
1716 return this._withIsolationSchema(await provider.resolveSessionConfig(this._toProviderConfig(params)), params);
1717 }
1719 > /**
1720 > * Host-owned contribution of the shared `isolation` (folder / worktree),
1721 > * `branch`, `worktreeBranchPrefix`, and `worktreeIncludeFiles` session-config
1722 > * properties on top of whatever an agent returned from `resolveSessionConfig`. Provider-returned
1723 > * properties and values with these keys are replaced by the host contribution.
1724 > */
1725 > private async _withIsolationSchema(result: ResolveSessionConfigResult, params: IAgentResolveSessionConfigParams): Promise<ResolveSessionConfigResult> {
1726 if (!this._worktree) {
1727 return result;
1756 return { schema: { ...result.schema, properties }, values };
1757 }
1759 > async sessionConfigCompletions(params: IAgentSessionConfigCompletionsParams): Promise<SessionConfigCompletionsResult> {
1760 // The host owns branch completions for every agent (they share the same
1761 // git-backed branch list); all other properties stay provider-specific.
1770 return provider.sessionConfigCompletions(this._toProviderConfig(params));
1771 }
1773 > async completions(params: CompletionsParams): Promise<CompletionsResult> {
1774 return this._completions.completions(params);
1775 }
1777 > async getCompletionTriggerCharacters(): Promise<readonly string[]> {
1778 return this._completions.triggerCharacters;
1779 }
1781 > async disposeSession(session: URI): Promise<void> {
1782 this._logService.trace(`[AgentService] disposeSession: ${session.toString()}`);
1783 const provider = this._findProviderForSession(session);
1799 await this._sessionDataService.deleteSessionData(session);
1800 }
1802 > // ---- Protocol methods ---------------------------------------------------
1803 >
1804 > async createTerminal(params: CreateTerminalParams): Promise<void> {
1805 await this._terminalManager.createTerminal(params);
1806 }
1808 > async disposeTerminal(terminal: URI): Promise<void> {
1809 this._terminalManager.disposeTerminal(terminal.toString());
1810 }
1812 > async subscribe(resource: URI, clientId: string): Promise<IStateSnapshot> {
1813 this._logService.trace(`[AgentService] subscribe: ${resource.toString()}`);
1814 const resourceStr = resource.toString();
1904 }
1905 }
1907 > /** Waits for an armed subagent chat to register (or its wait to time out); returns `undefined` if not armed or never registered. */
1908 > private async _awaitPendingSubagentChat(subagentChatUri: string): Promise<IStateSnapshot | undefined> {
1909 const pending = this._pendingSubagentChats.get(subagentChatUri);
1910 if (!pending) {
1914 return this._stateManager.getSnapshot(subagentChatUri);
1915 }
1917 > addSubscriber(resource: URI, clientId: string): void {
1918 let set = this._resourceSubscribers.get(resource);
1919 const wasUnsubscribed = !set || set.size === 0;
1935 }
1936 }
1938 > unsubscribe(resource: URI, clientId: string): void {
1939 const set = this._resourceSubscribers.get(resource);
1940 if (!set) {
1969 }, SESSION_RELEASE_GRACE_MS));
1970 }
1972 > private _cancelPendingSessionRelease(resource: URI): void {
1973 this._pendingSessionRelease.deleteAndDispose(resource);
1974 }
1976 > /**
1977 > * If `resource` names a session that no client is still subscribed to and
1978 > * that has produced no turns (and has no active turn), schedule a delayed
1979 > * {@link _runSessionGc} to fully tear it down — provider session, worktree,
1980 > * persisted state and all. Sessions with at least one turn are left to the
1981 > * existing {@link _maybeEvictIdleSession} path which only drops cached
1982 > * state and lets the session be restored from disk later.
1983 > *
1984 > * The delay ({@link SESSION_GC_GRACE_MS}) gives a disconnected client time
1985 > * to reconnect or a workspace switch to settle. Any subsequent subscribe
1986 > * (or createSession on the same URI) cancels the timer via
1987 > * {@link _cancelPendingSessionGc}.
1988 > *
1989 > * Returns `true` if a GC timer was armed (existing or newly scheduled),
1990 > * so callers can skip alternative cleanup paths.
1991 > */
1992 > private _maybeScheduleSessionGc(resource: URI): boolean {
1993 // Subagent URIs are backed by the parent session; the parent's GC is
1994 // scheduled when its own subscriber count reaches zero.
2012 return true;
2013 }
2015 > private _cancelPendingSessionGc(resource: URI): void {
2016 this._pendingSessionGc.deleteAndDispose(resource);
2017 }
2019 > /**
2020 > * Fires {@link SESSION_GC_GRACE_MS} after a session lost its last
2021 > * subscriber while empty. Re-checks both invariants (still no subscribers,
2022 > * still empty) before tearing the session down via {@link disposeSession}.
2023 > * The cached state may already have been evicted by
2024 > * {@link _maybeEvictIdleSession}; in that case we still proceed because
2025 > * "evicted + no resubscribe" implies no client is observing the session.
2026 > */
2027 > private async _runSessionGc(resource: URI): Promise<void> {
2028 const key = resource.toString();
2029 if (this._resourceSubscribers.has(resource)) {
2037 await this.disposeSession(resource);
2038 }
2040 > /**
2041 > * If `resource` names an idle session and no client is still subscribed to
2042 > * it (or, for a subagent URI, no sibling subagent under the same parent is
2043 > * still subscribed), release its in-memory footprint: drop the cached AHP
2044 > * state from the state manager AND ask the provider to release the session's
2045 > * SDK resources ({@link IAgent.releaseSession}). Subagent URIs evict the
2046 > * parent session entry; the parent owns the materialized turn tree that
2047 > * backs every subagent view. Nothing durable is deleted — the next subscribe
2048 > * rehydrates the session via {@link restoreSession} and the provider resumes
2049 > * the SDK session on demand.
2050 > */
2051 > private _maybeEvictIdleSession(resource: URI): void {
2052 const key = resource.toString();
2053 if (this._resourceSubscribers.has(resource)) {
2102 });
2103 }
2105 > // Returns true when a changeset is safe to drop from the in-memory cache.
2106 > private _isChangesetEvictable(changeset: string): boolean {
2107 const changesetUri = URI.parse(changeset);
2108 // A direct changeset subscriber is rendering this expanded URI. Keep
2134 return !this._changesets.isStaticChangesetComputeActive(changeset);
2135 }
2137 > private _isSubagentDescendantOf(resource: URI, parent: URI): boolean {
2138 let parsed = parseSubagentSessionUri(resource);
2139 while (parsed) {
2145 return false;
2146 }
2148 > /**
2149 > * Per-client sequencer that serialises action dispatches whose
2150 > * processing requires an asynchronous prelude (e.g. snapshotting
2151 > * user-message attachments into the session database before the
2152 > * action is reduced into state). Actions that don't need any
2153 > * asynchronous prelude bypass the queue entirely as long as no
2154 > * earlier action from the same client is still pending.
2155 > *
2156 > * todo@connor4312: we can drop this when sending a message become a command
2157 > */
2158 > private readonly _clientDispatchQueues = new Map<string, Promise<void>>();
2159 >
2160 > dispatchAction(channel: string, action: SessionAction | ChatAction | TerminalAction | ClientChangesetAction | ClientAnnotationsAction | IRootConfigChangedAction, clientId: string, clientSeq: number): void {
2161 this._logService.trace(`[AgentService] dispatchAction: type=${action.type}, clientId=${clientId}, clientSeq=${clientSeq}`, action);
2162
2197 }));
2198 }
2200 > private _dispatchActionNow(channel: string, sessionChannel: string, action: SessionAction | ChatAction | TerminalAction | ClientChangesetAction | ClientAnnotationsAction | IRootConfigChangedAction, clientId: string, clientSeq: number): void {
2201 const origin = { clientId, clientSeq };
2202 this._stateManager.dispatchClientAction(channel, action, origin);
2206 this._sideEffects.handleAction(channel, action, clientId);
2207 }
2209 > private _needsAsyncRewrite(channel: string, action: SessionAction | ChatAction | TerminalAction | ClientChangesetAction | ClientAnnotationsAction | IRootConfigChangedAction): action is ChatTurnStartedAction | ChatPendingMessageSetAction {
2210 if (action.type !== ActionType.ChatTurnStarted && action.type !== ActionType.ChatPendingMessageSet) {
2211 return false;
2214 return !!action.message.attachments?.some(a => this._isRewritableAttachment(a, attachmentsRootStr));
2215 }
2216 > private _isRewritableAttachment(attachment: MessageAttachment, attachmentsRootStr: string): boolean { agentService.ts
2217 if (attachment.type === MessageAttachmentKind.EmbeddedResource) {
2218 return true;
2231 return false;
2232 }
2234 > private _attachmentsRoot(session: string): URI {
2235 return joinPath(this._sessionDataService.getSessionDataDir(URI.parse(session)), SESSION_ATTACHMENTS_DIRNAME);
2236 }
2238 > /**
2239 > * Snapshot inline / client-resident attachment payloads onto disk
2240 > * under the session's data directory and rewrite the action to
2241 > * reference them via local `file:` URIs. Keeps potentially large
2242 > * blobs (e.g. pasted images) out of the in-memory state tree while
2243 > * letting the agent consume them via the standard {@link IFileService}
2244 > * surface — no special URI scheme or blob round-tripping needed.
2245 > *
2246 > * Failures are isolated per-attachment: if a rewrite cannot be
2247 > * performed (no client connection registered, `resourceRead` rejects,
2248 > * etc.) the original attachment is preserved so the agent still has a
2249 > * chance to make use of it.
2250 > */
2251 > private async _rewriteUserMessageAttachments<T extends ChatTurnStartedAction | ChatPendingMessageSetAction>(channel: string, action: T, clientId: string): Promise<T> {
2252 const attachments = action.message.attachments;
2253 if (!attachments?.length) {
2262 };
2263 }
2265 > private async _rewriteSingleAttachment(attachment: MessageAttachment, attachmentsRoot: URI, attachmentsRootStr: string, clientId: string): Promise<MessageAttachment> {
2266 try {
2267 if (attachment.type === MessageAttachmentKind.EmbeddedResource) {
2287 return attachment;
2288 }
2290 > /**
2291 > * Like {@link IFileService.exists} but never throws (e.g. when no provider
2292 > * is registered for the URI scheme), returning `false` in that case.
2293 > */
2294 > private async _fileExistsSafe(uri: URI): Promise<boolean> {
2295 try {
2296 return await this._fileService.exists(uri);
2299 }
2300 }
2302 > /**
2303 > * Reads `originalUri` through the `vscode-agent-client` filesystem
2304 > * provider so it is fetched from the originating client. Falls back to
2305 > * a direct read against `originalUri` when no client filesystem
2306 > * authority is registered for `clientId` (e.g. unit tests, in-process
2307 > * agent host with a local URI).
2308 > */
2309 > private async _readClientResource(originalUri: URI, clientId: string): Promise<Uint8Array> {
2310 const proxiedUri = clientId ? toAgentClientUri(originalUri, clientId) : originalUri;
2311 try {
2324 }
2325 }
2327 > private async _writeAndRewrite(
2328 original: MessageAttachment,
2329 bytes: Uint8Array,
2347 return rewritten;
2348 }
2350 > /**
2351 > * Pick a sensible on-disk basename for the snapshotted attachment,
2352 > * preserving a usable extension where possible so the SDK and other
2353 > * downstream consumers can detect the right type from the path alone.
2354 > */
2355 > private _attachmentBasename(label: string, contentType: string | undefined): string {
2356 const safeLabel = (label || 'attachment').replace(/[\\/:*?"<>|\u0000-\u001f]/g, '_');
2357 if (resourcesExtname(URI.file(safeLabel))) {
2361 return ext ? `${safeLabel}${ext}` : safeLabel;
2362 }
2364 > async resourceList(uri: URI): Promise<ResourceListResult> {
2365 let stat;
2366 try {
2380 return { entries };
2381 }
2383 > async restoreSession(session: URI): Promise<void> {
2384 const sessionStr = session.toString();
2385
2404 }
2405 }
2407 > private async _doRestoreSession(session: URI, sessionStr: string): Promise<void> {
2408 if (this._stateManager.getSessionState(sessionStr)) {
2409 return;
2629 void this._gitStateService.attachSessionGitHubPullRequest(sessionStr);
2630 }
2632 > /**
2633 > * Restores the additional (non-default) peer chats for a session.
2634 > *
2635 > * Enumeration is driven by the orchestrator's OWN persisted catalog (the
2636 > * {@link PEER_CHATS_METADATA_KEY} blob). For each catalog entry the agent's
2637 > * in-memory backing is re-attached via
2638 > * {@link IAgent.materializeChat} (handing back the opaque
2639 > * `providerData` blob) BEFORE its history is read, then the chat is
2640 > * re-registered in the state manager with its persisted title and draft so
2641 > * it reappears after a process restart. Best-effort: a chat whose history
2642 > * fails to load is restored with no turns rather than dropped.
2643 > *
2644 > * When the orchestrator catalog is absent ({@link _readPersistedPeerChatCatalog}
2645 > * returns `undefined`) the session predates orchestrator-owned persistence:
2646 > * a one-time migration ({@link _migrateLegacyPeerChats}) drains the agent's
2647 > * legacy `*.chats` enumeration into the catalog so it is never consulted
2648 > * again.
2649 > */
2650 > private async _restorePeerChats(agent: IAgent, session: URI): Promise<void> {
2651 const persisted = await this._readPersistedPeerChatCatalog(session);
2652 if (persisted !== undefined) {
2658 await this._migrateLegacyPeerChats(agent, session);
2659 }
2661 > /**
2662 > * One-time migration for sessions persisted before the orchestrator owned
2663 > * the peer-chat catalog: enumerate the agent's legacy `*.chats`
2664 > * ({@link IAgent.listLegacyChats}), restore them via the same path as the
2665 > * new catalog, then write the orchestrator {@link PEER_CHATS_METADATA_KEY}
2666 > * blob so subsequent restores read the new catalog and never consult the
2667 > * legacy read again. No-op when the agent has no legacy enumeration or none
2668 > * is persisted.
2669 > */
2670 > private async _migrateLegacyPeerChats(agent: IAgent, session: URI): Promise<void> {
2671 const legacy = await agent.listLegacyChats?.(session);
2672 if (!legacy || legacy.length === 0) {
2688 await this._enqueuePeerChatCatalogWrite(session, () => [...entries]);
2689 }
2691 > /**
2692 > * Restores a set of peer chats from an enumerated catalog. Loads each
2693 > * chat's history in parallel (after re-attaching its backing) but restores
2694 > * them in catalog order, so the catalog never reorders by which chat's
2695 > * history/title happened to resolve first.
2696 > */
2697 > private async _restorePeerChatsFromCatalog(agent: IAgent, session: URI, entries: readonly IPersistedPeerChat[]): Promise<void> {
2698 const restored = await Promise.all(entries.map(async (entry) => {
2699 let chatUri: URI;
2743 }
2744 }
2746 > /**
2747 > * Re-persists a peer chat's opaque `providerData` blob when the agent
2748 > * reports it changed (e.g. per-chat model switch or fork remap).
2749 > */
2750 > private _onChatDataChanged(e: IAgentChatDataChange): void {
2751 const sessionStr = parseDefaultChatUri(e.chat);
2752 if (sessionStr === undefined) {
2756 void this._persistPeerChat(URI.parse(sessionStr), e.chat, e.providerData);
2757 }
2759 > /**
2760 > * Deterministic membership sequencer for agent-spawned chats,
2761 > * driven off {@link IAgent.onDidSessionProgress}: a `subagent_started` adds
2762 > * the subagent chat to the catalog via the same spawn-channel handler
2763 > * ({@link _onChatSpawned}) used by {@link IAgent.onDidSpawnChat}.
2764 > * A completed subagent chat stays live and subscribable, so completion is
2765 > * not sequenced here; subagent chats are removed only on session teardown.
2766 > * Registered before {@link AgentSideEffects} so the subagent chat exists
2767 > * before its turn starts; addChat is idempotent so overlapping with the
2768 > * agent's own spawn bridge is safe.
2769 > */
2770 > private _sequenceSpawnedChat(signal: AgentSignal): void {
2771 const spawn = SubagentChatSignal.toSpawnEvent(signal);
2772 if (spawn) {
2774 }
2775 }
2777 > /** Marks a subagent chat as pending once its confirmed tool call reaches (or is about to reach) `Running`. */
2778 > private _trackPendingSubagentChatFromEnvelope(envelope: ActionEnvelope): void {
2779 const { channel, action } = envelope;
2780 if (action.type === ActionType.ChatToolCallStart || action.type === ActionType.ChatToolCallDelta || action.type === ActionType.ChatToolCallReady) {
2820 }
2821 }
2823 > private _armPendingSubagentChat(subagentChatUri: string): void {
2824 if (this._pendingSubagentChats.has(subagentChatUri) || this._stateManager.getSnapshot(subagentChatUri)) {
2825 return;
2833 }, SUBAGENT_CHAT_PENDING_TIMEOUT_MS));
2834 }
2836 > private _resolvePendingSubagentChat(resource: string): void {
2837 const deferred = this._pendingSubagentChats.get(resource);
2838 if (!deferred) {
2843 deferred.complete();
2844 }
2846 > /**
2847 > * Routes an agent-spawned chat (e.g. a sub-agent delegated by a tool
2848 > * call) straight into the chat catalog via {@link IAgentHostStateManager.addChat},
2849 > * so harness-spawned chats and user-driven chats share ONE membership path.
2850 > * The {@link IAgentSpawnChatEvent.parent} spawn edge is recorded as
2851 > * the chat's {@link ChatOriginKind.Tool} origin. Spawned chats are
2852 > * not written to the orchestrator's persisted peer-chat catalog — they are
2853 > * transient children re-derived from the parent's event log on restore.
2854 > */
2855 > private _onChatSpawned(e: IAgentSpawnChatEvent): void {
2856 this._stateManager.addChat(e.session.toString(), e.chat.toString(), {
2857 ...(e.title !== undefined ? { title: e.title } : {}),
2866 this._resolvePendingSubagentChat(e.chat.toString());
2867 }
2869 > /**
2870 > * Reads the orchestrator's persisted peer-chat catalog for a session.
2871 > * Returns `undefined` when the session has no catalog yet (a legacy session
2872 > * predating orchestrator-owned persistence, or a corrupt blob); the caller
2873 > * then performs a one-time migration from the agent's legacy `*.chats`
2874 > * enumeration (see {@link _restorePeerChats} / {@link _migrateLegacyPeerChats}).
2875 > * An empty array means the session is known to have no peer chats, so
2876 > * migration is skipped.
2877 > */
2878 > private async _readPersistedPeerChatCatalog(session: URI): Promise<IPersistedPeerChat[] | undefined> {
2879 const ref = await this._sessionDataService.tryOpenDatabase?.(session);
2880 if (!ref) {
2905 }
2906 }
2908 > /**
2909 > * Marks a peer chat's backing SDK session (in that session's own DB) so
2910 > * {@link listSessions} filters it out of the top-level session list. The
2911 > * marker is persisted, so it survives a host restart. Best-effort: a failure
2912 > * only means the backing session may transiently reappear in the list.
2913 > */
2914 > private _markPeerChatBacking(backingSession: URI, chat: URI): void {
2915 let ref;
2916 try {
2926 });
2927 }
2929 > /**
2930 > * Inserts or updates a single peer chat in the orchestrator's persisted
2931 > * catalog, recording its opaque `providerData` verbatim (or clearing it when
2932 > * `undefined`). When `origin` is supplied it is stored as the chat's
2933 > * provenance; when omitted (e.g. a provider-driven `providerData` refresh via
2934 > * {@link _onChatDataChanged}) any previously persisted origin is preserved so
2935 > * a data refresh never drops a side chat's source boundary. Serialized per
2936 > * session via {@link _enqueuePeerChatCatalogWrite}.
2937 > */
2938 > private _persistPeerChat(session: URI, chat: URI, providerData: string | undefined, origin?: ChatOrigin): Promise<void> {
2939 const chatUri = chat.toString();
2940 return this._enqueuePeerChatCatalogWrite(session, entries => {
2950 });
2951 }
2953 > /**
2954 > * Removes a peer chat from the orchestrator's persisted catalog. Serialized
2955 > * per session via {@link _enqueuePeerChatCatalogWrite}.
2956 > */
2957 > private _removePersistedPeerChat(session: URI, chat: URI): Promise<void> {
2958 const chatUri = chat.toString();
2959 return this._enqueuePeerChatCatalogWrite(session, entries => entries.filter(entry => entry.uri !== chatUri));
2960 }
2962 > /**
2963 > * Chains a read-modify-write of a session's persisted peer-chat catalog
2964 > * behind any in-flight write for the same session, so concurrent
2965 > * create/dispose/data-change updates can't clobber each other.
2966 > */
2967 > private _enqueuePeerChatCatalogWrite(session: URI, mutate: (entries: IPersistedPeerChat[]) => IPersistedPeerChat[]): Promise<void> {
2968 const key = session.toString();
2969 const previous = this._peerChatCatalogWrites.get(key) ?? Promise.resolve();
2978 return next;
2979 }
2981 > private async _applyPeerChatCatalogWrite(session: URI, mutate: (entries: IPersistedPeerChat[]) => IPersistedPeerChat[]): Promise<void> {
2982 const ref = await this._sessionDataService.tryOpenDatabase?.(session);
2983 if (!ref) {
3011 }
3012 }
3014 > /** Reads a chat's persisted custom title (default or peer chat), if any. */
3015 > private async _readPersistedChatTitle(session: URI, chatUri: URI): Promise<string | undefined> {
3016 const ref = await this._sessionDataService.tryOpenDatabase?.(session);
3017 if (!ref) {
3026 }
3027 }
3029 > private async _getChatDraft(session: URI, chatUri: URI): Promise<Message | undefined> {
3030 const ref = await this._sessionDataService.tryOpenDatabase(session);
3031 if (!ref) {
3038 }
3039 }
3041 > private async _getSessionMetadataForRestore(agent: IAgent, session: URI): Promise<IAgentSessionMetadata | undefined> {
3042 const sessionStr = session.toString();
3043 if (agent.getSessionMetadata) {
3065 return this._withWorktreeProject(session, await this._getSessionMetadataFromCatalog(agent, session));
3066 }
3068 > /**
3069 > * Merges the repository project for a worktree-isolated session onto its
3070 > * restored metadata so the session groups under the repository (not the
3071 > * `<repo>.worktrees/<name>` directory) in the sessions UI. No-op for folder
3072 > * sessions and for `undefined` metadata. Host-owned so agents stay unaware.
3073 > */
3074 > private async _withWorktreeProject(session: URI, meta: IAgentSessionMetadata | undefined): Promise<IAgentSessionMetadata | undefined> {
3075 if (!meta || !this._worktree) {
3076 return meta;
3079 return project ? { ...meta, project } : meta;
3080 }
3082 > private async _getSessionMetadataFromCatalog(agent: IAgent, session: URI): Promise<IAgentSessionMetadata | undefined> {
3083 const sessionStr = session.toString();
3084 let allSessions;
3094 return allSessions.find(s => s.session.toString() === sessionStr);
3095 }
3097 > async resourceRead(uri: URI): Promise<ResourceReadResult> {
3098 // Handle session-db: URIs that reference file-edit content stored
3099 // in a per-session SQLite database.
3131 }
3132 }
3134 > async resourceWrite(params: ResourceWriteParams): Promise<ResourceWriteResult> {
3135 const fileUri = typeof params.uri === 'string' ? URI.parse(params.uri) : URI.revive(params.uri);
3136 try {
3191 }
3192 }
3194 > private async _createFileExclusive(fileUri: URI, content: VSBuffer): Promise<void> {
3195 if (fileUri.scheme !== Schemas.file) {
3196 await this._fileService.createFile(fileUri, content, { overwrite: false });
3228 }
3229 }
3231 > /**
3232 > * Slow-path for {@link resourceWrite} when the caller requested a
3233 > * non-default {@link ResourceWriteMode}, supplied a `position`, or
3234 > * provided an `ifMatch` etag precondition. Reads the current file
3235 > * contents (when needed) and produces a single `writeFile` call that
3236 > * realises the requested splice. A missing file is treated as
3237 > * empty for `append` and `insert` (so the operation behaves like a
3238 > * create); for `truncate` it falls through to a normal write.
3239 > */
3240 > private async _resourceWriteWithMode(
3241 fileUri: URI,
3242 data: VSBuffer,
3298 }
3299 }
3301 > async resourceCopy(params: ResourceCopyParams): Promise<ResourceCopyResult> {
3302 const source = URI.parse(params.source);
3303 const destination = URI.parse(params.destination);
3316 }
3317 }
3319 > async resourceDelete(params: ResourceDeleteParams): Promise<ResourceDeleteResult> {
3320 const fileUri = URI.parse(params.uri);
3321 try {
3329 }
3330 }
3332 > async resourceMove(params: ResourceMoveParams): Promise<ResourceMoveResult> {
3333 const source = URI.parse(params.source);
3334 const destination = URI.parse(params.destination);
3347 }
3348 }
3350 > async resourceResolve(params: ResourceResolveParams): Promise<ResourceResolveResult> {
3351 const uri = typeof params.uri === 'string' ? URI.parse(params.uri) : URI.revive(params.uri);
3352 try {
3380 }
3381 }
3383 > async resourceMkdir(params: ResourceMkdirParams): Promise<ResourceMkdirResult> {
3384 const uri = typeof params.uri === 'string' ? URI.parse(params.uri) : URI.revive(params.uri);
3385 try {
3403 }
3404 }
3406 > async createResourceWatch(params: CreateResourceWatchParams): Promise<CreateResourceWatchResult> {
3407 const root = typeof params.uri === 'string' ? URI.parse(params.uri) : URI.revive(params.uri);
3408 // Verify the URI exists before we mint a channel; spec requires
3430 return { channel };
3431 }
3433 > /**
3434 > * Notifies the agent service that a client subscribed to a resource
3435 > * watch channel. On the first subscriber the underlying
3436 > * {@link IFileService} watcher is attached; subsequent subscribers
3437 > * bump the refcount and cancel any pending grace dispose. Returns
3438 > * the decoded descriptor for use as the subscribe snapshot, or
3439 > * `undefined` when `channel` is not a recognisable
3440 > * `ahp-resource-watch:` URI.
3441 > */
3442 > onResourceWatchSubscribed(channel: string): ResourceWatchState | undefined {
3443 const descriptor = parseResourceWatchChannelUri(channel);
3444 if (!descriptor) {
3495 return descriptor;
3496 }
3498 > /**
3499 > * Counterpart to {@link onResourceWatchSubscribed}. Decrements the
3500 > * subscriber refcount for a watch channel; when it reaches zero the
3501 > * watcher is held for {@link RESOURCE_WATCH_GRACE_MS} before being
3502 > * disposed, giving a transient disconnect time to resubscribe.
3503 > */
3504 > onResourceWatchUnsubscribed(channel: string): boolean {
3505 const entry = this._resourceWatches.get(channel);
3506 if (!entry) {
3520 return true;
3521 }
3523 > private _dispatchResourceWatchChanges(channel: string, raw: readonly IFileChange[]): void {
3524 if (raw.length === 0) {
3525 return;
3536 });
3537 }
3539 > async shutdown(): Promise<void> {
3540 this._logService.info('AgentService: shutting down all providers...');
3541 const promises: Promise<void>[] = [];
3549 this._downloadProgressInterest.clear();
3550 }
3552 > /**
3553 > * Wire the network diagnostics service backing {@link getNetworkDiagnosticsInfo}
3554 > * and {@link diagnosticsFetch}. A setter rather than a constructor argument
3555 > * because the service depends on the agent-host proxy resolver, which the
3556 > * remote server constructs lazily — after this service.
3557 > */
3558 > setNetworkDiagnosticsService(service: INetworkDiagnosticsService): void {
3559 this._networkDiagnostics = service;
3560 }
3562 > async getNetworkDiagnosticsInfo(): Promise<IAgentHostNetworkDiagnosticsInfo> {
3563 if (!this._networkDiagnostics) {
3564 throw new Error('Network diagnostics unavailable: service not wired');
3597 return this._networkDiagnostics.getInfo(endpoints, accounts.find(account => !!account));
3598 }
3600 > async getManagedSettingsDiagnostics(): Promise<readonly IAgentHostManagedSettingsDiagnostics[]> {
3601 const providers = [...this._providers.values()].filter(provider => provider.getManagedSettingsDiagnostics);
3602 return Promise.all(providers.map(async provider => {
3608 }));
3609 }
3611 > async diagnosticsFetch(url: string): Promise<IAgentHostNetworkFetchResult> {
3612 if (!this._networkDiagnostics) {
3613 throw new Error('Network diagnostics unavailable: service not wired');
3615 return this._networkDiagnostics.fetch(url);
3616 }
3618 > // ---- helpers ------------------------------------------------------------
3619 >
3620 > private async _fetchSessionDbContent(fields: ISessionDbUriFields): Promise<ResourceReadResult> {
3621 const sessionUri = URI.parse(fields.sessionUri);
3622 const ref = this._sessionDataService.openDatabase(sessionUri);
3639 }
3640 }
3642 > private async _fetchGitBlobContent(fields: IGitBlobUriFields): Promise<ResourceReadResult> {
3643 if (!this._gitService) {
3644 throw new ProtocolError(AhpErrorCodes.NotFound, `git service unavailable for: ${fields.repoRelativePath}`);
3658 };
3659 }
3661 > /**
3662 > * Restores a subagent session from its parent session's event history.
3663 > * Loads the parent's raw messages, filters for events belonging to
3664 > * the subagent (by `parentToolCallId`), and builds the child session's
3665 > * turns from those events.
3666 > */
3667 > private async _restoreSubagentSession(subagentUri: string, parentSession: URI): Promise<void> {
3668 if (this._stateManager.getSessionState(subagentUri)) {
3669 return;
3685 }
3686 }
3688 > private async _doRestoreSubagentSession(subagentUri: string, parentSession: URI): Promise<void> {
3689 // Ensure the parent session is loaded first
3690 const parentSessionKey = parentSession.toString();
3768 this._logService.info(`[AgentService] Restored subagent session: ${subagentUri} with ${childTurns.length} turn(s)`);
3769 }
3771 > /**
3772 > * Registers a subagent child session's state up-front from data the agent
3773 > * already reconstructed for the parent, so a later subscribe-driven
3774 > * {@link _restoreSubagentSession} finds it present and returns early
3775 > * instead of re-reading the parent event log. No-op if already registered.
3776 > */
3777 > private _registerRestoredSubagent(child: IRestoredSubagentSession, parentSummary: SessionSummary, parentSessionStr: string): void {
3778 const resourceStr = child.resource.toString();
3779 if (this._stateManager.getSessionState(resourceStr)) {
3807 });
3808 }
3810 > private _findProviderForSession(session: URI | string): IAgent | undefined {
3811 const key = typeof session === 'string' ? session : session.toString();
3812 const providerId = this._sessionToProvider.get(key);
3824 return undefined;
3825 }
3827 > /**
3828 > * Sets the agents observable to trigger model re-fetch and
3829 > * `root/agentsChanged` via the autorun in {@link AgentSideEffects}.
3830 > */
3831 > private _updateAgents(): void {
3832 this._agents.set([...this._providers.values()], undefined);
3833 }
3835 > override dispose(): void {
3836 for (const provider of this._providers.values()) {
3837 provider.dispose();
3840 super.dispose();
3841 }
3842 > } agentService.ts
3843 >
3844 function isErrorWithCode(error: unknown, code: string): boolean {
3845 return error instanceof Error && hasErrorCode(error, code);
3846 }
3848 function hasErrorCode(error: Error | { code: unknown }, code: string): boolean {
3849 return hasKey(error, { code: true }) && error.code === code;
3850 }
3852 > /**
3853 > * Runtime owner of an active resource watch — pairs the {@link IFileService}
3854 > * watcher disposables with the subscriber refcount and the optional
3855 > * grace-window timer used to delay disposal after the last unsubscribe.
3856 > */
3857 > interface IActiveResourceWatch extends IDisposable {
3858 > readonly channel: string;
3859 > readonly descriptor: ResourceWatchState;
3860 > subscribers: number;
3861 > readonly disposables: DisposableStore;
3862 > pendingGc: MutableDisposable<IDisposable>;
3863 > }
3864 >
3865 > /**
3866 > * Flatten a {@link FileChangesEvent} into a synthetic {@link IFileChange}
3867 > * list. The event stores only URI arrays publicly (the underlying
3868 > * `IFileChange[]` is private), so we reconstruct one entry per URI per
3869 > * change type. The synthetic shape is sufficient for translation into
3870 > * `ResourceWatchChangedAction` items.
3871 > */
3872 function collectChanges(event: FileChangesEvent): IFileChange[] {
3873 const out: IFileChange[] = [];
3883 return out;
3884 }
3886 > /**
3887 > * Variant of {@link collectChanges} that restricts the output to changes
3888 > * inside `root` (inclusive). Used for the recursive watch fallback,
3889 > * which feeds off the uncorrelated global stream and must filter out
3890 > * unrelated events.
3891 > */
3892 function collectChangesUnderRoot(event: FileChangesEvent, root: URI): IFileChange[] {
3893 const out: IFileChange[] = [];
src/vs/platform/agentHost/node/agentHostChangesetService.ts 327 introduced LOC · 53 ranges

Open complete file

1 > /*--------------------------------------------------------------------------------------------- agentHostChangesetService.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 { disposableTimeout, SequencerByKey } from '../../../base/common/async.js';
7 > import { toErrorMessage } from '../../../base/common/errorMessage.js';
8 > import { Disposable, DisposableMap } from '../../../base/common/lifecycle.js';
9 > import { URI } from '../../../base/common/uri.js';
10 > import { ILogService } from '../../log/common/log.js';
11 > import {
12 > buildBranchChangesetUri,
13 > buildCompareTurnsChangesetUri,
14 > buildSessionChangesetUri,
15 > buildTurnChangesetUri,
16 > buildUncommittedChangesetUri,
17 > parseChangesetUri,
18 > ChangesetKind,
19 > buildDefaultChangesetCatalog,
20 > } from '../common/changesetUri.js';
21 > import { IDiffComputeService } from '../common/diffComputeService.js';
22 > import { ISessionDatabase, ISessionDataService } from '../common/sessionDataService.js';
23 > import type { ChangesetState, ChangesSummary } from '../common/state/protocol/state.js';
24 > import { ActionType } from '../common/state/sessionActions.js';
25 > import {
26 > ChangesetStatus,
27 > type ChangesetFile,
28 > type ISessionFileDiff,
29 > type URI as ProtocolURI,
30 > readSessionGitState,
31 > isDefaultChatUri,
32 > SessionLifecycle,
33 > } from '../common/state/sessionState.js';
34 > import { AgentHostStateManager, IAgentHostStateManager } from './agentHostStateManager.js';
35 > import { IAgentConfigurationService } from './agentConfigurationService.js';
36 > import { IAgentHostGitService, META_DIFF_BASE_BRANCH, resolveDiffBaseBranchName } from '../common/agentHostGitService.js';
37 > import { IAgentHostCheckpointService } from '../common/agentHostCheckpointService.js';
38 > import { NodeWorkerDiffComputeService } from './diffComputeService.js';
39 > import { computeSessionDiffs, computeTurnDiffs, computeUnionedDiffs, type IIncrementalDiffOptions, type ISessionDiffSource } from './sessionDiffAggregator.js';
40 > import { META_CHECKPOINT_WORKING_DIR } from './agentHostCheckpointService.js';
41 > import { IAgentHostChangesetService, IPersistedChangesetMetadata, IRestoredChangesetDiffs, CHANGESET_DB_METADATA_KEYS, META_CHANGES_SUMMARY, META_CHANGESET_BRANCH, META_CHANGESET_SESSION, META_LEGACY_DIFFS, StaticChangesetKind } from '../common/agentHostChangesetService.js';
42 > import { IAgentHostChangesetSubscriptionService } from '../common/agentHostChangesetSubscriptionService.js';
43 > import { IAgentHostChangesetOperationService } from '../common/agentHostChangesetOperationService.js';
44 > import { IAgentHostReviewService } from '../common/agentHostReviewService.js';
45 > import { relativePath } from '../../../base/common/resources.js';
46 >
47 function staticChangesetUri(session: ProtocolURI, kind: StaticChangesetKind): ProtocolURI {
48 return kind === 'branch'
50 : buildSessionChangesetUri(session);
51 }
53 function persistKeyFor(kind: StaticChangesetKind): string {
54 return kind === 'branch'
56 : META_CHANGESET_SESSION;
57 }
59 > /**
60 > * Sums the per-file diff counts into the {@link ChangesSummary} shape
61 > * that lives on `summary.changes`. Returns `undefined` for an undefined
62 > * input so callers can distinguish "no data yet" from "data, zero changes".
63 > */
64 function summariseDiffs(diffs: readonly ISessionFileDiff[] | undefined): ChangesSummary | undefined {
65 if (!diffs) {
74 return { additions, deletions, files: diffs.length };
75 }
77 > /**
78 > * Derives the `summary.changes` aggregate for an unopened session from
79 > * the ready live {@link ChangesetState} of the catalogue entry whose
80 > * `changeKind === 'session'` — typically because a previous
81 > * `restoreStaticChangeset` warmed the cache before the session itself
82 > * was attached.
83 > *
84 > * Returns `undefined` when no live session-wide state is ready, so
85 > * `listSessions` leaves the `changes` field unset for sessions without
86 > * usable counts — preserving the long-standing contract that unopened
87 > * sessions without live or persisted data advertise no aggregate.
88 > *
89 > * Only the `changeKind: 'session'` entry feeds the summary; other kinds
90 > * (`'uncommitted'`, `'turn'`, `'compare-turns'`) describe slices, not
91 > * the session-level footprint. The static catalogue itself (built by
92 > * {@link buildDefaultChangesetCatalog}) is independent of counts and
93 > * is seeded once at session creation.
94 > */
95 function computeChangesSummaryFromLiveState(
96 session: ChangesetState | undefined,
99 return summariseDiffs(sessionDiffs);
100 }
102 > /**
103 > * Derives the `summary.changes` aggregate for an unopened session from
104 > * parsed persisted diffs for the `changeKind: 'session'` catalogue
105 > * entry. Returns `undefined` when the session-wide blob is absent so
106 > * malformed metadata leaves `summary.changes` unset.
107 > */
108 function computeChangesSummaryFromPersistedDiffs(
109 sessionDiffs: readonly ISessionFileDiff[] | undefined,
111 return summariseDiffs(sessionDiffs);
112 }
114 > /**
115 > * Parses a JSON-serialised {@link ISessionFileDiff}[] blob from session
116 > * metadata. Returns `undefined` for missing or malformed input, logging a
117 > * warning that names `sessionUri` and `kind` so operators can correlate the
118 > * failure with a specific session/changeset slot. Never throws.
119 > */
120 function tryParsePersistedDiffs(raw: string | undefined, sessionUri: string, kind: string, log: ILogService): ISessionFileDiff[] | undefined {
121 if (!raw) {
129 }
130 }
132 > export class AgentHostChangesetService extends Disposable implements IAgentHostChangesetService {
133 > declare readonly _serviceBrand: undefined;
134 >
135 > /** Shared diff compute service for calculating line-level diffs in a worker thread. */
136 > private readonly _diffComputeService: IDiffComputeService;
137 > /** Serializes per-session diff computations to avoid races with stale previousDiffs. */
138 > private readonly _diffComputationSequencer = new SequencerByKey<string>();
139 > /** Per-session debounce timers for mid-turn diff computation. */
140 > private readonly _debouncedDiffTimers = this._register(new DisposableMap<string>());
141 > /** Per-`(session, turnId)` debounce timers for mid-turn per-turn changeset recomputation. */
142 > private readonly _perTurnDebouncedDiffTimers = this._register(new DisposableMap<string>());
143 > private readonly _activeStaticComputes = new Set<ProtocolURI>();
144 > private static readonly _DIFF_DEBOUNCE_MS = 5000;
145 >
146 > /**
147 > * Sessions whose static changeset refresh was requested before the
148 > * working directory was known (provisional / not-yet-materialized
149 > * sessions). Drained from {@link onWorkingDirectoryAvailable} once the
150 > * working directory is set, which recomputes every changeset still
151 > * subscribed for the session.
152 > *
153 > * Firing a refresh before the working directory is known would compute
154 > * against a missing directory and the git path would bail, so we defer
155 > * instead and re-run once materialization / restore populates it.
156 > */
157 > private readonly _pendingMaterialization = new Set<ProtocolURI>();
158 >
159 > constructor(
160 @IAgentHostStateManager private readonly _stateManager: AgentHostStateManager,
161 @ILogService private readonly _logService: ILogService,
171 this._diffComputeService = this._register(new NodeWorkerDiffComputeService(this._logService));
172 }
174 > /**
175 > * Returns true when at least one client is subscribed to `changeset`
176 > * under `session`.
177 > */
178 > private _hasSubscription(session: ProtocolURI, changeset: ProtocolURI): boolean {
179 return this._changesetSubscriptions.getSessionSubscriptions(session).has(changeset);
180 }
182 > private _hasWorkingDirectory(session: ProtocolURI): boolean {
183 return !!this._configurationService.getEffectiveWorkingDirectory(session);
184 }
186 > registerStaticChangesets(session: ProtocolURI): void {
187 this._stateManager.registerChangeset(buildBranchChangesetUri(session));
188 this._stateManager.registerChangeset(buildUncommittedChangesetUri(session));
189 this._stateManager.registerChangeset(buildSessionChangesetUri(session));
190 }
192 > restoreStaticChangeset(session: ProtocolURI, kind: StaticChangesetKind, diffs: readonly ISessionFileDiff[]): void {
193 const changesetUri = this._stateManager.registerChangeset(staticChangesetUri(session, kind));
194 this._publishChangesetDiffs(session, changesetUri, diffs);
195 }
197 > parsePersistedStaticChangesets(sessionUri: ProtocolURI, metadata: IPersistedChangesetMetadata): IRestoredChangesetDiffs {
198 const persistedBranch = tryParsePersistedDiffs(metadata.branchRaw, sessionUri, 'branch', this._logService);
199
205 return { branch: persistedBranch, session: persistedSession };
206 }
208 > applyPersistedStaticChangesets(sessionUri: ProtocolURI, diffs: IRestoredChangesetDiffs): void {
209 // `seedIfEmpty`: only reseed persisted diffs when the matching live
210 // changeset state is absent or empty. Live state (e.g. from a prior
215 this._seedIfEmpty(sessionUri, 'session', diffs.session);
216 }
218 > restorePersistedStaticChangesets(sessionUri: ProtocolURI, metadata: IPersistedChangesetMetadata): IRestoredChangesetDiffs {
219 const parsed = this.parsePersistedStaticChangesets(sessionUri, metadata);
220 this.applyPersistedStaticChangesets(sessionUri, parsed);
221 return parsed;
222 }
224 > persistChangesSummary(sessionUri: ProtocolURI, summary: ChangesSummary): void {
225 this._persistSessionFlag(sessionUri, META_CHANGES_SUMMARY, JSON.stringify(summary));
226 }
228 > getListMetadataKeys(sessionUri: ProtocolURI): Record<string, true> | undefined {
229 // Fast path: a live `summary.changes` (loaded session) or a ready live
230 // `changeKind: 'session'` changeset state (registered but not-yet-
241 return CHANGESET_DB_METADATA_KEYS;
242 }
244 > computeListEntryChanges(sessionUri: ProtocolURI, metadata: Record<string, string | undefined>): ChangesSummary | undefined {
245 // Loaded session: the caller has already projected
246 // `state.summary.changes` onto the entry. Nothing to overlay.
294 return undefined;
295 }
297 > isStaticChangesetComputeActive(changesetUri: ProtocolURI): boolean {
298 return this._activeStaticComputes.has(changesetUri);
299 }
301 > private _seedIfEmpty(session: ProtocolURI, kind: StaticChangesetKind, diffs: readonly ISessionFileDiff[] | undefined): void {
302 if (!diffs) {
303 return;
309 this.restoreStaticChangeset(session, kind, diffs);
310 }
312 > refreshChangesetCatalog(session: ProtocolURI): void {
313 const state = this._stateManager.getSessionState(session);
314 if (!state || state?.lifecycle === SessionLifecycle.CreationFailed) {
319 this._stateManager.setSessionChangesets(session, changesets);
320 }
322 > refreshBranchChangeset(session: ProtocolURI): void {
323 if (!this._hasWorkingDirectory(session)) {
324 this._pendingMaterialization.add(session);
327 this._scheduleStaticRecompute(session, 'branch', undefined, this._markStaticChangesetComputing(session, 'branch'));
328 }
330 > refreshSessionChangeset(session: ProtocolURI): void {
331 if (!this._hasWorkingDirectory(session)) {
332 this._pendingMaterialization.add(session);
335 this._scheduleStaticRecompute(session, 'session', undefined, this._markStaticChangesetComputing(session, 'session'));
336 }
338 > /**
339 > * Drains static changeset refreshes that were deferred because the
340 > * session's working directory was not yet known. Called by the
341 > * coordinator once a session is materialized or restored. Recomputes
342 > * every changeset still subscribed for the session; subscriptions that
343 > * dropped while the working directory was unknown are naturally skipped.
344 > */
345 > onWorkingDirectoryAvailable(session: ProtocolURI): void {
346 if (this._pendingMaterialization.delete(session)) {
347 this.recomputeSubscribedChangesets(session);
348 }
349 }
351 > /**
352 > * Recomputes every changeset currently subscribed for `session`. Each
353 > * subscribed changeset is dispatched to its kind-specific recompute; the
354 > * recomputes self-defer when the working directory is still unknown.
355 > */
356 > recomputeSubscribedChangesets(session: ProtocolURI): void {
357 const subscriptions = this._changesetSubscriptions.getSessionSubscriptions(session);
358 if (subscriptions.size === 0) {
388 }
389 }
391 > /**
392 > * Forgets any deferred static changeset refreshes queued for a session
393 > * that is being disposed.
394 > */
395 > onSessionDisposed(session: ProtocolURI): void {
396 this._pendingMaterialization.delete(session);
397 }
399 > async computeTurnChangeset(session: ProtocolURI, turnId: string): Promise<ProtocolURI> {
400 const turnUri = this._stateManager.registerChangeset(buildTurnChangesetUri(session, turnId));
401 let ref: ReturnType<ISessionDataService['openDatabase']>;
431 return turnUri;
432 }
434 > async computeCompareTurnsChangeset(session: ProtocolURI, originalTurnId: string, modifiedTurnId: string): Promise<ProtocolURI> {
435 const compareUri = this._stateManager.registerChangeset(buildCompareTurnsChangesetUri(session, originalTurnId, modifiedTurnId));
436 let ref: ReturnType<ISessionDataService['openDatabase']>;
516 return compareUri;
517 }
519 > async computeUncommittedChangeset(session: ProtocolURI): Promise<ProtocolURI> {
520 const uncommittedUri = this._stateManager.registerChangeset(buildUncommittedChangesetUri(session));
521 if (!this._hasSubscription(session, uncommittedUri)) {
567 return uncommittedUri;
568 }
570 > private async _computeUncommittedDiffs(session: ProtocolURI): Promise<readonly ISessionFileDiff[] | undefined> {
571 const workingDirectory = this._stateManager.getSessionState(session)?.workingDirectories?.[0];
572 if (!workingDirectory) {
585 });
586 }
588 > private async _computeTurnDiffsPreferCheckpoint(session: ProtocolURI, db: ISessionDatabase, turnId: string): Promise<readonly ISessionFileDiff[]> {
589 const pair = await this._checkpointService.getTurnCheckpointPair(URI.parse(session), turnId);
590 if (pair && pair.parent !== pair.current) {
609 return computeTurnDiffs(session, db, this._diffComputeService, turnId);
610 }
612 > private async _resolveWorkingDirectory(db: ISessionDatabase): Promise<URI | undefined> {
613 // Checkpoint baseline writes `checkpoint.workingDir` alongside
614 // `checkpoint.baseRef`. We use that as the canonical working
618 return raw ? URI.parse(raw) : undefined;
619 }
621 > // ---- Lifecycle hooks invoked by AgentSideEffects -----------------------
622 >
623 > onToolCallEditsApplied(session: ProtocolURI, turnId: string): void {
624 this._scheduleDebouncedDiffComputation(session, turnId);
625 // Per-turn URIs have no catalogue chip aggregates, so skip the
631 }
632 }
634 > onTurnComplete(session: ProtocolURI, turnId: string | undefined): void {
635 // Ordering matters for cancellation: cancel any pending mid-turn
636 // debounces first so the final turn-complete computes supersede
653 this._scheduleStaticRecompute(session, 'session', turnId);
654 }
656 > onSessionTruncated(session: ProtocolURI): void {
657 // Turns were removed — recompute from scratch (no changedTurnId).
658 this._scheduleStaticRecompute(session, 'branch');
659 this._scheduleStaticRecompute(session, 'session');
660 }
662 > // ---- Internal compute pipeline -----------------------------------------
663 >
664 > /**
665 > * Schedules a debounced session-changeset recomputation. Uncommitted
666 > * recomputes ride the same turn-complete path; mid-turn debounce only
667 > * makes sense for the SDK-tracked session-wide diff (which sees fresh
668 > * `tool_complete` events between turn boundaries).
669 > */
670 > private _scheduleDebouncedDiffComputation(session: ProtocolURI, turnId: string): void {
671 this._debouncedDiffTimers.set(session, disposableTimeout(() => {
672 this._debouncedDiffTimers.deleteAndDispose(session);
675 }, AgentHostChangesetService._DIFF_DEBOUNCE_MS));
676 }
678 > /**
679 > * Cancels any pending debounced diff computation for a session.
680 > * Called at turn end before the final (non-debounced) computation.
681 > */
682 > private _cancelDebouncedDiffComputation(session: ProtocolURI): void {
683 this._debouncedDiffTimers.deleteAndDispose(session);
684 }
686 > /**
687 > * Schedules a debounced per-turn changeset recomputation. Mirrors
688 > * {@link _scheduleDebouncedDiffComputation} but uses a per-
689 > * `(session, turnId)` map key so a long-running per-turn compute
690 > * doesn't block the static session recompute path (and vice versa).
691 > */
692 > private _scheduleDebouncedTurnDiffComputation(session: ProtocolURI, turnId: string): void {
693 const key = `${session}\u0000${turnId}`;
694 this._perTurnDebouncedDiffTimers.set(key, disposableTimeout(() => {
697 }, AgentHostChangesetService._DIFF_DEBOUNCE_MS));
698 }
700 > /**
701 > * Cancels any pending debounced per-turn diff computation for a
702 > * `(session, turnId)`. Called at turn end before the final
703 > * (non-debounced) per-turn computation.
704 > */
705 > private _cancelDebouncedTurnDiffComputation(session: ProtocolURI, turnId: string): void {
706 this._perTurnDebouncedDiffTimers.deleteAndDispose(`${session}\u0000${turnId}`);
707 }
709 > /**
710 > * Queues a per-turn recompute on a per-`(session, turnId)` sequencer
711 > * key so back-to-back recomputes for the same turn serialise, but
712 > * recomputes for different turns (or for the static `session` /
713 > * `uncommitted` slots) run independently. Fire-and-forget — failures
714 > * are logged inside `computeTurnChangeset` and do not fail the turn.
715 > */
716 > private _scheduleTurnRecompute(session: ProtocolURI, turnId: string): void {
717 this._diffComputationSequencer.queue(`${session}\u0000turn\u0000${turnId}`, () => this.computeTurnChangeset(session, turnId).then(() => undefined));
718 }
720 > private _scheduleUncommittedRecompute(session: ProtocolURI): void {
721 this._diffComputationSequencer.queue(`${session}\u0000uncommitted`, () => this.computeUncommittedChangeset(session).then(() => undefined));
722 }
724 > /**
725 > * Schedules a static changeset (`uncommitted` or `session`) recompute,
726 > * serialised per-session so back-to-back triggers don't race against
727 > * stale `previousDiffs` reads. Fire-and-forget — failures are logged
728 > * but do not fail the turn.
729 > */
730 > private _scheduleStaticRecompute(session: ProtocolURI, kind: StaticChangesetKind, changedTurnId?: string, statusBeforeRefresh?: ChangesetStatus): void {
731 this._diffComputationSequencer.queue(`${session}\u0000${kind}`, () => this._doComputeStaticChangeset(session, kind, changedTurnId, statusBeforeRefresh));
732 }
734 > private _markStaticChangesetComputing(session: ProtocolURI, kind: StaticChangesetKind): ChangesetStatus | undefined {
735 const changesetUri = staticChangesetUri(session, kind);
736 this._stateManager.registerChangeset(changesetUri);
744 return status;
745 }
747 > private async _doComputeStaticChangeset(session: ProtocolURI, kind: StaticChangesetKind, changedTurnId?: string, statusBeforeRefresh?: ChangesetStatus): Promise<void> {
748 const changesetUri = staticChangesetUri(session, kind);
749 this._activeStaticComputes.add(changesetUri);
851 }
852 }
854 > /**
855 > * Refresh requests optimistically mark static changesets as Computing
856 > * while preserving their current files. Some refresh paths intentionally
857 > * do not publish a replacement file list (for example, uncommitted git
858 > * diff is temporarily unavailable), so restore the previous non-computing
859 > * status instead of leaving a stale cached snapshot stuck as Computing.
860 > */
861 > private _restoreStaticChangesetStatus(changesetUri: ProtocolURI, status: ChangesetStatus | undefined): void {
862 if (!status || status === ChangesetStatus.Computing) {
863 return;
868 });
869 }
871 > /**
872 > * Reads the previous diff list back out of the changeset state so the
873 > * incremental aggregator can avoid recomputing files that haven't
874 > * changed.
875 > */
876 > private _readPreviousChangesetDiffs(changesetUri: ProtocolURI): readonly ISessionFileDiff[] | undefined {
877 const state = this._stateManager.getChangesetState(changesetUri);
878 if (!state || state.files.length === 0) {
881 return state.files.map(f => f.edit);
882 }
884 > /**
885 > * Translates the new file list into a sequence of changeset/* actions
886 > * (fileSet, fileRemoved) and moves the changeset to `ready` once the
887 > * fresh file list has been applied.
888 > */
889 > private _publishChangesetDiffs(session: ProtocolURI, changesetUri: ProtocolURI, diffs: readonly ISessionFileDiff[], reviewed?: { readonly repoRoot: URI; readonly paths: ReadonlySet<string> }): void {
890 // Get the available operations for this changeset. This call assumes that at this point
891 // the git state of the session is up-to-date as it is being used to determine the available
930 }
931 }
933 > /**
934 > * Opens the databases for every non-default (peer) chat in a multi-chat
935 > * session. Each peer chat records its file edits into its own database
936 > * keyed by the chat URI, so the session changeset must union those
937 > * databases with the session DB. Returns an empty array for single-chat
938 > * sessions. Callers MUST dispose every returned `ref`.
939 > */
940 > private _openPeerChatSources(session: ProtocolURI): { sessionUri: ProtocolURI; ref: ReturnType<ISessionDataService['openDatabase']> }[] {
941 const chats = this._stateManager.getSessionState(session)?.chats ?? [];
942 const sources: { sessionUri: ProtocolURI; ref: ReturnType<ISessionDataService['openDatabase']> }[] = [];
954 return sources;
955 }
957 > /**
958 > * Returns the turn id whose checkpoint best represents the latest state of
959 > * the session's shared working tree. For single-chat sessions this is the
960 > * default chat's last turn. For multi-chat sessions it is the last turn of
961 > * the most-recently-modified chat (peer-chat turn checkpoints are stored
962 > * under the session URI keyed by their turn id). Returns `undefined` when
963 > * no chat has any turns.
964 > */
965 > private _latestTurnIdAcrossChats(session: ProtocolURI): string | undefined {
966 const sessionState = this._stateManager.getSessionState(session);
967 if (!sessionState) {
988 return bestTurnId;
989 }
991 > /**
992 > * Computes diffs for a static changeset by shelling out to git.
993 > * Returns the diff list when the session has a working directory and
994 > * that directory is a git work tree; returns `undefined` otherwise so
995 > * the caller can fall back to the edit-tracker aggregator (for
996 > * `kind: 'session'`) or preserve cached state (for `kind: 'branch'`).
997 > *
998 > * For `kind: 'session'` the diff is computed between the baseline
999 > * checkpoint ref and the latest turn checkpoint ref.
1000 > * For `kind: 'branch'` the diff is computed against the merge-base
1001 > * with {@link META_DIFF_BASE_BRANCH} when one is set; without a base
1002 > * branch git falls back to `HEAD`.
1003 > */
1004 > private async _tryComputeGitDiffs(session: ProtocolURI, db: ISessionDatabase, kind: StaticChangesetKind): Promise<readonly ISessionFileDiff[] | undefined> {
1005 const workingDirectory = this._stateManager.getSessionState(session)?.workingDirectories?.[0];
1006 if (!workingDirectory) {
1060 }
1061 }
1063 > /**
1064 > * Resolves the Branch Changes base branch, reused by the diff computation
1065 > * and the review-status lookup so both are keyed on the same baseline.
1066 > */
1067 > private async _resolveBranchBaseBranch(session: ProtocolURI, db: ISessionDatabase): Promise<string | undefined> {
1068 const persistedBaseBranch = await db.getMetadata(META_DIFF_BASE_BRANCH);
1069 const gitStateBaseBranch = readSessionGitState(this._stateManager.getSessionState(session)?._meta)?.baseBranchName;
1073 return resolveDiffBaseBranchName(persistedBaseBranch, gitStateBaseBranch);
1074 }
1076 > /**
1077 > * Computes the reviewed-paths overlay for the Branch changeset: the
1078 > * repository root (used to key file ids to repo-relative paths) and the set
1079 > * of reviewed repo-relative paths. Returns `undefined` when the session has
1080 > * no git working directory (review status is then simply omitted).
1081 > */
1082 > private async _computeReviewedInfo(session: ProtocolURI, db: ISessionDatabase): Promise<{ readonly repoRoot: URI; readonly paths: ReadonlySet<string> } | undefined> {
1083 const workingDirectory = this._stateManager.getSessionState(session)?.workingDirectories?.[0];
1084 if (!workingDirectory) {
1103 return { repoRoot, paths };
1104 }
1106 > /**
1107 > * Persists a session metadata key/value pair to the session database.
1108 > * Counterpart in `agentSideEffects.ts` (`AgentSideEffects._persistSessionFlag`):
1109 > * keep both copies in sync if the signature changes. Duplicated rather
1110 > * than lifted because the two consumers persist disjoint metadata
1111 > * (changeset diffs here vs. customTitle / isRead / isArchived /
1112 > * configValues there) and a shared util would only have two callers.
1113 > */
1114 > private _persistSessionFlag(session: ProtocolURI, key: string, value: string): void {
1115 const ref = this._sessionDataService.openDatabase(URI.parse(session));
1116 ref.object.setMetadata(key, value).catch(err => {
src/vs/platform/agentHost/node/agentHostReviewService.ts 69 introduced LOC · 14 ranges

Open complete file

1 > /*--------------------------------------------------------------------------------------------- agentHostReviewService.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 { SequencerByKey } from '../../../base/common/async.js';
7 > import { Disposable } from '../../../base/common/lifecycle.js';
8 > import { relativePath } from '../../../base/common/resources.js';
9 > import { URI } from '../../../base/common/uri.js';
10 > import { ILogService } from '../../log/common/log.js';
11 > import { AgentSession } from '../common/agentService.js';
12 > import { ChangesetKind, parseChangesetUri } from '../common/changesetUri.js';
13 > import { EMPTY_TREE_OBJECT, IAgentHostGitService, META_DIFF_BASE_BRANCH, resolveDiffBaseBranchName } from '../common/agentHostGitService.js';
14 > import { buildReviewedRefName, IAgentHostReviewService } from '../common/agentHostReviewService.js';
15 > import { ISessionDataService } from '../common/sessionDataService.js';
16 > import { readSessionGitState, type URI as ProtocolURI } from '../common/state/sessionState.js';
17 > import { AgentHostStateManager, IAgentHostStateManager } from './agentHostStateManager.js';
18 >
19 > /**
20 > * Resolved git context shared by the review operations: the repository root,
21 > * the Branch Changes baseline tree, and the current reviewed ref/tree.
22 > */
23 > interface IReviewContext {
24 > readonly repoRoot: URI;
25 > /** Tree object of the baseline. */
26 > readonly baselineTree: string;
27 > /** Name of the session's reviewed ref. */
28 > readonly reviewedRef: string;
29 > /** Current reviewed commit, or `undefined` when the ref does not exist yet. */
30 > readonly reviewedCommit: string | undefined;
31 > /** Current reviewed tree; equals `baselineTree` when the ref does not exist. */
32 > readonly reviewedTree: string;
33 > }
34 >
35 > export class AgentHostReviewService extends Disposable implements IAgentHostReviewService {
36 > declare readonly _serviceBrand: undefined;
37 >
38 > /**
39 > * Serializes mark/unmark/read per session so back-to-back mutations don't
40 > * race on the reviewed ref rebuild and reads observe a consistent ref.
41 > */
42 > private readonly _sequencer = new SequencerByKey<string>();
43 >
44 > constructor(
45 @IAgentHostStateManager private readonly _stateManager: AgentHostStateManager,
46 @IAgentHostGitService private readonly _gitService: IAgentHostGitService,
58 }));
59 }
61 > async setReviewState(channel: ProtocolURI, resources: readonly ProtocolURI[], reviewed: boolean): Promise<void> {
62 const parsed = parseChangesetUri(channel);
63 if (!parsed || parsed.kind !== ChangesetKind.Branch) {
89 });
90 }
92 > markFileReviewed(session: ProtocolURI, workingDirectory: URI, baseBranch: string | undefined, resource: URI): Promise<void> {
93 return this._sequencer.queue(session, () => this._setReviewed(session, workingDirectory, baseBranch, resource, true));
94 }
96 > markFileUnreviewed(session: ProtocolURI, workingDirectory: URI, baseBranch: string | undefined, resource: URI): Promise<void> {
97 return this._sequencer.queue(session, () => this._setReviewed(session, workingDirectory, baseBranch, resource, false));
98 }
100 > getReviewedPaths(session: ProtocolURI, workingDirectory: URI, baseBranch: string | undefined): Promise<ReadonlySet<string>> {
101 return this._sequencer.queue(session, () => this._getReviewedPaths(session, workingDirectory, baseBranch));
102 }
104 > copyReviewedRef(sourceSession: ProtocolURI, targetSession: ProtocolURI, workingDirectory: URI): Promise<void> {
105 return this._sequencer.queue(targetSession, () => this._copyReviewedRef(sourceSession, targetSession, workingDirectory));
106 }
108 > private async _copyReviewedRef(sourceSession: ProtocolURI, targetSession: ProtocolURI, workingDirectory: URI): Promise<void> {
109 const repoRoot = await this._gitService.getRepositoryRoot(workingDirectory);
110 if (!repoRoot) {
122 this._logService.trace(`[AgentHostReview][_copyReviewedRef] Copied reviewed ref ${sourceRef} -> ${targetRef} for fork`);
123 }
125 > private async _setReviewed(session: ProtocolURI, workingDirectory: URI, baseBranch: string | undefined, resource: URI, reviewed: boolean): Promise<void> {
126 const context = await this._resolveContext(session, workingDirectory, baseBranch);
127 if (!context) {
172 this._logService.trace(`[AgentHostReview][_setReviewed] ${message} for ${session.toString()} -> ${context.reviewedRef}@${commit}`);
173 }
175 > private async _getReviewedPaths(session: ProtocolURI, workingDirectory: URI, baseBranch: string | undefined): Promise<ReadonlySet<string>> {
176 const context = await this._resolveContext(session, workingDirectory, baseBranch);
177 if (!context?.reviewedCommit) {
202 return new Set(changed.filter(path => !unreviewedSet.has(path)));
203 }
205 > private async _resolveContext(session: ProtocolURI, workingDirectory: URI, baseBranch: string | undefined): Promise<IReviewContext | undefined> {
206 const repoRoot = await this._gitService.getRepositoryRoot(workingDirectory);
207 if (!repoRoot) {
229 return { repoRoot, baselineTree, reviewedRef, reviewedCommit, reviewedTree };
230 }
232 > async disposeSessionData(session: ProtocolURI): Promise<void> {
233 await this._sequencer.queue(session, () => this._disposeSessionData(session));
234 }
236 > private async _disposeSessionData(session: ProtocolURI): Promise<void> {
237 const workingDirectory = this._stateManager.getSessionState(session)?.workingDirectories?.[0];
238 if (!workingDirectory) {
src/vs/platform/agentHost/node/agentHostCheckpointService.ts 62 introduced LOC · 13 ranges

Open complete file

1 > /*--------------------------------------------------------------------------------------------- agentHostCheckpointService.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 { SequencerByKey } from '../../../base/common/async.js';
7 > import { Disposable } from '../../../base/common/lifecycle.js';
8 > import { URI } from '../../../base/common/uri.js';
9 > import { ILogService } from '../../log/common/log.js';
10 > import { IAgentHostCheckpointService, META_CHECKPOINT_BASE_REF, buildCheckpointRefName } from '../common/agentHostCheckpointService.js';
11 > import { AgentSession } from '../common/agentService.js';
12 > import { ISessionDatabase, ISessionDataService } from '../common/sessionDataService.js';
13 > import { IAgentHostGitService } from '../common/agentHostGitService.js';
14 >
15 > /**
16 > * `session_metadata` key under which the working directory used for
17 > * checkpoint capture is persisted (set when the baseline is created).
18 > * Stored as `URI.toString()`. Read by `captureTurnCheckpoint` /
19 > * `disposeSessionData` so they can resolve the repo without per-call
20 > * working-directory plumbing.
21 > */
22 > export const META_CHECKPOINT_WORKING_DIR = 'checkpoint.workingDir';
23 >
24 > export class AgentHostCheckpointService extends Disposable implements IAgentHostCheckpointService {
25 > declare readonly _serviceBrand: undefined;
26 >
27 > /**
28 > * Serializes capture/dispose per session so back-to-back end-of-turn
29 > * captures don't race on the temp-index files or the `setTurnCheckpointRef`
30 > * write, and a dispose can't run concurrently with an in-flight capture.
31 > * Keyed by session URI string.
32 > */
33 > private readonly _sequencer = new SequencerByKey<string>();
34 >
35 > constructor(
36 @ISessionDataService private readonly _sessionDataService: ISessionDataService,
37 @IAgentHostGitService private readonly _gitService: IAgentHostGitService,
48 }));
49 }
51 > captureBaseline(sessionUri: URI, workingDirectory: URI | undefined): Promise<string | undefined> {
52 return this._sequencer.queue(sessionUri.toString(), () => this._captureBaseline(sessionUri, workingDirectory));
53 }
55 > private async _captureBaseline(sessionUri: URI, workingDirectory: URI | undefined): Promise<string | undefined> {
56 if (!workingDirectory) {
57 return undefined;
85 }
86 }
88 > captureTurnCheckpoint(sessionUri: URI, turnId: string): Promise<string | undefined> {
89 return this._sequencer.queue(sessionUri.toString(), () => this._captureTurnCheckpoint(sessionUri, turnId));
90 }
92 > private async _captureTurnCheckpoint(sessionUri: URI, turnId: string): Promise<string | undefined> {
93 const ref = this._sessionDataService.openDatabase(sessionUri);
94 try {
153 }
154 }
156 > async getTurnCheckpointPair(sessionUri: URI, turnId: string): Promise<{ parent: string; current: string } | undefined> {
157 const ref = this._sessionDataService.openDatabase(sessionUri);
158 try {
174 }
175 }
177 > async getBaselineCheckpointRef(sessionUri: URI): Promise<string | undefined> {
178 const ref = this._sessionDataService.openDatabase(sessionUri);
179 try {
183 }
184 }
186 > async disposeSessionData(sessionUri: URI): Promise<void> {
187 await this._sequencer.queue(sessionUri.toString(), () => this._disposeSessionData(sessionUri));
188 }
190 > private async _disposeSessionData(sessionUri: URI): Promise<void> {
191 const refHandle = await this._sessionDataService.tryOpenDatabase(sessionUri);
192 if (!refHandle) {
228 }
229 }
231 > private async _writeCheckpointCommit(
232 workingDirectory: URI,
233 parentOid: string | undefined,
248 return { commitOid };
249 }
251 > /**
252 > * Parses the highest turn number from the existing refs and returns
253 > * the next one. Falls back to 1 (baseline is always 0).
254 > */
255 > private async _nextTurnNumber(db: ISessionDatabase): Promise<number> {
256 const refs = await db.getAllCheckpointRefs();
257 let max = 0;
src/vs/platform/agentHost/node/diffComputeService.ts 33 introduced LOC · 5 ranges

Open complete file

1 > /*--------------------------------------------------------------------------------------------- diffComputeService.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 { Worker } from 'worker_threads';
7 > import { Disposable } from '../../../base/common/lifecycle.js';
8 > import { FileAccess } from '../../../base/common/network.js';
9 > import { ILogService } from '../../log/common/log.js';
10 > import { DEFAULT_DIFF_TIMEOUT_MS, IDiffComputeService, type IDiffCountResult } from '../common/diffComputeService.js';
11 >
12 > /**
13 > * Node.js implementation of {@link IDiffComputeService} that runs
14 > * {@link DefaultLinesDiffComputer} in a worker thread to avoid blocking
15 > * the main thread.
16 > */
17 > export class NodeWorkerDiffComputeService extends Disposable implements IDiffComputeService {
18 >
19 > declare readonly _serviceBrand: undefined;
20 >
21 > private _worker: Worker | undefined;
22 > private _workerFailures = 0;
23 > private _nextId = 1;
24 > private readonly _pending = new Map<number, { resolve: (value: IDiffCountResult) => void; reject: (err: Error) => void }>();
25 >
26 > constructor(
27 @ILogService private readonly _logService: ILogService,
28 ) {
29 super();
30 }
32 > async computeDiffCounts(original: string, modified: string, timeoutMs: number = DEFAULT_DIFF_TIMEOUT_MS): Promise<IDiffCountResult> {
33 const worker = this._ensureWorker();
34 const id = this._nextId++;
43 });
44 }
46 > private _ensureWorker(): Worker {
47 if (this._workerFailures >= 3) {
48 throw new Error('Diff compute worker failed too many times');
80 return this._worker;
81 }
83 > override dispose(): void {
84 if (this._worker) {
85 this._worker.terminate();
src/vs/platform/agentHost/node/agentHostAuthenticationService.ts 30 introduced LOC · 7 ranges

Open complete file

1 > /*--------------------------------------------------------------------------------------------- agentHostAuthenticationService.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 type { ILogService } from '../../log/common/log.js';
7 > import type { AuthenticateParams, AuthenticateResult, IAgent, IAgentHostAuthTokenRequest } from '../common/agentService.js';
8 >
9 > interface IStoredAuthToken {
10 > readonly resource: string;
11 > readonly scopes: readonly string[];
12 > readonly token: string;
13 > }
14 >
15 > export class AgentHostAuthenticationService {
16 >
17 > private readonly _tokens = new Map<string, IStoredAuthToken>();
18 >
19 > constructor(
20 private readonly _logService: ILogService,
21 ) { }
23 > async authenticate(params: AuthenticateParams, providers: Iterable<IAgent>): Promise<AuthenticateResult> {
24 this._logService.trace(`[AgentHostAuthenticationService] authenticate called: resource=${params.resource}`);
25 const providerList = [...providers];
70 return { authenticated };
71 }
73 > getAuthToken(request: IAgentHostAuthTokenRequest): string | undefined {
74 const scopes = this._normalizeScopes(request.scopes);
75 const exact = this._tokens.get(this._key(request.resource, scopes));
102 return this._tokens.get(this._key(request.resource, []))?.token;
103 }
105 > private _containsAll(scopes: readonly string[], requested: ReadonlySet<string>): boolean {
106 for (const scope of requested) {
107 if (!scopes.includes(scope)) {
src/vs/platform/agentHost/node/agentHostDiscardChangesOperationProvider.ts 22 introduced LOC · 4 ranges

Open complete file

1 > /*--------------------------------------------------------------------------------------------- agentHostDiscardChangesOperationProvider.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 { Disposable, DisposableStore, IDisposable } from '../../../base/common/lifecycle.js';
7 > import { localize } from '../../../nls.js';
8 > import { IInstantiationService } from '../../instantiation/common/instantiation.js';
9 > import { ChangesetKind } from '../common/changesetUri.js';
10 > import type { IChangesetOperationContribution, IChangesetOperationContext, IChangesetOperationRegistry } from '../common/agentHostChangesetOperationService.js';
11 > import { ChangesetOperationScope, ChangesetOperationStatus, type ChangesetOperation } from '../common/state/sessionState.js';
12 > import { AgentHostDiscardChangesOperationHandler } from './agentHostDiscardChangesOperationHandler.js';
13 > import { AgentHostStateManager, IAgentHostStateManager } from './agentHostStateManager.js';
14 >
15 > export class AgentHostDiscardChangesOperationContribution extends Disposable implements IChangesetOperationContribution {
16 >
17 > constructor(
18 @IAgentHostStateManager private readonly _stateManager: AgentHostStateManager,
19 @IInstantiationService private readonly _instantiationService: IInstantiationService,
21 super();
22 }
24 > registerHandlers(registry: IChangesetOperationRegistry): IDisposable {
25 const store = new DisposableStore();
26 const getSessionState = (sessionKey: string) => this._stateManager.getSessionState(sessionKey);
30 return store;
31 }
33 > getOperations({ changesetKind, gitState }: IChangesetOperationContext): ChangesetOperation[] {
34 if (changesetKind !== ChangesetKind.Uncommitted || (gitState?.uncommittedChanges ?? 0) <= 0) {
35 return [];