@vybestack/llxprt-code 0.10.0 → 0.11.0-nightly.260728.ffaa8d5d7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/package.json +13 -13
  2. package/src/commands/mcp/add.ts +8 -4
  3. package/src/config/cliArgParser.ts +2 -0
  4. package/src/config/config.ts +2 -0
  5. package/src/config/configBuilder.ts +3 -0
  6. package/src/config/configError.ts +23 -0
  7. package/src/config/extensions/extensionEnablement.ts +62 -1
  8. package/src/config/interactiveContext.ts +2 -1
  9. package/src/config/intermediateConfig.ts +4 -0
  10. package/src/config/keyBindings.ts +29 -3
  11. package/src/config/policy.ts +7 -1
  12. package/src/config/postConfigRuntime.ts +20 -14
  13. package/src/config/settings-schema/schema-ui.ts +9 -0
  14. package/src/config/settingsLoader.ts +21 -17
  15. package/src/config/settingsSchema.ts +2 -2
  16. package/src/config/trustedFolders.ts +275 -58
  17. package/src/config/yargsOptions.ts +7 -0
  18. package/src/generated/git-commit.json +1 -1
  19. package/src/nonInteractiveCli.ts +1 -0
  20. package/src/nonInteractiveCliSupport.ts +171 -51
  21. package/src/runtime/interactiveToolScheduler.ts +6 -6
  22. package/src/services/BuiltinCommandLoader.ts +33 -25
  23. package/src/services/FileCommandLoader.ts +34 -7
  24. package/src/test-utils/async.ts +20 -0
  25. package/src/test-utils/render.tsx +10 -1
  26. package/src/ui/App.tsx +17 -10
  27. package/src/ui/AppContainerRuntime.tsx +7 -9
  28. package/src/ui/cliUiRuntime.ts +28 -0
  29. package/src/ui/commands/authCommand.ts +240 -3
  30. package/src/ui/commands/compressCommand.ts +9 -1
  31. package/src/ui/commands/directoryCommand.tsx +1 -1
  32. package/src/ui/commands/logoutCommand.ts +2 -2
  33. package/src/ui/commands/permissionsCommand.ts +77 -20
  34. package/src/ui/commands/policiesCommand.ts +6 -1
  35. package/src/ui/commands/quotaCommand.ts +501 -0
  36. package/src/ui/commands/statsCommand.ts +2 -2
  37. package/src/ui/commands/statsQuota.ts +1 -1
  38. package/src/ui/commands/subagentCommand.ts +59 -27
  39. package/src/ui/components/AuthDialog.tsx +3 -3
  40. package/src/ui/components/Composer.tsx +1 -0
  41. package/src/ui/components/DialogManager.tsx +9 -2
  42. package/src/ui/components/FolderTrustDialog.tsx +71 -60
  43. package/src/ui/components/PermissionsModifyTrustDialog.tsx +211 -146
  44. package/src/ui/components/ProfileCreateWizard/constants.ts +13 -1
  45. package/src/ui/components/QueuedMessagesPanel.tsx +388 -0
  46. package/src/ui/components/inputPromptHooks.ts +2 -0
  47. package/src/ui/components/inputPromptKeyHandlers.ts +14 -0
  48. package/src/ui/components/inputPromptTypes.ts +6 -0
  49. package/src/ui/components/messages/AiMessage.tsx +24 -12
  50. package/src/ui/components/messages/ToolMessage.tsx +26 -40
  51. package/src/ui/containers/AppContainer/builders/buildUIActions.ts +3 -1
  52. package/src/ui/containers/AppContainer/builders/buildUIState.ts +5 -4
  53. package/src/ui/containers/AppContainer/hooks/useAppBootstrap.ts +3 -33
  54. package/src/ui/containers/AppContainer/hooks/useAppDialogs.ts +2 -14
  55. package/src/ui/containers/AppContainer/hooks/useAppInput.ts +31 -25
  56. package/src/ui/containers/AppContainer/hooks/useAppLayout.ts +90 -60
  57. package/src/ui/containers/AppContainer/hooks/useDisplayPreferences.ts +10 -0
  58. package/src/ui/containers/AppContainer/hooks/useInputHandling.ts +3 -17
  59. package/src/ui/containers/AppContainer/hooks/useKeybindings.ts +51 -38
  60. package/src/ui/containers/AppContainer/hooks/useSteer.ts +51 -0
  61. package/src/ui/contexts/KeypressContext.tsx +13 -2
  62. package/src/ui/contexts/ShellCommandDisplayContext.tsx +103 -0
  63. package/src/ui/contexts/TodoContext.tsx +0 -13
  64. package/src/ui/contexts/TodoProvider.tsx +1 -34
  65. package/src/ui/contexts/UIActionsContext.tsx +2 -1
  66. package/src/ui/contexts/UIStateContext.tsx +3 -2
  67. package/src/ui/hooks/agentStream/streamUtils.ts +0 -24
  68. package/src/ui/hooks/agentStream/thoughtState.ts +110 -30
  69. package/src/ui/hooks/agentStream/types.ts +1 -0
  70. package/src/ui/hooks/agentStream/useAgentEventStream.ts +8 -17
  71. package/src/ui/hooks/agentStream/useAgentStream.ts +2 -2
  72. package/src/ui/hooks/agentStream/useAgentStreamLifecycle.ts +4 -5
  73. package/src/ui/hooks/agentStream/useAgentStreamOrchestration.ts +12 -5
  74. package/src/ui/hooks/agentStream/useQueuedSubmissions.ts +99 -0
  75. package/src/ui/hooks/agentStream/useStreamEventHandlers.ts +4 -13
  76. package/src/ui/hooks/agentStream/useStreamState.ts +60 -41
  77. package/src/ui/hooks/agentStream/useSubmitQuery.ts +284 -59
  78. package/src/ui/hooks/slashCommandProcessorSupport.ts +4 -0
  79. package/src/ui/hooks/useAgentStream-test-helpers.ts +1 -0
  80. package/src/ui/hooks/useAutoAcceptIndicator.ts +13 -0
  81. package/src/ui/hooks/useFolderTrust.ts +118 -81
  82. package/src/ui/hooks/useGitBranchName.ts +79 -33
  83. package/src/ui/hooks/useIdeTrustListener.ts +5 -19
  84. package/src/ui/hooks/usePermissionsModifyTrust.ts +223 -85
  85. package/src/ui/hooks/useReactToolScheduler.ts +13 -17
  86. package/src/ui/hooks/useTodoPausePreserver.ts +0 -55
  87. package/src/ui/layouts/DefaultAppLayout.tsx +2 -0
  88. package/src/ui/layouts/DefaultAppLayoutHelpers.tsx +37 -162
  89. package/src/ui/layouts/InlineContent.tsx +198 -0
  90. package/src/ui/trustDialogHelpers.ts +190 -0
  91. package/src/utils/cleanup-state.ts +115 -0
  92. package/src/utils/cleanup.ts +32 -34
  93. package/src/utils/sandbox-containers.ts +183 -53
  94. package/src/utils/sandbox-exec.ts +9 -3
  95. package/src/zed-integration/acp-terminal-shell-host.ts +112 -0
  96. package/src/zed-integration/acp-types.ts +35 -0
  97. package/src/zed-integration/fileSystemService.ts +1 -1
  98. package/src/zed-integration/runZedIntegration.ts +142 -0
  99. package/src/zed-integration/zed-agent-event-handler.ts +179 -0
  100. package/src/zed-integration/zed-agent-setup.ts +43 -0
  101. package/src/zed-integration/zed-command-registry.ts +167 -0
  102. package/src/zed-integration/zed-config-options.ts +280 -0
  103. package/src/zed-integration/zed-content-utils.ts +2 -2
  104. package/src/zed-integration/zed-helpers.ts +132 -0
  105. package/src/zed-integration/zed-initialize.ts +64 -0
  106. package/src/zed-integration/zed-plan-update.ts +20 -0
  107. package/src/zed-integration/zed-prompt-command.ts +77 -0
  108. package/src/zed-integration/zed-session-config.ts +119 -0
  109. package/src/zed-integration/zed-session-errors.ts +337 -0
  110. package/src/zed-integration/zed-session-events.ts +216 -0
  111. package/src/zed-integration/zed-session-info.ts +255 -0
  112. package/src/zed-integration/zed-session-lifecycle.ts +186 -0
  113. package/src/zed-integration/zed-session-listing.ts +153 -0
  114. package/src/zed-integration/zed-session-loader.ts +193 -0
  115. package/src/zed-integration/zed-session-pagination.ts +240 -0
  116. package/src/zed-integration/zed-session-replay.ts +564 -0
  117. package/src/zed-integration/zed-stream-batcher.ts +236 -0
  118. package/src/zed-integration/zed-terminal-manager.ts +416 -0
  119. package/src/zed-integration/zed-terminal-setup.ts +72 -0
  120. package/src/zed-integration/zed-test-helpers.ts +349 -4
  121. package/src/zed-integration/zed-tool-handler.ts +47 -5
  122. package/src/zed-integration/zedIntegration.ts +538 -587
  123. package/src/services/todo-continuation/todoContinuationService.ts +0 -610
  124. package/src/ui/containers/AppContainer/hooks/useIdeRestartHotkey.ts +0 -33
  125. package/src/ui/containers/AppContainer/hooks/useTodoContinuationFlow.ts +0 -160
  126. package/src/ui/hooks/useTodoContinuation.ts +0 -299
@@ -0,0 +1,564 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Vybestack LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ /**
8
+ * IContent -> ACP SessionUpdate mapping for ACP session/load (loadSession)
9
+ * conversation replay (issue #1604).
10
+ *
11
+ * When Zed calls `session/load`, the agent resumes a persisted session and must
12
+ * stream the historical conversation back to the client as `session/update`
13
+ * notifications BEFORE the load resolves, so the client can reconstruct the
14
+ * transcript. This module owns the pure, side-effect-free translation from the
15
+ * neutral {@link IContent} history (as returned by `agent.session.resume`) to
16
+ * the ordered list of ACP {@link acp.SessionUpdate} notifications the live
17
+ * streaming path (StreamBatcher / zed-tool-handler.ts) would have produced.
18
+ *
19
+ * Tool-call fidelity (order-aware pairing): the live path emits a tool_call
20
+ * (status 'in_progress', empty content, inferred locations/kind) at call time
21
+ * and a SEPARATE tool_call_update (completed/failed with the result text) when
22
+ * the response arrives. Replay mirrors that two-update shape via a SINGLE
23
+ * ordered walk that tracks a `pending` set of started-but-unfinished callIds:
24
+ *
25
+ * - an ai ToolCallBlock emits the in_progress tool_call and marks the id
26
+ * pending;
27
+ * - a tool ToolResponseBlock whose id IS pending emits exactly ONE terminal
28
+ * tool_call_update (first response wins) and clears the id — a duplicate
29
+ * response for the same id is then dropped;
30
+ * - a tool ToolResponseBlock whose id is NOT pending (an orphan response before
31
+ * its start, or a second response after completion) is DROPPED so replay
32
+ * never emits a floating terminal update with no matching start;
33
+ * - any id still pending at end-of-history (an interrupted turn) gets a
34
+ * synthetic 'failed' tool_call_update, in original start order, so the client
35
+ * never renders a perpetually-running tool.
36
+ *
37
+ * This ordered pairing (vs a global pre-scan of every response id) keeps the
38
+ * start->end status transitions correct across response-before-call, duplicate
39
+ * responses, and the same id reused across turns.
40
+ *
41
+ * It is kept separate from zedIntegration.ts (and free of any I/O) so the
42
+ * mapping stays small, individually unit-testable against the v1 snake_case
43
+ * wire discriminators, and lint-clean under the complexity guardrails.
44
+ */
45
+
46
+ import type * as acp from '@agentclientprotocol/sdk';
47
+ import type {
48
+ IContent,
49
+ ContentBlock,
50
+ TextBlock,
51
+ ThinkingBlock,
52
+ ToolCallBlock,
53
+ ToolResponseBlock,
54
+ } from '@vybestack/llxprt-code-core';
55
+ import {
56
+ buildToolLocations,
57
+ inferToolKind,
58
+ toAcpToolKind,
59
+ } from './zed-tool-handler.js';
60
+ import { extractToolResultText } from './zed-content-utils.js';
61
+
62
+ /** Readonly JSON-object shape used when narrowing recorded `unknown` payloads. */
63
+ type Dict = Readonly<Record<string, unknown>>;
64
+
65
+ /**
66
+ * Maps a full resumed history (ordered IContent[]) to the ordered ACP
67
+ * SessionUpdate notifications that replay the conversation. Empty/whitespace
68
+ * text chunks are skipped; unmappable blocks (media, code) contribute no update.
69
+ *
70
+ * Tool calls are paired in a single ordered pass (see the module doc): each ai
71
+ * tool_call emits an in_progress tool_call and is tracked as pending; the FIRST
72
+ * tool response for a pending id emits the terminal completed/failed update and
73
+ * clears it; unmatched responses are dropped; and any call still pending after
74
+ * the whole history is walked yields a trailing synthetic failed update, in
75
+ * original start order (Map iteration preserves insertion order). The output
76
+ * preserves history order and, per message, block order.
77
+ */
78
+ export function mapHistoryToSessionUpdates(
79
+ items: readonly IContent[],
80
+ ): acp.SessionUpdate[] {
81
+ const updates: acp.SessionUpdate[] = [];
82
+ const pending = new Map<string, acp.ToolKind>();
83
+ // FINDING D1: iterate as `unknown` because persisted history read back from
84
+ // disk is UNTRUSTED — the static `readonly IContent[]` type is a contract, not
85
+ // a runtime guarantee. A corrupt/truncated JSONL line can yield a null item or
86
+ // one whose `blocks` is missing/non-array; asRenderableContent narrows each
87
+ // against the real runtime shape so a malformed entry is skipped instead of
88
+ // throwing and aborting the WHOLE load.
89
+ for (const raw of items as readonly unknown[]) {
90
+ const item = asRenderableContent(raw);
91
+ if (item === null) {
92
+ continue;
93
+ }
94
+ // FINDING D1 (block level): each ELEMENT of a narrowed blocks array is STILL
95
+ // untrusted — the item-level narrowing only proved `blocks` is an array, not
96
+ // that its elements are objects. Persisted JSONL can carry blocks: [null],
97
+ // [undefined], [42], ['x'], [{}] (no type), or [{type: 42}]. asRenderableBlock
98
+ // narrows every element to a non-null object with a STRING `type`, so a
99
+ // malformed element is skipped silently (matching the malformed-ITEM skip)
100
+ // instead of throwing on `block.type` in appendBlockUpdates and aborting the
101
+ // WHOLE replay. A valid block AFTER a malformed one in the same item — and a
102
+ // valid item AFTER a malformed-blocks item — both still replay.
103
+ for (const rawBlock of item.blocks) {
104
+ const block = asRenderableBlock(rawBlock);
105
+ if (block === null) {
106
+ continue;
107
+ }
108
+ appendBlockUpdates(item.speaker, block, pending, updates);
109
+ }
110
+ }
111
+ // Every id still pending had no delivered response (an interrupted turn):
112
+ // synthesize its terminal failed update now, in start order, so the client
113
+ // does not render a perpetually-running tool. Map iteration preserves the
114
+ // insertion (call-start) order.
115
+ for (const [toolCallId, kind] of pending) {
116
+ updates.push(buildSyntheticFailedUpdate(toolCallId, kind));
117
+ }
118
+ return updates;
119
+ }
120
+
121
+ /**
122
+ * Appends the SessionUpdate(s) for a single (speaker, block) pair to `out`,
123
+ * mutating `pending` for tool-call lifecycle tracking. Text/thinking map to at
124
+ * most one update; an ai tool_call emits its in_progress start and marks the id
125
+ * pending; a tool-speaker tool_response emits its terminal update only when it pairs
126
+ * with a pending start (see appendToolResponseUpdate).
127
+ */
128
+ function appendBlockUpdates(
129
+ speaker: IContent['speaker'],
130
+ block: ContentBlock,
131
+ pending: Map<string, acp.ToolKind>,
132
+ out: acp.SessionUpdate[],
133
+ ): void {
134
+ switch (block.type) {
135
+ case 'text':
136
+ pushDefined(out, mapTextBlock(speaker, block));
137
+ return;
138
+ case 'thinking':
139
+ pushDefined(out, mapThinkingBlock(speaker, block));
140
+ return;
141
+ case 'tool_call':
142
+ // FINDING D4: a tool_call whose id is missing/non-string cannot be tracked
143
+ // (nothing could ever pair its response) and would emit an update carrying
144
+ // an undefined toolCallId on the wire. Skip it entirely.
145
+ if (speaker === 'ai' && isNonEmptyString(block.id)) {
146
+ const kind = toolKindForRecordedCall(block);
147
+ const previousKind = pending.get(block.id);
148
+ if (previousKind !== undefined) {
149
+ out.push(buildSyntheticFailedUpdate(block.id, previousKind));
150
+ }
151
+ out.push(buildToolCallStart(block, kind));
152
+ pending.set(block.id, kind);
153
+ }
154
+ return;
155
+ case 'tool_response':
156
+ // FINDING D4: a tool_response without a string callId can never pair with a
157
+ // started call, so it would always be dropped by the pending check anyway;
158
+ // guard explicitly so the intent is clear and no undefined id is handled.
159
+ if (speaker === 'tool' && isNonEmptyString(block.callId)) {
160
+ appendToolResponseUpdate(block, pending, out);
161
+ }
162
+ return;
163
+ // Media/code blocks are intentionally skipped in v1 replay: ACP has no
164
+ // lossless chunk for a stored CodeBlock, and re-streaming base64 MediaBlock
165
+ // payloads on every reconnect would be wasteful and is not required to
166
+ // reconstruct the readable transcript.
167
+ default:
168
+ return;
169
+ }
170
+ }
171
+
172
+ /**
173
+ * Emits the terminal tool_call_update for a recorded tool response, order-aware
174
+ * against the `pending` set of started-but-unfinished calls:
175
+ * - if the callId IS pending: emit exactly ONE terminal update (first response
176
+ * wins) and clear the id so a later duplicate response for the same id is
177
+ * dropped;
178
+ * - if the callId is NOT pending (an orphan response that arrived before its
179
+ * start, or a second response after the call already completed): DROP it.
180
+ * Emitting a floating terminal update with no matching in_progress start
181
+ * would hand the client a tool_call_update it never saw begin, corrupting the
182
+ * status transition; dropping keeps replay wire-consistent with the live
183
+ * start->end pairing.
184
+ */
185
+ function appendToolResponseUpdate(
186
+ block: ToolResponseBlock,
187
+ pending: Map<string, acp.ToolKind>,
188
+ out: acp.SessionUpdate[],
189
+ ): void {
190
+ const kind = pending.get(block.callId);
191
+ if (kind === undefined) {
192
+ return;
193
+ }
194
+ pending.delete(block.callId);
195
+ out.push(mapToolResponseBlock(block, kind));
196
+ }
197
+
198
+ /**
199
+ * Builds the synthetic terminal update for a tool call that never received a
200
+ * response: a 'failed' tool_call_update with empty content, matching the live
201
+ * behavior of never leaving a perpetually-running tool on the client.
202
+ */
203
+ function buildSyntheticFailedUpdate(
204
+ toolCallId: string,
205
+ kind: acp.ToolKind,
206
+ ): acp.SessionUpdate {
207
+ return {
208
+ sessionUpdate: 'tool_call_update',
209
+ toolCallId,
210
+ status: 'failed',
211
+ content: [],
212
+ kind,
213
+ };
214
+ }
215
+
216
+ /** Pushes `update` onto `out` when it is non-null (small readability helper). */
217
+ function pushDefined(
218
+ out: acp.SessionUpdate[],
219
+ update: acp.SessionUpdate | null,
220
+ ): void {
221
+ if (update !== null) {
222
+ out.push(update);
223
+ }
224
+ }
225
+
226
+ /**
227
+ * human text -> user_message_chunk; ai text -> agent_message_chunk. Tool-speaker
228
+ * text (rare) is skipped. Whitespace-only text is skipped.
229
+ */
230
+ function mapTextBlock(
231
+ speaker: IContent['speaker'],
232
+ block: TextBlock,
233
+ ): acp.SessionUpdate | null {
234
+ const text = block.text;
235
+ // FINDING D2: guard against a non-string `text` in malformed persisted data
236
+ // (mirrors the guard mapThinkingBlock already has for `thought`). Calling
237
+ // .trim() on a non-string would throw and abort the load; skip instead.
238
+ if (typeof text !== 'string' || text.trim().length === 0) {
239
+ return null;
240
+ }
241
+ if (speaker === 'human') {
242
+ return {
243
+ sessionUpdate: 'user_message_chunk',
244
+ content: { type: 'text', text },
245
+ };
246
+ }
247
+ if (speaker === 'ai') {
248
+ return {
249
+ sessionUpdate: 'agent_message_chunk',
250
+ content: { type: 'text', text },
251
+ };
252
+ }
253
+ return null;
254
+ }
255
+
256
+ /**
257
+ * ai thinking -> agent_thought_chunk (text content carrying the thought). Empty
258
+ * thoughts, or thinking blocks from a non-ai speaker, are skipped.
259
+ */
260
+ function mapThinkingBlock(
261
+ speaker: IContent['speaker'],
262
+ block: ThinkingBlock,
263
+ ): acp.SessionUpdate | null {
264
+ if (speaker !== 'ai') {
265
+ return null;
266
+ }
267
+ const text = block.thought;
268
+ if (typeof text !== 'string' || text.trim().length === 0) {
269
+ return null;
270
+ }
271
+ return {
272
+ sessionUpdate: 'agent_thought_chunk',
273
+ content: { type: 'text', text },
274
+ };
275
+ }
276
+
277
+ /**
278
+ * ai tool_call -> in_progress tool_call. Field names + shape mirror the live
279
+ * start path in zed-tool-handler.ts (emitToolCallStart): status 'in_progress',
280
+ * empty content, locations inferred from the recorded parameters via the SAME
281
+ * buildToolLocations helper, kind inferred via the SAME inferToolKind table and
282
+ * normalized to `other` for unknown tools, and rawInput ALWAYS present ({} when
283
+ * parameters are missing/malformed) exactly as the live path sends call.args.
284
+ */
285
+ function buildToolCallStart(
286
+ block: ToolCallBlock,
287
+ kind: acp.ToolKind,
288
+ ): acp.SessionUpdate {
289
+ // FINDING D4: fall back to the id for the title when `name` is missing/non-
290
+ // string in malformed persisted data. Keeping the (already-validated string)
291
+ // id as the title preserves identifying info on the wire rather than sending
292
+ // an undefined title.
293
+ const hasName = isNonEmptyString(block.name);
294
+ // rawInput is sent unconditionally ({} when the recorded parameters are
295
+ // missing/malformed) to stay wire-identical with the live start path in
296
+ // zed-tool-handler.ts (emitToolCallStart), which always includes rawInput.
297
+ const rawInput = toRawInput(block.parameters) ?? {};
298
+ return {
299
+ sessionUpdate: 'tool_call',
300
+ toolCallId: block.id,
301
+ title: hasName ? block.name : block.id,
302
+ status: 'in_progress',
303
+ content: [],
304
+ locations: buildToolLocations(rawInput),
305
+ kind,
306
+ rawInput,
307
+ };
308
+ }
309
+
310
+ function toolKindForRecordedCall(block: ToolCallBlock): acp.ToolKind {
311
+ return toAcpToolKind(
312
+ isNonEmptyString(block.name) ? inferToolKind(block.name) : undefined,
313
+ );
314
+ }
315
+
316
+ /**
317
+ * True when `value` is a non-empty string. Used to validate persisted tool-call
318
+ * ids/callIds/names read back from disk (FINDING D4) before they are emitted on
319
+ * the wire, so a corrupt block never yields an update with an undefined id.
320
+ */
321
+ function isNonEmptyString(value: unknown): value is string {
322
+ return typeof value === 'string' && value.length > 0;
323
+ }
324
+
325
+ /** The valid IContent speaker discriminators a persisted item may carry. */
326
+ const VALID_SPEAKERS: ReadonlySet<IContent['speaker']> = new Set([
327
+ 'human',
328
+ 'ai',
329
+ 'tool',
330
+ ]);
331
+
332
+ /**
333
+ * Narrows an UNTRUSTED persisted history entry to a renderable IContent — a
334
+ * non-null object whose `speaker` is a valid discriminator ('human' | 'ai' |
335
+ * 'tool') and whose `blocks` is an array (FINDING D1). Returns null for a
336
+ * null/undefined item or one whose speaker/blocks is missing/invalid (a
337
+ * corrupt/truncated JSONL line that still JSON-parsed), so the caller skips it
338
+ * rather than throwing on `item.blocks` or silently mapping an unknown speaker.
339
+ * The array ELEMENTS deliberately stay `unknown`: the caller narrows each one
340
+ * with {@link asRenderableBlock} (D1 at the block level) before use, and the
341
+ * type-specific fields are then guarded by the per-block D2/D4 checks. This
342
+ * function only guarantees the speaker union and that `blocks` is iterable — it
343
+ * does NOT (and must not) pretend the raw elements are valid ContentBlocks.
344
+ */
345
+ function asRenderableContent(
346
+ value: unknown,
347
+ ): { speaker: IContent['speaker']; blocks: readonly unknown[] } | null {
348
+ if (value === null || typeof value !== 'object') {
349
+ return null;
350
+ }
351
+ const record = value as { speaker?: unknown; blocks?: unknown };
352
+ if (!VALID_SPEAKERS.has(record.speaker as IContent['speaker'])) {
353
+ return null;
354
+ }
355
+ if (!Array.isArray(record.blocks)) {
356
+ return null;
357
+ }
358
+ return {
359
+ speaker: record.speaker as IContent['speaker'],
360
+ blocks: record.blocks as readonly unknown[],
361
+ };
362
+ }
363
+
364
+ /**
365
+ * Narrows a single UNTRUSTED persisted block to a renderable {@link ContentBlock}
366
+ * — a non-null object carrying a STRING `type` discriminator (FINDING D1 at the
367
+ * block level). Returns null for a null/undefined/primitive element or an object
368
+ * with no string `type` (the `blocks: [null]`, `[undefined]`, `[42]`, `['x']`,
369
+ * `[{}]`, `[{type: 42}]` shapes a corrupt/truncated JSONL line can carry), so the
370
+ * caller SKIPS it silently rather than throwing on `block.type` in
371
+ * {@link appendBlockUpdates} and aborting the WHOLE replay.
372
+ *
373
+ * Only the `type` discriminator is validated here; the type-specific fields
374
+ * (text/thought/id/callId) remain guarded downstream by the per-block D2/D4
375
+ * checks exactly as they are for a statically-typed block, so an object with a
376
+ * valid `type` but a malformed payload is still handled defensively rather than
377
+ * throwing. The `as ContentBlock` mirrors the established narrowing idiom in this
378
+ * module (see {@link asRecord} / {@link asRenderableContent}): a runtime check
379
+ * precedes a precise assertion, never `any`.
380
+ */
381
+ function asRenderableBlock(value: unknown): ContentBlock | null {
382
+ if (value === null || typeof value !== 'object') {
383
+ return null;
384
+ }
385
+ const record = value as { type?: unknown };
386
+ if (typeof record.type !== 'string') {
387
+ return null;
388
+ }
389
+ return value as ContentBlock;
390
+ }
391
+
392
+ /**
393
+ * tool-speaker tool_response -> terminal tool_call_update. Status is 'failed' when the
394
+ * block carries an error (ToolResponseBlock.error) OR its result is an object
395
+ * with a non-empty string `error` property OR a nested `error.message` string
396
+ * (the { error } and { error: { message } } failure shapes produced by
397
+ * createErrorResponse, FINDING F3), else 'completed'. The displayed text is
398
+ * extracted with a
399
+ * precedence that mirrors how results are actually recorded: result.output
400
+ * (the { output } success shape), then a string result.content, then an
401
+ * MCP-style result.content array (joined text elements), then the failure error
402
+ * text, then the shared extractToolResultText fallback. Only non-empty text
403
+ * yields a content entry; otherwise the update carries an empty content array
404
+ * (mirroring the live suppressed-display behavior).
405
+ *
406
+ * Diff replay gap: the recorded IContent does NOT persist the display/FileDiff
407
+ * metadata the live path uses to emit a { type: 'diff' } ToolCallContent, so a
408
+ * faithful diff replay is not reconstructable from recorded history yet. It is
409
+ * intentionally deferred here (tracked on issue #1604); replay surfaces the
410
+ * textual result instead of a structured diff.
411
+ */
412
+ function mapToolResponseBlock(
413
+ block: ToolResponseBlock,
414
+ kind: acp.ToolKind,
415
+ ): acp.SessionUpdate {
416
+ const record = asRecord(block.result);
417
+ const errorText = failureText(block, record);
418
+ const failed = errorText !== null;
419
+ const text = extractResponseText(block, record, failed, errorText);
420
+ const content: acp.ToolCallContent[] =
421
+ text !== null && text.length > 0
422
+ ? [{ type: 'content', content: { type: 'text', text } }]
423
+ : [];
424
+ return {
425
+ sessionUpdate: 'tool_call_update',
426
+ toolCallId: block.callId,
427
+ status: failed ? 'failed' : 'completed',
428
+ content,
429
+ kind,
430
+ };
431
+ }
432
+
433
+ /**
434
+ * Extracts the failure text carried by a result object's `error` property,
435
+ * supporting BOTH the `{ error: string }` shape and the `{ error: { message:
436
+ * string } }` object shape (FINDING F3). Returns the non-empty error/message
437
+ * string, or null when there is no representable error text.
438
+ */
439
+ function resultErrorText(record: Dict | null): string | null {
440
+ const resultError = record?.error;
441
+ if (typeof resultError === 'string' && resultError.length > 0) {
442
+ return resultError;
443
+ }
444
+ const nested = asRecord(resultError);
445
+ if (nested !== null) {
446
+ const message = nested.message;
447
+ if (typeof message === 'string' && message.length > 0) {
448
+ return message;
449
+ }
450
+ }
451
+ return null;
452
+ }
453
+
454
+ /**
455
+ * Extracts the display text for a tool response with the precedence documented
456
+ * on mapToolResponseBlock: result.output, then string result.content, then an
457
+ * MCP-style result.content array, then (for failures) the error text, then the
458
+ * shared extractToolResultText fallback. Returns null when no non-empty text is
459
+ * representable.
460
+ */
461
+ function extractResponseText(
462
+ block: ToolResponseBlock,
463
+ record: Dict | null,
464
+ failed: boolean,
465
+ precomputedErrorText: string | null = null,
466
+ ): string | null {
467
+ const output = record?.output;
468
+ if (typeof output === 'string' && output.length > 0) {
469
+ return output;
470
+ }
471
+ const inlineContent = record?.content;
472
+ if (typeof inlineContent === 'string' && inlineContent.length > 0) {
473
+ return inlineContent;
474
+ }
475
+ const arrayText = extractContentArrayText(inlineContent);
476
+ if (arrayText !== null) {
477
+ return arrayText;
478
+ }
479
+ if (failed) {
480
+ const errorText = precomputedErrorText ?? failureText(block, record);
481
+ if (errorText !== null) {
482
+ return errorText;
483
+ }
484
+ }
485
+ try {
486
+ return extractToolResultText({ llmContent: block.result });
487
+ } catch {
488
+ return null;
489
+ }
490
+ }
491
+
492
+ /**
493
+ * Joins the text of an MCP-style result.content array: keeps only elements that
494
+ * look like `{ type: 'text', text: string }`, concatenates their `text` (no
495
+ * separator, matching how streamed text chunks recombine), and returns the
496
+ * result only when non-empty. Non-text elements (images, resources) are skipped.
497
+ * Returns null when the value is not an array or yields no text, so the caller
498
+ * falls through to the next precedence tier.
499
+ */
500
+ function extractContentArrayText(value: unknown): string | null {
501
+ if (!Array.isArray(value)) {
502
+ return null;
503
+ }
504
+ const text = value
505
+ .map((element) => textOfContentElement(element))
506
+ .filter((entry): entry is string => entry !== null)
507
+ .join('');
508
+ return text.length > 0 ? text : null;
509
+ }
510
+
511
+ /**
512
+ * Narrows a single MCP-style content-array element to its text: returns the
513
+ * `text` string for a `{ type: 'text', text: string }` element whose text is
514
+ * non-whitespace, else null (skipping images/resources, malformed entries, and
515
+ * whitespace-only text elements, FINDING F11). Whitespace-only elements are
516
+ * dropped so a content array of only blank text yields an empty content array on
517
+ * the update rather than passing blank text through.
518
+ */
519
+ function textOfContentElement(element: unknown): string | null {
520
+ const record = asRecord(element);
521
+ if (record === null || record.type !== 'text') {
522
+ return null;
523
+ }
524
+ return typeof record.text === 'string' && record.text.trim().length > 0
525
+ ? record.text
526
+ : null;
527
+ }
528
+
529
+ /**
530
+ * Returns the failure text for a failed response: the block-level error string
531
+ * when present, else the result object's error text (string `error` OR nested
532
+ * `error.message`, via {@link resultErrorText}, FINDING F3), else null.
533
+ */
534
+ function failureText(
535
+ block: ToolResponseBlock,
536
+ record: Dict | null,
537
+ ): string | null {
538
+ if (typeof block.error === 'string' && block.error.length > 0) {
539
+ return block.error;
540
+ }
541
+ return resultErrorText(record);
542
+ }
543
+
544
+ /**
545
+ * Narrows a stored ToolCallBlock.parameters (typed `unknown`) to the `rawInput`
546
+ * shape ACP expects: a JSON object, or undefined when the stored value is not a
547
+ * plain object (so the wire payload never carries a non-object rawInput).
548
+ */
549
+ function toRawInput(parameters: unknown): Dict | undefined {
550
+ const record = asRecord(parameters);
551
+ return record ?? undefined;
552
+ }
553
+
554
+ /**
555
+ * Narrows an unknown value to a readonly JSON object (non-null, non-array
556
+ * object), or null otherwise. Local to this module so the mapping stays
557
+ * self-contained and pure.
558
+ */
559
+ function asRecord(value: unknown): Dict | null {
560
+ if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
561
+ return value as Dict;
562
+ }
563
+ return null;
564
+ }