Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/platform/agentHost/node/agentSideEffects.ts
13394 views
1
/*---------------------------------------------------------------------------------------------
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 { Disposable, DisposableMap, DisposableStore, IDisposable } from '../../../base/common/lifecycle.js';
8
import { equals } from '../../../base/common/objects.js';
9
import { autorun, IObservable, IReader } from '../../../base/common/observable.js';
10
import { hasKey } from '../../../base/common/types.js';
11
import { URI } from '../../../base/common/uri.js';
12
import { generateUuid } from '../../../base/common/uuid.js';
13
import { ILogService } from '../../log/common/log.js';
14
import { IInstantiationService } from '../../instantiation/common/instantiation.js';
15
import { AgentSignal, IAgent, IAgentAttachment, IAgentToolPendingConfirmationSignal } from '../common/agentService.js';
16
import { IDiffComputeService } from '../common/diffComputeService.js';
17
import { ISessionDatabase, ISessionDataService } from '../common/sessionDataService.js';
18
import type { AgentInfo } from '../common/state/protocol/state.js';
19
import { ActionType, isSessionAction, StateAction, type SessionToolCallCompleteAction } from '../common/state/sessionActions.js';
20
import {
21
PendingMessageKind,
22
ResponsePartKind,
23
SessionStatus,
24
ToolCallStatus,
25
ToolResultContentType,
26
buildSubagentSessionUri,
27
getToolFileEdits,
28
type SessionState,
29
type ToolResultContent,
30
type ISessionFileDiff,
31
type URI as ProtocolURI,
32
} from '../common/state/sessionState.js';
33
import { AgentHostStateManager } from './agentHostStateManager.js';
34
import { IAgentHostGitService, META_DIFF_BASE_BRANCH } from './agentHostGitService.js';
35
import { NodeWorkerDiffComputeService } from './diffComputeService.js';
36
import { computeSessionDiffs, type IIncrementalDiffOptions } from './sessionDiffAggregator.js';
37
import { SessionPermissionManager } from './sessionPermissions.js';
38
39
/**
40
* Options for constructing an {@link AgentSideEffects} instance.
41
*/
42
export interface IAgentSideEffectsOptions {
43
/** Resolve the agent responsible for a given session URI. */
44
readonly getAgent: (session: ProtocolURI) => IAgent | undefined;
45
/** Observable set of registered agents. Triggers `root/agentsChanged` when it changes. */
46
readonly agents: IObservable<readonly IAgent[]>;
47
/** Session data service for cleaning up per-session data on disposal. */
48
readonly sessionDataService: ISessionDataService;
49
/**
50
* Called after each top-level session turn completes so git state can be
51
* refreshed and published via `SessionMetaChanged`. Subagent turns are
52
* excluded — only the parent session URI is passed.
53
*/
54
readonly onTurnComplete: (session: ProtocolURI) => void;
55
}
56
57
/** A signal that was deferred because its subagent session does not exist yet. */
58
interface IPendingSubagentSignal {
59
readonly signal: AgentSignal;
60
readonly agent: IAgent;
61
}
62
63
/**
64
* Shared implementation of agent side-effect handling.
65
*
66
* Routes client-dispatched actions to the correct agent backend,
67
* restores sessions from previous lifetimes, handles filesystem
68
* operations (browse/fetch/write), tracks pending permission requests,
69
* and wires up agent progress events to the state manager.
70
*
71
* Session create/dispose/list and auth are handled by {@link AgentService}.
72
*/
73
export class AgentSideEffects extends Disposable {
74
75
/** Maps tool call IDs to the agent that owns them, for routing confirmations. */
76
private readonly _toolCallAgents = new Map<string, string>();
77
/** Shared diff compute service for calculating line-level diffs in a worker thread. */
78
private readonly _diffComputeService: IDiffComputeService;
79
/** Serializes per-session diff computations to avoid races with stale previousDiffs. */
80
private readonly _diffComputationSequencer = new SequencerByKey<string>();
81
private _lastAgentInfos: readonly AgentInfo[] = [];
82
/** Per-session debounce timers for mid-turn diff computation. */
83
private readonly _debouncedDiffTimers = this._register(new DisposableMap<string>());
84
private static readonly _DIFF_DEBOUNCE_MS = 5000;
85
86
private readonly _permissionManager: SessionPermissionManager;
87
88
/**
89
* Maps `parentSession:toolCallId` → subagent session URI.
90
* Used to route signals with `parentToolCallId` to the correct subagent.
91
*/
92
private readonly _subagentSessions = new Map<string, ProtocolURI>();
93
94
/**
95
* Buffers signals whose `parentToolCallId` references a subagent
96
* whose `subagent_started` signal has not yet been processed. The SDK is
97
* not strict about ordering: an inner `tool_start` can arrive before the
98
* `subagent_started` that creates the child session. Without buffering,
99
* those signals would be dispatched against the parent session and the
100
* UI would render the inner tool calls flat at the top level rather than
101
* grouping them under the subagent. Drained by `_handleSubagentStarted`.
102
*
103
* Key: `${parentSession}:${parentToolCallId}`.
104
*/
105
private readonly _pendingSubagentSignals = new Map<string, IPendingSubagentSignal[]>();
106
107
constructor(
108
private readonly _stateManager: AgentHostStateManager,
109
private readonly _options: IAgentSideEffectsOptions,
110
@IInstantiationService instantiationService: IInstantiationService,
111
@ILogService private readonly _logService: ILogService,
112
@IAgentHostGitService private readonly _gitService: IAgentHostGitService,
113
) {
114
super();
115
this._diffComputeService = this._register(new NodeWorkerDiffComputeService(this._logService));
116
this._permissionManager = this._register(instantiationService.createInstance(SessionPermissionManager, this._stateManager));
117
118
// Whenever the agents observable changes, publish to root state.
119
this._register(autorun(reader => {
120
const agents = this._options.agents.read(reader);
121
this._publishAgentInfos(agents, reader);
122
}));
123
124
// Server-dispatched SessionToolCallComplete actions (e.g. from
125
// the disconnect timeout in ProtocolServerHandler) bypass
126
// handleAction, so the agent's SDK deferred never resolves.
127
// Listen for these envelopes and notify the agent directly.
128
this._register(this._stateManager.onDidEmitEnvelope(envelope => {
129
if (!envelope.origin && envelope.action.type === ActionType.SessionToolCallComplete) {
130
const action = envelope.action;
131
const agent = this._options.getAgent(action.session);
132
agent?.onClientToolCallComplete(URI.parse(action.session), action.toolCallId, action.result);
133
}
134
}));
135
}
136
137
/**
138
* Publishes agent descriptors using the last known model lists.
139
*/
140
private _publishAgentInfos(agents: readonly IAgent[], reader?: IReader): void {
141
const infos: AgentInfo[] = agents.map(a => {
142
const d = a.getDescriptor();
143
const protectedResources = a.getProtectedResources();
144
const models = reader ? a.models.read(reader) : a.models.get();
145
const customizations = a.getCustomizations?.();
146
return {
147
provider: d.provider, displayName: d.displayName, description: d.description, models: models.map(m => ({
148
id: m.id,
149
provider: m.provider,
150
name: m.name,
151
maxContextWindow: m.maxContextWindow,
152
supportsVision: m.supportsVision,
153
policyState: m.policyState,
154
configSchema: m.configSchema,
155
})),
156
customizations: customizations?.length ? [...customizations] : undefined,
157
protectedResources: protectedResources.length > 0 ? protectedResources : undefined,
158
};
159
});
160
if (equals(this._lastAgentInfos, infos)) {
161
return;
162
}
163
this._lastAgentInfos = infos;
164
this._stateManager.dispatchServerAction({ type: ActionType.RootAgentsChanged, agents: infos });
165
}
166
167
private async _publishSessionCustomizations(agent: IAgent, session: ProtocolURI): Promise<void> {
168
if (!agent.getSessionCustomizations) {
169
return;
170
}
171
172
const customizations = await agent.getSessionCustomizations(URI.parse(session));
173
this._stateManager.dispatchServerAction({
174
type: ActionType.SessionCustomizationsChanged,
175
session,
176
customizations: [...customizations],
177
});
178
}
179
180
private _publishSessionCustomizationsSoon(agent: IAgent, session: ProtocolURI): void {
181
void this._publishSessionCustomizations(agent, session).catch(err => {
182
this._logService.error('[AgentSideEffects] getSessionCustomizations failed', err);
183
});
184
}
185
186
private _publishSessionCustomizationsForAgent(agent: IAgent): void {
187
for (const session of this._stateManager.getSessionUris()) {
188
if (this._options.getAgent(session) === agent) {
189
this._publishSessionCustomizationsSoon(agent, session);
190
}
191
}
192
}
193
194
private _publishAllSessionCustomizations(): void {
195
for (const session of this._stateManager.getSessionUris()) {
196
const agent = this._options.getAgent(session);
197
if (agent) {
198
this._publishSessionCustomizationsSoon(agent, session);
199
}
200
}
201
}
202
203
// ---- Initialization ----------------------------------------------------
204
205
/**
206
* Initializes async resources (tree-sitter WASM) used for command
207
* auto-approval. Await this before any session events can arrive to
208
* guarantee that auto-approval checks are fully synchronous.
209
*/
210
initialize(): Promise<void> {
211
return this._permissionManager.initialize();
212
}
213
214
// ---- Agent registration -------------------------------------------------
215
216
/**
217
* Registers a progress-signal listener on the given agent so that
218
* {@link AgentSignal}s are routed/dispatched through the state manager.
219
* Returns a disposable that removes the listener.
220
*/
221
registerProgressListener(agent: IAgent): IDisposable {
222
const disposables = new DisposableStore();
223
disposables.add(agent.onDidSessionProgress(signal => {
224
this._handleAgentSignal(agent, signal);
225
}));
226
if (agent.onDidCustomizationsChange) {
227
disposables.add(agent.onDidCustomizationsChange(() => {
228
this._publishAgentInfos(this._options.agents.get());
229
this._publishSessionCustomizationsForAgent(agent);
230
}));
231
}
232
return disposables;
233
}
234
235
/**
236
* Routes a single signal from `agent` to the correct session.
237
*
238
* Action signals with a `parentToolCallId` are routed to the matching
239
* subagent session. If the subagent session does not exist yet (the SDK
240
* can emit an inner `tool_start` before its `subagent_started`), the
241
* signal is buffered in {@link _pendingSubagentSignals} and replayed
242
* once the `subagent_started` arrives.
243
*/
244
private _handleAgentSignal(agent: IAgent, signal: AgentSignal): void {
245
const sessionKey = signal.session.toString();
246
247
// Track tool calls so handleAction can route confirmations. Defer
248
// registration for inner subagent tool calls until we know which
249
// subagent session they belong to — otherwise we'd register them
250
// under the parent session key and a later `pending_confirmation`
251
// (which lacks
252
// `parentToolCallId`) could be routed against the wrong session.
253
if (signal.kind === 'action'
254
&& signal.action.type === ActionType.SessionToolCallStart
255
&& !signal.parentToolCallId
256
) {
257
this._toolCallAgents.set(`${sessionKey}:${signal.action.toolCallId}`, agent.id);
258
}
259
260
if (signal.kind === 'subagent_started') {
261
this._handleSubagentStarted(sessionKey, signal.toolCallId, signal.agentName, signal.agentDisplayName, signal.agentDescription);
262
this._drainPendingSubagentSignals(sessionKey, signal.toolCallId);
263
return;
264
}
265
266
if (signal.kind === 'steering_consumed') {
267
this._stateManager.dispatchServerAction({
268
type: ActionType.SessionPendingMessageRemoved,
269
session: sessionKey,
270
kind: PendingMessageKind.Steering,
271
id: signal.id,
272
});
273
return;
274
}
275
276
// Route signals with parentToolCallId to the subagent session.
277
// Both action signals and pending_confirmation signals can carry
278
// a parentToolCallId — for client tools inside a subagent the
279
// permission flow fires `pending_confirmation` for an inner tool
280
// call, and that signal must be routed to the subagent session
281
// (otherwise the resulting SessionToolCallReady would land on the
282
// parent session, which has no matching SessionToolCallStart).
283
const parentToolCallId = signal.kind === 'action' || signal.kind === 'pending_confirmation'
284
? signal.parentToolCallId
285
: undefined;
286
if (parentToolCallId) {
287
const subagentKey = `${sessionKey}:${parentToolCallId}`;
288
const subagentSession = this._subagentSessions.get(subagentKey);
289
if (subagentSession) {
290
// Track tool calls in subagent context for confirmation routing.
291
if (signal.kind === 'action' && signal.action.type === ActionType.SessionToolCallStart) {
292
this._toolCallAgents.set(`${subagentSession}:${signal.action.toolCallId}`, agent.id);
293
}
294
const subTurnId = this._stateManager.getActiveTurnId(subagentSession);
295
if (subTurnId) {
296
this._dispatchActionForSession(signal, subagentSession, subTurnId, agent);
297
}
298
return;
299
}
300
301
// Subagent session does not exist yet — buffer the signal so we can
302
// replay it after `subagent_started` arrives.
303
this._logService.trace(`[AgentSideEffects] Buffering ${this._describeSignal(signal)} for pending subagent ${subagentKey}`);
304
let buffer = this._pendingSubagentSignals.get(subagentKey);
305
if (!buffer) {
306
buffer = [];
307
this._pendingSubagentSignals.set(subagentKey, buffer);
308
}
309
buffer.push({ signal, agent });
310
return;
311
}
312
313
// Route pending_confirmation signals for tools inside subagent sessions
314
// (legacy path for signals without an explicit parentToolCallId — the
315
// tool was previously registered under its subagent session key in
316
// _toolCallAgents).
317
if (signal.kind === 'pending_confirmation') {
318
const subagentSession = this._findSubagentSessionForToolCall(sessionKey, signal.state.toolCallId);
319
if (subagentSession) {
320
const subTurnId = this._stateManager.getActiveTurnId(subagentSession);
321
if (subTurnId) {
322
this._handleToolReady(signal, subagentSession, subTurnId, agent);
323
}
324
return;
325
}
326
}
327
328
const turnId = this._stateManager.getActiveTurnId(sessionKey);
329
if (turnId) {
330
this._dispatchActionForSession(signal, sessionKey, turnId, agent);
331
return;
332
}
333
334
// No active turn on the session. Most signals are silently dropped,
335
// but a `SessionTurnComplete` (idle) still needs to drive its
336
// post-turn side effects — flushing pending diff computation,
337
// recomputing diffs, and notifying the host. Tests routinely fire
338
// `idle` without first dispatching the matching `SessionTurnStarted`
339
// through the state manager.
340
if (signal.kind === 'action' && signal.action.type === ActionType.SessionTurnComplete) {
341
this._runTurnCompleteSideEffects(sessionKey, undefined);
342
}
343
}
344
345
/**
346
* Dispatches a signal against a resolved session+turn. Performs the
347
* subagent-content merge for tool_complete and the related side effects.
348
*/
349
private _dispatchActionForSession(signal: AgentSignal, sessionKey: ProtocolURI, turnId: string, agent?: IAgent): void {
350
if (signal.kind === 'pending_confirmation') {
351
if (agent) {
352
this._handleToolReady(signal, sessionKey, turnId, agent);
353
}
354
return;
355
}
356
if (signal.kind !== 'action') {
357
return;
358
}
359
// The agent emits actions with its own view of the active turnId
360
// targeting the top-level session. The state manager is the source
361
// of truth — rewrite `session` and `turnId` so the action lands in
362
// the right reducer (subagent session for routed signals, queued
363
// turn ID when the agent hasn't yet seen `sendMessage`, etc.).
364
// Actions without a `turnId` field (`SessionTitleChanged`,
365
// `SessionInputRequested`) only get their `session` rewritten.
366
let action = signal.action;
367
if (isSessionAction(action) && action.session !== sessionKey) {
368
action = { ...action, session: sessionKey };
369
}
370
if (hasKey(action, { turnId: true }) && action.turnId !== turnId) {
371
action = { ...action, turnId };
372
}
373
374
// When a parent tool call has an associated subagent session,
375
// preserve the subagent content metadata in the completion result.
376
// The SDK's tool_complete provides its own content which would
377
// overwrite the ToolResultSubagentContent that was set via
378
// SessionToolCallContentChanged while running.
379
if (action.type === ActionType.SessionToolCallComplete) {
380
const subagentKey = `${sessionKey}:${action.toolCallId}`;
381
const subagentUri = this._subagentSessions.get(subagentKey);
382
if (subagentUri) {
383
const parentState = this._stateManager.getSessionState(sessionKey);
384
const runningContent = this._getRunningToolCallContent(parentState, turnId, action.toolCallId);
385
const subagentEntry = runningContent.find(c => hasKey(c, { type: true }) && c.type === ToolResultContentType.Subagent);
386
if (subagentEntry) {
387
const mergedContent = [...(action.result.content ?? []), subagentEntry];
388
const merged: SessionToolCallCompleteAction = { ...action, result: { ...action.result, content: mergedContent } };
389
action = merged;
390
}
391
}
392
}
393
394
this._stateManager.dispatchServerAction(action);
395
396
if (action.type === ActionType.SessionToolCallComplete) {
397
this.completeSubagentSession(sessionKey, action.toolCallId);
398
if (getToolFileEdits(action.result).length > 0) {
399
this._scheduleDebouncedDiffComputation(sessionKey, turnId);
400
}
401
}
402
403
if (action.type === ActionType.SessionTurnComplete) {
404
this._runTurnCompleteSideEffects(sessionKey, turnId);
405
}
406
}
407
408
/**
409
* Post-turn side effects: flush any pending debounced diff computation,
410
* compute final diffs immediately, drain the next queued message, and
411
* notify the host so it can refresh git state.
412
*/
413
private _runTurnCompleteSideEffects(sessionKey: ProtocolURI, turnId: string | undefined): void {
414
this._cancelDebouncedDiffComputation(sessionKey);
415
this._computeSessionDiffs(sessionKey, turnId);
416
this._tryConsumeNextQueuedMessage(sessionKey);
417
this._options.onTurnComplete(sessionKey);
418
}
419
420
private _describeSignal(signal: AgentSignal): string {
421
return signal.kind === 'action' ? `action(${signal.action.type})` : signal.kind;
422
}
423
424
/**
425
* Replays any signals that were buffered while waiting for
426
* `subagent_started` to create the subagent session. Called immediately
427
* after `_handleSubagentStarted`.
428
*/
429
private _drainPendingSubagentSignals(parentSession: ProtocolURI, parentToolCallId: string): void {
430
const subagentKey = `${parentSession}:${parentToolCallId}`;
431
const buffer = this._pendingSubagentSignals.get(subagentKey);
432
if (!buffer) {
433
return;
434
}
435
this._pendingSubagentSignals.delete(subagentKey);
436
this._logService.trace(`[AgentSideEffects] Draining ${buffer.length} buffered signal(s) for subagent ${subagentKey}`);
437
for (const { signal, agent } of buffer) {
438
this._handleAgentSignal(agent, signal);
439
}
440
}
441
442
// ---- Subagent session management ----------------------------------------
443
444
/**
445
* Creates a subagent session in response to a `subagent_started` event.
446
* The subagent session is created silently (no `sessionAdded` notification)
447
* and immediately transitioned to ready with an active turn.
448
*/
449
private _handleSubagentStarted(
450
parentSession: ProtocolURI,
451
toolCallId: string,
452
agentName: string,
453
agentDisplayName: string,
454
agentDescription?: string,
455
): void {
456
const subagentSessionUri = buildSubagentSessionUri(parentSession, toolCallId);
457
const subagentKey = `${parentSession}:${toolCallId}`;
458
459
// Already tracking this subagent
460
if (this._subagentSessions.has(subagentKey)) {
461
return;
462
}
463
464
this._logService.info(`[AgentSideEffects] Creating subagent session: ${subagentSessionUri} (parent=${parentSession}, toolCallId=${toolCallId})`);
465
const parentState = this._stateManager.getSessionState(parentSession);
466
467
// Create the subagent session silently (restoreSession skips notification)
468
this._stateManager.restoreSession(
469
{
470
resource: subagentSessionUri,
471
provider: 'subagent',
472
title: agentDisplayName,
473
status: SessionStatus.Idle,
474
createdAt: Date.now(),
475
modifiedAt: Date.now(),
476
...(parentState?.summary.project ? { project: parentState.summary.project } : {}),
477
},
478
[],
479
);
480
481
// Start a turn on the subagent session
482
const turnId = generateUuid();
483
this._stateManager.dispatchServerAction({
484
type: ActionType.SessionTurnStarted,
485
session: subagentSessionUri,
486
turnId,
487
userMessage: { text: '' },
488
});
489
490
this._subagentSessions.set(subagentKey, subagentSessionUri);
491
492
// Dispatch content on the parent tool call so clients discover the subagent.
493
// Merge with any existing content to avoid dropping prior content blocks.
494
const parentTurnId = this._stateManager.getActiveTurnId(parentSession);
495
if (parentTurnId) {
496
const parentState = this._stateManager.getSessionState(parentSession);
497
const existingContent = this._getRunningToolCallContent(parentState, parentTurnId, toolCallId);
498
const mergedContent = [
499
...existingContent,
500
{
501
type: ToolResultContentType.Subagent as const,
502
resource: subagentSessionUri,
503
title: agentDisplayName,
504
agentName,
505
description: agentDescription,
506
},
507
];
508
this._stateManager.dispatchServerAction({
509
type: ActionType.SessionToolCallContentChanged,
510
session: parentSession,
511
turnId: parentTurnId,
512
toolCallId,
513
content: mergedContent,
514
});
515
}
516
}
517
518
/**
519
* Gets the current content array from a running tool call, if any.
520
*/
521
private _getRunningToolCallContent(
522
state: SessionState | undefined,
523
turnId: string,
524
toolCallId: string,
525
): ToolResultContent[] {
526
if (!state?.activeTurn || state.activeTurn.id !== turnId) {
527
return [];
528
}
529
for (const rp of state.activeTurn.responseParts) {
530
if (rp.kind === ResponsePartKind.ToolCall && rp.toolCall.toolCallId === toolCallId && rp.toolCall.status === ToolCallStatus.Running) {
531
return rp.toolCall.content ? [...rp.toolCall.content] : [];
532
}
533
}
534
return [];
535
}
536
537
/**
538
* Cancels all active subagent sessions for a given parent session.
539
*/
540
cancelSubagentSessions(parentSession: ProtocolURI): void {
541
for (const [key, subagentUri] of this._subagentSessions) {
542
if (key.startsWith(`${parentSession}:`)) {
543
const turnId = this._stateManager.getActiveTurnId(subagentUri);
544
if (turnId) {
545
this._stateManager.dispatchServerAction({
546
type: ActionType.SessionTurnCancelled,
547
session: subagentUri,
548
turnId,
549
});
550
}
551
this._subagentSessions.delete(key);
552
}
553
}
554
// Drop any buffered events targeted at subagents that never started.
555
for (const key of [...this._pendingSubagentSignals.keys()]) {
556
if (key.startsWith(`${parentSession}:`)) {
557
this._pendingSubagentSignals.delete(key);
558
}
559
}
560
}
561
562
/**
563
* Completes all active subagent sessions for a given parent session.
564
* Called when a parent tool call completes.
565
*/
566
completeSubagentSession(parentSession: ProtocolURI, toolCallId: string): void {
567
const key = `${parentSession}:${toolCallId}`;
568
569
// Drop any events that were buffered waiting for a `subagent_started`
570
// that never arrived (e.g. the parent tool failed before the subagent
571
// was created). Without this, the buffer entry would leak until the
572
// parent session is disposed.
573
this._pendingSubagentSignals.delete(key);
574
575
const subagentUri = this._subagentSessions.get(key);
576
if (!subagentUri) {
577
return;
578
}
579
580
const turnId = this._stateManager.getActiveTurnId(subagentUri);
581
if (turnId) {
582
this._stateManager.dispatchServerAction({
583
type: ActionType.SessionTurnComplete,
584
session: subagentUri,
585
turnId,
586
});
587
}
588
this._subagentSessions.delete(key);
589
}
590
591
/**
592
* Removes all subagent sessions for a given parent session from
593
* the state manager. Called when the parent session is disposed.
594
*/
595
removeSubagentSessions(parentSession: ProtocolURI): void {
596
const toRemove: string[] = [];
597
for (const [key, subagentUri] of this._subagentSessions) {
598
if (key.startsWith(`${parentSession}:`)) {
599
this._stateManager.removeSession(subagentUri);
600
toRemove.push(key);
601
}
602
}
603
for (const key of toRemove) {
604
this._subagentSessions.delete(key);
605
}
606
607
// Also clean up any subagent sessions that are in the state manager
608
// but not tracked (e.g. restored sessions)
609
const prefix = `${parentSession}/subagent/`;
610
for (const uri of this._stateManager.getSessionUrisWithPrefix(prefix)) {
611
this._stateManager.removeSession(uri);
612
}
613
614
// Drop any buffered events targeted at subagents that never started.
615
for (const key of [...this._pendingSubagentSignals.keys()]) {
616
if (key.startsWith(`${parentSession}:`)) {
617
this._pendingSubagentSignals.delete(key);
618
}
619
}
620
}
621
622
/**
623
* Finds the subagent session that owns a given tool call by checking
624
* whether the tool call was previously registered under a subagent
625
* session key in `_toolCallAgents`. Scoped to subagent sessions owned
626
* by the given parent to avoid cross-session collisions.
627
*/
628
private _findSubagentSessionForToolCall(parentSession: ProtocolURI, toolCallId: string): ProtocolURI | undefined {
629
const prefix = `${parentSession}:`;
630
for (const [key, subagentUri] of this._subagentSessions) {
631
if (key.startsWith(prefix) && this._toolCallAgents.has(`${subagentUri}:${toolCallId}`)) {
632
return subagentUri;
633
}
634
}
635
return undefined;
636
}
637
638
// ---- Side-effect handlers --------------------------------------------------
639
640
/**
641
* Handles a `pending_confirmation` signal end-to-end: checks for
642
* auto-approval via the permission manager, and if not auto-approved,
643
* dispatches the `SessionToolCallReady` action with confirmation options
644
* for the client.
645
*/
646
private _handleToolReady(e: IAgentToolPendingConfirmationSignal, sessionKey: ProtocolURI, turnId: string, agent: IAgent): void {
647
const approvalEvent = {
648
toolCallId: e.state.toolCallId,
649
session: e.session,
650
permissionKind: e.permissionKind,
651
permissionPath: e.permissionPath,
652
toolInput: e.state.toolInput,
653
};
654
const autoApproval = this._permissionManager.getAutoApproval(approvalEvent, sessionKey);
655
let effective = e;
656
if (autoApproval !== undefined) {
657
this._toolCallAgents.delete(`${sessionKey}:${e.state.toolCallId}`);
658
agent.respondToPermissionRequest(e.state.toolCallId, true);
659
// Strip confirmationTitle so createToolReadyAction emits the
660
// auto-approved (no-options) action.
661
effective = { ...e, state: { ...e.state, confirmationTitle: undefined } };
662
}
663
this._stateManager.dispatchServerAction(
664
this._permissionManager.createToolReadyAction(effective, sessionKey, turnId)
665
);
666
}
667
668
handleAction(action: StateAction): void {
669
switch (action.type) {
670
case ActionType.SessionTurnStarted: {
671
// Per-turn streaming part tracking is owned by the agent
672
// (e.g. CopilotAgentSession) and reset on its `send()` call.
673
674
// On the very first turn, immediately set the session title to the
675
// user's message so the UI shows a meaningful title right away
676
// while waiting for the AI-generated title. Only apply when the
677
// title is still the default placeholder to avoid clobbering a
678
// title set by the user or provider before the first turn.
679
const state = this._stateManager.getSessionState(action.session);
680
const fallbackTitle = action.userMessage.text.trim().replace(/\s+/g, ' ').slice(0, 200);
681
if (state && state.turns.length === 0 && !state.summary.title && fallbackTitle.length > 0) {
682
this._stateManager.dispatchServerAction({
683
type: ActionType.SessionTitleChanged,
684
session: action.session,
685
title: fallbackTitle,
686
});
687
}
688
689
const agent = this._options.getAgent(action.session);
690
if (!agent) {
691
this._stateManager.dispatchServerAction({
692
type: ActionType.SessionError,
693
session: action.session,
694
turnId: action.turnId,
695
error: { errorType: 'noAgent', message: 'No agent found for session' },
696
});
697
return;
698
}
699
const attachments = action.userMessage.attachments?.map((a): IAgentAttachment => ({
700
type: a.type,
701
uri: URI.parse(a.uri),
702
displayName: a.displayName,
703
}));
704
agent.sendMessage(URI.parse(action.session), action.userMessage.text, attachments, action.turnId).catch(err => {
705
const errCode = (err as { code?: number })?.code;
706
this._logService.error(`[AgentSideEffects] sendMessage failed for session=${action.session}: code=${errCode}, message=${err instanceof Error ? err.message : String(err)}, type=${err?.constructor?.name}`, err);
707
this._stateManager.dispatchServerAction({
708
type: ActionType.SessionError,
709
session: action.session,
710
turnId: action.turnId,
711
error: { errorType: 'sendFailed', message: String(err) },
712
});
713
});
714
break;
715
}
716
case ActionType.SessionToolCallConfirmed: {
717
const toolCallKey = `${action.session}:${action.toolCallId}`;
718
const agentId = this._toolCallAgents.get(toolCallKey);
719
if (agentId) {
720
this._toolCallAgents.delete(toolCallKey);
721
const agent = this._options.agents.get().find(a => a.id === agentId);
722
agent?.respondToPermissionRequest(action.toolCallId, action.approved);
723
} else {
724
this._logService.warn(`[AgentSideEffects] No agent for tool call confirmation: ${action.toolCallId}`);
725
}
726
727
// When the user chose "Allow in this Session", add the tool
728
// to the session's permissions so future calls are auto-approved.
729
if (action.approved) {
730
this._permissionManager.handleToolCallConfirmed(action.session, action.toolCallId, action.selectedOptionId);
731
}
732
break;
733
}
734
case ActionType.SessionInputCompleted: {
735
const agent = this._options.getAgent(action.session);
736
agent?.respondToUserInputRequest(action.requestId, action.response, action.answers);
737
break;
738
}
739
case ActionType.SessionTurnCancelled: {
740
// Cancel all subagent sessions for this parent
741
this.cancelSubagentSessions(action.session);
742
const agent = this._options.getAgent(action.session);
743
agent?.abortSession(URI.parse(action.session)).catch(err => {
744
this._logService.error('[AgentSideEffects] abortSession failed', err);
745
});
746
break;
747
}
748
case ActionType.SessionModelChanged: {
749
const agent = this._options.getAgent(action.session);
750
agent?.changeModel?.(URI.parse(action.session), action.model).catch(err => {
751
this._logService.error('[AgentSideEffects] changeModel failed', err);
752
});
753
break;
754
}
755
case ActionType.SessionTitleChanged: {
756
this._persistSessionFlag(action.session, 'customTitle', action.title);
757
break;
758
}
759
case ActionType.SessionPendingMessageSet:
760
case ActionType.SessionPendingMessageRemoved:
761
case ActionType.SessionQueuedMessagesReordered: {
762
this._syncPendingMessages(action.session);
763
break;
764
}
765
case ActionType.SessionTruncated: {
766
const agent = this._options.getAgent(action.session);
767
agent?.truncateSession?.(URI.parse(action.session), action.turnId).catch(err => {
768
this._logService.error('[AgentSideEffects] truncateSession failed', err);
769
});
770
// Turns were removed — recompute diffs from scratch (no changedTurnId)
771
this._computeSessionDiffs(action.session);
772
break;
773
}
774
case ActionType.SessionActiveClientChanged: {
775
const agent = this._options.getAgent(action.session);
776
if (!agent) {
777
break;
778
}
779
// Always forward client tools, even if empty, to clear previous client's tools
780
const clientId = action.activeClient?.clientId ?? '';
781
agent.setClientTools(URI.parse(action.session), clientId, action.activeClient?.tools ?? []);
782
783
const refs = action.activeClient?.customizations ?? [];
784
agent.setClientCustomizations(
785
clientId,
786
refs,
787
() => {
788
this._publishSessionCustomizationsSoon(agent, action.session);
789
},
790
).then(() => {
791
this._publishSessionCustomizationsSoon(agent, action.session);
792
}).catch(err => {
793
this._logService.error('[AgentSideEffects] setClientCustomizations failed', err);
794
});
795
break;
796
}
797
case ActionType.RootConfigChanged: {
798
// Host customizations are self-managed by each agent's
799
// PluginController via IAgentConfigurationService.onDidRootConfigChange.
800
// Republish agent infos for non-customization schema changes
801
// (e.g. permissions) and session customizations as a catchall.
802
this._publishAgentInfos(this._options.agents.get());
803
this._publishAllSessionCustomizations();
804
break;
805
}
806
case ActionType.SessionActiveClientToolsChanged: {
807
const agent = this._options.getAgent(action.session);
808
if (agent) {
809
const sessionState = this._stateManager.getSessionState(action.session);
810
const toolClientId = sessionState?.activeClient?.clientId;
811
if (toolClientId) {
812
agent.setClientTools(URI.parse(action.session), toolClientId, action.tools);
813
}
814
}
815
break;
816
}
817
case ActionType.SessionCustomizationToggled: {
818
const agent = this._options.getAgent(action.session);
819
agent?.setCustomizationEnabled?.(action.uri, action.enabled);
820
break;
821
}
822
case ActionType.SessionIsReadChanged: {
823
this._persistSessionFlag(action.session, 'isRead', action.isRead ? 'true' : '');
824
break;
825
}
826
case ActionType.SessionIsArchivedChanged: {
827
this._persistSessionFlag(action.session, 'isArchived', action.isArchived ? 'true' : '');
828
const agent = this._options.getAgent(action.session);
829
agent?.onArchivedChanged?.(URI.parse(action.session), action.isArchived).catch(err => {
830
this._logService.warn(`[AgentSideEffects] onArchivedChanged failed for ${action.session}`, err);
831
});
832
break;
833
}
834
case ActionType.SessionConfigChanged: {
835
// Persist merged values so a future `restoreSession` can re-hydrate
836
// the user's previous selections (e.g. autoApprove).
837
const sessionState = this._stateManager.getSessionState(action.session);
838
const values = sessionState?.config?.values;
839
if (values) {
840
this._persistSessionFlag(action.session, 'configValues', JSON.stringify(values));
841
}
842
break;
843
}
844
case ActionType.SessionToolCallComplete: {
845
const agent = this._options.getAgent(action.session);
846
agent?.onClientToolCallComplete(URI.parse(action.session), action.toolCallId, action.result);
847
break;
848
}
849
}
850
}
851
852
/**
853
* Persists a session metadata key/value pair to the session database.
854
* Used for fields the host needs to remember across restarts (custom
855
* title, isRead/isArchived flags, merged config values).
856
*/
857
private _persistSessionFlag(session: ProtocolURI, key: string, value: string): void {
858
const ref = this._options.sessionDataService.openDatabase(URI.parse(session));
859
ref.object.setMetadata(key, value).catch(err => {
860
this._logService.warn(`[AgentSideEffects] Failed to persist ${key}`, err);
861
}).finally(() => {
862
ref.dispose();
863
});
864
}
865
866
/**
867
* Pushes the current pending message state from the session to the agent.
868
* The server controls queued message consumption; only steering messages
869
* are forwarded to the agent for mid-turn injection.
870
*/
871
private _syncPendingMessages(session: ProtocolURI): void {
872
const state = this._stateManager.getSessionState(session);
873
if (!state) {
874
return;
875
}
876
const agent = this._options.getAgent(session);
877
agent?.setPendingMessages?.(
878
URI.parse(session),
879
state.steeringMessage,
880
[],
881
);
882
883
// Steering message removal is now dispatched by the agent
884
// via the 'steering_consumed' progress event once the message
885
// has actually been sent to the model.
886
887
// If the session is idle, try to consume the next queued message
888
this._tryConsumeNextQueuedMessage(session);
889
}
890
891
/**
892
* Consumes the next queued message by dispatching a server-initiated
893
* `SessionTurnStarted` action with `queuedMessageId` set. The reducer
894
* atomically creates the active turn and removes the message from the
895
* queue. Only consumes one message at a time; subsequent messages are
896
* consumed when the next `idle` event fires.
897
*/
898
private _tryConsumeNextQueuedMessage(session: ProtocolURI): void {
899
// Bail if there's already an active turn
900
if (this._stateManager.getActiveTurnId(session)) {
901
return;
902
}
903
const state = this._stateManager.getSessionState(session);
904
if (!state?.queuedMessages?.length) {
905
return;
906
}
907
908
const msg = state.queuedMessages[0];
909
const turnId = generateUuid();
910
911
// Per-turn streaming part tracking is owned by the agent (reset
912
// inside its `send()` call), so no host-side reset is needed.
913
914
// Dispatch server-initiated turn start; the reducer removes the queued message atomically
915
this._stateManager.dispatchServerAction({
916
type: ActionType.SessionTurnStarted,
917
session,
918
turnId,
919
userMessage: msg.userMessage,
920
queuedMessageId: msg.id,
921
});
922
923
// Send the message to the agent backend
924
const agent = this._options.getAgent(session);
925
if (!agent) {
926
this._stateManager.dispatchServerAction({
927
type: ActionType.SessionError,
928
session,
929
turnId,
930
error: { errorType: 'noAgent', message: 'No agent found for session' },
931
});
932
return;
933
}
934
const attachments = msg.userMessage.attachments?.map((a): IAgentAttachment => ({
935
type: a.type,
936
uri: URI.parse(a.uri),
937
displayName: a.displayName,
938
}));
939
agent.sendMessage(URI.parse(session), msg.userMessage.text, attachments, turnId).catch(err => {
940
this._logService.error('[AgentSideEffects] sendMessage failed (queued)', err);
941
this._stateManager.dispatchServerAction({
942
type: ActionType.SessionError,
943
session,
944
turnId,
945
error: { errorType: 'sendFailed', message: String(err) },
946
});
947
});
948
}
949
950
// ---- Session diff computation ----------------------------------------------
951
952
/**
953
* Schedules a debounced diff computation for a session. If a timer is
954
* already pending for this session, it is replaced (restarting the delay).
955
* The computation fires after {@link _DIFF_DEBOUNCE_MS} unless cancelled
956
* or flushed by the turn-complete handler.
957
*/
958
private _scheduleDebouncedDiffComputation(session: ProtocolURI, turnId: string): void {
959
// DisposableMap.set() auto-disposes any previous timer for this session
960
this._debouncedDiffTimers.set(session, disposableTimeout(() => {
961
this._debouncedDiffTimers.deleteAndDispose(session);
962
this._computeSessionDiffs(session, turnId);
963
}, AgentSideEffects._DIFF_DEBOUNCE_MS));
964
}
965
966
/**
967
* Cancels any pending debounced diff computation for a session.
968
* Called at turn end before the final (non-debounced) computation.
969
*/
970
private _cancelDebouncedDiffComputation(session: ProtocolURI): void {
971
this._debouncedDiffTimers.deleteAndDispose(session);
972
}
973
974
/**
975
* Asynchronously (re)computes aggregated diff statistics for a session
976
* and dispatches {@link ActionType.SessionDiffsChanged} to update the
977
* session summary. Fire-and-forget: errors are logged but do not fail
978
* the turn.
979
*/
980
private _computeSessionDiffs(session: ProtocolURI, changedTurnId?: string): void {
981
// Chain onto any pending computation for this session to ensure
982
// sequential access to previousDiffs (avoids stale-read races).
983
this._diffComputationSequencer.queue(session, () => this._doComputeSessionDiffs(session, changedTurnId));
984
}
985
986
private async _doComputeSessionDiffs(session: ProtocolURI, changedTurnId?: string): Promise<void> {
987
let ref: ReturnType<ISessionDataService['openDatabase']>;
988
try {
989
ref = this._options.sessionDataService.openDatabase(URI.parse(session));
990
} catch (err) {
991
this._logService.warn(`[AgentSideEffects] Failed to open session database for diff computation: ${session}`, err);
992
return;
993
}
994
try {
995
// Prefer a git-driven diff so terminal-driven file changes show up
996
// alongside SDK-tracked tool edits. The git path is the source of
997
// truth whenever the working directory is a real work tree; we
998
// only fall back to the edit-tracker aggregator when it isn't
999
// (e.g. agents running in non-git scratch directories or under
1000
// test harnesses without git).
1001
let diffs = await this._tryComputeGitDiffs(session, ref.object);
1002
if (!diffs) {
1003
// Build incremental options when a specific turn triggered the recomputation
1004
let incremental: IIncrementalDiffOptions | undefined;
1005
if (changedTurnId) {
1006
const previousDiffs = this._stateManager.getSessionState(session)?.summary.diffs;
1007
if (previousDiffs) {
1008
incremental = { changedTurnId, previousDiffs };
1009
}
1010
}
1011
diffs = await computeSessionDiffs(session, ref.object, this._diffComputeService, incremental);
1012
}
1013
1014
this._stateManager.dispatchServerAction({
1015
type: ActionType.SessionDiffsChanged,
1016
session,
1017
diffs: [...diffs],
1018
});
1019
// Persist diffs to the session database so they survive restarts
1020
ref.object.setMetadata('diffs', JSON.stringify(diffs)).catch(err => {
1021
this._logService.warn('[AgentSideEffects] Failed to persist session diffs', err);
1022
});
1023
} catch (err) {
1024
this._logService.warn('[AgentSideEffects] Failed to compute session diffs', err);
1025
} finally {
1026
ref.dispose();
1027
}
1028
}
1029
1030
/**
1031
* Computes session diffs by shelling out to git. Returns the diff list
1032
* when the session has a working directory and that directory is a git
1033
* work tree; returns `undefined` otherwise so the caller can fall back
1034
* to the edit-tracker aggregator. The base branch (anchor for the
1035
* `merge-base` baseline) is read from the provider-agnostic
1036
* {@link META_DIFF_BASE_BRANCH} metadata key — agents that create
1037
* worktrees write it at session-creation time.
1038
*/
1039
private async _tryComputeGitDiffs(session: ProtocolURI, db: ISessionDatabase): Promise<readonly ISessionFileDiff[] | undefined> {
1040
const workingDirectory = this._stateManager.getSessionState(session)?.summary.workingDirectory;
1041
if (!workingDirectory) {
1042
return undefined;
1043
}
1044
let workingDirectoryUri: URI;
1045
try {
1046
workingDirectoryUri = URI.parse(workingDirectory);
1047
} catch {
1048
return undefined;
1049
}
1050
const baseBranch = (await db.getMetadata(META_DIFF_BASE_BRANCH)) ?? undefined;
1051
try {
1052
return await this._gitService.computeSessionFileDiffs(workingDirectoryUri, { sessionUri: session, baseBranch });
1053
} catch (err) {
1054
this._logService.warn('[AgentSideEffects] git-driven diff computation failed; falling back to edit-tracker', err);
1055
return undefined;
1056
}
1057
}
1058
1059
override dispose(): void {
1060
this._toolCallAgents.clear();
1061
super.dispose();
1062
}
1063
}
1064
1065