@inceptionstack/roundhouse 0.5.21 → 0.5.25

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,200 @@
1
+ /**
2
+ * message-validator.ts — Validates and repairs agent message history
3
+ *
4
+ * Detects orphaned toolCall/toolResult blocks that corrupt message history:
5
+ * - orphaned toolResults (result without matching call)
6
+ * - orphaned toolCalls (call with no matching result, e.g., aborted tool execution)
7
+ *
8
+ * ⚠️ STATUS: Draft pending integration decision per Codex review.
9
+ * Codex recommends stream-time tool lifecycle tracking (in gateway/streaming.ts)
10
+ * over batch history validation for Pi/Kiro adapters.
11
+ * This module is designed for future raw-history providers.
12
+ */
13
+
14
+ import type {
15
+ Message,
16
+ AssistantMessage,
17
+ ToolCall,
18
+ ToolResultMessage,
19
+ } from '@earendil-works/pi-ai';
20
+
21
+ // Re-export Message so tests/consumers can import it from this module
22
+ // (required under isolatedModules when tests do `import { type Message } from './message-validator'`)
23
+ export type { Message } from '@earendil-works/pi-ai';
24
+
25
+ export interface ValidationResult {
26
+ isValid: boolean;
27
+ orphanedToolCallIds: string[];
28
+ orphanedToolResultIds: string[];
29
+ orphanedCount: number;
30
+ }
31
+
32
+ /**
33
+ * Validates that all toolCall/toolResult pairs are complete:
34
+ * - Every toolResult has a matching toolCall earlier in history
35
+ * - Every toolCall has a matching toolResult later in history (in order)
36
+ *
37
+ * Returns both orphaned toolCalls (calls with no result) and orphaned toolResults
38
+ * (results with no matching call).
39
+ */
40
+ export function validateToolPairing(messages: Message[]): ValidationResult {
41
+ const toolCallIds = new Set<string>();
42
+ const toolCallIndexes = new Map<string, number>(); // toolCallId -> msgIndex
43
+ const toolResultIds = new Set<string>();
44
+ const orphanedCalls: string[] = [];
45
+ const orphanedResults: string[] = [];
46
+
47
+ // First pass: collect all tool call IDs and their positions
48
+ for (let i = 0; i < messages.length; i++) {
49
+ const msg = messages[i];
50
+ if (msg.role === 'assistant' && msg.content) {
51
+ for (const block of msg.content) {
52
+ if ((block as ToolCall).type === 'toolCall' && (block as ToolCall).id) {
53
+ const callId = (block as ToolCall).id;
54
+ // Duplicate toolCall IDs would be a pi-ai invariant violation upstream;
55
+ // keep first-occurrence semantics (set().add ignores dups; Map.set overwrites
56
+ // with last-seen index — guard to preserve earliest position for ordering checks).
57
+ if (!toolCallIndexes.has(callId)) {
58
+ toolCallIndexes.set(callId, i);
59
+ }
60
+ toolCallIds.add(callId);
61
+ }
62
+ }
63
+ }
64
+ }
65
+
66
+ // Second pass: check all toolResult messages have matching toolCall earlier
67
+ for (let i = 0; i < messages.length; i++) {
68
+ const msg = messages[i];
69
+ if (msg.role === 'toolResult') {
70
+ const resultMsg = msg as ToolResultMessage;
71
+ toolResultIds.add(resultMsg.toolCallId);
72
+
73
+ if (!toolCallIds.has(resultMsg.toolCallId)) {
74
+ orphanedResults.push(resultMsg.toolCallId);
75
+ } else {
76
+ // Verify the toolCall appears before this toolResult
77
+ const callIdx = toolCallIndexes.get(resultMsg.toolCallId);
78
+ if (callIdx !== undefined && callIdx >= i) {
79
+ // Call appears after result (invalid order)
80
+ orphanedResults.push(resultMsg.toolCallId);
81
+ }
82
+ }
83
+ }
84
+ }
85
+
86
+ // Third pass: check all toolCalls have matching toolResult later
87
+ for (const [callId, callIdx] of toolCallIndexes.entries()) {
88
+ if (!toolResultIds.has(callId)) {
89
+ // toolCall has no matching toolResult (e.g., execution aborted)
90
+ orphanedCalls.push(callId);
91
+ } else {
92
+ // Verify toolResult appears after this toolCall
93
+ let resultFound = false;
94
+ for (let i = callIdx + 1; i < messages.length; i++) {
95
+ if (messages[i].role === 'toolResult' &&
96
+ (messages[i] as ToolResultMessage).toolCallId === callId) {
97
+ resultFound = true;
98
+ break;
99
+ }
100
+ }
101
+ if (!resultFound) {
102
+ orphanedCalls.push(callId);
103
+ }
104
+ }
105
+ }
106
+
107
+ const allOrphaned = orphanedCalls.concat(orphanedResults);
108
+ return {
109
+ isValid: allOrphaned.length === 0,
110
+ orphanedToolCallIds: orphanedCalls,
111
+ orphanedToolResultIds: orphanedResults,
112
+ orphanedCount: allOrphaned.length,
113
+ };
114
+ }
115
+
116
+ /**
117
+ * Strips orphaned toolResults and toolCalls from message history.
118
+ * Returns repaired array or original if no orphans found.
119
+ *
120
+ * - Removes toolResult messages with orphaned IDs
121
+ * - Removes toolCall blocks from assistant messages with orphaned IDs
122
+ * - Strips entire assistant message if it becomes empty after removing toolCalls
123
+ */
124
+ export function stripOrphanedResults(messages: Message[]): Message[] {
125
+ const { orphanedToolCallIds, orphanedToolResultIds } = validateToolPairing(messages);
126
+ const idsToRemove = new Set([...orphanedToolCallIds, ...orphanedToolResultIds]);
127
+
128
+ if (idsToRemove.size === 0) {
129
+ return messages;
130
+ }
131
+
132
+ const result: Message[] = [];
133
+
134
+ for (const msg of messages) {
135
+ if (msg.role === 'toolResult') {
136
+ const resultMsg = msg as ToolResultMessage;
137
+ // Skip orphaned toolResult messages
138
+ if (!idsToRemove.has(resultMsg.toolCallId)) {
139
+ result.push(msg);
140
+ }
141
+ } else if (msg.role === 'assistant') {
142
+ const assistantMsg = msg as AssistantMessage;
143
+ // Filter out orphaned toolCall blocks
144
+ const cleanedContent = assistantMsg.content.filter(block => {
145
+ if ((block as ToolCall).type === 'toolCall') {
146
+ return !idsToRemove.has((block as ToolCall).id);
147
+ }
148
+ return true; // Keep text, thinking, images
149
+ });
150
+
151
+ // Only keep assistant message if it has remaining content
152
+ if (cleanedContent.length > 0) {
153
+ result.push({
154
+ ...assistantMsg,
155
+ content: cleanedContent,
156
+ });
157
+ }
158
+ } else {
159
+ result.push(msg);
160
+ }
161
+ }
162
+
163
+ return result;
164
+ }
165
+
166
+ /**
167
+ * Validates and repairs message history in one call.
168
+ * Returns cleaned messages and a report if any repairs were made.
169
+ */
170
+ export interface RepairResult {
171
+ messages: Message[];
172
+ wasRepaired: boolean;
173
+ strippedCount: number;
174
+ strippedCallIds: string[];
175
+ strippedResultIds: string[];
176
+ }
177
+
178
+ export function validateAndRepair(messages: Message[]): RepairResult {
179
+ const validation = validateToolPairing(messages);
180
+
181
+ if (validation.isValid) {
182
+ return {
183
+ messages,
184
+ wasRepaired: false,
185
+ strippedCount: 0,
186
+ strippedCallIds: [],
187
+ strippedResultIds: [],
188
+ };
189
+ }
190
+
191
+ const cleaned = stripOrphanedResults(messages);
192
+
193
+ return {
194
+ messages: cleaned,
195
+ wasRepaired: true,
196
+ strippedCount: validation.orphanedCount,
197
+ strippedCallIds: validation.orphanedToolCallIds,
198
+ strippedResultIds: validation.orphanedToolResultIds,
199
+ };
200
+ }
@@ -0,0 +1,378 @@
1
+ /**
2
+ * session-repair.test.ts — Tests for file-level session repair.
3
+ */
4
+
5
+ import { describe, it, expect, afterEach } from 'vitest';
6
+ import { writeFileSync, readFileSync, unlinkSync, existsSync, readdirSync } from 'node:fs';
7
+ import { join } from 'node:path';
8
+ import { tmpdir } from 'node:os';
9
+ import {
10
+ parseSessionFile,
11
+ inspectSessionFile,
12
+ repairSessionFile,
13
+ isToolPairingError,
14
+ } from './session-repair';
15
+
16
+ // ---------- fixtures ----------
17
+
18
+ const HEADER = {
19
+ type: 'session',
20
+ version: 3,
21
+ id: 'sess-1',
22
+ timestamp: '2026-05-01T00:00:00Z',
23
+ cwd: '/x',
24
+ };
25
+
26
+ const MODEL_CHANGE = {
27
+ type: 'model_change',
28
+ id: 'mc-1',
29
+ parentId: null,
30
+ timestamp: '2026-05-01T00:00:01Z',
31
+ provider: 'amazon-bedrock',
32
+ modelId: 'us.anthropic.claude-opus-4-7',
33
+ };
34
+
35
+ function userMsg(id: string, parentId: string | null, text: string) {
36
+ return {
37
+ type: 'message',
38
+ id,
39
+ parentId,
40
+ timestamp: '2026-05-01T00:00:02Z',
41
+ message: { role: 'user', content: [{ type: 'text', text }], timestamp: 1 },
42
+ };
43
+ }
44
+
45
+ function assistantToolCall(id: string, parentId: string | null, toolCallId: string, toolName = 'bash') {
46
+ return {
47
+ type: 'message',
48
+ id,
49
+ parentId,
50
+ timestamp: '2026-05-01T00:00:03Z',
51
+ message: {
52
+ role: 'assistant',
53
+ content: [{ type: 'toolCall', id: toolCallId, name: toolName, arguments: {} }],
54
+ api: 'bedrock-converse-stream',
55
+ provider: 'amazon-bedrock',
56
+ model: 'claude-opus',
57
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
58
+ stopReason: 'toolUse',
59
+ timestamp: 2,
60
+ },
61
+ };
62
+ }
63
+
64
+ function assistantTextAndToolCall(id: string, parentId: string | null, toolCallId: string, text: string) {
65
+ return {
66
+ type: 'message',
67
+ id,
68
+ parentId,
69
+ timestamp: '2026-05-01T00:00:03Z',
70
+ message: {
71
+ role: 'assistant',
72
+ content: [
73
+ { type: 'text', text },
74
+ { type: 'toolCall', id: toolCallId, name: 'bash', arguments: {} },
75
+ ],
76
+ api: 'bedrock-converse-stream',
77
+ provider: 'amazon-bedrock',
78
+ model: 'claude-opus',
79
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
80
+ stopReason: 'toolUse',
81
+ timestamp: 2,
82
+ },
83
+ };
84
+ }
85
+
86
+ function toolResult(id: string, parentId: string, toolCallId: string, text = 'ok') {
87
+ return {
88
+ type: 'message',
89
+ id,
90
+ parentId,
91
+ timestamp: '2026-05-01T00:00:04Z',
92
+ message: {
93
+ role: 'toolResult',
94
+ toolCallId,
95
+ toolName: 'bash',
96
+ content: [{ type: 'text', text }],
97
+ isError: false,
98
+ timestamp: 3,
99
+ },
100
+ };
101
+ }
102
+
103
+ function writeJsonl(path: string, entries: object[]): void {
104
+ writeFileSync(path, entries.map(e => JSON.stringify(e)).join('\n') + '\n');
105
+ }
106
+
107
+ // ---------- helpers ----------
108
+
109
+ let tmpFiles: string[] = [];
110
+ function tmpJsonl(entries: object[]): string {
111
+ const p = join(tmpdir(), `session-repair-test-${Date.now()}-${Math.random().toString(36).slice(2)}.jsonl`);
112
+ writeJsonl(p, entries);
113
+ tmpFiles.push(p);
114
+ return p;
115
+ }
116
+
117
+ afterEach(() => {
118
+ for (const f of tmpFiles) {
119
+ if (existsSync(f)) try { unlinkSync(f); } catch { /* ignore */ }
120
+ // clean up any .bak-* files the repair created next to f
121
+ try {
122
+ const dir = f.substring(0, f.lastIndexOf('/'));
123
+ const name = f.substring(f.lastIndexOf('/') + 1);
124
+ for (const sibling of readdirSync(dir)) {
125
+ if (sibling.startsWith(`${name}.bak-`) || sibling.startsWith(`${name}.tmp-`)) {
126
+ try { unlinkSync(join(dir, sibling)); } catch { /* ignore */ }
127
+ }
128
+ }
129
+ } catch { /* ignore */ }
130
+ }
131
+ tmpFiles = [];
132
+ });
133
+
134
+ // ---------- tests ----------
135
+
136
+ describe('session-repair', () => {
137
+ describe('parseSessionFile', () => {
138
+ it('parses a valid JSONL session', () => {
139
+ const path = tmpJsonl([HEADER, MODEL_CHANGE, userMsg('u1', 'mc-1', 'hi')]);
140
+ const entries = parseSessionFile(path);
141
+ expect(entries.length).toBe(3);
142
+ expect(entries[0].type).toBe('session');
143
+ expect(entries[2].message).toBeDefined();
144
+ });
145
+
146
+ it('tolerates trailing blank lines', () => {
147
+ const path = tmpJsonl([HEADER]);
148
+ writeFileSync(path, JSON.stringify(HEADER) + '\n\n\n');
149
+ expect(parseSessionFile(path).length).toBe(1);
150
+ });
151
+
152
+ it('throws on malformed JSON with line number', () => {
153
+ const path = join(tmpdir(), `bad-${Date.now()}.jsonl`);
154
+ writeFileSync(path, JSON.stringify(HEADER) + '\n{not json\n');
155
+ tmpFiles.push(path);
156
+ expect(() => parseSessionFile(path)).toThrow(/line 2/);
157
+ });
158
+ });
159
+
160
+ describe('inspectSessionFile', () => {
161
+ it('reports clean session as no orphans', () => {
162
+ const path = tmpJsonl([
163
+ HEADER, MODEL_CHANGE,
164
+ userMsg('u1', 'mc-1', 'hi'),
165
+ assistantToolCall('a1', 'u1', 'call-1'),
166
+ toolResult('r1', 'a1', 'call-1'),
167
+ ]);
168
+ const rep = inspectSessionFile(path);
169
+ expect(rep.hasOrphans).toBe(false);
170
+ expect(rep.totalMessages).toBe(3);
171
+ });
172
+
173
+ it('detects orphaned toolCall (no result)', () => {
174
+ const path = tmpJsonl([
175
+ HEADER, MODEL_CHANGE,
176
+ userMsg('u1', 'mc-1', 'hi'),
177
+ assistantToolCall('a1', 'u1', 'call-1'),
178
+ // no toolResult — simulates crashed mid-tool
179
+ ]);
180
+ const rep = inspectSessionFile(path);
181
+ expect(rep.hasOrphans).toBe(true);
182
+ expect(rep.orphanedToolCallIds).toEqual(['call-1']);
183
+ expect(rep.orphanedToolResultIds).toEqual([]);
184
+ });
185
+
186
+ it('detects orphaned toolResult (no matching call)', () => {
187
+ const path = tmpJsonl([
188
+ HEADER, MODEL_CHANGE,
189
+ userMsg('u1', 'mc-1', 'hi'),
190
+ toolResult('r1', 'u1', 'call-ghost'),
191
+ ]);
192
+ const rep = inspectSessionFile(path);
193
+ expect(rep.hasOrphans).toBe(true);
194
+ expect(rep.orphanedToolResultIds).toEqual(['call-ghost']);
195
+ });
196
+ });
197
+
198
+ describe('repairSessionFile', () => {
199
+ it('is a no-op on clean sessions (returns repaired:false, writes no backup)', () => {
200
+ const path = tmpJsonl([
201
+ HEADER, MODEL_CHANGE,
202
+ userMsg('u1', 'mc-1', 'hi'),
203
+ assistantToolCall('a1', 'u1', 'call-1'),
204
+ toolResult('r1', 'a1', 'call-1'),
205
+ ]);
206
+ const rep = repairSessionFile(path);
207
+ expect(rep.repaired).toBe(false);
208
+ expect(rep.backupPath).toBeUndefined();
209
+ });
210
+
211
+ it('drops orphaned toolCall-only assistant entry and preserves tree', () => {
212
+ const path = tmpJsonl([
213
+ HEADER, MODEL_CHANGE,
214
+ userMsg('u1', 'mc-1', 'hi'),
215
+ assistantToolCall('a1', 'u1', 'call-1'), // orphan - no result
216
+ userMsg('u2', 'a1', 'next question'), // child of dropped entry
217
+ ]);
218
+ const rep = repairSessionFile(path);
219
+ expect(rep.repaired).toBe(true);
220
+ expect(rep.droppedEntryIds).toContain('a1');
221
+ expect(rep.droppedToolCallIds).toEqual(['call-1']);
222
+ expect(rep.backupPath).toBeDefined();
223
+
224
+ // Verify tree reparenting: u2's parentId should now be u1 (not a1)
225
+ const repaired = parseSessionFile(path);
226
+ const u2 = repaired.find(e => e.id === 'u2');
227
+ expect(u2?.parentId).toBe('u1');
228
+ // a1 gone
229
+ expect(repaired.find(e => e.id === 'a1')).toBeUndefined();
230
+ });
231
+
232
+ it('drops orphaned toolResult entry only, keeps its siblings', () => {
233
+ const path = tmpJsonl([
234
+ HEADER, MODEL_CHANGE,
235
+ userMsg('u1', 'mc-1', 'hi'),
236
+ toolResult('r-ghost', 'u1', 'call-ghost'),
237
+ userMsg('u2', 'r-ghost', 'next'),
238
+ ]);
239
+ const rep = repairSessionFile(path);
240
+ expect(rep.repaired).toBe(true);
241
+ expect(rep.droppedEntryIds).toEqual(['r-ghost']);
242
+ expect(rep.droppedToolResultIds).toEqual(['call-ghost']);
243
+
244
+ const repaired = parseSessionFile(path);
245
+ const u2 = repaired.find(e => e.id === 'u2');
246
+ expect(u2?.parentId).toBe('u1'); // reparented
247
+ });
248
+
249
+ it('keeps assistant entry but strips orphan toolCall block when text coexists', () => {
250
+ const path = tmpJsonl([
251
+ HEADER, MODEL_CHANGE,
252
+ userMsg('u1', 'mc-1', 'hi'),
253
+ assistantTextAndToolCall('a1', 'u1', 'call-1', 'thinking out loud'),
254
+ // no toolResult for call-1 → orphan, but entry has text too, so KEEP entry + strip block
255
+ ]);
256
+ const rep = repairSessionFile(path);
257
+ expect(rep.repaired).toBe(true);
258
+ expect(rep.droppedEntryIds).not.toContain('a1'); // entry preserved
259
+ expect(rep.droppedToolCallIds).toEqual(['call-1']);
260
+
261
+ const repaired = parseSessionFile(path);
262
+ const a1 = repaired.find(e => e.id === 'a1');
263
+ expect(a1).toBeDefined();
264
+ const content = (a1!.message as { content: Array<{ type: string }> }).content;
265
+ expect(content.length).toBe(1);
266
+ expect(content[0].type).toBe('text');
267
+ });
268
+
269
+ it('writes backup file before mutation', () => {
270
+ const path = tmpJsonl([
271
+ HEADER, MODEL_CHANGE,
272
+ userMsg('u1', 'mc-1', 'hi'),
273
+ assistantToolCall('a1', 'u1', 'call-1'),
274
+ ]);
275
+ const before = readFileSync(path, 'utf8');
276
+ const rep = repairSessionFile(path);
277
+ expect(rep.backupPath).toBeDefined();
278
+ expect(existsSync(rep.backupPath!)).toBe(true);
279
+ expect(readFileSync(rep.backupPath!, 'utf8')).toBe(before);
280
+ });
281
+
282
+ it('throws on missing file', () => {
283
+ expect(() => repairSessionFile('/nonexistent/path.jsonl')).toThrow(/not found/);
284
+ });
285
+ });
286
+
287
+ describe('isToolPairingError', () => {
288
+ it('matches Bedrock tool_use without tool_result', () => {
289
+ const err = new Error('messages.3: `tool_use` ids were found without `tool_result` blocks immediately after');
290
+ expect(isToolPairingError(err)).toBe(true);
291
+ });
292
+
293
+ it('matches Anthropic toolUse without toolResult', () => {
294
+ const err = new Error('toolUse id abc123 without matching toolResult');
295
+ expect(isToolPairingError(err)).toBe(true);
296
+ });
297
+
298
+ it('matches nested ValidationException', () => {
299
+ const err = Object.assign(new Error('Request failed with status 400'), {
300
+ name: 'ValidationException',
301
+ $metadata: { httpStatusCode: 400 },
302
+ cause: { message: 'messages.0: unmatched tool_use block' },
303
+ });
304
+ expect(isToolPairingError(err)).toBe(true);
305
+ });
306
+
307
+ it('does not match unrelated 400s', () => {
308
+ const err = new Error('Invalid model ID');
309
+ expect(isToolPairingError(err)).toBe(false);
310
+ });
311
+
312
+ it('does not match generic messages containing "400" (F5 tightening)', () => {
313
+ // Before the fix, any message containing "400" triggered a JSON.stringify
314
+ // deep search. After fix, we require ValidationException name OR
315
+ // $metadata.httpStatusCode === 400.
316
+ const err = new Error('queued 400 tasks for retry');
317
+ expect(isToolPairingError(err)).toBe(false);
318
+ });
319
+
320
+ it('uses $metadata.httpStatusCode to gate deep search (F5)', () => {
321
+ const err = Object.assign(new Error('Bad request'), {
322
+ $metadata: { httpStatusCode: 400 },
323
+ cause: { message: 'tool_use without tool_result in messages.5' },
324
+ });
325
+ expect(isToolPairingError(err)).toBe(true);
326
+ });
327
+
328
+ it('handles null/undefined safely', () => {
329
+ expect(isToolPairingError(null)).toBe(false);
330
+ expect(isToolPairingError(undefined)).toBe(false);
331
+ });
332
+ });
333
+
334
+ describe('repairSessionFile — tree edge cases', () => {
335
+ it('survives a self-parenting cycle when reparenting a kept child (F3)', () => {
336
+ // Malformed file: orphan assistant entry a1 self-parents (cycle),
337
+ // AND a kept entry u2 points at a1 — forcing reparentDroppedEntries
338
+ // to resolve an ancestor through the cycle. Without the visited guard,
339
+ // this stack-overflows.
340
+ const entries: object[] = [
341
+ HEADER, MODEL_CHANGE,
342
+ userMsg('u1', 'mc-1', 'hi'),
343
+ { ...assistantToolCall('a1', 'a1', 'call-1') }, // self-parent orphan
344
+ userMsg('u2', 'a1', 'next'), // kept child pointing into the cycle
345
+ ];
346
+ const path = tmpJsonl(entries);
347
+ const rep = repairSessionFile(path);
348
+ expect(rep.repaired).toBe(true);
349
+ expect(rep.droppedEntryIds).toContain('a1');
350
+ // With the cycle, resolveAncestor bails to null — u2 becomes a root.
351
+ const repaired = parseSessionFile(path);
352
+ const u2 = repaired.find(e => e.id === 'u2');
353
+ expect(u2).toBeDefined();
354
+ expect(u2?.parentId).toBeNull();
355
+ });
356
+
357
+ it('survives a 2-node parentId loop when reparenting a kept child (F3)', () => {
358
+ // a1 <-> a2 form a cycle, both orphan toolCalls.
359
+ // u3 is a kept child pointing into the cycle — forces traversal.
360
+ const a1 = assistantToolCall('a1', 'a2', 'call-1');
361
+ const a2 = assistantToolCall('a2', 'a1', 'call-2');
362
+ const path = tmpJsonl([
363
+ HEADER, MODEL_CHANGE,
364
+ userMsg('u1', 'mc-1', 'hi'),
365
+ a1, a2,
366
+ userMsg('u3', 'a2', 'next'), // kept child into the cycle
367
+ ]);
368
+ const rep = repairSessionFile(path);
369
+ expect(rep.repaired).toBe(true);
370
+ expect(rep.droppedEntryIds.sort()).toEqual(['a1', 'a2']);
371
+ // u3 gets reparented to null (cycle bail) rather than crashing.
372
+ const repaired = parseSessionFile(path);
373
+ const u3 = repaired.find(e => e.id === 'u3');
374
+ expect(u3).toBeDefined();
375
+ expect(u3?.parentId).toBeNull();
376
+ });
377
+ });
378
+ });