src/vs/platform/agentHost/node/claude/claudeMapSessionEvents.ts

725 LOC · 696 covered · 29 uncovered · 124 ranges · 507 concepts · 48 introducers · 261 tests

File neighbourhood

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

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

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

Graph controls are ready.

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

1 > /*--------------------------------------------------------------------------------------------- claudeMapSessionEvents.ts ×22
2 > * Copyright (c) Microsoft Corporation. All rights reserved.
3 > * Licensed under the MIT License. See License.txt in the project root for license information.
4 > *--------------------------------------------------------------------------------------------*/
5 >
6 > import type { SDKMessage } from '@anthropic-ai/claude-agent-sdk';
7 > import type { URI } from '../../../../base/common/uri.js';
8 > import { LogLevel, type ILogService } from '../../../log/common/log.js';
9 > import type { AgentSignal } from '../../common/agentService.js';
10 > import { ActionType } from '../../common/state/sessionActions.js';
11 > import { ResponsePartKind, ToolResultContentType, type ToolResultContent, type ToolResultFileEditContent } from '../../common/state/sessionState.js';
12 > import { extractForwardedErrorInfo } from '../shared/forwardedChatError.js';
13 > import { buildTopLevelSubagentReadyAction, emitInnerAssistantSignals, mapSubagentSystemMessage, SUBAGENT_SPAWNING_TOOL_NAMES, tagWithParent } from './claudeSubagentSignals.js';
14 > import type { SubagentRegistry } from './claudeSubagentRegistry.js';
15 > import { stripClientToolNamePrefix, hasClientToolNamePrefix } from './clientTools/claudeClientToolMcpServer.js';
16 > import { buildClaudeToolMeta, getClaudePastTenseMessage, getClaudeToolDisplayName } from './claudeToolDisplay.js';
17 > import { claudeToolDenialCode } from './claudeToolDenial.js';
18 > import { ClaudeToolCallRegistry } from './claudeToolCallRegistry.js';
19 > import { ToolCallConfirmationReason, ToolCallContributorKind, type StringOrMarkdown } from '../../common/state/protocol/state.js';
20 >
21 > /**
22 > * Cross-call state for {@link mapSDKMessageToAgentSignals}. One instance
23 > * lives per {@link ClaudeAgentSession} and is threaded through every
24 > * mapper invocation for that session's lifetime.
25 > *
26 > * Three scopes:
27 > *
28 > * - **Per-message** (`activeToolBlocks`, `currentMessageId`): mirror
29 > * the SDK's per-message `BetaRawContentBlockStartEvent.index`
30 > * namespace. Reset on every `message_start`. `activeToolBlocks` lets
31 > * `input_json_delta` look up the tool block that owns the current
32 > * index. `currentMessageId` qualifies text/thinking part ids so a
33 > * later message in the same turn does not collide with an earlier
34 > * message that used the same `index` for a different block kind
35 > * (e.g. turn one: `thinking@0`; turn two after `tool_result`:
36 > * `text@0`).
37 > * - **Cross-message** (`toolCallTurnIds`, `toolCallNames`): a `tool_use`
38 > * lands in one assistant message, the matching `tool_result` arrives
39 > * in a later synthetic `user` message. Keyed by the SDK's globally-
40 > * unique `block.id` so re-use of `index` between messages is harmless.
41 > * Drained on `tool_result` (happy path) or on the turn's `result`
42 > * envelope as a defense-in-depth fallback so an SDK that never
43 > * delivers `tool_result` cannot leak entries across turns.
44 > *
45 > * Encapsulated as a class (vs. a plain interface) so the maps' mutators
46 > * are not part of the public surface — Phase 6.1's lesson — and the
47 > * lifecycle invariants live behind named methods.
48 > */
49 > export class ClaudeMapperState {
50 > private readonly _activeToolBlocks = new Map<number, { toolUseId: string; toolName: string }>(); claudeMapSessionEvents.ts ×1
51 > /**
52 > * Phase 8.5 — cross-message tool-call attribution + input
53 > * accumulation + computed start-info, encapsulated as its own
54 > * collaborator class so it can be unit-tested independently.
55 > * Public so mapper functions can call its lifecycle methods
56 > * directly without forwarding through this class.
57 > */
58 > readonly toolCalls = new ClaudeToolCallRegistry();
59 > private _currentMessageId: string | undefined;
60 >
61 > /**
62 > * Phase 8 — file-edit content pre-staged by
63 > * `ClaudeAgentSession._observeUserMessage` and consumed by
64 > * {@link mapUserMessage} when the matching `tool_result` arrives.
65 > * Keyed by SDK `tool_use_id`. The session's `_processMessages` loop
66 > * awaits the after-snapshot before invoking the synchronous mapper,
67 > * so by the time `takeFileEdit` is called the entry is always
68 > * populated for tracked file-edit tools.
69 > */
70 > private readonly _completedFileEdits = new Map<string, ToolResultFileEditContent>();
72 > /**
73 > * Reset per-message state. Called on `message_start`. Cross-message
74 > * tool-call tracking is deliberately NOT cleared here — the
75 > * `tool_result` for a `tool_use` arrives in a later message.
76 > */
77 > resetMessage(messageId: string): void {
78 > this._activeToolBlocks.clear(); claudeMapSessionEvents.ts ×2
79 > this._currentMessageId = messageId;
80 > }
82 > getCurrentMessageId(): string | undefined {
83 > return this._currentMessageId; claudeMapSessionEvents.ts ×2
84 > }
86 > /**
87 > * Open a tool block at the given content-block index. Seeds both
88 > * scopes; the per-message map gets drained on `content_block_stop`,
89 > * the cross-message maps survive until the matching `tool_result`.
90 > */
91 > startToolBlock(index: number, toolUseId: string, toolName: string, turnId: string): void {
92 > this._activeToolBlocks.set(index, { toolUseId, toolName }); claudeMapSessionEvents.ts ×4
93 > this.toolCalls.begin(toolUseId, toolName, turnId);
94 > }
96 > getActiveToolBlock(index: number): { toolUseId: string; toolName: string } | undefined {
97 > return this._activeToolBlocks.get(index); claudeMapSessionEvents.ts ×1
98 > }
100 > endToolBlock(index: number): void {
101 > this._activeToolBlocks.delete(index); claudeMapSessionEvents.ts ×6
102 > }
104 > /**
105 > * Phase 8.5 — forward an `input_json_delta.partial_json` chunk
106 > * to the registry. Resolves the index → `tool_use_id` mapping
107 > * locally (the registry is keyed by id, not by index) and is a
108 > * no-op when the index is unknown.
109 > */
110 > appendToolBlockInputDelta(index: number, partialJson: string): void {
111 > const tracked = this._activeToolBlocks.get(index); claudeMapSessionEvents.ts ×4
112 > if (!tracked) {
113 return;
114 }
115 > this.toolCalls.appendInputDelta(tracked.toolUseId, partialJson); claudeMapSessionEvents.ts ×4
116 > }
118 > /**
119 > * Phase 8.5 — forward the `content_block_stop` signal to the
120 > * registry, which parses the buffer and stashes the computed
121 > * start-info.
122 > */
123 > finalizeToolBlock(index: number): void {
124 > const tracked = this._activeToolBlocks.get(index); claudeMapSessionEvents.ts ×6
125 > if (!tracked) {
127 > }
128 > this.toolCalls.finalize(tracked.toolUseId); claudeMapSessionEvents.ts ×3
131 > /**
132 > * Cross-message lookup for `tool_result` handling. Returns
133 > * `undefined` if the `tool_use_id` is unknown (defense-in-depth
134 > * against transport drift / replay).
135 > */
136 > lookupToolCall(toolUseId: string): { turnId: string; toolName: string } | undefined {
137 > const entry = this.toolCalls.lookup(toolUseId); claudeMapSessionEvents.ts ×7
138 > return entry ? { turnId: entry.turnId, toolName: entry.toolName } : undefined;
139 > }
141 > /** Drain cross-message tracking once a `tool_result` is delivered. */
142 > completeToolCall(toolUseId: string): void {
143 > this.toolCalls.complete(toolUseId); claudeMapSessionEvents.ts ×5
144 > }
146 > /**
147 > * Phase 8 — stash a {@link ToolResultFileEditContent} produced by
148 > * `ClaudeAgentSession._observeUserMessage` so the synchronous mapper
149 > * can append it to the matching `ChatToolCallComplete` action.
150 > */
151 > cacheFileEdit(toolUseId: string, content: ToolResultFileEditContent): void {
152 > this._completedFileEdits.set(toolUseId, content); claudeMapSessionEvents.ts ×2
153 > }
155 > /**
156 > * Phase 8 — consume and remove the cached file edit for this
157 > * `tool_use_id`. Returns `undefined` for non-file-edit tools or for
158 > * file-edit tools where snapshotting was skipped (e.g. denied before
159 > * the SDK ran the tool, or no actual file change occurred).
160 > */
161 > takeFileEdit(toolUseId: string): ToolResultFileEditContent | undefined {
162 > const content = this._completedFileEdits.get(toolUseId); claudeMapSessionEvents.ts ×2
163 > if (content) {
164 > this._completedFileEdits.delete(toolUseId); claudeMapSessionEvents.ts ×2
165 > }
166 > return content; claudeMapSessionEvents.ts ×2
167 > }
169 > /**
170 > * Drop any cross-message tracking that is still pending at the end
171 > * of a turn. A `tool_use` whose `tool_result` never arrives — model
172 > * misbehavior, transport drop, future cancellation — would otherwise
173 > * survive in the maps for the lifetime of the session and accumulate
174 > * across turns. Called from {@link mapResult} on every `result`
175 > * envelope; warns once per orphan to surface the protocol break.
176 > *
177 > * Phase 12 subagent state lives on {@link SubagentRegistry}, not
178 > * here; the mapper drives that drain via
179 > * `registry.drainForegroundSpawns()` from {@link mapResult}.
180 > */
181 > clearPendingToolCalls(logService: ILogService): void {
182 > this.toolCalls.clearPending(logService); claudeMapSessionEvents.ts ×7
183 > }
185 >
186 > /**
187 > * Map one SDK message to zero or more agent signals.
188 > *
189 > * Stateful via {@link ClaudeMapperState} as of Phase 7: per-block tool
190 > * tracking is per-message, cross-block `tool_use` → `tool_result`
191 > * linkage is cross-message. Callers MUST thread one shared state
192 > * instance through every invocation for a given session.
193 > *
194 > * Phase 6 emissions (text / thinking / usage / turn complete) are
195 > * unchanged and stateless. Phase 7 adds:
196 > *
197 > * - {@link ActionType.ChatToolCallStart} on
198 > * `content_block_start` with a `tool_use` block.
199 > * - {@link ActionType.ChatToolCallDelta} on `content_block_delta`
200 > * with an `input_json_delta`.
201 > * - {@link ActionType.ChatToolCallComplete} on a synthetic `user`
202 > * message whose `message.content` includes a `tool_result` block —
203 > * the originating `turnId` is recovered from {@link ClaudeMapperState}
204 > * so the action lands on the correct turn even when the result
205 > * arrives in a later message.
206 > *
207 > * Reducer ordering invariant: `ChatResponsePart` MUST precede the
208 > * first `ChatDelta` / `ChatReasoning` for that part id (see
209 > * `actions.ts:233, 540`). The same holds for tool calls
210 > * (`ChatToolCallStart` precedes `ChatToolCallDelta` and
211 > * `ChatToolCallComplete`). The SDK protocol orders
212 > * `content_block_start` before any delta at the same index, and
213 > * `tool_result` cannot arrive before its matching `tool_use`, so the
214 > * invariant holds by construction.
215 > */
216 > export function mapSDKMessageToAgentSignals(
217 > message: SDKMessage, claudeMapSessionEvents.ts ×7
218 > chat: URI,
219 > turnId: string,
220 > state: ClaudeMapperState,
221 > logService: ILogService,
222 > registry: SubagentRegistry,
223 > clientToolOwner?: (toolName: string) => string | undefined,
224 > turnDuration?: number,
225 > ): AgentSignal[] {
226 > if (logService.getLevel() <= LogLevel.Trace) {
227 try {
228 const snippet = JSON.stringify(message, (k, v) => typeof v === 'string' && v.length > 200 ? v.slice(0, 200) + '…' : v);
229 logService.trace(`[claudeMapSessionEvents] SDK message type=${message.type}: ${snippet?.slice(0, 2000) ?? '<unserializable>'}`);
230 } catch {
231 logService.trace(`[claudeMapSessionEvents] SDK message type=${message.type} (unserializable)`);
232 }
233 }
234 > switch (message.type) { claudeMapSessionEvents.ts ×7
235 > case 'stream_event':
236 > return tagWithParent( claudeMapSessionEvents.ts ×8
237 > mapStreamEvent(message.event, chat, turnId, state, logService, message.parent_tool_use_id, registry, clientToolOwner),
238 > chat,
239 > message.parent_tool_use_id,
240 > registry,
241 > );
242 > case 'result': claudeMapSessionEvents.ts ×7
243 > return mapResult(message, chat, turnId, turnDuration, state, logService, registry); claudeMapSessionEvents.ts ×7
244 > case 'assistant': claudeMapSessionEvents.ts ×7
245 > return tagWithParent( claudeMapSessionEvents.ts ×2
246 > mapAssistantCanonical(message, chat, turnId, state, message.parent_tool_use_id, registry),
247 > chat,
248 > message.parent_tool_use_id,
249 > registry,
250 > );
251 > case 'user': claudeMapSessionEvents.ts ×7
252 > return tagWithParent( claudeMapSessionEvents.ts ×2
253 > mapUserMessage(message, chat, state, logService, registry),
254 > chat,
255 > message.parent_tool_use_id,
256 > registry,
257 > );
259 > // Phase 12 step 7 — system subtypes for subagent task discrimination. claudeMapSessionEvents.ts ×1
260 > if (message.type === 'system') {
261 > return mapSubagentSystemMessage(message, chat, registry);
262 > }
263 return [];
265 > }
267 > /**
268 > * Handle the canonical {@link SDKAssistantMessage} (`type: 'assistant'`).
269 > *
270 > * **Top-level (`parent_tool_use_id === null`)**: the SDK delivered each
271 > * block via `stream_event` partials and `mapStreamEvent` emitted the
272 > * matching signals, so most blocks here are no-ops. **Exception**: for
273 > * Task/Agent tool_use blocks we synthesise a `ChatToolCallReady`
274 > * (via {@link buildTopLevelSubagentReadyAction}) because the SDK skips
275 > * `canUseTool` for them and the parent tool would otherwise stay in
276 > * `Streaming` — see that function's JSDoc.
277 > *
278 > * **Inner subagent context (`parent_tool_use_id !== null`)**: empirically
279 > * the SDK does NOT deliver inner content via `stream_event` — only via
280 > * canonical `assistant` and `user` messages, even with
281 > * `Options.forwardSubagentText: true`. Delegated to
282 > * {@link emitInnerAssistantSignals} which emits one signal per content
283 > * block. `tagWithParent` then stamps every emitted action with the
284 > * envelope's `parent_tool_use_id` so `AgentSideEffects` routes them to
285 > * the subagent session.
286 > */
287 > function mapAssistantCanonical( claudeMapSessionEvents.ts ×2
288 > message: Extract<SDKMessage, { type: 'assistant' }>,
289 > chat: URI,
290 > turnId: string,
291 > state: ClaudeMapperState,
292 > parentToolUseId: string | null,
293 > registry: SubagentRegistry,
294 > ): AgentSignal[] {
295 > if (parentToolUseId === null) {
296 > const top: AgentSignal[] = []; claudeMapSessionEvents.ts ×2
297 > for (const block of message.message.content) {
298 > if (block.type !== 'tool_use' || !SUBAGENT_SPAWNING_TOOL_NAMES.has(block.name)) {
300 > }
301 > top.push(buildTopLevelSubagentReadyAction(block, chat, turnId, registry)); claudeSubagentSignals.ts ×2
302 > }
303 > return top; claudeMapSessionEvents.ts ×2
304 > }
305 > return emitInnerAssistantSignals(message, chat, turnId, state, parentToolUseId, registry); claudeSubagentSignals.ts ×2
306 > }
308 > /**
309 > * Handle synthetic `user` messages whose `message.content` carries
310 > * `tool_result` blocks. The SDK delivers these as the response to a
311 > * prior `tool_use`. Per Phase 7 S3.3.4, each such block emits a
312 > * {@link ActionType.ChatToolCallComplete} action targeting the turn
313 > * that owned the original `tool_use`.
314 > *
315 > * Cross-message linkage is via {@link ClaudeMapperState.lookupToolCall};
316 > * unknown `tool_use_id`s warn and drop (defense-in-depth, mirrors the
317 > * Phase 7 plan S3.3.5 directive).
318 > */
319 > function mapUserMessage( claudeMapSessionEvents.ts ×2
320 > message: Extract<SDKMessage, { type: 'user' }>,
321 > chat: URI,
322 > state: ClaudeMapperState,
323 > logService: ILogService,
324 > registry: SubagentRegistry,
325 > ): AgentSignal[] {
326 > const content = message.message.content;
327 > if (!Array.isArray(content)) {
329 > }
331 > const signals: AgentSignal[] = [];
332 > for (const block of content) {
333 > if (block.type !== 'tool_result') {
334 continue;
335 }
336 > const tracked = state.lookupToolCall(block.tool_use_id); claudeMapSessionEvents.ts ×7
337 > if (!tracked) {
338 > logService.warn(`[claudeMapSessionEvents] tool_result for unknown tool_use_id ${block.tool_use_id}`); claudeMapSessionEvents.ts ×1
339 > continue;
340 > }
341 > const isError = block.is_error === true; claudeMapSessionEvents.ts ×5
342 > const content: ToolResultContent[] = extractToolResultContent(block.content) ?? [];
343 > const fileEdit = state.takeFileEdit(block.tool_use_id); claudeMapSessionEvents.ts ×7
344 > if (fileEdit) {
345 > content.push(fileEdit); claudeMapSessionEvents.ts ×1
346 > }
347 > const info = state.toolCalls.lookup(block.tool_use_id)?.info; claudeMapSessionEvents.ts ×5
348 > const resultText = content claudeMapSessionEvents.ts ×7
349 > .filter((c): c is { type: ToolResultContentType.Text; text: string } => c.type === ToolResultContentType.Text)
350 > .map(c => c.text)
351 > .join('\n');
352 > const pastTenseMessage: StringOrMarkdown = info
353 > ? getClaudePastTenseMessage(info.toolName, info.displayName, info.parsedInput, !isError, resultText) claudeMapSessionEvents.ts ×1
354 > : `${getClaudeToolDisplayName(tracked.toolName)} finished`; claudeMapSessionEvents.ts ×1
355 > // A denied/cancelled tool surfaces as an `is_error` result whose content claudeMapSessionEvents.ts ×7
356 > // is the deny `message` we returned from `canUseTool`; classify it so the
357 > // telemetry reports `userCancelled` rather than a generic error.
358 > const denialCode = isError ? claudeToolDenialCode(resultText) : undefined;
359 > signals.push({
360 > kind: 'action',
361 > resource: chat,
362 > action: {
363 > type: ActionType.ChatToolCallComplete,
364 > turnId: tracked.turnId,
365 > toolCallId: block.tool_use_id,
366 > result: {
367 > success: !isError,
368 > pastTenseMessage,
369 > content: content.length > 0 ? content : undefined,
370 > ...(denialCode ? { error: { message: resultText, code: denialCode } } : {}),
371 > },
372 > },
373 > });
374 > state.completeToolCall(block.tool_use_id);
375 > // Phase 12 — foreground subagent completion. A tool_result for a
376 > // known spawning Task/Agent tool_use fires `subagent_completed`
377 > // UNLESS the spawning entry has been flagged background, in which
378 > // case completion is deferred to a later `task_notification`.
379 > const spawn = registry.getSpawn(block.tool_use_id);
380 > if (spawn && !spawn.background && spawn.markCompleted()) {
381 > signals.push({ claudeMapSessionEvents.ts ×1
382 > kind: 'subagent_completed',
383 > chat,
384 > toolCallId: block.tool_use_id,
385 > });
386 > registry.removeSpawn(block.tool_use_id);
387 > }
389 > return signals;
390 > }
392 > /**
393 > * Project the SDK's `ToolResultBlockParam.content` into the protocol's
394 > * text content shape. The SDK accepts either a bare string (legacy
395 > * shape) or an array of typed blocks; non-text blocks are dropped
396 > * here. Phase 8 file-edit content is appended separately by
397 > * {@link mapUserMessage} from {@link ClaudeMapperState.takeFileEdit}.
398 > */
399 > function extractToolResultContent(content: unknown): { type: ToolResultContentType.Text; text: string }[] | undefined { claudeMapSessionEvents.ts ×5
400 > if (typeof content === 'string') {
401 > return [{ type: ToolResultContentType.Text, text: content }]; claudeMapSessionEvents.ts ×1
402 > }
403 > if (!Array.isArray(content)) { claudeMapSessionEvents.ts ×4
404 return undefined;
405 }
406 > const out: { type: ToolResultContentType.Text; text: string }[] = []; claudeMapSessionEvents.ts ×4
407 > for (const block of content) {
408 > if (isToolResultTextBlock(block)) {
409 > out.push({ type: ToolResultContentType.Text, text: block.text });
410 > }
411 > }
412 > return out.length > 0 ? out : undefined; claudeMapSessionEvents.ts ×5
413 > }
415 > function isToolResultTextBlock(block: unknown): block is { type: 'text'; text: string } { claudeMapSessionEvents.ts ×4
416 > if (block === null || typeof block !== 'object') {
417 return false;
418 }
419 > const candidate = block as { type?: unknown; text?: unknown }; claudeMapSessionEvents.ts ×4
420 > return candidate.type === 'text' && typeof candidate.text === 'string';
421 > }
423 > function mapResult( claudeMapSessionEvents.ts ×7
424 > message: Extract<SDKMessage, { type: 'result' }>,
425 > session: URI,
426 > turnId: string,
427 > turnDuration: number | undefined,
428 > state: ClaudeMapperState,
429 > logService: ILogService,
430 > registry: SubagentRegistry,
431 > ): AgentSignal[] {
432 > const signals: AgentSignal[] = [];
433 > if (message.subtype === 'success') {
434 > // `modelUsage` is keyed by model name; pick the first key as the claudeMapSessionEvents.ts ×2
435 > // reported model. Phase 6 turns are single-model; multi-model
436 > // attribution is a Phase 7+ concern.
437 > const modelKey = Object.keys(message.modelUsage)[0];
438 > // Per-turn credits are deliberately NOT derived from
439 > // `total_cost_usd`: that is the SDK's Anthropic-list-price USD
440 > // estimate, not what CAPI actually bills. Real Copilot credits come
441 > // from CAPI's `copilot_usage.total_nano_aiu`, which the proxy
442 > // captures and `ClaudeAgentSession` attaches to this action as
443 > // `_meta.copilotUsage.totalNanoAiu` (the key the workbench reads).
444 > signals.push({
445 > kind: 'action',
446 > resource: session,
447 > action: {
448 > type: ActionType.ChatUsage,
449 > turnId,
450 > usage: {
451 > inputTokens: message.usage.input_tokens,
452 > outputTokens: message.usage.output_tokens,
453 > cacheReadTokens: message.usage.cache_read_input_tokens,
454 > ...(modelKey ? { model: modelKey } : {}),
455 > },
456 > },
457 > });
458 > }
460 > // Surface execution errors (e.g. an upstream CAPI failure relayed by the
461 > // proxy) as a ChatError so the turn renders an error instead of
462 > // completing empty. Mirrors the Copilot Chat extension's
463 > // `handleResultMessage`. The proxy embeds a `VSCODE_PROXY_ERROR` marker in
464 > // the error text, which we decode into `_meta` for rich, localized
465 > // messaging (rate limit, quota + upgrade affordance, etc.).
466 > const errorText = getResultErrorText(message);
467 > if (errorText !== undefined) {
468 > signals.push({ claudeMapSessionEvents.ts ×1
469 > kind: 'action',
470 > resource: session,
471 > action: {
472 > type: ActionType.ChatError,
473 > turnId,
474 > duration: typeof turnDuration === 'number' && Number.isFinite(turnDuration) ? Math.max(0, turnDuration) : 0,
475 > error: {
476 > errorType: message.subtype,
477 > ...extractForwardedErrorInfo(errorText),
478 > },
479 > },
480 > });
481 > }
482 > // `ChatTurnComplete` is emitted by the session via claudeMapSessionEvents.ts ×7
483 > // `ClaudeSdkPipeline.onTurnComplete`, NOT here. The pipeline knows
484 > // when the protocol Turn is truly done (queue fully drained vs an
485 > // intermediate result during a steering preempt — CONTEXT.md M10);
486 > // the mapper does not have that state.
487 > state.clearPendingToolCalls(logService);
488 > // Phase 12 — drain orphaned subagent-spawning entries (foreground
489 > // only; background entries survive across turns by design). The
490 > // registry owns this state; the mapper drives the drain at turn end.
491 > for (const orphan of registry.drainForegroundSpawns()) {
492 logService.warn(`[claudeMapSessionEvents] turn ended with pending subagent-spawning tool_use ${orphan.toolUseId} (agentId=${orphan.agentId ?? '<unresolved>'}); dropping cross-message state`);
493 }
494 > return signals; claudeMapSessionEvents.ts ×7
495 > }
497 > /**
498 > * Extracts the error text from an SDK result message for the error subtypes
499 > * the proxy can relay. Mirrors the Copilot Chat extension's
500 > * `getResultErrorText`.
501 > */
502 > function getResultErrorText(message: Extract<SDKMessage, { type: 'result' }>): string | undefined { claudeMapSessionEvents.ts ×7
503 > if (message.subtype === 'success') {
504 > return message.is_error ? message.result : undefined; claudeMapSessionEvents.ts ×2
505 > }
506 > if (message.subtype === 'error_during_execution') { claudeMapSessionEvents.ts ×1
507 > return message.errors?.join('\n');
508 > }
509 return undefined;
510 }
512 > function mapStreamEvent( claudeMapSessionEvents.ts ×8
513 > event: Extract<SDKMessage, { type: 'stream_event' }>['event'],
514 > chat: URI,
515 > turnId: string,
516 > state: ClaudeMapperState,
517 > logService: ILogService,
518 > parentToolUseId: string | null,
519 > registry: SubagentRegistry,
520 > clientToolOwner: ((toolName: string) => string | undefined) | undefined,
521 > ): AgentSignal[] {
522 > switch (event.type) {
523 > case 'message_start':
524 > state.resetMessage(event.message.id); claudeMapSessionEvents.ts ×2
525 > return [];
527 > case 'content_block_start': {
528 > const block = event.content_block; claudeMapSessionEvents.ts ×1
529 > if (block.type === 'text') {
531 > kind: 'action',
532 > resource: chat,
533 > action: {
534 > type: ActionType.ChatResponsePart,
535 > turnId,
536 > part: {
537 > kind: ResponsePartKind.Markdown,
538 > id: makeContentBlockPartId(turnId, state, event.index, logService),
539 > content: '',
540 > },
541 > },
542 > }];
543 > }
544 > if (block.type === 'thinking') { claudeMapSessionEvents.ts ×1
546 > kind: 'action',
547 > resource: chat,
548 > action: {
549 > type: ActionType.ChatResponsePart,
550 > turnId,
551 > part: {
552 > kind: ResponsePartKind.Reasoning,
553 > id: makeContentBlockPartId(turnId, state, event.index, logService),
554 > content: '',
555 > },
556 > },
557 > }];
558 > }
559 > if (block.type === 'tool_use') { claudeMapSessionEvents.ts ×4
560 > // Phase 10 — strip the SDK's `mcp__<server>__` prefix for
561 > // our in-process client-tool MCP server. The SDK surfaces
562 > // in-process MCP tools to the model with that prefix, but
563 > // the workbench's registered client-tool list (and the
564 > // MCP handler's closure) use the unprefixed name. Without
565 > // normalizing at the seam, `ChatToolCallReady` /
566 > // `ChatToolCallComplete` would carry the prefixed name
567 > // and the workbench would never recognize them as client
568 > // tools. SDK-owned tools (Read, Write, Bash, etc.) and
569 > // subagent spawn tools pass through unchanged because
570 > // they don't carry the prefix.
571 > const toolName = stripClientToolNamePrefix(block.name);
572 > const isClientTool = hasClientToolNamePrefix(block.name);
573 > state.startToolBlock(event.index, block.id, toolName, turnId);
574 > // Phase 12 — subagent correlation bookkeeping. Either this
575 > // tool_use is at the top level and (if Task/Agent) spawns a
576 > // new subagent, or it is inner and we record its edge to the
577 > // parent. They are mutually exclusive (a Task call inside a
578 > // subagent is itself an inner tool_use; the resolver chain
579 > // handles nested spawns by following the parent chain).
580 > // Gated on `!isClientTool` so a workbench tool named `Task` /
581 > // `Agent` cannot impersonate the SDK's subagent-spawn tools.
582 > const isSubagentSpawn = !isClientTool && SUBAGENT_SPAWNING_TOOL_NAMES.has(toolName);
583 > if (parentToolUseId === null) {
584 > if (isSubagentSpawn) {
585 > registry.recordSpawn(block.id); claudeMapSessionEvents.ts ×1
586 > }
588 > registry.noteInnerTool(block.id, parentToolUseId); claudeMapSessionEvents.ts ×1
589 > }
590 > // Phase 8.5 — `_meta.toolKind` drives the workbench's terminal / claudeMapSessionEvents.ts ×4
591 > // search / subagent renderers. Single write at the tool-open
592 > // seam; the reducer carries `_meta` forward to all subsequent
593 > // state transitions (D6). Subagent meta from Phase 12 is now
594 > // produced by `buildClaudeToolMeta` because
595 > // `getClaudeToolKind('Task') === 'subagent'`.
596 > const meta = buildClaudeToolMeta(toolName);
597 > const toolClientId = isClientTool ? clientToolOwner?.(toolName) : undefined;
598 > return [{
599 > kind: 'action',
600 > resource: chat,
601 > action: {
602 > type: ActionType.ChatToolCallStart,
603 > turnId,
604 > toolCallId: block.id,
605 > toolName,
606 > displayName: getClaudeToolDisplayName(toolName),
607 > ...(toolClientId ? { contributor: { kind: ToolCallContributorKind.Client, clientId: toolClientId } } : {}),
608 > ...(meta ? { _meta: meta } : {}),
609 > },
610 > }];
611 > }
612 return [];
613 }
615 > case 'content_block_delta': {
616 > if (event.delta.type === 'text_delta') { claudeMapSessionEvents.ts ×1
618 > kind: 'action',
619 > resource: chat,
620 > action: {
621 > type: ActionType.ChatDelta,
622 > turnId,
623 > partId: makeContentBlockPartId(turnId, state, event.index, logService),
624 > content: event.delta.text,
625 > },
626 > }];
627 > }
628 > if (event.delta.type === 'thinking_delta') { claudeMapSessionEvents.ts ×1
630 > kind: 'action',
631 > resource: chat,
632 > action: {
633 > type: ActionType.ChatReasoning,
634 > turnId,
635 > partId: makeContentBlockPartId(turnId, state, event.index, logService),
636 > content: event.delta.thinking,
637 > },
638 > }];
639 > }
640 > if (event.delta.type === 'input_json_delta') { claudeMapSessionEvents.ts ×4
641 > const tracked = state.getActiveToolBlock(event.index);
642 > if (!tracked) {
643 logService.warn(`[claudeMapSessionEvents] input_json_delta for unknown content-block index ${event.index}`);
644 return [];
645 }
646 > state.appendToolBlockInputDelta(event.index, event.delta.partial_json); claudeMapSessionEvents.ts ×4
647 > return [{
648 > kind: 'action',
649 > resource: chat,
650 > action: {
651 > type: ActionType.ChatToolCallDelta,
652 > turnId,
653 > toolCallId: tracked.toolUseId,
654 > content: event.delta.partial_json,
655 > },
656 > }];
657 > }
658 return [];
659 }
661 > case 'content_block_stop': {
662 > const tracked = state.getActiveToolBlock(event.index); claudeMapSessionEvents.ts ×6
663 > state.finalizeToolBlock(event.index);
664 > state.endToolBlock(event.index);
665 > if (!tracked) {
667 > }
668 > const entry = state.toolCalls.lookup(tracked.toolUseId); claudeMapSessionEvents.ts ×3
669 > const info = entry?.info;
670 > if (!info) { claudeMapSessionEvents.ts ×6
671 return [];
672 }
673 > const meta = buildClaudeToolMeta(tracked.toolName); claudeMapSessionEvents.ts ×3
674 > return [{
675 > kind: 'action',
676 > resource: chat,
677 > action: {
678 > type: ActionType.ChatToolCallReady,
679 > turnId,
680 > toolCallId: tracked.toolUseId,
681 > invocationMessage: info.invocationMessage,
682 > ...(info.toolInput !== undefined ? { toolInput: info.toolInput } : {}), claudeMapSessionEvents.ts ×6
683 > confirmed: ToolCallConfirmationReason.NotNeeded,
684 > ...(meta ? { _meta: meta } : {}),
685 > },
686 > }];
687 > }
689 > case 'message_delta':
690 > case 'message_stop':
693 > default:
696 > }
698 > /**
699 > * Build the {@link ResponsePartKind.Markdown}/{@link ResponsePartKind.Reasoning}
700 > * id for a text or thinking content block. Qualifying with the SDK's
701 > * per-message id is required: a single turn can span multiple SDK
702 > * messages (e.g. assistant message → tool_use → tool_result → assistant
703 > * message) and `event.index` resets to 0 on each new `message_start`.
704 > * Without the message-id qualifier, a `text@0` block in the second
705 > * message collides with a `thinking@0` block in the first and the
706 > * reducer treats it as a duplicate, dropping the follow-up text.
707 > *
708 > * If `currentMessageId` is missing we fall back to the legacy
709 > * `${turnId}#${index}` form and warn — the SDK protocol guarantees
710 > * `message_start` precedes any content block, so the absence is a real
711 > * bug, not a transport reorder.
712 > */
713 > function makeContentBlockPartId( claudeMapSessionEvents.ts ×2
714 > turnId: string,
715 > state: ClaudeMapperState,
716 > index: number,
717 > logService: ILogService,
718 > ): string {
719 > const messageId = state.getCurrentMessageId();
720 > if (messageId === undefined) {
721 > logService.warn(`[claudeMapSessionEvents] content block at index ${index} arrived before message_start; using turn-scoped id`); claudeMapSessionEvents.ts ×1
722 > return `${turnId}#${index}`;
723 > }
724 > return `${turnId}#${messageId}#${index}`; claudeMapSessionEvents.ts ×1
725 > }