Skip to content

Commit 4406585

Browse files
connor4312CopilotdmitrivMS
authored
agentHost: observe dispatched actions through a chat contribution hook (#333238)
* agentHost: report every terminal turn outcome through onTurnEnd Closes three gaps where a turn ended without telling contributions. A turn that captured a start checkpoint and then failed to send never scheduled its changeset recompute, because `captureTurnStartCheckpoint` runs before `sendMessage` but the catch never reported the end. - Reports a turn end when a send throws. This is the bug: `CheckpointAndChangesetContribution` handles `kind === 'error'` and never heard about these turns, so their changeset recompute and unread marking did not run. - Reports a turn end for a client-dispatched cancellation, which the agent-signal path already did. This fires exactly once: the reducer clears the active turn first, so a later agent-emitted cancellation takes the no-active-turn path, which reports only completions. - Adds a `rejected` reason for requests refused before their turn started, and fires it from both `startTurn` early returns: a rejected admission and a missing provider. This restores the per-rejection observability that hoisting the admission gate removed. - Makes `MarkUnreadContribution` ignore `rejected`. It filtered negatively, so the new reason would have marked a session unread for a request that never ran, and a rejection on an archived session would resurface it. - Leaves `QueueDrainContribution` untouched for `rejected`. A rejected queued turn consumes its message and the queue stalls, but draining would cascade an archived chat through every queued message. The fix is to return the message to the queue, which is recorded as such. - Adds tests for all three paths, including that a rejected turn end does not resurface a read session. Both new `agentSideEffects` tests were confirmed to fail without the fix. Rewrites `chatContributions/TODO.md` as a backlog. It had grown into a changelog of finished extractions and a design-decision record, and two of its claims were already wrong: the side-chat migration it listed as pending was complete, and `githubReferences` was filed under outgoing-turn contributions although it only implements `onTurnEnd`. Rationale that had no other home moved into JSDoc beside the code it explains. (Commit message generated by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * agentHost: observe dispatched actions through a chat contribution hook Moves session input-needed aggregation, turn-usage persistence, and session flag persistence out of `AgentSideEffects`, which loses 252 lines. All three observed `AgentHostStateManager.onDidEmitEnvelope` rather than the client dispatch path, so none of them could use the existing action hook: most of the actions they react to are dispatched by the host, not by a client. - Adds `onDidDispatchAction`, which observes an action from any origin once its outcome is known, including server-dispatched actions and rejected ones that never reduced. It is a separate hook and not a widening of the client hook, because `queueDrain` depends on that hook being client-only and would otherwise see each client action twice. - Adds `SessionInputNeededContribution` (order 200), which mirrors per-chat blockers into the owning session's `inputNeeded` list. This is the bulk of the move at roughly 170 lines. - Gives `PersistedTurnUsageContribution` the write half of turn-usage persistence. It already owned the restore half, so one contribution now owns both directions, as chat drafts and chat titles already do. - Adds `SessionFlagsContribution` (order 700) for read state, archived state, and merged config values. It preserves an asymmetry that is easy to lose: read and archived changes skip a rejected action, config values do not. - Makes `AgentHostToolCallTracker` injectable. Input-needed reports blocked and unblocked tool calls to it, and it was constructed inside `AgentSideEffects`, so a contribution could not reach it. Renames the two action hooks so each says what it observes: - `onAction` becomes `onDidApplyClientAction`, and `IObservedAction` becomes `IAppliedClientAction`. Rejected client actions never reach it, so an action it sees always reduced. - `onEnvelope` becomes `onDidDispatchAction`, and `IObservedEnvelope` becomes `IDispatchedAction`. It also delivers rejected actions, so a name claiming they were applied would be wrong. Registers the built-in contributions in the tool-call and turn-hang telemetry test graphs. Both build `AgentSideEffects` without them, so they stopped exercising blocked-call telemetry once that reporting moved into a contribution. The graphs now mirror production wiring; no assertion changed. Updates the contributions skill, which described four hooks and named two that no longer exist. It now documents all seven, records that the admission hook fails closed and is synchronous while every other hook isolates failures, and carries the design rationale that `TODO.md` used to hold. `TODO.md` keeps only open work and caveats, with each fact in one place. (Commit message generated by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * agentHost: fix cyclic dependency and rejected-action handling from review - Moves `toolSourceKindFromContributor` and `canRefineContributor` into `node/shared/toolCallContributor.ts`. Injecting the turn tracker into the tool-call tracker made the two import each other at runtime, which failed the cyclic dependency check. - Reports a turn end only when a turn actually ended. `AgentHostTurnTracker.turnCompleted` now returns whether it had a tracked turn, and the client cancellation and failed-send paths use that. The reducer no-ops a stale or duplicate cancellation, so reporting one unconditionally marked a read session unread for a turn that never stopped. - Reports a turn end when a resumed turn fails to send. That path dispatched `ChatError` and completed tracking without telling contributions, so it stayed invisible. - Skips rejected actions in `sessionInputNeeded` and `persistedTurnUsage`. A rejected action never reduced, so mirroring it can clear a blocker that is still outstanding, and persisting it can write durable state that was refused. - Starts the turn through `handleAction` in the client-cancellation test, as `_dispatchActionNow` does. The shared `startTurn` helper only dispatches to state, so the turn was never tracked and the test did not reflect production wiring. - Adds a test that a stale cancellation reports no turn end, and corrects a stale `_chatContributions.action(...)` reference in the backlog. (Commit message generated by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Dmitriy Vasyura <dmitriv@microsoft.com>
1 parent a39698f commit 4406585

25 files changed

Lines changed: 1076 additions & 635 deletions

‎.github/skills/agent-host-chat-contributions/SKILL.md‎

Lines changed: 105 additions & 33 deletions
Large diffs are not rendered by default.

‎src/vs/platform/agentHost/common/agentHostChatContributionsService.ts‎

Lines changed: 55 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,16 @@ export const IAgentHostChatContributions = createDecorator<IAgentHostChatContrib
1616

1717
/**
1818
* Why a turn ended. Contributions must discriminate on `kind`: successful,
19-
* cancelled, failed, and host-handled local-command turns deliberately run
20-
* different side effects. `success`, `cancelled`, and `localCommand` carry no
21-
* payload on purpose; add fields only when a contribution actually needs them.
19+
* cancelled, failed, rejected, and host-handled local-command turns deliberately
20+
* run different side effects. A rejected request was refused or could not be
21+
* routed before its turn started, so contributions that finalize started turns
22+
* must ignore it because there is no turn or checkpoint to finalize.
2223
*/
2324
export type TurnEndReason =
2425
| { readonly kind: 'success' }
2526
| { readonly kind: 'cancelled' }
2627
| { readonly kind: 'error'; readonly error: ErrorInfo; readonly resumable: boolean }
28+
| { readonly kind: 'rejected'; readonly error: ErrorInfo }
2729
| { readonly kind: 'localCommand' };
2830

2931
/** A terminal turn outcome offered to contributions after a turn ended. */
@@ -123,14 +125,31 @@ export interface IRestoredChat {
123125
}
124126

125127
/** A client action after it has been reduced into host state. */
126-
export interface IObservedAction {
128+
export interface IAppliedClientAction {
127129
readonly channel: ProtocolURI;
128130
readonly session: ProtocolURI;
129131
readonly action: StateAction;
130132
readonly clientId: string | undefined;
131133
readonly clientContext: IAgentHostClientTelemetryContext;
132134
}
133135

136+
/**
137+
* A dispatched action, from any origin, after its outcome is known.
138+
*
139+
* Unlike {@link IAppliedClientAction}, this covers server-dispatched actions — which is
140+
* most agent-produced state — and rejected actions, which never reduced. A contribution
141+
* that only cares about client dispatch should use `onDidApplyClientAction` instead;
142+
* observing both would see the same client action twice.
143+
*/
144+
export interface IDispatchedAction {
145+
readonly channel: ProtocolURI;
146+
/** The owning session URI, or the channel itself when it is not a chat channel. */
147+
readonly session: ProtocolURI;
148+
readonly action: StateAction;
149+
/** Set when the action was rejected and never reached state. */
150+
readonly rejectionReason?: string;
151+
}
152+
134153
export interface IQueuedMessageSender {
135154
readonly clientId: string | undefined;
136155
readonly clientContext: IAgentHostClientTelemetryContext;
@@ -205,21 +224,44 @@ export interface IAgentHostChatContribution extends IDisposable {
205224
*/
206225
readonly order?: number;
207226
/**
208-
* Fires when a turn ends through the agent signal path, or when a host handled
209-
* local command completes. It does NOT fire for a client dispatched
210-
* cancellation, nor for the failures that report `ChatError` directly, such as
211-
* a missing provider, a read-only or archived chat, or a send that throws.
212-
* Unifying those paths is tracked in `chatContributions/TODO.md`.
227+
* Fires on every terminal outcome a started turn can reach — the agent signal
228+
* path, a host-handled local command, a send that throws, and a cancellation
229+
* from either the client or the agent — and when admission refuses a request
230+
* before its turn starts. Discriminate `rejected` from `error`: the former has
231+
* no started turn to finalize and no checkpoint to capture.
232+
*
233+
* It does not fire for an agent-emitted terminal action that arrives when no
234+
* turn is active, because the reducer no-ops for those.
213235
*
214236
* Must not throw; the dispatcher isolates failures.
215237
*/
216238
onTurnEnd?(turn: ITurnEnd): void;
217-
/** Observes actions submitted through the client dispatch path after state reduction. */
218-
onAction?(action: IObservedAction): void;
239+
/**
240+
* Observes a client-dispatched action after it was applied to host state.
241+
* Rejected client actions never reach this hook, so an action seen here always
242+
* reduced.
243+
*/
244+
onDidApplyClientAction?(action: IAppliedClientAction): void;
245+
/**
246+
* Observes a dispatched action from any origin, after its outcome is known.
247+
* This covers server-dispatched actions — which is most agent-produced state — and
248+
* rejected actions, which never reduced. Check `rejectionReason` before acting on
249+
* one.
250+
*
251+
* Prefer `onDidApplyClientAction` when a behavior genuinely only concerns client
252+
* dispatch: a contribution implementing both sees every client action twice.
253+
*
254+
* Must not throw; the dispatcher isolates failures.
255+
*/
256+
onDidDispatchAction?(dispatched: IDispatchedAction): void;
219257
/**
220258
* Awaited before the turn is sent. Instructions are concatenated in `order`;
221259
* text replacements are threaded in `order`, so each contribution observes
222260
* the prior contribution's text. Failures are isolated and do not block the send.
261+
*
262+
* Runs after admission and after the provider lookup, so a rejected turn and a turn
263+
* that fails with `noAgent` never reach it: only messages that are actually sent are
264+
* enriched.
223265
*/
224266
onOutgoingTurn?(turn: IOutgoingTurn): ISendContribution | undefined | Promise<ISendContribution | undefined>;
225267
/**
@@ -271,7 +313,8 @@ export interface IAgentHostChatContributions extends IDisposable {
271313
/** Returns the currently registered host, if AgentSideEffects has been constructed. */
272314
getHost(): IAgentHostChatContributionHost | undefined;
273315
turnEnd(turn: ITurnEnd): void;
274-
action(action: IObservedAction): void;
316+
didApplyClientAction(action: IAppliedClientAction): void;
317+
didDispatchAction(dispatched: IDispatchedAction): void;
275318
outgoingTurn(turn: IOutgoingTurn): Promise<IOutgoingTurnContributionResult>;
276319
incomingRequest(request: IIncomingRequest): IncomingRequestDisposition;
277320
hydrateTurns(context: IHydrationContext, turns: readonly Turn[]): Promise<readonly Turn[]>;

‎src/vs/platform/agentHost/node/agentHostChatContributionsService.ts‎

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { NKeyMap } from '../../../base/common/map.js';
88
import { observableValue, type ISettableObservable } from '../../../base/common/observable.js';
99
import { IInstantiationService, type IConstructorSignature } from '../../instantiation/common/instantiation.js';
1010
import { ILogService } from '../../log/common/log.js';
11-
import type { IAgentHostChatContribution, IAgentHostChatContributionContext, IAgentHostChatContributionHost, IAgentHostChatContributions, IChatMementoKey, IHydrationContext, IIncomingRequest, IObservedAction, IOutgoingTurn, IOutgoingTurnContributionResult, IncomingRequestDisposition, IRestoredChat, ISessionMementoKey, ITurnEnd } from '../common/agentHostChatContributionsService.js';
11+
import type { IAgentHostChatContribution, IAgentHostChatContributionContext, IAgentHostChatContributionHost, IAgentHostChatContributions, IChatMementoKey, IHydrationContext, IIncomingRequest, IAppliedClientAction, IDispatchedAction, IOutgoingTurn, IOutgoingTurnContributionResult, IncomingRequestDisposition, IRestoredChat, ISessionMementoKey, ITurnEnd } from '../common/agentHostChatContributionsService.js';
1212
import { isAhpChatChannel, parseRequiredSessionUriFromChatUri, type Turn, type URI as ProtocolURI } from '../common/state/sessionState.js';
1313

1414
type MementoKeySegment = string | boolean | number;
@@ -146,14 +146,28 @@ export class AgentHostChatContributions extends Disposable implements IAgentHost
146146
}
147147
}
148148

149-
action(action: IObservedAction): void {
149+
didApplyClientAction(action: IAppliedClientAction): void {
150150
for (const registration of this._getOrderedContributions()) {
151151
const { contribution } = registration;
152-
if (!contribution.onAction) {
152+
if (!contribution.onDidApplyClientAction) {
153153
continue;
154154
}
155155
try {
156-
contribution.onAction(action);
156+
contribution.onDidApplyClientAction(action);
157+
} catch (err) {
158+
this._logContributionFailure(registration, err);
159+
}
160+
}
161+
}
162+
163+
didDispatchAction(dispatched: IDispatchedAction): void {
164+
for (const registration of this._getOrderedContributions()) {
165+
const { contribution } = registration;
166+
if (!contribution.onDidDispatchAction) {
167+
continue;
168+
}
169+
try {
170+
contribution.onDidDispatchAction(dispatched);
157171
} catch (err) {
158172
this._logContributionFailure(registration, err);
159173
}

‎src/vs/platform/agentHost/node/agentHostServices.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ import { AgentHostSessionOpenTelemetry, IAgentHostSessionOpenTelemetry } from '.
5555
import { AgentHostStorageService, IAgentHostStorageService } from './agentHostStorageService.js';
5656
import { AgentHostTerminalManager, IAgentHostTerminalManager } from './agentHostTerminalManager.js';
5757
import { AgentHostTelemetryReporter, IAgentHostTelemetryReporter } from './agentHostTelemetryReporter.js';
58+
import { AgentHostToolCallTracker, IAgentHostToolCallTracker } from './agentHostToolCallTracker.js';
5859
import { AgentHostTurnTracker, IAgentHostTurnTracker } from './agentHostTurnTracker.js';
5960
import { AgentHostProviderService, IAgentHostProviderService } from './agentHostProviderService.js';
6061
import { AgentEditAttributionService } from './shared/agentEditAttributionService.js';
@@ -100,6 +101,7 @@ export function registerAgentHostCoreServices(services: ServiceCollection, input
100101
services.set(IAgentHostChatContributions, new SyncDescriptor(AgentHostChatContributions));
101102
services.set(IAgentHostTelemetryReporter, new SyncDescriptor(AgentHostTelemetryReporter));
102103
services.set(IAgentHostTurnTracker, new SyncDescriptor(AgentHostTurnTracker));
104+
services.set(IAgentHostToolCallTracker, new SyncDescriptor(AgentHostToolCallTracker));
103105
services.set(IAgentHostProviderService, new SyncDescriptor(AgentHostProviderService));
104106
services.set(IAgentBranchNameGenerator, new SyncDescriptor(AgentBranchNameGenerator));
105107
services.set(IAgentHostWorktreeIsolation, new SyncDescriptor(WorktreeIsolation));

‎src/vs/platform/agentHost/node/agentHostToolCallTracker.ts‎

Lines changed: 12 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,13 @@
66
import { disposableTimeout } from '../../../base/common/async.js';
77
import { Disposable, DisposableMap } from '../../../base/common/lifecycle.js';
88
import { StopWatch } from '../../../base/common/stopwatch.js';
9+
import { createDecorator } from '../../instantiation/common/instantiation.js';
910
import type { IAgentHostClientTelemetryContext } from '../common/agentHostTelemetry.js';
1011
import type { SessionToolAuthenticationRequest, SessionToolClientExecutionRequest, SessionToolConfirmationRequest } from '../common/state/protocol/state.js';
11-
import { ToolCallContributorKind, type ToolCallContributor, type ToolCallResult } from '../common/state/sessionState.js';
12-
import type { AgentHostModelTelemetryKind, AgentHostTelemetryReporter, IAgentHostToolInvokedReport } from './agentHostTelemetryReporter.js';
12+
import { type ToolCallContributor, type ToolCallResult } from '../common/state/sessionState.js';
13+
import { IAgentHostTelemetryReporter, type AgentHostModelTelemetryKind, type AgentHostTelemetryReporter, type IAgentHostToolInvokedReport } from './agentHostTelemetryReporter.js';
14+
import { IAgentHostTurnTracker, type AgentHostTurnTracker } from './agentHostTurnTracker.js';
15+
import { canRefineContributor, toolSourceKindFromContributor } from './shared/toolCallContributor.js';
1316

1417
export type ToolInvokedResult = 'success' | 'error' | 'userCancelled';
1518

@@ -34,41 +37,6 @@ export function deriveToolInvokedResult(result: ToolCallResult): ToolInvokedResu
3437
return 'error';
3538
}
3639

37-
/**
38-
* Maps a tool call's contributor to the telemetry `toolSourceKind`. A tool with
39-
* no contributor is provided by the agent host itself; an MCP contributor maps
40-
* to `mcp` and a client contributor to `client`.
41-
*/
42-
export function toolSourceKindFromContributor(contributor: ToolCallContributor | undefined): string {
43-
if (!contributor) {
44-
return 'agentHost';
45-
}
46-
// Widen to `string` so an unrecognized kind from a newer protocol version
47-
// falls through to a valid telemetry value rather than `undefined`.
48-
const kind: string = contributor.kind;
49-
switch (kind) {
50-
case ToolCallContributorKind.MCP:
51-
return 'mcp';
52-
case ToolCallContributorKind.Client:
53-
return 'client';
54-
default:
55-
return kind;
56-
}
57-
}
58-
59-
/**
60-
* Whether `next` is a safe refinement of the currently recorded contributor.
61-
* A client contributor may only be refined by the same client, and a client
62-
* contributor is never replaced by a non-client one, so execution ownership
63-
* cannot be reassigned by a later, less specific signal.
64-
*/
65-
export function canRefineContributor(current: ToolCallContributor | undefined, next: ToolCallContributor): boolean {
66-
if (current?.kind === ToolCallContributorKind.Client) {
67-
return next.kind === ToolCallContributorKind.Client && next.clientId === current.clientId;
68-
}
69-
return next.kind !== ToolCallContributorKind.Client;
70-
}
71-
7240
/** Per-tool-call timing state, keyed by `session:toolCallId`. */
7341
interface IToolCallTiming {
7442
readonly lifecycleStopWatch: StopWatch;
@@ -104,17 +72,21 @@ interface IStalledToolCall {
10472
* tool call) are dropped via {@link clearSession} / {@link clear} so the
10573
* tracking map cannot leak.
10674
*/
75+
export const IAgentHostToolCallTracker = createDecorator<AgentHostToolCallTracker>('agentHostToolCallTracker');
76+
10777
export class AgentHostToolCallTracker extends Disposable {
10878

79+
declare readonly _serviceBrand: undefined;
80+
10981
private readonly _toolCalls = new Map<string, IToolCallTiming>();
11082
private readonly _turnModels = new Map<string, { model: string; modelTelemetryKind: AgentHostModelTelemetryKind }>();
11183
private readonly _pendingToolReports = new Map<string, IAgentHostToolInvokedReport[]>();
11284
private readonly _toolCallStallTimers = this._register(new DisposableMap<string>());
11385
private readonly _stalledToolCalls = new Map<string, IStalledToolCall>();
11486

11587
constructor(
116-
private readonly _reporter: AgentHostTelemetryReporter,
117-
private readonly _getClientContext: (session: string, turnId: string) => IAgentHostClientTelemetryContext | undefined = () => undefined,
88+
@IAgentHostTelemetryReporter private readonly _reporter: AgentHostTelemetryReporter,
89+
@IAgentHostTurnTracker private readonly _turnTracker: AgentHostTurnTracker,
11890
) {
11991
super();
12092
}
@@ -132,7 +104,7 @@ export class AgentHostToolCallTracker extends Disposable {
132104
model: resolvedModel?.model ?? model,
133105
modelTelemetryKind: resolvedModel?.modelTelemetryKind ?? modelTelemetryKind,
134106
modelResolvedFromUsage: resolvedModel !== undefined,
135-
clientContext: this._getClientContext(session, turnId),
107+
clientContext: this._turnTracker.getClientTelemetryContext(session, turnId),
136108
});
137109
}
138110

‎src/vs/platform/agentHost/node/agentHostTurnStarter.ts‎

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import type { IAgent } from '../common/agent.js';
1010
import type { IAgentHostClientTelemetryContext } from '../common/agentHostTelemetry.js';
1111
import { IAgentHostChatContributions } from '../common/agentHostChatContributionsService.js';
1212
import { ActionType } from '../common/state/sessionActions.js';
13-
import { createErrorResponsePart, type Message, type URI as ProtocolURI } from '../common/state/sessionState.js';
13+
import { createErrorResponsePart, type ErrorInfo, type Message, type URI as ProtocolURI } from '../common/state/sessionState.js';
1414
import { createAgentChatContext } from './agentChatContext.js';
1515
import { IAgentHostStateManager } from './agentHostStateManager.js';
1616
import { IAgentHostProviderService } from './agentHostProviderService.js';
@@ -37,7 +37,22 @@ export interface ITurnStartRequest {
3737
readonly turnStopWatch: StopWatch;
3838
}
3939

40-
/** Admits a turn, records its telemetry, and resolves the provider that will send it. */
40+
/**
41+
* Admits a turn, records its telemetry, and resolves the provider that will send it.
42+
* Both the direct (`handleAction`) and queued (`QueueDrainContribution`) admission paths
43+
* run this, so the preamble has one copy.
44+
*
45+
* This is a plain function rather than a service because it holds no state: registering it
46+
* in DI bought nothing and cost a registration in every hand-built test service graph. Keep
47+
* it a function unless it acquires state.
48+
*
49+
* The gate runs before {@link IAgentHostTurnTracker.turnStarted}, so a refused request has
50+
* no turn to complete: it reports neither `userMessageSent` nor `turnCompleted`, and does
51+
* not call `_completeTurn` or clear the tool-call tracker. That is deliberate, not an
52+
* oversight — a rejection is observable through `onTurnEnd` with `kind === 'rejected'`, so
53+
* measure rejection volume from a contribution rather than by reviving the turn-completion
54+
* path.
55+
*/
4156
export function startTurn(accessor: ServicesAccessor, request: ITurnStartRequest): IStartedTurn | undefined {
4257
const chatContributions = accessor.get(IAgentHostChatContributions);
4358
const stateManager = accessor.get(IAgentHostStateManager);
@@ -66,6 +81,13 @@ export function startTurn(accessor: ServicesAccessor, request: ITurnStartRequest
6681
duration: Math.max(0, request.turnStopWatch.elapsed()),
6782
part: createErrorResponsePart(disposition.error),
6883
});
84+
chatContributions.turnEnd({
85+
session: request.session,
86+
channel: request.turnChannel,
87+
turnId: request.turnId,
88+
reason: { kind: 'rejected', error: disposition.error },
89+
clientContext: request.clientContext,
90+
});
6991
return undefined;
7092
}
7193

@@ -77,11 +99,19 @@ export function startTurn(accessor: ServicesAccessor, request: ITurnStartRequest
7799

78100
const agent = providerService.getProviderForSession(request.session);
79101
if (!agent) {
102+
const error: ErrorInfo = { errorType: 'noAgent', message: 'No agent found for session' };
80103
stateManager.dispatchServerAction(request.turnChannel, {
81104
type: ActionType.ChatError,
82105
turnId: request.turnId,
83106
duration: Math.max(0, request.turnStopWatch.elapsed()),
84-
part: createErrorResponsePart({ errorType: 'noAgent', message: 'No agent found for session' }),
107+
part: createErrorResponsePart(error),
108+
});
109+
chatContributions.turnEnd({
110+
session: request.session,
111+
channel: request.turnChannel,
112+
turnId: request.turnId,
113+
reason: { kind: 'rejected', error },
114+
clientContext: request.clientContext,
85115
});
86116
return undefined;
87117
}

‎src/vs/platform/agentHost/node/agentHostTurnTracker.ts‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import { createUnknownAgentHostClientTelemetryContext, type IAgentHostClientTele
1616
import { AgentHostClientType } from '../common/agentHostClientInfo.js';
1717
import { IAgentHostClientConnectionService } from './agentHostClientConnectionService.js';
1818
import { ILogService } from '../../log/common/log.js';
19-
import { canRefineContributor, toolSourceKindFromContributor } from './agentHostToolCallTracker.js';
19+
import { canRefineContributor, toolSourceKindFromContributor } from './shared/toolCallContributor.js';
2020
import { SessionInputRequestKind } from '../common/state/protocol/state.js';
2121
import type { ITurnTokenTotal, ToolCallContributor } from '../common/state/sessionState.js';
2222
import { IAgentHostTelemetryReporter, type AgentHostInitiatorClientConnectionState, type AgentHostMessageOriginTelemetryKind, type AgentHostModelTelemetryKind, type AgentHostProviderDiagnosticState, type AgentHostTelemetryReporter, type AgentHostTurnFailureStage, type AgentHostTurnHangReason, type AgentHostTurnResult, type IAgentHostTurnFailure } from './agentHostTelemetryReporter.js';
@@ -374,11 +374,17 @@ export class AgentHostTurnTracker extends Disposable {
374374
return this._turnTimings.get(this._key(session, turnId))?.initiatorClientId;
375375
}
376376

377-
turnCompleted(session: string, turnId: string, result: AgentHostTurnResult, failure?: IAgentHostTurnFailure, workspace?: { readonly isMultiRoot: boolean; readonly folderCount: number }): void {
377+
/**
378+
* Records a turn's completion. Returns whether a tracked turn actually ended:
379+
* `false` means no turn was in flight for `turnId`, which happens for a stale or
380+
* duplicate terminal action that the reducer also no-ops. Callers that run
381+
* end-of-turn side effects should skip them when this returns `false`.
382+
*/
383+
turnCompleted(session: string, turnId: string, result: AgentHostTurnResult, failure?: IAgentHostTurnFailure, workspace?: { readonly isMultiRoot: boolean; readonly folderCount: number }): boolean {
378384
const key = this._key(session, turnId);
379385
const timing = this._turnTimings.get(key);
380386
if (!timing) {
381-
return;
387+
return false;
382388
}
383389
const usage = this._turnUsages.get(key);
384390
this._disposeTurn(key, timing);
@@ -425,6 +431,7 @@ export class AgentHostTurnTracker extends Disposable {
425431
timeAfterHangMs: timing.lastHangStopWatch?.elapsed() ?? 0,
426432
});
427433
}
434+
return true;
428435
}
429436

430437
/**

0 commit comments

Comments
 (0)