@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.
- package/package.json +13 -13
- package/src/commands/mcp/add.ts +8 -4
- package/src/config/cliArgParser.ts +2 -0
- package/src/config/config.ts +2 -0
- package/src/config/configBuilder.ts +3 -0
- package/src/config/configError.ts +23 -0
- package/src/config/extensions/extensionEnablement.ts +62 -1
- package/src/config/interactiveContext.ts +2 -1
- package/src/config/intermediateConfig.ts +4 -0
- package/src/config/keyBindings.ts +29 -3
- package/src/config/policy.ts +7 -1
- package/src/config/postConfigRuntime.ts +20 -14
- package/src/config/settings-schema/schema-ui.ts +9 -0
- package/src/config/settingsLoader.ts +21 -17
- package/src/config/settingsSchema.ts +2 -2
- package/src/config/trustedFolders.ts +275 -58
- package/src/config/yargsOptions.ts +7 -0
- package/src/generated/git-commit.json +1 -1
- package/src/nonInteractiveCli.ts +1 -0
- package/src/nonInteractiveCliSupport.ts +171 -51
- package/src/runtime/interactiveToolScheduler.ts +6 -6
- package/src/services/BuiltinCommandLoader.ts +33 -25
- package/src/services/FileCommandLoader.ts +34 -7
- package/src/test-utils/async.ts +20 -0
- package/src/test-utils/render.tsx +10 -1
- package/src/ui/App.tsx +17 -10
- package/src/ui/AppContainerRuntime.tsx +7 -9
- package/src/ui/cliUiRuntime.ts +28 -0
- package/src/ui/commands/authCommand.ts +240 -3
- package/src/ui/commands/compressCommand.ts +9 -1
- package/src/ui/commands/directoryCommand.tsx +1 -1
- package/src/ui/commands/logoutCommand.ts +2 -2
- package/src/ui/commands/permissionsCommand.ts +77 -20
- package/src/ui/commands/policiesCommand.ts +6 -1
- package/src/ui/commands/quotaCommand.ts +501 -0
- package/src/ui/commands/statsCommand.ts +2 -2
- package/src/ui/commands/statsQuota.ts +1 -1
- package/src/ui/commands/subagentCommand.ts +59 -27
- package/src/ui/components/AuthDialog.tsx +3 -3
- package/src/ui/components/Composer.tsx +1 -0
- package/src/ui/components/DialogManager.tsx +9 -2
- package/src/ui/components/FolderTrustDialog.tsx +71 -60
- package/src/ui/components/PermissionsModifyTrustDialog.tsx +211 -146
- package/src/ui/components/ProfileCreateWizard/constants.ts +13 -1
- package/src/ui/components/QueuedMessagesPanel.tsx +388 -0
- package/src/ui/components/inputPromptHooks.ts +2 -0
- package/src/ui/components/inputPromptKeyHandlers.ts +14 -0
- package/src/ui/components/inputPromptTypes.ts +6 -0
- package/src/ui/components/messages/AiMessage.tsx +24 -12
- package/src/ui/components/messages/ToolMessage.tsx +26 -40
- package/src/ui/containers/AppContainer/builders/buildUIActions.ts +3 -1
- package/src/ui/containers/AppContainer/builders/buildUIState.ts +5 -4
- package/src/ui/containers/AppContainer/hooks/useAppBootstrap.ts +3 -33
- package/src/ui/containers/AppContainer/hooks/useAppDialogs.ts +2 -14
- package/src/ui/containers/AppContainer/hooks/useAppInput.ts +31 -25
- package/src/ui/containers/AppContainer/hooks/useAppLayout.ts +90 -60
- package/src/ui/containers/AppContainer/hooks/useDisplayPreferences.ts +10 -0
- package/src/ui/containers/AppContainer/hooks/useInputHandling.ts +3 -17
- package/src/ui/containers/AppContainer/hooks/useKeybindings.ts +51 -38
- package/src/ui/containers/AppContainer/hooks/useSteer.ts +51 -0
- package/src/ui/contexts/KeypressContext.tsx +13 -2
- package/src/ui/contexts/ShellCommandDisplayContext.tsx +103 -0
- package/src/ui/contexts/TodoContext.tsx +0 -13
- package/src/ui/contexts/TodoProvider.tsx +1 -34
- package/src/ui/contexts/UIActionsContext.tsx +2 -1
- package/src/ui/contexts/UIStateContext.tsx +3 -2
- package/src/ui/hooks/agentStream/streamUtils.ts +0 -24
- package/src/ui/hooks/agentStream/thoughtState.ts +110 -30
- package/src/ui/hooks/agentStream/types.ts +1 -0
- package/src/ui/hooks/agentStream/useAgentEventStream.ts +8 -17
- package/src/ui/hooks/agentStream/useAgentStream.ts +2 -2
- package/src/ui/hooks/agentStream/useAgentStreamLifecycle.ts +4 -5
- package/src/ui/hooks/agentStream/useAgentStreamOrchestration.ts +12 -5
- package/src/ui/hooks/agentStream/useQueuedSubmissions.ts +99 -0
- package/src/ui/hooks/agentStream/useStreamEventHandlers.ts +4 -13
- package/src/ui/hooks/agentStream/useStreamState.ts +60 -41
- package/src/ui/hooks/agentStream/useSubmitQuery.ts +284 -59
- package/src/ui/hooks/slashCommandProcessorSupport.ts +4 -0
- package/src/ui/hooks/useAgentStream-test-helpers.ts +1 -0
- package/src/ui/hooks/useAutoAcceptIndicator.ts +13 -0
- package/src/ui/hooks/useFolderTrust.ts +118 -81
- package/src/ui/hooks/useGitBranchName.ts +79 -33
- package/src/ui/hooks/useIdeTrustListener.ts +5 -19
- package/src/ui/hooks/usePermissionsModifyTrust.ts +223 -85
- package/src/ui/hooks/useReactToolScheduler.ts +13 -17
- package/src/ui/hooks/useTodoPausePreserver.ts +0 -55
- package/src/ui/layouts/DefaultAppLayout.tsx +2 -0
- package/src/ui/layouts/DefaultAppLayoutHelpers.tsx +37 -162
- package/src/ui/layouts/InlineContent.tsx +198 -0
- package/src/ui/trustDialogHelpers.ts +190 -0
- package/src/utils/cleanup-state.ts +115 -0
- package/src/utils/cleanup.ts +32 -34
- package/src/utils/sandbox-containers.ts +183 -53
- package/src/utils/sandbox-exec.ts +9 -3
- package/src/zed-integration/acp-terminal-shell-host.ts +112 -0
- package/src/zed-integration/acp-types.ts +35 -0
- package/src/zed-integration/fileSystemService.ts +1 -1
- package/src/zed-integration/runZedIntegration.ts +142 -0
- package/src/zed-integration/zed-agent-event-handler.ts +179 -0
- package/src/zed-integration/zed-agent-setup.ts +43 -0
- package/src/zed-integration/zed-command-registry.ts +167 -0
- package/src/zed-integration/zed-config-options.ts +280 -0
- package/src/zed-integration/zed-content-utils.ts +2 -2
- package/src/zed-integration/zed-helpers.ts +132 -0
- package/src/zed-integration/zed-initialize.ts +64 -0
- package/src/zed-integration/zed-plan-update.ts +20 -0
- package/src/zed-integration/zed-prompt-command.ts +77 -0
- package/src/zed-integration/zed-session-config.ts +119 -0
- package/src/zed-integration/zed-session-errors.ts +337 -0
- package/src/zed-integration/zed-session-events.ts +216 -0
- package/src/zed-integration/zed-session-info.ts +255 -0
- package/src/zed-integration/zed-session-lifecycle.ts +186 -0
- package/src/zed-integration/zed-session-listing.ts +153 -0
- package/src/zed-integration/zed-session-loader.ts +193 -0
- package/src/zed-integration/zed-session-pagination.ts +240 -0
- package/src/zed-integration/zed-session-replay.ts +564 -0
- package/src/zed-integration/zed-stream-batcher.ts +236 -0
- package/src/zed-integration/zed-terminal-manager.ts +416 -0
- package/src/zed-integration/zed-terminal-setup.ts +72 -0
- package/src/zed-integration/zed-test-helpers.ts +349 -4
- package/src/zed-integration/zed-tool-handler.ts +47 -5
- package/src/zed-integration/zedIntegration.ts +538 -587
- package/src/services/todo-continuation/todoContinuationService.ts +0 -610
- package/src/ui/containers/AppContainer/hooks/useIdeRestartHotkey.ts +0 -33
- package/src/ui/containers/AppContainer/hooks/useTodoContinuationFlow.ts +0 -160
- 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
|
+
}
|