@intx/inference 0.1.2

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.
Files changed (43) hide show
  1. package/README.md +46 -0
  2. package/package.json +20 -0
  3. package/src/actions.ts +245 -0
  4. package/src/adapter.ts +57 -0
  5. package/src/assembly.test.ts +728 -0
  6. package/src/assembly.ts +250 -0
  7. package/src/audit-collector.test.ts +332 -0
  8. package/src/audit-collector.ts +172 -0
  9. package/src/auth.test.ts +117 -0
  10. package/src/auth.ts +61 -0
  11. package/src/authz-extension.test.ts +269 -0
  12. package/src/authz-extension.ts +145 -0
  13. package/src/correlation.ts +61 -0
  14. package/src/default-director.test.ts +314 -0
  15. package/src/default-director.ts +344 -0
  16. package/src/director.ts +87 -0
  17. package/src/errors.test.ts +133 -0
  18. package/src/errors.ts +115 -0
  19. package/src/gates.ts +128 -0
  20. package/src/harness.test.ts +655 -0
  21. package/src/harness.ts +1571 -0
  22. package/src/index.ts +76 -0
  23. package/src/providers/anthropic.test.ts +771 -0
  24. package/src/providers/anthropic.ts +810 -0
  25. package/src/providers/google-genai-files.ts +289 -0
  26. package/src/providers/google-genai.ts +1518 -0
  27. package/src/providers/openai.ts +719 -0
  28. package/src/providers/registry.ts +33 -0
  29. package/src/reactor.test.ts +3660 -0
  30. package/src/reactor.ts +1058 -0
  31. package/src/retry-policy.ts +99 -0
  32. package/src/scheduler.test.ts +41 -0
  33. package/src/sse.test.ts +133 -0
  34. package/src/sse.ts +76 -0
  35. package/src/state.ts +135 -0
  36. package/src/transform.test.ts +207 -0
  37. package/src/transform.ts +159 -0
  38. package/src/transforms/index.ts +2 -0
  39. package/src/transforms/size-cap.test.ts +172 -0
  40. package/src/transforms/size-cap.ts +110 -0
  41. package/src/turns.ts +54 -0
  42. package/tsconfig.json +4 -0
  43. package/tsconfig.tsbuildinfo +1 -0
@@ -0,0 +1,99 @@
1
+ // The default per-call mechanical retry policy. See `RetryPolicy` /
2
+ // `RetrySituation` / `RetryDecision` in `@intx/types/runtime` for the
3
+ // public contract. This module ships the opinionated defaults the
4
+ // harness substitutes when `InferenceOptions.retryPolicy` is omitted.
5
+ //
6
+ // The defaults are deliberately conservative: enough to absorb the
7
+ // transient-flake surface (TCP resets, 5xx, rate-limit jitter, half-
8
+ // streamed connection drops) without masking a genuinely persistent
9
+ // failure under a retry loop a human would never notice.
10
+
11
+ import type {
12
+ RetryPolicy,
13
+ RetrySituation,
14
+ RetryDecision,
15
+ } from "@intx/types/runtime";
16
+
17
+ const MAX_ATTEMPTS = 3;
18
+ // Indexed by the failed attempt number (1-indexed): the delay BEFORE
19
+ // the attempt-after-this-one starts. Length must be `MAX_ATTEMPTS - 1`
20
+ // because after the final attempt fails the policy aborts. Drives the
21
+ // `retryable` and `timeout` schedules.
22
+ const RETRYABLE_BACKOFF_BY_FAILED_ATTEMPT_MS: readonly number[] = [500, 1000];
23
+ const QUOTA_DEFAULT_DELAY_MS = 1000;
24
+
25
+ /**
26
+ * The default retry policy bundled with `@intx/inference`. Behaviour by
27
+ * `InferenceError.category`:
28
+ *
29
+ * - `credential_failure`, `context_overflow`, `fatal`, `aborted`,
30
+ * `protocol_mismatch` — never retry. These categories describe a
31
+ * deterministic per-call failure that re-issuing the identical
32
+ * request cannot resolve: bad credentials stay bad, a too-large
33
+ * context stays too large, a caller-driven abort is intentional,
34
+ * and a wire-shape mismatch will repeat on the next response.
35
+ * - `retryable`, `timeout` — up to 3 attempts total. 500ms before
36
+ * attempt 2, then 1000ms before attempt 3. Exponential rather than
37
+ * constant so a server taking longer than usual to recover gets a
38
+ * slightly larger window each time without compounding into a long
39
+ * tail.
40
+ * - `quota_exhausted` — up to 3 attempts total. The delay is taken
41
+ * from `error.retryAfterMs` when the provider returned one (the
42
+ * server told us when it would be ready); otherwise a flat
43
+ * `1000`ms baseline. The baseline does NOT grow across attempts —
44
+ * if 1s isn't long enough for a rate limit to clear, exponential
45
+ * backoff on top of the provider's own pacing instructions is more
46
+ * likely to mask a config problem than help. Operators who need
47
+ * exponential pacing for rate limits should supply a custom policy.
48
+ *
49
+ * The 3-attempt cap is the same across every retryable category: a
50
+ * single transient flake is plausible, two is rare, and a third
51
+ * failure across the backoff schedule is a real signal that the call
52
+ * is not going to succeed on its own.
53
+ */
54
+ export function createDefaultRetryPolicy(): RetryPolicy {
55
+ return (situation: RetrySituation): RetryDecision => {
56
+ const { error, attempt } = situation;
57
+
58
+ switch (error.category) {
59
+ case "credential_failure":
60
+ case "context_overflow":
61
+ case "fatal":
62
+ case "aborted":
63
+ case "protocol_mismatch":
64
+ return { kind: "abort" };
65
+
66
+ case "retryable":
67
+ case "timeout": {
68
+ if (attempt >= MAX_ATTEMPTS) return { kind: "abort" };
69
+ const delayMs = RETRYABLE_BACKOFF_BY_FAILED_ATTEMPT_MS[attempt - 1];
70
+ if (delayMs === undefined) {
71
+ // Unreachable in practice given the `attempt >= MAX_ATTEMPTS`
72
+ // guard above, but the explicit narrowing keeps the schedule
73
+ // table and the cap from drifting silently if anyone bumps
74
+ // `MAX_ATTEMPTS` without extending the table.
75
+ return { kind: "abort" };
76
+ }
77
+ return { kind: "retry", delayMs };
78
+ }
79
+
80
+ case "quota_exhausted":
81
+ if (attempt >= MAX_ATTEMPTS) return { kind: "abort" };
82
+ return {
83
+ kind: "retry",
84
+ delayMs: error.retryAfterMs ?? QUOTA_DEFAULT_DELAY_MS,
85
+ };
86
+
87
+ default: {
88
+ // Exhaustiveness: if a new InferenceError.category lands
89
+ // without a clause here, the never-assignment fails at
90
+ // compile time rather than silently returning undefined
91
+ // from the policy callback.
92
+ const exhaustive: never = error.category;
93
+ throw new Error(
94
+ `createDefaultRetryPolicy: unhandled error category ${String(exhaustive)}`,
95
+ );
96
+ }
97
+ }
98
+ };
99
+ }
@@ -0,0 +1,41 @@
1
+ // Tests for the `Scheduler` time source. The harness reads
2
+ // `scheduler.now()` to time deltas across multiple operations within a
3
+ // single call; the contract that matters is monotonicity and the same
4
+ // time domain as `setTimeout`. We do not assert specific values — the
5
+ // production default uses `performance.now()` which is sub-ms and
6
+ // floating-point.
7
+
8
+ import { describe, test, expect } from "bun:test";
9
+
10
+ import { createDefaultScheduler } from "./harness";
11
+
12
+ describe("createDefaultScheduler", () => {
13
+ test("now() returns a finite number", () => {
14
+ const scheduler = createDefaultScheduler();
15
+ const value = scheduler.now();
16
+ expect(Number.isFinite(value)).toBe(true);
17
+ });
18
+
19
+ test("now() is monotonic: a second read is not less than the first", () => {
20
+ const scheduler = createDefaultScheduler();
21
+ const a = scheduler.now();
22
+ const b = scheduler.now();
23
+ expect(b).toBeGreaterThanOrEqual(a);
24
+ });
25
+
26
+ test("now() advances across a setTimeout that fires", async () => {
27
+ const scheduler = createDefaultScheduler();
28
+ const before = scheduler.now();
29
+ await new Promise<void>((resolve) => {
30
+ scheduler.setTimeout(() => {
31
+ resolve();
32
+ }, 5);
33
+ });
34
+ const after = scheduler.now();
35
+ // Real wall-clock time elapsed across the await must be reflected
36
+ // in `now()` — the time domain of `setTimeout` and `now()` is the
37
+ // contract that lets the retry wrapper compute `elapsedMs` from
38
+ // two `now()` reads sandwiching a `setTimeout`-driven delay.
39
+ expect(after - before).toBeGreaterThanOrEqual(5);
40
+ });
41
+ });
@@ -0,0 +1,133 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import { parseSSE } from "./sse";
3
+
4
+ function makeStream(chunks: string[]): ReadableStream<Uint8Array> {
5
+ const encoder = new TextEncoder();
6
+ return new ReadableStream({
7
+ start(controller) {
8
+ for (const chunk of chunks) {
9
+ controller.enqueue(encoder.encode(chunk));
10
+ }
11
+ controller.close();
12
+ },
13
+ });
14
+ }
15
+
16
+ async function collectSSE(
17
+ stream: ReadableStream<Uint8Array>,
18
+ ): Promise<string[]> {
19
+ const results: string[] = [];
20
+ for await (const data of parseSSE(stream)) {
21
+ results.push(data);
22
+ }
23
+ return results;
24
+ }
25
+
26
+ describe("parseSSE", () => {
27
+ test("parses a single complete data line", async () => {
28
+ const stream = makeStream(["data: hello\n\n"]);
29
+ const results = await collectSSE(stream);
30
+ expect(results).toEqual(["hello"]);
31
+ });
32
+
33
+ test("parses multiple data events", async () => {
34
+ const stream = makeStream(["data: first\n\ndata: second\n\n"]);
35
+ const results = await collectSSE(stream);
36
+ expect(results).toEqual(["first", "second"]);
37
+ });
38
+
39
+ test("strips the optional space after data:", async () => {
40
+ const stream = makeStream(["data:nospace\n\ndata: withspace\n\n"]);
41
+ const results = await collectSSE(stream);
42
+ expect(results).toEqual(["nospace", "withspace"]);
43
+ });
44
+
45
+ test("skips comment lines", async () => {
46
+ const stream = makeStream([": this is a comment\ndata: payload\n\n"]);
47
+ const results = await collectSSE(stream);
48
+ expect(results).toEqual(["payload"]);
49
+ });
50
+
51
+ test("skips blank lines between events", async () => {
52
+ const stream = makeStream(["data: a\n\n\ndata: b\n\n"]);
53
+ const results = await collectSSE(stream);
54
+ expect(results).toEqual(["a", "b"]);
55
+ });
56
+
57
+ test("stops at [DONE] sentinel", async () => {
58
+ const stream = makeStream(["data: a\n\ndata: [DONE]\n\ndata: b\n\n"]);
59
+ const results = await collectSSE(stream);
60
+ expect(results).toEqual(["a"]);
61
+ });
62
+
63
+ test("handles CRLF line endings", async () => {
64
+ const stream = makeStream(["data: hello\r\n\r\n"]);
65
+ const results = await collectSSE(stream);
66
+ expect(results).toEqual(["hello"]);
67
+ });
68
+
69
+ test("handles data split across chunks", async () => {
70
+ // The data line is split mid-word across two chunks.
71
+ const stream = makeStream(["data: hel", "lo\n\n"]);
72
+ const results = await collectSSE(stream);
73
+ expect(results).toEqual(["hello"]);
74
+ });
75
+
76
+ test("handles newline split across chunks", async () => {
77
+ const stream = makeStream(["data: hello\n", "\n"]);
78
+ const results = await collectSSE(stream);
79
+ expect(results).toEqual(["hello"]);
80
+ });
81
+
82
+ test("handles many small chunks", async () => {
83
+ const stream = makeStream([
84
+ "d",
85
+ "a",
86
+ "t",
87
+ "a",
88
+ ":",
89
+ " ",
90
+ "t",
91
+ "o",
92
+ "k",
93
+ "e",
94
+ "n",
95
+ "\n",
96
+ "\n",
97
+ ]);
98
+ const results = await collectSSE(stream);
99
+ expect(results).toEqual(["token"]);
100
+ });
101
+
102
+ test("ignores non-data field lines", async () => {
103
+ const stream = makeStream([
104
+ "event: content_block_delta\ndata: payload\n\n",
105
+ ]);
106
+ const results = await collectSSE(stream);
107
+ expect(results).toEqual(["payload"]);
108
+ });
109
+
110
+ test("handles empty stream", async () => {
111
+ const stream = makeStream([]);
112
+ const results = await collectSSE(stream);
113
+ expect(results).toEqual([]);
114
+ });
115
+
116
+ test("handles stream with only comments", async () => {
117
+ const stream = makeStream([": ping\n: ping\n"]);
118
+ const results = await collectSSE(stream);
119
+ expect(results).toEqual([]);
120
+ });
121
+
122
+ test("parses JSON data payloads", async () => {
123
+ const stream = makeStream(['data: {"type":"text","text":"hello"}\n\n']);
124
+ const results = await collectSSE(stream);
125
+ expect(results).toEqual(['{"type":"text","text":"hello"}']);
126
+ });
127
+
128
+ test("handles multiple events in one chunk", async () => {
129
+ const stream = makeStream(["data: one\n\ndata: two\n\ndata: three\n\n"]);
130
+ const results = await collectSSE(stream);
131
+ expect(results).toEqual(["one", "two", "three"]);
132
+ });
133
+ });
package/src/sse.ts ADDED
@@ -0,0 +1,76 @@
1
+ // Server-Sent Events byte stream parser.
2
+ //
3
+ // Converts a ReadableStream<Uint8Array> (the raw HTTP response body) into an
4
+ // AsyncIterable<string> of SSE data payloads. Each yielded string is the
5
+ // value of one `data:` field. Comments (`:`) and blank-line separators are
6
+ // consumed internally. The `[DONE]` sentinel (OpenAI convention) terminates
7
+ // the iteration.
8
+ //
9
+ // The parser buffers incomplete lines across chunk boundaries so split chunks
10
+ // are handled correctly regardless of where chunk boundaries fall.
11
+
12
+ const decoder = new TextDecoder();
13
+
14
+ export async function* parseSSE(
15
+ stream: ReadableStream<Uint8Array>,
16
+ ): AsyncIterable<string> {
17
+ const reader = stream.getReader();
18
+ let buffer = "";
19
+
20
+ try {
21
+ while (true) {
22
+ const { done, value } = await reader.read();
23
+
24
+ if (done) {
25
+ // Flush any remaining content in the buffer as a final line.
26
+ if (buffer.length > 0) {
27
+ const payload = extractDataPayload(buffer);
28
+ if (payload !== null) {
29
+ yield payload;
30
+ }
31
+ }
32
+ break;
33
+ }
34
+
35
+ buffer += decoder.decode(value, { stream: true });
36
+
37
+ // Process all complete lines (lines terminated by \n).
38
+ // A line ending in \r\n counts as terminated at the \n.
39
+ let newlineIndex: number;
40
+ while ((newlineIndex = buffer.indexOf("\n")) !== -1) {
41
+ const rawLine = buffer.slice(0, newlineIndex);
42
+ buffer = buffer.slice(newlineIndex + 1);
43
+
44
+ // Strip trailing \r for CRLF line endings.
45
+ const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
46
+
47
+ // Blank lines and comment lines are ignored.
48
+ if (line === "" || line.startsWith(":")) {
49
+ continue;
50
+ }
51
+
52
+ const payload = extractDataPayload(line);
53
+ if (payload === null) {
54
+ continue;
55
+ }
56
+
57
+ if (payload === "[DONE]") {
58
+ return;
59
+ }
60
+
61
+ yield payload;
62
+ }
63
+ }
64
+ } finally {
65
+ reader.releaseLock();
66
+ }
67
+ }
68
+
69
+ function extractDataPayload(line: string): string | null {
70
+ if (line.startsWith("data:")) {
71
+ // The spec allows an optional space after the colon.
72
+ const raw = line.slice(5);
73
+ return raw.startsWith(" ") ? raw.slice(1) : raw;
74
+ }
75
+ return null;
76
+ }
package/src/state.ts ADDED
@@ -0,0 +1,135 @@
1
+ // Reactor state management: turn history, async operations, usage tracking.
2
+ //
3
+ // The state object is the authoritative view the director receives on every
4
+ // decision. It is mutable by the reactor only — the director receives a
5
+ // snapshot so it cannot corrupt the reactor's internal state.
6
+ //
7
+ // (INFERENCE.md § Agent Reactor › Director Decision Function)
8
+
9
+ import type {
10
+ ConversationTurn,
11
+ LastCycleSource,
12
+ PendingOperation,
13
+ TokenUsage,
14
+ ReactorState,
15
+ } from "@intx/types/runtime";
16
+ import type { GateSnapshot } from "./gates";
17
+
18
+ export type ReactorStateManager = ReturnType<typeof createStateManager>;
19
+
20
+ /**
21
+ * Creates a mutable state container. All mutations go through explicit methods;
22
+ * the `snapshot()` method produces an immutable view for the director.
23
+ */
24
+ export function createStateManager(
25
+ sessionId: string,
26
+ initialTurns: ConversationTurn[],
27
+ initialOps: PendingOperation[],
28
+ initialUsage: TokenUsage,
29
+ ) {
30
+ let turns: ConversationTurn[] = [...initialTurns];
31
+ const pendingOperations = new Map<string, PendingOperation>(
32
+ initialOps.map((op) => [op.correlationId, op]),
33
+ );
34
+ const tokenUsage: TokenUsage = { ...initialUsage };
35
+ let lastCycleUsage: TokenUsage | null = null;
36
+ let lastCycleSource: LastCycleSource | null = null;
37
+ let activeGatesSnapshot: GateSnapshot[] = [];
38
+ const activeForks: { forkId: string; mode: "independent" | "child" }[] = [];
39
+
40
+ function appendTurn(msg: ConversationTurn): void {
41
+ turns.push(msg);
42
+ }
43
+
44
+ function replaceTurns(next: ConversationTurn[]): void {
45
+ turns = [...next];
46
+ }
47
+
48
+ function addPendingOperation(op: PendingOperation): void {
49
+ pendingOperations.set(op.correlationId, op);
50
+ }
51
+
52
+ function removePendingOperation(correlationId: string): void {
53
+ pendingOperations.delete(correlationId);
54
+ }
55
+
56
+ function accumUsage(usage: TokenUsage): void {
57
+ tokenUsage.input += usage.input;
58
+ tokenUsage.output += usage.output;
59
+ tokenUsage.cacheRead += usage.cacheRead;
60
+ tokenUsage.cacheWrite += usage.cacheWrite;
61
+ tokenUsage.thinking += usage.thinking;
62
+ }
63
+
64
+ function setLastCycleUsage(usage: TokenUsage): void {
65
+ lastCycleUsage = { ...usage };
66
+ }
67
+
68
+ function setLastCycleSource(source: LastCycleSource): void {
69
+ lastCycleSource = { ...source };
70
+ }
71
+
72
+ function setGatesSnapshot(gates: GateSnapshot[]): void {
73
+ activeGatesSnapshot = gates;
74
+ }
75
+
76
+ function addFork(forkId: string, mode: "independent" | "child"): void {
77
+ activeForks.push({ forkId, mode });
78
+ }
79
+
80
+ function removeFork(forkId: string): void {
81
+ const idx = activeForks.findIndex((f) => f.forkId === forkId);
82
+ if (idx !== -1) activeForks.splice(idx, 1);
83
+ }
84
+
85
+ function getTurns(): ConversationTurn[] {
86
+ return turns;
87
+ }
88
+
89
+ function getPendingOperations(): PendingOperation[] {
90
+ return Array.from(pendingOperations.values());
91
+ }
92
+
93
+ function getTokenUsage(): TokenUsage {
94
+ return { ...tokenUsage };
95
+ }
96
+
97
+ function snapshot(): ReactorState {
98
+ return {
99
+ sessionId,
100
+ turns: turns.map((m) => ({
101
+ ...m,
102
+ content: m.content.map((b) => structuredClone(b)),
103
+ })),
104
+ pendingOperations: Array.from(pendingOperations.values()).map((op) =>
105
+ structuredClone(op),
106
+ ),
107
+ activeGates: activeGatesSnapshot.map((g) => ({
108
+ gateId: g.gateId,
109
+ type: g.type,
110
+ timeoutAt: g.timeoutAt,
111
+ })),
112
+ activeForks: activeForks.map((f) => ({ ...f })),
113
+ tokenUsage: { ...tokenUsage },
114
+ lastCycleUsage: lastCycleUsage !== null ? { ...lastCycleUsage } : null,
115
+ lastCycleSource: lastCycleSource !== null ? { ...lastCycleSource } : null,
116
+ };
117
+ }
118
+
119
+ return {
120
+ appendTurn,
121
+ replaceTurns,
122
+ addPendingOperation,
123
+ removePendingOperation,
124
+ accumUsage,
125
+ setLastCycleUsage,
126
+ setLastCycleSource,
127
+ setGatesSnapshot,
128
+ addFork,
129
+ removeFork,
130
+ getTurns,
131
+ getPendingOperations,
132
+ getTokenUsage,
133
+ snapshot,
134
+ };
135
+ }
@@ -0,0 +1,207 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import { transformMessages, createIDNormalizer } from "./transform";
3
+ import type { ConversationTurn } from "@intx/types/runtime";
4
+
5
+ describe("transformMessages", () => {
6
+ test("preserves messages when target model matches originating model", () => {
7
+ const messages: ConversationTurn[] = [
8
+ {
9
+ role: "assistant",
10
+ model: "claude-3-5-sonnet",
11
+ content: [
12
+ { type: "thinking", thinking: "Let me think..." },
13
+ { type: "text", text: "Here is the answer." },
14
+ ],
15
+ timestamp: 1000,
16
+ },
17
+ ];
18
+
19
+ const result = transformMessages(messages, {
20
+ targetModel: "claude-3-5-sonnet",
21
+ keepThinkingForSameModel: true,
22
+ });
23
+
24
+ expect(result).toHaveLength(1);
25
+ const firstMsg = result[0];
26
+ expect(firstMsg?.content).toHaveLength(2);
27
+ expect(firstMsg?.content[0]?.type).toBe("thinking");
28
+ });
29
+
30
+ test("strips thinking blocks when replaying to a different model", () => {
31
+ const messages: ConversationTurn[] = [
32
+ {
33
+ role: "assistant",
34
+ model: "claude-3-5-sonnet",
35
+ content: [
36
+ { type: "thinking", thinking: "Some reasoning..." },
37
+ { type: "text", text: "Answer." },
38
+ ],
39
+ timestamp: 1000,
40
+ },
41
+ ];
42
+
43
+ const result = transformMessages(messages, {
44
+ targetModel: "gpt-4o",
45
+ });
46
+
47
+ expect(result).toHaveLength(1);
48
+ const firstMsg = result[0];
49
+ expect(firstMsg?.content).toHaveLength(1);
50
+ expect(firstMsg?.content[0]?.type).toBe("text");
51
+ });
52
+
53
+ test("strips thinking blocks when keepThinkingForSameModel is false", () => {
54
+ const messages: ConversationTurn[] = [
55
+ {
56
+ role: "assistant",
57
+ model: "claude-3-5-sonnet",
58
+ content: [
59
+ { type: "thinking", thinking: "Reasoning..." },
60
+ { type: "text", text: "Answer." },
61
+ ],
62
+ timestamp: 1000,
63
+ },
64
+ ];
65
+
66
+ const result = transformMessages(messages, {
67
+ targetModel: "claude-3-5-sonnet",
68
+ keepThinkingForSameModel: false,
69
+ });
70
+
71
+ const firstMsg = result[0];
72
+ expect(firstMsg?.content).toHaveLength(1);
73
+ expect(firstMsg?.content[0]?.type).toBe("text");
74
+ });
75
+
76
+ test("injects synthetic tool results for orphaned tool calls", () => {
77
+ const messages: ConversationTurn[] = [
78
+ {
79
+ role: "user",
80
+ content: [{ type: "text", text: "Do something." }],
81
+ timestamp: 1000,
82
+ },
83
+ {
84
+ role: "assistant",
85
+ content: [
86
+ {
87
+ type: "tool_call",
88
+ id: "call_1",
89
+ name: "read_file",
90
+ arguments: { path: "/tmp/foo" },
91
+ },
92
+ ],
93
+ timestamp: 1000,
94
+ },
95
+ // No tool result follows — the conversation was interrupted.
96
+ ];
97
+
98
+ const result = transformMessages(messages, { targetModel: "gpt-4o" });
99
+
100
+ // Should inject a synthetic tool result message.
101
+ expect(result).toHaveLength(3);
102
+ const injected = result[2];
103
+ expect(injected?.role).toBe("user");
104
+ expect(injected?.content[0]?.type).toBe("tool_result");
105
+ const toolResult = injected?.content[0];
106
+ if (toolResult?.type === "tool_result") {
107
+ expect(toolResult.callId).toBe("call_1");
108
+ expect(toolResult.isError).toBe(true);
109
+ }
110
+ });
111
+
112
+ test("does not inject when tool results are present", () => {
113
+ const messages: ConversationTurn[] = [
114
+ {
115
+ role: "user",
116
+ content: [{ type: "text", text: "Do something." }],
117
+ timestamp: 1000,
118
+ },
119
+ {
120
+ role: "assistant",
121
+ content: [
122
+ {
123
+ type: "tool_call",
124
+ id: "call_1",
125
+ name: "read_file",
126
+ arguments: { path: "/tmp/foo" },
127
+ },
128
+ ],
129
+ timestamp: 1000,
130
+ },
131
+ {
132
+ role: "user",
133
+ content: [
134
+ {
135
+ type: "tool_result",
136
+ callId: "call_1",
137
+ content: [{ type: "text", text: "file contents" }],
138
+ },
139
+ ],
140
+ timestamp: 1000,
141
+ },
142
+ ];
143
+
144
+ const result = transformMessages(messages, { targetModel: "gpt-4o" });
145
+ expect(result).toHaveLength(3);
146
+ });
147
+
148
+ test("preserves user and system messages unchanged", () => {
149
+ const messages: ConversationTurn[] = [
150
+ {
151
+ role: "system",
152
+ content: [{ type: "text", text: "You are helpful." }],
153
+ timestamp: 1000,
154
+ },
155
+ {
156
+ role: "user",
157
+ content: [{ type: "text", text: "Hello." }],
158
+ timestamp: 1000,
159
+ },
160
+ ];
161
+
162
+ const result = transformMessages(messages, { targetModel: "gpt-4o" });
163
+ expect(result).toHaveLength(2);
164
+ expect(result[0]).toEqual(messages[0]);
165
+ expect(result[1]).toEqual(messages[1]);
166
+ });
167
+ });
168
+
169
+ describe("createIDNormalizer", () => {
170
+ test("assigns stable portable IDs", () => {
171
+ const norm = createIDNormalizer();
172
+ const id1 = norm.normalize(
173
+ "call_abc_very_long_id_from_openai_responses_api",
174
+ );
175
+ const id2 = norm.normalize(
176
+ "call_abc_very_long_id_from_openai_responses_api",
177
+ );
178
+ expect(id1).toBe(id2);
179
+ expect(id1.startsWith("tc_")).toBe(true);
180
+ });
181
+
182
+ test("assigns different IDs to different provider IDs", () => {
183
+ const norm = createIDNormalizer();
184
+ const id1 = norm.normalize("toolu_01A");
185
+ const id2 = norm.normalize("toolu_02B");
186
+ expect(id1).not.toBe(id2);
187
+ });
188
+
189
+ test("resolves a portable ID back to the provider ID", () => {
190
+ const norm = createIDNormalizer();
191
+ const providerId = "toolu_01AbCdEfGhIjKlMnOpQrStUvWx";
192
+ const portable = norm.normalize(providerId);
193
+ expect(norm.resolve(portable)).toBe(providerId);
194
+ });
195
+
196
+ test("resolve returns undefined for unknown portable ID", () => {
197
+ const norm = createIDNormalizer();
198
+ expect(norm.resolve("tc_unknown")).toBeUndefined();
199
+ });
200
+
201
+ test("IDs are monotonically increasing", () => {
202
+ const norm = createIDNormalizer();
203
+ const ids = ["a", "b", "c"].map((x) => norm.normalize(x));
204
+ // All different.
205
+ expect(new Set(ids).size).toBe(3);
206
+ });
207
+ });