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

1728 LOC · 1611 covered · 117 uncovered · 386 ranges · 1049 concepts · 152 introducers · 506 tests

File neighbourhood

The centred file is linked to every concept that introduces one of its ranges, every test that runs code from the file, and the gray connector concepts standing between those tests and the file's own introducer concepts. Undirected links join concepts to every file where they introduce source and concepts to the tests they introduce; arrows show specialization between the displayed concepts and bridge only concepts omitted from this view. Concept colors match the source ranges below; connector concepts have no source color and are shown in gray.

Focused file, its introducer and connector concepts, their introduced files, and tests that run code from the file

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

Graph controls are ready.

Interactive rendering requires JavaScript and WebGL. Use the related-file, concept, and source links on this page while the interactive map is unavailable.

1 > /*--------------------------------------------------------------------------------------------- agentSideEffects.ts ×51
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 { getErrorCode } from '../../../base/common/errors.js';
7 > import { Disposable, DisposableStore, IDisposable } from '../../../base/common/lifecycle.js';
8 > import { NKeyMap } from '../../../base/common/map.js';
9 > import { equals } from '../../../base/common/objects.js';
10 > import { autorun, IObservable, IReader } from '../../../base/common/observable.js';
11 > import { StopWatch } from '../../../base/common/stopwatch.js';
12 > import { hasKey } from '../../../base/common/types.js';
13 > import { URI } from '../../../base/common/uri.js';
14 > import { generateUuid } from '../../../base/common/uuid.js';
15 > import { IInstantiationService } from '../../instantiation/common/instantiation.js';
16 > import { ILogService } from '../../log/common/log.js';
17 > import { IAgentHostChangesetService } from '../common/agentHostChangesetService.js';
18 > import { IAgentHostCheckpointService } from '../common/agentHostCheckpointService.js';
19 > import { readAgentModelByokIdentifier } from '../common/agentModelByokMeta.js';
20 > import { AgentSession, AgentSignal, IAgent, IAgentToolPendingConfirmationSignal } from '../common/agentService.js';
21 > import { readToolCallMeta, toToolCallMeta } from '../common/meta/agentToolCallMeta.js';
22 >
23 > import { ITelemetryService } from '../../telemetry/common/telemetry.js';
24 > import { ISessionDataService } from '../common/sessionDataService.js';
25 > import { SessionConfigKey } from '../common/sessionConfigKeys.js';
26 > import { resolveChatAttachment } from '../common/state/chatAttachmentContext.js';
27 > import { SessionInputRequestKind, ToolCallContributorKind, type AgentInfo, type SessionInputRequest } from '../common/state/protocol/state.js';
28 > import { ActionType, isChatAction, StateAction, type ChatAction, type ChatToolCallCompleteAction } from '../common/state/sessionActions.js';
29 > import {
30 > buildSubagentChatUri,
31 > getToolFileEdits,
32 > isAhpChatChannel,
33 > isDefaultChatUri,
34 > isSubagentChatUri,
35 > isChatReadOnly,
36 > AH_META_IS_ARCHIVED_DB_KEY,
37 > MessageAttachmentKind,
38 > MessageKind,
39 > parseChatUri,
40 > parseRequiredSessionUriFromChatUri,
41 > PendingMessageKind,
42 > ResponsePartKind,
43 > ROOT_STATE_URI,
44 > SessionLifecycle,
45 > SessionStatus,
46 > ToolCallStatus,
47 > ToolResultContentType,
48 > type ErrorInfo,
49 > type ISessionWithDefaultChat,
50 > type Message,
51 > type MessageAttachment,
52 > type URI as ProtocolURI,
53 > type SessionState,
54 > type ToolCallState,
55 > type ToolCallResult,
56 > type ToolResultContent,
57 > type Turn
58 > } from '../common/state/sessionState.js';
59 > import { AgentHostLocalTurns } from './agentHostLocalTurns.js';
60 > import { AgentHostSessionTitleController } from './agentHostSessionTitleController.js';
61 > import { AgentHostStateManager } from './agentHostStateManager.js';
62 > import { AgentHostTelemetryReporter, type AgentHostModelTelemetryKind, type AgentHostTurnFailureStage, type IAgentHostTurnFailure } from './agentHostTelemetryReporter.js';
63 > import { AgentHostToolCallTracker } from './agentHostToolCallTracker.js';
64 > import { updateAgentHostTelemetryLevelFromConfig } from './agentHostTelemetryService.js';
65 > import { AgentHostTurnTracker } from './agentHostTurnTracker.js';
66 > import { AgentHostLocalCommands } from './localCommands/localChatCommand.js';
67 > import './localCommands/localChatCommands.contribution.js';
68 > import { SessionPermissionManager } from './sessionPermissions.js';
69 > import type { ICopilotApiService } from './shared/copilotApiService.js';
70 > import { stripProxyErrorMarker, toChatErrorMeta, tryParseForwardedChatError } from './shared/forwardedChatError.js';
71 > import { persistSessionMetadata } from './shared/persistSessionMetadata.js';
72 > import type { WorktreeIsolation } from './shared/worktreeIsolation.js';
73 >
74 > /**
75 > * Options for constructing an {@link AgentSideEffects} instance.
76 > */
77 > export interface IAgentSideEffectsOptions {
78 > /** Resolve the agent responsible for a given session URI. */
79 > readonly getAgent: (session: ProtocolURI) => IAgent | undefined;
80 > /** Observable set of registered agents. Triggers `root/agentsChanged` when it changes. */
81 > readonly agents: IObservable<readonly IAgent[]>;
82 > /** Session data service for cleaning up per-session data on disposal. */
83 > readonly sessionDataService: ISessionDataService;
84 > /** Registry that persists host-injected `/rename` and `!command` turns. */
85 > readonly localTurns: AgentHostLocalTurns;
86 > /** Get the GitHub token used for Copilot utility title generation. */
87 > readonly getGitHubCopilotToken?: () => string | undefined;
88 > /** CAPI service used for Copilot utility title generation. */
89 > readonly copilotApiService?: ICopilotApiService;
90 > /**
91 > * Host-owned working-directory resolution hook, awaited before the agent's
92 > * first send so the session's working directory (an isolated worktree created
93 > * on the first send, or the picked folder) is resolved before the agent
94 > * materializes and its cwd is locked. Resolves to the working directory to
95 > * hand the agent, or `undefined` for workspace-less sessions. Provided by
96 > * {@link AgentService}.
97 > */
98 > readonly resolveWorkingDirectoryBeforeSend?: (params: { session: ProtocolURI; chat: ProtocolURI; turnId: string; prompt: string }) => Promise<URI | undefined>;
99 > /** Resolves a referenced chat's turns, hydrating its owning session when needed. */
100 > readonly resolveChatAttachmentTurns?: (resource: ProtocolURI) => Promise<readonly Turn[]>;
101 > /**
102 > * Called after each top-level session turn completes so git state can be
103 > * refreshed and published via `SessionMetaChanged`. Subagent turns are
104 > * excluded — only the parent session URI is passed.
105 > */
106 > readonly onTurnComplete: (session: ProtocolURI) => void;
107 > }
108 >
109 > /** A signal that was deferred because its subagent session does not exist yet. */
110 > interface IPendingSubagentSignal {
111 > readonly signal: AgentSignal;
112 > readonly agent: IAgent;
113 > }
114 >
115 > interface ISubagentSessionRef {
116 > readonly parentChatUri: ProtocolURI;
117 > readonly toolCallId: string;
118 > readonly sessionUri: ProtocolURI;
119 > readonly chatUri: ProtocolURI;
120 > readonly turnStopWatch: StopWatch;
121 > }
122 >
123 > type AgentSignalTurnIdRouting = 'preserve' | 'remap';
124 >
125 > /**
126 > * Shared implementation of agent side-effect handling.
127 > *
128 > * Routes client-dispatched actions to the correct agent backend,
129 > * restores sessions from previous lifetimes, handles filesystem
130 > * operations (browse/fetch/write), tracks pending permission requests,
131 > * and wires up agent progress events to the state manager.
132 > *
133 > * Session create/dispose/list and auth are handled by {@link AgentService}.
134 > */
135 > export class AgentSideEffects extends Disposable {
136 >
137 > /** Maps tool call IDs to the agent that owns them, for routing confirmations. */
138 > private readonly _toolCallAgents = new Map<string, string>();
139 > private _lastAgentInfos: readonly AgentInfo[] = [];
140 >
141 > private readonly _permissionManager: SessionPermissionManager;
142 >
143 > /** Registry-driven dispatcher for host-handled `/rename` / `!command` etc. */
144 > private readonly _localCommands: AgentHostLocalCommands;
145 >
146 > private readonly _subagentChats = new NKeyMap<ISubagentSessionRef, [ProtocolURI, string]>();
147 > private readonly _cancelledTurnIds = new Map<ProtocolURI, Set<string>>();
148 >
149 > /**
150 > * Buffers signals whose `parentToolCallId` references a subagent
151 > * whose `subagent_started` signal has not yet been processed. The SDK is
152 > * not strict about ordering: an inner `tool_start` can arrive before the
153 > * `subagent_started` that creates the child session. Without buffering,
154 > * those signals would be dispatched against the parent session and the
155 > * UI would render the inner tool calls flat at the top level rather than
156 > * grouping them under the subagent. Drained by `_handleSubagentStarted`.
157 > *
158 > */
159 > private readonly _pendingSubagentSignals = new NKeyMap<IPendingSubagentSignal[], [ProtocolURI, string]>();
160 > private readonly _telemetryReporter: AgentHostTelemetryReporter;
161 > private readonly _turnTracker: AgentHostTurnTracker;
162 > private readonly _toolCallTracker: AgentHostToolCallTracker;
163 > private readonly _titleController: AgentHostSessionTitleController;
164 > /** Host-owned worktree isolation controller; injected post-construction. */
165 > private _worktree: WorktreeIsolation | undefined;
166 >
167 > constructor(
168 > private readonly _stateManager: AgentHostStateManager, agentSideEffects.ts ×6
169 > private readonly _options: IAgentSideEffectsOptions,
170 > @IInstantiationService instantiationService: IInstantiationService,
171 > @ILogService private readonly _logService: ILogService,
172 > @IAgentHostChangesetService private readonly _changesets: IAgentHostChangesetService,
173 > @ITelemetryService private readonly _telemetryService: ITelemetryService,
174 > @IAgentHostCheckpointService private readonly _checkpointService: IAgentHostCheckpointService,
175 > ) {
176 > super();
177 > this._telemetryReporter = new AgentHostTelemetryReporter(this._telemetryService);
178 > this._turnTracker = new AgentHostTurnTracker(this._telemetryReporter);
179 > this._toolCallTracker = this._register(new AgentHostToolCallTracker(this._telemetryReporter));
180 > this._permissionManager = this._register(instantiationService.createInstance(SessionPermissionManager, this._stateManager, {}));
181 > this._localCommands = this._register(instantiationService.createInstance(
182 > AgentHostLocalCommands,
183 > this._stateManager,
184 > this._options.localTurns,
185 > // Draining the queue re-enters agent lookup / telemetry / sendMessage,
186 > // which is this class's responsibility, so the dispatcher hands the
187 > // turn back here once it has completed a host-handled command.
188 > (turnChannel: ProtocolURI) => this._tryConsumeNextQueuedMessage(turnChannel),
189 > ));
190 > this._titleController = this._register(instantiationService.createInstance(AgentHostSessionTitleController, this._stateManager, {
191 > sessionDataService: this._options.sessionDataService,
192 > getGitHubCopilotToken: this._options.getGitHubCopilotToken,
193 > copilotApiService: this._options.copilotApiService,
194 > }));
195 >
196 > // Whenever the agents observable changes, publish to root state.
197 > this._register(autorun(reader => {
198 > const agents = this._options.agents.read(reader);
199 > this._publishAgentInfos(agents, reader);
200 > }));
201 >
202 > // Server-dispatched ChatToolCallComplete actions (e.g. from
203 > // the disconnect timeout in ProtocolServerHandler) bypass
204 > // handleAction, so the agent's SDK deferred never resolves.
205 > // Listen for these envelopes and notify the agent directly.
206 > this._register(this._stateManager.onDidEmitEnvelope(envelope => {
207 > if (isAhpChatChannel(envelope.channel) && isChatAction(envelope.action)) { agentSideEffects.ts ×3
208 > if (envelope.action.type === ActionType.ChatTurnCancelled) { agentSideEffects.ts ×8
209 > let turnIds = this._cancelledTurnIds.get(envelope.channel); agentSideEffects.ts ×1
210 > if (!turnIds) {
211 > turnIds = new Set();
212 > this._cancelledTurnIds.set(envelope.channel, turnIds);
213 > }
214 > turnIds.add(envelope.action.turnId);
215 > }
216 > this._syncSessionInputNeededForChatAction(envelope.channel, envelope.action); agentSideEffects.ts ×8
217 > }
218 > if (!envelope.origin && envelope.action.type === ActionType.ChatToolCallComplete) { agentSideEffects.ts ×3
219 > const action = envelope.action; agentSideEffects.ts ×2
220 > // Chat-action envelopes are emitted on the chat channel URI;
221 > // agents are keyed by session URI, so resolve back to the
222 > // owning session before notifying the agent. Pass the chat URI
223 > // alongside so agents that track peer chats can route correctly.
224 > if (!isAhpChatChannel(envelope.channel)) {
225 return; // Not a chat channel; ignore (already logged elsewhere).
226 }
227 > const sessionChannel = parseRequiredSessionUriFromChatUri(envelope.channel); agentSideEffects.ts ×2
228 > this._notifyClientToolCallComplete(sessionChannel, envelope.channel, action.toolCallId, action.result, 'server-envelope');
229 > }
230 > if (envelope.action.type === ActionType.ChatDraftChanged) { agentSideEffects.ts ×3
231 > this._persistChatDraft(envelope.channel, envelope.action.draft); agentSideEffects.ts ×5
232 > }
234 > }
236 > /**
237 > * Publishes agent descriptors using the last known model lists.
238 > */
239 > private _publishAgentInfos(agents: readonly IAgent[], reader?: IReader): void {
240 > const infos: AgentInfo[] = agents.map(a => { agentSideEffects.ts ×6
241 > const d = a.getDescriptor(); agentSideEffects.ts ×3
242 > const protectedResources = a.getProtectedResources();
243 > const models = reader ? a.models.read(reader) : a.models.get();
244 > const customizations = a.getCustomizations?.();
245 > return {
246 > provider: d.provider, displayName: d.displayName, description: d.description, models: models.map(m => ({
247 > id: m.id, agentSideEffects.ts ×1
248 > provider: m.provider,
249 > name: m.name,
250 > maxContextWindow: m.maxContextWindow,
251 > maxOutputTokens: m.maxOutputTokens,
252 > maxPromptTokens: m.maxPromptTokens,
253 > supportsVision: m.supportsVision,
254 > policyState: m.policyState,
255 > configSchema: m.configSchema,
256 > _meta: m._meta,
258 > customizations: customizations?.length ? [...customizations] : undefined,
259 > protectedResources: protectedResources.length > 0 ? protectedResources : undefined,
260 > capabilities: d.capabilities ? { ...d.capabilities } : undefined,
261 > };
263 > if (equals(this._lastAgentInfos, infos)) {
264 > return; agentSideEffects.ts ×1
265 > }
266 > this._lastAgentInfos = infos; agentSideEffects.ts ×3
267 > this._stateManager.dispatchServerAction(ROOT_STATE_URI, { type: ActionType.RootAgentsChanged, agents: infos });
270 > private async _publishSessionCustomizations(agent: IAgent, session: ProtocolURI): Promise<void> {
271 > if (!agent.getSessionCustomizations) { agentSideEffects.ts ×5
272 > return; agentHostSchema.ts ×2
273 > }
275 > const customizations = await agent.getSessionCustomizations(URI.parse(session));
276 >
277 > // Skip the dispatch when the resolved customizations match what the
278 > // session state already holds. A single edit under a shared `~/.claude`
279 > // tree fans out to every open session (and, via the agent-level
280 > // `onDidCustomizationsChange`, is republished once per session), so
281 > // without this guard a single change emitted O(N^2) identical
282 > // `SessionCustomizationsChanged` envelopes. Comparing against the
283 > // authoritative session state (rather than a side cache) keeps this
284 > // correct across idle-eviction + restore: a restored session's state
285 > // starts without customizations, so the first successful refresh always
286 > // dispatches even if the resolved set matches the prior incarnation.
287 > // It also needs no cleanup on session teardown. `undefined` (never
288 > // published) never equals a resolved array, so the initial publish
289 > // always goes through.
290 > const current = this._stateManager.getSessionState(session)?.customizations;
291 > if (current && equals(current, customizations)) { agentSideEffects.ts ×5
292 > return; agentSideEffects.ts ×1
293 > }
295 > this._stateManager.dispatchServerAction(session, {
296 > type: ActionType.SessionCustomizationsChanged,
297 > customizations: [...customizations],
298 > });
301 > private _publishSessionCustomizationsSoon(agent: IAgent, session: ProtocolURI): void {
302 > void this._publishSessionCustomizations(agent, session).catch(err => { agentSideEffects.ts ×5
303 this._logService.error('[AgentSideEffects] getSessionCustomizations failed', err);
305 > }
307 > private _publishSessionCustomizationsForAgent(agent: IAgent): void {
308 > for (const session of this._stateManager.getSessionUris()) { agentSideEffects.ts ×2
309 > if (this._options.getAgent(session) === agent) {
310 > this._publishSessionCustomizationsSoon(agent, session);
311 > }
312 > }
313 > }
315 > private _publishAllSessionCustomizations(): void {
316 > for (const session of this._stateManager.getSessionUris()) { agentSideEffects.ts ×3
317 > const agent = this._options.getAgent(session); agentSideEffects.ts ×1
318 > if (agent) {
319 > this._publishSessionCustomizationsSoon(agent, session);
320 > }
321 > }
324 > // ---- Session input-needed aggregation ----------------------------------
325 > //
326 > // Mirrors per-chat blockers (user-input elicitations, tool confirmations,
327 > // client-tool executions, and MCP authentication) into the owning session's
328 > // `inputNeeded` list so clients subscribed only to the session channel can
329 > // discover and answer them without subscribing to each chat. This handler
330 > // only produces the state; it does not consume it.
331 >
332 > private _syncSessionInputNeededForChatAction(chatUri: ProtocolURI, action: ChatAction): void {
333 > switch (action.type) { agentSideEffects.ts ×8
334 > case ActionType.ChatInputRequested:
335 > this._syncChatInputNeeded(chatUri, action.request.id); agentSideEffects.ts ×5
336 > break;
337 > case ActionType.ChatInputAnswerChanged: agentSideEffects.ts ×8
338 > this._syncChatInputNeeded(chatUri, action.requestId); reducer.ts ×4
339 > break;
340 > case ActionType.ChatInputCompleted: agentSideEffects.ts ×8
341 > this._removeSessionInputNeeded(chatUri, this._chatInputNeededId(chatUri, action.requestId)); reducer.ts ×4
342 > break;
343 > case ActionType.ChatToolCallStart: agentSideEffects.ts ×8
344 > case ActionType.ChatToolCallReady:
345 > case ActionType.ChatToolCallConfirmed:
346 > case ActionType.ChatToolCallComplete:
347 > case ActionType.ChatToolCallResultConfirmed:
348 > case ActionType.ChatToolCallAuthRequired:
349 > case ActionType.ChatToolCallAuthResolved:
350 > this._syncToolInputNeeded(chatUri, action.turnId, action.toolCallId); agentSideEffects.ts ×9
351 > break;
352 > case ActionType.ChatTurnComplete: agentSideEffects.ts ×8
353 > case ActionType.ChatTurnCancelled:
354 > case ActionType.ChatError:
355 > case ActionType.ChatTruncated:
356 > this._removeSessionInputNeededForChat(chatUri); agentSideEffects.ts ×3
357 > break;
359 > }
361 > private _syncChatInputNeeded(chatUri: ProtocolURI, requestId: string): void {
362 > const state = this._stateManager.getSessionState(chatUri); agentSideEffects.ts ×5
363 > const part = state?.activeTurn?.responseParts.find(part =>
364 > part.kind === ResponsePartKind.InputRequest agentSideEffects.ts ×2
365 > && part.response === undefined
366 > && part.request.id === requestId
368 > const id = this._chatInputNeededId(chatUri, requestId);
369 > if (!part || part.kind !== ResponsePartKind.InputRequest) {
370 > this._removeSessionInputNeeded(chatUri, id); agentSideEffects.ts ×1
371 > return;
372 > }
373 > this._setSessionInputNeeded(chatUri, { agentSideEffects.ts ×2
374 > id,
375 > kind: SessionInputRequestKind.ChatInput,
376 > chat: chatUri,
377 > request: part.request,
378 > });
381 > private _syncToolInputNeeded(chatUri: ProtocolURI, turnId: string, toolCallId: string): void {
382 > const confirmationId = this._toolConfirmationNeededId(chatUri, turnId, toolCallId); agentSideEffects.ts ×9
383 > const clientExecutionId = this._toolClientExecutionNeededId(chatUri, turnId, toolCallId);
384 > const authenticationId = this._toolAuthenticationNeededId(chatUri, turnId, toolCallId);
385 > const toolCall = this._findToolCall(chatUri, turnId, toolCallId);
386 >
387 > // A call auto-approved by the session's bypass setting is run
388 > // automatically by the owning client and never blocks on the user, so
389 > // keep it out of the session `inputNeeded` queue (which would flash
390 > // "input needed" in the sessions list). `autoApproveBySetting` covers
391 > // only the parameter gate; a `PendingResultConfirmation` is a genuine
392 > // prompt and is still surfaced.
393 > const autoApproved = !!toolCall && readToolCallMeta(toolCall).autoApproveBySetting === true;
394 >
395 > const suppressAutoApprovedConfirmation = autoApproved && toolCall?.status === ToolCallStatus.PendingConfirmation;
396 > const needsConfirmation = !suppressAutoApprovedConfirmation && (toolCall?.status === ToolCallStatus.PendingConfirmation || toolCall?.status === ToolCallStatus.PendingResultConfirmation);
397 > if (needsConfirmation && toolCall) {
398 > this._setSessionInputNeeded(chatUri, { agentSideEffects.ts ×1
399 > id: confirmationId,
400 > kind: SessionInputRequestKind.ToolConfirmation,
401 > chat: chatUri,
402 > turnId,
403 > toolCall,
404 > });
405 > } else { agentSideEffects.ts ×9
406 > this._removeSessionInputNeeded(chatUri, confirmationId);
407 > }
408 >
409 > const contributor = toolCall?.contributor;
410 > if (!autoApproved && toolCall?.status === ToolCallStatus.Running && contributor?.kind === ToolCallContributorKind.Client) {
411 > this._setSessionInputNeeded(chatUri, { agentSideEffects.ts ×1
412 > id: clientExecutionId,
413 > kind: SessionInputRequestKind.ToolClientExecution,
414 > chat: chatUri,
415 > turnId,
416 > clientId: contributor.clientId,
417 > toolCall,
418 > });
419 > } else { agentSideEffects.ts ×9
420 > this._removeSessionInputNeeded(chatUri, clientExecutionId);
421 > }
422 >
423 > if (toolCall?.status === ToolCallStatus.AuthRequired) {
424 > this._setSessionInputNeeded(chatUri, { reducer.ts ×3
425 > id: authenticationId,
426 > kind: SessionInputRequestKind.ToolAuthentication,
427 > chat: chatUri,
428 > turnId,
429 > toolCall,
430 > });
431 > } else { agentSideEffects.ts ×9
432 > this._removeSessionInputNeeded(chatUri, authenticationId);
433 > }
434 > }
436 > private _findToolCall(chatUri: ProtocolURI, turnId: string, toolCallId: string): ToolCallState | undefined {
437 > const state = this._stateManager.getSessionState(chatUri); agentSideEffects.ts ×9
438 > const turn = state?.activeTurn?.id === turnId ? state.activeTurn : state?.turns.find(t => t.id === turnId);
439 > const part = turn?.responseParts.find(p => p.kind === ResponsePartKind.ToolCall && p.toolCall.toolCallId === toolCallId);
440 > return part?.kind === ResponsePartKind.ToolCall ? part.toolCall : undefined;
441 > }
443 > private _setSessionInputNeeded(chatUri: ProtocolURI, request: SessionInputRequest): void {
444 > const sessionUri = parseRequiredSessionUriFromChatUri(chatUri); reducer.ts ×3
445 > const existing = this._stateManager.getSessionState(sessionUri)?.inputNeeded?.find(r => r.id === request.id);
446 > if (existing && equals(existing, request)) {
447 > return; agentSideEffects.ts ×1
448 > }
449 > this._stateManager.dispatchServerAction(sessionUri, { type: ActionType.SessionInputNeededSet, request }); reducer.ts ×3
450 > if (request.kind !== SessionInputRequestKind.ChatInput) {
451 > const agent = this._options.getAgent(sessionUri); agentHostToolCallTracker.ts ×3
452 > if (agent) {
453 > this._toolCallTracker.toolCallBlocked(agent.id, chatUri, request);
454 > }
455 > }
456 > } reducer.ts ×3
458 > private _removeSessionInputNeeded(chatUri: ProtocolURI, id: string): void {
459 > const sessionUri = parseRequiredSessionUriFromChatUri(chatUri); agentHostToolCallTracker.ts ×2
460 > this._toolCallTracker.toolCallUnblocked(chatUri, id);
461 > if (!this._stateManager.getSessionState(sessionUri)?.inputNeeded?.some(r => r.id === id)) {
462 > return; agentSideEffects.ts ×1
463 > }
464 > this._stateManager.dispatchServerAction(sessionUri, { type: ActionType.SessionInputNeededRemoved, id }); reducer.ts ×5
467 > private _removeSessionInputNeededForChat(chatUri: ProtocolURI): void {
468 > const sessionUri = parseRequiredSessionUriFromChatUri(chatUri); agentSideEffects.ts ×3
469 > for (const request of this._stateManager.getSessionState(sessionUri)?.inputNeeded ?? []) {
470 > if (request.chat === chatUri) { agentSideEffects.ts ×1
471 > this._removeSessionInputNeeded(chatUri, request.id);
472 > }
473 > }
476 > private _chatInputNeededId(chatUri: ProtocolURI, requestId: string): string {
477 > return `chatInput:${chatUri}:${requestId}`; agentSideEffects.ts ×5
478 > }
480 > private _toolConfirmationNeededId(chatUri: ProtocolURI, turnId: string, toolCallId: string): string {
481 > return `toolConfirmation:${chatUri}:${turnId}:${toolCallId}`; agentSideEffects.ts ×9
482 > }
484 > private _toolClientExecutionNeededId(chatUri: ProtocolURI, turnId: string, toolCallId: string): string {
485 > return `toolClientExecution:${chatUri}:${turnId}:${toolCallId}`; agentSideEffects.ts ×9
486 > }
488 > private _toolAuthenticationNeededId(chatUri: ProtocolURI, turnId: string, toolCallId: string): string {
489 > return `toolAuthentication:${chatUri}:${turnId}:${toolCallId}`; agentSideEffects.ts ×9
490 > }
492 > // ---- Initialization ----------------------------------------------------
493 >
494 > /**
495 > * Initializes async resources (tree-sitter WASM) used for command
496 > * auto-approval. Await this before any session events can arrive to
497 > * guarantee that auto-approval checks are fully synchronous.
498 > */
499 > initialize(): Promise<void> {
500 > return this._permissionManager.initialize(); agentService.ts ×16
501 > }
503 > // ---- Agent registration -------------------------------------------------
504 >
505 > /**
506 > * Registers a progress-signal listener on the given agent so that
507 > * {@link AgentSignal}s are routed/dispatched through the state manager.
508 > * Returns a disposable that removes the listener.
509 > */
510 > registerProgressListener(agent: IAgent): IDisposable {
511 > const disposables = new DisposableStore(); agentSideEffects.ts ×4
512 > disposables.add(agent.onDidSessionProgress(signal => {
513 > this._handleAgentSignal(agent, signal); agentSideEffects.ts ×4
515 > if (agent.onDidCustomizationsChange) {
516 > disposables.add(agent.onDidCustomizationsChange(() => {
517 > this._publishAgentInfos(this._options.agents.get()); agentSideEffects.ts ×2
518 > this._publishSessionCustomizationsForAgent(agent);
520 > }
521 > if (agent.onDidRequireAuth) {
522 > disposables.add(agent.onDidRequireAuth(e => this._stateManager.emitAuthRequired(e))); agentService.ts ×1
523 > }
524 > return disposables; agentSideEffects.ts ×4
525 > }
527 > /**
528 > * Routes a single signal from `agent` to the correct session.
529 > *
530 > * Action signals with a `parentToolCallId` are routed to the matching
531 > * subagent session. If the subagent session does not exist yet (the SDK
532 > * can emit an inner `tool_start` before its `subagent_started`), the
533 > * signal is buffered in {@link _pendingSubagentSignals} and replayed
534 > * once the `subagent_started` arrives.
535 > */
536 > private _handleAgentSignal(agent: IAgent, signal: AgentSignal): void {
537 > if (signal.kind === 'subagent_started') { agentSideEffects.ts ×4
538 > this._handleSubagentStarted(signal.chat.toString(), signal.toolCallId, signal.agentName, signal.agentDisplayName, signal.agentDescription, signal.taskPrompt, signal.parentToolCallId); agentSideEffects.ts ×9
539 > this._drainPendingSubagentSignals(signal.chat.toString(), signal.toolCallId);
540 > return;
541 > }
543 > if (signal.kind === 'subagent_completed') {
544 > this.completeSubagentSession(signal.chat.toString(), signal.toolCallId); agentSideEffects.ts ×3
545 > return;
546 > }
548 > if (signal.kind === 'steering_consumed') {
549 > this._stateManager.dispatchServerAction(signal.chat.toString(), { reducer.ts ×2
550 > type: ActionType.ChatPendingMessageRemoved,
551 > kind: PendingMessageKind.Steering,
552 > id: signal.id,
553 > });
554 > return;
555 > }
556 > const sessionKey = signal.kind === 'action' ? signal.resource.toString() : signal.chat.toString(); agentSideEffects.ts ×4
557 >
558 > // Route signals with parentToolCallId to the subagent session.
559 > // Both action signals and pending_confirmation signals can carry
560 > // a parentToolCallId — for client tools inside a subagent the
561 > // permission flow fires `pending_confirmation` for an inner tool
562 > // call, and that signal must be routed to the subagent session
563 > // (otherwise the resulting ChatToolCallReady would land on the
564 > // parent session, which has no matching ChatToolCallStart).
565 > const parentToolCallId = signal.parentToolCallId;
566 > if (parentToolCallId) {
567 > const subagentSession = this._subagentChats.get(sessionKey, parentToolCallId); agentSideEffects.ts ×1
568 > if (subagentSession) {
569 > const subTurnId = this._stateManager.getActiveTurnId(subagentSession.chatUri); agentSideEffects.ts ×2
570 > if (subTurnId) {
571 > this._dispatchActionForSession(signal, subagentSession.chatUri, subTurnId, 'remap', agent);
572 > }
573 > return;
574 > }
576 > // Subagent session does not exist yet — buffer the signal so we can
577 > // replay it after `subagent_started` arrives.
578 > this._logService.trace(`[AgentSideEffects] Buffering ${this._describeSignal(signal)} for pending subagent ${sessionKey}/${parentToolCallId}`);
579 > let buffer = this._pendingSubagentSignals.get(sessionKey, parentToolCallId);
580 > if (!buffer) {
581 > buffer = [];
582 > this._pendingSubagentSignals.set(buffer, sessionKey, parentToolCallId);
583 > }
584 > buffer.push({ signal, agent });
585 > return;
586 > }
588 > // Route pending_confirmation signals for tools inside subagent sessions
589 > // (legacy path for signals without an explicit parentToolCallId — the
590 > // tool was previously registered under its subagent session key in
591 > // _toolCallAgents).
592 > if (signal.kind === 'pending_confirmation') {
593 > const subagentChatUri = this._findSubagentChatForToolCall(sessionKey, signal.state.toolCallId); agentSideEffects.ts ×4
594 > if (subagentChatUri) {
595 > const subTurnId = this._stateManager.getActiveTurnId(subagentChatUri) ?? ''; agentSideEffects.ts ×3
596 > void this._handleToolReady(signal, subagentChatUri, subTurnId, agent).catch(err => {
597 this._logService.error('[AgentSideEffects] _handleToolReady failed', err);
599 > return;
600 > }
603 > const turnId = this._stateManager.getActiveTurnId(sessionKey);
604 > if (turnId) {
605 > this._dispatchActionForSession(signal, sessionKey, turnId, 'preserve', agent); agentSideEffects.ts ×9
606 > return;
607 > }
609 > // No active turn on the session. Non-action signals are silently
610 > // dropped, but action signals can still target session-level state
611 > // such as customizations, title, or configuration. A turnComplete
612 > // action also drives post-turn side effects even when the matching
613 > // turnStarted was not observed by this side-effects instance.
614 > //
615 > // pending_confirmation signals must also be handled here: when a
616 > // hook-triggered continuation runs after the protocol turn has
617 > // already completed, tool actions are dispatched (below) with an
618 > // empty turnId. Without this, the pending_confirmation is silently
619 > // dropped, the permission deferred never resolves, and the session
620 > // hangs indefinitely.
621 > if (signal.kind === 'pending_confirmation') {
622 > void this._handleToolReady(signal, sessionKey, '', agent).catch(err => { agentSideEffects.ts ×2
623 this._logService.error('[AgentSideEffects] _handleToolReady failed', err);
625 > return;
626 > }
627 > if (signal.kind === 'action') { agentSideEffects.ts ×3
628 > const action = signal.action;
629 > if (action.type === ActionType.ChatTurnComplete && this._cancelledTurnIds.get(sessionKey)?.has(action.turnId)) {
630 > this._logService.trace(`[AgentSideEffects] Dropping completion for cancelled turn ${action.turnId} on ${sessionKey}`); reducer.ts ×2
631 > return;
632 > }
633 > this._stateManager.dispatchServerAction(sessionKey, action); agentSideEffects.ts ×1
634 > if (action.type === ActionType.ChatTurnComplete) {
635 > this._runTurnCompleteSideEffects(sessionKey, undefined); agentSideEffects.ts ×2
636 > }
640 > /**
641 > * Dispatches a signal to a resolved chat, preserving top-level turn identity or remapping cross-channel subagent actions.
642 > */
643 > private _dispatchActionForSession(signal: AgentSignal, sessionKey: ProtocolURI, turnId: string, turnIdRouting: AgentSignalTurnIdRouting, agent?: IAgent): void {
644 > if (signal.kind === 'pending_confirmation') { agentSideEffects.ts ×9
645 > if (agent) { agentSideEffects.ts ×2
646 > void this._handleToolReady(signal, sessionKey, turnId, agent).catch(err => {
647 this._logService.error('[AgentSideEffects] _handleToolReady failed', err);
649 > }
650 > return;
651 > }
652 > if (signal.kind !== 'action') { agentSideEffects.ts ×9
653 return;
654 }
655 > let action = signal.action; agentSideEffects.ts ×9
656 > if (action.type !== ActionType.ChatTruncated && hasKey(action, { turnId: true }) && action.turnId !== turnId) {
657 > if (turnIdRouting === 'remap') { agentSideEffects.ts ×3
658 > action = { ...action, turnId }; agentSideEffects.ts ×2
659 > } else { agentSideEffects.ts ×3
660 > this._logService.trace(`[AgentSideEffects] Dropping stale ${action.type} for ${sessionKey}: producerTurnId=${action.turnId}, activeTurnId=${turnId}`); agentSideEffects.ts ×1
661 > return;
662 > }
665 > if (action.type === ActionType.ChatToolCallStart && agent) { agentSideEffects.ts ×9
666 > this._toolCallAgents.set(`${sessionKey}:${action.toolCallId}`, agent.id); agentSideEffects.ts ×2
667 > // Stamp the tool call start for `languageModelToolInvoked` telemetry.
668 > // Only the start action carries the tool name and contributor, so the
669 > // source kind must be captured here rather than on completion. The
670 > // provider comes from the agent that emitted the signal.
671 > this._toolCallTracker.toolCallStarted(agent.id, sessionKey, action.toolCallId, action.toolName, action.contributor);
672 > }
674 > const sessionUri = isAhpChatChannel(sessionKey) ? parseRequiredSessionUriFromChatUri(sessionKey) : sessionKey; agentSideEffects.ts ×9
675 >
676 > // Stamp the subagent chat URI onto the tool call as soon as toolKind
677 > // is known, so clients get it from the wire instead of deriving it.
678 > if (
679 > (action.type === ActionType.ChatToolCallStart || action.type === ActionType.ChatToolCallDelta || action.type === ActionType.ChatToolCallReady)
680 > && readToolCallMeta(action).toolKind === 'subagent' agentSideEffects.ts ×2
681 > && readToolCallMeta(action).subagentChatUri === undefined agentSideEffects.ts ×2
683 > action = { ...action, _meta: { ...action._meta, subagentChatUri: buildSubagentChatUri(sessionUri, action.toolCallId) } }; agentSideEffects.ts ×2
684 > }
686 > // When a parent tool call has an associated subagent session,
687 > // preserve the subagent content metadata in the completion result.
688 > // The SDK's tool_complete provides its own content which would
689 > // overwrite the ToolResultSubagentContent that was set via
690 > // ChatToolCallContentChanged while running.
691 > if (action.type === ActionType.ChatToolCallComplete) {
692 > const subagent = this._subagentChats.get(sessionKey, action.toolCallId); agentSideEffects.ts ×4
693 > if (subagent) {
694 > const parentState = this._stateManager.getSessionState(sessionKey); agentSideEffects.ts ×1
695 > const runningContent = this._getRunningToolCallContent(parentState, turnId, action.toolCallId);
696 > const subagentEntry = runningContent.find(c => hasKey(c, { type: true }) && c.type === ToolResultContentType.Subagent);
697 > if (subagentEntry) {
698 > const mergedContent = [...(action.result.content ?? []), subagentEntry];
699 > const merged: ChatToolCallCompleteAction = { ...action, result: { ...action.result, content: mergedContent } };
700 > action = merged;
701 > }
702 >
703 > }
706 > this._stateManager.dispatchServerAction(sessionKey, action);
707 >
708 > // Mark first visible progress for TTFT telemetry
709 > if (action.type === ActionType.ChatDelta
710 > || action.type === ActionType.ChatResponsePart
711 > || action.type === ActionType.ChatToolCallStart agentSideEffects.ts ×1
712 > || action.type === ActionType.ChatReasoning) { agentSideEffects.ts ×9
713 > this._turnTracker.markFirstProgress(sessionKey, turnId); agentHostTurnTracker.ts ×2
714 > }
716 > if (action.type === ActionType.ChatToolCallComplete) {
717 > // Emit `languageModelToolInvoked` telemetry for the completed tool agentSideEffects.ts ×4
718 > // call. `action.result` carries `success`/`error.code` even after the
719 > // subagent-content merge above (which only touches `result.content`).
720 > this._toolCallTracker.toolCallCompleted(sessionKey, action.toolCallId, action.result);
721 >
722 > // Drop any events that were buffered for a subagent whose
723 > // `subagent_started` never arrived (e.g. the parent tool failed
724 > // before the subagent was created). The actual subagent session
725 > // teardown is driven by the `subagent_completed` signal because
726 > // background subagents (`mode: background`) continue running
727 > // after the parent tool call returns.
728 > this._pendingSubagentSignals.delete(sessionKey, action.toolCallId);
729 > if (getToolFileEdits(action.result).length > 0) {
730 > this._changesets.onToolCallEditsApplied(sessionUri, turnId); sessionState.ts ×1
731 > }
734 > if (action.type === ActionType.ChatTurnComplete) {
735 > this._turnTracker.turnCompleted(sessionKey, turnId, 'success'); agentSideEffects.ts ×4
736 > this._toolCallTracker.clearSession(sessionKey);
737 > this._runTurnCompleteSideEffects(sessionKey, turnId);
738 > }
740 > if (action.type === ActionType.ChatTurnCancelled) {
741 > this._turnTracker.turnCompleted(sessionKey, turnId, 'cancelled'); agentSideEffects.ts ×1
742 > this._toolCallTracker.clearSession(sessionKey);
743 > this._markSessionUnread(sessionUri);
744 > }
746 > if (action.type === ActionType.ChatError) {
747 > this._turnTracker.turnCompleted(sessionKey, turnId, 'error', { stage: 'provider', error: action.error }); agentSideEffects.ts ×1
748 > this._toolCallTracker.clearSession(sessionKey);
749 > this._markSessionUnread(sessionUri);
750 > }
753 > /**
754 > * Post-turn side effects: flush any pending debounced diff computation,
755 > * compute final diffs immediately, drain the next queued message, and
756 > * notify the host so it can refresh git state.
757 > */
758 > private _runTurnCompleteSideEffects(sessionKey: ProtocolURI, turnId: string | undefined): void {
759 > // Checkpoints, changesets and the host git-refresh notification are agentSideEffects.ts ×4
760 > // scoped to the owning session's working tree, which peer chats
761 > // share. Normalize an additional-chat channel to its session for
762 > // those, while keeping the original channel for per-chat queued
763 > // message consumption (queues live on the chat state). For the
764 > // default chat / single-chat case `sessionKey` is already the
765 > // session URI, so this is a no-op.
766 > const sessionUri = isAhpChatChannel(sessionKey) ? parseRequiredSessionUriFromChatUri(sessionKey) : sessionKey;
767 > // Capture the end-of-turn git checkpoint BEFORE notifying the
768 > // changeset service so the per-turn changeset recompute can take
769 > // the authoritative git-diff fast path (which includes terminal-tool
770 > // edits the FileEditTracker misses). The capture is best-effort —
771 > // any failure logs and the changeset pipeline falls back to the
772 > // `file_edits`-based path. We don't block subsequent side effects
773 > // (queued message drain, host notification) on the changeset
774 > // completion since those have always been fire-and-forget; the
775 > // ordering guarantee we care about is checkpoint-then-changeset.
776 > if (turnId !== undefined) {
777 > this._checkpointService.captureTurnCheckpoint(URI.parse(sessionUri), turnId).then(() => {
778 > this._changesets.onTurnComplete(sessionUri, turnId);
779 > }, err => {
780 this._logService.warn(`[AgentSideEffects] Turn checkpoint capture failed for ${sessionUri}/${turnId}: ${err instanceof Error ? err.message : String(err)}`);
781 this._changesets.onTurnComplete(sessionUri, turnId);
783 > } else {
784 > this._changesets.onTurnComplete(sessionUri, turnId); agentSideEffects.ts ×2
785 > }
786 > this._tryConsumeNextQueuedMessage(sessionKey); agentSideEffects.ts ×4
787 > this._options.onTurnComplete(sessionUri);
788 >
789 > // After the first turn completes, refine the auto-generated title using
790 > // the full first-turn context (request + response). No-op for later
791 > // turns or when the title has since been changed. `sessionKey` may be an
792 > // additional chat channel; route it as `chatChannel` so the refinement
793 > // targets that chat's title, mirroring `seedTitleFromFirstMessage`.
794 > const titleChatChannel = isAhpChatChannel(sessionKey) && !isDefaultChatUri(sessionKey) ? sessionKey : undefined;
795 > this._titleController.refineTitleFromFirstTurn(sessionUri, titleChatChannel);
796 >
797 > // A completed turn produces new output the user may not have seen. Route
798 > // subagent turns to their owning session too (a background subagent can
799 > // complete after the parent turn). Each client keeps its active session
800 > // read; `_markSessionUnread` is idempotent.
801 > this._markSessionUnread(sessionUri);
802 > }
804 > private _markSessionUnread(session: ProtocolURI): void {
805 > const status = this._stateManager.getSessionSummary(session)?.status ?? 0; agentSideEffects.ts ×2
806 > if (!(status & SessionStatus.IsRead)) {
807 > return; agentSideEffects.ts ×1
808 > }
809 > this._stateManager.dispatchServerAction(session, { type: ActionType.SessionIsReadChanged, isRead: false }); reducer.ts ×1
810 > this._persistSessionFlag(session, 'isRead', '');
813 > private _describeSignal(signal: AgentSignal): string {
814 > return signal.kind === 'action' ? `action(${signal.action.type})` : signal.kind; agentSideEffects.ts ×2
815 > }
817 > /**
818 > * Replays any signals that were buffered while waiting for
819 > * `subagent_started` to create the subagent session. Called immediately
820 > * after `_handleSubagentStarted`.
821 > */
822 > private _drainPendingSubagentSignals(parentChatURI: ProtocolURI, parentToolCallId: string): void {
823 > const buffer = this._pendingSubagentSignals.get(parentChatURI, parentToolCallId); agentSideEffects.ts ×9
824 > if (!buffer) {
825 > return; agentSideEffects.ts ×1
826 > }
827 > this._pendingSubagentSignals.delete(parentChatURI, parentToolCallId); agentSideEffects.ts ×1
828 > this._logService.trace(`[AgentSideEffects] Draining ${buffer.length} buffered signal(s) for subagent ${parentChatURI}/${parentToolCallId}`);
829 > for (const { signal, agent } of buffer) {
830 > this._handleAgentSignal(agent, signal);
831 > }
834 > // ---- Subagent session management ----------------------------------------
835 >
836 > /**
837 > * Starts the subagent turn in response to a `subagent_started` event and
838 > * wires the parent tool call to the subagent chat. The subagent chat's
839 > * catalog membership is owned by the spawn channel
840 > * ({@link AgentService._onChatSpawned}), which the orchestrator applies
841 > * before this runs, so this only drives the turn/tracking/parent content
842 > * — it does not add the chat.
843 > *
844 > * `chatURI` is always the agent's top-level chat: the subagent is
845 > * registered (and inner events routed) under it because inner-tool
846 > * signals carry the top-level chat as their resource. `spawningToolParentId`,
847 > * when set, is the tool call one level up from the spawning `toolCallId`
848 > * — the tool call in whose (subagent) chat the spawning tool lives — and
849 > * is used to route the discovery content block to that immediate parent
850 > * chat. Since subagent chats are flat (keyed off the root session), this
851 > * one-hop reference resolves the parent chat at any nesting depth.
852 > */
853 > private _handleSubagentStarted(
854 > chatURI: ProtocolURI, agentSideEffects.ts ×9
855 > toolCallId: string,
856 > agentName: string,
857 > agentDisplayName: string,
858 > agentDescription?: string,
859 > taskPrompt?: string,
860 > spawningToolParentId?: string,
861 > ): void {
862 > const parentSessionUri = parseRequiredSessionUriFromChatUri(chatURI);
863 > const subagentChatUri = buildSubagentChatUri(parentSessionUri, toolCallId);
864 >
865 > // Already tracking this subagent
866 > if (this._subagentChats.get(chatURI, toolCallId)) {
867 return;
868 }
870 > this._logService.info(`[AgentSideEffects] Starting subagent turn: ${subagentChatUri} (parent=${chatURI}, toolCallId=${toolCallId})`);
871 >
872 > // The spawning tool call lives in the immediate parent chat (top-level, or the parent subagent chat when nested).
873 > const contentChatUri = spawningToolParentId
874 > ? this._subagentChats.get(chatURI, spawningToolParentId)?.chatUri ?? chatURI agentSideEffects.ts ×1
875 > : chatURI; agentSideEffects.ts ×9
876 >
877 > // Seed the subagent's opening request with the delegated task prompt,
878 > // supplied by the provider on the `subagent_started` signal.
879 > const turnId = generateUuid();
880 > this._stateManager.dispatchServerAction(subagentChatUri, {
881 > type: ActionType.ChatTurnStarted,
882 > turnId,
883 > startedAt: new Date().toISOString(),
884 > message: { text: taskPrompt ?? '', origin: { kind: MessageKind.User } },
885 > });
886 >
887 > this._subagentChats.set({ parentChatUri: chatURI, toolCallId, sessionUri: parentSessionUri, chatUri: subagentChatUri, turnStopWatch: StopWatch.create(false) }, chatURI, toolCallId);
888 >
889 > // Dispatch the discovery content on the spawning tool call's own chat; the top-level chat is a no-op when nested.
890 > const parentTurnId = this._stateManager.getActiveTurnId(contentChatUri);
891 > if (parentTurnId) {
892 > const parentState = this._stateManager.getSessionState(contentChatUri);
893 > const existingContent = this._getRunningToolCallContent(parentState, parentTurnId, toolCallId);
894 > this._stateManager.dispatchServerAction(contentChatUri, {
895 > type: ActionType.ChatToolCallContentChanged,
896 > turnId: parentTurnId,
897 > toolCallId,
898 > content: [
899 > ...existingContent,
900 > {
901 > type: ToolResultContentType.Subagent,
902 > resource: subagentChatUri,
903 > title: agentDisplayName,
904 > agentName,
905 > description: agentDescription,
906 > },
907 > ],
908 > });
909 > }
910 > }
912 > /**
913 > * Gets the current content array from a running tool call, if any.
914 > */
915 > private _getRunningToolCallContent(
916 > state: ISessionWithDefaultChat | undefined, agentSideEffects.ts ×9
917 > turnId: string,
918 > toolCallId: string,
919 > ): ToolResultContent[] {
920 > if (!state?.activeTurn || state.activeTurn.id !== turnId) {
921 return [];
922 }
923 > for (const rp of state.activeTurn.responseParts) { agentSideEffects.ts ×9
924 > if (rp.kind === ResponsePartKind.ToolCall && rp.toolCall.toolCallId === toolCallId && rp.toolCall.status === ToolCallStatus.Running) { agentSideEffects.ts ×2
925 > return rp.toolCall.content ? [...rp.toolCall.content] : []; agentSideEffects.ts ×1
926 > }
928 > return []; agentSideEffects.ts ×1
931 > private _turnDuration(stopWatch: StopWatch | undefined): number {
932 > const elapsed = stopWatch?.elapsed(); agentSideEffects.ts ×1
933 > return typeof elapsed === 'number' && Number.isFinite(elapsed) ? Math.max(0, elapsed) : 0;
934 > }
936 > /**
937 > * Cancels all active subagent sessions for a given parent session.
938 > */
939 > cancelSubagentSessions(parentChatURI: ProtocolURI): void {
940 > for (const subagent of this._subagentChats.getAll(parentChatURI)) { agentSideEffects.ts ×5
941 > const turnId = this._stateManager.getActiveTurnId(subagent.chatUri); agentSideEffects.ts ×1
942 > if (turnId) {
943 > this._stateManager.dispatchServerAction(subagent.chatUri, {
944 > type: ActionType.ChatTurnCancelled,
945 > turnId,
946 > duration: this._turnDuration(subagent.turnStopWatch),
947 > });
948 > this._turnTracker.turnCompleted(subagent.chatUri, turnId, 'cancelled');
949 > }
950 > this._toolCallTracker.clearSession(subagent.chatUri);
951 > }
952 > this._subagentChats.deleteAll(parentChatURI); agentSideEffects.ts ×5
953 > // Drop any buffered events targeted at subagents that never started.
954 > this._pendingSubagentSignals.deleteAll(parentChatURI);
955 > }
957 > /**
958 > * Completes the subagent session associated with a parent tool call.
959 > * Driven by the `subagent_completed` signal from the agent (which the
960 > * SDK fires on both `subagent.completed` and `subagent.failed`), not by
961 > * parent tool call completion — background subagents keep running after
962 > * their parent tool returns.
963 > */
964 > completeSubagentSession(parentChatURI: ProtocolURI, toolCallId: string): void {
965 > // Drop any events that were buffered waiting for a `subagent_started` agentSideEffects.ts ×3
966 > // that never arrived (e.g. the parent tool failed before the subagent
967 > // was created). Without this, the buffer entry would leak until the
968 > // parent session is disposed.
969 > this._pendingSubagentSignals.delete(parentChatURI, toolCallId);
970 >
971 > const subagent = this._subagentChats.get(parentChatURI, toolCallId);
972 > if (!subagent) {
973 return;
974 }
976 > const turnId = this._stateManager.getActiveTurnId(subagent.chatUri);
977 > if (turnId) {
978 > this._stateManager.dispatchServerAction(subagent.chatUri, {
979 > type: ActionType.ChatTurnComplete,
980 > turnId,
981 > duration: this._turnDuration(subagent.turnStopWatch),
982 > });
983 > }
984 > this._subagentChats.delete(parentChatURI, toolCallId);
985 > }
987 > /**
988 > * Removes all subagent chats for a given parent session from the state manager.
989 > */
990 > removeSubagentSessions(parentSession: ProtocolURI): void {
991 > for (const chatUri of this._cancelledTurnIds.keys()) { agentSideEffects.ts ×4
992 if (parseRequiredSessionUriFromChatUri(chatUri) === parentSession) {
993 this._cancelledTurnIds.delete(chatUri);
994 }
995 }
996 > const parentChatURIs = new Set<ProtocolURI>(); agentSideEffects.ts ×4
997 > for (const subagent of this._subagentChats.values()) {
998 > if (subagent.sessionUri === parentSession) { agentSideEffects.ts ×2
999 > this._stateManager.removeChat(subagent.sessionUri, subagent.chatUri);
1000 > this._toolCallTracker.clearSession(subagent.chatUri);
1001 > parentChatURIs.add(subagent.parentChatUri);
1002 > }
1003 > }
1004 > for (const parentChatURI of parentChatURIs) { agentSideEffects.ts ×4
1005 > this._subagentChats.deleteAll(parentChatURI); agentSideEffects.ts ×2
1006 > this._pendingSubagentSignals.deleteAll(parentChatURI);
1007 > }
1010 > /**
1011 > * Finds the subagent session that owns a given tool call by checking
1012 > * whether the tool call was previously registered under a subagent
1013 > * session key in `_toolCallAgents`. Scoped to subagent sessions owned
1014 > * by the given parent to avoid cross-session collisions.
1015 > */
1016 > private _findSubagentChatForToolCall(parentChatURI: ProtocolURI, toolCallId: string): ProtocolURI | undefined {
1017 > for (const subagent of this._subagentChats.getAll(parentChatURI)) { agentSideEffects.ts ×4
1018 > if (this._toolCallAgents.has(`${subagent.chatUri}:${toolCallId}`)) { agentSideEffects.ts ×3
1019 > return subagent.chatUri;
1020 > }
1021 > }
1022 > return undefined; agentSideEffects.ts ×1
1025 > private _toolCallCompletionChat(chatChannel: ProtocolURI): ProtocolURI {
1026 > if (!isSubagentChatUri(chatChannel)) { agentSideEffects.ts ×4
1027 > return chatChannel;
1028 > }
1030 > for (const subagent of this._subagentChats.values()) {
1031 > if (subagent.chatUri === chatChannel) {
1032 > return this._toolCallCompletionChat(subagent.parentChatUri);
1033 > }
1034 > }
1035
1036 this._logService.warn(`[AgentSideEffects] Missing parent chat for subagent tool completion: chat=${chatChannel}`);
1037 return chatChannel;
1040 > private _notifyClientToolCallComplete(sessionChannel: ProtocolURI, chatChannel: ProtocolURI, toolCallId: string, result: ToolCallResult, source: 'client-dispatch' | 'server-envelope'): void {
1041 > const completionChat = this._toolCallCompletionChat(chatChannel); agentSideEffects.ts ×4
1042 > const agent = this._options.getAgent(sessionChannel);
1043 > if (!agent) {
1044 this._logService.warn(`[AgentSideEffects] No agent for client tool completion: source=${source}, session=${sessionChannel}, chat=${chatChannel}, completionChat=${completionChat}, toolCallId=${toolCallId}`);
1045 return;
1046 }
1047 > this._logService.info(`[AgentSideEffects] Forwarding client tool completion: source=${source}, session=${sessionChannel}, chat=${chatChannel}, completionChat=${completionChat}, toolCallId=${toolCallId}, success=${result.success}`); agentSideEffects.ts ×4
1048 > agent.onClientToolCallComplete(URI.parse(sessionChannel), URI.parse(completionChat), toolCallId, result);
1049 > }
1051 > // ---- Side-effect handlers --------------------------------------------------
1052 >
1053 > /**
1054 > * Handles a `pending_confirmation` signal end-to-end: checks for
1055 > * auto-approval via the permission manager, and if not auto-approved,
1056 > * dispatches the `ChatToolCallReady` action with confirmation options
1057 > * for the client.
1058 > */
1059 > private async _handleToolReady(e: IAgentToolPendingConfirmationSignal, sessionKey: ProtocolURI, turnId: string, agent: IAgent): Promise<void> {
1060 > const approvalEvent = { agentSideEffects.ts ×5
1061 > toolCallId: e.state.toolCallId,
1062 > session: e.chat,
1063 > permissionKind: e.permissionKind,
1064 > permissionPath: e.permissionPath,
1065 > toolInput: e.state.toolInput,
1066 > requestSandboxBypass: e.requestSandboxBypass,
1067 > };
1068 > const autoApproval = await this._permissionManager.getAutoApproval(approvalEvent, sessionKey);
1069 > const part = this._stateManager.getSessionState(sessionKey)?.activeTurn?.responseParts.find(part => part.kind === ResponsePartKind.ToolCall && part.toolCall.toolCallId === e.state.toolCallId);
1070 > const toolCall = part?.kind === ResponsePartKind.ToolCall ? part.toolCall : undefined;
1071 > if (toolCall
1072 > && toolCall.status !== ToolCallStatus.Streaming agentSideEffects.ts ×1
1073 > && toolCall.status !== ToolCallStatus.Running agentSideEffects.ts ×1
1074 > && toolCall.status !== ToolCallStatus.PendingConfirmation) { agentSideEffects.ts ×5
1075 > this._toolCallAgents.delete(`${sessionKey}:${e.state.toolCallId}`); agentSideEffects.ts ×1
1076 > this._logService.trace(`[AgentSideEffects] Dropping stale tool ready for ${e.state.toolCallId}: status=${toolCall.status}`);
1077 > return;
1078 > }
1079 > const contributor = e.state.contributor ?? toolCall?.contributor; agentSideEffects.ts ×3
1080 > let effective = e; agentSideEffects.ts ×5
1081 > const clientShouldAutoApprove = autoApproval !== undefined
1082 > && contributor?.kind === ToolCallContributorKind.Client agentSideEffects.ts ×1
1083 > && !!e.state.confirmationTitle; agentSideEffects.ts ×2
1084 > if (clientShouldAutoApprove) { agentSideEffects.ts ×5
1085 > this._toolCallAgents.set(`${sessionKey}:${e.state.toolCallId}`, agent.id); agentSideEffects.ts ×2
1086 > effective = { ...e, state: { ...e.state, _meta: { ...toolCall?._meta, ...e.state._meta, ...toToolCallMeta({ autoApproveBySetting: true }) } } };
1087 > } else if (autoApproval !== undefined) { agentSideEffects.ts ×3
1088 > this._toolCallAgents.delete(`${sessionKey}:${e.state.toolCallId}`); agentSideEffects.ts ×1
1089 > agent.respondToPermissionRequest(e.state.toolCallId, true);
1090 > // Strip confirmationTitle so createToolReadyAction emits the
1091 > // auto-approved (no-options) action.
1092 > effective = { ...e, state: { ...e.state, confirmationTitle: undefined } };
1093 > } else if (effective.state.confirmationTitle) { agentSideEffects.ts ×1
1094 > // Make sure the agent is registered for the eventual `ChatToolCallConfirmed` response. agentSideEffects.ts ×1
1095 > this._toolCallAgents.set(`${sessionKey}:${e.state.toolCallId}`, agent.id);
1096 > }
1097 > this._stateManager.dispatchServerAction( agentSideEffects.ts ×3
1098 > sessionKey,
1099 > this._permissionManager.createToolReadyAction(effective, sessionKey, turnId)
1100 > );
1103 > handleAction(channel: ProtocolURI, action: StateAction, clientId?: string): void {
1104 > const chatChannel = isAhpChatChannel(channel) ? channel : undefined; agentSideEffects.ts ×17
1105 > const sessionChannel = chatChannel ? parseRequiredSessionUriFromChatUri(chatChannel) : channel;
1106 > switch (action.type) {
1107 > case ActionType.ChatTurnStarted: {
1108 > if (!chatChannel) { agentSideEffects.ts ×3
1109 throw new Error(`ChatTurnStarted must be handled on an AHP chat channel: ${channel}`);
1110 }
1111 > const turnStopWatch = StopWatch.create(false); agentSideEffects.ts ×3
1112 > // Per-turn streaming part tracking is owned by the agent
1113 > // (e.g. CopilotAgentSession) and reset on its `send()` call.
1114 >
1115 > // Generic, agent-agnostic host commands (`/rename`, `!command`,
1116 > // …) are intercepted here and handled by the local-command
1117 > // dispatcher rather than forwarded to the agent SDK.
1118 > const handled = this._localCommands.tryHandle({ turnChannel: channel, turnId: action.turnId, text: action.message.text });
1119 > if (handled) {
1120 > if (handled.suggestedTitle !== undefined) { agentSideEffects.ts ×1
1121 > this._titleController.seedProvisionalTitle(sessionChannel, handled.suggestedTitle, chatChannel);
1122 > }
1123 > break;
1124 > }
1126 > const state = this._stateManager.getSessionState(channel);
1127 > if (!state) {
1128 this._logService.info(`[AgentSideEffects] Turn started for session not in state manager: ${channel}, turnId=${action.turnId} - status/summary updates may be dropped unless the session is restored`);
1129 }
1130 > this._titleController.seedTitleFromFirstMessage(sessionChannel, action.message.text, chatChannel); agentSideEffects.ts ×2
1131 >
1132 > const agent = this._options.getAgent(sessionChannel);
1133 > if (!agent) {
1134 > this._stateManager.dispatchServerAction(channel, { agentSideEffects.ts ×1
1135 > type: ActionType.ChatError,
1136 > turnId: action.turnId,
1137 > duration: this._turnDuration(turnStopWatch),
1138 > error: { errorType: 'noAgent', message: 'No agent found for session' },
1139 > });
1140 > return;
1141 > }
1142 > const attachments = action.message.attachments; agentSideEffects.ts ×1
1143 > this._telemetryReporter.userMessageSent(agent.id, channel, state, 'direct', attachments);
1144 > const { model, modelTelemetryKind, permissionLevel } = this._getTurnTelemetryContext(agent, state, action.message.model?.id); agentSideEffects.ts ×3
1145 > this._turnTracker.turnStarted(agent.id, channel, action.turnId, model, modelTelemetryKind, permissionLevel);
1146 > void this._sendTurnMessage({
1147 > agent,
1148 > sessionChannel,
1149 > turnChannel: channel,
1150 > chat: channel,
1151 > message: action.message,
1152 > turnId: action.turnId,
1153 > senderClientId: clientId,
1154 > turnStopWatch,
1155 > });
1156 > break;
1157 > }
1158 > case ActionType.ChatToolCallConfirmed: { agentSideEffects.ts ×17
1159 > if (!chatChannel) { agentSideEffects.ts ×4
1160 throw new Error(`ChatToolCallConfirmed must be handled on an AHP chat channel: ${channel}`);
1161 }
1162 > const toolCallKey = `${channel}:${action.toolCallId}`; agentSideEffects.ts ×4
1163 > const agentId = this._toolCallAgents.get(toolCallKey);
1164 > if (agentId) {
1165 > this._toolCallAgents.delete(toolCallKey);
1166 > const agent = this._options.agents.get().find(a => a.id === agentId);
1167 > agent?.respondToPermissionRequest(action.toolCallId, action.approved);
1168 > } else {
1169 this._logService.warn(`[AgentSideEffects] No agent for tool call confirmation: ${action.toolCallId}`);
1170 }
1172 > // When the user chose "Allow in this Session", add the tool
1173 > // to the session's permissions so future calls are auto-approved.
1174 > if (action.approved) {
1175 > this._permissionManager.handleToolCallConfirmed(channel, action.toolCallId, action.selectedOptionId); sessionPermissions.ts ×3
1176 > }
1177 > break; agentSideEffects.ts ×4
1178 > }
1179 > case ActionType.ChatInputCompleted: { agentSideEffects.ts ×17
1180 if (!chatChannel) {
1181 throw new Error(`ChatInputCompleted must be handled on an AHP chat channel: ${channel}`);
1182 }
1183 const agent = this._options.getAgent(sessionChannel);
1184 agent?.respondToUserInputRequest(action.requestId, action.response, action.answers);
1185 break;
1186 }
1187 > case ActionType.ChatTurnCancelled: { agentSideEffects.ts ×17
1188 > if (!chatChannel) { agentSideEffects.ts ×5
1189 throw new Error(`ChatTurnCancelled must be handled on an AHP chat channel: ${channel}`);
1190 }
1191 > this._turnTracker.turnCompleted(channel, action.turnId, 'cancelled'); agentSideEffects.ts ×5
1192 > this._toolCallTracker.clearSession(channel);
1193 > // Cancel all subagent sessions for this parent
1194 > this.cancelSubagentSessions(channel);
1195 > const agent = this._options.getAgent(sessionChannel);
1196 > if (agent) {
1197 > const chat = URI.parse(channel);
1198 > agent.chats.abort(chat).catch(err => {
1199 this._logService.error('[AgentSideEffects] abort failed', err);
1201 > }
1202 > // Intentionally do NOT drain queued messages here: cancelling means
1203 > // "stop", so messages queued behind the turn stay queued for the
1204 > // user to dequeue/run manually. (A message the user sends *after*
1205 > // the abort is still consumed via the ChatPendingMessageSet path
1206 > // once cancellation has cleared the active turn.)
1207 > break;
1208 > }
1209 > case ActionType.SessionTitleChanged: { agentSideEffects.ts ×17
1210 > if (chatChannel) { agentSideEffects.ts ×2
1211 // The rename targeted a specific chat (default or additional),
1212 // not the whole session. Route it to a per-chat title update so
1213 // the session title stays independent.
1214 this._stateManager.updateChatTitle(sessionChannel, chatChannel, action.title);
1215 this._persistSessionFlag(sessionChannel, `customChatTitle:${chatChannel}`, action.title);
1216 break;
1217 }
1218 > this._persistSessionFlag(channel, 'customTitle', action.title); agentSideEffects.ts ×2
1219 > break;
1220 > }
1221 > case ActionType.ChatPendingMessageSet: agentSideEffects.ts ×17
1222 > case ActionType.ChatPendingMessageRemoved:
1223 > case ActionType.ChatQueuedMessagesReordered: {
1224 > if (!chatChannel) { agentSideEffects.ts ×4
1225 throw new Error(`${action.type} must be handled on an AHP chat channel: ${channel}`);
1226 }
1227 > this._syncPendingMessages(channel); agentSideEffects.ts ×4
1228 > break;
1229 > }
1230 > case ActionType.ChatTruncated: { agentSideEffects.ts ×17
1231 > if (!chatChannel) { agentSideEffects.ts ×4
1232 throw new Error(`ChatTruncated must be handled on an AHP chat channel: ${channel}`);
1233 }
1234 > const agent = this._options.getAgent(sessionChannel); agentSideEffects.ts ×4
1235 > // When the truncation boundary is a host-injected local turn
1236 > // (`/rename` / `!command`), redirect the SDK truncation to the
1237 > // preceding concrete turn so the agent keeps everything up to
1238 > // the real message before it.
1239 > const sdkTurnId = action.turnId !== undefined
1240 > ? this._options.localTurns.resolveConcreteTurnId(chatChannel, action.turnId) agentSideEffects.ts ×1
1241 > : action.turnId; reducer.ts ×2
1242 > // Route to the chat being truncated: the default chat (addressed agentSideEffects.ts ×4
1243 > // by the session) or a peer chat with its own backing.
1244 > agent?.truncateSession?.(URI.parse(sessionChannel), sdkTurnId, URI.parse(chatChannel)).catch(err => {
1245 this._logService.error('[AgentSideEffects] truncateSession failed', err);
1247 > // Drop persisted local turns that no longer survive in the
1248 > // (already-truncated) chat state.
1249 > const survivingIds = new Set((this._stateManager.getChatState(chatChannel)?.turns ?? []).map(t => t.id));
1250 > const removed = this._options.localTurns.getLocalTurnIds(chatChannel).filter(id => !survivingIds.has(id));
1251 > this._options.localTurns.deleteLocals(sessionChannel, removed);
1252 > this._changesets.onSessionTruncated(sessionChannel);
1253 > break;
1254 > }
1255 > case ActionType.SessionActiveClientSet: { agentSideEffects.ts ×17
1256 > const agent = this._options.getAgent(channel); agentSideEffects.ts ×2
1257 > if (!agent) {
1258 break;
1259 }
1260 > const activeClient = action.activeClient; agentSideEffects.ts ×2
1261 > const handle = agent.getOrCreateActiveClient(URI.parse(channel), {
1262 > clientId: activeClient.clientId,
1263 > displayName: activeClient.displayName,
1264 > });
1265 > handle.tools = activeClient.tools;
1266 > handle.customizations = activeClient.customizations ?? [];
1267 > break;
1268 > }
1269 > case ActionType.SessionActiveClientRemoved: { agentSideEffects.ts ×17
1270 > const agent = this._options.getAgent(channel); agentSideEffects.ts ×1
1271 > agent?.removeActiveClient(URI.parse(channel), action.clientId);
1272 > break;
1273 > }
1274 > case ActionType.RootConfigChanged: { agentSideEffects.ts ×17
1275 > updateAgentHostTelemetryLevelFromConfig(this._telemetryService, action.config); agentSideEffects.ts ×3
1276 > // Host customizations are self-managed by each agent's
1277 > // PluginController via IAgentConfigurationService.onDidRootConfigChange.
1278 > // Republish agent infos for non-customization schema changes
1279 > // (e.g. permissions) and session customizations as a catchall.
1280 > this._publishAgentInfos(this._options.agents.get());
1281 > this._publishAllSessionCustomizations();
1282 > break;
1283 > }
1284 > case ActionType.SessionMcpServerStartRequested: { agentSideEffects.ts ×17
1285 const agent = this._options.getAgent(sessionChannel);
1286 agent?.startMcpServer?.(URI.parse(sessionChannel), action.id).catch(err => {
1287 this._logService.warn(`[AgentSideEffects] startMcpServer failed for ${sessionChannel}`, err);
1288 });
1289 break;
1290 }
1291 > case ActionType.SessionMcpServerStopRequested: { agentSideEffects.ts ×17
1292 const agent = this._options.getAgent(sessionChannel);
1293 agent?.stopMcpServer?.(URI.parse(sessionChannel), action.id).catch(err => {
1294 this._logService.warn(`[AgentSideEffects] stopMcpServer failed for ${sessionChannel}`, err);
1295 });
1296 break;
1297 }
1298 > case ActionType.SessionIsReadChanged: { agentSideEffects.ts ×17
1299 this._persistSessionFlag(channel, 'isRead', action.isRead ? 'true' : '');
1300 break;
1301 }
1302 > case ActionType.SessionIsArchivedChanged: { agentSideEffects.ts ×17
1303 this._persistSessionFlag(channel, AH_META_IS_ARCHIVED_DB_KEY, action.isArchived ? 'true' : '');
1304 // Host-owned worktree lifecycle (agents stay unaware): remove the
1305 // clean, branch-preserved worktree on archive and recreate it on
1306 // unarchive. Serialized per session inside the controller so it can't
1307 // interleave with a first-send worktree resolution.
1308 if (this._worktree) {
1309 const sessionUri = URI.parse(channel);
1310 const sessionId = AgentSession.id(channel);
1311 const worktreeOp = action.isArchived
1312 ? this._worktree.cleanupWorktreeOnArchive(sessionUri, sessionId)
1313 : this._worktree.recreateWorktreeOnUnarchive(sessionUri, sessionId);
1314 worktreeOp.catch(err => this._logService.warn(`[AgentSideEffects] worktree ${action.isArchived ? 'cleanup' : 'recreate'} failed for ${channel}`, err));
1315 }
1316 const agent = this._options.getAgent(channel);
1317 agent?.onArchivedChanged?.(URI.parse(channel), action.isArchived).catch(err => {
1318 this._logService.warn(`[AgentSideEffects] onArchivedChanged failed for ${channel}`, err);
1319 });
1320 break;
1321 }
1322 > case ActionType.SessionConfigChanged: { agentSideEffects.ts ×17
1323 > // Persist merged values so a future `restoreSession` can re-hydrate agentSideEffects.ts ×2
1324 > // the user's previous selections (e.g. autoApprove).
1325 > const sessionState = this._stateManager.getSessionState(channel);
1326 > const values = sessionState?.config?.values;
1327 > if (values) {
1328 > this._persistSessionFlag(channel, 'configValues', JSON.stringify(values));
1329 > }
1330 > if (this._worktree && sessionState?.lifecycle === SessionLifecycle.Creating) {
1331 > const sessionId = AgentSession.id(channel); agentSideEffects.ts ×1
1332 > const isolation = values?.[SessionConfigKey.Isolation];
1333 > if (isolation === 'worktree') {
1334 > this._worktree.notePending(sessionId);
1335 > } else if (isolation === 'folder') {
1336 > this._worktree.clearPending(sessionId);
1337 > }
1338 > }
1339 > // This case is reached only for client-dispatched config changes agentSideEffects.ts ×2
1340 > // (a user picker edit); internal server-side writes use
1341 > // `dispatchServerAction` and never land here. So the provider can
1342 > // forward a live, session-mutable change (e.g. Claude's
1343 > // `permissionMode`) to its running SDK without re-entering its own
1344 > // tool callbacks.
1345 > this._options.getAgent(channel)?.onSessionConfigChanged?.(URI.parse(channel), values ?? {});
1346 > break;
1347 > }
1348 > case ActionType.ChatToolCallComplete: { agentSideEffects.ts ×17
1349 > if (!chatChannel) { agentSideEffects.ts ×2
1350 break; // Not a chat channel; ignore.
1351 }
1352 > this._notifyClientToolCallComplete(sessionChannel, chatChannel, action.toolCallId, action.result, 'client-dispatch'); agentSideEffects.ts ×2
1353 > break;
1354 > }
1356 > }
1358 > /** Injects the host-owned worktree isolation controller (see {@link AgentService.setWorktreeIsolation}). */
1359 > setWorktreeIsolation(worktree: WorktreeIsolation): void {
1360 > this._worktree = worktree; agentService.ts ×9
1361 > }
1363 > cancelSessionTitleGeneration(session: ProtocolURI): void {
1364 > this._titleController.cancelTitleGeneration(session); agentService.ts ×2
1365 > }
1367 > /**
1368 > * Generates a content-derived title for a freshly forked session
1369 > * (`chatChannel` undefined) or peer chat from its inherited chat
1370 > * turns, replacing the placeholder `Forked: …` title once ready.
1371 > */
1372 > generateForkedTitle(channel: ProtocolURI, chatChannel: ProtocolURI | undefined, turns: readonly Turn[], fallbackTitle: string, sourceTitle?: string): void {
1373 > this._titleController.generateForkedTitle(channel, chatChannel, turns, fallbackTitle, sourceTitle); agentService.ts ×2
1374 > }
1376 > /**
1377 > * Persists a session metadata key/value pair to the session database.
1378 > * Used for fields the host needs to remember across restarts (custom
1379 > * title, isRead/isArchived flags, merged config values).
1380 > */
1381 > private _persistSessionFlag(session: ProtocolURI, key: string, value: string): void {
1382 > persistSessionMetadata(this._options.sessionDataService, this._logService, session, key, value); agentSideEffects.ts ×1
1383 > }
1385 > private _persistChatDraft(channel: ProtocolURI, draft: Message | undefined): void {
1386 > if (!isAhpChatChannel(channel)) { agentSideEffects.ts ×5
1387 return;
1388 }
1390 > const parsed = parseChatUri(channel);
1391 > if (!parsed) {
1392 return;
1393 }
1395 > const session = URI.parse(parsed.session);
1396 > const ref = this._options.sessionDataService.openDatabase(session);
1397 > ref.object.setChatDraft(URI.parse(channel), draft).catch(err => {
1398 this._logService.warn(`[AgentSideEffects] Failed to persist chat draft for ${channel.toString()}`, err);
1399 > }).finally(() => { agentSideEffects.ts ×5
1400 > ref.dispose();
1401 > });
1402 > }
1404 > /**
1405 > * Pushes the current pending message state from the chat to the agent.
1406 > * The server controls queued message consumption; only steering messages
1407 > * are forwarded to the agent for mid-turn injection.
1408 > */
1409 > private _syncPendingMessages(chatChannel: ProtocolURI): void {
1410 > const sessionChannel = parseRequiredSessionUriFromChatUri(chatChannel); agentSideEffects.ts ×4
1411 > const state = this._stateManager.getSessionState(chatChannel);
1412 > if (!state) {
1413 return;
1414 }
1415 > const agent = this._options.getAgent(sessionChannel); agentSideEffects.ts ×4
1416 > agent?.setPendingMessages?.(
1417 > URI.parse(chatChannel),
1418 > state.steeringMessage,
1419 > [],
1420 > );
1421 >
1422 > // Steering message removal is now dispatched by the agent
1423 > // via the 'steering_consumed' progress event once the message
1424 > // has actually been sent to the model.
1425 >
1426 > // If the session is idle, try to consume the next queued message
1427 > this._tryConsumeNextQueuedMessage(chatChannel);
1428 > }
1430 > /**
1431 > * Consumes the next queued message by dispatching a server-initiated
1432 > * `ChatTurnStarted` action with `queuedMessageId` set. The reducer
1433 > * atomically creates the active turn and removes the message from the
1434 > * queue. Only consumes one message at a time; subsequent messages are
1435 > * consumed when the next `idle` event fires.
1436 > */
1437 > private _tryConsumeNextQueuedMessage(session: ProtocolURI): void {
1438 > const sessionChannel = parseRequiredSessionUriFromChatUri(session); agentSideEffects.ts ×3
1439 > // Bail if there's already an active turn
1440 > if (this._stateManager.getActiveTurnId(session)) {
1441 > return; agentSideEffects.ts ×1
1442 > }
1443 > const state = this._stateManager.getSessionState(session); agentSideEffects.ts ×1
1444 > if (!state?.queuedMessages?.length) { agentSideEffects.ts ×3
1445 > return; agentSideEffects.ts ×1
1446 > }
1448 > const msg = state.queuedMessages[0];
1449 > const turnId = generateUuid();
1450 >
1451 > // Per-turn streaming part tracking is owned by the agent (reset
1452 > // inside its `send()` call), so no host-side reset is needed.
1453 >
1454 > // Dispatch server-initiated turn start; the reducer removes the queued message atomically
1455 > this._stateManager.dispatchServerAction(session, {
1456 > type: ActionType.ChatTurnStarted,
1457 > turnId,
1458 > startedAt: new Date().toISOString(),
1459 > message: msg.message,
1460 > queuedMessageId: msg.id,
1461 > });
1462 > const turnStopWatch = StopWatch.create(false);
1463 >
1464 > // Generic host commands (`/rename`, `!command`, …) are intercepted by
1465 > // the local-command dispatcher (see the ChatTurnStarted handler) and
1466 > // must not reach the agent SDK even when queued.
1467 > const handled = this._localCommands.tryHandle({ turnChannel: session, turnId, text: msg.message.text });
1468 > if (handled) {
1469 > // A local command may suggest a provisional title (e.g. a `!command` agentSideEffects.ts ×1
1470 > // dequeued before any real request has titled the session).
1471 > if (handled.suggestedTitle !== undefined) {
1472 > this._titleController.seedProvisionalTitle(sessionChannel, handled.suggestedTitle, session);
1473 > }
1474 > return;
1475 > }
1477 > this._titleController.seedTitleFromFirstMessage(sessionChannel, msg.message.text, session);
1478 >
1479 > // Send the message to the agent backend. When `session` is an
1480 > // additional chat channel, the SDK chat is owned by the
1481 > // parent session: look up the provider by the parent session URI and
1482 > // pass the chat channel so the harness routes to the right peer chat.
1483 > const agent = this._options.getAgent(sessionChannel);
1484 > if (!agent) {
1485 this._stateManager.dispatchServerAction(session, {
1486 type: ActionType.ChatError,
1487 turnId,
1488 duration: this._turnDuration(turnStopWatch),
1489 error: { errorType: 'noAgent', message: 'No agent found for session' },
1490 });
1491 return;
1492 }
1493 > const attachments = msg.message.attachments; agentSideEffects.ts ×3
1494 > const queuedState = this._stateManager.getSessionState(session);
1495 > this._telemetryReporter.userMessageSent(agent.id, session, queuedState, 'queued', attachments);
1496 > const { model, modelTelemetryKind, permissionLevel } = this._getTurnTelemetryContext(agent, queuedState, msg.message.model?.id); agentSideEffects.ts ×3
1497 > this._turnTracker.turnStarted(agent.id, session, turnId, model, modelTelemetryKind, permissionLevel);
1498 > // Selection travels on the queued message; it is applied before sending.
1499 > void this._sendTurnMessage({
1500 > agent,
1501 > sessionChannel,
1502 > turnChannel: session,
1503 > chat: session,
1504 > message: msg.message,
1505 > turnId,
1506 > senderClientId: undefined,
1507 > turnStopWatch,
1508 > });
1509 > }
1511 >
1512 > private _getTurnTelemetryContext(agent: IAgent, state: SessionState | undefined, modelId: string | undefined): { model: string | undefined; modelTelemetryKind: AgentHostModelTelemetryKind | undefined; permissionLevel: string | undefined } {
1513 > const permissionValue = state?.config?.values[SessionConfigKey.AutoApprove]; agentSideEffects.ts ×5
1514 > const permissionLevel = typeof permissionValue === 'string' ? permissionValue : undefined;
1515 > const model = modelId === undefined ? undefined : agent.models.get().find(model => model.id === modelId);
1516 > let modelTelemetryKind: AgentHostModelTelemetryKind | undefined;
1517 > if (modelId === 'auto') {
1518 > modelTelemetryKind = 'trusted'; agentSideEffects.ts ×1
1519 > } else if (modelId === undefined) { agentSideEffects.ts ×5
1520 > modelTelemetryKind = undefined; agentSideEffects.ts ×1
1521 > } else if (model === undefined) { agentSideEffects.ts ×1
1522 > modelTelemetryKind = 'unknown'; agentSideEffects.ts ×1
1523 > } else { agentSideEffects.ts ×1
1524 > modelTelemetryKind = readAgentModelByokIdentifier(model) === undefined ? 'trusted' : 'byok'; agentSideEffects.ts ×1
1525 > }
1526 > return { model: modelId, modelTelemetryKind, permissionLevel }; agentSideEffects.ts ×5
1527 > }
1529 > /**
1530 > * Applies a turn message's model/agent selection (see
1531 > * {@link _applyMessageSelection}) and forwards it to the agent's
1532 > * `sendMessage`. A rejected send is wired to fail the turn: it logs,
1533 > * dispatches {@link ActionType.ChatError} on the turn channel, and marks the
1534 > * turn errored.
1535 > */
1536 > private async _sendTurnMessage(options: {
1537 > agent: IAgent; agentSideEffects.ts ×5
1538 > /** The agent/session URI the chat lives on (the send target). */
1539 > sessionChannel: ProtocolURI;
1540 > /** The channel the turn runs on — where `ChatError` / turn completion are reported. */
1541 > turnChannel: ProtocolURI;
1542 > /** Chat channel URI the turn targets. */
1543 > chat: ProtocolURI;
1544 > message: Message;
1545 > turnId: string;
1546 > senderClientId: string | undefined;
1547 > turnStopWatch: StopWatch;
1548 > }): Promise<void> {
1549 > const { agent, sessionChannel, turnChannel, chat, message, turnId, senderClientId, turnStopWatch } = options;
1550 >
1551 > // Read-only chats reject user-dispatched turns. `interactivity` is the
1552 > // general signal (e.g. subagent worker chats are `ReadOnly`), and an
1553 > // archived session downgrades its interactive chats to read-only too — so
1554 > // enforce off the chat's effective interactivity rather than special-casing
1555 > // archived. This is the enforcement behind the UI hiding the composer, so a
1556 > // buggy or remote client cannot run work in a read-only or archived session
1557 > // (which may no longer have its isolated worktree on disk).
1558 > const chatState = this._stateManager.getChatState(chat);
1559 > const sessionStatus = this._stateManager.getSessionSummary(options.sessionChannel)?.status ?? 0;
1560 > const sessionArchived = (sessionStatus & SessionStatus.IsArchived) === SessionStatus.IsArchived;
1561 > if (isChatReadOnly(chatState?.interactivity, sessionArchived)) {
1562 > const error = sessionArchived agentSideEffects.ts ×2
1563 > ? { errorType: 'archived', message: 'This session is archived and read-only. Restore the session to continue the conversation.' } reducer.ts ×1
1564 > : { errorType: 'readOnly', message: 'This chat is read-only.' }; agentHostSessionTitleController.ts ×1
1565 > this._logService.warn(`[AgentSideEffects] Rejecting turn on read-only chat=${chat} (archived=${sessionArchived}), turnId=${turnId}`); agentSideEffects.ts ×2
1566 > this._stateManager.dispatchServerAction(turnChannel, {
1567 > type: ActionType.ChatError,
1568 > turnId,
1569 > duration: this._turnDuration(turnStopWatch),
1570 > error,
1571 > });
1572 > this._turnTracker.turnCompleted(turnChannel, turnId, 'error', { stage: 'validation', error });
1573 > this._toolCallTracker.clearSession(turnChannel);
1574 > return;
1575 > }
1577 > const chatUri = URI.parse(chat);
1578 >
1579 > let failureStage: AgentHostTurnFailureStage = 'workingDirectory';
1580 > try {
1581 > // Host-owned working-directory resolution: resolve the session's working
1582 > // directory before the agent materializes, so the agent runs in it
1583 > // without ever knowing how it was derived. Returns the created worktree
1584 > // for worktree sessions (created here on the first send) or the picked
1585 > // folder for folder sessions; undefined for workspace-less sessions.
1586 > const resolvedWorkingDirectory = await this._options.resolveWorkingDirectoryBeforeSend?.({ session: options.sessionChannel, chat, turnId, prompt: message.text });
1588 > const selectionUpdates: Promise<void>[] = [];
1589 > if (message.model) {
1590 > failureStage = 'modelSelection'; agentSideEffects.ts ×1
1591 > selectionUpdates.push(agent.chats.changeModel(chatUri, message.model));
1592 > }
1593 > selectionUpdates.push(agent.chats.changeAgent(chatUri, message.agent).catch(err => { agentSideEffects.ts ×3
1594 this._logService.error('[AgentSideEffects] changeAgent failed', err);
1596 >
1597 > await Promise.all(selectionUpdates);
1599 > failureStage = 'sendMessage';
1600 > const resolvedAttachments = await this._resolveChatAttachments(sessionChannel, message.attachments);
1601 > await agent.chats.sendMessage(chatUri, message.text, resolvedWorkingDirectory, resolvedAttachments, turnId, senderClientId); agentSideEffects.ts ×1
1602 > } catch (err) { agentSideEffects.ts ×2
1603 > const failure = buildTurnFailure(failureStage, err); agentSideEffects.ts ×7
1604 > const error = failure.error;
1605 > this._logService.error(`[AgentSideEffects] ${failureStage} failed for session=${turnChannel}: code=${failure.errorCode}, message=${error.message}, type=${failure.errorName}`, err);
1606 > this._stateManager.dispatchServerAction(turnChannel, {
1607 > type: ActionType.ChatError,
1608 > turnId,
1609 > duration: this._turnDuration(turnStopWatch),
1610 > error,
1611 > });
1612 > this._turnTracker.turnCompleted(turnChannel, turnId, 'error', failure);
1613 > this._toolCallTracker.clearSession(turnChannel);
1614 > this._failSessionCreationIfStillCreating(sessionChannel, error);
1615 > }
1618 > private async _resolveChatAttachments(sessionChannel: ProtocolURI, attachments: readonly MessageAttachment[] | undefined): Promise<readonly MessageAttachment[] | undefined> {
1619 > if (!attachments?.some(attachment => attachment.type === MessageAttachmentKind.Chat)) { agentSideEffects.ts ×3
1620 > return attachments; agentSideEffects.ts ×1
1621 > }
1622 > return Promise.all(attachments.map(async attachment => { agentSideEffects.ts ×5
1623 > if (attachment.type !== MessageAttachmentKind.Chat) {
1624 return attachment;
1625 }
1626 > const sourceSession = isAhpChatChannel(attachment.resource) agentSideEffects.ts ×5
1627 > ? parseRequiredSessionUriFromChatUri(attachment.resource) agentSideEffects.ts ×1
1628 > : URI.parse(attachment.resource).toString(); agentSideEffects.ts ×1
1629 > if (sourceSession !== URI.parse(sessionChannel).toString()) { agentSideEffects.ts ×5
1630 > throw new Error(`Chat attachment source must belong to the target session: ${attachment.resource}`); agentSideEffects.ts ×1
1631 > }
1632 > const sourceState = this._resolveSourceChatState(attachment.resource); agentSideEffects.ts ×3
1633 > if (sourceState?.activeTurn?.id === attachment.endTurn) { agentSideEffects.ts ×5
1634 > throw new Error(`Chat attachment endTurn must reference a completed turn: ${attachment.resource}#${attachment.endTurn}`); agentSideEffects.ts ×2
1635 > }
1636 > const sourceTurns = await this._options.resolveChatAttachmentTurns?.(attachment.resource) agentSideEffects.ts ×2
1637 > ?? sourceState?.turns agentSideEffects.ts ×1
1638 ?? [];
1639 > return resolveChatAttachment(attachment, sourceTurns); agentSideEffects.ts ×5
1640 > }));
1643 > private _resolveSourceChatState(sourceUri: string) {
1644 > const peerState = this._stateManager.getChatState(sourceUri); agentSideEffects.ts ×3
1645 > if (peerState) {
1646 > return peerState; agentSideEffects.ts ×2
1647 > }
1648 > if (!isAhpChatChannel(sourceUri)) { agentSideEffects.ts ×2
1649 > return this._stateManager.getDefaultChatState(sourceUri); agentSideEffects.ts ×1
1650 > }
1651 > if (isDefaultChatUri(sourceUri)) { agentService.ts ×4
1652 return this._stateManager.getDefaultChatState(parseRequiredSessionUriFromChatUri(sourceUri));
1653 }
1654 > return undefined; agentService.ts ×4
1657 > /**
1658 > * Surfaces a failed first turn on a not-yet-materialized session as a
1659 > * terminal creation failure.
1660 > *
1661 > * Provisional sessions defer both their root-catalog `SessionAdded`
1662 > * notification and their `Creating -> Ready` lifecycle transition until the
1663 > * agent materializes them (worktree setup, SDK session init, …) on the
1664 > * first `sendMessage`. When that first send rejects — e.g. worktree/branch
1665 > * creation throws — the session never entered the catalog and its lifecycle
1666 > * is stuck at `Creating`, so clients that optimistically rendered it as
1667 > * in-progress keep spinning forever.
1668 > *
1669 > * When the failing session is still `Creating`, dispatch
1670 > * {@link ActionType.SessionCreationFailed} to move it to a terminal
1671 > * `CreationFailed` lifecycle, then announce its catalog entry via
1672 > * {@link AgentHostStateManager.markSessionPersisted}. The summary's status
1673 > * was already aggregated to `Error` by the preceding `ChatError` dispatch,
1674 > * so subscribers render the session as failed immediately rather than
1675 > * waiting on a client-side timeout. The provisional session survives on the
1676 > * agent, so resending re-attempts materialization.
1677 > */
1678 > private _failSessionCreationIfStillCreating(sessionChannel: ProtocolURI, error: ErrorInfo): void {
1679 > const state = this._stateManager.getSessionState(sessionChannel); agentSideEffects.ts ×7
1680 > if (state?.lifecycle !== SessionLifecycle.Creating) {
1681 > return; agentSideEffects.ts ×1
1682 > }
1683 > this._stateManager.dispatchServerAction(sessionChannel, { reducer.ts ×1
1684 > type: ActionType.SessionCreationFailed,
1685 > error,
1686 > });
1687 > const summary = this._stateManager.getSessionSummary(sessionChannel);
1688 > if (summary) {
1689 > this._stateManager.markSessionPersisted(sessionChannel, summary);
1690 > }
1693 >
1694 > override dispose(): void {
1695 > this._toolCallAgents.clear(); agentSideEffects.ts ×6
1696 > this._toolCallTracker.clear();
1697 > super.dispose();
1698 > }
1700 >
1701 > /**
1702 > * Builds the {@link ErrorInfo} for a failed `sendMessage` rejection. When the
1703 > * rejection text carries a `VSCODE_PROXY_ERROR` marker (embedded by a model
1704 > * proxy and echoed back through the agent SDK), the decoded structured chat
1705 > * error is attached to `_meta.chatError` so core can render a rich, localized
1706 > * message. Otherwise the raw error message is used as-is.
1707 > */
1708 > function buildTurnFailure(stage: AgentHostTurnFailureStage, err: unknown): IAgentHostTurnFailure { agentSideEffects.ts ×7
1709 > const error = buildTurnFailureError(stage, err);
1710 > return {
1711 > stage,
1712 > error,
1713 > errorName: err instanceof Error ? err.name : typeof err,
1714 > errorCode: getErrorCode(err),
1715 > errorStack: err instanceof Error ? err.stack : undefined,
1716 > };
1717 > }
1719 > function buildTurnFailureError(stage: AgentHostTurnFailureStage, err: unknown): ErrorInfo { agentSideEffects.ts ×7
1720 > const message = String(err);
1721 > const forwarded = tryParseForwardedChatError(err instanceof Error ? err.message : message);
1722 > const errorType = stage === 'modelSelection' ? 'modelSelectionFailed'
1723 > : stage === 'workingDirectory' ? 'workingDirectoryFailed' : 'sendFailed'; agentSideEffects.ts ×1
1724 > if (forwarded) { agentSideEffects.ts ×7
1725 return { errorType, message: stripProxyErrorMarker(message), _meta: toChatErrorMeta(forwarded) };
1726 }
1727 > return { errorType, message }; agentSideEffects.ts ×7
1728 > }