@co0ontty/wand 4.3.0 → 4.4.0-beta.gdcdccb2

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.
@@ -0,0 +1,995 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { existsSync, readFileSync, statSync } from "node:fs";
3
+ import path from "node:path";
4
+ import { getErrorMessage } from "./error-utils.js";
5
+ import { normalizeStructuredToolResultContent } from "./structured-content.js";
6
+ function asRecord(value) {
7
+ return value && typeof value === "object" && !Array.isArray(value)
8
+ ? value
9
+ : null;
10
+ }
11
+ function getString(value) {
12
+ return typeof value === "string" ? value : "";
13
+ }
14
+ function parseJsonRecord(value) {
15
+ if (asRecord(value))
16
+ return value;
17
+ if (typeof value !== "string" || !value.trim())
18
+ return {};
19
+ try {
20
+ const parsed = JSON.parse(value);
21
+ return asRecord(parsed) ?? {};
22
+ }
23
+ catch {
24
+ return {};
25
+ }
26
+ }
27
+ function codexPatchToolName(kind) {
28
+ if (kind === "add")
29
+ return "Write";
30
+ return "Edit";
31
+ }
32
+ function codexPatchResultText(stdout, stderr, success) {
33
+ const err = getString(stderr).trim();
34
+ const out = getString(stdout).trim();
35
+ if (!success)
36
+ return err || out || "patch apply failed";
37
+ return "";
38
+ }
39
+ export function buildCodexPatchApplyBlocks(item) {
40
+ const changes = asRecord(item.changes);
41
+ if (!changes)
42
+ return [];
43
+ const callId = getString(item.call_id) || getString(item.id) || "patch";
44
+ const status = getString(item.status) || "completed";
45
+ const success = item.success !== false && status !== "failed";
46
+ const resultText = codexPatchResultText(item.stdout, item.stderr, success);
47
+ const entries = Object.entries(changes);
48
+ const blocks = [];
49
+ entries.forEach(([filePath, rawChange], index) => {
50
+ const change = asRecord(rawChange) ?? {};
51
+ const kind = getString(change.type) || "update";
52
+ const unifiedDiff = getString(change.unified_diff);
53
+ const movePath = getString(change.move_path);
54
+ const toolUseId = `${callId}#${index}`;
55
+ const input = {
56
+ file_path: filePath,
57
+ kind,
58
+ status,
59
+ };
60
+ if (unifiedDiff)
61
+ input.unified_diff = unifiedDiff;
62
+ if (movePath)
63
+ input.move_path = movePath;
64
+ blocks.push({
65
+ type: "tool_use",
66
+ id: toolUseId,
67
+ name: codexPatchToolName(kind),
68
+ description: kind,
69
+ input,
70
+ });
71
+ blocks.push({
72
+ type: "tool_result",
73
+ tool_use_id: toolUseId,
74
+ content: resultText,
75
+ is_error: !success,
76
+ });
77
+ });
78
+ return blocks;
79
+ }
80
+ const CODEX_FILE_SNAPSHOT_MAX_BYTES = 512 * 1024;
81
+ const CODEX_DIFF_MAX_EDIT_DISTANCE = 512;
82
+ const CODEX_DIFF_MAX_CHARS = 32 * 1024;
83
+ const CODEX_DIFF_CONTEXT_LINES = 3;
84
+ function readCodexFileSnapshot(filePath) {
85
+ if (!filePath || !existsSync(filePath))
86
+ return { exists: false, text: "" };
87
+ try {
88
+ const stat = statSync(filePath);
89
+ if (!stat.isFile()) {
90
+ return { exists: true, text: null, unavailableReason: "目标不是普通文件" };
91
+ }
92
+ if (stat.size > CODEX_FILE_SNAPSHOT_MAX_BYTES) {
93
+ return { exists: true, text: null, unavailableReason: "文件过大,未生成差异正文" };
94
+ }
95
+ const content = readFileSync(filePath);
96
+ if (content.includes(0)) {
97
+ return { exists: true, text: null, unavailableReason: "二进制文件不支持文本差异" };
98
+ }
99
+ return { exists: true, text: content.toString("utf8") };
100
+ }
101
+ catch (error) {
102
+ return {
103
+ exists: true,
104
+ text: null,
105
+ unavailableReason: `读取文件失败:${getErrorMessage(error)}`,
106
+ };
107
+ }
108
+ }
109
+ /**
110
+ * Myers line diff. File snapshots are bounded above; the edit-distance guard
111
+ * keeps completely rewritten generated files from consuming quadratic memory.
112
+ */
113
+ function diffCodexLines(before, after) {
114
+ const max = before.length + after.length;
115
+ let frontier = new Map([[1, 0]]);
116
+ const trace = [];
117
+ let completedDistance = -1;
118
+ for (let distance = 0; distance <= max && distance <= CODEX_DIFF_MAX_EDIT_DISTANCE; distance++) {
119
+ trace.push(new Map(frontier));
120
+ for (let diagonal = -distance; diagonal <= distance; diagonal += 2) {
121
+ const down = frontier.get(diagonal + 1) ?? Number.NEGATIVE_INFINITY;
122
+ const right = frontier.get(diagonal - 1) ?? Number.NEGATIVE_INFINITY;
123
+ let oldIndex = diagonal === -distance || (diagonal !== distance && right < down)
124
+ ? Math.max(0, down)
125
+ : Math.max(0, right + 1);
126
+ let newIndex = oldIndex - diagonal;
127
+ while (oldIndex < before.length
128
+ && newIndex < after.length
129
+ && before[oldIndex] === after[newIndex]) {
130
+ oldIndex++;
131
+ newIndex++;
132
+ }
133
+ frontier.set(diagonal, oldIndex);
134
+ if (oldIndex >= before.length && newIndex >= after.length) {
135
+ completedDistance = distance;
136
+ break;
137
+ }
138
+ }
139
+ if (completedDistance >= 0)
140
+ break;
141
+ }
142
+ // A very large rewrite is still useful to inspect. This fallback is not
143
+ // minimal, but remains truthful and is later clipped by transport/UI limits.
144
+ if (completedDistance < 0) {
145
+ return [
146
+ ...before.map((text) => ({ kind: "delete", text })),
147
+ ...after.map((text) => ({ kind: "add", text })),
148
+ ];
149
+ }
150
+ const reversed = [];
151
+ let oldIndex = before.length;
152
+ let newIndex = after.length;
153
+ for (let distance = completedDistance; distance >= 0; distance--) {
154
+ const previous = trace[distance];
155
+ const diagonal = oldIndex - newIndex;
156
+ const down = previous.get(diagonal + 1) ?? Number.NEGATIVE_INFINITY;
157
+ const right = previous.get(diagonal - 1) ?? Number.NEGATIVE_INFINITY;
158
+ const previousDiagonal = diagonal === -distance || (diagonal !== distance && right < down)
159
+ ? diagonal + 1
160
+ : diagonal - 1;
161
+ const previousOldIndex = Math.max(0, previous.get(previousDiagonal) ?? 0);
162
+ const previousNewIndex = previousOldIndex - previousDiagonal;
163
+ while (oldIndex > previousOldIndex && newIndex > previousNewIndex) {
164
+ reversed.push({ kind: "equal", text: before[oldIndex - 1] });
165
+ oldIndex--;
166
+ newIndex--;
167
+ }
168
+ if (distance === 0)
169
+ break;
170
+ if (oldIndex === previousOldIndex) {
171
+ reversed.push({ kind: "add", text: after[newIndex - 1] });
172
+ newIndex--;
173
+ }
174
+ else {
175
+ reversed.push({ kind: "delete", text: before[oldIndex - 1] });
176
+ oldIndex--;
177
+ }
178
+ }
179
+ return reversed.reverse();
180
+ }
181
+ function codexDiffPath(filePath) {
182
+ return filePath.replace(/[\r\n]/g, " ").replace(/^\/+/, "");
183
+ }
184
+ function codexDiffLines(text) {
185
+ if (!text)
186
+ return [];
187
+ const normalized = text.replace(/\r\n/g, "\n");
188
+ const lines = normalized.split("\n");
189
+ if (normalized.endsWith("\n"))
190
+ lines.pop();
191
+ return lines;
192
+ }
193
+ function buildCodexUnifiedDiff(filePath, before, after) {
194
+ if (before.text === null || after.text === null || before.text === after.text)
195
+ return "";
196
+ const oldLines = codexDiffLines(before.text);
197
+ const newLines = codexDiffLines(after.text);
198
+ const lines = diffCodexLines(oldLines, newLines);
199
+ const changedIndexes = lines
200
+ .map((line, index) => line.kind === "equal" ? -1 : index)
201
+ .filter((index) => index >= 0);
202
+ if (changedIndexes.length === 0)
203
+ return "";
204
+ const oldBefore = [];
205
+ const newBefore = [];
206
+ let oldCount = 0;
207
+ let newCount = 0;
208
+ lines.forEach((line, index) => {
209
+ oldBefore[index] = oldCount;
210
+ newBefore[index] = newCount;
211
+ if (line.kind !== "add")
212
+ oldCount++;
213
+ if (line.kind !== "delete")
214
+ newCount++;
215
+ });
216
+ const hunks = [];
217
+ for (const changedIndex of changedIndexes) {
218
+ const start = Math.max(0, changedIndex - CODEX_DIFF_CONTEXT_LINES);
219
+ const end = Math.min(lines.length, changedIndex + CODEX_DIFF_CONTEXT_LINES + 1);
220
+ const previous = hunks[hunks.length - 1];
221
+ if (previous && start <= previous.end)
222
+ previous.end = Math.max(previous.end, end);
223
+ else
224
+ hunks.push({ start, end });
225
+ }
226
+ const displayPath = codexDiffPath(filePath);
227
+ const output = [
228
+ before.exists ? `--- a/${displayPath}` : "--- /dev/null",
229
+ after.exists ? `+++ b/${displayPath}` : "+++ /dev/null",
230
+ ];
231
+ for (const hunk of hunks) {
232
+ const hunkLines = lines.slice(hunk.start, hunk.end);
233
+ const hunkOldCount = hunkLines.filter((line) => line.kind !== "add").length;
234
+ const hunkNewCount = hunkLines.filter((line) => line.kind !== "delete").length;
235
+ const hunkOldStart = hunkOldCount === 0 ? oldBefore[hunk.start] : oldBefore[hunk.start] + 1;
236
+ const hunkNewStart = hunkNewCount === 0 ? newBefore[hunk.start] : newBefore[hunk.start] + 1;
237
+ output.push(`@@ -${hunkOldStart},${hunkOldCount} +${hunkNewStart},${hunkNewCount} @@`);
238
+ for (const line of hunkLines) {
239
+ output.push(`${line.kind === "add" ? "+" : line.kind === "delete" ? "-" : " "}${line.text}`);
240
+ }
241
+ }
242
+ const diff = output.join("\n");
243
+ if (diff.length <= CODEX_DIFF_MAX_CHARS)
244
+ return diff;
245
+ const cutAt = diff.lastIndexOf("\n", CODEX_DIFF_MAX_CHARS);
246
+ return `${diff.slice(0, cutAt > 0 ? cutAt : CODEX_DIFF_MAX_CHARS)}\n…(差异正文已截断)`;
247
+ }
248
+ export function buildCodexFileChangeBlocks(item, completed, beforeSnapshots = new Map(), afterSnapshots = new Map()) {
249
+ const id = getString(item.id) || "file-change";
250
+ const rawChanges = Array.isArray(item.changes) ? item.changes : [];
251
+ const status = getString(item.status) || (completed ? "completed" : "in_progress");
252
+ const isError = status === "failed";
253
+ const blocks = [];
254
+ rawChanges.forEach((entry, index) => {
255
+ const change = asRecord(entry);
256
+ if (!change)
257
+ return;
258
+ const filePath = getString(change.path);
259
+ const kind = getString(change.kind) || "update";
260
+ const toolUseId = `${id}#${index}`;
261
+ const input = { file_path: filePath, kind, status };
262
+ const before = beforeSnapshots.get(toolUseId);
263
+ const after = afterSnapshots.get(toolUseId);
264
+ if (completed && before && after) {
265
+ const unifiedDiff = buildCodexUnifiedDiff(filePath, before, after);
266
+ if (unifiedDiff)
267
+ input.unified_diff = unifiedDiff;
268
+ const unavailableReason = before.unavailableReason || after.unavailableReason;
269
+ if (!unifiedDiff && unavailableReason)
270
+ input.diff_unavailable_reason = unavailableReason;
271
+ }
272
+ blocks.push({
273
+ type: "tool_use",
274
+ id: toolUseId,
275
+ name: codexPatchToolName(kind),
276
+ description: kind,
277
+ input,
278
+ });
279
+ if (completed) {
280
+ blocks.push({
281
+ type: "tool_result",
282
+ tool_use_id: toolUseId,
283
+ content: isError ? `file change failed: ${filePath}` : "",
284
+ is_error: isError,
285
+ });
286
+ }
287
+ });
288
+ return blocks;
289
+ }
290
+ /**
291
+ * Codex `exec --json` only publishes authoritative usage with `turn.completed`.
292
+ * Keep the bottom usage row useful while the turn is running by estimating the
293
+ * model-produced text/tool arguments; the final provider value replaces this.
294
+ */
295
+ export function estimateCodexOutputTokens(blocks) {
296
+ let asciiUnits = 0;
297
+ let wideUnits = 0;
298
+ const addText = (value) => {
299
+ for (const char of value) {
300
+ if (char.codePointAt(0) <= 0x7f)
301
+ asciiUnits += 1;
302
+ else
303
+ wideUnits += 1;
304
+ }
305
+ };
306
+ for (const block of blocks) {
307
+ if (block.type === "text")
308
+ addText(block.text);
309
+ else if (block.type === "thinking")
310
+ addText(block.thinking);
311
+ else if (block.type === "tool_use") {
312
+ addText(block.name);
313
+ try {
314
+ addText(JSON.stringify(block.input));
315
+ }
316
+ catch { /* best-effort live estimate */ }
317
+ }
318
+ }
319
+ if (asciiUnits === 0 && wideUnits === 0)
320
+ return 0;
321
+ return Math.max(1, Math.ceil(asciiUnits / 4 + wideUnits));
322
+ }
323
+ function refreshEstimatedCodexUsage(turnState) {
324
+ if (turnState.usage?.estimated !== true)
325
+ return;
326
+ turnState.usage = {
327
+ outputTokens: estimateCodexOutputTokens(turnState.blocks),
328
+ estimated: true,
329
+ };
330
+ }
331
+ export class CodexProtocolReducer {
332
+ state;
333
+ errors = [];
334
+ primaryError = null;
335
+ constructor(session) {
336
+ this.state = {
337
+ blocks: [],
338
+ result: "",
339
+ sessionId: session.claudeSessionId,
340
+ model: session.selectedModel ?? session.structuredState?.model,
341
+ usage: { outputTokens: 0, estimated: true },
342
+ codexBlockIndex: new Map(),
343
+ codexFileSnapshots: new Map(),
344
+ cwd: session.cwd,
345
+ };
346
+ }
347
+ apply(parsed) {
348
+ const event = this.unwrapCodexStreamEvent(parsed);
349
+ if (event?.type === "thread.started" && typeof event.thread_id === "string") {
350
+ this.state.sessionId = event.thread_id;
351
+ return true;
352
+ }
353
+ if (event?.type === "item.started" && asRecord(event.item)) {
354
+ this.applyCodexItem(this.state, event.item, "started");
355
+ return this.refreshUsage();
356
+ }
357
+ if (event?.type === "item.updated" && asRecord(event.item)) {
358
+ this.applyCodexItem(this.state, event.item, "updated");
359
+ return this.refreshUsage();
360
+ }
361
+ if (event?.type === "item.completed" && asRecord(event.item)) {
362
+ this.applyCodexItem(this.state, event.item, "completed");
363
+ return this.refreshUsage();
364
+ }
365
+ if (event?.type === "turn.completed") {
366
+ this.state.usage = this.extractCodexUsage(asRecord(event.usage) ?? undefined) ?? this.state.usage;
367
+ return true;
368
+ }
369
+ if (event?.type === "token_count") {
370
+ const info = asRecord(event.info);
371
+ const lastUsage = asRecord(info?.last_token_usage);
372
+ this.state.usage = this.extractCodexUsage(lastUsage ?? undefined) ?? this.state.usage;
373
+ return true;
374
+ }
375
+ if (this.applyCodexLooseEvent(this.state, event))
376
+ return this.refreshUsage();
377
+ if (event?.type === "error") {
378
+ const message = typeof event.message === "string" ? event.message : "";
379
+ if (message)
380
+ this.errors.push(message);
381
+ return false;
382
+ }
383
+ if (event?.type === "turn.failed") {
384
+ const error = asRecord(event.error);
385
+ this.primaryError = getString(error?.message) || getString(event.message) || "codex turn failed";
386
+ }
387
+ return false;
388
+ }
389
+ refreshUsage() {
390
+ refreshEstimatedCodexUsage(this.state);
391
+ return true;
392
+ }
393
+ normalizeToolResultContent(content) {
394
+ return normalizeStructuredToolResultContent(content);
395
+ }
396
+ unwrapCodexStreamEvent(parsed) {
397
+ const event = asRecord(parsed);
398
+ if (!event)
399
+ return null;
400
+ const type = getString(event.type);
401
+ if ((type === "response_item" || type === "event_msg") && asRecord(event.payload)) {
402
+ return event.payload;
403
+ }
404
+ return event;
405
+ }
406
+ applyCodexLooseEvent(turnState, event) {
407
+ if (!event)
408
+ return false;
409
+ const type = getString(event.type);
410
+ const supported = new Set([
411
+ "message",
412
+ "agent_message",
413
+ "reasoning",
414
+ "function_call",
415
+ "function_call_output",
416
+ "custom_tool_call",
417
+ "custom_tool_call_output",
418
+ "command_execution",
419
+ "patch_apply_end",
420
+ "file_change",
421
+ "mcp_tool_call",
422
+ "mcp_tool_call_end",
423
+ "web_search_call",
424
+ "web_search_end",
425
+ "web_search",
426
+ "tool_search_call",
427
+ "tool_search_output",
428
+ "collab_tool_call",
429
+ "todo_list",
430
+ ]);
431
+ if (!supported.has(type))
432
+ return false;
433
+ this.applyCodexItem(turnState, event, "completed");
434
+ return true;
435
+ }
436
+ codexFunctionToolUse(item) {
437
+ const rawName = getString(item.name) || "function_call";
438
+ const callId = getString(item.call_id) || getString(item.id) || rawName;
439
+ const args = parseJsonRecord(item.arguments);
440
+ const input = { ...args };
441
+ if (rawName === "exec_command") {
442
+ const command = getString(args.cmd) || getString(args.command);
443
+ if (command)
444
+ input.command = command;
445
+ return {
446
+ type: "tool_use",
447
+ id: callId,
448
+ name: "Bash",
449
+ description: getString(args.workdir) || undefined,
450
+ input,
451
+ };
452
+ }
453
+ if (rawName === "write_stdin") {
454
+ return {
455
+ type: "tool_use",
456
+ id: callId,
457
+ name: "Bash",
458
+ description: "write stdin",
459
+ input: {
460
+ ...input,
461
+ command: `write_stdin ${getString(args.session_id) || getString(args.sessionId) || ""}`.trim(),
462
+ },
463
+ };
464
+ }
465
+ if (rawName === "update_plan" && Array.isArray(args.plan)) {
466
+ const todos = args.plan.map((entry) => {
467
+ const rec = asRecord(entry) ?? {};
468
+ const status = getString(rec.status);
469
+ return {
470
+ content: getString(rec.step),
471
+ activeForm: getString(rec.step),
472
+ status: status === "completed" ? "completed" : status === "in_progress" ? "in_progress" : "pending",
473
+ };
474
+ });
475
+ return {
476
+ type: "tool_use",
477
+ id: callId,
478
+ name: "TodoWrite",
479
+ description: getString(args.explanation) || undefined,
480
+ input: { todos },
481
+ };
482
+ }
483
+ if (rawName === "view_image") {
484
+ const filePath = getString(args.path);
485
+ return {
486
+ type: "tool_use",
487
+ id: callId,
488
+ name: "Read",
489
+ description: "view image",
490
+ input: filePath ? { ...input, file_path: filePath } : input,
491
+ };
492
+ }
493
+ if (rawName === "js") {
494
+ return {
495
+ type: "tool_use",
496
+ id: callId,
497
+ name: "node_repl__js",
498
+ description: getString(args.title) || undefined,
499
+ input,
500
+ };
501
+ }
502
+ return {
503
+ type: "tool_use",
504
+ id: callId,
505
+ name: rawName,
506
+ input,
507
+ };
508
+ }
509
+ codexMcpToolBlocks(item) {
510
+ const callId = getString(item.call_id) || getString(item.id) || "mcp";
511
+ const invocation = asRecord(item.invocation) ?? {};
512
+ const server = getString(invocation.server) || "mcp";
513
+ const tool = getString(invocation.tool) || "tool";
514
+ const args = asRecord(invocation.arguments) ?? {};
515
+ const result = asRecord(item.result);
516
+ const isError = !!result?.Err || getString(item.status) === "failed";
517
+ const ok = asRecord(result?.Ok);
518
+ const content = ok ? this.extractCodexText(ok.content) || JSON.stringify(ok).slice(0, 4096) : this.extractCodexText(result);
519
+ return [
520
+ { type: "tool_use", id: callId, name: `${server}__${tool}`, input: args },
521
+ { type: "tool_result", tool_use_id: callId, content, is_error: isError },
522
+ ];
523
+ }
524
+ extractCodexText(value) {
525
+ if (typeof value === "string")
526
+ return value;
527
+ if (!value || typeof value !== "object")
528
+ return "";
529
+ if (Array.isArray(value)) {
530
+ return value.map((item) => this.extractCodexText(item)).filter(Boolean).join("");
531
+ }
532
+ const record = value;
533
+ for (const key of ["text", "output_text", "message", "content", "summary"]) {
534
+ const extracted = this.extractCodexText(record[key]);
535
+ if (extracted)
536
+ return extracted;
537
+ }
538
+ return "";
539
+ }
540
+ /**
541
+ * Merge one codex `item.*` event into `turnState.blocks`.
542
+ *
543
+ * 三种 phase 行为:
544
+ * - "started": 首次出现的 item,块直接 push(tool_result 走 upsert 配对)。
545
+ * text/thinking/TodoWrite 这种"靠 id 替换"的块记录到
546
+ * codexBlockIndex 里,方便后续 updated/completed 找回原位。
547
+ * - "updated": codex 重发完整 ThreadItem(不是 delta)。已记录过的块就
548
+ * 替换;新块按 started 路径处理。
549
+ * - "completed": 把"in_progress"卡片定型——text 同时更新 turnState.result
550
+ * 以便 result fallback 不为空;tool_use ↔ tool_result 通过
551
+ * 共享 id 配对到一起(包括 file_change 子项的 `${id}#i`)。
552
+ */
553
+ applyCodexItem(turnState, item, phase) {
554
+ const completed = phase === "completed";
555
+ const itemId = typeof item.id === "string" ? item.id : "";
556
+ const itemType = getString(item.type);
557
+ let afterSnapshots;
558
+ if (itemType === "file_change" && itemId) {
559
+ const snapshots = turnState.codexFileSnapshots ??= new Map();
560
+ const rawChanges = Array.isArray(item.changes) ? item.changes : [];
561
+ if (phase === "started") {
562
+ rawChanges.forEach((entry, index) => {
563
+ const filePath = getString(asRecord(entry)?.path);
564
+ const absolutePath = path.isAbsolute(filePath)
565
+ ? filePath
566
+ : path.resolve(turnState.cwd || process.cwd(), filePath);
567
+ snapshots.set(`${itemId}#${index}`, readCodexFileSnapshot(absolutePath));
568
+ });
569
+ }
570
+ else if (completed) {
571
+ afterSnapshots = new Map();
572
+ rawChanges.forEach((entry, index) => {
573
+ const filePath = getString(asRecord(entry)?.path);
574
+ const absolutePath = path.isAbsolute(filePath)
575
+ ? filePath
576
+ : path.resolve(turnState.cwd || process.cwd(), filePath);
577
+ afterSnapshots?.set(`${itemId}#${index}`, readCodexFileSnapshot(absolutePath));
578
+ });
579
+ }
580
+ }
581
+ const blocks = this.extractCodexItemBlock(item, completed, turnState.codexFileSnapshots, afterSnapshots);
582
+ if (blocks.length === 0)
583
+ return;
584
+ const index = turnState.codexBlockIndex ??= new Map();
585
+ for (const block of blocks) {
586
+ // text / thinking / TodoWrite tool_use 的卡片是"按 item id 整体替换"语义,
587
+ // 否则一个 agent_message 在 updated/completed 时会被重复 push 多次。
588
+ const replaceable = block.type === "text"
589
+ || block.type === "thinking"
590
+ || (block.type === "tool_use" && block.name === "TodoWrite");
591
+ if (replaceable && itemId) {
592
+ const existing = index.get(itemId);
593
+ if (existing !== undefined && existing < turnState.blocks.length) {
594
+ turnState.blocks[existing] = block;
595
+ }
596
+ else {
597
+ index.set(itemId, turnState.blocks.length);
598
+ turnState.blocks.push(block);
599
+ }
600
+ if (block.type === "text" && completed) {
601
+ turnState.result = block.text;
602
+ }
603
+ continue;
604
+ }
605
+ // 其它块(tool_use 非 Todo / tool_result / 文件改动的多 sub-id 块)
606
+ // 仍然走原有 upsert:tool_result 按 tool_use_id 配对,其余直接 push。
607
+ this.upsertCodexBlock(turnState.blocks, block);
608
+ }
609
+ if (completed && itemType === "file_change") {
610
+ for (const key of [...(turnState.codexFileSnapshots?.keys() ?? [])]) {
611
+ if (key.startsWith(`${itemId}#`))
612
+ turnState.codexFileSnapshots?.delete(key);
613
+ }
614
+ }
615
+ }
616
+ /**
617
+ * Map a codex `item.{started,updated,completed}` payload into wand's
618
+ * `ContentBlock[]` so the chat UI's existing tool/diff/todo cards just work.
619
+ *
620
+ * Codex `exec --json` emits 8 item.type values (see
621
+ * `codex-rs/exec/src/exec_events.rs`); below they're routed to whatever wand
622
+ * tool name reuses an existing renderer:
623
+ *
624
+ * agent_message → text
625
+ * reasoning → thinking
626
+ * command_execution → tool_use "Bash" + tool_result
627
+ * file_change → one Edit/Write per file; snapshots taken between
628
+ * item.started/completed restore the omitted diff body
629
+ * mcp_tool_call → tool_use named "<server>__<tool>" + tool_result
630
+ * web_search → tool_use "WebSearch" + tool_result (results not in stream)
631
+ * todo_list → tool_use "TodoWrite" (replaced in place on each update)
632
+ * error → text block prefixed with ❌
633
+ *
634
+ * Returns [] when there is nothing to emit yet (e.g. agent_message at
635
+ * `item.started` before any text has been produced).
636
+ *
637
+ * Callers handle in-place replacement for `item.updated` via
638
+ * `turnState.codexBlockIndex`; tool_use ↔ tool_result pairing still goes
639
+ * through `upsertCodexBlock` by matching ids.
640
+ */
641
+ extractCodexItemBlock(item, completed, beforeSnapshots, afterSnapshots) {
642
+ const id = typeof item.id === "string" ? item.id : randomUUID();
643
+ const type = typeof item.type === "string" ? item.type : "unknown";
644
+ if (type === "message") {
645
+ const role = getString(item.role);
646
+ if (role !== "assistant")
647
+ return [];
648
+ const text = this.extractCodexText(item.content);
649
+ return text ? [{ type: "text", text }] : [];
650
+ }
651
+ if (type === "agent_message") {
652
+ const text = this.extractCodexText(item);
653
+ return text ? [{ type: "text", text }] : [];
654
+ }
655
+ if (type === "reasoning") {
656
+ const text = this.extractCodexText(item);
657
+ return text ? [{ type: "thinking", thinking: text }] : [];
658
+ }
659
+ if (type === "command_execution") {
660
+ const command = typeof item.command === "string" ? item.command : "";
661
+ const aggregatedOutput = typeof item.aggregated_output === "string" ? item.aggregated_output : "";
662
+ const exitCode = typeof item.exit_code === "number" ? item.exit_code : null;
663
+ const status = typeof item.status === "string" ? item.status : completed ? "completed" : "in_progress";
664
+ const input = { command, status };
665
+ if (exitCode !== null)
666
+ input.exit_code = exitCode;
667
+ if (!completed) {
668
+ return [{
669
+ type: "tool_use",
670
+ id,
671
+ name: "Bash",
672
+ description: "running",
673
+ input,
674
+ }];
675
+ }
676
+ // codex 的 status 可能是 declined(sandbox 拒了命令)/ failed(执行失败)—
677
+ // 这时 exit_code 经常是 null,光靠 exitCode !== 0 判 is_error 会漏。
678
+ const isError = status === "failed" || status === "declined"
679
+ || (typeof exitCode === "number" && exitCode !== 0);
680
+ const fallbackText = status === "declined"
681
+ ? "command declined by sandbox"
682
+ : (exitCode === null ? "" : `exit_code: ${exitCode}`);
683
+ return [
684
+ {
685
+ type: "tool_use",
686
+ id,
687
+ name: "Bash",
688
+ description: exitCode === null ? status : `${status} · exit ${exitCode}`,
689
+ input,
690
+ },
691
+ {
692
+ type: "tool_result",
693
+ tool_use_id: id,
694
+ content: aggregatedOutput || fallbackText,
695
+ is_error: isError,
696
+ },
697
+ ];
698
+ }
699
+ if (type === "function_call") {
700
+ const block = this.codexFunctionToolUse(item);
701
+ return block ? [block] : [];
702
+ }
703
+ if (type === "function_call_output") {
704
+ const callId = getString(item.call_id) || id;
705
+ return [{
706
+ type: "tool_result",
707
+ tool_use_id: callId,
708
+ content: this.normalizeToolResultContent(item.output),
709
+ }];
710
+ }
711
+ if (type === "custom_tool_call") {
712
+ const callId = getString(item.call_id) || id;
713
+ const name = getString(item.name) || "custom_tool_call";
714
+ return [{
715
+ type: "tool_use",
716
+ id: callId,
717
+ name,
718
+ description: getString(item.status) || undefined,
719
+ input: {
720
+ input: getString(item.input),
721
+ status: getString(item.status) || (completed ? "completed" : "in_progress"),
722
+ },
723
+ }];
724
+ }
725
+ if (type === "custom_tool_call_output") {
726
+ const callId = getString(item.call_id) || id;
727
+ return [{
728
+ type: "tool_result",
729
+ tool_use_id: callId,
730
+ content: this.normalizeToolResultContent(item.output),
731
+ }];
732
+ }
733
+ if (type === "patch_apply_end") {
734
+ return buildCodexPatchApplyBlocks(item);
735
+ }
736
+ if (type === "file_change") {
737
+ return buildCodexFileChangeBlocks(item, completed, beforeSnapshots, afterSnapshots);
738
+ }
739
+ if (type === "mcp_tool_call_end") {
740
+ return this.codexMcpToolBlocks(item);
741
+ }
742
+ if (type === "mcp_tool_call") {
743
+ const server = typeof item.server === "string" ? item.server : "mcp";
744
+ const tool = typeof item.tool === "string" ? item.tool : "tool";
745
+ const args = item.arguments && typeof item.arguments === "object" ? item.arguments : {};
746
+ const errObj = item.error && typeof item.error === "object" ? item.error : null;
747
+ const status = typeof item.status === "string" ? item.status : completed ? "completed" : "in_progress";
748
+ const isError = !!errObj || status === "failed";
749
+ const input = { ...args, status };
750
+ if (!completed) {
751
+ return [{
752
+ type: "tool_use",
753
+ id,
754
+ name: `${server}__${tool}`,
755
+ description: status,
756
+ input,
757
+ }];
758
+ }
759
+ let resultText = "";
760
+ if (errObj && typeof errObj.message === "string") {
761
+ resultText = errObj.message;
762
+ }
763
+ else if (item.result && typeof item.result === "object") {
764
+ const resultRec = item.result;
765
+ const inner = this.extractCodexText(resultRec.content);
766
+ resultText = inner || JSON.stringify(resultRec).slice(0, 4096);
767
+ }
768
+ return [
769
+ {
770
+ type: "tool_use",
771
+ id,
772
+ name: `${server}__${tool}`,
773
+ description: status,
774
+ input,
775
+ },
776
+ {
777
+ type: "tool_result",
778
+ tool_use_id: id,
779
+ content: resultText,
780
+ is_error: isError,
781
+ },
782
+ ];
783
+ }
784
+ if (type === "web_search_call") {
785
+ const callId = getString(item.call_id) || id;
786
+ return [{
787
+ type: "tool_use",
788
+ id: callId,
789
+ name: "WebSearch",
790
+ description: getString(item.status) || "searching",
791
+ input: {},
792
+ }];
793
+ }
794
+ if (type === "web_search_end") {
795
+ const callId = getString(item.call_id) || id;
796
+ const action = asRecord(item.action);
797
+ const query = getString(item.query);
798
+ const actionType = getString(action?.type);
799
+ return [
800
+ {
801
+ type: "tool_use",
802
+ id: callId,
803
+ name: "WebSearch",
804
+ description: actionType || "completed",
805
+ input: query ? { query, action: actionType } : { action: actionType },
806
+ },
807
+ {
808
+ type: "tool_result",
809
+ tool_use_id: callId,
810
+ content: query ? `query: ${query}` : "",
811
+ },
812
+ ];
813
+ }
814
+ if (type === "tool_search_call") {
815
+ const callId = getString(item.call_id) || id;
816
+ const args = asRecord(item.arguments) ?? {};
817
+ return [{
818
+ type: "tool_use",
819
+ id: callId,
820
+ name: "tool_search",
821
+ description: getString(item.status) || undefined,
822
+ input: args,
823
+ }];
824
+ }
825
+ if (type === "tool_search_output") {
826
+ const callId = getString(item.call_id) || id;
827
+ return [{
828
+ type: "tool_result",
829
+ tool_use_id: callId,
830
+ content: this.normalizeToolResultContent(item.tools),
831
+ }];
832
+ }
833
+ if (type === "web_search") {
834
+ const query = typeof item.query === "string" ? item.query : "";
835
+ const action = item.action && typeof item.action === "object" ? item.action : null;
836
+ const actionType = action && typeof action.type === "string" ? action.type : "";
837
+ const queries = action && Array.isArray(action.queries)
838
+ ? action.queries.filter((v) => typeof v === "string")
839
+ : [];
840
+ const input = { query };
841
+ if (actionType)
842
+ input.action = actionType;
843
+ if (queries.length > 0)
844
+ input.queries = queries;
845
+ if (!completed) {
846
+ return [{
847
+ type: "tool_use",
848
+ id,
849
+ name: "WebSearch",
850
+ description: actionType || "searching",
851
+ input,
852
+ }];
853
+ }
854
+ return [
855
+ {
856
+ type: "tool_use",
857
+ id,
858
+ name: "WebSearch",
859
+ description: actionType || "completed",
860
+ input,
861
+ },
862
+ {
863
+ type: "tool_result",
864
+ tool_use_id: id,
865
+ // codex 不在 exec 流里回 search 结果,这里给个占位让 UI 卡片完成态。
866
+ content: queries.length > 0 ? queries.map((q) => `query: ${q}`).join("\n") : (query ? `query: ${query}` : ""),
867
+ },
868
+ ];
869
+ }
870
+ if (type === "collab_tool_call") {
871
+ // codex 的子-agent 编排(spawn_agent / send_input / wait / close_agent)。
872
+ // 没有对应 Claude tool,所以名称用 "Codex/<op>" 让 UI 默认 tool 卡渲染时
873
+ // 一眼能看出来是 codex 多 agent 操作。
874
+ const tool = typeof item.tool === "string" ? item.tool : "collab";
875
+ const prompt = typeof item.prompt === "string" ? item.prompt : "";
876
+ const senderId = typeof item.sender_thread_id === "string" ? item.sender_thread_id : "";
877
+ const receiverIds = Array.isArray(item.receiver_thread_ids)
878
+ ? item.receiver_thread_ids.filter((v) => typeof v === "string")
879
+ : [];
880
+ const agentsStates = item.agents_states && typeof item.agents_states === "object"
881
+ ? item.agents_states
882
+ : {};
883
+ const status = typeof item.status === "string" ? item.status : completed ? "completed" : "in_progress";
884
+ const toolName = `Codex/${tool}`;
885
+ const input = { tool };
886
+ if (prompt)
887
+ input.prompt = prompt;
888
+ if (senderId)
889
+ input.sender_thread_id = senderId;
890
+ if (receiverIds.length > 0)
891
+ input.receiver_thread_ids = receiverIds;
892
+ if (Object.keys(agentsStates).length > 0)
893
+ input.agents_states = agentsStates;
894
+ if (!completed) {
895
+ return [{ type: "tool_use", id, name: toolName, input }];
896
+ }
897
+ // 完成态:把每个 receiver agent 的最终状态汇总成可读 result。
898
+ const summaryLines = [];
899
+ for (const [tid, state] of Object.entries(agentsStates)) {
900
+ if (!state || typeof state !== "object")
901
+ continue;
902
+ const rec = state;
903
+ const s = typeof rec.status === "string" ? rec.status : "?";
904
+ const msg = typeof rec.message === "string" && rec.message ? ` — ${rec.message}` : "";
905
+ summaryLines.push(`${tid.slice(0, 8)}: ${s}${msg}`);
906
+ }
907
+ const isError = status === "failed"
908
+ || summaryLines.some((l) => /errored|not_found|interrupted/.test(l));
909
+ const content = summaryLines.length > 0
910
+ ? summaryLines.join("\n")
911
+ : (status === "completed" ? "ok" : status);
912
+ return [
913
+ { type: "tool_use", id, name: toolName, input },
914
+ { type: "tool_result", tool_use_id: id, content, is_error: isError },
915
+ ];
916
+ }
917
+ if (type === "todo_list") {
918
+ // codex 的 todo: { items: [{ text, completed: bool }] }
919
+ // wand UI(renderTodoWrite)读的是 block.input.todos = [{content, status, activeForm}]
920
+ // 这里做形状翻译;in_progress 状态 codex 不区分,全部 pending → completed 二值。
921
+ const rawItems = Array.isArray(item.items) ? item.items : [];
922
+ const todos = rawItems.map((entry) => {
923
+ const rec = (entry && typeof entry === "object") ? entry : {};
924
+ const text = typeof rec.text === "string" ? rec.text : "";
925
+ const done = rec.completed === true;
926
+ return {
927
+ content: text,
928
+ status: done ? "completed" : "pending",
929
+ activeForm: text,
930
+ };
931
+ });
932
+ return [{
933
+ type: "tool_use",
934
+ id,
935
+ name: "TodoWrite",
936
+ input: { todos },
937
+ }];
938
+ }
939
+ if (type === "error") {
940
+ // item-level error(不是 top-level error 事件,那个走 codexErrors / 退出报错路径)
941
+ const message = this.extractCodexText(item) || "codex item error";
942
+ return [{ type: "text", text: `❌ ${message}` }];
943
+ }
944
+ // unknown / 兜底:completed 时尝试取 text 字段免得 silently 丢
945
+ if (completed) {
946
+ const text = this.extractCodexText(item);
947
+ if (text)
948
+ return [{ type: "text", text }];
949
+ }
950
+ return [];
951
+ }
952
+ upsertCodexBlock(blocks, block) {
953
+ // tool_use 按 id 去重——file_change 在 item.started 已经 push 过一份 tool_use,
954
+ // 到 item.completed 还会再发一份相同 id 的(带 status 更新),不去重就出现
955
+ // 两张同名卡片。command_execution 不受影响(它在 completed 只 emit tool_result)。
956
+ if (block.type === "tool_use") {
957
+ const existingIndex = blocks.findIndex((existing) => existing.type === "tool_use" && existing.id === block.id);
958
+ if (existingIndex >= 0) {
959
+ blocks[existingIndex] = block;
960
+ return;
961
+ }
962
+ }
963
+ if (block.type === "tool_result") {
964
+ const toolUseIndex = blocks.findIndex((existing) => existing.type === "tool_use" && existing.id === block.tool_use_id);
965
+ if (toolUseIndex >= 0) {
966
+ const nextIndex = toolUseIndex + 1;
967
+ if (blocks[nextIndex]?.type === "tool_result" && blocks[nextIndex].tool_use_id === block.tool_use_id) {
968
+ blocks[nextIndex] = block;
969
+ }
970
+ else {
971
+ blocks.splice(nextIndex, 0, block);
972
+ }
973
+ return;
974
+ }
975
+ }
976
+ blocks.push(block);
977
+ }
978
+ extractCodexUsage(source) {
979
+ if (!source || typeof source !== "object")
980
+ return undefined;
981
+ const value = {
982
+ inputTokens: typeof source.input_tokens === "number" ? source.input_tokens : undefined,
983
+ outputTokens: typeof source.output_tokens === "number" ? source.output_tokens : undefined,
984
+ cacheReadInputTokens: typeof source.cached_input_tokens === "number" ? source.cached_input_tokens : undefined,
985
+ reasoningOutputTokens: typeof source.reasoning_output_tokens === "number" ? source.reasoning_output_tokens : undefined,
986
+ };
987
+ if (value.inputTokens === undefined
988
+ && value.outputTokens === undefined
989
+ && value.cacheReadInputTokens === undefined
990
+ && value.reasoningOutputTokens === undefined) {
991
+ return undefined;
992
+ }
993
+ return value;
994
+ }
995
+ }