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

603 LOC · 538 covered · 65 uncovered · 130 ranges · 431 concepts · 35 introducers · 238 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 > /*--------------------------------------------------------------------------------------------- claudeReplayMapper.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 { SessionMessage } from '@anthropic-ai/claude-agent-sdk';
7 > import type { URI } from '../../../../base/common/uri.js';
8 > import type { ILogService } from '../../../log/common/log.js';
9 > import {
10 > ResponsePartKind,
11 > ToolCallCancellationReason,
12 > ToolCallConfirmationReason,
13 > ToolCallStatus,
14 > ToolResultContentType,
15 > TurnState,
16 > MessageKind,
17 > type ResponsePart,
18 > type ToolCallCancelledState,
19 > type ToolCallCompletedState,
20 > type ToolCallResponsePart,
21 > type ToolResultContent,
22 > type Turn,
23 > } from '../../common/state/protocol/state.js';
24 > import { buildSubagentSessionUri } from '../../common/state/sessionState.js';
25 > import { readToolCallMeta } from '../../common/meta/agentToolCallMeta.js';
26 > import { buildClaudeToolMeta, getClaudeInvocationMessage, getClaudePastTenseMessage, getClaudeToolDisplayName, getClaudeToolInputString } from './claudeToolDisplay.js';
27 > import { stripClientToolNamePrefix } from './clientTools/claudeClientToolMcpServer.js';
28 >
29 > /**
30 > * Phase 13 — replay mapper. Reduces a flat `SessionMessage[]` (the SDK's
31 > * on-disk JSONL transcript) into the protocol's `Turn[]` shape per
32 > * [CONTEXT.md M7](./CONTEXT.md). Pure function; no I/O, no DI.
33 > *
34 > * Distinct from the live mapper (`mapSDKMessageToAgentSignals`) because:
35 > * - input shape differs (`SessionMessage` envelope vs `SDKMessage` union),
36 > * - output shape differs (`Turn[]` vs `AgentSignal[]`),
37 > * - replay has no `'result'` envelope (SDK doesn't persist it) and no
38 > * `'stream_event'` lifecycle (terminal states only).
39 > *
40 > * Shared invariant with the live mapper: the `Map<tool_use_id, turnId>`
41 > * attribution rule from M7 — `tool_result` legitimately lands in a later
42 > * `'user'` envelope and must resolve back to the announcing `tool_use`'s
43 > * turn. This mapper builds an equivalent local map during its single pass.
44 > */
45 > export function mapSessionMessagesToTurns(
46 > messages: readonly SessionMessage[], claudeReplayMapper.ts ×6
47 > session: URI,
48 > logService: ILogService,
49 > ): readonly Turn[] {
50 > const builder = new ReplayBuilder(session, logService);
51 > for (const msg of messages) {
52 > const parsed = parseSessionMessage(msg); claudeReplayMapper.ts ×12
53 > if (parsed === undefined) {
54 > continue; claudeReplayMapper.ts ×1
55 > }
56 > builder.consume(parsed); claudeReplayMapper.ts ×12
57 > }
58 > return builder.finish(); claudeReplayMapper.ts ×6
59 > }
61 > /**
62 > * Phase 6.5 — translate a protocol `turnId` (the last KEPT turn N) into the
63 > * SDK envelope `uuid` that `forkSession({ upToMessageId })` accepts
64 > * (INCLUSIVE). Returns the `uuid` of turn N's last `'assistant'` envelope,
65 > * or `turnId` itself when turn N has no assistant reply (still a valid
66 > * inclusive anchor), or `undefined` when `turnId` is not in the transcript.
67 > * Reuses {@link parseSessionMessage} so the turn-boundary rule matches
68 > * {@link ReplayBuilder}; always returns an envelope `uuid`, never a `msg_…` id.
69 > */
70 > export function resolveForkAnchorUuid(messages: readonly SessionMessage[], turnId: string): string | undefined {
71 > let seenTarget = false; claudeReplayMapper.ts ×3
72 > let lastAssistantUuid: string | undefined;
73 > for (const msg of messages) {
74 > const parsed = parseSessionMessage(msg); claudeReplayMapper.ts ×5
75 > if (parsed === undefined) {
76 > continue; claudeReplayMapper.ts ×1
77 > }
78 > if (parsed.kind === 'user-text') { claudeReplayMapper.ts ×5
79 > if (seenTarget) {
80 > // First genuine user-text after turn N started → turn N is over. claudeReplayMapper.ts ×1
81 > break;
82 > }
83 > if (parsed.uuid === turnId) { claudeReplayMapper.ts ×5
84 > seenTarget = true; claudeReplayMapper.ts ×2
85 > }
86 > } else if (parsed.kind === 'assistant' && seenTarget) { claudeReplayMapper.ts ×5
87 > lastAssistantUuid = parsed.uuid; claudeReplayMapper.ts ×1
88 > }
89 > // 'user-tool-results' / 'system-notification' never flip the turn. claudeReplayMapper.ts ×5
90 > }
91 > if (!seenTarget) { claudeReplayMapper.ts ×3
92 > return undefined; claudeReplayMapper.ts ×1
93 > }
94 > return lastAssistantUuid ?? turnId; claudeReplayMapper.ts ×2
97 > // #region Parsed message union — narrow-at-the-seam adapter
98 >
99 > interface UserTextBlock { readonly type: 'text'; readonly text: string }
100 > interface UserToolResultBlock { readonly type: 'tool_result'; readonly tool_use_id: string; readonly content: unknown; readonly is_error: boolean }
101 > interface AssistantBlock { readonly type: string; readonly text?: string; readonly thinking?: string; readonly id?: string; readonly name?: string; readonly input?: unknown }
102 >
103 > /**
104 > * Discriminated union of replay-relevant message shapes. Everything that
105 > * the mapper actually cares about is one of these; everything else (hooks,
106 > * CLI-echo entries, unallowed system subtypes, malformed envelopes) returns
107 > * `undefined` from {@link parseSessionMessage}.
108 > *
109 > * The split keeps SDK shape detection (this seam) separate from the
110 > * stateful reduction (the {@link ReplayBuilder}) — see CONTEXT M7.
111 > */
112 > type ParsedSessionMessage =
113 > | { readonly kind: 'user-text'; readonly uuid: string; readonly text: string; readonly timestamp?: string }
114 > | { readonly kind: 'user-tool-results'; readonly uuid: string; readonly results: readonly UserToolResultBlock[]; readonly timestamp?: string }
115 > | { readonly kind: 'assistant'; readonly uuid: string; readonly blocks: readonly AssistantBlock[]; readonly isInner: boolean; readonly timestamp?: string }
116 > | { readonly kind: 'system-notification'; readonly uuid: string; readonly subtype: string; readonly text: string; readonly timestamp?: string };
117 >
118 > function parseSessionMessage(msg: SessionMessage): ParsedSessionMessage | undefined { claudeReplayMapper.ts ×23
119 > const timestamp = readTimestamp(msg);
120 > switch (msg.type) {
121 > case 'user': return parseUserMessage(msg, timestamp);
122 > case 'assistant': return parseAssistantMessage(msg, timestamp);
123 > case 'system': return parseSystemMessage(msg, timestamp);
124 > default: return undefined;
125 > }
126 > }
128 > function readTimestamp(msg: SessionMessage & { readonly timestamp?: unknown }): string | undefined { claudeReplayMapper.ts ×23
129 > if (typeof msg.timestamp !== 'string') {
130 > return undefined; claudeReplayMapper.ts ×1
131 > }
132 > const timestamp = Date.parse(msg.timestamp); claudeReplayMapper.ts ×1
133 > return Number.isFinite(timestamp) ? new Date(timestamp).toISOString() : undefined; claudeReplayMapper.ts ×23
134 > }
136 > function parseUserMessage(msg: SessionMessage, timestamp: string | undefined): ParsedSessionMessage | undefined { claudeReplayMapper.ts ×23
137 > const content = readUserContent(msg.message);
138 > if (content === undefined) {
139 return undefined;
140 }
141 > if (isCliEchoContent(content)) { claudeReplayMapper.ts ×23
142 > return undefined; claudeReplayMapper.ts ×3
143 > }
144 > if (typeof content === 'string') { claudeReplayMapper.ts ×23
145 return { kind: 'user-text', uuid: msg.uuid, text: content, timestamp };
146 }
147 > const textBlocks = content.filter((b): b is UserTextBlock => b.type === 'text'); claudeReplayMapper.ts ×23
148 > if (textBlocks.length === 0) {
149 > const results = content.filter((b): b is UserToolResultBlock => b.type === 'tool_result'); claudeReplayMapper.ts ×2
150 > return results.length > 0 ? { kind: 'user-tool-results', uuid: msg.uuid, results, timestamp } : undefined;
151 > }
152 > // Mixed or text-only: text wins — matches prior behavior where tool_results claudeReplayMapper.ts ×2
153 > // in a text-bearing envelope are dropped (they should already have been delivered).
154 > return { kind: 'user-text', uuid: msg.uuid, text: textBlocks.map(b => b.text).join('\n'), timestamp };
155 > }
157 > function parseAssistantMessage(msg: SessionMessage, timestamp: string | undefined): ParsedSessionMessage | undefined { claudeReplayMapper.ts ×23
158 > const blocks = readAssistantBlocks(msg.message);
159 > if (blocks === undefined || blocks.length === 0) {
160 return undefined;
161 }
162 > // Subagent transcripts (from `getSubagentMessages`) carry a claudeReplayMapper.ts ×23
163 > // `parent_tool_use_id` on every envelope and have no synthetic spawning
164 > // user prompt, so they legitimately open with an assistant message —
165 > // `isInner` lets the builder synthesize a turn instead of dropping it.
166 > return { kind: 'assistant', uuid: msg.uuid, blocks, isInner: msg.parent_tool_use_id !== null, timestamp };
167 > }
169 > function parseSystemMessage(msg: SessionMessage, timestamp: string | undefined): ParsedSessionMessage | undefined { claudeReplayMapper.ts ×4
170 > const subtype = readSystemSubtype(msg.message);
171 > if (subtype === undefined || !ALLOWED_SYSTEM_SUBTYPES.has(subtype)) {
172 > return undefined; claudeReplayMapper.ts ×1
173 > }
174 > const text = readSystemText(msg.message) ?? `[${subtype}]`; claudeReplayMapper.ts ×3
175 > return { kind: 'system-notification', uuid: msg.uuid, subtype, text, timestamp }; claudeReplayMapper.ts ×4
176 > }
178 > // #endregion
179 >
180 > // #region Builder
181 >
182 > /**
183 > * Allowlist of `system` subtypes that survive replay as
184 > * {@link ResponsePartKind.SystemNotification} parts on the active turn.
185 > * Mirrors CONTEXT M7's table — anything not in this set is dropped.
186 > */
187 > const ALLOWED_SYSTEM_SUBTYPES: ReadonlySet<string> = new Set([
188 > 'compact_boundary',
189 > 'notification',
190 > ]);
191 >
192 > /**
193 > * CLI-echo markers the Claude Code CLI writes into the transcript for
194 > * replay fidelity. They are `type: 'user'` envelopes whose `message.content`
195 > * is a raw string starting with one of these tags — `<command-name>` /
196 > * `<command-args>` (slash-command echoes like `/model claude-opus-4.7`),
197 > * `<local-command-stdout>` / `<local-command-stderr>` (echo of the local
198 > * handler's output, e.g. "Set model to claude-opus-4.7"), and
199 > * `<local-command-caveat>` (the "messages below were generated while…"
200 > * preamble). The entries don't carry `isSynthetic` / `isMeta` reliably
201 > * (the `/model` echo lacks both, verified empirically), so the only reliable
202 > * discriminator is the content shape itself. Drop on replay so the workbench
203 > * doesn't render them as user turns.
204 > */
205 > const CLI_ECHO_MARKER_PATTERN = /^<(command-name|command-message|command-args|local-command-stdout|local-command-stderr|local-command-caveat)>/;
206 >
207 > interface InProgressTurn {
208 > readonly id: string;
209 > readonly userText: string;
210 > readonly startedAt?: string;
211 > lastResponseAt?: string;
212 > readonly responseParts: ResponsePart[];
213 > /**
214 > * `tool_use_id`s announced by THIS turn. Drained when the matching
215 > * `tool_result` lands (which may arrive in this turn's user-side
216 > * `tool_result` block or a later turn's). At turn close, non-empty →
217 > * tail Turn marked `Cancelled`.
218 > */
219 > readonly pendingToolUseIds: Set<string>;
220 > /**
221 > * Stash of completed `ToolCallResponsePart`s waiting on their result
222 > * content. `tool_use` opens with a placeholder; the matching
223 > * `tool_result` fills it in. Keyed by `tool_use_id`.
224 > */
225 > readonly toolCallParts: Map<string, ToolCallResponsePart>;
226 > }
227 >
228 > class ReplayBuilder {
229 > private readonly _turns: Turn[] = [];
230 > private _active: InProgressTurn | undefined;
231 > /**
232 > * Cross-turn tool-use tracking. Keyed by `tool_use_id`:
233 > * - `turnId` — the announcing turn (so a late `tool_result` in a
234 > * later `user` envelope can attach back to the right turn per M7).
235 > * - `parsedInput` — the original `tool_use.input`, looked up at
236 > * `_attachToolResult` so the past-tense message can include the
237 > * original parameters. Mirrors the live mapper's `_toolCallInfo`
238 > * pattern but simpler (replay has the full input synchronously on
239 > * the `tool_use` block).
240 > */
241 > private readonly _toolUses = new Map<string, { readonly turnId: string; readonly parsedInput: Record<string, unknown> | undefined }>();
242 >
243 > constructor(private readonly _session: URI, private readonly _logService: ILogService) { }
244 >
245 > consume(msg: ParsedSessionMessage): void {
246 > switch (msg.kind) { claudeReplayMapper.ts ×12
247 > case 'user-text':
248 > this._closeActive(); claudeReplayMapper.ts ×1
249 > this._active = {
250 > id: msg.uuid,
251 > userText: msg.text,
252 > startedAt: msg.timestamp,
253 > responseParts: [],
254 > pendingToolUseIds: new Set(),
255 > toolCallParts: new Map(),
256 > };
257 > return;
258 > case 'user-tool-results': { claudeReplayMapper.ts ×12
259 > let updatesActiveTurn = false; claudeReplayMapper.ts ×11
260 > for (const block of msg.results) {
261 > updatesActiveTurn = this._attachToolResult(block) === this._active?.id || updatesActiveTurn;
262 > }
263 > if (updatesActiveTurn && this._active && msg.timestamp) {
264 this._active.lastResponseAt = msg.timestamp;
265 }
267 > }
268 > case 'assistant': claudeReplayMapper.ts ×12
269 > this._consumeAssistant(msg);
270 > return;
271 > case 'system-notification':
272 > if (this._active === undefined) { claudeReplayMapper.ts ×4
273 // System notification before any user message — drop. Without an active turn there's nowhere to attach.
274 return;
275 }
276 > this._active.responseParts.push({ claudeReplayMapper.ts ×4
277 > kind: ResponsePartKind.SystemNotification,
278 > content: msg.text,
279 > });
280 > if (msg.timestamp) {
281 this._active.lastResponseAt = msg.timestamp;
282 }
285 > }
287 > finish(): readonly Turn[] {
288 > this._closeActive(); claudeReplayMapper.ts ×6
289 > return this._turns;
290 > }
292 > private _consumeAssistant(msg: ParsedSessionMessage & { kind: 'assistant' }): void {
293 > if (this._active === undefined) { claudeReplayMapper.ts ×12
294 > if (!msg.isInner) { claudeReplayMapper.ts ×1
295 > // Top-level assistant envelope without a preceding user message — claudeReplayMapper.ts ×1
296 > // anomalous; synthesizing an empty user turn would be wrong, so
297 > // drop with a warn.
298 > this._logService.warn(`[claudeReplayMapper] assistant envelope ${msg.uuid} arrived before any user message; dropping`);
299 > return;
300 > }
301 > // Subagent transcript: every envelope carries `parent_tool_use_id` claudeReplayMapper.ts ×2
302 > // and the SDK omits the synthetic spawning prompt, so the transcript
303 > // legitimately opens with an assistant message. Synthesize an
304 > // empty-prompt turn to hold the subagent's reply instead of dropping
305 > // it (which would lose the entire subagent transcript on replay).
306 > this._active = {
307 > id: msg.uuid,
308 > userText: '',
309 > startedAt: msg.timestamp,
310 > responseParts: [],
311 > pendingToolUseIds: new Set(),
312 > toolCallParts: new Map(),
313 > };
314 > }
315 > let textPartCounter = 0; claudeReplayMapper.ts ×12
316 > let reasoningPartCounter = 0;
317 > for (const block of msg.blocks) {
318 > if (block.type === 'text' && typeof block.text === 'string') {
319 > this._active.responseParts.push({ claudeReplayMapper.ts ×1
320 > kind: ResponsePartKind.Markdown,
321 > id: `${this._active.id}#${msg.uuid}#text-${textPartCounter++}`,
322 > content: block.text,
323 > });
324 > } else if (block.type === 'thinking' && typeof block.thinking === 'string') { claudeReplayMapper.ts ×12
325 > this._active.responseParts.push({ claudeReplayMapper.ts ×2
326 > kind: ResponsePartKind.Reasoning,
327 > id: `${this._active.id}#${msg.uuid}#thinking-${reasoningPartCounter++}`,
328 > content: block.thinking,
329 > });
330 > } else if (block.type === 'tool_use' && typeof block.id === 'string' && typeof block.name === 'string') { claudeReplayMapper.ts ×3
331 > // Strip the in-process MCP server prefix so the workbench resolves
332 > // the workbench-registered tool by its unprefixed name (matches the
333 > // live stream mapper). Without this, replayed client-tool calls
334 > // fall back to the generic "Run MCP tool" rendering.
335 > this._openToolUse(block.id, stripClientToolNamePrefix(block.name), block.input);
336 > }
337 > // Other block types (server_tool_use, etc.) are dropped silently per M7. claudeReplayMapper.ts ×12
338 > }
339 > if (msg.timestamp) {
340 > this._active.lastResponseAt = msg.timestamp; claudeReplayMapper.ts ×2
341 > }
344 > private _openToolUse(toolUseId: string, toolName: string, input: unknown): void {
345 > if (this._active === undefined) { claudeReplayMapper.ts ×3
346 return;
347 }
348 > const displayName = getClaudeToolDisplayName(toolName); claudeReplayMapper.ts ×3
349 > const parsedInput = input !== null && typeof input === 'object' ? input as Record<string, unknown> : undefined;
350 > const meta = buildClaudeToolMeta(toolName);
351 > // Build a placeholder Cancelled state by default; replaced with Completed when the tool_result lands.
352 > const placeholder: ToolCallCancelledState = {
353 > status: ToolCallStatus.Cancelled,
354 > toolCallId: toolUseId,
355 > toolName,
356 > displayName,
357 > invocationMessage: getClaudeInvocationMessage(toolName, displayName, parsedInput),
358 > toolInput: parsedInput !== undefined ? getClaudeToolInputString(toolName, parsedInput) : (typeof input === 'string' ? input : input !== undefined ? safeStringify(input) : undefined),
359 > reason: ToolCallCancellationReason.Skipped,
360 > ...(meta ? { _meta: meta } : {}),
361 > };
362 > const part: ToolCallResponsePart = {
363 > kind: ResponsePartKind.ToolCall,
364 > toolCall: placeholder,
365 > };
366 > this._active.responseParts.push(part);
367 > this._active.toolCallParts.set(toolUseId, part);
368 > this._active.pendingToolUseIds.add(toolUseId);
369 > this._toolUses.set(toolUseId, { turnId: this._active.id, parsedInput });
370 > }
372 > private _attachToolResult(block: UserToolResultBlock): string | undefined {
373 > const entry = this._toolUses.get(block.tool_use_id); claudeReplayMapper.ts ×11
374 > if (entry === undefined) {
375 this._logService.warn(`[claudeReplayMapper] tool_result for unknown tool_use_id ${block.tool_use_id}`);
376 return undefined;
377 }
378 > const announcingTurnId = entry.turnId; claudeReplayMapper.ts ×11
379 > // Find the part — it lives on the announcing turn (which may be `_active` or one already pushed to `_turns`).
380 > const part = this._findToolCallPart(announcingTurnId, block.tool_use_id);
381 > if (part === undefined) {
382 return undefined;
383 }
384 > const isError = block.is_error; claudeReplayMapper.ts ×11
385 > const previousState = part.toolCall;
386 > const isSubagent = readToolCallMeta(previousState).toolKind === 'subagent';
387 > const content: ToolResultContent[] = extractToolResultContent(block.content) ?? [];
388 > const resultText = content
389 > .filter((c): c is { type: ToolResultContentType.Text; text: string } => c.type === ToolResultContentType.Text)
390 > .map(c => c.text)
391 > .join('\n');
392 > if (isSubagent) {
393 > content.push({ claudeReplayMapper.ts ×1
394 > type: ToolResultContentType.Subagent,
395 > resource: buildSubagentSessionUri(this._session.toString(), previousState.toolCallId),
396 > title: previousState.displayName,
397 > });
398 > }
399 > const completed: ToolCallCompletedState = { claudeReplayMapper.ts ×11
400 > status: ToolCallStatus.Completed,
401 > toolCallId: previousState.toolCallId,
402 > toolName: previousState.toolName,
403 > displayName: previousState.displayName,
404 > invocationMessage: previousState.invocationMessage ?? previousState.displayName,
405 > toolInput: previousState.status === ToolCallStatus.Streaming ? undefined : previousState.toolInput,
406 > confirmed: ToolCallConfirmationReason.NotNeeded,
407 > success: !isError,
408 > pastTenseMessage: getClaudePastTenseMessage(previousState.toolName, previousState.displayName, entry.parsedInput, !isError, resultText),
409 > content: content.length > 0 ? content : undefined,
410 > ...(previousState._meta ? { _meta: previousState._meta } : {}),
411 > };
412 > part.toolCall = completed;
413 > // Drain pending tracker on the announcing turn — but only if that
414 > // turn is still in progress. Committed turns have their state
415 > // locked at close time per Fixture 6b ("orphan in turn N does
416 > // NOT cancel turn N+1"); a late-arriving tool_result for a
417 > // committed turn doesn't re-promote it.
418 > if (this._active?.id === announcingTurnId) {
419 > this._active.pendingToolUseIds.delete(block.tool_use_id); claudeReplayMapper.ts ×2
420 > }
421 > return announcingTurnId; claudeReplayMapper.ts ×11
422 > }
424 > private _findToolCallPart(turnId: string, toolUseId: string): ToolCallResponsePart | undefined {
425 > if (this._active && this._active.id === turnId) { claudeReplayMapper.ts ×11
426 > return this._active.toolCallParts.get(toolUseId); claudeReplayMapper.ts ×2
427 > }
428 > // Already-closed turn: search committed Turns. Linear scan is fine — replay is one-shot per session and turns are O(tens-hundreds). claudeReplayMapper.ts ×2
429 > for (let i = this._turns.length - 1; i >= 0; i--) {
430 > if (this._turns[i].id !== turnId) {
431 continue;
432 }
433 > for (const part of this._turns[i].responseParts) { claudeReplayMapper.ts ×2
434 > if (part.kind === ResponsePartKind.ToolCall && part.toolCall.toolCallId === toolUseId) {
435 > return part;
436 > }
437 > }
438 return undefined;
439 }
440 return undefined;
443 > private _closeActive(): void {
444 > if (this._active === undefined) { claudeReplayMapper.ts ×6
446 > }
447 > const a = this._active; claudeReplayMapper.ts ×12
448 > const state = a.pendingToolUseIds.size === 0 ? TurnState.Complete : TurnState.Cancelled; claudeReplayMapper.ts ×6
449 > const startedAt = a.startedAt === undefined ? undefined : Date.parse(a.startedAt);
450 > const endedAt = a.lastResponseAt === undefined ? undefined : Date.parse(a.lastResponseAt);
451 > const duration = startedAt !== undefined && endedAt !== undefined && Number.isFinite(startedAt) && Number.isFinite(endedAt)
452 > ? Math.max(0, endedAt - startedAt) claudeReplayMapper.ts ×2
453 > : undefined; claudeReplayMapper.ts ×1
454 > const turn: Turn = { claudeReplayMapper.ts ×6
455 > id: a.id,
456 > startedAt: a.startedAt,
457 > duration,
458 > message: { text: a.userText, origin: { kind: MessageKind.User } },
459 > responseParts: a.responseParts,
460 > usage: undefined,
461 > state,
462 > };
463 > this._turns.push(turn);
464 > this._active = undefined;
465 > }
467 >
468 > // #endregion
469 >
470 > // #region Helpers — narrow-at-the-seam shape readers
471 >
472 > /**
473 > * Returns string content (legacy form) or an array of recognised user
474 > * blocks (text + tool_result). Anything else returns `undefined` and the
475 > * caller drops the message — matches the production extension's parser
476 > * semantics per CONTEXT M7 glossary.
477 > */
478 > function readUserContent(raw: unknown): string | ReadonlyArray<UserTextBlock | UserToolResultBlock> | undefined { claudeReplayMapper.ts ×23
479 > if (raw === null || typeof raw !== 'object') {
480 return undefined;
481 }
482 > const content = (raw as { content?: unknown }).content; claudeReplayMapper.ts ×23
483 > if (typeof content === 'string') {
484 > return content.length > 0 ? content : undefined; claudeReplayMapper.ts ×3
485 > }
486 > if (!Array.isArray(content) || content.length === 0) { claudeReplayMapper.ts ×23
487 return undefined;
488 }
489 > const out: (UserTextBlock | UserToolResultBlock)[] = []; claudeReplayMapper.ts ×23
490 > for (const block of content) {
491 > if (block === null || typeof block !== 'object') {
492 continue;
493 }
494 > const b = block as { type?: unknown; text?: unknown; tool_use_id?: unknown; content?: unknown; is_error?: unknown }; claudeReplayMapper.ts ×23
495 > if (b.type === 'text' && typeof b.text === 'string') {
496 > out.push({ type: 'text', text: b.text }); claudeReplayMapper.ts ×2
497 > } else if (b.type === 'tool_result' && typeof b.tool_use_id === 'string') { claudeReplayMapper.ts ×23
498 > out.push({ type: 'tool_result', tool_use_id: b.tool_use_id, content: b.content, is_error: b.is_error === true }); claudeReplayMapper.ts ×2
499 > }
501 > return out.length > 0 ? out : undefined;
502 > }
504 > function readAssistantBlocks(raw: unknown): readonly AssistantBlock[] | undefined { claudeReplayMapper.ts ×23
505 > if (raw === null || typeof raw !== 'object') {
506 return undefined;
507 }
508 > const content = (raw as { content?: unknown }).content; claudeReplayMapper.ts ×23
509 > if (!Array.isArray(content)) {
510 return undefined;
511 }
512 > const out: AssistantBlock[] = []; claudeReplayMapper.ts ×23
513 > for (const block of content) {
514 > if (block === null || typeof block !== 'object') {
515 continue;
516 }
517 > const b = block as { type?: unknown; text?: unknown; thinking?: unknown; id?: unknown; name?: unknown; input?: unknown }; claudeReplayMapper.ts ×23
518 > if (typeof b.type !== 'string') {
519 continue;
520 }
521 > out.push({ claudeReplayMapper.ts ×23
522 > type: b.type,
523 > text: typeof b.text === 'string' ? b.text : undefined,
524 > thinking: typeof b.thinking === 'string' ? b.thinking : undefined,
525 > id: typeof b.id === 'string' ? b.id : undefined,
526 > name: typeof b.name === 'string' ? b.name : undefined,
527 > input: b.input,
528 > });
529 > }
530 > return out;
531 > }
533 > function readSystemSubtype(raw: unknown): string | undefined { claudeReplayMapper.ts ×4
534 > if (raw === null || typeof raw !== 'object') {
535 return undefined;
536 }
537 > const subtype = (raw as { subtype?: unknown }).subtype; claudeReplayMapper.ts ×4
538 > return typeof subtype === 'string' ? subtype : undefined;
539 > }
541 > function readSystemText(raw: unknown): string | undefined { claudeReplayMapper.ts ×3
542 > if (raw === null || typeof raw !== 'object') {
543 return undefined;
544 }
545 > const r = raw as { text?: unknown; message?: unknown }; claudeReplayMapper.ts ×3
546 > if (typeof r.text === 'string') {
547 > return r.text; claudeReplayMapper.ts ×4
548 > }
549 > if (typeof r.message === 'string') { claudeReplayMapper.ts ×2
550 return r.message;
551 }
552 > return undefined; claudeReplayMapper.ts ×2
553 > }
555 > /**
556 > * Mirror of the live mapper's helper — kept inline so the two mappers
557 > * don't yet need a shared module. If a third consumer appears, factor
558 > * to `claudeToolResultContent.ts`.
559 > */
560 > function extractToolResultContent(content: unknown): { type: ToolResultContentType.Text; text: string }[] | undefined { claudeReplayMapper.ts ×11
561 > if (typeof content === 'string') {
562 > return content.length > 0 ? [{ type: ToolResultContentType.Text, text: content }] : undefined;
563 > }
564 if (!Array.isArray(content)) {
565 return undefined;
566 }
567 const out: { type: ToolResultContentType.Text; text: string }[] = [];
568 for (const block of content) {
569 if (block === null || typeof block !== 'object') {
570 continue;
571 }
572 const b = block as { type?: unknown; text?: unknown };
573 if (b.type === 'text' && typeof b.text === 'string') {
574 out.push({ type: ToolResultContentType.Text, text: b.text });
575 }
576 }
577 > return out.length > 0 ? out : undefined; claudeReplayMapper.ts ×11
578 > }
580 function safeStringify(v: unknown): string | undefined {
581 try {
582 return JSON.stringify(v);
583 } catch {
584 return undefined;
585 }
586 }
588 > /**
589 > * True when the message content is a CLI slash-command echo (e.g.
590 > * `<command-name>/model</command-name>...`) that the subprocess writes
591 > * to the transcript for restore fidelity but is not a user-authored prompt.
592 > * Checks the first text fragment only; mixed messages where the first
593 > * content block is a real prompt are NOT filtered.
594 > */
595 > function isCliEchoContent(content: string | ReadonlyArray<UserTextBlock | UserToolResultBlock>): boolean { claudeReplayMapper.ts ×23
596 > if (typeof content === 'string') {
597 > return CLI_ECHO_MARKER_PATTERN.test(content); claudeReplayMapper.ts ×3
598 > }
599 > const firstText = content.find((b): b is UserTextBlock => b.type === 'text'); claudeReplayMapper.ts ×23
600 > return firstText !== undefined && CLI_ECHO_MARKER_PATTERN.test(firstText.text);
601 > }
603 > // #endregion