Path: blob/main/src/vs/platform/agentHost/node/agentSideEffects.ts
13394 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { disposableTimeout, SequencerByKey } from '../../../base/common/async.js';6import { Disposable, DisposableMap, DisposableStore, IDisposable } from '../../../base/common/lifecycle.js';7import { equals } from '../../../base/common/objects.js';8import { autorun, IObservable, IReader } from '../../../base/common/observable.js';9import { hasKey } from '../../../base/common/types.js';10import { URI } from '../../../base/common/uri.js';11import { generateUuid } from '../../../base/common/uuid.js';12import { ILogService } from '../../log/common/log.js';13import { IInstantiationService } from '../../instantiation/common/instantiation.js';14import { AgentSignal, IAgent, IAgentAttachment, IAgentToolPendingConfirmationSignal } from '../common/agentService.js';15import { IDiffComputeService } from '../common/diffComputeService.js';16import { ISessionDatabase, ISessionDataService } from '../common/sessionDataService.js';17import type { AgentInfo } from '../common/state/protocol/state.js';18import { ActionType, isSessionAction, StateAction, type SessionToolCallCompleteAction } from '../common/state/sessionActions.js';19import {20PendingMessageKind,21ResponsePartKind,22SessionStatus,23ToolCallStatus,24ToolResultContentType,25buildSubagentSessionUri,26getToolFileEdits,27type SessionState,28type ToolResultContent,29type ISessionFileDiff,30type URI as ProtocolURI,31} from '../common/state/sessionState.js';32import { AgentHostStateManager } from './agentHostStateManager.js';33import { IAgentHostGitService, META_DIFF_BASE_BRANCH } from './agentHostGitService.js';34import { NodeWorkerDiffComputeService } from './diffComputeService.js';35import { computeSessionDiffs, type IIncrementalDiffOptions } from './sessionDiffAggregator.js';36import { SessionPermissionManager } from './sessionPermissions.js';3738/**39* Options for constructing an {@link AgentSideEffects} instance.40*/41export interface IAgentSideEffectsOptions {42/** Resolve the agent responsible for a given session URI. */43readonly getAgent: (session: ProtocolURI) => IAgent | undefined;44/** Observable set of registered agents. Triggers `root/agentsChanged` when it changes. */45readonly agents: IObservable<readonly IAgent[]>;46/** Session data service for cleaning up per-session data on disposal. */47readonly sessionDataService: ISessionDataService;48/**49* Called after each top-level session turn completes so git state can be50* refreshed and published via `SessionMetaChanged`. Subagent turns are51* excluded — only the parent session URI is passed.52*/53readonly onTurnComplete: (session: ProtocolURI) => void;54}5556/** A signal that was deferred because its subagent session does not exist yet. */57interface IPendingSubagentSignal {58readonly signal: AgentSignal;59readonly agent: IAgent;60}6162/**63* Shared implementation of agent side-effect handling.64*65* Routes client-dispatched actions to the correct agent backend,66* restores sessions from previous lifetimes, handles filesystem67* operations (browse/fetch/write), tracks pending permission requests,68* and wires up agent progress events to the state manager.69*70* Session create/dispose/list and auth are handled by {@link AgentService}.71*/72export class AgentSideEffects extends Disposable {7374/** Maps tool call IDs to the agent that owns them, for routing confirmations. */75private readonly _toolCallAgents = new Map<string, string>();76/** Shared diff compute service for calculating line-level diffs in a worker thread. */77private readonly _diffComputeService: IDiffComputeService;78/** Serializes per-session diff computations to avoid races with stale previousDiffs. */79private readonly _diffComputationSequencer = new SequencerByKey<string>();80private _lastAgentInfos: readonly AgentInfo[] = [];81/** Per-session debounce timers for mid-turn diff computation. */82private readonly _debouncedDiffTimers = this._register(new DisposableMap<string>());83private static readonly _DIFF_DEBOUNCE_MS = 5000;8485private readonly _permissionManager: SessionPermissionManager;8687/**88* Maps `parentSession:toolCallId` → subagent session URI.89* Used to route signals with `parentToolCallId` to the correct subagent.90*/91private readonly _subagentSessions = new Map<string, ProtocolURI>();9293/**94* Buffers signals whose `parentToolCallId` references a subagent95* whose `subagent_started` signal has not yet been processed. The SDK is96* not strict about ordering: an inner `tool_start` can arrive before the97* `subagent_started` that creates the child session. Without buffering,98* those signals would be dispatched against the parent session and the99* UI would render the inner tool calls flat at the top level rather than100* grouping them under the subagent. Drained by `_handleSubagentStarted`.101*102* Key: `${parentSession}:${parentToolCallId}`.103*/104private readonly _pendingSubagentSignals = new Map<string, IPendingSubagentSignal[]>();105106constructor(107private readonly _stateManager: AgentHostStateManager,108private readonly _options: IAgentSideEffectsOptions,109@IInstantiationService instantiationService: IInstantiationService,110@ILogService private readonly _logService: ILogService,111@IAgentHostGitService private readonly _gitService: IAgentHostGitService,112) {113super();114this._diffComputeService = this._register(new NodeWorkerDiffComputeService(this._logService));115this._permissionManager = this._register(instantiationService.createInstance(SessionPermissionManager, this._stateManager));116117// Whenever the agents observable changes, publish to root state.118this._register(autorun(reader => {119const agents = this._options.agents.read(reader);120this._publishAgentInfos(agents, reader);121}));122123// Server-dispatched SessionToolCallComplete actions (e.g. from124// the disconnect timeout in ProtocolServerHandler) bypass125// handleAction, so the agent's SDK deferred never resolves.126// Listen for these envelopes and notify the agent directly.127this._register(this._stateManager.onDidEmitEnvelope(envelope => {128if (!envelope.origin && envelope.action.type === ActionType.SessionToolCallComplete) {129const action = envelope.action;130const agent = this._options.getAgent(action.session);131agent?.onClientToolCallComplete(URI.parse(action.session), action.toolCallId, action.result);132}133}));134}135136/**137* Publishes agent descriptors using the last known model lists.138*/139private _publishAgentInfos(agents: readonly IAgent[], reader?: IReader): void {140const infos: AgentInfo[] = agents.map(a => {141const d = a.getDescriptor();142const protectedResources = a.getProtectedResources();143const models = reader ? a.models.read(reader) : a.models.get();144const customizations = a.getCustomizations?.();145return {146provider: d.provider, displayName: d.displayName, description: d.description, models: models.map(m => ({147id: m.id,148provider: m.provider,149name: m.name,150maxContextWindow: m.maxContextWindow,151supportsVision: m.supportsVision,152policyState: m.policyState,153configSchema: m.configSchema,154})),155customizations: customizations?.length ? [...customizations] : undefined,156protectedResources: protectedResources.length > 0 ? protectedResources : undefined,157};158});159if (equals(this._lastAgentInfos, infos)) {160return;161}162this._lastAgentInfos = infos;163this._stateManager.dispatchServerAction({ type: ActionType.RootAgentsChanged, agents: infos });164}165166private async _publishSessionCustomizations(agent: IAgent, session: ProtocolURI): Promise<void> {167if (!agent.getSessionCustomizations) {168return;169}170171const customizations = await agent.getSessionCustomizations(URI.parse(session));172this._stateManager.dispatchServerAction({173type: ActionType.SessionCustomizationsChanged,174session,175customizations: [...customizations],176});177}178179private _publishSessionCustomizationsSoon(agent: IAgent, session: ProtocolURI): void {180void this._publishSessionCustomizations(agent, session).catch(err => {181this._logService.error('[AgentSideEffects] getSessionCustomizations failed', err);182});183}184185private _publishSessionCustomizationsForAgent(agent: IAgent): void {186for (const session of this._stateManager.getSessionUris()) {187if (this._options.getAgent(session) === agent) {188this._publishSessionCustomizationsSoon(agent, session);189}190}191}192193private _publishAllSessionCustomizations(): void {194for (const session of this._stateManager.getSessionUris()) {195const agent = this._options.getAgent(session);196if (agent) {197this._publishSessionCustomizationsSoon(agent, session);198}199}200}201202// ---- Initialization ----------------------------------------------------203204/**205* Initializes async resources (tree-sitter WASM) used for command206* auto-approval. Await this before any session events can arrive to207* guarantee that auto-approval checks are fully synchronous.208*/209initialize(): Promise<void> {210return this._permissionManager.initialize();211}212213// ---- Agent registration -------------------------------------------------214215/**216* Registers a progress-signal listener on the given agent so that217* {@link AgentSignal}s are routed/dispatched through the state manager.218* Returns a disposable that removes the listener.219*/220registerProgressListener(agent: IAgent): IDisposable {221const disposables = new DisposableStore();222disposables.add(agent.onDidSessionProgress(signal => {223this._handleAgentSignal(agent, signal);224}));225if (agent.onDidCustomizationsChange) {226disposables.add(agent.onDidCustomizationsChange(() => {227this._publishAgentInfos(this._options.agents.get());228this._publishSessionCustomizationsForAgent(agent);229}));230}231return disposables;232}233234/**235* Routes a single signal from `agent` to the correct session.236*237* Action signals with a `parentToolCallId` are routed to the matching238* subagent session. If the subagent session does not exist yet (the SDK239* can emit an inner `tool_start` before its `subagent_started`), the240* signal is buffered in {@link _pendingSubagentSignals} and replayed241* once the `subagent_started` arrives.242*/243private _handleAgentSignal(agent: IAgent, signal: AgentSignal): void {244const sessionKey = signal.session.toString();245246// Track tool calls so handleAction can route confirmations. Defer247// registration for inner subagent tool calls until we know which248// subagent session they belong to — otherwise we'd register them249// under the parent session key and a later `pending_confirmation`250// (which lacks251// `parentToolCallId`) could be routed against the wrong session.252if (signal.kind === 'action'253&& signal.action.type === ActionType.SessionToolCallStart254&& !signal.parentToolCallId255) {256this._toolCallAgents.set(`${sessionKey}:${signal.action.toolCallId}`, agent.id);257}258259if (signal.kind === 'subagent_started') {260this._handleSubagentStarted(sessionKey, signal.toolCallId, signal.agentName, signal.agentDisplayName, signal.agentDescription);261this._drainPendingSubagentSignals(sessionKey, signal.toolCallId);262return;263}264265if (signal.kind === 'steering_consumed') {266this._stateManager.dispatchServerAction({267type: ActionType.SessionPendingMessageRemoved,268session: sessionKey,269kind: PendingMessageKind.Steering,270id: signal.id,271});272return;273}274275// Route signals with parentToolCallId to the subagent session.276// Both action signals and pending_confirmation signals can carry277// a parentToolCallId — for client tools inside a subagent the278// permission flow fires `pending_confirmation` for an inner tool279// call, and that signal must be routed to the subagent session280// (otherwise the resulting SessionToolCallReady would land on the281// parent session, which has no matching SessionToolCallStart).282const parentToolCallId = signal.kind === 'action' || signal.kind === 'pending_confirmation'283? signal.parentToolCallId284: undefined;285if (parentToolCallId) {286const subagentKey = `${sessionKey}:${parentToolCallId}`;287const subagentSession = this._subagentSessions.get(subagentKey);288if (subagentSession) {289// Track tool calls in subagent context for confirmation routing.290if (signal.kind === 'action' && signal.action.type === ActionType.SessionToolCallStart) {291this._toolCallAgents.set(`${subagentSession}:${signal.action.toolCallId}`, agent.id);292}293const subTurnId = this._stateManager.getActiveTurnId(subagentSession);294if (subTurnId) {295this._dispatchActionForSession(signal, subagentSession, subTurnId, agent);296}297return;298}299300// Subagent session does not exist yet — buffer the signal so we can301// replay it after `subagent_started` arrives.302this._logService.trace(`[AgentSideEffects] Buffering ${this._describeSignal(signal)} for pending subagent ${subagentKey}`);303let buffer = this._pendingSubagentSignals.get(subagentKey);304if (!buffer) {305buffer = [];306this._pendingSubagentSignals.set(subagentKey, buffer);307}308buffer.push({ signal, agent });309return;310}311312// Route pending_confirmation signals for tools inside subagent sessions313// (legacy path for signals without an explicit parentToolCallId — the314// tool was previously registered under its subagent session key in315// _toolCallAgents).316if (signal.kind === 'pending_confirmation') {317const subagentSession = this._findSubagentSessionForToolCall(sessionKey, signal.state.toolCallId);318if (subagentSession) {319const subTurnId = this._stateManager.getActiveTurnId(subagentSession);320if (subTurnId) {321this._handleToolReady(signal, subagentSession, subTurnId, agent);322}323return;324}325}326327const turnId = this._stateManager.getActiveTurnId(sessionKey);328if (turnId) {329this._dispatchActionForSession(signal, sessionKey, turnId, agent);330return;331}332333// No active turn on the session. Most signals are silently dropped,334// but a `SessionTurnComplete` (idle) still needs to drive its335// post-turn side effects — flushing pending diff computation,336// recomputing diffs, and notifying the host. Tests routinely fire337// `idle` without first dispatching the matching `SessionTurnStarted`338// through the state manager.339if (signal.kind === 'action' && signal.action.type === ActionType.SessionTurnComplete) {340this._runTurnCompleteSideEffects(sessionKey, undefined);341}342}343344/**345* Dispatches a signal against a resolved session+turn. Performs the346* subagent-content merge for tool_complete and the related side effects.347*/348private _dispatchActionForSession(signal: AgentSignal, sessionKey: ProtocolURI, turnId: string, agent?: IAgent): void {349if (signal.kind === 'pending_confirmation') {350if (agent) {351this._handleToolReady(signal, sessionKey, turnId, agent);352}353return;354}355if (signal.kind !== 'action') {356return;357}358// The agent emits actions with its own view of the active turnId359// targeting the top-level session. The state manager is the source360// of truth — rewrite `session` and `turnId` so the action lands in361// the right reducer (subagent session for routed signals, queued362// turn ID when the agent hasn't yet seen `sendMessage`, etc.).363// Actions without a `turnId` field (`SessionTitleChanged`,364// `SessionInputRequested`) only get their `session` rewritten.365let action = signal.action;366if (isSessionAction(action) && action.session !== sessionKey) {367action = { ...action, session: sessionKey };368}369if (hasKey(action, { turnId: true }) && action.turnId !== turnId) {370action = { ...action, turnId };371}372373// When a parent tool call has an associated subagent session,374// preserve the subagent content metadata in the completion result.375// The SDK's tool_complete provides its own content which would376// overwrite the ToolResultSubagentContent that was set via377// SessionToolCallContentChanged while running.378if (action.type === ActionType.SessionToolCallComplete) {379const subagentKey = `${sessionKey}:${action.toolCallId}`;380const subagentUri = this._subagentSessions.get(subagentKey);381if (subagentUri) {382const parentState = this._stateManager.getSessionState(sessionKey);383const runningContent = this._getRunningToolCallContent(parentState, turnId, action.toolCallId);384const subagentEntry = runningContent.find(c => hasKey(c, { type: true }) && c.type === ToolResultContentType.Subagent);385if (subagentEntry) {386const mergedContent = [...(action.result.content ?? []), subagentEntry];387const merged: SessionToolCallCompleteAction = { ...action, result: { ...action.result, content: mergedContent } };388action = merged;389}390}391}392393this._stateManager.dispatchServerAction(action);394395if (action.type === ActionType.SessionToolCallComplete) {396this.completeSubagentSession(sessionKey, action.toolCallId);397if (getToolFileEdits(action.result).length > 0) {398this._scheduleDebouncedDiffComputation(sessionKey, turnId);399}400}401402if (action.type === ActionType.SessionTurnComplete) {403this._runTurnCompleteSideEffects(sessionKey, turnId);404}405}406407/**408* Post-turn side effects: flush any pending debounced diff computation,409* compute final diffs immediately, drain the next queued message, and410* notify the host so it can refresh git state.411*/412private _runTurnCompleteSideEffects(sessionKey: ProtocolURI, turnId: string | undefined): void {413this._cancelDebouncedDiffComputation(sessionKey);414this._computeSessionDiffs(sessionKey, turnId);415this._tryConsumeNextQueuedMessage(sessionKey);416this._options.onTurnComplete(sessionKey);417}418419private _describeSignal(signal: AgentSignal): string {420return signal.kind === 'action' ? `action(${signal.action.type})` : signal.kind;421}422423/**424* Replays any signals that were buffered while waiting for425* `subagent_started` to create the subagent session. Called immediately426* after `_handleSubagentStarted`.427*/428private _drainPendingSubagentSignals(parentSession: ProtocolURI, parentToolCallId: string): void {429const subagentKey = `${parentSession}:${parentToolCallId}`;430const buffer = this._pendingSubagentSignals.get(subagentKey);431if (!buffer) {432return;433}434this._pendingSubagentSignals.delete(subagentKey);435this._logService.trace(`[AgentSideEffects] Draining ${buffer.length} buffered signal(s) for subagent ${subagentKey}`);436for (const { signal, agent } of buffer) {437this._handleAgentSignal(agent, signal);438}439}440441// ---- Subagent session management ----------------------------------------442443/**444* Creates a subagent session in response to a `subagent_started` event.445* The subagent session is created silently (no `sessionAdded` notification)446* and immediately transitioned to ready with an active turn.447*/448private _handleSubagentStarted(449parentSession: ProtocolURI,450toolCallId: string,451agentName: string,452agentDisplayName: string,453agentDescription?: string,454): void {455const subagentSessionUri = buildSubagentSessionUri(parentSession, toolCallId);456const subagentKey = `${parentSession}:${toolCallId}`;457458// Already tracking this subagent459if (this._subagentSessions.has(subagentKey)) {460return;461}462463this._logService.info(`[AgentSideEffects] Creating subagent session: ${subagentSessionUri} (parent=${parentSession}, toolCallId=${toolCallId})`);464const parentState = this._stateManager.getSessionState(parentSession);465466// Create the subagent session silently (restoreSession skips notification)467this._stateManager.restoreSession(468{469resource: subagentSessionUri,470provider: 'subagent',471title: agentDisplayName,472status: SessionStatus.Idle,473createdAt: Date.now(),474modifiedAt: Date.now(),475...(parentState?.summary.project ? { project: parentState.summary.project } : {}),476},477[],478);479480// Start a turn on the subagent session481const turnId = generateUuid();482this._stateManager.dispatchServerAction({483type: ActionType.SessionTurnStarted,484session: subagentSessionUri,485turnId,486userMessage: { text: '' },487});488489this._subagentSessions.set(subagentKey, subagentSessionUri);490491// Dispatch content on the parent tool call so clients discover the subagent.492// Merge with any existing content to avoid dropping prior content blocks.493const parentTurnId = this._stateManager.getActiveTurnId(parentSession);494if (parentTurnId) {495const parentState = this._stateManager.getSessionState(parentSession);496const existingContent = this._getRunningToolCallContent(parentState, parentTurnId, toolCallId);497const mergedContent = [498...existingContent,499{500type: ToolResultContentType.Subagent as const,501resource: subagentSessionUri,502title: agentDisplayName,503agentName,504description: agentDescription,505},506];507this._stateManager.dispatchServerAction({508type: ActionType.SessionToolCallContentChanged,509session: parentSession,510turnId: parentTurnId,511toolCallId,512content: mergedContent,513});514}515}516517/**518* Gets the current content array from a running tool call, if any.519*/520private _getRunningToolCallContent(521state: SessionState | undefined,522turnId: string,523toolCallId: string,524): ToolResultContent[] {525if (!state?.activeTurn || state.activeTurn.id !== turnId) {526return [];527}528for (const rp of state.activeTurn.responseParts) {529if (rp.kind === ResponsePartKind.ToolCall && rp.toolCall.toolCallId === toolCallId && rp.toolCall.status === ToolCallStatus.Running) {530return rp.toolCall.content ? [...rp.toolCall.content] : [];531}532}533return [];534}535536/**537* Cancels all active subagent sessions for a given parent session.538*/539cancelSubagentSessions(parentSession: ProtocolURI): void {540for (const [key, subagentUri] of this._subagentSessions) {541if (key.startsWith(`${parentSession}:`)) {542const turnId = this._stateManager.getActiveTurnId(subagentUri);543if (turnId) {544this._stateManager.dispatchServerAction({545type: ActionType.SessionTurnCancelled,546session: subagentUri,547turnId,548});549}550this._subagentSessions.delete(key);551}552}553// Drop any buffered events targeted at subagents that never started.554for (const key of [...this._pendingSubagentSignals.keys()]) {555if (key.startsWith(`${parentSession}:`)) {556this._pendingSubagentSignals.delete(key);557}558}559}560561/**562* Completes all active subagent sessions for a given parent session.563* Called when a parent tool call completes.564*/565completeSubagentSession(parentSession: ProtocolURI, toolCallId: string): void {566const key = `${parentSession}:${toolCallId}`;567568// Drop any events that were buffered waiting for a `subagent_started`569// that never arrived (e.g. the parent tool failed before the subagent570// was created). Without this, the buffer entry would leak until the571// parent session is disposed.572this._pendingSubagentSignals.delete(key);573574const subagentUri = this._subagentSessions.get(key);575if (!subagentUri) {576return;577}578579const turnId = this._stateManager.getActiveTurnId(subagentUri);580if (turnId) {581this._stateManager.dispatchServerAction({582type: ActionType.SessionTurnComplete,583session: subagentUri,584turnId,585});586}587this._subagentSessions.delete(key);588}589590/**591* Removes all subagent sessions for a given parent session from592* the state manager. Called when the parent session is disposed.593*/594removeSubagentSessions(parentSession: ProtocolURI): void {595const toRemove: string[] = [];596for (const [key, subagentUri] of this._subagentSessions) {597if (key.startsWith(`${parentSession}:`)) {598this._stateManager.removeSession(subagentUri);599toRemove.push(key);600}601}602for (const key of toRemove) {603this._subagentSessions.delete(key);604}605606// Also clean up any subagent sessions that are in the state manager607// but not tracked (e.g. restored sessions)608const prefix = `${parentSession}/subagent/`;609for (const uri of this._stateManager.getSessionUrisWithPrefix(prefix)) {610this._stateManager.removeSession(uri);611}612613// Drop any buffered events targeted at subagents that never started.614for (const key of [...this._pendingSubagentSignals.keys()]) {615if (key.startsWith(`${parentSession}:`)) {616this._pendingSubagentSignals.delete(key);617}618}619}620621/**622* Finds the subagent session that owns a given tool call by checking623* whether the tool call was previously registered under a subagent624* session key in `_toolCallAgents`. Scoped to subagent sessions owned625* by the given parent to avoid cross-session collisions.626*/627private _findSubagentSessionForToolCall(parentSession: ProtocolURI, toolCallId: string): ProtocolURI | undefined {628const prefix = `${parentSession}:`;629for (const [key, subagentUri] of this._subagentSessions) {630if (key.startsWith(prefix) && this._toolCallAgents.has(`${subagentUri}:${toolCallId}`)) {631return subagentUri;632}633}634return undefined;635}636637// ---- Side-effect handlers --------------------------------------------------638639/**640* Handles a `pending_confirmation` signal end-to-end: checks for641* auto-approval via the permission manager, and if not auto-approved,642* dispatches the `SessionToolCallReady` action with confirmation options643* for the client.644*/645private _handleToolReady(e: IAgentToolPendingConfirmationSignal, sessionKey: ProtocolURI, turnId: string, agent: IAgent): void {646const approvalEvent = {647toolCallId: e.state.toolCallId,648session: e.session,649permissionKind: e.permissionKind,650permissionPath: e.permissionPath,651toolInput: e.state.toolInput,652};653const autoApproval = this._permissionManager.getAutoApproval(approvalEvent, sessionKey);654let effective = e;655if (autoApproval !== undefined) {656this._toolCallAgents.delete(`${sessionKey}:${e.state.toolCallId}`);657agent.respondToPermissionRequest(e.state.toolCallId, true);658// Strip confirmationTitle so createToolReadyAction emits the659// auto-approved (no-options) action.660effective = { ...e, state: { ...e.state, confirmationTitle: undefined } };661}662this._stateManager.dispatchServerAction(663this._permissionManager.createToolReadyAction(effective, sessionKey, turnId)664);665}666667handleAction(action: StateAction): void {668switch (action.type) {669case ActionType.SessionTurnStarted: {670// Per-turn streaming part tracking is owned by the agent671// (e.g. CopilotAgentSession) and reset on its `send()` call.672673// On the very first turn, immediately set the session title to the674// user's message so the UI shows a meaningful title right away675// while waiting for the AI-generated title. Only apply when the676// title is still the default placeholder to avoid clobbering a677// title set by the user or provider before the first turn.678const state = this._stateManager.getSessionState(action.session);679const fallbackTitle = action.userMessage.text.trim().replace(/\s+/g, ' ').slice(0, 200);680if (state && state.turns.length === 0 && !state.summary.title && fallbackTitle.length > 0) {681this._stateManager.dispatchServerAction({682type: ActionType.SessionTitleChanged,683session: action.session,684title: fallbackTitle,685});686}687688const agent = this._options.getAgent(action.session);689if (!agent) {690this._stateManager.dispatchServerAction({691type: ActionType.SessionError,692session: action.session,693turnId: action.turnId,694error: { errorType: 'noAgent', message: 'No agent found for session' },695});696return;697}698const attachments = action.userMessage.attachments?.map((a): IAgentAttachment => ({699type: a.type,700uri: URI.parse(a.uri),701displayName: a.displayName,702}));703agent.sendMessage(URI.parse(action.session), action.userMessage.text, attachments, action.turnId).catch(err => {704const errCode = (err as { code?: number })?.code;705this._logService.error(`[AgentSideEffects] sendMessage failed for session=${action.session}: code=${errCode}, message=${err instanceof Error ? err.message : String(err)}, type=${err?.constructor?.name}`, err);706this._stateManager.dispatchServerAction({707type: ActionType.SessionError,708session: action.session,709turnId: action.turnId,710error: { errorType: 'sendFailed', message: String(err) },711});712});713break;714}715case ActionType.SessionToolCallConfirmed: {716const toolCallKey = `${action.session}:${action.toolCallId}`;717const agentId = this._toolCallAgents.get(toolCallKey);718if (agentId) {719this._toolCallAgents.delete(toolCallKey);720const agent = this._options.agents.get().find(a => a.id === agentId);721agent?.respondToPermissionRequest(action.toolCallId, action.approved);722} else {723this._logService.warn(`[AgentSideEffects] No agent for tool call confirmation: ${action.toolCallId}`);724}725726// When the user chose "Allow in this Session", add the tool727// to the session's permissions so future calls are auto-approved.728if (action.approved) {729this._permissionManager.handleToolCallConfirmed(action.session, action.toolCallId, action.selectedOptionId);730}731break;732}733case ActionType.SessionInputCompleted: {734const agent = this._options.getAgent(action.session);735agent?.respondToUserInputRequest(action.requestId, action.response, action.answers);736break;737}738case ActionType.SessionTurnCancelled: {739// Cancel all subagent sessions for this parent740this.cancelSubagentSessions(action.session);741const agent = this._options.getAgent(action.session);742agent?.abortSession(URI.parse(action.session)).catch(err => {743this._logService.error('[AgentSideEffects] abortSession failed', err);744});745break;746}747case ActionType.SessionModelChanged: {748const agent = this._options.getAgent(action.session);749agent?.changeModel?.(URI.parse(action.session), action.model).catch(err => {750this._logService.error('[AgentSideEffects] changeModel failed', err);751});752break;753}754case ActionType.SessionTitleChanged: {755this._persistSessionFlag(action.session, 'customTitle', action.title);756break;757}758case ActionType.SessionPendingMessageSet:759case ActionType.SessionPendingMessageRemoved:760case ActionType.SessionQueuedMessagesReordered: {761this._syncPendingMessages(action.session);762break;763}764case ActionType.SessionTruncated: {765const agent = this._options.getAgent(action.session);766agent?.truncateSession?.(URI.parse(action.session), action.turnId).catch(err => {767this._logService.error('[AgentSideEffects] truncateSession failed', err);768});769// Turns were removed — recompute diffs from scratch (no changedTurnId)770this._computeSessionDiffs(action.session);771break;772}773case ActionType.SessionActiveClientChanged: {774const agent = this._options.getAgent(action.session);775if (!agent) {776break;777}778// Always forward client tools, even if empty, to clear previous client's tools779const clientId = action.activeClient?.clientId ?? '';780agent.setClientTools(URI.parse(action.session), clientId, action.activeClient?.tools ?? []);781782const refs = action.activeClient?.customizations ?? [];783agent.setClientCustomizations(784clientId,785refs,786() => {787this._publishSessionCustomizationsSoon(agent, action.session);788},789).then(() => {790this._publishSessionCustomizationsSoon(agent, action.session);791}).catch(err => {792this._logService.error('[AgentSideEffects] setClientCustomizations failed', err);793});794break;795}796case ActionType.RootConfigChanged: {797// Host customizations are self-managed by each agent's798// PluginController via IAgentConfigurationService.onDidRootConfigChange.799// Republish agent infos for non-customization schema changes800// (e.g. permissions) and session customizations as a catchall.801this._publishAgentInfos(this._options.agents.get());802this._publishAllSessionCustomizations();803break;804}805case ActionType.SessionActiveClientToolsChanged: {806const agent = this._options.getAgent(action.session);807if (agent) {808const sessionState = this._stateManager.getSessionState(action.session);809const toolClientId = sessionState?.activeClient?.clientId;810if (toolClientId) {811agent.setClientTools(URI.parse(action.session), toolClientId, action.tools);812}813}814break;815}816case ActionType.SessionCustomizationToggled: {817const agent = this._options.getAgent(action.session);818agent?.setCustomizationEnabled?.(action.uri, action.enabled);819break;820}821case ActionType.SessionIsReadChanged: {822this._persistSessionFlag(action.session, 'isRead', action.isRead ? 'true' : '');823break;824}825case ActionType.SessionIsArchivedChanged: {826this._persistSessionFlag(action.session, 'isArchived', action.isArchived ? 'true' : '');827const agent = this._options.getAgent(action.session);828agent?.onArchivedChanged?.(URI.parse(action.session), action.isArchived).catch(err => {829this._logService.warn(`[AgentSideEffects] onArchivedChanged failed for ${action.session}`, err);830});831break;832}833case ActionType.SessionConfigChanged: {834// Persist merged values so a future `restoreSession` can re-hydrate835// the user's previous selections (e.g. autoApprove).836const sessionState = this._stateManager.getSessionState(action.session);837const values = sessionState?.config?.values;838if (values) {839this._persistSessionFlag(action.session, 'configValues', JSON.stringify(values));840}841break;842}843case ActionType.SessionToolCallComplete: {844const agent = this._options.getAgent(action.session);845agent?.onClientToolCallComplete(URI.parse(action.session), action.toolCallId, action.result);846break;847}848}849}850851/**852* Persists a session metadata key/value pair to the session database.853* Used for fields the host needs to remember across restarts (custom854* title, isRead/isArchived flags, merged config values).855*/856private _persistSessionFlag(session: ProtocolURI, key: string, value: string): void {857const ref = this._options.sessionDataService.openDatabase(URI.parse(session));858ref.object.setMetadata(key, value).catch(err => {859this._logService.warn(`[AgentSideEffects] Failed to persist ${key}`, err);860}).finally(() => {861ref.dispose();862});863}864865/**866* Pushes the current pending message state from the session to the agent.867* The server controls queued message consumption; only steering messages868* are forwarded to the agent for mid-turn injection.869*/870private _syncPendingMessages(session: ProtocolURI): void {871const state = this._stateManager.getSessionState(session);872if (!state) {873return;874}875const agent = this._options.getAgent(session);876agent?.setPendingMessages?.(877URI.parse(session),878state.steeringMessage,879[],880);881882// Steering message removal is now dispatched by the agent883// via the 'steering_consumed' progress event once the message884// has actually been sent to the model.885886// If the session is idle, try to consume the next queued message887this._tryConsumeNextQueuedMessage(session);888}889890/**891* Consumes the next queued message by dispatching a server-initiated892* `SessionTurnStarted` action with `queuedMessageId` set. The reducer893* atomically creates the active turn and removes the message from the894* queue. Only consumes one message at a time; subsequent messages are895* consumed when the next `idle` event fires.896*/897private _tryConsumeNextQueuedMessage(session: ProtocolURI): void {898// Bail if there's already an active turn899if (this._stateManager.getActiveTurnId(session)) {900return;901}902const state = this._stateManager.getSessionState(session);903if (!state?.queuedMessages?.length) {904return;905}906907const msg = state.queuedMessages[0];908const turnId = generateUuid();909910// Per-turn streaming part tracking is owned by the agent (reset911// inside its `send()` call), so no host-side reset is needed.912913// Dispatch server-initiated turn start; the reducer removes the queued message atomically914this._stateManager.dispatchServerAction({915type: ActionType.SessionTurnStarted,916session,917turnId,918userMessage: msg.userMessage,919queuedMessageId: msg.id,920});921922// Send the message to the agent backend923const agent = this._options.getAgent(session);924if (!agent) {925this._stateManager.dispatchServerAction({926type: ActionType.SessionError,927session,928turnId,929error: { errorType: 'noAgent', message: 'No agent found for session' },930});931return;932}933const attachments = msg.userMessage.attachments?.map((a): IAgentAttachment => ({934type: a.type,935uri: URI.parse(a.uri),936displayName: a.displayName,937}));938agent.sendMessage(URI.parse(session), msg.userMessage.text, attachments, turnId).catch(err => {939this._logService.error('[AgentSideEffects] sendMessage failed (queued)', err);940this._stateManager.dispatchServerAction({941type: ActionType.SessionError,942session,943turnId,944error: { errorType: 'sendFailed', message: String(err) },945});946});947}948949// ---- Session diff computation ----------------------------------------------950951/**952* Schedules a debounced diff computation for a session. If a timer is953* already pending for this session, it is replaced (restarting the delay).954* The computation fires after {@link _DIFF_DEBOUNCE_MS} unless cancelled955* or flushed by the turn-complete handler.956*/957private _scheduleDebouncedDiffComputation(session: ProtocolURI, turnId: string): void {958// DisposableMap.set() auto-disposes any previous timer for this session959this._debouncedDiffTimers.set(session, disposableTimeout(() => {960this._debouncedDiffTimers.deleteAndDispose(session);961this._computeSessionDiffs(session, turnId);962}, AgentSideEffects._DIFF_DEBOUNCE_MS));963}964965/**966* Cancels any pending debounced diff computation for a session.967* Called at turn end before the final (non-debounced) computation.968*/969private _cancelDebouncedDiffComputation(session: ProtocolURI): void {970this._debouncedDiffTimers.deleteAndDispose(session);971}972973/**974* Asynchronously (re)computes aggregated diff statistics for a session975* and dispatches {@link ActionType.SessionDiffsChanged} to update the976* session summary. Fire-and-forget: errors are logged but do not fail977* the turn.978*/979private _computeSessionDiffs(session: ProtocolURI, changedTurnId?: string): void {980// Chain onto any pending computation for this session to ensure981// sequential access to previousDiffs (avoids stale-read races).982this._diffComputationSequencer.queue(session, () => this._doComputeSessionDiffs(session, changedTurnId));983}984985private async _doComputeSessionDiffs(session: ProtocolURI, changedTurnId?: string): Promise<void> {986let ref: ReturnType<ISessionDataService['openDatabase']>;987try {988ref = this._options.sessionDataService.openDatabase(URI.parse(session));989} catch (err) {990this._logService.warn(`[AgentSideEffects] Failed to open session database for diff computation: ${session}`, err);991return;992}993try {994// Prefer a git-driven diff so terminal-driven file changes show up995// alongside SDK-tracked tool edits. The git path is the source of996// truth whenever the working directory is a real work tree; we997// only fall back to the edit-tracker aggregator when it isn't998// (e.g. agents running in non-git scratch directories or under999// test harnesses without git).1000let diffs = await this._tryComputeGitDiffs(session, ref.object);1001if (!diffs) {1002// Build incremental options when a specific turn triggered the recomputation1003let incremental: IIncrementalDiffOptions | undefined;1004if (changedTurnId) {1005const previousDiffs = this._stateManager.getSessionState(session)?.summary.diffs;1006if (previousDiffs) {1007incremental = { changedTurnId, previousDiffs };1008}1009}1010diffs = await computeSessionDiffs(session, ref.object, this._diffComputeService, incremental);1011}10121013this._stateManager.dispatchServerAction({1014type: ActionType.SessionDiffsChanged,1015session,1016diffs: [...diffs],1017});1018// Persist diffs to the session database so they survive restarts1019ref.object.setMetadata('diffs', JSON.stringify(diffs)).catch(err => {1020this._logService.warn('[AgentSideEffects] Failed to persist session diffs', err);1021});1022} catch (err) {1023this._logService.warn('[AgentSideEffects] Failed to compute session diffs', err);1024} finally {1025ref.dispose();1026}1027}10281029/**1030* Computes session diffs by shelling out to git. Returns the diff list1031* when the session has a working directory and that directory is a git1032* work tree; returns `undefined` otherwise so the caller can fall back1033* to the edit-tracker aggregator. The base branch (anchor for the1034* `merge-base` baseline) is read from the provider-agnostic1035* {@link META_DIFF_BASE_BRANCH} metadata key — agents that create1036* worktrees write it at session-creation time.1037*/1038private async _tryComputeGitDiffs(session: ProtocolURI, db: ISessionDatabase): Promise<readonly ISessionFileDiff[] | undefined> {1039const workingDirectory = this._stateManager.getSessionState(session)?.summary.workingDirectory;1040if (!workingDirectory) {1041return undefined;1042}1043let workingDirectoryUri: URI;1044try {1045workingDirectoryUri = URI.parse(workingDirectory);1046} catch {1047return undefined;1048}1049const baseBranch = (await db.getMetadata(META_DIFF_BASE_BRANCH)) ?? undefined;1050try {1051return await this._gitService.computeSessionFileDiffs(workingDirectoryUri, { sessionUri: session, baseBranch });1052} catch (err) {1053this._logService.warn('[AgentSideEffects] git-driven diff computation failed; falling back to edit-tracker', err);1054return undefined;1055}1056}10571058override dispose(): void {1059this._toolCallAgents.clear();1060super.dispose();1061}1062}106310641065