@gswangg/duncan-cc 0.1.0

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,702 @@
1
+ /**
2
+ * CC Message Normalization
3
+ *
4
+ * Converts internal CC message format to API-compatible format.
5
+ * Handles: filtering, type conversion, merging, attachment conversion,
6
+ * and 8 post-normalization transforms matching CC 2.1.85's chain:
7
+ *
8
+ * Pipeline: reorder-attachments → filter → type-switch → post-transforms (8 steps)
9
+ */
10
+
11
+ import type { CCMessage } from "./parser.js";
12
+ import { isApiErrorMessage, isCompactBoundary, isLocalCommand } from "./parser.js";
13
+
14
+ // ============================================================================
15
+ // Helpers
16
+ // ============================================================================
17
+
18
+ /** Make a synthetic user message */
19
+ function makeUserMessage(content: string | any[], opts: Partial<CCMessage> = {}): CCMessage {
20
+ return {
21
+ type: "user",
22
+ uuid: opts.uuid ?? crypto.randomUUID(),
23
+ parentUuid: null,
24
+ timestamp: opts.timestamp ?? new Date().toISOString(),
25
+ isMeta: opts.isMeta ?? true,
26
+ message: {
27
+ role: "user",
28
+ content: content,
29
+ },
30
+ ...opts,
31
+ };
32
+ }
33
+
34
+ /** Normalize content to array form */
35
+ function toContentArray(content: string | any[]): any[] {
36
+ if (typeof content === "string") {
37
+ return [{ type: "text", text: content }];
38
+ }
39
+ return content;
40
+ }
41
+
42
+ /** Merge two user messages */
43
+ function mergeUsers(a: CCMessage, b: CCMessage): CCMessage {
44
+ const aContent = toContentArray(a.message.content);
45
+ const bContent = toContentArray(b.message.content);
46
+
47
+ // CC's uyq: tool_results first, then other content
48
+ const toolResults = [...aContent, ...bContent].filter((c) => c.type === "tool_result");
49
+ const other = [...aContent, ...bContent].filter((c) => c.type !== "tool_result");
50
+ const merged = [...toolResults, ...other];
51
+
52
+ return {
53
+ ...a,
54
+ uuid: a.isMeta ? b.uuid : a.uuid,
55
+ message: {
56
+ ...a.message,
57
+ content: merged,
58
+ },
59
+ };
60
+ }
61
+
62
+ /** Check if a content block is a tool_reference — CC's bx() */
63
+ function isToolReference(block: any): boolean {
64
+ return block?.type === "tool_reference" || block?.type === "server_tool_use";
65
+ }
66
+
67
+ /** Last element of array */
68
+ function last<T>(arr: T[]): T | undefined {
69
+ return arr[arr.length - 1];
70
+ }
71
+
72
+ /** Check if content is thinking-only — CC's St1() */
73
+ function isThinkingBlock(block: any): boolean {
74
+ return block?.type === "thinking" || block?.type === "redacted_thinking";
75
+ }
76
+
77
+ /** Check if all content blocks are whitespace-only text — CC's djY() */
78
+ function isWhitespaceOnly(content: any[]): boolean {
79
+ if (content.length === 0) return false;
80
+ return content.every(
81
+ (c) => c.type === "text" && (c.text === undefined || c.text.trim() === "")
82
+ );
83
+ }
84
+
85
+ // ============================================================================
86
+ // Attachment conversion
87
+ //
88
+ // For duncan queries, we do a simplified version that preserves the
89
+ // semantic content without needing the full tool definitions.
90
+ // ============================================================================
91
+
92
+ function convertAttachment(msg: CCMessage): CCMessage[] {
93
+ const attachment = msg.attachment;
94
+ if (!attachment) return [];
95
+
96
+ switch (attachment.type) {
97
+ case "directory":
98
+ return [
99
+ makeUserMessage(
100
+ `Called the Bash tool with the following input: ${JSON.stringify({ command: `ls ${attachment.path}` })}`,
101
+ { isMeta: true, timestamp: msg.timestamp },
102
+ ),
103
+ makeUserMessage(
104
+ `Result of calling the Bash tool: ${JSON.stringify({ stdout: attachment.content, stderr: "", interrupted: false })}`,
105
+ { isMeta: true, timestamp: msg.timestamp },
106
+ ),
107
+ ];
108
+
109
+ case "file": {
110
+ const content = attachment.content;
111
+ if (content?.type === "image") {
112
+ return [makeUserMessage(
113
+ Array.isArray(content.content) ? content.content : [content],
114
+ { isMeta: true, timestamp: msg.timestamp },
115
+ )];
116
+ }
117
+ // text, notebook, pdf
118
+ const text = typeof content === "string"
119
+ ? content
120
+ : content?.text ?? content?.content ?? JSON.stringify(content);
121
+ return [makeUserMessage(
122
+ `Result of calling the Read tool: ${text}`,
123
+ { isMeta: true, timestamp: msg.timestamp },
124
+ )];
125
+ }
126
+
127
+ case "edited_text_file":
128
+ return [makeUserMessage(
129
+ `Note: ${attachment.filename} was modified, either by the user or by a linter. Here are the relevant changes:\n${attachment.snippet}`,
130
+ { isMeta: true, timestamp: msg.timestamp },
131
+ )];
132
+
133
+ case "selected_lines_in_ide": {
134
+ const content = attachment.content?.length > 2000
135
+ ? attachment.content.substring(0, 2000) + "\n... (truncated)"
136
+ : attachment.content;
137
+ return [makeUserMessage(
138
+ `The user selected lines ${attachment.lineStart} to ${attachment.lineEnd} from ${attachment.filename}:\n${content}\n\nThis may or may not be related to the current task.`,
139
+ { isMeta: true, timestamp: msg.timestamp },
140
+ )];
141
+ }
142
+
143
+ case "opened_file_in_ide":
144
+ return [makeUserMessage(
145
+ `The user opened the file ${attachment.filename} in the IDE. This may or may not be related to the current task.`,
146
+ { isMeta: true, timestamp: msg.timestamp },
147
+ )];
148
+
149
+ case "compact_file_reference":
150
+ return [makeUserMessage(
151
+ `Note: ${attachment.filename} was read before the last conversation was summarized, but the contents are too large to include. Use Read tool if you need to access it.`,
152
+ { isMeta: true, timestamp: msg.timestamp },
153
+ )];
154
+
155
+ case "plan_file_reference":
156
+ return [makeUserMessage(
157
+ `A plan file exists from plan mode at: ${attachment.planFilePath}\n\nPlan contents:\n\n${attachment.planContent}`,
158
+ { isMeta: true, timestamp: msg.timestamp },
159
+ )];
160
+
161
+ case "invoked_skills": {
162
+ if (!attachment.skills?.length) return [];
163
+ const skillsText = attachment.skills
164
+ .map((s: any) => `### Skill: ${s.name}\nPath: ${s.path}\n\n${s.content}`)
165
+ .join("\n\n---\n\n");
166
+ return [makeUserMessage(
167
+ `The following skills were invoked in this session:\n\n${skillsText}`,
168
+ { isMeta: true, timestamp: msg.timestamp },
169
+ )];
170
+ }
171
+
172
+ case "pdf_reference":
173
+ return [makeUserMessage(
174
+ `PDF file: ${attachment.filename} (${attachment.pageCount} pages, ${attachment.fileSize}). Use the Read tool with pages parameter to read specific page ranges.`,
175
+ { isMeta: true, timestamp: msg.timestamp },
176
+ )];
177
+
178
+ case "teammate_mailbox":
179
+ case "team_context":
180
+ // Simplified: include the raw content
181
+ return [makeUserMessage(
182
+ JSON.stringify(attachment),
183
+ { isMeta: true, timestamp: msg.timestamp },
184
+ )];
185
+
186
+ case "todo_reminder": {
187
+ if (!attachment.content?.length) return [];
188
+ const todos = attachment.content
189
+ .map((t: any, i: number) => `${i + 1}. [${t.status}] ${t.text}`)
190
+ .join("\n");
191
+ return [makeUserMessage(
192
+ `Active todos:\n${todos}`,
193
+ { isMeta: true, timestamp: msg.timestamp },
194
+ )];
195
+ }
196
+
197
+ default:
198
+ // Unknown attachment type — include as JSON
199
+ return [makeUserMessage(
200
+ `[Attachment: ${attachment.type}]\n${JSON.stringify(attachment)}`,
201
+ { isMeta: true, timestamp: msg.timestamp },
202
+ )];
203
+ }
204
+ }
205
+
206
+ // ============================================================================
207
+ // Pre-step: Reorder attachments adjacent to referencing messages
208
+ // ============================================================================
209
+
210
+ function reorderAttachments(messages: CCMessage[]): CCMessage[] {
211
+ const result: CCMessage[] = [];
212
+ const pendingAttachments: CCMessage[] = [];
213
+
214
+ for (let i = messages.length - 1; i >= 0; i--) {
215
+ const msg = messages[i];
216
+ if (msg.type === "attachment") {
217
+ pendingAttachments.push(msg);
218
+ } else if (
219
+ (msg.type === "assistant" ||
220
+ (msg.type === "user" &&
221
+ Array.isArray(msg.message.content) &&
222
+ msg.message.content[0]?.type === "tool_result")) &&
223
+ pendingAttachments.length > 0
224
+ ) {
225
+ for (const att of pendingAttachments) result.push(att);
226
+ result.push(msg);
227
+ pendingAttachments.length = 0;
228
+ } else {
229
+ result.push(msg);
230
+ }
231
+ }
232
+ // Remaining attachments
233
+ for (const att of pendingAttachments) result.push(att);
234
+
235
+ return result.reverse();
236
+ }
237
+
238
+ // ============================================================================
239
+ // Strip tool references from user messages — CC's It1()
240
+ // ============================================================================
241
+
242
+ function stripToolReferences(msg: CCMessage): CCMessage {
243
+ const content = msg.message.content;
244
+ if (!Array.isArray(content)) return msg;
245
+
246
+ const hasRefs = content.some(
247
+ (c) =>
248
+ c.type === "tool_result" &&
249
+ Array.isArray(c.content) &&
250
+ c.content.some(isToolReference)
251
+ );
252
+ if (!hasRefs) return msg;
253
+
254
+ return {
255
+ ...msg,
256
+ message: {
257
+ ...msg.message,
258
+ content: content.map((c) => {
259
+ if (c.type !== "tool_result" || !Array.isArray(c.content)) return c;
260
+ const filtered = c.content.filter((b: any) => !isToolReference(b));
261
+ if (filtered.length === 0)
262
+ return { ...c, content: [{ type: "text", text: "[Tool references removed]" }] };
263
+ return { ...c, content: filtered };
264
+ }),
265
+ },
266
+ };
267
+ }
268
+
269
+ // ============================================================================
270
+ // Main normalization
271
+ // ============================================================================
272
+
273
+ export function normalizeMessages(messages: CCMessage[]): CCMessage[] {
274
+ // Pre-step: reorder attachments
275
+ const reordered = reorderAttachments(messages);
276
+
277
+ // Filter and convert
278
+ const filtered = reordered.filter((msg) => {
279
+ // Remove progress messages
280
+ if (msg.type === "progress") return false;
281
+ // Remove non-local-command system messages (including compact boundaries)
282
+ if (msg.type === "system" && !isLocalCommand(msg)) return false;
283
+ // Remove API error messages
284
+ if (isApiErrorMessage(msg)) return false;
285
+ return true;
286
+ });
287
+
288
+ const result: CCMessage[] = [];
289
+
290
+ for (const msg of filtered) {
291
+ switch (msg.type) {
292
+ case "system": {
293
+ // Only local_command system messages reach here
294
+ // Convert to user message
295
+ const userMsg = makeUserMessage(msg.content ?? "", {
296
+ uuid: msg.uuid,
297
+ timestamp: msg.timestamp,
298
+ });
299
+ const prev = last(result);
300
+ if (prev?.type === "user") {
301
+ result[result.length - 1] = mergeUsers(prev, userMsg);
302
+ } else {
303
+ result.push(userMsg);
304
+ }
305
+ break;
306
+ }
307
+
308
+ case "user": {
309
+ // Strip tool references
310
+ const stripped = stripToolReferences(msg);
311
+ const prev = last(result);
312
+ if (prev?.type === "user") {
313
+ result[result.length - 1] = mergeUsers(prev, stripped);
314
+ } else {
315
+ result.push(stripped);
316
+ }
317
+ break;
318
+ }
319
+
320
+ case "assistant": {
321
+ // Remap tool names (simplified: keep as-is for duncan)
322
+ // Merge split assistant messages (same message.id)
323
+ const prev = last(result);
324
+ if (
325
+ prev?.type === "assistant" &&
326
+ prev.message.id &&
327
+ msg.message.id &&
328
+ prev.message.id === msg.message.id
329
+ ) {
330
+ // Merge content arrays
331
+ result[result.length - 1] = {
332
+ ...prev,
333
+ message: {
334
+ ...prev.message,
335
+ content: [
336
+ ...(Array.isArray(prev.message.content) ? prev.message.content : []),
337
+ ...(Array.isArray(msg.message.content) ? msg.message.content : []),
338
+ ],
339
+ },
340
+ };
341
+ } else {
342
+ result.push(msg);
343
+ }
344
+ break;
345
+ }
346
+
347
+ case "attachment": {
348
+ const converted = convertAttachment(msg);
349
+ for (const convMsg of converted) {
350
+ const prev = last(result);
351
+ if (prev?.type === "user") {
352
+ result[result.length - 1] = mergeUsers(prev, convMsg);
353
+ } else {
354
+ result.push(convMsg);
355
+ }
356
+ }
357
+ break;
358
+ }
359
+ }
360
+ }
361
+
362
+ // Post-transforms (8 steps matching CC pipeline)
363
+ let normalized = result;
364
+ normalized = relocateDeferredToolRefText(normalized);
365
+ normalized = filterOrphanedThinking(normalized);
366
+ normalized = removeTrailingThinking(normalized);
367
+ normalized = removeWhitespaceAssistant(normalized);
368
+ normalized = fixEmptyAssistantContent(normalized);
369
+ normalized = reorderSystemReminders(normalized);
370
+ // re-merge consecutive users is inlined in removeWhitespaceAssistant
371
+ normalized = flattenErrorToolResults(normalized);
372
+ normalized = fixOrphanedToolUse(normalized); // ensure every tool_use has a tool_result
373
+
374
+ return normalized;
375
+ }
376
+
377
+ // ============================================================================
378
+ // Post-transform 1: Filter orphaned thinking-only assistant messages
379
+ // ============================================================================
380
+
381
+ function filterOrphanedThinking(messages: CCMessage[]): CCMessage[] {
382
+ // Collect message IDs that have non-thinking content
383
+ const hasNonThinking = new Set<string>();
384
+ for (const msg of messages) {
385
+ if (msg.type !== "assistant") continue;
386
+ const content = msg.message.content;
387
+ if (!Array.isArray(content)) continue;
388
+ if (content.some((c) => !isThinkingBlock(c)) && msg.message.id) {
389
+ hasNonThinking.add(msg.message.id);
390
+ }
391
+ }
392
+
393
+ return messages.filter((msg) => {
394
+ if (msg.type !== "assistant") return true;
395
+ const content = msg.message.content;
396
+ if (!Array.isArray(content) || content.length === 0) return true;
397
+ // Keep if has non-thinking content
398
+ if (!content.every(isThinkingBlock)) return true;
399
+ // Keep if another message with same ID has non-thinking content
400
+ if (msg.message.id && hasNonThinking.has(msg.message.id)) return true;
401
+ // Filter out
402
+ return false;
403
+ });
404
+ }
405
+
406
+ // ============================================================================
407
+ // Post-transform 2: Remove trailing thinking from last assistant
408
+ // ============================================================================
409
+
410
+ function removeTrailingThinking(messages: CCMessage[]): CCMessage[] {
411
+ if (messages.length === 0) return messages;
412
+ const lastMsg = messages[messages.length - 1];
413
+ if (lastMsg.type !== "assistant") return messages;
414
+
415
+ const content = lastMsg.message.content;
416
+ if (!Array.isArray(content)) return messages;
417
+
418
+ // Find last non-thinking index
419
+ const lastBlock = content[content.length - 1];
420
+ if (!lastBlock || !isThinkingBlock(lastBlock)) return messages;
421
+
422
+ let lastNonThinking = content.length - 1;
423
+ while (lastNonThinking >= 0 && isThinkingBlock(content[lastNonThinking])) {
424
+ lastNonThinking--;
425
+ }
426
+
427
+ const trimmed =
428
+ lastNonThinking < 0
429
+ ? [{ type: "text", text: "[No message content]", citations: [] }]
430
+ : content.slice(0, lastNonThinking + 1);
431
+
432
+ const result = [...messages];
433
+ result[messages.length - 1] = {
434
+ ...lastMsg,
435
+ message: { ...lastMsg.message, content: trimmed },
436
+ };
437
+ return result;
438
+ }
439
+
440
+ // ============================================================================
441
+ // Post-transform 3: Remove whitespace-only assistant messages
442
+ // ============================================================================
443
+
444
+ function removeWhitespaceAssistant(messages: CCMessage[]): CCMessage[] {
445
+ let hasRemoval = false;
446
+ const filtered = messages.filter((msg) => {
447
+ if (msg.type !== "assistant") return true;
448
+ const content = msg.message.content;
449
+ if (!Array.isArray(content) || content.length === 0) return true;
450
+ if (isWhitespaceOnly(content)) {
451
+ hasRemoval = true;
452
+ return false;
453
+ }
454
+ return true;
455
+ });
456
+
457
+ if (!hasRemoval) return messages;
458
+
459
+ // Merge resulting adjacent user messages
460
+ const merged: CCMessage[] = [];
461
+ for (const msg of filtered) {
462
+ const prev = last(merged);
463
+ if (msg.type === "user" && prev?.type === "user") {
464
+ merged[merged.length - 1] = mergeUsers(prev, msg);
465
+ } else {
466
+ merged.push(msg);
467
+ }
468
+ }
469
+ return merged;
470
+ }
471
+
472
+ // ============================================================================
473
+ // Post-transform 4: Fix empty assistant content
474
+ // ============================================================================
475
+
476
+ function fixEmptyAssistantContent(messages: CCMessage[]): CCMessage[] {
477
+ const PLACEHOLDER = "[No message content]";
478
+
479
+ return messages.map((msg, i) => {
480
+ if (msg.type !== "assistant") return msg;
481
+ // Don't fix the last message
482
+ if (i === messages.length - 1) return msg;
483
+ const content = msg.message.content;
484
+ if (Array.isArray(content) && content.length === 0) {
485
+ return {
486
+ ...msg,
487
+ message: {
488
+ ...msg.message,
489
+ content: [{ type: "text", text: PLACEHOLDER, citations: [] }],
490
+ },
491
+ };
492
+ }
493
+ return msg;
494
+ });
495
+ }
496
+
497
+ // ============================================================================
498
+ // Post-transform 5: Relocate deferred tool_reference text
499
+ // Moves text blocks from user messages that contain tool_references into the
500
+ // next user message that has tool_results (but no tool_references itself).
501
+ // This keeps reference context adjacent to the tool output it describes.
502
+ // ============================================================================
503
+
504
+ function hasToolReferences(content: any[]): boolean {
505
+ return content.some((c: any) => c.type === "tool_reference");
506
+ }
507
+
508
+ function relocateDeferredToolRefText(messages: CCMessage[]): CCMessage[] {
509
+ const result = [...messages];
510
+
511
+ for (let i = 0; i < result.length; i++) {
512
+ const msg = result[i];
513
+ if (msg.type !== "user") continue;
514
+ const content = msg.message.content;
515
+ if (!Array.isArray(content)) continue;
516
+ if (!hasToolReferences(content)) continue;
517
+
518
+ const textBlocks = content.filter((c: any) => c.type === "text");
519
+ if (textBlocks.length === 0) continue;
520
+
521
+ // Find the next user message with tool_results but without tool_references
522
+ let targetIdx = -1;
523
+ for (let j = i + 1; j < result.length; j++) {
524
+ const candidate = result[j];
525
+ if (candidate.type !== "user") continue;
526
+ const cc = candidate.message.content;
527
+ if (!Array.isArray(cc)) continue;
528
+ if (!cc.some((c: any) => c.type === "tool_result")) continue;
529
+ if (hasToolReferences(cc)) continue;
530
+ targetIdx = j;
531
+ break;
532
+ }
533
+
534
+ if (targetIdx === -1) continue;
535
+
536
+ // Move text blocks from source to target
537
+ result[i] = {
538
+ ...msg,
539
+ message: {
540
+ ...msg.message,
541
+ content: content.filter((c: any) => c.type !== "text"),
542
+ },
543
+ };
544
+
545
+ const target = result[targetIdx];
546
+ result[targetIdx] = {
547
+ ...target,
548
+ message: {
549
+ ...target.message,
550
+ content: [...target.message.content, ...textBlocks],
551
+ },
552
+ };
553
+ }
554
+
555
+ return result;
556
+ }
557
+
558
+ // ============================================================================
559
+ // Post-transform 6: Reorder system-reminder blocks in tool_results
560
+ // Moves <system-reminder> text blocks from user messages into the last
561
+ // tool_result in that same message, keeping them adjacent to tool output.
562
+ // ============================================================================
563
+
564
+ function reorderSystemReminders(messages: CCMessage[]): CCMessage[] {
565
+ return messages.map((msg) => {
566
+ if (msg.type !== "user") return msg;
567
+ const content = msg.message.content;
568
+ if (!Array.isArray(content)) return msg;
569
+ if (!content.some((c: any) => c.type === "tool_result")) return msg;
570
+
571
+ // Separate system-reminder text blocks from everything else
572
+ const reminders: any[] = [];
573
+ const rest: any[] = [];
574
+ for (const block of content) {
575
+ if (
576
+ block.type === "text" &&
577
+ typeof block.text === "string" &&
578
+ block.text.startsWith("<system-reminder>")
579
+ ) {
580
+ reminders.push(block);
581
+ } else {
582
+ rest.push(block);
583
+ }
584
+ }
585
+
586
+ if (reminders.length === 0) return msg;
587
+
588
+ // Find the last tool_result and inject reminders into its content
589
+ const lastToolResultIdx = rest.map((c: any) => c.type).lastIndexOf("tool_result");
590
+ if (lastToolResultIdx === -1) return msg;
591
+
592
+ const lastToolResult = rest[lastToolResultIdx];
593
+ const existingContent = Array.isArray(lastToolResult.content)
594
+ ? lastToolResult.content
595
+ : typeof lastToolResult.content === "string"
596
+ ? [{ type: "text", text: lastToolResult.content }]
597
+ : [];
598
+
599
+ const updated = {
600
+ ...lastToolResult,
601
+ content: [...existingContent, ...reminders],
602
+ };
603
+
604
+ const newContent = [...rest.slice(0, lastToolResultIdx), updated, ...rest.slice(lastToolResultIdx + 1)];
605
+ return {
606
+ ...msg,
607
+ message: { ...msg.message, content: newContent },
608
+ };
609
+ });
610
+ }
611
+
612
+ // ============================================================================
613
+ // Post-transform 7: Flatten error tool_results
614
+ // Error tool_results that contain non-text blocks (images, etc.) get stripped
615
+ // to text-only content. Prevents sending binary content in error responses.
616
+ // ============================================================================
617
+
618
+ function flattenErrorToolResults(messages: CCMessage[]): CCMessage[] {
619
+ return messages.map((msg) => {
620
+ if (msg.type !== "user") return msg;
621
+ const content = msg.message.content;
622
+ if (!Array.isArray(content)) return msg;
623
+
624
+ let changed = false;
625
+ const newContent = content.map((block: any) => {
626
+ if (block.type !== "tool_result" || !block.is_error) return block;
627
+ const inner = block.content;
628
+ if (!Array.isArray(inner)) return block;
629
+ // If all content is text, leave it alone
630
+ if (inner.every((c: any) => c.type === "text")) return block;
631
+
632
+ changed = true;
633
+ const textParts = inner.filter((c: any) => c.type === "text").map((c: any) => c.text);
634
+ return {
635
+ ...block,
636
+ content: textParts.length > 0 ? [{ type: "text", text: textParts.join("\n\n") }] : [],
637
+ };
638
+ });
639
+
640
+ if (!changed) return msg;
641
+ return { ...msg, message: { ...msg.message, content: newContent } };
642
+ });
643
+ }
644
+
645
+ // ============================================================================
646
+ // Post-transform 8: Fix orphaned tool_use blocks
647
+ // Ensure every tool_use in an assistant message has a corresponding tool_result
648
+ // in the following user message. Insert synthetic results for missing ones.
649
+ // ============================================================================
650
+
651
+ function fixOrphanedToolUse(messages: CCMessage[]): CCMessage[] {
652
+ const result: CCMessage[] = [];
653
+
654
+ for (let i = 0; i < messages.length; i++) {
655
+ result.push(messages[i]);
656
+
657
+ const msg = messages[i];
658
+ if (msg.type !== "assistant" || !Array.isArray(msg.message.content)) continue;
659
+
660
+ const toolUses = msg.message.content.filter((c: any) => c.type === "tool_use");
661
+ if (toolUses.length === 0) continue;
662
+
663
+ // Check next message for matching tool_results
664
+ const next = messages[i + 1];
665
+ const nextContent = (next?.type === "user" && Array.isArray(next?.message?.content))
666
+ ? next.message.content
667
+ : [];
668
+ const existingResultIds = new Set(
669
+ nextContent.filter((c: any) => c.type === "tool_result").map((c: any) => c.tool_use_id)
670
+ );
671
+
672
+ const orphaned = toolUses.filter((tu: any) => !existingResultIds.has(tu.id));
673
+ if (orphaned.length === 0) continue;
674
+
675
+ // Build synthetic tool_result blocks
676
+ const syntheticResults = orphaned.map((tu: any) => ({
677
+ type: "tool_result",
678
+ tool_use_id: tu.id,
679
+ content: "[Tool execution interrupted]",
680
+ is_error: true,
681
+ }));
682
+
683
+ if (next?.type === "user" && Array.isArray(next?.message?.content)) {
684
+ // Inject into the existing user message
685
+ messages[i + 1] = {
686
+ ...next,
687
+ message: {
688
+ ...next.message,
689
+ content: [...syntheticResults, ...nextContent],
690
+ },
691
+ };
692
+ } else {
693
+ // Insert a new user message with the synthetic results
694
+ result.push(makeUserMessage(syntheticResults, {
695
+ timestamp: msg.timestamp,
696
+ isMeta: true,
697
+ }));
698
+ }
699
+ }
700
+
701
+ return result;
702
+ }