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.
/*---------------------------------------------------------------------------------------------
agentSideEffects.ts ×51
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { getErrorCode } from '../../../base/common/errors.js';
import { Disposable, DisposableStore, IDisposable } from '../../../base/common/lifecycle.js';
import { NKeyMap } from '../../../base/common/map.js';
import { equals } from '../../../base/common/objects.js';
import { autorun, IObservable, IReader } from '../../../base/common/observable.js';
import { StopWatch } from '../../../base/common/stopwatch.js';
import { hasKey } from '../../../base/common/types.js';
import { URI } from '../../../base/common/uri.js';
import { generateUuid } from '../../../base/common/uuid.js';
import { IInstantiationService } from '../../instantiation/common/instantiation.js';
import { ILogService } from '../../log/common/log.js';
import { IAgentHostChangesetService } from '../common/agentHostChangesetService.js';
import { IAgentHostCheckpointService } from '../common/agentHostCheckpointService.js';
import { readAgentModelByokIdentifier } from '../common/agentModelByokMeta.js';
import { AgentSession, AgentSignal, IAgent, IAgentToolPendingConfirmationSignal } from '../common/agentService.js';
import { readToolCallMeta, toToolCallMeta } from '../common/meta/agentToolCallMeta.js';
import { ITelemetryService } from '../../telemetry/common/telemetry.js';
import { ISessionDataService } from '../common/sessionDataService.js';
import { SessionConfigKey } from '../common/sessionConfigKeys.js';
import { resolveChatAttachment } from '../common/state/chatAttachmentContext.js';
import { SessionInputRequestKind, ToolCallContributorKind, type AgentInfo, type SessionInputRequest } from '../common/state/protocol/state.js';
import { ActionType, isChatAction, StateAction, type ChatAction, type ChatToolCallCompleteAction } from '../common/state/sessionActions.js';
import {
buildSubagentChatUri,
getToolFileEdits,
isAhpChatChannel,
isDefaultChatUri,
isSubagentChatUri,
isChatReadOnly,
AH_META_IS_ARCHIVED_DB_KEY,
MessageAttachmentKind,
MessageKind,
parseChatUri,
parseRequiredSessionUriFromChatUri,
PendingMessageKind,
ResponsePartKind,
ROOT_STATE_URI,
SessionLifecycle,
SessionStatus,
ToolCallStatus,
ToolResultContentType,
type ErrorInfo,
type ISessionWithDefaultChat,
type Message,
type MessageAttachment,
type URI as ProtocolURI,
type SessionState,
type ToolCallState,
type ToolCallResult,
type ToolResultContent,
type Turn
} from '../common/state/sessionState.js';
import { AgentHostLocalTurns } from './agentHostLocalTurns.js';
import { AgentHostSessionTitleController } from './agentHostSessionTitleController.js';
import { AgentHostStateManager } from './agentHostStateManager.js';
import { AgentHostTelemetryReporter, type AgentHostModelTelemetryKind, type AgentHostTurnFailureStage, type IAgentHostTurnFailure } from './agentHostTelemetryReporter.js';
import { AgentHostToolCallTracker } from './agentHostToolCallTracker.js';
import { updateAgentHostTelemetryLevelFromConfig } from './agentHostTelemetryService.js';
import { AgentHostTurnTracker } from './agentHostTurnTracker.js';
import { AgentHostLocalCommands } from './localCommands/localChatCommand.js';
import './localCommands/localChatCommands.contribution.js';
import { SessionPermissionManager } from './sessionPermissions.js';
import type { ICopilotApiService } from './shared/copilotApiService.js';
import { stripProxyErrorMarker, toChatErrorMeta, tryParseForwardedChatError } from './shared/forwardedChatError.js';
import { persistSessionMetadata } from './shared/persistSessionMetadata.js';
import type { WorktreeIsolation } from './shared/worktreeIsolation.js';
/**
* Options for constructing an {@link AgentSideEffects} instance.
*/
export interface IAgentSideEffectsOptions {
/** Resolve the agent responsible for a given session URI. */
readonly getAgent: (session: ProtocolURI) => IAgent | undefined;
/** Observable set of registered agents. Triggers `root/agentsChanged` when it changes. */
readonly agents: IObservable<readonly IAgent[]>;
/** Session data service for cleaning up per-session data on disposal. */
readonly sessionDataService: ISessionDataService;
/** Registry that persists host-injected `/rename` and `!command` turns. */
readonly localTurns: AgentHostLocalTurns;
/** Get the GitHub token used for Copilot utility title generation. */
readonly getGitHubCopilotToken?: () => string | undefined;
/** CAPI service used for Copilot utility title generation. */
readonly copilotApiService?: ICopilotApiService;
/**
* Host-owned working-directory resolution hook, awaited before the agent's
* first send so the session's working directory (an isolated worktree created
* on the first send, or the picked folder) is resolved before the agent
* materializes and its cwd is locked. Resolves to the working directory to
* hand the agent, or `undefined` for workspace-less sessions. Provided by
* {@link AgentService}.
*/
readonly resolveWorkingDirectoryBeforeSend?: (params: { session: ProtocolURI; chat: ProtocolURI; turnId: string; prompt: string }) => Promise<URI | undefined>;
/** Resolves a referenced chat's turns, hydrating its owning session when needed. */
readonly resolveChatAttachmentTurns?: (resource: ProtocolURI) => Promise<readonly Turn[]>;
/**
* Called after each top-level session turn completes so git state can be
* refreshed and published via `SessionMetaChanged`. Subagent turns are
* excluded — only the parent session URI is passed.
*/
readonly onTurnComplete: (session: ProtocolURI) => void;
}
/** A signal that was deferred because its subagent session does not exist yet. */
interface IPendingSubagentSignal {
readonly signal: AgentSignal;
readonly agent: IAgent;
}
interface ISubagentSessionRef {
readonly parentChatUri: ProtocolURI;
readonly toolCallId: string;
readonly sessionUri: ProtocolURI;
readonly chatUri: ProtocolURI;
readonly turnStopWatch: StopWatch;
}
type AgentSignalTurnIdRouting = 'preserve' | 'remap';
/**
* Shared implementation of agent side-effect handling.
*
* Routes client-dispatched actions to the correct agent backend,
* restores sessions from previous lifetimes, handles filesystem
* operations (browse/fetch/write), tracks pending permission requests,
* and wires up agent progress events to the state manager.
*
* Session create/dispose/list and auth are handled by {@link AgentService}.
*/
export class AgentSideEffects extends Disposable {
/** Maps tool call IDs to the agent that owns them, for routing confirmations. */
private readonly _toolCallAgents = new Map<string, string>();
private _lastAgentInfos: readonly AgentInfo[] = [];
private readonly _permissionManager: SessionPermissionManager;
/** Registry-driven dispatcher for host-handled `/rename` / `!command` etc. */
private readonly _localCommands: AgentHostLocalCommands;
private readonly _subagentChats = new NKeyMap<ISubagentSessionRef, [ProtocolURI, string]>();
private readonly _cancelledTurnIds = new Map<ProtocolURI, Set<string>>();
/**
* Buffers signals whose `parentToolCallId` references a subagent
* whose `subagent_started` signal has not yet been processed. The SDK is
* not strict about ordering: an inner `tool_start` can arrive before the
* `subagent_started` that creates the child session. Without buffering,
* those signals would be dispatched against the parent session and the
* UI would render the inner tool calls flat at the top level rather than
* grouping them under the subagent. Drained by `_handleSubagentStarted`.
*
*/
private readonly _pendingSubagentSignals = new NKeyMap<IPendingSubagentSignal[], [ProtocolURI, string]>();
private readonly _telemetryReporter: AgentHostTelemetryReporter;
private readonly _turnTracker: AgentHostTurnTracker;
private readonly _toolCallTracker: AgentHostToolCallTracker;
private readonly _titleController: AgentHostSessionTitleController;
/** Host-owned worktree isolation controller; injected post-construction. */
private _worktree: WorktreeIsolation | undefined;
constructor(
private readonly _options: IAgentSideEffectsOptions,
@IInstantiationService instantiationService: IInstantiationService,
@ILogService private readonly _logService: ILogService,
@IAgentHostChangesetService private readonly _changesets: IAgentHostChangesetService,
@ITelemetryService private readonly _telemetryService: ITelemetryService,
@IAgentHostCheckpointService private readonly _checkpointService: IAgentHostCheckpointService,
) {
super();
this._telemetryReporter = new AgentHostTelemetryReporter(this._telemetryService);
this._turnTracker = new AgentHostTurnTracker(this._telemetryReporter);
this._toolCallTracker = this._register(new AgentHostToolCallTracker(this._telemetryReporter));
this._permissionManager = this._register(instantiationService.createInstance(SessionPermissionManager, this._stateManager, {}));
this._localCommands = this._register(instantiationService.createInstance(
AgentHostLocalCommands,
this._stateManager,
this._options.localTurns,
// Draining the queue re-enters agent lookup / telemetry / sendMessage,
// which is this class's responsibility, so the dispatcher hands the
// turn back here once it has completed a host-handled command.
(turnChannel: ProtocolURI) => this._tryConsumeNextQueuedMessage(turnChannel),
));
this._titleController = this._register(instantiationService.createInstance(AgentHostSessionTitleController, this._stateManager, {
sessionDataService: this._options.sessionDataService,
getGitHubCopilotToken: this._options.getGitHubCopilotToken,
copilotApiService: this._options.copilotApiService,
}));
// Whenever the agents observable changes, publish to root state.
this._register(autorun(reader => {
const agents = this._options.agents.read(reader);
this._publishAgentInfos(agents, reader);
}));
// Server-dispatched ChatToolCallComplete actions (e.g. from
// the disconnect timeout in ProtocolServerHandler) bypass
// handleAction, so the agent's SDK deferred never resolves.
// Listen for these envelopes and notify the agent directly.
this._register(this._stateManager.onDidEmitEnvelope(envelope => {
if (isAhpChatChannel(envelope.channel) && isChatAction(envelope.action)) {
agentSideEffects.ts ×3
if (!turnIds) {
turnIds = new Set();
this._cancelledTurnIds.set(envelope.channel, turnIds);
}
turnIds.add(envelope.action.turnId);
}
this._syncSessionInputNeededForChatAction(envelope.channel, envelope.action);
agentSideEffects.ts ×8
}
if (!envelope.origin && envelope.action.type === ActionType.ChatToolCallComplete) {
agentSideEffects.ts ×3
// Chat-action envelopes are emitted on the chat channel URI;
// agents are keyed by session URI, so resolve back to the
// owning session before notifying the agent. Pass the chat URI
// alongside so agents that track peer chats can route correctly.
if (!isAhpChatChannel(envelope.channel)) {
return; // Not a chat channel; ignore (already logged elsewhere).
}
const sessionChannel = parseRequiredSessionUriFromChatUri(envelope.channel);
agentSideEffects.ts ×2
this._notifyClientToolCallComplete(sessionChannel, envelope.channel, action.toolCallId, action.result, 'server-envelope');
}
}
}
/**
* Publishes agent descriptors using the last known model lists.
*/
private _publishAgentInfos(agents: readonly IAgent[], reader?: IReader): void {
const protectedResources = a.getProtectedResources();
const models = reader ? a.models.read(reader) : a.models.get();
const customizations = a.getCustomizations?.();
return {
provider: d.provider, displayName: d.displayName, description: d.description, models: models.map(m => ({
provider: m.provider,
name: m.name,
maxContextWindow: m.maxContextWindow,
maxOutputTokens: m.maxOutputTokens,
maxPromptTokens: m.maxPromptTokens,
supportsVision: m.supportsVision,
policyState: m.policyState,
configSchema: m.configSchema,
_meta: m._meta,
customizations: customizations?.length ? [...customizations] : undefined,
protectedResources: protectedResources.length > 0 ? protectedResources : undefined,
capabilities: d.capabilities ? { ...d.capabilities } : undefined,
};
if (equals(this._lastAgentInfos, infos)) {
}
this._stateManager.dispatchServerAction(ROOT_STATE_URI, { type: ActionType.RootAgentsChanged, agents: infos });
private async _publishSessionCustomizations(agent: IAgent, session: ProtocolURI): Promise<void> {
}
const customizations = await agent.getSessionCustomizations(URI.parse(session));
// Skip the dispatch when the resolved customizations match what the
// session state already holds. A single edit under a shared `~/.claude`
// tree fans out to every open session (and, via the agent-level
// `onDidCustomizationsChange`, is republished once per session), so
// without this guard a single change emitted O(N^2) identical
// `SessionCustomizationsChanged` envelopes. Comparing against the
// authoritative session state (rather than a side cache) keeps this
// correct across idle-eviction + restore: a restored session's state
// starts without customizations, so the first successful refresh always
// dispatches even if the resolved set matches the prior incarnation.
// It also needs no cleanup on session teardown. `undefined` (never
// published) never equals a resolved array, so the initial publish
// always goes through.
const current = this._stateManager.getSessionState(session)?.customizations;
}
this._stateManager.dispatchServerAction(session, {
type: ActionType.SessionCustomizationsChanged,
customizations: [...customizations],
});
private _publishSessionCustomizationsSoon(agent: IAgent, session: ProtocolURI): void {
this._logService.error('[AgentSideEffects] getSessionCustomizations failed', err);
}
private _publishSessionCustomizationsForAgent(agent: IAgent): void {
if (this._options.getAgent(session) === agent) {
this._publishSessionCustomizationsSoon(agent, session);
}
}
}
private _publishAllSessionCustomizations(): void {
if (agent) {
this._publishSessionCustomizationsSoon(agent, session);
}
}
// ---- Session input-needed aggregation ----------------------------------
//
// Mirrors per-chat blockers (user-input elicitations, tool confirmations,
// client-tool executions, and MCP authentication) into the owning session's
// `inputNeeded` list so clients subscribed only to the session channel can
// discover and answer them without subscribing to each chat. This handler
// only produces the state; it does not consume it.
private _syncSessionInputNeededForChatAction(chatUri: ProtocolURI, action: ChatAction): void {
case ActionType.ChatInputRequested:
break;
break;
this._removeSessionInputNeeded(chatUri, this._chatInputNeededId(chatUri, action.requestId));
reducer.ts ×4
break;
case ActionType.ChatToolCallReady:
case ActionType.ChatToolCallConfirmed:
case ActionType.ChatToolCallComplete:
case ActionType.ChatToolCallResultConfirmed:
case ActionType.ChatToolCallAuthRequired:
case ActionType.ChatToolCallAuthResolved:
break;
case ActionType.ChatTurnCancelled:
case ActionType.ChatError:
case ActionType.ChatTruncated:
break;
}
private _syncChatInputNeeded(chatUri: ProtocolURI, requestId: string): void {
const part = state?.activeTurn?.responseParts.find(part =>
&& part.response === undefined
&& part.request.id === requestId
const id = this._chatInputNeededId(chatUri, requestId);
if (!part || part.kind !== ResponsePartKind.InputRequest) {
return;
}
id,
kind: SessionInputRequestKind.ChatInput,
chat: chatUri,
request: part.request,
});
private _syncToolInputNeeded(chatUri: ProtocolURI, turnId: string, toolCallId: string): void {
const confirmationId = this._toolConfirmationNeededId(chatUri, turnId, toolCallId);
agentSideEffects.ts ×9
const clientExecutionId = this._toolClientExecutionNeededId(chatUri, turnId, toolCallId);
const authenticationId = this._toolAuthenticationNeededId(chatUri, turnId, toolCallId);
const toolCall = this._findToolCall(chatUri, turnId, toolCallId);
// A call auto-approved by the session's bypass setting is run
// automatically by the owning client and never blocks on the user, so
// keep it out of the session `inputNeeded` queue (which would flash
// "input needed" in the sessions list). `autoApproveBySetting` covers
// only the parameter gate; a `PendingResultConfirmation` is a genuine
// prompt and is still surfaced.
const autoApproved = !!toolCall && readToolCallMeta(toolCall).autoApproveBySetting === true;
const suppressAutoApprovedConfirmation = autoApproved && toolCall?.status === ToolCallStatus.PendingConfirmation;
const needsConfirmation = !suppressAutoApprovedConfirmation && (toolCall?.status === ToolCallStatus.PendingConfirmation || toolCall?.status === ToolCallStatus.PendingResultConfirmation);
if (needsConfirmation && toolCall) {
id: confirmationId,
kind: SessionInputRequestKind.ToolConfirmation,
chat: chatUri,
turnId,
toolCall,
});
this._removeSessionInputNeeded(chatUri, confirmationId);
}
const contributor = toolCall?.contributor;
if (!autoApproved && toolCall?.status === ToolCallStatus.Running && contributor?.kind === ToolCallContributorKind.Client) {
id: clientExecutionId,
kind: SessionInputRequestKind.ToolClientExecution,
chat: chatUri,
turnId,
clientId: contributor.clientId,
toolCall,
});
this._removeSessionInputNeeded(chatUri, clientExecutionId);
}
if (toolCall?.status === ToolCallStatus.AuthRequired) {
id: authenticationId,
kind: SessionInputRequestKind.ToolAuthentication,
chat: chatUri,
turnId,
toolCall,
});
this._removeSessionInputNeeded(chatUri, authenticationId);
}
}
private _findToolCall(chatUri: ProtocolURI, turnId: string, toolCallId: string): ToolCallState | undefined {
const turn = state?.activeTurn?.id === turnId ? state.activeTurn : state?.turns.find(t => t.id === turnId);
const part = turn?.responseParts.find(p => p.kind === ResponsePartKind.ToolCall && p.toolCall.toolCallId === toolCallId);
return part?.kind === ResponsePartKind.ToolCall ? part.toolCall : undefined;
}
private _setSessionInputNeeded(chatUri: ProtocolURI, request: SessionInputRequest): void {
const existing = this._stateManager.getSessionState(sessionUri)?.inputNeeded?.find(r => r.id === request.id);
if (existing && equals(existing, request)) {
}
this._stateManager.dispatchServerAction(sessionUri, { type: ActionType.SessionInputNeededSet, request });
reducer.ts ×3
if (request.kind !== SessionInputRequestKind.ChatInput) {
if (agent) {
this._toolCallTracker.toolCallBlocked(agent.id, chatUri, request);
}
}
private _removeSessionInputNeeded(chatUri: ProtocolURI, id: string): void {
const sessionUri = parseRequiredSessionUriFromChatUri(chatUri);
agentHostToolCallTracker.ts ×2
this._toolCallTracker.toolCallUnblocked(chatUri, id);
if (!this._stateManager.getSessionState(sessionUri)?.inputNeeded?.some(r => r.id === id)) {
}
this._stateManager.dispatchServerAction(sessionUri, { type: ActionType.SessionInputNeededRemoved, id });
reducer.ts ×5
private _removeSessionInputNeededForChat(chatUri: ProtocolURI): void {
for (const request of this._stateManager.getSessionState(sessionUri)?.inputNeeded ?? []) {
this._removeSessionInputNeeded(chatUri, request.id);
}
}
private _chatInputNeededId(chatUri: ProtocolURI, requestId: string): string {
}
private _toolConfirmationNeededId(chatUri: ProtocolURI, turnId: string, toolCallId: string): string {
}
private _toolClientExecutionNeededId(chatUri: ProtocolURI, turnId: string, toolCallId: string): string {
}
private _toolAuthenticationNeededId(chatUri: ProtocolURI, turnId: string, toolCallId: string): string {
}
// ---- Initialization ----------------------------------------------------
/**
* Initializes async resources (tree-sitter WASM) used for command
* auto-approval. Await this before any session events can arrive to
* guarantee that auto-approval checks are fully synchronous.
*/
initialize(): Promise<void> {
}
// ---- Agent registration -------------------------------------------------
/**
* Registers a progress-signal listener on the given agent so that
* {@link AgentSignal}s are routed/dispatched through the state manager.
* Returns a disposable that removes the listener.
*/
registerProgressListener(agent: IAgent): IDisposable {
disposables.add(agent.onDidSessionProgress(signal => {
if (agent.onDidCustomizationsChange) {
disposables.add(agent.onDidCustomizationsChange(() => {
this._publishSessionCustomizationsForAgent(agent);
}
if (agent.onDidRequireAuth) {
disposables.add(agent.onDidRequireAuth(e => this._stateManager.emitAuthRequired(e)));
agentService.ts ×1
}
}
/**
* Routes a single signal from `agent` to the correct session.
*
* Action signals with a `parentToolCallId` are routed to the matching
* subagent session. If the subagent session does not exist yet (the SDK
* can emit an inner `tool_start` before its `subagent_started`), the
* signal is buffered in {@link _pendingSubagentSignals} and replayed
* once the `subagent_started` arrives.
*/
private _handleAgentSignal(agent: IAgent, signal: AgentSignal): void {
this._handleSubagentStarted(signal.chat.toString(), signal.toolCallId, signal.agentName, signal.agentDisplayName, signal.agentDescription, signal.taskPrompt, signal.parentToolCallId);
agentSideEffects.ts ×9
this._drainPendingSubagentSignals(signal.chat.toString(), signal.toolCallId);
return;
}
if (signal.kind === 'subagent_completed') {
this.completeSubagentSession(signal.chat.toString(), signal.toolCallId);
agentSideEffects.ts ×3
return;
}
if (signal.kind === 'steering_consumed') {
type: ActionType.ChatPendingMessageRemoved,
kind: PendingMessageKind.Steering,
id: signal.id,
});
return;
}
const sessionKey = signal.kind === 'action' ? signal.resource.toString() : signal.chat.toString();
agentSideEffects.ts ×4
// Route signals with parentToolCallId to the subagent session.
// Both action signals and pending_confirmation signals can carry
// a parentToolCallId — for client tools inside a subagent the
// permission flow fires `pending_confirmation` for an inner tool
// call, and that signal must be routed to the subagent session
// (otherwise the resulting ChatToolCallReady would land on the
// parent session, which has no matching ChatToolCallStart).
const parentToolCallId = signal.parentToolCallId;
if (parentToolCallId) {
const subagentSession = this._subagentChats.get(sessionKey, parentToolCallId);
agentSideEffects.ts ×1
if (subagentSession) {
const subTurnId = this._stateManager.getActiveTurnId(subagentSession.chatUri);
agentSideEffects.ts ×2
if (subTurnId) {
this._dispatchActionForSession(signal, subagentSession.chatUri, subTurnId, 'remap', agent);
}
return;
}
// Subagent session does not exist yet — buffer the signal so we can
// replay it after `subagent_started` arrives.
this._logService.trace(`[AgentSideEffects] Buffering ${this._describeSignal(signal)} for pending subagent ${sessionKey}/${parentToolCallId}`);
let buffer = this._pendingSubagentSignals.get(sessionKey, parentToolCallId);
if (!buffer) {
buffer = [];
this._pendingSubagentSignals.set(buffer, sessionKey, parentToolCallId);
}
buffer.push({ signal, agent });
return;
}
// Route pending_confirmation signals for tools inside subagent sessions
// (legacy path for signals without an explicit parentToolCallId — the
// tool was previously registered under its subagent session key in
// _toolCallAgents).
if (signal.kind === 'pending_confirmation') {
const subagentChatUri = this._findSubagentChatForToolCall(sessionKey, signal.state.toolCallId);
agentSideEffects.ts ×4
if (subagentChatUri) {
const subTurnId = this._stateManager.getActiveTurnId(subagentChatUri) ?? '';
agentSideEffects.ts ×3
void this._handleToolReady(signal, subagentChatUri, subTurnId, agent).catch(err => {
this._logService.error('[AgentSideEffects] _handleToolReady failed', err);
return;
}
const turnId = this._stateManager.getActiveTurnId(sessionKey);
if (turnId) {
this._dispatchActionForSession(signal, sessionKey, turnId, 'preserve', agent);
agentSideEffects.ts ×9
return;
}
// No active turn on the session. Non-action signals are silently
// dropped, but action signals can still target session-level state
// such as customizations, title, or configuration. A turnComplete
// action also drives post-turn side effects even when the matching
// turnStarted was not observed by this side-effects instance.
//
// pending_confirmation signals must also be handled here: when a
// hook-triggered continuation runs after the protocol turn has
// already completed, tool actions are dispatched (below) with an
// empty turnId. Without this, the pending_confirmation is silently
// dropped, the permission deferred never resolves, and the session
// hangs indefinitely.
if (signal.kind === 'pending_confirmation') {
void this._handleToolReady(signal, sessionKey, '', agent).catch(err => {
agentSideEffects.ts ×2
this._logService.error('[AgentSideEffects] _handleToolReady failed', err);
return;
}
const action = signal.action;
if (action.type === ActionType.ChatTurnComplete && this._cancelledTurnIds.get(sessionKey)?.has(action.turnId)) {
this._logService.trace(`[AgentSideEffects] Dropping completion for cancelled turn ${action.turnId} on ${sessionKey}`);
reducer.ts ×2
return;
}
if (action.type === ActionType.ChatTurnComplete) {
}
/**
* Dispatches a signal to a resolved chat, preserving top-level turn identity or remapping cross-channel subagent actions.
*/
private _dispatchActionForSession(signal: AgentSignal, sessionKey: ProtocolURI, turnId: string, turnIdRouting: AgentSignalTurnIdRouting, agent?: IAgent): void {
void this._handleToolReady(signal, sessionKey, turnId, agent).catch(err => {
this._logService.error('[AgentSideEffects] _handleToolReady failed', err);
}
return;
}
return;
}
if (action.type !== ActionType.ChatTruncated && hasKey(action, { turnId: true }) && action.turnId !== turnId) {
this._logService.trace(`[AgentSideEffects] Dropping stale ${action.type} for ${sessionKey}: producerTurnId=${action.turnId}, activeTurnId=${turnId}`);
agentSideEffects.ts ×1
return;
}
this._toolCallAgents.set(`${sessionKey}:${action.toolCallId}`, agent.id);
agentSideEffects.ts ×2
// Stamp the tool call start for `languageModelToolInvoked` telemetry.
// Only the start action carries the tool name and contributor, so the
// source kind must be captured here rather than on completion. The
// provider comes from the agent that emitted the signal.
this._toolCallTracker.toolCallStarted(agent.id, sessionKey, action.toolCallId, action.toolName, action.contributor);
}
const sessionUri = isAhpChatChannel(sessionKey) ? parseRequiredSessionUriFromChatUri(sessionKey) : sessionKey;
agentSideEffects.ts ×9
// Stamp the subagent chat URI onto the tool call as soon as toolKind
// is known, so clients get it from the wire instead of deriving it.
if (
(action.type === ActionType.ChatToolCallStart || action.type === ActionType.ChatToolCallDelta || action.type === ActionType.ChatToolCallReady)
action = { ...action, _meta: { ...action._meta, subagentChatUri: buildSubagentChatUri(sessionUri, action.toolCallId) } };
agentSideEffects.ts ×2
}
// When a parent tool call has an associated subagent session,
// preserve the subagent content metadata in the completion result.
// The SDK's tool_complete provides its own content which would
// overwrite the ToolResultSubagentContent that was set via
// ChatToolCallContentChanged while running.
if (action.type === ActionType.ChatToolCallComplete) {
const subagent = this._subagentChats.get(sessionKey, action.toolCallId);
agentSideEffects.ts ×4
if (subagent) {
const runningContent = this._getRunningToolCallContent(parentState, turnId, action.toolCallId);
const subagentEntry = runningContent.find(c => hasKey(c, { type: true }) && c.type === ToolResultContentType.Subagent);
if (subagentEntry) {
const mergedContent = [...(action.result.content ?? []), subagentEntry];
const merged: ChatToolCallCompleteAction = { ...action, result: { ...action.result, content: mergedContent } };
action = merged;
}
}
this._stateManager.dispatchServerAction(sessionKey, action);
// Mark first visible progress for TTFT telemetry
if (action.type === ActionType.ChatDelta
|| action.type === ActionType.ChatResponsePart
}
if (action.type === ActionType.ChatToolCallComplete) {
// call. `action.result` carries `success`/`error.code` even after the
// subagent-content merge above (which only touches `result.content`).
this._toolCallTracker.toolCallCompleted(sessionKey, action.toolCallId, action.result);
// Drop any events that were buffered for a subagent whose
// `subagent_started` never arrived (e.g. the parent tool failed
// before the subagent was created). The actual subagent session
// teardown is driven by the `subagent_completed` signal because
// background subagents (`mode: background`) continue running
// after the parent tool call returns.
this._pendingSubagentSignals.delete(sessionKey, action.toolCallId);
if (getToolFileEdits(action.result).length > 0) {
}
if (action.type === ActionType.ChatTurnComplete) {
this._toolCallTracker.clearSession(sessionKey);
this._runTurnCompleteSideEffects(sessionKey, turnId);
}
if (action.type === ActionType.ChatTurnCancelled) {
this._toolCallTracker.clearSession(sessionKey);
this._markSessionUnread(sessionUri);
}
if (action.type === ActionType.ChatError) {
this._turnTracker.turnCompleted(sessionKey, turnId, 'error', { stage: 'provider', error: action.error });
agentSideEffects.ts ×1
this._toolCallTracker.clearSession(sessionKey);
this._markSessionUnread(sessionUri);
}
/**
* Post-turn side effects: flush any pending debounced diff computation,
* compute final diffs immediately, drain the next queued message, and
* notify the host so it can refresh git state.
*/
private _runTurnCompleteSideEffects(sessionKey: ProtocolURI, turnId: string | undefined): void {
// scoped to the owning session's working tree, which peer chats
// share. Normalize an additional-chat channel to its session for
// those, while keeping the original channel for per-chat queued
// message consumption (queues live on the chat state). For the
// default chat / single-chat case `sessionKey` is already the
// session URI, so this is a no-op.
const sessionUri = isAhpChatChannel(sessionKey) ? parseRequiredSessionUriFromChatUri(sessionKey) : sessionKey;
// Capture the end-of-turn git checkpoint BEFORE notifying the
// changeset service so the per-turn changeset recompute can take
// the authoritative git-diff fast path (which includes terminal-tool
// edits the FileEditTracker misses). The capture is best-effort —
// any failure logs and the changeset pipeline falls back to the
// `file_edits`-based path. We don't block subsequent side effects
// (queued message drain, host notification) on the changeset
// completion since those have always been fire-and-forget; the
// ordering guarantee we care about is checkpoint-then-changeset.
if (turnId !== undefined) {
this._checkpointService.captureTurnCheckpoint(URI.parse(sessionUri), turnId).then(() => {
this._changesets.onTurnComplete(sessionUri, turnId);
}, err => {
this._logService.warn(`[AgentSideEffects] Turn checkpoint capture failed for ${sessionUri}/${turnId}: ${err instanceof Error ? err.message : String(err)}`);
this._changesets.onTurnComplete(sessionUri, turnId);
} else {
}
this._options.onTurnComplete(sessionUri);
// After the first turn completes, refine the auto-generated title using
// the full first-turn context (request + response). No-op for later
// turns or when the title has since been changed. `sessionKey` may be an
// additional chat channel; route it as `chatChannel` so the refinement
// targets that chat's title, mirroring `seedTitleFromFirstMessage`.
const titleChatChannel = isAhpChatChannel(sessionKey) && !isDefaultChatUri(sessionKey) ? sessionKey : undefined;
this._titleController.refineTitleFromFirstTurn(sessionUri, titleChatChannel);
// A completed turn produces new output the user may not have seen. Route
// subagent turns to their owning session too (a background subagent can
// complete after the parent turn). Each client keeps its active session
// read; `_markSessionUnread` is idempotent.
this._markSessionUnread(sessionUri);
}
private _markSessionUnread(session: ProtocolURI): void {
const status = this._stateManager.getSessionSummary(session)?.status ?? 0;
agentSideEffects.ts ×2
if (!(status & SessionStatus.IsRead)) {
}
this._stateManager.dispatchServerAction(session, { type: ActionType.SessionIsReadChanged, isRead: false });
reducer.ts ×1
this._persistSessionFlag(session, 'isRead', '');
private _describeSignal(signal: AgentSignal): string {
return signal.kind === 'action' ? `action(${signal.action.type})` : signal.kind;
agentSideEffects.ts ×2
}
/**
* Replays any signals that were buffered while waiting for
* `subagent_started` to create the subagent session. Called immediately
* after `_handleSubagentStarted`.
*/
private _drainPendingSubagentSignals(parentChatURI: ProtocolURI, parentToolCallId: string): void {
const buffer = this._pendingSubagentSignals.get(parentChatURI, parentToolCallId);
agentSideEffects.ts ×9
if (!buffer) {
}
this._logService.trace(`[AgentSideEffects] Draining ${buffer.length} buffered signal(s) for subagent ${parentChatURI}/${parentToolCallId}`);
for (const { signal, agent } of buffer) {
this._handleAgentSignal(agent, signal);
}
// ---- Subagent session management ----------------------------------------
/**
* Starts the subagent turn in response to a `subagent_started` event and
* wires the parent tool call to the subagent chat. The subagent chat's
* catalog membership is owned by the spawn channel
* ({@link AgentService._onChatSpawned}), which the orchestrator applies
* before this runs, so this only drives the turn/tracking/parent content
* — it does not add the chat.
*
* `chatURI` is always the agent's top-level chat: the subagent is
* registered (and inner events routed) under it because inner-tool
* signals carry the top-level chat as their resource. `spawningToolParentId`,
* when set, is the tool call one level up from the spawning `toolCallId`
* — the tool call in whose (subagent) chat the spawning tool lives — and
* is used to route the discovery content block to that immediate parent
* chat. Since subagent chats are flat (keyed off the root session), this
* one-hop reference resolves the parent chat at any nesting depth.
*/
private _handleSubagentStarted(
toolCallId: string,
agentName: string,
agentDisplayName: string,
agentDescription?: string,
taskPrompt?: string,
spawningToolParentId?: string,
): void {
const parentSessionUri = parseRequiredSessionUriFromChatUri(chatURI);
const subagentChatUri = buildSubagentChatUri(parentSessionUri, toolCallId);
// Already tracking this subagent
if (this._subagentChats.get(chatURI, toolCallId)) {
return;
}
this._logService.info(`[AgentSideEffects] Starting subagent turn: ${subagentChatUri} (parent=${chatURI}, toolCallId=${toolCallId})`);
// The spawning tool call lives in the immediate parent chat (top-level, or the parent subagent chat when nested).
const contentChatUri = spawningToolParentId
? this._subagentChats.get(chatURI, spawningToolParentId)?.chatUri ?? chatURI
agentSideEffects.ts ×1
// Seed the subagent's opening request with the delegated task prompt,
// supplied by the provider on the `subagent_started` signal.
const turnId = generateUuid();
this._stateManager.dispatchServerAction(subagentChatUri, {
type: ActionType.ChatTurnStarted,
turnId,
startedAt: new Date().toISOString(),
message: { text: taskPrompt ?? '', origin: { kind: MessageKind.User } },
});
this._subagentChats.set({ parentChatUri: chatURI, toolCallId, sessionUri: parentSessionUri, chatUri: subagentChatUri, turnStopWatch: StopWatch.create(false) }, chatURI, toolCallId);
// Dispatch the discovery content on the spawning tool call's own chat; the top-level chat is a no-op when nested.
const parentTurnId = this._stateManager.getActiveTurnId(contentChatUri);
if (parentTurnId) {
const parentState = this._stateManager.getSessionState(contentChatUri);
const existingContent = this._getRunningToolCallContent(parentState, parentTurnId, toolCallId);
this._stateManager.dispatchServerAction(contentChatUri, {
type: ActionType.ChatToolCallContentChanged,
turnId: parentTurnId,
toolCallId,
content: [
...existingContent,
{
type: ToolResultContentType.Subagent,
resource: subagentChatUri,
title: agentDisplayName,
agentName,
description: agentDescription,
},
],
});
}
}
/**
* Gets the current content array from a running tool call, if any.
*/
private _getRunningToolCallContent(
turnId: string,
toolCallId: string,
): ToolResultContent[] {
if (!state?.activeTurn || state.activeTurn.id !== turnId) {
return [];
}
if (rp.kind === ResponsePartKind.ToolCall && rp.toolCall.toolCallId === toolCallId && rp.toolCall.status === ToolCallStatus.Running) {
agentSideEffects.ts ×2
}
private _turnDuration(stopWatch: StopWatch | undefined): number {
return typeof elapsed === 'number' && Number.isFinite(elapsed) ? Math.max(0, elapsed) : 0;
}
/**
* Cancels all active subagent sessions for a given parent session.
*/
cancelSubagentSessions(parentChatURI: ProtocolURI): void {
if (turnId) {
this._stateManager.dispatchServerAction(subagent.chatUri, {
type: ActionType.ChatTurnCancelled,
turnId,
duration: this._turnDuration(subagent.turnStopWatch),
});
this._turnTracker.turnCompleted(subagent.chatUri, turnId, 'cancelled');
}
this._toolCallTracker.clearSession(subagent.chatUri);
}
// Drop any buffered events targeted at subagents that never started.
this._pendingSubagentSignals.deleteAll(parentChatURI);
}
/**
* Completes the subagent session associated with a parent tool call.
* Driven by the `subagent_completed` signal from the agent (which the
* SDK fires on both `subagent.completed` and `subagent.failed`), not by
* parent tool call completion — background subagents keep running after
* their parent tool returns.
*/
completeSubagentSession(parentChatURI: ProtocolURI, toolCallId: string): void {
// that never arrived (e.g. the parent tool failed before the subagent
// was created). Without this, the buffer entry would leak until the
// parent session is disposed.
this._pendingSubagentSignals.delete(parentChatURI, toolCallId);
const subagent = this._subagentChats.get(parentChatURI, toolCallId);
if (!subagent) {
return;
}
const turnId = this._stateManager.getActiveTurnId(subagent.chatUri);
if (turnId) {
this._stateManager.dispatchServerAction(subagent.chatUri, {
type: ActionType.ChatTurnComplete,
turnId,
duration: this._turnDuration(subagent.turnStopWatch),
});
}
this._subagentChats.delete(parentChatURI, toolCallId);
}
/**
* Removes all subagent chats for a given parent session from the state manager.
*/
removeSubagentSessions(parentSession: ProtocolURI): void {
if (parseRequiredSessionUriFromChatUri(chatUri) === parentSession) {
this._cancelledTurnIds.delete(chatUri);
}
}
for (const subagent of this._subagentChats.values()) {
this._stateManager.removeChat(subagent.sessionUri, subagent.chatUri);
this._toolCallTracker.clearSession(subagent.chatUri);
parentChatURIs.add(subagent.parentChatUri);
}
}
this._pendingSubagentSignals.deleteAll(parentChatURI);
}
/**
* Finds the subagent session that owns a given tool call by checking
* whether the tool call was previously registered under a subagent
* session key in `_toolCallAgents`. Scoped to subagent sessions owned
* by the given parent to avoid cross-session collisions.
*/
private _findSubagentChatForToolCall(parentChatURI: ProtocolURI, toolCallId: string): ProtocolURI | undefined {
return subagent.chatUri;
}
}
private _toolCallCompletionChat(chatChannel: ProtocolURI): ProtocolURI {
return chatChannel;
}
for (const subagent of this._subagentChats.values()) {
if (subagent.chatUri === chatChannel) {
return this._toolCallCompletionChat(subagent.parentChatUri);
}
}
this._logService.warn(`[AgentSideEffects] Missing parent chat for subagent tool completion: chat=${chatChannel}`);
return chatChannel;
private _notifyClientToolCallComplete(sessionChannel: ProtocolURI, chatChannel: ProtocolURI, toolCallId: string, result: ToolCallResult, source: 'client-dispatch' | 'server-envelope'): void {
const agent = this._options.getAgent(sessionChannel);
if (!agent) {
this._logService.warn(`[AgentSideEffects] No agent for client tool completion: source=${source}, session=${sessionChannel}, chat=${chatChannel}, completionChat=${completionChat}, toolCallId=${toolCallId}`);
return;
}
this._logService.info(`[AgentSideEffects] Forwarding client tool completion: source=${source}, session=${sessionChannel}, chat=${chatChannel}, completionChat=${completionChat}, toolCallId=${toolCallId}, success=${result.success}`);
agentSideEffects.ts ×4
agent.onClientToolCallComplete(URI.parse(sessionChannel), URI.parse(completionChat), toolCallId, result);
}
// ---- Side-effect handlers --------------------------------------------------
/**
* Handles a `pending_confirmation` signal end-to-end: checks for
* auto-approval via the permission manager, and if not auto-approved,
* dispatches the `ChatToolCallReady` action with confirmation options
* for the client.
*/
private async _handleToolReady(e: IAgentToolPendingConfirmationSignal, sessionKey: ProtocolURI, turnId: string, agent: IAgent): Promise<void> {
toolCallId: e.state.toolCallId,
session: e.chat,
permissionKind: e.permissionKind,
permissionPath: e.permissionPath,
toolInput: e.state.toolInput,
requestSandboxBypass: e.requestSandboxBypass,
};
const autoApproval = await this._permissionManager.getAutoApproval(approvalEvent, sessionKey);
const part = this._stateManager.getSessionState(sessionKey)?.activeTurn?.responseParts.find(part => part.kind === ResponsePartKind.ToolCall && part.toolCall.toolCallId === e.state.toolCallId);
const toolCall = part?.kind === ResponsePartKind.ToolCall ? part.toolCall : undefined;
if (toolCall
this._logService.trace(`[AgentSideEffects] Dropping stale tool ready for ${e.state.toolCallId}: status=${toolCall.status}`);
return;
}
const clientShouldAutoApprove = autoApproval !== undefined
this._toolCallAgents.set(`${sessionKey}:${e.state.toolCallId}`, agent.id);
agentSideEffects.ts ×2
effective = { ...e, state: { ...e.state, _meta: { ...toolCall?._meta, ...e.state._meta, ...toToolCallMeta({ autoApproveBySetting: true }) } } };
agent.respondToPermissionRequest(e.state.toolCallId, true);
// Strip confirmationTitle so createToolReadyAction emits the
// auto-approved (no-options) action.
effective = { ...e, state: { ...e.state, confirmationTitle: undefined } };
// Make sure the agent is registered for the eventual `ChatToolCallConfirmed` response.
agentSideEffects.ts ×1
this._toolCallAgents.set(`${sessionKey}:${e.state.toolCallId}`, agent.id);
}
sessionKey,
this._permissionManager.createToolReadyAction(effective, sessionKey, turnId)
);
handleAction(channel: ProtocolURI, action: StateAction, clientId?: string): void {
const sessionChannel = chatChannel ? parseRequiredSessionUriFromChatUri(chatChannel) : channel;
switch (action.type) {
case ActionType.ChatTurnStarted: {
throw new Error(`ChatTurnStarted must be handled on an AHP chat channel: ${channel}`);
}
// Per-turn streaming part tracking is owned by the agent
// (e.g. CopilotAgentSession) and reset on its `send()` call.
// Generic, agent-agnostic host commands (`/rename`, `!command`,
// …) are intercepted here and handled by the local-command
// dispatcher rather than forwarded to the agent SDK.
const handled = this._localCommands.tryHandle({ turnChannel: channel, turnId: action.turnId, text: action.message.text });
if (handled) {
this._titleController.seedProvisionalTitle(sessionChannel, handled.suggestedTitle, chatChannel);
}
break;
}
const state = this._stateManager.getSessionState(channel);
if (!state) {
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`);
}
this._titleController.seedTitleFromFirstMessage(sessionChannel, action.message.text, chatChannel);
agentSideEffects.ts ×2
const agent = this._options.getAgent(sessionChannel);
if (!agent) {
type: ActionType.ChatError,
turnId: action.turnId,
duration: this._turnDuration(turnStopWatch),
error: { errorType: 'noAgent', message: 'No agent found for session' },
});
return;
}
this._telemetryReporter.userMessageSent(agent.id, channel, state, 'direct', attachments);
const { model, modelTelemetryKind, permissionLevel } = this._getTurnTelemetryContext(agent, state, action.message.model?.id);
agentSideEffects.ts ×3
this._turnTracker.turnStarted(agent.id, channel, action.turnId, model, modelTelemetryKind, permissionLevel);
void this._sendTurnMessage({
agent,
sessionChannel,
turnChannel: channel,
chat: channel,
message: action.message,
turnId: action.turnId,
senderClientId: clientId,
turnStopWatch,
});
break;
}
throw new Error(`ChatToolCallConfirmed must be handled on an AHP chat channel: ${channel}`);
}
const agentId = this._toolCallAgents.get(toolCallKey);
if (agentId) {
this._toolCallAgents.delete(toolCallKey);
const agent = this._options.agents.get().find(a => a.id === agentId);
agent?.respondToPermissionRequest(action.toolCallId, action.approved);
} else {
this._logService.warn(`[AgentSideEffects] No agent for tool call confirmation: ${action.toolCallId}`);
}
// When the user chose "Allow in this Session", add the tool
// to the session's permissions so future calls are auto-approved.
if (action.approved) {
this._permissionManager.handleToolCallConfirmed(channel, action.toolCallId, action.selectedOptionId);
sessionPermissions.ts ×3
}
}
if (!chatChannel) {
throw new Error(`ChatInputCompleted must be handled on an AHP chat channel: ${channel}`);
}
const agent = this._options.getAgent(sessionChannel);
agent?.respondToUserInputRequest(action.requestId, action.response, action.answers);
break;
}
throw new Error(`ChatTurnCancelled must be handled on an AHP chat channel: ${channel}`);
}
this._toolCallTracker.clearSession(channel);
// Cancel all subagent sessions for this parent
this.cancelSubagentSessions(channel);
const agent = this._options.getAgent(sessionChannel);
if (agent) {
const chat = URI.parse(channel);
agent.chats.abort(chat).catch(err => {
this._logService.error('[AgentSideEffects] abort failed', err);
}
// Intentionally do NOT drain queued messages here: cancelling means
// "stop", so messages queued behind the turn stay queued for the
// user to dequeue/run manually. (A message the user sends *after*
// the abort is still consumed via the ChatPendingMessageSet path
// once cancellation has cleared the active turn.)
break;
}
// The rename targeted a specific chat (default or additional),
// not the whole session. Route it to a per-chat title update so
// the session title stays independent.
this._stateManager.updateChatTitle(sessionChannel, chatChannel, action.title);
this._persistSessionFlag(sessionChannel, `customChatTitle:${chatChannel}`, action.title);
break;
}
break;
}
case ActionType.ChatPendingMessageRemoved:
case ActionType.ChatQueuedMessagesReordered: {
throw new Error(`${action.type} must be handled on an AHP chat channel: ${channel}`);
}
break;
}
throw new Error(`ChatTruncated must be handled on an AHP chat channel: ${channel}`);
}
// When the truncation boundary is a host-injected local turn
// (`/rename` / `!command`), redirect the SDK truncation to the
// preceding concrete turn so the agent keeps everything up to
// the real message before it.
const sdkTurnId = action.turnId !== undefined
? this._options.localTurns.resolveConcreteTurnId(chatChannel, action.turnId)
agentSideEffects.ts ×1
// by the session) or a peer chat with its own backing.
agent?.truncateSession?.(URI.parse(sessionChannel), sdkTurnId, URI.parse(chatChannel)).catch(err => {
this._logService.error('[AgentSideEffects] truncateSession failed', err);
// Drop persisted local turns that no longer survive in the
// (already-truncated) chat state.
const survivingIds = new Set((this._stateManager.getChatState(chatChannel)?.turns ?? []).map(t => t.id));
const removed = this._options.localTurns.getLocalTurnIds(chatChannel).filter(id => !survivingIds.has(id));
this._options.localTurns.deleteLocals(sessionChannel, removed);
this._changesets.onSessionTruncated(sessionChannel);
break;
}
if (!agent) {
break;
}
const handle = agent.getOrCreateActiveClient(URI.parse(channel), {
clientId: activeClient.clientId,
displayName: activeClient.displayName,
});
handle.tools = activeClient.tools;
handle.customizations = activeClient.customizations ?? [];
break;
}
agent?.removeActiveClient(URI.parse(channel), action.clientId);
break;
}
updateAgentHostTelemetryLevelFromConfig(this._telemetryService, action.config);
agentSideEffects.ts ×3
// Host customizations are self-managed by each agent's
// PluginController via IAgentConfigurationService.onDidRootConfigChange.
// Republish agent infos for non-customization schema changes
// (e.g. permissions) and session customizations as a catchall.
this._publishAgentInfos(this._options.agents.get());
this._publishAllSessionCustomizations();
break;
}
const agent = this._options.getAgent(sessionChannel);
agent?.startMcpServer?.(URI.parse(sessionChannel), action.id).catch(err => {
this._logService.warn(`[AgentSideEffects] startMcpServer failed for ${sessionChannel}`, err);
});
break;
}
const agent = this._options.getAgent(sessionChannel);
agent?.stopMcpServer?.(URI.parse(sessionChannel), action.id).catch(err => {
this._logService.warn(`[AgentSideEffects] stopMcpServer failed for ${sessionChannel}`, err);
});
break;
}
this._persistSessionFlag(channel, 'isRead', action.isRead ? 'true' : '');
break;
}
this._persistSessionFlag(channel, AH_META_IS_ARCHIVED_DB_KEY, action.isArchived ? 'true' : '');
// Host-owned worktree lifecycle (agents stay unaware): remove the
// clean, branch-preserved worktree on archive and recreate it on
// unarchive. Serialized per session inside the controller so it can't
// interleave with a first-send worktree resolution.
if (this._worktree) {
const sessionUri = URI.parse(channel);
const sessionId = AgentSession.id(channel);
const worktreeOp = action.isArchived
? this._worktree.cleanupWorktreeOnArchive(sessionUri, sessionId)
: this._worktree.recreateWorktreeOnUnarchive(sessionUri, sessionId);
worktreeOp.catch(err => this._logService.warn(`[AgentSideEffects] worktree ${action.isArchived ? 'cleanup' : 'recreate'} failed for ${channel}`, err));
}
const agent = this._options.getAgent(channel);
agent?.onArchivedChanged?.(URI.parse(channel), action.isArchived).catch(err => {
this._logService.warn(`[AgentSideEffects] onArchivedChanged failed for ${channel}`, err);
});
break;
}
// the user's previous selections (e.g. autoApprove).
const sessionState = this._stateManager.getSessionState(channel);
const values = sessionState?.config?.values;
if (values) {
this._persistSessionFlag(channel, 'configValues', JSON.stringify(values));
}
if (this._worktree && sessionState?.lifecycle === SessionLifecycle.Creating) {
const isolation = values?.[SessionConfigKey.Isolation];
if (isolation === 'worktree') {
this._worktree.notePending(sessionId);
} else if (isolation === 'folder') {
this._worktree.clearPending(sessionId);
}
}
// (a user picker edit); internal server-side writes use
// `dispatchServerAction` and never land here. So the provider can
// forward a live, session-mutable change (e.g. Claude's
// `permissionMode`) to its running SDK without re-entering its own
// tool callbacks.
this._options.getAgent(channel)?.onSessionConfigChanged?.(URI.parse(channel), values ?? {});
break;
}
break; // Not a chat channel; ignore.
}
this._notifyClientToolCallComplete(sessionChannel, chatChannel, action.toolCallId, action.result, 'client-dispatch');
agentSideEffects.ts ×2
break;
}
}
/** Injects the host-owned worktree isolation controller (see {@link AgentService.setWorktreeIsolation}). */
setWorktreeIsolation(worktree: WorktreeIsolation): void {
}
cancelSessionTitleGeneration(session: ProtocolURI): void {
}
/**
* Generates a content-derived title for a freshly forked session
* (`chatChannel` undefined) or peer chat from its inherited chat
* turns, replacing the placeholder `Forked: …` title once ready.
*/
generateForkedTitle(channel: ProtocolURI, chatChannel: ProtocolURI | undefined, turns: readonly Turn[], fallbackTitle: string, sourceTitle?: string): void {
this._titleController.generateForkedTitle(channel, chatChannel, turns, fallbackTitle, sourceTitle);
agentService.ts ×2
}
/**
* Persists a session metadata key/value pair to the session database.
* Used for fields the host needs to remember across restarts (custom
* title, isRead/isArchived flags, merged config values).
*/
private _persistSessionFlag(session: ProtocolURI, key: string, value: string): void {
persistSessionMetadata(this._options.sessionDataService, this._logService, session, key, value);
agentSideEffects.ts ×1
}
private _persistChatDraft(channel: ProtocolURI, draft: Message | undefined): void {
return;
}
const parsed = parseChatUri(channel);
if (!parsed) {
return;
}
const session = URI.parse(parsed.session);
const ref = this._options.sessionDataService.openDatabase(session);
ref.object.setChatDraft(URI.parse(channel), draft).catch(err => {
this._logService.warn(`[AgentSideEffects] Failed to persist chat draft for ${channel.toString()}`, err);
ref.dispose();
});
}
/**
* Pushes the current pending message state from the chat to the agent.
* The server controls queued message consumption; only steering messages
* are forwarded to the agent for mid-turn injection.
*/
private _syncPendingMessages(chatChannel: ProtocolURI): void {
const sessionChannel = parseRequiredSessionUriFromChatUri(chatChannel);
agentSideEffects.ts ×4
const state = this._stateManager.getSessionState(chatChannel);
if (!state) {
return;
}
agent?.setPendingMessages?.(
URI.parse(chatChannel),
state.steeringMessage,
[],
);
// Steering message removal is now dispatched by the agent
// via the 'steering_consumed' progress event once the message
// has actually been sent to the model.
// If the session is idle, try to consume the next queued message
this._tryConsumeNextQueuedMessage(chatChannel);
}
/**
* Consumes the next queued message by dispatching a server-initiated
* `ChatTurnStarted` action with `queuedMessageId` set. The reducer
* atomically creates the active turn and removes the message from the
* queue. Only consumes one message at a time; subsequent messages are
* consumed when the next `idle` event fires.
*/
private _tryConsumeNextQueuedMessage(session: ProtocolURI): void {
// Bail if there's already an active turn
if (this._stateManager.getActiveTurnId(session)) {
}
}
const msg = state.queuedMessages[0];
const turnId = generateUuid();
// Per-turn streaming part tracking is owned by the agent (reset
// inside its `send()` call), so no host-side reset is needed.
// Dispatch server-initiated turn start; the reducer removes the queued message atomically
this._stateManager.dispatchServerAction(session, {
type: ActionType.ChatTurnStarted,
turnId,
startedAt: new Date().toISOString(),
message: msg.message,
queuedMessageId: msg.id,
});
const turnStopWatch = StopWatch.create(false);
// Generic host commands (`/rename`, `!command`, …) are intercepted by
// the local-command dispatcher (see the ChatTurnStarted handler) and
// must not reach the agent SDK even when queued.
const handled = this._localCommands.tryHandle({ turnChannel: session, turnId, text: msg.message.text });
if (handled) {
// dequeued before any real request has titled the session).
if (handled.suggestedTitle !== undefined) {
this._titleController.seedProvisionalTitle(sessionChannel, handled.suggestedTitle, session);
}
return;
}
this._titleController.seedTitleFromFirstMessage(sessionChannel, msg.message.text, session);
// Send the message to the agent backend. When `session` is an
// additional chat channel, the SDK chat is owned by the
// parent session: look up the provider by the parent session URI and
// pass the chat channel so the harness routes to the right peer chat.
const agent = this._options.getAgent(sessionChannel);
if (!agent) {
this._stateManager.dispatchServerAction(session, {
type: ActionType.ChatError,
turnId,
duration: this._turnDuration(turnStopWatch),
error: { errorType: 'noAgent', message: 'No agent found for session' },
});
return;
}
const queuedState = this._stateManager.getSessionState(session);
this._telemetryReporter.userMessageSent(agent.id, session, queuedState, 'queued', attachments);
const { model, modelTelemetryKind, permissionLevel } = this._getTurnTelemetryContext(agent, queuedState, msg.message.model?.id);
agentSideEffects.ts ×3
this._turnTracker.turnStarted(agent.id, session, turnId, model, modelTelemetryKind, permissionLevel);
// Selection travels on the queued message; it is applied before sending.
void this._sendTurnMessage({
agent,
sessionChannel,
turnChannel: session,
chat: session,
message: msg.message,
turnId,
senderClientId: undefined,
turnStopWatch,
});
}
private _getTurnTelemetryContext(agent: IAgent, state: SessionState | undefined, modelId: string | undefined): { model: string | undefined; modelTelemetryKind: AgentHostModelTelemetryKind | undefined; permissionLevel: string | undefined } {
const permissionValue = state?.config?.values[SessionConfigKey.AutoApprove];
agentSideEffects.ts ×5
const permissionLevel = typeof permissionValue === 'string' ? permissionValue : undefined;
const model = modelId === undefined ? undefined : agent.models.get().find(model => model.id === modelId);
let modelTelemetryKind: AgentHostModelTelemetryKind | undefined;
if (modelId === 'auto') {
modelTelemetryKind = readAgentModelByokIdentifier(model) === undefined ? 'trusted' : 'byok';
agentSideEffects.ts ×1
}
}
/**
* Applies a turn message's model/agent selection (see
* {@link _applyMessageSelection}) and forwards it to the agent's
* `sendMessage`. A rejected send is wired to fail the turn: it logs,
* dispatches {@link ActionType.ChatError} on the turn channel, and marks the
* turn errored.
*/
private async _sendTurnMessage(options: {
/** The agent/session URI the chat lives on (the send target). */
sessionChannel: ProtocolURI;
/** The channel the turn runs on — where `ChatError` / turn completion are reported. */
turnChannel: ProtocolURI;
/** Chat channel URI the turn targets. */
chat: ProtocolURI;
message: Message;
turnId: string;
senderClientId: string | undefined;
turnStopWatch: StopWatch;
}): Promise<void> {
const { agent, sessionChannel, turnChannel, chat, message, turnId, senderClientId, turnStopWatch } = options;
// Read-only chats reject user-dispatched turns. `interactivity` is the
// general signal (e.g. subagent worker chats are `ReadOnly`), and an
// archived session downgrades its interactive chats to read-only too — so
// enforce off the chat's effective interactivity rather than special-casing
// archived. This is the enforcement behind the UI hiding the composer, so a
// buggy or remote client cannot run work in a read-only or archived session
// (which may no longer have its isolated worktree on disk).
const chatState = this._stateManager.getChatState(chat);
const sessionStatus = this._stateManager.getSessionSummary(options.sessionChannel)?.status ?? 0;
const sessionArchived = (sessionStatus & SessionStatus.IsArchived) === SessionStatus.IsArchived;
if (isChatReadOnly(chatState?.interactivity, sessionArchived)) {
? { errorType: 'archived', message: 'This session is archived and read-only. Restore the session to continue the conversation.' }
reducer.ts ×1
: { errorType: 'readOnly', message: 'This chat is read-only.' };
agentHostSessionTitleController.ts ×1
this._logService.warn(`[AgentSideEffects] Rejecting turn on read-only chat=${chat} (archived=${sessionArchived}), turnId=${turnId}`);
agentSideEffects.ts ×2
this._stateManager.dispatchServerAction(turnChannel, {
type: ActionType.ChatError,
turnId,
duration: this._turnDuration(turnStopWatch),
error,
});
this._turnTracker.turnCompleted(turnChannel, turnId, 'error', { stage: 'validation', error });
this._toolCallTracker.clearSession(turnChannel);
return;
}
const chatUri = URI.parse(chat);
let failureStage: AgentHostTurnFailureStage = 'workingDirectory';
try {
// Host-owned working-directory resolution: resolve the session's working
// directory before the agent materializes, so the agent runs in it
// without ever knowing how it was derived. Returns the created worktree
// for worktree sessions (created here on the first send) or the picked
// folder for folder sessions; undefined for workspace-less sessions.
const resolvedWorkingDirectory = await this._options.resolveWorkingDirectoryBeforeSend?.({ session: options.sessionChannel, chat, turnId, prompt: message.text });
const selectionUpdates: Promise<void>[] = [];
if (message.model) {
selectionUpdates.push(agent.chats.changeModel(chatUri, message.model));
}
selectionUpdates.push(agent.chats.changeAgent(chatUri, message.agent).catch(err => {
agentSideEffects.ts ×3
this._logService.error('[AgentSideEffects] changeAgent failed', err);
await Promise.all(selectionUpdates);
failureStage = 'sendMessage';
const resolvedAttachments = await this._resolveChatAttachments(sessionChannel, message.attachments);
await agent.chats.sendMessage(chatUri, message.text, resolvedWorkingDirectory, resolvedAttachments, turnId, senderClientId);
agentSideEffects.ts ×1
const error = failure.error;
this._logService.error(`[AgentSideEffects] ${failureStage} failed for session=${turnChannel}: code=${failure.errorCode}, message=${error.message}, type=${failure.errorName}`, err);
this._stateManager.dispatchServerAction(turnChannel, {
type: ActionType.ChatError,
turnId,
duration: this._turnDuration(turnStopWatch),
error,
});
this._turnTracker.turnCompleted(turnChannel, turnId, 'error', failure);
this._toolCallTracker.clearSession(turnChannel);
this._failSessionCreationIfStillCreating(sessionChannel, error);
}
private async _resolveChatAttachments(sessionChannel: ProtocolURI, attachments: readonly MessageAttachment[] | undefined): Promise<readonly MessageAttachment[] | undefined> {
if (!attachments?.some(attachment => attachment.type === MessageAttachmentKind.Chat)) {
agentSideEffects.ts ×3
}
if (attachment.type !== MessageAttachmentKind.Chat) {
return attachment;
}
throw new Error(`Chat attachment source must belong to the target session: ${attachment.resource}`);
agentSideEffects.ts ×1
}
const sourceState = this._resolveSourceChatState(attachment.resource);
agentSideEffects.ts ×3
throw new Error(`Chat attachment endTurn must reference a completed turn: ${attachment.resource}#${attachment.endTurn}`);
agentSideEffects.ts ×2
}
const sourceTurns = await this._options.resolveChatAttachmentTurns?.(attachment.resource)
agentSideEffects.ts ×2
?? [];
}));
private _resolveSourceChatState(sourceUri: string) {
if (peerState) {
}
}
return this._stateManager.getDefaultChatState(parseRequiredSessionUriFromChatUri(sourceUri));
}
/**
* Surfaces a failed first turn on a not-yet-materialized session as a
* terminal creation failure.
*
* Provisional sessions defer both their root-catalog `SessionAdded`
* notification and their `Creating -> Ready` lifecycle transition until the
* agent materializes them (worktree setup, SDK session init, …) on the
* first `sendMessage`. When that first send rejects — e.g. worktree/branch
* creation throws — the session never entered the catalog and its lifecycle
* is stuck at `Creating`, so clients that optimistically rendered it as
* in-progress keep spinning forever.
*
* When the failing session is still `Creating`, dispatch
* {@link ActionType.SessionCreationFailed} to move it to a terminal
* `CreationFailed` lifecycle, then announce its catalog entry via
* {@link AgentHostStateManager.markSessionPersisted}. The summary's status
* was already aggregated to `Error` by the preceding `ChatError` dispatch,
* so subscribers render the session as failed immediately rather than
* waiting on a client-side timeout. The provisional session survives on the
* agent, so resending re-attempts materialization.
*/
private _failSessionCreationIfStillCreating(sessionChannel: ProtocolURI, error: ErrorInfo): void {
if (state?.lifecycle !== SessionLifecycle.Creating) {
}
type: ActionType.SessionCreationFailed,
error,
});
const summary = this._stateManager.getSessionSummary(sessionChannel);
if (summary) {
this._stateManager.markSessionPersisted(sessionChannel, summary);
}
override dispose(): void {
this._toolCallTracker.clear();
super.dispose();
}
/**
* Builds the {@link ErrorInfo} for a failed `sendMessage` rejection. When the
* rejection text carries a `VSCODE_PROXY_ERROR` marker (embedded by a model
* proxy and echoed back through the agent SDK), the decoded structured chat
* error is attached to `_meta.chatError` so core can render a rich, localized
* message. Otherwise the raw error message is used as-is.
*/
function buildTurnFailure(stage: AgentHostTurnFailureStage, err: unknown): IAgentHostTurnFailure {
agentSideEffects.ts ×7
const error = buildTurnFailureError(stage, err);
return {
stage,
error,
errorName: err instanceof Error ? err.name : typeof err,
errorCode: getErrorCode(err),
errorStack: err instanceof Error ? err.stack : undefined,
};
}
function buildTurnFailureError(stage: AgentHostTurnFailureStage, err: unknown): ErrorInfo {
agentSideEffects.ts ×7
const message = String(err);
const forwarded = tryParseForwardedChatError(err instanceof Error ? err.message : message);
const errorType = stage === 'modelSelection' ? 'modelSelectionFailed'
: stage === 'workingDirectory' ? 'workingDirectoryFailed' : 'sendFailed';
agentSideEffects.ts ×1
return { errorType, message: stripProxyErrorMarker(message), _meta: toChatErrorMeta(forwarded) };
}
}