@inceptionstack/roundhouse 0.5.22 → 0.5.26
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 +259 -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 +55 -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
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,30 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to `@inceptionstack/roundhouse` are documented here.
|
|
4
4
|
|
|
5
|
+
## [0.5.25] — 2026-05-12
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
- **Emergency-compact loop** — when a pi session exceeded the model's context limit (e.g. Bedrock 200k), the memory-flush step sent a prompt through the already-overloaded session, which the provider rejected, leaving `pendingCompact = "emergency"` re-armed on every turn (infinite loop). Fix: on emergency pressure, skip flush entirely and go straight to `session.compact()`, which builds its own summarization payload from older history and does not require the live session to fit under the limit. (#122)
|
|
9
|
+
- **Telemetry:** `timing.model` no longer mis-reports the flush model when the adapter lacks `compactWithModel`. Documented remaining BaseAdapter-shim ambiguity inline. (#122)
|
|
10
|
+
- **Soft flush no longer blocks thread lock** — Haiku flush (30–120s) runs outside the lock; hard/emergency compact still inside for memory invariants. Removes 2-minute silent dead zones after user messages. (#110)
|
|
11
|
+
- **Session auto-repair** — corrupted session history (orphan tool-call/result pairs from crashed tools) is now detected and the session file repaired on the fly, preventing permanent thread wedging. (#118)
|
|
12
|
+
- **Sub-agent orphan recovery** — watcher checks stdout before marking a run failed on non-zero exit, so runs that produced output survive process crashes. (#109)
|
|
13
|
+
- **Sub-agent completion on non-zero exit** — runs with stdout output treated as complete regardless of exit code. (#105)
|
|
14
|
+
- **Sub-agents run with `--no-extensions --no-skills`** — prevents stale-context crashes on teardown. (#107)
|
|
15
|
+
- **Boot turn** — synthetic thread now fully transport-agnostic via `transport.createThread()`. Fixes Telegram coupling and missing `handleStream`. (#100, #102, #103)
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
- **`/topic` inline keyboard** — tap-to-switch topic menu in private chats with `🏠 main` escape button; falls back to text list when no topics exist. Sentinel design: button value is `-main` so user input can never collide (leading `-` stripped by normalizer). (#120)
|
|
19
|
+
- **Cron management notifications via IPC** — `roundhouse cron add/pause/resume/trigger/delete` now posts to the active transport without spawning an agent turn. (#114)
|
|
20
|
+
- **Sub-agent completion injects result into parent agent** — synthetic agent turn fires with stdout, so the parent "hears" what the sub-agent did. (#108)
|
|
21
|
+
- **Sub-agent launch notification** (🔬) via `onSpawn()` observer on the orchestrator interface. (#111, #112)
|
|
22
|
+
|
|
23
|
+
### Changed
|
|
24
|
+
- **Gateway notifications use markdown** — transport adapters convert to their native format (Telegram HTML, future Slack mrkdwn). Removed `parseMode` from the transport interface. (#113)
|
|
25
|
+
- **Command dispatch: descriptor pattern** — each command declares `{triggers, stage, acceptsArgs, invoke, actions}` and the gateway iterates a single list. Replaces three branching dispatch loops. Adding a command is now one object literal. (#121)
|
|
26
|
+
- **Clean code pass** on `cron-commands` + gateway (SRP/DRY extraction). (#115)
|
|
27
|
+
- **Slack adapter design doc** added (Socket Mode, `pairedChannels`, DM-based pairing, progressive streaming). (#116, #117)
|
|
28
|
+
|
|
5
29
|
## [0.5.19] — 2026-05-10
|
|
6
30
|
- Sub-agent orchestrator: spawn background Pi agents for review/research/scout/implementation
|
|
7
31
|
- CLI: `roundhouse subagent spawn/status/list/abort`
|
package/package.json
CHANGED
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
|
|
29
29
|
import type { AgentAdapter, AgentAdapterFactory, AgentMessage, AgentResponse, AgentStreamEvent, MessageContext } from "../../types";
|
|
30
30
|
import { formatMessage, extractCustomMessage, customContentToText } from "./message-format";
|
|
31
|
+
import { isToolPairingError, repairSessionFile } from "../shared/session-repair";
|
|
31
32
|
import { SESSIONS_DIR } from "../../config";
|
|
32
33
|
import { DEBUG_STREAM, threadIdToDir } from "../../util";
|
|
33
34
|
|
|
@@ -35,6 +36,8 @@ interface SessionEntry {
|
|
|
35
36
|
session: AgentSession;
|
|
36
37
|
lastUsed: number;
|
|
37
38
|
inFlight: number;
|
|
39
|
+
/** Captured config used to recreate the session after disk-level repair. */
|
|
40
|
+
threadId?: string;
|
|
38
41
|
}
|
|
39
42
|
|
|
40
43
|
const DEFAULT_SESSIONS_DIR = SESSIONS_DIR;
|
|
@@ -80,10 +83,94 @@ export const createPiAgentAdapter: AgentAdapterFactory = (config) => {
|
|
|
80
83
|
}
|
|
81
84
|
|
|
82
85
|
|
|
83
|
-
|
|
86
|
+
/**
|
|
87
|
+
* Callback shape for re-subscribing after a mid-turn session swap (auto-repair).
|
|
88
|
+
* Caller provides:
|
|
89
|
+
* - `subscribe(session)` — re-attach the caller's event handler to the new session
|
|
90
|
+
* - `unsubscribeOld()` — detach from the old session before it's disposed
|
|
91
|
+
*
|
|
92
|
+
* Ordering inside runPromptAndFollowUps on repair:
|
|
93
|
+
* 1. unsubscribeOld() on the pre-repair session
|
|
94
|
+
* 2. dispose old session
|
|
95
|
+
* 3. repair + reload
|
|
96
|
+
* 4. subscribe(newSession) so the retry's events reach the caller
|
|
97
|
+
*/
|
|
98
|
+
interface Resubscribe {
|
|
99
|
+
unsubscribeOld: () => void;
|
|
100
|
+
subscribe: (session: AgentSession) => void;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function runPromptAndFollowUps(
|
|
104
|
+
entry: SessionEntry,
|
|
105
|
+
text: string,
|
|
106
|
+
onDraining?: () => void,
|
|
107
|
+
onDrainComplete?: () => void,
|
|
108
|
+
resubscribe?: Resubscribe,
|
|
109
|
+
): Promise<void> {
|
|
84
110
|
entry.inFlight++;
|
|
111
|
+
// Track whether *this specific prompt call* has already retried after a
|
|
112
|
+
// repair. Prevents retry loops within one turn, but doesn't latch the
|
|
113
|
+
// SessionEntry for its whole lifetime — a future prompt on the same
|
|
114
|
+
// long-lived entry can still auto-repair if new corruption appears (F3).
|
|
115
|
+
let repairedThisCall = false;
|
|
85
116
|
try {
|
|
86
|
-
|
|
117
|
+
try {
|
|
118
|
+
await entry.session.prompt(text);
|
|
119
|
+
} catch (err) {
|
|
120
|
+
// Auto-recover from session-history corruption (orphaned toolCall/toolResult
|
|
121
|
+
// pairs caused by crashed/aborted tools mid-session). Repair the .jsonl on
|
|
122
|
+
// disk, reload the session, retry once. Do NOT loop — if the repaired file
|
|
123
|
+
// still fails, something else is wrong; surface the original error.
|
|
124
|
+
if (!isToolPairingError(err) || repairedThisCall) {
|
|
125
|
+
throw err;
|
|
126
|
+
}
|
|
127
|
+
repairedThisCall = true;
|
|
128
|
+
const sessionFile = entry.session.sessionFile;
|
|
129
|
+
if (!sessionFile) {
|
|
130
|
+
throw err; // in-memory session — nothing to repair on disk
|
|
131
|
+
}
|
|
132
|
+
console.warn(`[pi-agent] tool-pairing error detected on session ${sessionFile} — attempting repair`);
|
|
133
|
+
const report = repairSessionFile(sessionFile);
|
|
134
|
+
if (!report.repaired) {
|
|
135
|
+
// File had no orphans but model still rejected — not our problem to fix.
|
|
136
|
+
console.warn(`[pi-agent] repair found no orphans; re-throwing original error`);
|
|
137
|
+
throw err;
|
|
138
|
+
}
|
|
139
|
+
console.warn(
|
|
140
|
+
`[pi-agent] repaired session: dropped ${report.droppedEntryIds.length} entries ` +
|
|
141
|
+
`(${report.droppedToolCallIds.length} toolCalls, ${report.droppedToolResultIds.length} toolResults). ` +
|
|
142
|
+
`Backup: ${report.backupPath}`
|
|
143
|
+
);
|
|
144
|
+
// Detach caller's subscriber from the dying session before we swap, so
|
|
145
|
+
// it doesn't receive events from (or prevent GC of) the old session.
|
|
146
|
+
resubscribe?.unsubscribeOld();
|
|
147
|
+
// Reload session FIRST (before disposing old) so that if
|
|
148
|
+
// SessionManager.open / createAgentSession throws, we don't leave the
|
|
149
|
+
// SessionEntry with a disposed-but-still-referenced session that
|
|
150
|
+
// subsequent prompts would reuse. Old session stays alive as fallback
|
|
151
|
+
// until the new one is fully constructed.
|
|
152
|
+
let reloaded: { session: AgentSession };
|
|
153
|
+
try {
|
|
154
|
+
reloaded = await reloadSession(entry, sessionFile);
|
|
155
|
+
} catch (reloadErr) {
|
|
156
|
+
console.warn(`[pi-agent] reloadSession failed after repair — keeping old session, re-subscribing`, reloadErr);
|
|
157
|
+
// Re-attach to the old session so the caller isn't silently orphaned.
|
|
158
|
+
resubscribe?.subscribe(entry.session);
|
|
159
|
+
throw err; // surface original tool-pairing error — repair didn't help
|
|
160
|
+
}
|
|
161
|
+
// Now it's safe to dispose the old session: we have a working replacement.
|
|
162
|
+
const oldSession = entry.session;
|
|
163
|
+
entry.session = reloaded.session;
|
|
164
|
+
try {
|
|
165
|
+
oldSession.dispose();
|
|
166
|
+
} catch (disposeErr) {
|
|
167
|
+
console.warn(`[pi-agent] old session dispose failed (non-fatal):`, disposeErr);
|
|
168
|
+
}
|
|
169
|
+
// Re-attach caller's subscriber to the new session so the retry's
|
|
170
|
+
// text_delta/tool events flow through (F1).
|
|
171
|
+
resubscribe?.subscribe(entry.session);
|
|
172
|
+
await entry.session.prompt(text);
|
|
173
|
+
}
|
|
87
174
|
await drainSessionEvents(entry.session);
|
|
88
175
|
|
|
89
176
|
// Check for pending follow-up work AFTER drainSessionEvents — that's
|
|
@@ -138,6 +225,38 @@ export const createPiAgentAdapter: AgentAdapterFactory = (config) => {
|
|
|
138
225
|
}
|
|
139
226
|
}
|
|
140
227
|
|
|
228
|
+
/**
|
|
229
|
+
* Rebuild just the AgentSession for an existing SessionEntry. Used after
|
|
230
|
+
* on-disk session repair — we need pi-ai to re-read the fixed .jsonl, which
|
|
231
|
+
* means a fresh SessionManager + createAgentSession call.
|
|
232
|
+
*
|
|
233
|
+
* Intentionally does NOT re-run one-time setup (memory-capability detection,
|
|
234
|
+
* tool registration) — those belong to createSession(). This is a narrow
|
|
235
|
+
* "replace the pi session object" operation.
|
|
236
|
+
*
|
|
237
|
+
* Opens the *exact* repaired file by path (not continueRecent) to avoid
|
|
238
|
+
* picking up a different recent session file.
|
|
239
|
+
*/
|
|
240
|
+
async function reloadSession(entry: SessionEntry, repairedSessionFile: string): Promise<{ session: AgentSession }> {
|
|
241
|
+
const threadId = entry.threadId;
|
|
242
|
+
if (!threadId) {
|
|
243
|
+
throw new Error("reloadSession: entry has no threadId; cannot reload");
|
|
244
|
+
}
|
|
245
|
+
const dirName = threadIdToDir(threadId);
|
|
246
|
+
const threadDir = join(sessionsDir, dirName);
|
|
247
|
+
// Open the specific repaired file by path so we don't race with other
|
|
248
|
+
// session files in the directory (F4).
|
|
249
|
+
const sessionManager = SessionManager.open(repairedSessionFile, threadDir, cwd);
|
|
250
|
+
console.log(`[pi-agent] reloaded session for ${threadId}: ${sessionManager.getSessionFile()}`);
|
|
251
|
+
const result = await createAgentSession({
|
|
252
|
+
cwd,
|
|
253
|
+
sessionManager,
|
|
254
|
+
authStorage,
|
|
255
|
+
modelRegistry,
|
|
256
|
+
});
|
|
257
|
+
return { session: result.session };
|
|
258
|
+
}
|
|
259
|
+
|
|
141
260
|
async function createSession(threadId: string): Promise<SessionEntry> {
|
|
142
261
|
const dirName = threadIdToDir(threadId);
|
|
143
262
|
const threadDir = join(sessionsDir, dirName);
|
|
@@ -181,7 +300,7 @@ export const createPiAgentAdapter: AgentAdapterFactory = (config) => {
|
|
|
181
300
|
console.log(`[pi-agent] model fallback: ${result.modelFallbackMessage}`);
|
|
182
301
|
}
|
|
183
302
|
|
|
184
|
-
const entry: SessionEntry = { session: result.session, lastUsed: Date.now(), inFlight: 0 };
|
|
303
|
+
const entry: SessionEntry = { session: result.session, lastUsed: Date.now(), inFlight: 0, threadId };
|
|
185
304
|
sessions.set(threadId, entry);
|
|
186
305
|
|
|
187
306
|
// Detect memory capabilities from loaded extensions (first session only)
|
|
@@ -346,7 +465,10 @@ export const createPiAgentAdapter: AgentAdapterFactory = (config) => {
|
|
|
346
465
|
const entry = await getOrCreate(threadId);
|
|
347
466
|
entry.lastUsed = Date.now();
|
|
348
467
|
|
|
349
|
-
|
|
468
|
+
// Extracted subscriber handler so it can be re-attached after an
|
|
469
|
+
// auto-repair session swap (captures only the enqueue closure,
|
|
470
|
+
// not `entry.session`, so it's safe to re-use).
|
|
471
|
+
const handleEvent = (event: AgentSessionEvent) => {
|
|
350
472
|
if (DEBUG_STREAM) {
|
|
351
473
|
const extra =
|
|
352
474
|
event.type === "message_end" || event.type === "message_start"
|
|
@@ -384,21 +506,34 @@ export const createPiAgentAdapter: AgentAdapterFactory = (config) => {
|
|
|
384
506
|
eventQueue.push(streamEvent);
|
|
385
507
|
resolve?.();
|
|
386
508
|
}
|
|
387
|
-
}
|
|
509
|
+
};
|
|
510
|
+
|
|
511
|
+
// Subscription is mutable so auto-repair can swap it when the
|
|
512
|
+
// session is reloaded mid-prompt.
|
|
513
|
+
let unsub = entry.session.subscribe(handleEvent);
|
|
388
514
|
|
|
389
515
|
try {
|
|
390
|
-
await runPromptAndFollowUps(
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
516
|
+
await runPromptAndFollowUps(
|
|
517
|
+
entry,
|
|
518
|
+
text,
|
|
519
|
+
() => {
|
|
520
|
+
eventQueue.push({ type: "draining" });
|
|
521
|
+
resolve?.();
|
|
522
|
+
},
|
|
523
|
+
() => {
|
|
524
|
+
eventQueue.push({ type: "drain_complete" });
|
|
525
|
+
resolve?.();
|
|
526
|
+
},
|
|
527
|
+
{
|
|
528
|
+
unsubscribeOld: () => { try { unsub(); } catch { /* ignore */ } },
|
|
529
|
+
subscribe: (newSession) => { unsub = newSession.subscribe(handleEvent); },
|
|
530
|
+
},
|
|
531
|
+
);
|
|
397
532
|
// Final drain — guarantees all subscriber events have been delivered
|
|
398
533
|
// before we unsubscribe below.
|
|
399
534
|
await drainSessionEvents(entry.session);
|
|
400
535
|
} finally {
|
|
401
|
-
unsub();
|
|
536
|
+
try { unsub(); } catch { /* ignore */ }
|
|
402
537
|
eventQueue.push({ type: "agent_end" });
|
|
403
538
|
done = true;
|
|
404
539
|
resolve?.();
|
|
@@ -591,7 +726,7 @@ export const createPiAgentAdapter: AgentAdapterFactory = (config) => {
|
|
|
591
726
|
entry.lastUsed = Date.now();
|
|
592
727
|
|
|
593
728
|
let fullText = "";
|
|
594
|
-
const
|
|
729
|
+
const handleEvent = (event: AgentSessionEvent) => {
|
|
595
730
|
if (
|
|
596
731
|
event.type === "message_update" &&
|
|
597
732
|
event.assistantMessageEvent.type === "text_delta"
|
|
@@ -603,13 +738,17 @@ export const createPiAgentAdapter: AgentAdapterFactory = (config) => {
|
|
|
603
738
|
fullText += "\n\n" + custom.content;
|
|
604
739
|
}
|
|
605
740
|
}
|
|
606
|
-
}
|
|
741
|
+
};
|
|
742
|
+
let unsub = entry.session.subscribe(handleEvent);
|
|
607
743
|
|
|
608
744
|
try {
|
|
609
|
-
await runPromptAndFollowUps(entry, text
|
|
745
|
+
await runPromptAndFollowUps(entry, text, undefined, undefined, {
|
|
746
|
+
unsubscribeOld: () => { try { unsub(); } catch { /* ignore */ } },
|
|
747
|
+
subscribe: (newSession) => { unsub = newSession.subscribe(handleEvent); },
|
|
748
|
+
});
|
|
610
749
|
await drainSessionEvents(entry.session);
|
|
611
750
|
} finally {
|
|
612
|
-
unsub();
|
|
751
|
+
try { unsub(); } catch { /* ignore */ }
|
|
613
752
|
}
|
|
614
753
|
|
|
615
754
|
return { text: fullText };
|
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* message-validator.test.ts — Tests for message history validation
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect } from 'vitest';
|
|
6
|
+
import {
|
|
7
|
+
validateToolPairing,
|
|
8
|
+
stripOrphanedResults,
|
|
9
|
+
validateAndRepair,
|
|
10
|
+
type Message,
|
|
11
|
+
} from './message-validator';
|
|
12
|
+
|
|
13
|
+
describe('message-validator', () => {
|
|
14
|
+
describe('validateToolPairing', () => {
|
|
15
|
+
it('passes when all toolCalls and toolResults match', () => {
|
|
16
|
+
const messages: Message[] = [
|
|
17
|
+
{
|
|
18
|
+
role: 'user',
|
|
19
|
+
content: 'help me',
|
|
20
|
+
timestamp: 1,
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
role: 'assistant',
|
|
24
|
+
content: [
|
|
25
|
+
{ type: 'toolCall', id: 'call-1', name: 'bash', arguments: {} },
|
|
26
|
+
],
|
|
27
|
+
api: 'bedrock-converse-stream',
|
|
28
|
+
provider: 'amazon-bedrock',
|
|
29
|
+
model: 'claude-opus',
|
|
30
|
+
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
|
|
31
|
+
stopReason: 'toolUse',
|
|
32
|
+
timestamp: 2,
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
role: 'toolResult',
|
|
36
|
+
toolCallId: 'call-1',
|
|
37
|
+
toolName: 'bash',
|
|
38
|
+
content: [{ type: 'text', text: 'ok' }],
|
|
39
|
+
isError: false,
|
|
40
|
+
timestamp: 3,
|
|
41
|
+
},
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
const result = validateToolPairing(messages);
|
|
45
|
+
expect(result.isValid).toBe(true);
|
|
46
|
+
expect(result.orphanedCount).toBe(0);
|
|
47
|
+
expect(result.orphanedToolCallIds).toEqual([]);
|
|
48
|
+
expect(result.orphanedToolResultIds).toEqual([]);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('detects orphaned toolResults (no matching toolCall)', () => {
|
|
52
|
+
const messages: Message[] = [
|
|
53
|
+
{
|
|
54
|
+
role: 'assistant',
|
|
55
|
+
content: [
|
|
56
|
+
{ type: 'toolCall', id: 'call-1', name: 'bash', arguments: {} },
|
|
57
|
+
],
|
|
58
|
+
api: 'bedrock-converse-stream',
|
|
59
|
+
provider: 'amazon-bedrock',
|
|
60
|
+
model: 'claude-opus',
|
|
61
|
+
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
|
|
62
|
+
stopReason: 'toolUse',
|
|
63
|
+
timestamp: 1,
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
role: 'toolResult',
|
|
67
|
+
toolCallId: 'call-1',
|
|
68
|
+
toolName: 'bash',
|
|
69
|
+
content: [{ type: 'text', text: 'ok' }],
|
|
70
|
+
isError: false,
|
|
71
|
+
timestamp: 2,
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
role: 'toolResult', // No matching toolCall!
|
|
75
|
+
toolCallId: 'orphan-1',
|
|
76
|
+
toolName: 'bash',
|
|
77
|
+
content: [{ type: 'text', text: 'orphan' }],
|
|
78
|
+
isError: false,
|
|
79
|
+
timestamp: 3,
|
|
80
|
+
},
|
|
81
|
+
];
|
|
82
|
+
|
|
83
|
+
const result = validateToolPairing(messages);
|
|
84
|
+
expect(result.isValid).toBe(false);
|
|
85
|
+
expect(result.orphanedToolResultIds).toContain('orphan-1');
|
|
86
|
+
expect(result.orphanedCount).toBe(1);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('detects orphaned toolCalls (no matching toolResult)', () => {
|
|
90
|
+
const messages: Message[] = [
|
|
91
|
+
{
|
|
92
|
+
role: 'assistant',
|
|
93
|
+
content: [
|
|
94
|
+
{ type: 'toolCall', id: 'call-1', name: 'bash', arguments: {} },
|
|
95
|
+
{ type: 'toolCall', id: 'call-2', name: 'read', arguments: {} }, // No result!
|
|
96
|
+
],
|
|
97
|
+
api: 'bedrock-converse-stream',
|
|
98
|
+
provider: 'amazon-bedrock',
|
|
99
|
+
model: 'claude-opus',
|
|
100
|
+
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
|
|
101
|
+
stopReason: 'toolUse',
|
|
102
|
+
timestamp: 1,
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
role: 'toolResult',
|
|
106
|
+
toolCallId: 'call-1',
|
|
107
|
+
toolName: 'bash',
|
|
108
|
+
content: [{ type: 'text', text: 'ok' }],
|
|
109
|
+
isError: false,
|
|
110
|
+
timestamp: 2,
|
|
111
|
+
},
|
|
112
|
+
];
|
|
113
|
+
|
|
114
|
+
const result = validateToolPairing(messages);
|
|
115
|
+
expect(result.isValid).toBe(false);
|
|
116
|
+
expect(result.orphanedToolCallIds).toContain('call-2');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('detects out-of-order: toolResult before toolCall', () => {
|
|
120
|
+
const messages: Message[] = [
|
|
121
|
+
{
|
|
122
|
+
role: 'toolResult',
|
|
123
|
+
toolCallId: 'call-1',
|
|
124
|
+
toolName: 'bash',
|
|
125
|
+
content: [{ type: 'text', text: 'result' }],
|
|
126
|
+
isError: false,
|
|
127
|
+
timestamp: 1,
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
role: 'assistant',
|
|
131
|
+
content: [
|
|
132
|
+
{ type: 'toolCall', id: 'call-1', name: 'bash', arguments: {} },
|
|
133
|
+
],
|
|
134
|
+
api: 'bedrock-converse-stream',
|
|
135
|
+
provider: 'amazon-bedrock',
|
|
136
|
+
model: 'claude-opus',
|
|
137
|
+
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
|
|
138
|
+
stopReason: 'toolUse',
|
|
139
|
+
timestamp: 2,
|
|
140
|
+
},
|
|
141
|
+
];
|
|
142
|
+
|
|
143
|
+
const result = validateToolPairing(messages);
|
|
144
|
+
expect(result.isValid).toBe(false);
|
|
145
|
+
expect(result.orphanedToolResultIds).toContain('call-1');
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('handles empty history', () => {
|
|
149
|
+
const result = validateToolPairing([]);
|
|
150
|
+
expect(result.isValid).toBe(true);
|
|
151
|
+
expect(result.orphanedCount).toBe(0);
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
describe('stripOrphanedResults', () => {
|
|
156
|
+
it('removes orphaned toolResult messages', () => {
|
|
157
|
+
const messages: Message[] = [
|
|
158
|
+
{
|
|
159
|
+
role: 'assistant',
|
|
160
|
+
content: [
|
|
161
|
+
{ type: 'toolCall', id: 'call-1', name: 'bash', arguments: {} },
|
|
162
|
+
],
|
|
163
|
+
api: 'bedrock-converse-stream',
|
|
164
|
+
provider: 'amazon-bedrock',
|
|
165
|
+
model: 'claude-opus',
|
|
166
|
+
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
|
|
167
|
+
stopReason: 'toolUse',
|
|
168
|
+
timestamp: 1,
|
|
169
|
+
},
|
|
170
|
+
{
|
|
171
|
+
role: 'toolResult',
|
|
172
|
+
toolCallId: 'call-1',
|
|
173
|
+
toolName: 'bash',
|
|
174
|
+
content: [{ type: 'text', text: 'ok' }],
|
|
175
|
+
isError: false,
|
|
176
|
+
timestamp: 2,
|
|
177
|
+
},
|
|
178
|
+
{
|
|
179
|
+
role: 'toolResult',
|
|
180
|
+
toolCallId: 'orphan-1',
|
|
181
|
+
toolName: 'bash',
|
|
182
|
+
content: [{ type: 'text', text: 'orphan' }],
|
|
183
|
+
isError: false,
|
|
184
|
+
timestamp: 3,
|
|
185
|
+
},
|
|
186
|
+
];
|
|
187
|
+
|
|
188
|
+
const cleaned = stripOrphanedResults(messages);
|
|
189
|
+
expect(cleaned).toHaveLength(2);
|
|
190
|
+
expect(cleaned).not.toContainEqual(
|
|
191
|
+
expect.objectContaining({ toolCallId: 'orphan-1' })
|
|
192
|
+
);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it('removes toolCall blocks from assistant messages with orphaned IDs', () => {
|
|
196
|
+
const messages: Message[] = [
|
|
197
|
+
{
|
|
198
|
+
role: 'assistant',
|
|
199
|
+
content: [
|
|
200
|
+
{ type: 'toolCall', id: 'call-1', name: 'bash', arguments: {} },
|
|
201
|
+
{ type: 'toolCall', id: 'call-2', name: 'read', arguments: {} },
|
|
202
|
+
],
|
|
203
|
+
api: 'bedrock-converse-stream',
|
|
204
|
+
provider: 'amazon-bedrock',
|
|
205
|
+
model: 'claude-opus',
|
|
206
|
+
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
|
|
207
|
+
stopReason: 'toolUse',
|
|
208
|
+
timestamp: 1,
|
|
209
|
+
},
|
|
210
|
+
{
|
|
211
|
+
role: 'toolResult',
|
|
212
|
+
toolCallId: 'call-1',
|
|
213
|
+
toolName: 'bash',
|
|
214
|
+
content: [{ type: 'text', text: 'ok' }],
|
|
215
|
+
isError: false,
|
|
216
|
+
timestamp: 2,
|
|
217
|
+
},
|
|
218
|
+
];
|
|
219
|
+
|
|
220
|
+
const cleaned = stripOrphanedResults(messages);
|
|
221
|
+
expect(cleaned).toHaveLength(2);
|
|
222
|
+
const assistant = cleaned[0] as any;
|
|
223
|
+
expect(assistant.content).toHaveLength(1);
|
|
224
|
+
expect(assistant.content[0].id).toBe('call-1');
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('removes entire assistant message if all toolCalls are orphaned', () => {
|
|
228
|
+
const messages: Message[] = [
|
|
229
|
+
{
|
|
230
|
+
role: 'assistant',
|
|
231
|
+
content: [
|
|
232
|
+
{ type: 'toolCall', id: 'call-1', name: 'bash', arguments: {} },
|
|
233
|
+
],
|
|
234
|
+
api: 'bedrock-converse-stream',
|
|
235
|
+
provider: 'amazon-bedrock',
|
|
236
|
+
model: 'claude-opus',
|
|
237
|
+
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
|
|
238
|
+
stopReason: 'toolUse',
|
|
239
|
+
timestamp: 1,
|
|
240
|
+
},
|
|
241
|
+
];
|
|
242
|
+
|
|
243
|
+
const cleaned = stripOrphanedResults(messages);
|
|
244
|
+
expect(cleaned).toHaveLength(0);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it('keeps assistant message if it has text content despite orphaned toolCall', () => {
|
|
248
|
+
const messages: Message[] = [
|
|
249
|
+
{
|
|
250
|
+
role: 'assistant',
|
|
251
|
+
content: [
|
|
252
|
+
{ type: 'text', text: 'thinking...' },
|
|
253
|
+
{ type: 'toolCall', id: 'call-1', name: 'bash', arguments: {} },
|
|
254
|
+
],
|
|
255
|
+
api: 'bedrock-converse-stream',
|
|
256
|
+
provider: 'amazon-bedrock',
|
|
257
|
+
model: 'claude-opus',
|
|
258
|
+
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
|
|
259
|
+
stopReason: 'toolUse',
|
|
260
|
+
timestamp: 1,
|
|
261
|
+
},
|
|
262
|
+
];
|
|
263
|
+
|
|
264
|
+
const cleaned = stripOrphanedResults(messages);
|
|
265
|
+
expect(cleaned).toHaveLength(1);
|
|
266
|
+
const assistant = cleaned[0] as any;
|
|
267
|
+
expect(assistant.content).toHaveLength(1);
|
|
268
|
+
expect(assistant.content[0].type).toBe('text');
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it('returns original array if no orphans', () => {
|
|
272
|
+
const messages: Message[] = [
|
|
273
|
+
{ role: 'user', content: 'hello', timestamp: 1 },
|
|
274
|
+
];
|
|
275
|
+
const cleaned = stripOrphanedResults(messages);
|
|
276
|
+
expect(cleaned).toBe(messages); // Same reference
|
|
277
|
+
});
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
describe('validateAndRepair', () => {
|
|
281
|
+
it('returns wasRepaired=false for valid history', () => {
|
|
282
|
+
const messages: Message[] = [
|
|
283
|
+
{
|
|
284
|
+
role: 'assistant',
|
|
285
|
+
content: [
|
|
286
|
+
{ type: 'toolCall', id: 'call-1', name: 'bash', arguments: {} },
|
|
287
|
+
],
|
|
288
|
+
api: 'bedrock-converse-stream',
|
|
289
|
+
provider: 'amazon-bedrock',
|
|
290
|
+
model: 'claude-opus',
|
|
291
|
+
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
|
|
292
|
+
stopReason: 'toolUse',
|
|
293
|
+
timestamp: 1,
|
|
294
|
+
},
|
|
295
|
+
{
|
|
296
|
+
role: 'toolResult',
|
|
297
|
+
toolCallId: 'call-1',
|
|
298
|
+
toolName: 'bash',
|
|
299
|
+
content: [{ type: 'text', text: 'ok' }],
|
|
300
|
+
isError: false,
|
|
301
|
+
timestamp: 2,
|
|
302
|
+
},
|
|
303
|
+
];
|
|
304
|
+
|
|
305
|
+
const result = validateAndRepair(messages);
|
|
306
|
+
expect(result.wasRepaired).toBe(false);
|
|
307
|
+
expect(result.strippedCount).toBe(0);
|
|
308
|
+
expect(result.messages).toBe(messages);
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
it('repairs and reports orphaned calls + results', () => {
|
|
312
|
+
const messages: Message[] = [
|
|
313
|
+
{
|
|
314
|
+
role: 'assistant',
|
|
315
|
+
content: [
|
|
316
|
+
{ type: 'toolCall', id: 'call-1', name: 'bash', arguments: {} },
|
|
317
|
+
{ type: 'toolCall', id: 'orphan-call', name: 'read', arguments: {} },
|
|
318
|
+
],
|
|
319
|
+
api: 'bedrock-converse-stream',
|
|
320
|
+
provider: 'amazon-bedrock',
|
|
321
|
+
model: 'claude-opus',
|
|
322
|
+
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
|
|
323
|
+
stopReason: 'toolUse',
|
|
324
|
+
timestamp: 1,
|
|
325
|
+
},
|
|
326
|
+
{
|
|
327
|
+
role: 'toolResult',
|
|
328
|
+
toolCallId: 'call-1',
|
|
329
|
+
toolName: 'bash',
|
|
330
|
+
content: [{ type: 'text', text: 'ok' }],
|
|
331
|
+
isError: false,
|
|
332
|
+
timestamp: 2,
|
|
333
|
+
},
|
|
334
|
+
{
|
|
335
|
+
role: 'toolResult',
|
|
336
|
+
toolCallId: 'orphan-result',
|
|
337
|
+
toolName: 'bash',
|
|
338
|
+
content: [{ type: 'text', text: 'orphan' }],
|
|
339
|
+
isError: false,
|
|
340
|
+
timestamp: 3,
|
|
341
|
+
},
|
|
342
|
+
];
|
|
343
|
+
|
|
344
|
+
const result = validateAndRepair(messages);
|
|
345
|
+
expect(result.wasRepaired).toBe(true);
|
|
346
|
+
expect(result.strippedCount).toBe(2);
|
|
347
|
+
expect(result.strippedCallIds).toContain('orphan-call');
|
|
348
|
+
expect(result.strippedResultIds).toContain('orphan-result');
|
|
349
|
+
});
|
|
350
|
+
});
|
|
351
|
+
});
|