@inceptionstack/roundhouse 0.5.22 → 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.
- package/CHANGELOG.md +24 -0
- package/package.json +1 -1
- package/src/agents/pi/pi-adapter.ts +156 -17
- package/src/agents/shared/message-validator.test.ts +351 -0
- package/src/agents/shared/message-validator.ts +200 -0
- package/src/agents/shared/session-repair.test.ts +378 -0
- package/src/agents/shared/session-repair.ts +328 -0
- package/src/cli/cron-commands.ts +54 -39
- package/src/gateway/command-registry.ts +158 -0
- package/src/gateway/gateway.ts +239 -102
- package/src/gateway/inline-keyboard.ts +64 -0
- package/src/gateway/model-command.ts +11 -15
- package/src/gateway/topic-command.ts +147 -18
- package/src/memory/lifecycle.ts +45 -11
- package/src/subagents/orchestrator.ts +31 -4
- package/src/subagents/process-launcher.ts +1 -1
- package/src/subagents/types.ts +1 -0
- package/src/transports/telegram/telegram-adapter.ts +5 -2
- package/src/transports/types.ts +1 -1
|
@@ -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
|
+
});
|