@intx/inference 0.1.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/LICENSE +176 -0
  2. package/dist/actions.d.ts +16 -0
  3. package/dist/actions.js +200 -0
  4. package/dist/adapter.d.ts +40 -0
  5. package/dist/adapter.js +31 -0
  6. package/dist/assembly.d.ts +75 -0
  7. package/dist/assembly.js +133 -0
  8. package/dist/audit-collector.d.ts +10 -0
  9. package/dist/audit-collector.js +139 -0
  10. package/dist/auth.d.ts +24 -0
  11. package/{src/auth.ts → dist/auth.js} +13 -19
  12. package/dist/authz-extension.d.ts +46 -0
  13. package/dist/authz-extension.js +184 -0
  14. package/dist/correlation.d.ts +26 -0
  15. package/dist/correlation.js +39 -0
  16. package/dist/default-director.d.ts +111 -0
  17. package/dist/default-director.js +228 -0
  18. package/dist/director.d.ts +6 -0
  19. package/dist/director.js +56 -0
  20. package/dist/errors.d.ts +18 -0
  21. package/dist/errors.js +83 -0
  22. package/dist/gates.d.ts +28 -0
  23. package/dist/gates.js +103 -0
  24. package/dist/harness.d.ts +147 -0
  25. package/dist/harness.js +1407 -0
  26. package/dist/index.d.ts +37 -0
  27. package/dist/index.js +21 -0
  28. package/dist/manifest.d.ts +31 -0
  29. package/dist/manifest.js +44 -0
  30. package/dist/providers/anthropic.d.ts +37 -0
  31. package/dist/providers/anthropic.js +917 -0
  32. package/dist/providers/google-genai-files.d.ts +48 -0
  33. package/dist/providers/google-genai-files.js +205 -0
  34. package/dist/providers/google-genai.d.ts +5 -0
  35. package/dist/providers/google-genai.js +1205 -0
  36. package/dist/providers/index.d.ts +38 -0
  37. package/dist/providers/index.js +56 -0
  38. package/dist/providers/openai.d.ts +9 -0
  39. package/dist/providers/openai.js +903 -0
  40. package/dist/reactor.d.ts +50 -0
  41. package/dist/reactor.js +1233 -0
  42. package/dist/retry-policy.d.ts +31 -0
  43. package/{src/retry-policy.ts → dist/retry-policy.js} +41 -53
  44. package/dist/sse.d.ts +1 -0
  45. package/dist/sse.js +63 -0
  46. package/dist/state.d.ts +23 -0
  47. package/dist/state.js +100 -0
  48. package/dist/tool-name.d.ts +6 -0
  49. package/dist/tool-name.js +110 -0
  50. package/dist/transform.d.ts +11 -0
  51. package/dist/transform.js +132 -0
  52. package/dist/transforms/index.d.ts +2 -0
  53. package/dist/transforms/index.js +1 -0
  54. package/dist/transforms/size-cap.d.ts +12 -0
  55. package/dist/transforms/size-cap.js +80 -0
  56. package/dist/turns.d.ts +21 -0
  57. package/dist/turns.js +135 -0
  58. package/package.json +22 -6
  59. package/src/actions.ts +0 -245
  60. package/src/adapter.ts +0 -57
  61. package/src/assembly.test.ts +0 -728
  62. package/src/assembly.ts +0 -250
  63. package/src/audit-collector.test.ts +0 -332
  64. package/src/audit-collector.ts +0 -172
  65. package/src/auth.test.ts +0 -117
  66. package/src/authz-extension.test.ts +0 -269
  67. package/src/authz-extension.ts +0 -145
  68. package/src/correlation.ts +0 -61
  69. package/src/default-director.test.ts +0 -314
  70. package/src/default-director.ts +0 -344
  71. package/src/director.ts +0 -87
  72. package/src/errors.test.ts +0 -133
  73. package/src/errors.ts +0 -115
  74. package/src/gates.ts +0 -128
  75. package/src/harness.test.ts +0 -655
  76. package/src/harness.ts +0 -1571
  77. package/src/index.ts +0 -76
  78. package/src/providers/anthropic.test.ts +0 -771
  79. package/src/providers/anthropic.ts +0 -810
  80. package/src/providers/google-genai-files.ts +0 -289
  81. package/src/providers/google-genai.ts +0 -1518
  82. package/src/providers/openai.ts +0 -719
  83. package/src/providers/registry.ts +0 -33
  84. package/src/reactor.test.ts +0 -3660
  85. package/src/reactor.ts +0 -1058
  86. package/src/scheduler.test.ts +0 -41
  87. package/src/sse.test.ts +0 -133
  88. package/src/sse.ts +0 -76
  89. package/src/state.ts +0 -135
  90. package/src/transform.test.ts +0 -207
  91. package/src/transform.ts +0 -159
  92. package/src/transforms/index.ts +0 -2
  93. package/src/transforms/size-cap.test.ts +0 -172
  94. package/src/transforms/size-cap.ts +0 -110
  95. package/src/turns.ts +0 -54
  96. package/tsconfig.json +0 -4
  97. package/tsconfig.tsbuildinfo +0 -1
@@ -1,3660 +0,0 @@
1
- import { describe, test, expect } from "bun:test";
2
-
3
- import { validateActions } from "./actions";
4
- import { createGateManager } from "./gates";
5
- import { createCorrelationRegistry } from "./correlation";
6
- import { createReactor } from "./reactor";
7
- import { createDefaultDependencies } from "./harness";
8
- import { createInboundMessage } from "@intx/mime";
9
-
10
- import type {
11
- ReactorDirector,
12
- ReactorAction,
13
- ReactorInboundEvent,
14
- ReactorState,
15
- ReactorCapabilities,
16
- ContextStore,
17
- ToolRunner,
18
- InferenceEvent,
19
- InboundMessage,
20
- ConversationTurn,
21
- PendingOperation,
22
- TokenUsage,
23
- ContextCommit,
24
- AssistantTurn,
25
- InferenceError,
26
- PartialMessage,
27
- BeforeToolExtension,
28
- Compactor,
29
- LastCycleSource,
30
- } from "@intx/types/runtime";
31
-
32
- import type { ReactorConfig, Reactor, ReactorEmittedEvent } from "./reactor";
33
- import type { Dependencies, InferenceHarnessOptions } from "./harness";
34
- import type { CorrelationValidator } from "./correlation";
35
-
36
- // ---------------------------------------------------------------------------
37
- // Helpers
38
- // ---------------------------------------------------------------------------
39
-
40
- function emptyUsage(): TokenUsage {
41
- return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, thinking: 0 };
42
- }
43
-
44
- const TEST_SOURCE: LastCycleSource = {
45
- sourceId: "test-source",
46
- provider: "test-provider",
47
- model: "test-model",
48
- };
49
-
50
- function makeContextStore(turns: ConversationTurn[] = []): ContextStore {
51
- async function commit(
52
- options: { message: string },
53
- _signal?: AbortSignal,
54
- ): Promise<ContextCommit> {
55
- return { hash: "abc", message: options.message, timestamp: Date.now() };
56
- }
57
-
58
- return {
59
- async load() {
60
- return {
61
- turns,
62
- pendingOperations: [],
63
- tokenUsage: emptyUsage(),
64
- connectorState: null,
65
- };
66
- },
67
- setConnectorState() {
68
- /* noop */
69
- },
70
- commit,
71
- async branch() {
72
- /* noop */
73
- },
74
- async log() {
75
- return [];
76
- },
77
- async readAt() {
78
- return [];
79
- },
80
- async writeBlob() {
81
- /* noop */
82
- },
83
- async readBlob() {
84
- throw new Error("not implemented");
85
- },
86
- async writePrompt() {
87
- /* noop */
88
- },
89
- async writeResponse() {
90
- /* noop */
91
- },
92
- async writeManifest() {
93
- /* noop */
94
- },
95
- async writeTurns() {
96
- /* noop */
97
- },
98
- async writeMetadata() {
99
- /* noop */
100
- },
101
- async readManifestHistory() {
102
- throw new Error("not implemented");
103
- },
104
- };
105
- }
106
-
107
- function makeToolRunner(
108
- handler: (call: {
109
- id: string;
110
- name: string;
111
- arguments: Record<string, unknown>;
112
- }) => Promise<{
113
- callId: string;
114
- content: string;
115
- isError?: boolean;
116
- }>,
117
- ): ToolRunner {
118
- return {
119
- async run(call, _signal) {
120
- return handler(call);
121
- },
122
- };
123
- }
124
-
125
- function noopToolRunner(): ToolRunner {
126
- return {
127
- async run(call) {
128
- return { callId: call.id, content: "ok" };
129
- },
130
- };
131
- }
132
-
133
- function collectEvents(): {
134
- events: ReactorEmittedEvent[];
135
- onEvent: (e: ReactorEmittedEvent) => void;
136
- } {
137
- const events: ReactorEmittedEvent[] = [];
138
- return {
139
- events,
140
- onEvent: (e: ReactorEmittedEvent) => events.push(e),
141
- };
142
- }
143
-
144
- function waitForEvent(
145
- events: ReactorEmittedEvent[],
146
- predicate: (e: ReactorEmittedEvent) => boolean,
147
- timeoutMs = 2000,
148
- ): Promise<ReactorEmittedEvent> {
149
- return new Promise((resolve, reject) => {
150
- const deadline = setTimeout(
151
- () => reject(new Error("Timed out waiting for event")),
152
- timeoutMs,
153
- );
154
-
155
- function check() {
156
- const found = events.find(predicate);
157
- if (found !== undefined) {
158
- clearTimeout(deadline);
159
- resolve(found);
160
- return;
161
- }
162
- setTimeout(check, 10);
163
- }
164
- check();
165
- });
166
- }
167
-
168
- // Simple inbound message factory. Delegates to the mail-builder so the
169
- // reactor tests exercise the same shape consumers of @intx/mime
170
- // produce in production code.
171
- function makeInboundMessage(correlationId?: string): InboundMessage {
172
- return createInboundMessage({
173
- from: "test@example.com",
174
- to: "agent@example.com",
175
- content: "hello",
176
- ...(correlationId !== undefined ? { correlationId } : {}),
177
- });
178
- }
179
-
180
- // ---------------------------------------------------------------------------
181
- // Test harness helpers
182
- // ---------------------------------------------------------------------------
183
-
184
- let testSessionCounter = 0;
185
-
186
- type DirectorHandler<E> = (
187
- event: E,
188
- state: ReactorState,
189
- caps: ReactorCapabilities,
190
- ) => ReactorAction | ReactorAction[] | Promise<ReactorAction | ReactorAction[]>;
191
-
192
- type DirectorTable = {
193
- [K in ReactorInboundEvent["type"]]?: DirectorHandler<
194
- Extract<ReactorInboundEvent, { type: K }>
195
- >;
196
- };
197
-
198
- function directorFromTable(
199
- table: DirectorTable,
200
- defaultAction: "done" | "wait" = "done",
201
- ): ReactorDirector {
202
- return {
203
- async decide(event, state, caps) {
204
- // TypeScript cannot correlate the runtime key with the mapped type's
205
- // per-key handler signature (correlated union problem): table[event.type]
206
- // is typed as a union of all handlers, but we know it matches this event.
207
- // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- correlated union: table[event.type] is guaranteed to be typed for this event.type by the DirectorTable mapped type
208
- const handler = table[event.type] as
209
- | DirectorHandler<typeof event>
210
- | undefined;
211
- if (handler !== undefined) {
212
- return handler(event, state, caps);
213
- }
214
- return defaultAction === "done" ? caps.done() : caps.wait();
215
- },
216
- };
217
- }
218
-
219
- type TestReactorOverrides = {
220
- director?: ReactorDirector;
221
- toolRunner?: ToolRunner;
222
- contextStore?: ContextStore;
223
- correlationValidator?: CorrelationValidator;
224
- inferenceRunner?: (
225
- opts: InferenceHarnessOptions,
226
- ) => AsyncGenerator<InferenceEvent>;
227
- beforeToolExtensions?: BeforeToolExtension[];
228
- afterCheckpoint?: () => Promise<void>;
229
- onShutdown?: () => Promise<void>;
230
- sessionId?: string;
231
- gateTimeout?: number;
232
- shutdownTimeoutMs?: number;
233
- // Wire-level tests (see tests/inference/reactor-streaming.test.ts) supply
234
- // their own `Dependencies` (fetch stubbed by the harness) and may target a
235
- // non-Anthropic provider. Both override hooks are optional; omit them for
236
- // tests that don't care about the inference HTTP path.
237
- deps?: Dependencies;
238
- source?: ReactorConfig["source"];
239
- compactors?: Record<string, Compactor>;
240
- };
241
-
242
- type TestReactorHandle = {
243
- reactor: Reactor;
244
- events: ReactorEmittedEvent[];
245
- waitFor: (
246
- type: ReactorEmittedEvent["type"],
247
- timeoutMs?: number,
248
- ) => Promise<ReactorEmittedEvent>;
249
- };
250
-
251
- function createTestReactor(
252
- overrides: TestReactorOverrides = {},
253
- ): TestReactorHandle {
254
- const { events, onEvent } = collectEvents();
255
- const sessionId = overrides.sessionId ?? `test-sess-${++testSessionCounter}`;
256
-
257
- const config: ReactorConfig = {
258
- sessionId,
259
- director: overrides.director ?? directorFromTable({}),
260
- source: overrides.source ?? {
261
- id: "anthropic:test-model",
262
- provider: "anthropic",
263
- baseURL: "https://api.anthropic.com",
264
- apiKey: "test",
265
- model: "test-model",
266
- },
267
- toolRunner: overrides.toolRunner ?? noopToolRunner(),
268
- contextStore: overrides.contextStore ?? makeContextStore(),
269
- onEvent,
270
- deps: overrides.deps ?? createDefaultDependencies(),
271
- shutdownTimeoutMs: overrides.shutdownTimeoutMs ?? 100,
272
- ...(overrides.correlationValidator !== undefined
273
- ? { correlationValidator: overrides.correlationValidator }
274
- : {}),
275
- ...(overrides.inferenceRunner !== undefined
276
- ? { inferenceRunner: overrides.inferenceRunner }
277
- : {}),
278
- ...(overrides.beforeToolExtensions !== undefined
279
- ? { beforeToolExtensions: overrides.beforeToolExtensions }
280
- : {}),
281
- ...(overrides.afterCheckpoint !== undefined
282
- ? { afterCheckpoint: overrides.afterCheckpoint }
283
- : {}),
284
- ...(overrides.onShutdown !== undefined
285
- ? { onShutdown: overrides.onShutdown }
286
- : {}),
287
- ...(overrides.gateTimeout !== undefined
288
- ? { gateTimeout: overrides.gateTimeout }
289
- : {}),
290
- ...(overrides.compactors !== undefined
291
- ? { compactors: overrides.compactors }
292
- : {}),
293
- };
294
-
295
- const reactor = createReactor(config);
296
-
297
- function waitFor(
298
- type: ReactorEmittedEvent["type"],
299
- timeoutMs = 2000,
300
- ): Promise<ReactorEmittedEvent> {
301
- return waitForEvent(events, (e) => e.type === type, timeoutMs);
302
- }
303
-
304
- return { reactor, events, waitFor };
305
- }
306
-
307
- function failingContextStore(error: Error): ContextStore {
308
- const fail = () => {
309
- throw new Error("failingContextStore: should not be called");
310
- };
311
- return {
312
- async load() {
313
- throw error;
314
- },
315
- setConnectorState() {
316
- return fail();
317
- },
318
- async commit() {
319
- return fail();
320
- },
321
- async branch() {
322
- return fail();
323
- },
324
- async log() {
325
- return fail();
326
- },
327
- async readAt() {
328
- return fail();
329
- },
330
- async writeBlob() {
331
- return fail();
332
- },
333
- async readBlob() {
334
- return fail();
335
- },
336
- async writePrompt() {
337
- return fail();
338
- },
339
- async writeResponse() {
340
- return fail();
341
- },
342
- async writeManifest() {
343
- return fail();
344
- },
345
- async writeTurns() {
346
- return fail();
347
- },
348
- async writeMetadata() {
349
- return fail();
350
- },
351
- async readManifestHistory() {
352
- return fail();
353
- },
354
- };
355
- }
356
-
357
- function throwingToolRunner(error: Error): ToolRunner {
358
- return {
359
- async run() {
360
- throw error;
361
- },
362
- };
363
- }
364
-
365
- function getEvent<T extends ReactorEmittedEvent["type"]>(
366
- events: ReactorEmittedEvent[],
367
- type: T,
368
- ): Extract<ReactorEmittedEvent, { type: T }> {
369
- const found = events.find(
370
- (e): e is Extract<ReactorEmittedEvent, { type: T }> => e.type === type,
371
- );
372
- if (found === undefined) {
373
- throw new Error(`No event of type '${type}' found`);
374
- }
375
- return found;
376
- }
377
-
378
- // ---------------------------------------------------------------------------
379
- // 1. Action validation
380
- // ---------------------------------------------------------------------------
381
-
382
- describe("validateActions", () => {
383
- test("single infer action is valid", () => {
384
- const result = validateActions({ type: "infer" });
385
- expect(result.ok).toBe(true);
386
- if (!result.ok) throw new Error("unreachable");
387
- expect(result.normalized.some((a) => a.type === "infer")).toBe(true);
388
- });
389
-
390
- test("single done action is valid", () => {
391
- const result = validateActions({ type: "done" });
392
- expect(result.ok).toBe(true);
393
- });
394
-
395
- test("single execute_tools is valid", () => {
396
- const result = validateActions({
397
- type: "execute_tools",
398
- calls: [{ id: "c1", name: "tool", arguments: {} }],
399
- });
400
- expect(result.ok).toBe(true);
401
- });
402
-
403
- test("checkpoint + infer is valid (composable)", () => {
404
- const result = validateActions([
405
- { type: "checkpoint", message: "checkpoint" },
406
- { type: "infer" },
407
- ]);
408
- expect(result.ok).toBe(true);
409
- });
410
-
411
- test("checkpoint message is preserved in normalized output", () => {
412
- const result = validateActions({
413
- type: "checkpoint",
414
- message: "before tool call",
415
- });
416
- expect(result.ok).toBe(true);
417
- if (!result.ok) throw new Error("unreachable");
418
- const cp = result.normalized.find((a) => a.type === "checkpoint");
419
- expect(cp).toBeDefined();
420
- if (cp?.type !== "checkpoint") throw new Error("unreachable");
421
- expect(cp.message).toBe("before tool call");
422
- });
423
-
424
- test("multiple checkpoint actions are rejected", () => {
425
- const result = validateActions([
426
- { type: "checkpoint", message: "first" },
427
- { type: "checkpoint", message: "second" },
428
- ]);
429
- expect(result.ok).toBe(false);
430
- if (result.ok) throw new Error("unreachable");
431
- expect(result.error).toMatch(/multiple checkpoint/i);
432
- });
433
-
434
- test("emit + infer is valid", () => {
435
- const result = validateActions([
436
- { type: "emit", eventType: "custom.progress", data: { pct: 50 } },
437
- { type: "infer" },
438
- ]);
439
- expect(result.ok).toBe(true);
440
- });
441
-
442
- test("multiple execute_tools are collapsed into one", () => {
443
- const result = validateActions([
444
- {
445
- type: "execute_tools",
446
- calls: [{ id: "c1", name: "a", arguments: {} }],
447
- },
448
- {
449
- type: "execute_tools",
450
- calls: [{ id: "c2", name: "b", arguments: {} }],
451
- },
452
- ]);
453
- expect(result.ok).toBe(true);
454
- if (!result.ok) throw new Error("unreachable");
455
- const toolActions = result.normalized.filter(
456
- (a) => a.type === "execute_tools",
457
- );
458
- expect(toolActions.length).toBe(1);
459
- const ta = toolActions[0];
460
- if (ta === undefined || ta.type !== "execute_tools")
461
- throw new Error("unreachable");
462
- expect(ta.calls.length).toBe(2);
463
- });
464
-
465
- test("infer + done is invalid", () => {
466
- const result = validateActions([{ type: "infer" }, { type: "done" }]);
467
- expect(result.ok).toBe(false);
468
- if (result.ok) throw new Error("unreachable");
469
- expect(result.error).toMatch(/infer.*done/i);
470
- });
471
-
472
- test("multiple infer actions are invalid", () => {
473
- const result = validateActions([{ type: "infer" }, { type: "infer" }]);
474
- expect(result.ok).toBe(false);
475
- });
476
-
477
- test("suspend + infer is invalid", () => {
478
- const result = validateActions([
479
- { type: "infer" },
480
- {
481
- type: "suspend",
482
- gate: {
483
- type: "approval",
484
- gateId: "g1",
485
- timeoutMs: 60000,
486
- },
487
- },
488
- ]);
489
- expect(result.ok).toBe(false);
490
- if (result.ok) throw new Error("unreachable");
491
- expect(result.error).toMatch(/suspend.*infer/i);
492
- });
493
-
494
- test("suspend + execute_tools is invalid", () => {
495
- const result = validateActions([
496
- {
497
- type: "execute_tools",
498
- calls: [{ id: "c1", name: "t", arguments: {} }],
499
- },
500
- {
501
- type: "suspend",
502
- gate: {
503
- type: "payment",
504
- gateId: "g2",
505
- timeoutMs: 60000,
506
- },
507
- },
508
- ]);
509
- expect(result.ok).toBe(false);
510
- });
511
-
512
- test("empty action list is valid (no-op)", () => {
513
- const result = validateActions([]);
514
- expect(result.ok).toBe(true);
515
- if (result.ok) {
516
- expect(result.normalized).toEqual([]);
517
- }
518
- });
519
-
520
- test("suspend alone is valid", () => {
521
- const result = validateActions({
522
- type: "suspend",
523
- gate: { type: "approval", gateId: "g1", timeoutMs: 60000 },
524
- });
525
- expect(result.ok).toBe(true);
526
- });
527
-
528
- test("wait action is included in normalized output", () => {
529
- const result = validateActions({ type: "wait" });
530
- expect(result.ok).toBe(true);
531
- if (!result.ok) throw new Error("unreachable");
532
- expect(result.normalized.length).toBe(1);
533
- expect(result.normalized[0]?.type).toBe("wait");
534
- });
535
-
536
- test("multiple wait actions are invalid", () => {
537
- const result = validateActions([{ type: "wait" }, { type: "wait" }]);
538
- expect(result.ok).toBe(false);
539
- if (result.ok) throw new Error("unreachable");
540
- expect(result.error).toMatch(/multiple.*wait/i);
541
- });
542
-
543
- test("wait + infer is invalid", () => {
544
- const result = validateActions([{ type: "wait" }, { type: "infer" }]);
545
- expect(result.ok).toBe(false);
546
- if (result.ok) throw new Error("unreachable");
547
- expect(result.error).toMatch(/wait.*infer/i);
548
- });
549
-
550
- test("wait + execute_tools is invalid", () => {
551
- const result = validateActions([
552
- { type: "wait" },
553
- {
554
- type: "execute_tools",
555
- calls: [{ id: "c1", name: "t", arguments: {} }],
556
- },
557
- ]);
558
- expect(result.ok).toBe(false);
559
- if (result.ok) throw new Error("unreachable");
560
- expect(result.error).toMatch(/wait.*execute_tools/i);
561
- });
562
-
563
- test("wait + suspend is invalid", () => {
564
- const result = validateActions([
565
- { type: "wait" },
566
- {
567
- type: "suspend",
568
- gate: { type: "approval", gateId: "g1", timeoutMs: 60000 },
569
- },
570
- ]);
571
- expect(result.ok).toBe(false);
572
- if (result.ok) throw new Error("unreachable");
573
- expect(result.error).toMatch(/wait.*suspend/i);
574
- });
575
-
576
- test("wait + reply is invalid", () => {
577
- const result = validateActions([
578
- { type: "wait" },
579
- { type: "reply", content: "hello" },
580
- ]);
581
- expect(result.ok).toBe(false);
582
- if (result.ok) throw new Error("unreachable");
583
- expect(result.error).toMatch(/wait.*reply/i);
584
- });
585
-
586
- test("wait + done is invalid", () => {
587
- const result = validateActions([{ type: "wait" }, { type: "done" }]);
588
- expect(result.ok).toBe(false);
589
- if (result.ok) throw new Error("unreachable");
590
- expect(result.error).toMatch(/wait.*done/i);
591
- });
592
-
593
- test("reply + infer is invalid", () => {
594
- const result = validateActions([
595
- { type: "reply", content: "hello" },
596
- { type: "infer" },
597
- ]);
598
- expect(result.ok).toBe(false);
599
- if (result.ok) throw new Error("unreachable");
600
- expect(result.error).toMatch(/reply.*infer/i);
601
- });
602
-
603
- test("reply + execute_tools is invalid", () => {
604
- const result = validateActions([
605
- { type: "reply", content: "hello" },
606
- {
607
- type: "execute_tools",
608
- calls: [{ id: "c1", name: "t", arguments: {} }],
609
- },
610
- ]);
611
- expect(result.ok).toBe(false);
612
- if (result.ok) throw new Error("unreachable");
613
- expect(result.error).toMatch(/reply.*execute_tools/i);
614
- });
615
-
616
- test("multiple done actions are invalid", () => {
617
- const result = validateActions([{ type: "done" }, { type: "done" }]);
618
- expect(result.ok).toBe(false);
619
- if (result.ok) throw new Error("unreachable");
620
- expect(result.error).toMatch(/multiple.*done/i);
621
- });
622
-
623
- test("multiple reply actions are invalid", () => {
624
- const result = validateActions([
625
- { type: "reply", content: "a" },
626
- { type: "reply", content: "b" },
627
- ]);
628
- expect(result.ok).toBe(false);
629
- if (result.ok) throw new Error("unreachable");
630
- expect(result.error).toMatch(/multiple.*reply/i);
631
- });
632
-
633
- test("multiple suspend actions are invalid", () => {
634
- const result = validateActions([
635
- {
636
- type: "suspend",
637
- gate: { type: "approval", gateId: "g1", timeoutMs: 60000 },
638
- },
639
- {
640
- type: "suspend",
641
- gate: { type: "payment", gateId: "g2", timeoutMs: 60000 },
642
- },
643
- ]);
644
- expect(result.ok).toBe(false);
645
- if (result.ok) throw new Error("unreachable");
646
- expect(result.error).toMatch(/multiple.*suspend/i);
647
- });
648
-
649
- test("duplicate fork IDs are invalid", () => {
650
- const result = validateActions([
651
- { type: "fork", mode: "independent", forkId: "f1" },
652
- { type: "fork", mode: "child", forkId: "f1" },
653
- ]);
654
- expect(result.ok).toBe(false);
655
- if (result.ok) throw new Error("unreachable");
656
- expect(result.error).toMatch(/duplicate.*fork/i);
657
- });
658
-
659
- test("reply + done is invalid", () => {
660
- const result = validateActions([
661
- { type: "reply", content: "goodbye" },
662
- { type: "done" },
663
- ]);
664
- expect(result.ok).toBe(false);
665
- if (result.ok) throw new Error("unreachable");
666
- expect(result.error).toMatch(/reply.*done/i);
667
- });
668
-
669
- test("reply + suspend is invalid", () => {
670
- const result = validateActions([
671
- { type: "reply", content: "hang on" },
672
- {
673
- type: "suspend",
674
- gate: { type: "approval", gateId: "g1", timeoutMs: 60000 },
675
- },
676
- ]);
677
- expect(result.ok).toBe(false);
678
- if (result.ok) throw new Error("unreachable");
679
- expect(result.error).toMatch(/reply.*suspend/i);
680
- });
681
- });
682
-
683
- // ---------------------------------------------------------------------------
684
- // 2. Gate lifecycle
685
- // ---------------------------------------------------------------------------
686
-
687
- describe("createGateManager", () => {
688
- test("register and clear a gate", async () => {
689
- const manager = createGateManager();
690
- const cleared: string[] = [];
691
-
692
- const promise = manager.register(
693
- "gate-1",
694
- "approval",
695
- 5000,
696
- undefined,
697
- (id, reason) => cleared.push(`${id}:${reason}`),
698
- );
699
-
700
- const didClear = manager.clear("gate-1");
701
- expect(didClear).toBe(true);
702
-
703
- const reason = await promise;
704
- expect(reason).toBe("resolved");
705
- expect(cleared).toEqual(["gate-1:resolved"]);
706
- });
707
-
708
- test("gate timeout fires with reason=timeout", async () => {
709
- const manager = createGateManager();
710
- const cleared: string[] = [];
711
-
712
- const promise = manager.register(
713
- "gate-2",
714
- "approval",
715
- 50,
716
- undefined,
717
- (id, reason) => cleared.push(`${id}:${reason}`),
718
- );
719
-
720
- const reason = await promise;
721
- expect(reason).toBe("timeout");
722
- expect(cleared).toEqual(["gate-2:timeout"]);
723
- });
724
-
725
- test("shutdown clears all gates with reason=shutdown", async () => {
726
- const manager = createGateManager();
727
- const cleared: string[] = [];
728
-
729
- const p1 = manager.register(
730
- "g1",
731
- "approval",
732
- 60000,
733
- undefined,
734
- (id, reason) => cleared.push(`${id}:${reason}`),
735
- );
736
- const p2 = manager.register(
737
- "g2",
738
- "payment",
739
- 60000,
740
- undefined,
741
- (id, reason) => cleared.push(`${id}:${reason}`),
742
- );
743
-
744
- manager.shutdown();
745
-
746
- const [r1, r2] = await Promise.all([p1, p2]);
747
- expect(r1).toBe("shutdown");
748
- expect(r2).toBe("shutdown");
749
- expect(cleared.sort()).toEqual(["g1:shutdown", "g2:shutdown"].sort());
750
- });
751
-
752
- test("clearing a nonexistent gate returns false", () => {
753
- const manager = createGateManager();
754
- expect(manager.clear("no-such-gate")).toBe(false);
755
- });
756
-
757
- test("zero timeout throws", () => {
758
- const manager = createGateManager();
759
- expect(() =>
760
- manager.register("g", "approval", 0, undefined, () => {
761
- /* noop */
762
- }),
763
- ).toThrow();
764
- });
765
-
766
- test("duplicate gate ID throws", () => {
767
- const manager = createGateManager();
768
- manager.register("g", "approval", 5000, undefined, () => {
769
- /* noop */
770
- });
771
- expect(() =>
772
- manager.register("g", "approval", 5000, undefined, () => {
773
- /* noop */
774
- }),
775
- ).toThrow();
776
- });
777
-
778
- test("findByCorrelationId returns the gate", () => {
779
- const manager = createGateManager();
780
- manager.register("g-corr", "message_response", 5000, "corr-42", () => {
781
- /* noop */
782
- });
783
- const found = manager.findByCorrelationId("corr-42");
784
- expect(found?.gateId).toBe("g-corr");
785
-
786
- const notFound = manager.findByCorrelationId("no-match");
787
- expect(notFound).toBeUndefined();
788
- });
789
- });
790
-
791
- // ---------------------------------------------------------------------------
792
- // 3. Correlation registry
793
- // ---------------------------------------------------------------------------
794
-
795
- describe("createCorrelationRegistry", () => {
796
- function op(id: string): PendingOperation {
797
- return { correlationId: id, registeredAt: Date.now(), gateId: `g-${id}` };
798
- }
799
-
800
- test("register and lookup", () => {
801
- const reg = createCorrelationRegistry();
802
- reg.register(op("abc"));
803
- const found = reg.lookup("abc");
804
- expect(found?.correlationId).toBe("abc");
805
- });
806
-
807
- test("lookup nonexistent returns undefined", () => {
808
- const reg = createCorrelationRegistry();
809
- expect(reg.lookup("missing")).toBeUndefined();
810
- });
811
-
812
- test("remove returns true for existing entry", () => {
813
- const reg = createCorrelationRegistry();
814
- reg.register(op("x"));
815
- expect(reg.remove("x")).toBe(true);
816
- expect(reg.lookup("x")).toBeUndefined();
817
- });
818
-
819
- test("remove returns false for nonexistent", () => {
820
- const reg = createCorrelationRegistry();
821
- expect(reg.remove("ghost")).toBe(false);
822
- });
823
-
824
- test("duplicate registration throws", () => {
825
- const reg = createCorrelationRegistry();
826
- reg.register(op("dup"));
827
- expect(() => reg.register(op("dup"))).toThrow();
828
- });
829
-
830
- test("all() returns all operations", () => {
831
- const reg = createCorrelationRegistry();
832
- reg.register(op("a"));
833
- reg.register(op("b"));
834
- const all = reg.all();
835
- expect(all.map((o) => o.correlationId).sort()).toEqual(["a", "b"]);
836
- });
837
- });
838
-
839
- // ---------------------------------------------------------------------------
840
- // 4. Reactor loop — basic flow
841
- // ---------------------------------------------------------------------------
842
-
843
- describe("createReactor — basic flow", () => {
844
- test("message.received → done emits reactor.done", async () => {
845
- const { reactor, events, waitFor } = createTestReactor();
846
-
847
- reactor.start();
848
- reactor.deliver(makeInboundMessage());
849
- await waitFor("reactor.done");
850
-
851
- const types = events.map((e) => e.type);
852
- expect(types).toContain("reactor.start");
853
- expect(types).toContain("message.received");
854
- expect(types).toContain("reactor.done");
855
- });
856
-
857
- test("reactor.start is the first event", async () => {
858
- const { reactor, events, waitFor } = createTestReactor();
859
-
860
- reactor.start();
861
- reactor.deliver(makeInboundMessage());
862
- await waitFor("reactor.done");
863
-
864
- expect(events[0]?.type).toBe("reactor.start");
865
- });
866
-
867
- test("sequence numbers are monotonically increasing", async () => {
868
- const { reactor, events, waitFor } = createTestReactor({
869
- director: directorFromTable({
870
- "message.received": (_e, _s, caps) => [
871
- caps.emit("custom.test", { x: 1 }),
872
- caps.done(),
873
- ],
874
- }),
875
- });
876
-
877
- reactor.start();
878
- reactor.deliver(makeInboundMessage());
879
- await waitFor("reactor.done");
880
-
881
- const seqs = events.map((e) => e.seq);
882
- for (let i = 1; i < seqs.length; i++) {
883
- const prev = seqs[i - 1];
884
- const curr = seqs[i];
885
- if (prev === undefined || curr === undefined) continue;
886
- expect(curr).toBeGreaterThan(prev);
887
- }
888
- });
889
- });
890
-
891
- // ---------------------------------------------------------------------------
892
- // 5. Director exception handling
893
- // ---------------------------------------------------------------------------
894
-
895
- describe("createReactor — director exception", () => {
896
- test("director exception emits reactor.error and shuts down", async () => {
897
- const { events, onEvent } = collectEvents();
898
-
899
- const director: ReactorDirector = {
900
- async decide() {
901
- throw new Error("director blew up");
902
- },
903
- };
904
-
905
- const reactor = createReactor({
906
- sessionId: "sess-err",
907
- director,
908
- source: {
909
- id: "anthropic:test-model",
910
- provider: "anthropic",
911
- baseURL: "https://api.anthropic.com",
912
- apiKey: "test",
913
- model: "test-model",
914
- },
915
- toolRunner: noopToolRunner(),
916
- contextStore: makeContextStore(),
917
- onEvent,
918
- deps: createDefaultDependencies(),
919
- shutdownTimeoutMs: 100,
920
- });
921
-
922
- reactor.start();
923
- reactor.deliver(makeInboundMessage());
924
-
925
- await waitForEvent(events, (e) => e.type === "reactor.error");
926
-
927
- const errorEvent = events.find((e) => e.type === "reactor.error");
928
- if (errorEvent === undefined || errorEvent.type !== "reactor.error") {
929
- throw new Error("expected reactor.error");
930
- }
931
- expect(errorEvent.data.error).toMatch(/director blew up/);
932
- expect(errorEvent.data.fatal).toBe(true);
933
- });
934
- });
935
-
936
- // ---------------------------------------------------------------------------
937
- // 6. Gate lifecycle via reactor
938
- // ---------------------------------------------------------------------------
939
-
940
- describe("createReactor — gate lifecycle", () => {
941
- test("suspend registers gate and reactor shuts down on abort", async () => {
942
- const { reactor, events, waitFor } = createTestReactor({
943
- director: directorFromTable({
944
- "message.received": (_e, _s, caps) =>
945
- caps.suspend({
946
- type: "approval",
947
- gateId: "test-gate",
948
- timeoutMs: 5000,
949
- }),
950
- "reactor.gate.cleared": (_e, _s, caps) => caps.done(),
951
- }),
952
- });
953
-
954
- reactor.start();
955
- reactor.deliver(makeInboundMessage());
956
-
957
- await waitFor("reactor.gate.blocked");
958
-
959
- // Abort the reactor — gate clears with reason "shutdown".
960
- reactor.abort("admin_kill");
961
-
962
- await waitFor("reactor.done");
963
-
964
- const blocked = getEvent(events, "reactor.gate.blocked");
965
- expect(blocked.data.reason).toBe("approval");
966
- expect(blocked.data.gateId).toBe("test-gate");
967
-
968
- const cleared = getEvent(events, "reactor.gate.cleared");
969
- expect(cleared.data.gateId).toBe("test-gate");
970
- expect(cleared.data.reason).toBe("shutdown");
971
- });
972
-
973
- test("gate timeout fires reactor.gate.cleared with reason=timeout", async () => {
974
- const { reactor, events, waitFor } = createTestReactor({
975
- shutdownTimeoutMs: 500,
976
- director: directorFromTable({
977
- "message.received": (_e, _s, caps) =>
978
- caps.suspend({
979
- type: "approval",
980
- gateId: "timeout-gate",
981
- timeoutMs: 80,
982
- }),
983
- "reactor.gate.cleared": (_e, _s, caps) => caps.done(),
984
- }),
985
- });
986
-
987
- reactor.start();
988
- reactor.deliver(makeInboundMessage());
989
-
990
- await waitFor("reactor.done", 3000);
991
-
992
- const cleared = getEvent(events, "reactor.gate.cleared");
993
- expect(cleared.data.reason).toBe("timeout");
994
- });
995
-
996
- test("gate with timeoutMs: 0 falls back to session-level gateTimeout", async () => {
997
- const { reactor, events, waitFor } = createTestReactor({
998
- gateTimeout: 80,
999
- director: directorFromTable({
1000
- "message.received": (_e, _s, caps) =>
1001
- caps.suspend({
1002
- type: "approval",
1003
- gateId: "fallback-gate",
1004
- timeoutMs: 0,
1005
- }),
1006
- "reactor.gate.cleared": (_e, _s, caps) => caps.done(),
1007
- }),
1008
- });
1009
-
1010
- reactor.start();
1011
- reactor.deliver(makeInboundMessage());
1012
-
1013
- await waitFor("reactor.done");
1014
-
1015
- const cleared = getEvent(events, "reactor.gate.cleared");
1016
- expect(cleared.data.reason).toBe("timeout");
1017
- expect(cleared.data.gateId).toBe("fallback-gate");
1018
- });
1019
- });
1020
-
1021
- // ---------------------------------------------------------------------------
1022
- // 7. Tool execution
1023
- // ---------------------------------------------------------------------------
1024
-
1025
- describe("createReactor — tool execution", () => {
1026
- test("execute_tools dispatches tools and returns results", async () => {
1027
- const toolsRun: string[] = [];
1028
- const { reactor, events, waitFor } = createTestReactor({
1029
- director: directorFromTable({
1030
- "message.received": (_e, _s, caps) =>
1031
- caps.executeTools(
1032
- [
1033
- { id: "c1", name: "tool_a", arguments: {} },
1034
- { id: "c2", name: "tool_b", arguments: {} },
1035
- ],
1036
- true,
1037
- ),
1038
- "tool.done": (_e, _s, caps) => caps.done(),
1039
- }),
1040
- toolRunner: makeToolRunner(async (call) => {
1041
- toolsRun.push(call.name);
1042
- return { callId: call.id, content: `result-${call.name}` };
1043
- }),
1044
- });
1045
-
1046
- reactor.start();
1047
- reactor.deliver(makeInboundMessage());
1048
- await waitFor("reactor.done");
1049
-
1050
- expect(toolsRun.sort()).toEqual(["tool_a", "tool_b"].sort());
1051
-
1052
- const toolStarts = events.filter((e) => e.type === "tool.start");
1053
- const toolDones = events.filter((e) => e.type === "tool.done");
1054
- expect(toolStarts.length).toBe(2);
1055
- expect(toolDones.length).toBe(2);
1056
- });
1057
- });
1058
-
1059
- // ---------------------------------------------------------------------------
1060
- // 8. Correlation matching
1061
- // ---------------------------------------------------------------------------
1062
-
1063
- describe("createReactor — correlation", () => {
1064
- test("message with matching correlationId triggers message.correlated", async () => {
1065
- const CORR_ID = "corr-xyz-123";
1066
- let delivered = false;
1067
-
1068
- // Multi-phase state machine: deliver → execute_tools → suspend with
1069
- // correlation → deliver correlated response → done. Kept as raw director
1070
- // because the `delivered` flag makes directorFromTable awkward.
1071
- const director: ReactorDirector = {
1072
- async decide(event, _state, caps) {
1073
- if (event.type === "message.received" && !delivered) {
1074
- delivered = true;
1075
- return caps.executeTools([
1076
- { id: "tc1", name: "send_message", arguments: {} },
1077
- ]);
1078
- }
1079
- if (event.type === "tool.done") {
1080
- return caps.suspend({
1081
- type: "message_response",
1082
- gateId: "msg-gate",
1083
- timeoutMs: 5000,
1084
- correlationId: CORR_ID,
1085
- });
1086
- }
1087
- if (event.type === "reactor.gate.cleared") {
1088
- return caps.done();
1089
- }
1090
- return caps.done();
1091
- },
1092
- };
1093
-
1094
- const { reactor, events, waitFor } = createTestReactor({
1095
- director,
1096
- shutdownTimeoutMs: 500,
1097
- toolRunner: makeToolRunner(async (call) => {
1098
- return {
1099
- callId: call.id,
1100
- content: "message sent",
1101
- pendingMarker: {
1102
- status: "pending" as const,
1103
- correlationId: CORR_ID,
1104
- },
1105
- };
1106
- }),
1107
- });
1108
-
1109
- reactor.start();
1110
- reactor.deliver(makeInboundMessage());
1111
-
1112
- await waitFor("reactor.gate.blocked");
1113
-
1114
- // Deliver the correlated response.
1115
- reactor.deliver(makeInboundMessage(CORR_ID));
1116
-
1117
- await waitFor("reactor.done", 3000);
1118
-
1119
- const correlated = getEvent(events, "message.correlated");
1120
- expect(correlated.data.correlationId).toBe(CORR_ID);
1121
- expect(correlated.data.message.headers.interchangeCorrelationId).toBe(
1122
- CORR_ID,
1123
- );
1124
- });
1125
-
1126
- test("message with non-matching correlationId passes through uncorrelated", async () => {
1127
- const { reactor, events, waitFor } = createTestReactor({
1128
- director: directorFromTable({
1129
- "message.received": (_e, _s, caps) => caps.done(),
1130
- }),
1131
- });
1132
-
1133
- reactor.start();
1134
- reactor.deliver(makeInboundMessage());
1135
-
1136
- await waitFor("reactor.done");
1137
-
1138
- expect(events.some((e) => e.type === "message.correlated")).toBe(false);
1139
- expect(events.some((e) => e.type === "message.received")).toBe(true);
1140
- });
1141
- });
1142
-
1143
- // ---------------------------------------------------------------------------
1144
- // 9. Test harness helper validation
1145
- // ---------------------------------------------------------------------------
1146
-
1147
- describe("test harness helpers", () => {
1148
- test("createTestReactor produces a working reactor with defaults", async () => {
1149
- const { reactor, events, waitFor } = createTestReactor({
1150
- director: directorFromTable({
1151
- "message.received": (_e, _s, caps) => caps.done(),
1152
- }),
1153
- });
1154
-
1155
- reactor.start();
1156
- reactor.deliver(makeInboundMessage());
1157
- await waitFor("reactor.done");
1158
-
1159
- expect(events[0]?.type).toBe("reactor.start");
1160
- const done = getEvent(events, "reactor.done");
1161
- expect(done.type).toBe("reactor.done");
1162
- });
1163
-
1164
- test("failingContextStore rejects on load", async () => {
1165
- const store = failingContextStore(new Error("disk on fire"));
1166
- let thrown: Error | undefined;
1167
- try {
1168
- await store.load();
1169
- } catch (cause) {
1170
- thrown = cause instanceof Error ? cause : new Error(String(cause));
1171
- }
1172
- expect(thrown?.message).toContain("disk on fire");
1173
- });
1174
-
1175
- test("throwingToolRunner throws on run", async () => {
1176
- const runner = throwingToolRunner(new Error("tool exploded"));
1177
- const signal = new AbortController().signal;
1178
- let thrown: Error | undefined;
1179
- try {
1180
- await runner.run({ id: "c1", name: "t", arguments: {} }, signal);
1181
- } catch (cause) {
1182
- thrown = cause instanceof Error ? cause : new Error(String(cause));
1183
- }
1184
- expect(thrown?.message).toContain("tool exploded");
1185
- });
1186
-
1187
- test("getEvent throws when event is missing", () => {
1188
- const events: ReactorEmittedEvent[] = [];
1189
- expect(() => getEvent(events, "reactor.error")).toThrow(
1190
- "No event of type 'reactor.error' found",
1191
- );
1192
- });
1193
-
1194
- test("directorFromTable with wait default falls through on unhandled events", async () => {
1195
- // Suspend produces a reactor.gate.cleared event. That event type is
1196
- // NOT in the table, so the defaultAction "wait" fallthrough fires.
1197
- // The reactor stays alive because of the fallthrough, then a second
1198
- // message triggers done via the table handler.
1199
- let messageCount = 0;
1200
- const { reactor, events, waitFor } = createTestReactor({
1201
- director: directorFromTable(
1202
- {
1203
- "message.received": (_e, _s, caps) => {
1204
- messageCount++;
1205
- if (messageCount >= 2) return caps.done();
1206
- return caps.suspend({
1207
- type: "approval",
1208
- gateId: "wait-test-gate",
1209
- timeoutMs: 50,
1210
- });
1211
- },
1212
- // reactor.gate.cleared is intentionally omitted from the table.
1213
- // The defaultAction "wait" keeps the reactor alive when it fires.
1214
- },
1215
- "wait",
1216
- ),
1217
- });
1218
-
1219
- reactor.start();
1220
- reactor.deliver(makeInboundMessage());
1221
-
1222
- // Wait for the gate to time out and clear. The reactor.gate.cleared
1223
- // event hits the fallthrough, which returns caps.wait().
1224
- await waitFor("reactor.gate.cleared");
1225
-
1226
- // Reactor is still alive because of the wait fallthrough. Deliver
1227
- // another message to trigger done.
1228
- reactor.deliver(makeInboundMessage());
1229
- await waitFor("reactor.done");
1230
-
1231
- expect(messageCount).toBe(2);
1232
- expect(events.some((e) => e.type === "reactor.gate.blocked")).toBe(true);
1233
- });
1234
- });
1235
-
1236
- // ---------------------------------------------------------------------------
1237
- // 10. Context store failures
1238
- // ---------------------------------------------------------------------------
1239
-
1240
- describe("createReactor — context store failures", () => {
1241
- test("context store load failure emits reactor.error and reactor.done without reactor.start", async () => {
1242
- const { reactor, events, waitFor } = createTestReactor({
1243
- contextStore: failingContextStore(new Error("disk on fire")),
1244
- });
1245
-
1246
- reactor.start();
1247
- await waitFor("reactor.done");
1248
-
1249
- const errorEvent = getEvent(events, "reactor.error");
1250
- expect(errorEvent.data.error).toMatch(/disk on fire/);
1251
- expect(errorEvent.data.fatal).toBe(true);
1252
-
1253
- // reactor.start must NOT be emitted — load failed before the loop began.
1254
- expect(events.some((e) => e.type === "reactor.start")).toBe(false);
1255
-
1256
- // reactor.done must still be emitted for cleanup listeners.
1257
- expect(events.some((e) => e.type === "reactor.done")).toBe(true);
1258
- });
1259
- });
1260
-
1261
- // ---------------------------------------------------------------------------
1262
- // 11. Tool runner failures
1263
- // ---------------------------------------------------------------------------
1264
-
1265
- describe("createReactor — tool runner failures", () => {
1266
- test("tool runner that throws triggers fatal error and shutdown", async () => {
1267
- const { reactor, events, waitFor } = createTestReactor({
1268
- director: directorFromTable({
1269
- "message.received": (_e, _s, caps) =>
1270
- caps.executeTools([{ id: "c1", name: "boom", arguments: {} }]),
1271
- }),
1272
- toolRunner: throwingToolRunner(new Error("tool exploded")),
1273
- });
1274
-
1275
- reactor.start();
1276
- reactor.deliver(makeInboundMessage());
1277
- await waitFor("reactor.done");
1278
-
1279
- const errorEvent = getEvent(events, "reactor.error");
1280
- expect(errorEvent.data.error).toMatch(
1281
- /^Internal reactor error:.*tool exploded/,
1282
- );
1283
- expect(errorEvent.data.fatal).toBe(true);
1284
- });
1285
-
1286
- test("tool runner returning isError propagates error result to director", async () => {
1287
- const { reactor, events, waitFor } = createTestReactor({
1288
- director: directorFromTable({
1289
- "message.received": (_e, _s, caps) =>
1290
- caps.executeTools([{ id: "c1", name: "bad_tool", arguments: {} }]),
1291
- "tool.done": (_e, _s, caps) => caps.done(),
1292
- }),
1293
- toolRunner: makeToolRunner(async (call) => {
1294
- return {
1295
- callId: call.id,
1296
- content: "something went wrong",
1297
- isError: true,
1298
- };
1299
- }),
1300
- });
1301
-
1302
- reactor.start();
1303
- reactor.deliver(makeInboundMessage());
1304
- await waitFor("reactor.done");
1305
-
1306
- // The tool.done event should carry the error result with isError flag.
1307
- const toolDone = getEvent(events, "tool.done");
1308
- expect(toolDone.data.result.isError).toBe(true);
1309
- expect(toolDone.data.result.content).toBe("something went wrong");
1310
- // No reactor.error should be emitted — isError is a normal result, not a crash.
1311
- expect(events.some((e) => e.type === "reactor.error")).toBe(false);
1312
- });
1313
-
1314
- test("sequential tool execution runs tools in order", async () => {
1315
- const order: string[] = [];
1316
- const { reactor, waitFor } = createTestReactor({
1317
- director: directorFromTable({
1318
- "message.received": (_e, _s, caps) =>
1319
- caps.executeTools(
1320
- [
1321
- { id: "c1", name: "first", arguments: {} },
1322
- { id: "c2", name: "second", arguments: {} },
1323
- { id: "c3", name: "third", arguments: {} },
1324
- ],
1325
- false,
1326
- ),
1327
- "tool.done": (_e, _s, caps) => caps.done(),
1328
- }),
1329
- toolRunner: makeToolRunner(async (call) => {
1330
- order.push(call.name);
1331
- return { callId: call.id, content: "ok" };
1332
- }),
1333
- });
1334
-
1335
- reactor.start();
1336
- reactor.deliver(makeInboundMessage());
1337
- await waitFor("reactor.done");
1338
-
1339
- // Order must be preserved — not sorted.
1340
- expect(order).toEqual(["first", "second", "third"]);
1341
- });
1342
-
1343
- test("addToHistory=false runs tools but skips history append", async () => {
1344
- // We observe history indirectly via the reactor's cycle commit, which now
1345
- // writes through contextStore.writeTurns just before commit({ message }).
1346
- let committedTurns: ConversationTurn[] = [];
1347
- const capturingStore: ContextStore = {
1348
- async load() {
1349
- return {
1350
- turns: [],
1351
- pendingOperations: [],
1352
- tokenUsage: emptyUsage(),
1353
- connectorState: null,
1354
- };
1355
- },
1356
- setConnectorState() {
1357
- /* noop */
1358
- },
1359
- async commit(options) {
1360
- return { hash: "abc", message: options.message, timestamp: Date.now() };
1361
- },
1362
- async branch() {
1363
- /* noop */
1364
- },
1365
- async log() {
1366
- return [];
1367
- },
1368
- async readAt() {
1369
- return [];
1370
- },
1371
- async writeBlob() {
1372
- throw new Error("not implemented");
1373
- },
1374
- async readBlob() {
1375
- throw new Error("not implemented");
1376
- },
1377
- async writePrompt() {
1378
- throw new Error("not implemented");
1379
- },
1380
- async writeResponse() {
1381
- throw new Error("not implemented");
1382
- },
1383
- async writeManifest() {
1384
- /* noop */
1385
- },
1386
- async writeTurns(turns) {
1387
- committedTurns = [...turns];
1388
- },
1389
- async writeMetadata() {
1390
- /* noop */
1391
- },
1392
- async readManifestHistory() {
1393
- throw new Error("not implemented");
1394
- },
1395
- };
1396
-
1397
- const { reactor, waitFor } = createTestReactor({
1398
- contextStore: capturingStore,
1399
- director: directorFromTable({
1400
- "message.received": (_e, _s, caps) =>
1401
- caps.executeTools(
1402
- [{ id: "c1", name: "ghost_tool", arguments: {} }],
1403
- true,
1404
- false,
1405
- ),
1406
- "tool.done": (_e, _s, caps) => [caps.checkpoint(), caps.done()],
1407
- }),
1408
- });
1409
-
1410
- reactor.start();
1411
- reactor.deliver(makeInboundMessage());
1412
- await waitFor("reactor.done");
1413
-
1414
- // Verify commit was actually called before checking its contents.
1415
- expect(committedTurns.length).toBeGreaterThan(0);
1416
-
1417
- // The committed history should contain the inbound text message but
1418
- // no tool_result message since addToHistory was false.
1419
- const hasToolResult = committedTurns.some((m) =>
1420
- m.content.some((b) => b.type === "tool_result"),
1421
- );
1422
- expect(hasToolResult).toBe(false);
1423
-
1424
- // The inbound user message should still be in history.
1425
- const hasUserText = committedTurns.some(
1426
- (m) => m.role === "user" && m.content.some((b) => b.type === "text"),
1427
- );
1428
- expect(hasUserText).toBe(true);
1429
- });
1430
- });
1431
-
1432
- // ---------------------------------------------------------------------------
1433
- // 12. Director misbehavior
1434
- // ---------------------------------------------------------------------------
1435
-
1436
- describe("createReactor — director misbehavior", () => {
1437
- test("reactor shuts down when director returns invalid action set", async () => {
1438
- const { reactor, events, waitFor } = createTestReactor({
1439
- director: directorFromTable({
1440
- "message.received": (_e, _s, caps) => [caps.infer(), caps.done()],
1441
- }),
1442
- });
1443
-
1444
- reactor.start();
1445
- reactor.deliver(makeInboundMessage());
1446
- await waitFor("reactor.done");
1447
-
1448
- const errorEvent = getEvent(events, "reactor.error");
1449
- expect(errorEvent.data.error).toMatch(/infer.*done/i);
1450
- expect(errorEvent.data.fatal).toBe(true);
1451
- });
1452
-
1453
- test("director emitting reserved namespace inference.* produces non-fatal error", async () => {
1454
- const { reactor, events, waitFor } = createTestReactor({
1455
- director: directorFromTable({
1456
- "message.received": (_e, _s, caps) => [
1457
- // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- intentionally passing a reserved-namespace string to test that the reactor rejects it
1458
- caps.emit("inference.hijack" as `custom.${string}`, {}),
1459
- caps.done(),
1460
- ],
1461
- }),
1462
- });
1463
-
1464
- reactor.start();
1465
- reactor.deliver(makeInboundMessage());
1466
- await waitFor("reactor.done");
1467
-
1468
- const errorEvent = getEvent(events, "reactor.error");
1469
- expect(errorEvent.data.error).toMatch(/reserved event type/);
1470
- expect(errorEvent.data.fatal).toBe(false);
1471
- });
1472
-
1473
- test("director emitting reserved namespace tool.* produces non-fatal error", async () => {
1474
- const { reactor, events, waitFor } = createTestReactor({
1475
- director: directorFromTable({
1476
- "message.received": (_e, _s, caps) => [
1477
- // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- intentionally passing a reserved-namespace string to test that the reactor rejects it
1478
- caps.emit("tool.fake" as `custom.${string}`, {}),
1479
- caps.done(),
1480
- ],
1481
- }),
1482
- });
1483
-
1484
- reactor.start();
1485
- reactor.deliver(makeInboundMessage());
1486
- await waitFor("reactor.done");
1487
-
1488
- const errorEvent = getEvent(events, "reactor.error");
1489
- expect(errorEvent.data.error).toMatch(/reserved event type/);
1490
- expect(errorEvent.data.fatal).toBe(false);
1491
- });
1492
-
1493
- test("director emitting reserved namespace reactor.* produces non-fatal error", async () => {
1494
- const { reactor, events, waitFor } = createTestReactor({
1495
- director: directorFromTable({
1496
- "message.received": (_e, _s, caps) => [
1497
- // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- intentionally passing a reserved-namespace string to test that the reactor rejects it
1498
- caps.emit("reactor.fake" as `custom.${string}`, {}),
1499
- caps.done(),
1500
- ],
1501
- }),
1502
- });
1503
-
1504
- reactor.start();
1505
- reactor.deliver(makeInboundMessage());
1506
- await waitFor("reactor.done");
1507
-
1508
- const errorEvent = getEvent(events, "reactor.error");
1509
- expect(errorEvent.data.error).toMatch(/reserved event type/);
1510
- expect(errorEvent.data.fatal).toBe(false);
1511
- });
1512
-
1513
- test("director emitting reserved namespace fork.* produces non-fatal error", async () => {
1514
- const { reactor, events, waitFor } = createTestReactor({
1515
- director: directorFromTable({
1516
- "message.received": (_e, _s, caps) => [
1517
- // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- intentionally passing a reserved-namespace string to test that the reactor rejects it
1518
- caps.emit("fork.fake" as `custom.${string}`, {}),
1519
- caps.done(),
1520
- ],
1521
- }),
1522
- });
1523
-
1524
- reactor.start();
1525
- reactor.deliver(makeInboundMessage());
1526
- await waitFor("reactor.done");
1527
-
1528
- const errorEvent = getEvent(events, "reactor.error");
1529
- expect(errorEvent.data.error).toMatch(/reserved event type/);
1530
- expect(errorEvent.data.fatal).toBe(false);
1531
- });
1532
-
1533
- test("fork action emits non-fatal unsupported error", async () => {
1534
- const { reactor, events, waitFor } = createTestReactor({
1535
- director: directorFromTable({
1536
- "message.received": (_e, _s, caps) => [
1537
- caps.fork("independent", "fork-1"),
1538
- caps.done(),
1539
- ],
1540
- }),
1541
- });
1542
-
1543
- reactor.start();
1544
- reactor.deliver(makeInboundMessage());
1545
- await waitFor("reactor.done");
1546
-
1547
- const errorEvent = getEvent(events, "reactor.error");
1548
- expect(errorEvent.data.error).toMatch(/not supported/i);
1549
- expect(errorEvent.data.fatal).toBe(false);
1550
- });
1551
-
1552
- test("wait action keeps reactor alive without shutdown", async () => {
1553
- let messageCount = 0;
1554
- const { reactor, events, waitFor } = createTestReactor({
1555
- director: directorFromTable({
1556
- "message.received": (_e, _s, caps) => {
1557
- messageCount++;
1558
- if (messageCount >= 2) return caps.done();
1559
- return caps.wait();
1560
- },
1561
- }),
1562
- });
1563
-
1564
- reactor.start();
1565
- reactor.deliver(makeInboundMessage());
1566
-
1567
- // After wait, deliver a second message which triggers done.
1568
- // Small delay to let the reactor process the first message.
1569
- setTimeout(() => reactor.deliver(makeInboundMessage()), 20);
1570
- await waitFor("reactor.done");
1571
-
1572
- expect(messageCount).toBe(2);
1573
- // No reactor.error should have been emitted.
1574
- expect(events.some((e) => e.type === "reactor.error")).toBe(false);
1575
- });
1576
- });
1577
-
1578
- // ---------------------------------------------------------------------------
1579
- // 13. Reply action
1580
- // ---------------------------------------------------------------------------
1581
-
1582
- describe("createReactor — reply action", () => {
1583
- test("reply action emits connector.reply and reactor stays alive", async () => {
1584
- let messageCount = 0;
1585
- const { reactor, events, waitFor } = createTestReactor({
1586
- director: directorFromTable({
1587
- "message.received": (_e, _s, caps) => {
1588
- messageCount++;
1589
- if (messageCount >= 2) return caps.done();
1590
- return caps.reply("hello back");
1591
- },
1592
- }),
1593
- });
1594
-
1595
- reactor.start();
1596
- reactor.deliver(makeInboundMessage());
1597
-
1598
- // After reply, reactor waits for next event. Deliver another message.
1599
- setTimeout(() => reactor.deliver(makeInboundMessage()), 20);
1600
- await waitFor("reactor.done");
1601
-
1602
- const replyEvent = getEvent(events, "connector.reply");
1603
- expect(replyEvent.data.content).toBe("hello back");
1604
- expect(messageCount).toBe(2);
1605
- });
1606
- });
1607
-
1608
- // ---------------------------------------------------------------------------
1609
- // 14. Checkpoint failure
1610
- // ---------------------------------------------------------------------------
1611
-
1612
- describe("createReactor — checkpoint failure", () => {
1613
- test("checkpoint failure emits non-fatal error and reactor continues", async () => {
1614
- let checkpointCalled = false;
1615
- const failingStore: ContextStore = {
1616
- async load() {
1617
- return {
1618
- turns: [],
1619
- pendingOperations: [],
1620
- tokenUsage: emptyUsage(),
1621
- connectorState: null,
1622
- };
1623
- },
1624
- setConnectorState() {
1625
- /* noop */
1626
- },
1627
- async commit(): Promise<ContextCommit> {
1628
- checkpointCalled = true;
1629
- throw new Error("storage full");
1630
- },
1631
- async branch() {
1632
- /* noop */
1633
- },
1634
- async log() {
1635
- return [];
1636
- },
1637
- async readAt() {
1638
- return [];
1639
- },
1640
- async writeBlob() {
1641
- /* noop */
1642
- },
1643
- async readBlob() {
1644
- throw new Error("not implemented");
1645
- },
1646
- async writePrompt() {
1647
- /* noop */
1648
- },
1649
- async writeResponse() {
1650
- /* noop */
1651
- },
1652
- async writeManifest() {
1653
- /* noop */
1654
- },
1655
- async writeTurns() {
1656
- /* noop */
1657
- },
1658
- async writeMetadata() {
1659
- /* noop */
1660
- },
1661
- async readManifestHistory() {
1662
- throw new Error("not implemented");
1663
- },
1664
- };
1665
-
1666
- let messageCount = 0;
1667
- const { reactor, events, waitFor } = createTestReactor({
1668
- contextStore: failingStore,
1669
- director: directorFromTable({
1670
- "message.received": (_e, _s, caps) => {
1671
- messageCount++;
1672
- if (messageCount === 1) return [caps.checkpoint(), caps.wait()];
1673
- return caps.done();
1674
- },
1675
- }),
1676
- });
1677
-
1678
- reactor.start();
1679
- reactor.deliver(makeInboundMessage());
1680
-
1681
- // After checkpoint failure + wait, deliver another message.
1682
- setTimeout(() => reactor.deliver(makeInboundMessage()), 50);
1683
- await waitFor("reactor.done");
1684
-
1685
- expect(checkpointCalled).toBe(true);
1686
-
1687
- const errorEvent = getEvent(events, "reactor.error");
1688
- expect(errorEvent.data.error).toMatch(/storage full/);
1689
- expect(errorEvent.data.fatal).toBe(false);
1690
-
1691
- // Reactor continued after the error — it processed the second message.
1692
- expect(messageCount).toBe(2);
1693
- });
1694
- });
1695
-
1696
- // ---------------------------------------------------------------------------
1697
- // 15. Abort handling
1698
- // ---------------------------------------------------------------------------
1699
-
1700
- describe("createReactor — abort handling", () => {
1701
- test("abort event is processed with priority over queued events", async () => {
1702
- // Track how many message.received events the director processed.
1703
- let messagesProcessed = 0;
1704
- const { reactor, waitFor } = createTestReactor({
1705
- director: directorFromTable(
1706
- {
1707
- "message.received": (_e, _s, caps) => {
1708
- messagesProcessed++;
1709
- return caps.wait();
1710
- },
1711
- },
1712
- "wait",
1713
- ),
1714
- });
1715
-
1716
- reactor.start();
1717
- reactor.deliver(makeInboundMessage());
1718
-
1719
- // Wait for reactor to process the first message.
1720
- await waitFor("message.received");
1721
-
1722
- // Queue more messages and an abort. The abort should jump the queue
1723
- // and shut down before the extra messages reach the director.
1724
- reactor.deliver(makeInboundMessage());
1725
- reactor.deliver(makeInboundMessage());
1726
- reactor.abort("admin_kill");
1727
-
1728
- await waitFor("reactor.done");
1729
-
1730
- // Only the first message should have been processed by the director.
1731
- // The abort jumped ahead of the two queued messages.
1732
- expect(messagesProcessed).toBe(1);
1733
- });
1734
-
1735
- test("multiple abort calls do not cause double shutdown", async () => {
1736
- const { reactor, events, waitFor } = createTestReactor({
1737
- director: directorFromTable(
1738
- {
1739
- "message.received": (_e, _s, caps) => caps.wait(),
1740
- },
1741
- "wait",
1742
- ),
1743
- });
1744
-
1745
- reactor.start();
1746
- reactor.deliver(makeInboundMessage());
1747
- await waitFor("message.received");
1748
-
1749
- reactor.abort("admin_kill");
1750
- reactor.abort("admin_kill");
1751
- reactor.abort("admin_kill");
1752
-
1753
- await waitFor("reactor.done");
1754
-
1755
- // Exactly one reactor.done should be emitted.
1756
- const doneEvents = events.filter((e) => e.type === "reactor.done");
1757
- expect(doneEvents.length).toBe(1);
1758
- });
1759
-
1760
- test("abort before start shuts down immediately when start is called", async () => {
1761
- const { reactor, events, waitFor } = createTestReactor({
1762
- director: directorFromTable(
1763
- {
1764
- "message.received": (_e, _s, caps) => caps.wait(),
1765
- },
1766
- "wait",
1767
- ),
1768
- });
1769
-
1770
- // Abort is enqueued before start().
1771
- reactor.abort("admin_kill");
1772
- reactor.start();
1773
-
1774
- await waitFor("reactor.done");
1775
-
1776
- expect(events.some((e) => e.type === "reactor.start")).toBe(true);
1777
- expect(events.some((e) => e.type === "reactor.done")).toBe(true);
1778
-
1779
- // No messages should have been processed.
1780
- expect(events.some((e) => e.type === "message.received")).toBe(false);
1781
- });
1782
- });
1783
-
1784
- // ---------------------------------------------------------------------------
1785
- // 16. Dequeue priority (mid-cycle interleaving prevention)
1786
- // ---------------------------------------------------------------------------
1787
-
1788
- describe("createReactor — dequeue priority", () => {
1789
- test("tool.done is prioritized over message.received when tool calls are pending", async () => {
1790
- // Pre-seed history with an assistant message containing a tool_call
1791
- // block. Using addToHistory=false on executeTools keeps this as the
1792
- // last message, so historyHasPendingToolCalls() stays true when the
1793
- // loop calls dequeueNext() after tools complete.
1794
- const seededTurns: ConversationTurn[] = [
1795
- {
1796
- role: "assistant",
1797
- content: [
1798
- {
1799
- type: "tool_call",
1800
- id: "tc-pending",
1801
- name: "some_tool",
1802
- arguments: {},
1803
- },
1804
- ],
1805
- model: "test-model",
1806
- timestamp: 1000,
1807
- },
1808
- ];
1809
-
1810
- const order: string[] = [];
1811
-
1812
- // Empty content prevents the reactor from appending a user text
1813
- // message to history, preserving the assistant tool_call as the
1814
- // last entry so historyHasPendingToolCalls() stays true.
1815
- const emptyMessage: InboundMessage = {
1816
- ref: { uid: 1, mailbox: "INBOX" },
1817
- headers: {
1818
- from: "test@example.com",
1819
- to: ["agent@example.com"],
1820
- date: new Date().toISOString(),
1821
- messageId: `msg-empty-${Math.random()}`,
1822
- },
1823
- flags: [],
1824
- content: "",
1825
- signatureStatus: "missing",
1826
- };
1827
-
1828
- const { reactor, waitFor } = createTestReactor({
1829
- contextStore: makeContextStore(seededTurns),
1830
- director: {
1831
- async decide(event, _state, caps) {
1832
- order.push(event.type);
1833
- if (event.type === "message.received") {
1834
- // First message.received: run tools with addToHistory=false.
1835
- // Second message.received (after tool.done): shut down.
1836
- if (order.filter((t) => t === "message.received").length >= 2) {
1837
- return caps.done();
1838
- }
1839
- return caps.executeTools(
1840
- [{ id: "tc-pending", name: "some_tool", arguments: {} }],
1841
- true,
1842
- false,
1843
- );
1844
- }
1845
- if (event.type === "tool.done") {
1846
- return caps.wait();
1847
- }
1848
- return caps.done();
1849
- },
1850
- },
1851
- toolRunner: makeToolRunner(async (call) => {
1852
- // While the tool is running, deliver a second message. This
1853
- // enqueues message.received before executeTools enqueues tool.done.
1854
- reactor.deliver(emptyMessage);
1855
- return { callId: call.id, content: "ok" };
1856
- }),
1857
- });
1858
-
1859
- reactor.start();
1860
- reactor.deliver(emptyMessage);
1861
- await waitFor("reactor.done");
1862
-
1863
- // tool.done should appear before the second message.received in the
1864
- // order array, because dequeueNext prioritizes cycle events when
1865
- // the last history message has pending tool_calls.
1866
- const toolDoneIdx = order.indexOf("tool.done");
1867
- const secondMessageIdx = order.lastIndexOf("message.received");
1868
- expect(toolDoneIdx).toBeGreaterThan(-1);
1869
- expect(secondMessageIdx).toBeGreaterThan(-1);
1870
- expect(toolDoneIdx).toBeLessThan(secondMessageIdx);
1871
- });
1872
- });
1873
-
1874
- // ---------------------------------------------------------------------------
1875
- // 17. Correlation validator edge cases
1876
- // ---------------------------------------------------------------------------
1877
-
1878
- describe("createReactor — correlation validator", () => {
1879
- test("validator returning false lets message through as uncorrelated", async () => {
1880
- const CORR_ID = "corr-rejected";
1881
- let toolDoneSeen = false;
1882
-
1883
- const { reactor, events, waitFor } = createTestReactor({
1884
- correlationValidator: {
1885
- async validate() {
1886
- return false;
1887
- },
1888
- },
1889
- director: directorFromTable({
1890
- "message.received": (_e, _s, caps) => {
1891
- if (toolDoneSeen) return caps.done();
1892
- return caps.executeTools([
1893
- { id: "tc1", name: "send_msg", arguments: {} },
1894
- ]);
1895
- },
1896
- "tool.done": (_e, _s, caps) => {
1897
- toolDoneSeen = true;
1898
- return caps.suspend({
1899
- type: "message_response",
1900
- gateId: "val-gate",
1901
- timeoutMs: 5000,
1902
- correlationId: CORR_ID,
1903
- });
1904
- },
1905
- }),
1906
- toolRunner: makeToolRunner(async (call) => {
1907
- return {
1908
- callId: call.id,
1909
- content: "sent",
1910
- pendingMarker: {
1911
- status: "pending" as const,
1912
- correlationId: CORR_ID,
1913
- },
1914
- };
1915
- }),
1916
- });
1917
-
1918
- reactor.start();
1919
- reactor.deliver(makeInboundMessage());
1920
-
1921
- await waitFor("reactor.gate.blocked");
1922
-
1923
- // Deliver a message with matching correlationId — but the validator
1924
- // rejects it, so it should arrive as message.received, not
1925
- // message.correlated.
1926
- reactor.deliver(makeInboundMessage(CORR_ID));
1927
-
1928
- await waitFor("reactor.done");
1929
-
1930
- expect(events.some((e) => e.type === "message.correlated")).toBe(false);
1931
- // The message should have been delivered as uncorrelated.
1932
- const receivedEvents = events.filter((e) => e.type === "message.received");
1933
- expect(receivedEvents.length).toBe(2);
1934
- });
1935
-
1936
- test("validator that throws lets message through as uncorrelated", async () => {
1937
- const CORR_ID = "corr-throws";
1938
- let toolDoneSeen = false;
1939
-
1940
- const { reactor, events, waitFor } = createTestReactor({
1941
- correlationValidator: {
1942
- async validate() {
1943
- throw new Error("validator crashed");
1944
- },
1945
- },
1946
- director: directorFromTable({
1947
- "message.received": (_e, _s, caps) => {
1948
- if (toolDoneSeen) return caps.done();
1949
- return caps.executeTools([
1950
- { id: "tc1", name: "send_msg", arguments: {} },
1951
- ]);
1952
- },
1953
- "tool.done": (_e, _s, caps) => {
1954
- toolDoneSeen = true;
1955
- return caps.suspend({
1956
- type: "message_response",
1957
- gateId: "throw-gate",
1958
- timeoutMs: 5000,
1959
- correlationId: CORR_ID,
1960
- });
1961
- },
1962
- }),
1963
- toolRunner: makeToolRunner(async (call) => {
1964
- return {
1965
- callId: call.id,
1966
- content: "sent",
1967
- pendingMarker: {
1968
- status: "pending" as const,
1969
- correlationId: CORR_ID,
1970
- },
1971
- };
1972
- }),
1973
- });
1974
-
1975
- reactor.start();
1976
- reactor.deliver(makeInboundMessage());
1977
-
1978
- await waitFor("reactor.gate.blocked");
1979
-
1980
- reactor.deliver(makeInboundMessage(CORR_ID));
1981
-
1982
- await waitFor("reactor.done");
1983
-
1984
- expect(events.some((e) => e.type === "message.correlated")).toBe(false);
1985
- const receivedEvents = events.filter((e) => e.type === "message.received");
1986
- expect(receivedEvents.length).toBe(2);
1987
- });
1988
-
1989
- test("concurrent delivery with same correlationId only correlates once", async () => {
1990
- const CORR_ID = "corr-double";
1991
- let toolDoneSeen = false;
1992
-
1993
- // Use a validator with a manually-resolved promise so we can control
1994
- // timing. The first deliver enters the validator and blocks. The second
1995
- // deliver hits the correlatingIds guard and falls through.
1996
- let resolveValidator!: () => void;
1997
- const validatorPromise = new Promise<void>((resolve) => {
1998
- resolveValidator = resolve;
1999
- });
2000
-
2001
- const { reactor, events, waitFor } = createTestReactor({
2002
- correlationValidator: {
2003
- async validate() {
2004
- await validatorPromise;
2005
- return true;
2006
- },
2007
- },
2008
- director: directorFromTable({
2009
- "message.received": (_e, _s, caps) => {
2010
- if (toolDoneSeen) return caps.done();
2011
- return caps.executeTools([
2012
- { id: "tc1", name: "send_msg", arguments: {} },
2013
- ]);
2014
- },
2015
- "tool.done": (_e, _s, caps) => {
2016
- toolDoneSeen = true;
2017
- return caps.suspend({
2018
- type: "message_response",
2019
- gateId: "double-gate",
2020
- timeoutMs: 5000,
2021
- correlationId: CORR_ID,
2022
- });
2023
- },
2024
- "reactor.gate.cleared": (_e, _s, caps) => caps.done(),
2025
- }),
2026
- toolRunner: makeToolRunner(async (call) => {
2027
- return {
2028
- callId: call.id,
2029
- content: "sent",
2030
- pendingMarker: {
2031
- status: "pending" as const,
2032
- correlationId: CORR_ID,
2033
- },
2034
- };
2035
- }),
2036
- });
2037
-
2038
- reactor.start();
2039
- reactor.deliver(makeInboundMessage());
2040
-
2041
- await waitFor("reactor.gate.blocked");
2042
-
2043
- // Deliver two messages with the same correlationId in the same tick.
2044
- // The first enters the validator (which blocks on the promise).
2045
- // The second hits the correlatingIds guard and is rejected.
2046
- reactor.deliver(makeInboundMessage(CORR_ID));
2047
- reactor.deliver(makeInboundMessage(CORR_ID));
2048
-
2049
- // Let the validator resolve so the first correlation completes.
2050
- resolveValidator();
2051
-
2052
- await waitFor("reactor.done");
2053
-
2054
- // Only one message.correlated event should have been emitted.
2055
- const correlatedEvents = events.filter(
2056
- (e) => e.type === "message.correlated",
2057
- );
2058
- expect(correlatedEvents.length).toBe(1);
2059
-
2060
- // The second message should have been delivered as uncorrelated.
2061
- const receivedEvents = events.filter((e) => e.type === "message.received");
2062
- expect(receivedEvents.length).toBe(2);
2063
- });
2064
- });
2065
-
2066
- // ---------------------------------------------------------------------------
2067
- // 18. Start guard
2068
- // ---------------------------------------------------------------------------
2069
-
2070
- describe("createReactor — start guard", () => {
2071
- test("calling start() twice throws", () => {
2072
- const { reactor } = createTestReactor();
2073
- reactor.start();
2074
- expect(() => reactor.start()).toThrow(/already running/);
2075
- });
2076
- });
2077
-
2078
- // ---------------------------------------------------------------------------
2079
- // 19. State snapshot inspection
2080
- // ---------------------------------------------------------------------------
2081
-
2082
- describe("createReactor — state snapshot inspection", () => {
2083
- test("state.turns contains delivered message text", async () => {
2084
- let capturedTurns: ConversationTurn[] = [];
2085
- const { reactor, waitFor } = createTestReactor({
2086
- director: directorFromTable({
2087
- "message.received": (_e, state, caps) => {
2088
- capturedTurns = state.turns;
2089
- return caps.done();
2090
- },
2091
- }),
2092
- });
2093
-
2094
- reactor.start();
2095
- reactor.deliver(makeInboundMessage());
2096
- await waitFor("reactor.done");
2097
-
2098
- expect(capturedTurns.length).toBe(1);
2099
- const last = capturedTurns.at(-1);
2100
- if (last === undefined) throw new Error("unreachable");
2101
- expect(last.role).toBe("user");
2102
- const textBlock = last.content.find((b) => b.type === "text");
2103
- if (textBlock === undefined || textBlock.type !== "text")
2104
- throw new Error("unreachable");
2105
- expect(textBlock.text).toBe("[From: test@example.com]\n\nhello");
2106
- });
2107
-
2108
- test("state.pendingOperations tracks pending markers from tools", async () => {
2109
- const CORR_ID = "corr-snapshot-check";
2110
- let capturedOps: PendingOperation[] = [];
2111
-
2112
- const { reactor, waitFor } = createTestReactor({
2113
- director: directorFromTable({
2114
- "message.received": (_e, _s, caps) =>
2115
- caps.executeTools([{ id: "tc1", name: "send_msg", arguments: {} }]),
2116
- "tool.done": (_e, state, caps) => {
2117
- capturedOps = state.pendingOperations;
2118
- return caps.done();
2119
- },
2120
- }),
2121
- toolRunner: {
2122
- async run(call) {
2123
- return {
2124
- callId: call.id,
2125
- content: "sent",
2126
- pendingMarker: {
2127
- status: "pending" as const,
2128
- correlationId: CORR_ID,
2129
- },
2130
- };
2131
- },
2132
- },
2133
- });
2134
-
2135
- reactor.start();
2136
- reactor.deliver(makeInboundMessage());
2137
- await waitFor("reactor.done");
2138
-
2139
- expect(capturedOps.length).toBe(1);
2140
- const op = capturedOps[0];
2141
- if (op === undefined) throw new Error("unreachable");
2142
- expect(op.correlationId).toBe(CORR_ID);
2143
- });
2144
-
2145
- test("state.activeGates reflects registered gates during suspend", async () => {
2146
- let gatesDuringSuspend: ReactorState["activeGates"] = [];
2147
- let gatesAfterCleared: ReactorState["activeGates"] = [];
2148
- let messageCount = 0;
2149
-
2150
- const { reactor, waitFor } = createTestReactor({
2151
- director: {
2152
- async decide(event, state, caps) {
2153
- if (event.type === "message.received") {
2154
- messageCount++;
2155
- if (messageCount === 1) {
2156
- return caps.suspend({
2157
- type: "approval",
2158
- gateId: "snapshot-gate",
2159
- timeoutMs: 500,
2160
- });
2161
- }
2162
- // Second message arrives while gate is active.
2163
- gatesDuringSuspend = state.activeGates;
2164
- return caps.wait();
2165
- }
2166
- if (event.type === "reactor.gate.cleared") {
2167
- gatesAfterCleared = state.activeGates;
2168
- return caps.done();
2169
- }
2170
- return caps.done();
2171
- },
2172
- },
2173
- });
2174
-
2175
- reactor.start();
2176
- reactor.deliver(makeInboundMessage());
2177
- await waitFor("reactor.gate.blocked");
2178
-
2179
- // Deliver a second message while the gate is active. The 500ms
2180
- // gate timeout is far larger than the async overhead of deliver(),
2181
- // so the second message.received is guaranteed to be processed
2182
- // before the timeout fires.
2183
- reactor.deliver(makeInboundMessage());
2184
-
2185
- // The gate times out after 500ms, firing reactor.gate.cleared
2186
- // where we capture the post-clear state and return done.
2187
- await waitFor("reactor.done");
2188
-
2189
- expect(gatesDuringSuspend.length).toBe(1);
2190
- const gate = gatesDuringSuspend[0];
2191
- if (gate === undefined) throw new Error("unreachable");
2192
- expect(gate.gateId).toBe("snapshot-gate");
2193
- expect(gate.type).toBe("approval");
2194
- expect(gatesAfterCleared.length).toBe(0);
2195
- });
2196
-
2197
- test("state.tokenUsage has initial zero values without inference", async () => {
2198
- let capturedUsage: TokenUsage | undefined;
2199
- const { reactor, waitFor } = createTestReactor({
2200
- director: directorFromTable({
2201
- "message.received": (_e, state, caps) => {
2202
- capturedUsage = state.tokenUsage;
2203
- return caps.done();
2204
- },
2205
- }),
2206
- });
2207
-
2208
- reactor.start();
2209
- reactor.deliver(makeInboundMessage());
2210
- await waitFor("reactor.done");
2211
-
2212
- if (capturedUsage === undefined) throw new Error("unreachable");
2213
- expect(capturedUsage.input).toBe(0);
2214
- expect(capturedUsage.output).toBe(0);
2215
- expect(capturedUsage.cacheRead).toBe(0);
2216
- expect(capturedUsage.cacheWrite).toBe(0);
2217
- expect(capturedUsage.thinking).toBe(0);
2218
- });
2219
-
2220
- test("director cannot corrupt reactor state by mutating snapshot content blocks", async () => {
2221
- let secondSnapshot: ReactorState | undefined;
2222
- let messageCount = 0;
2223
-
2224
- const { reactor, waitFor } = createTestReactor({
2225
- director: {
2226
- async decide(event, state, caps) {
2227
- if (event.type === "message.received") {
2228
- messageCount++;
2229
- if (messageCount === 1) {
2230
- // Mutate the snapshot's content block.
2231
- const msg = state.turns[0];
2232
- if (msg !== undefined) {
2233
- const block = msg.content[0];
2234
- if (block !== undefined && block.type === "text") {
2235
- (block as { text: string }).text = "CORRUPTED";
2236
- }
2237
- }
2238
- return caps.wait();
2239
- }
2240
- // Second message: capture a fresh snapshot to verify isolation.
2241
- secondSnapshot = state;
2242
- return caps.done();
2243
- }
2244
- return caps.done();
2245
- },
2246
- },
2247
- });
2248
-
2249
- reactor.start();
2250
- reactor.deliver(makeInboundMessage());
2251
-
2252
- // Wait for the first message to be processed, then deliver a second.
2253
- setTimeout(() => reactor.deliver(makeInboundMessage()), 30);
2254
- await waitFor("reactor.done");
2255
-
2256
- if (secondSnapshot === undefined) throw new Error("unreachable");
2257
- const firstMsg = secondSnapshot.turns[0];
2258
- if (firstMsg === undefined) throw new Error("unreachable");
2259
- const block = firstMsg.content[0];
2260
- if (block === undefined || block.type !== "text")
2261
- throw new Error("unreachable");
2262
- expect(block.text).toBe("[From: test@example.com]\n\nhello");
2263
- });
2264
- });
2265
-
2266
- // ---------------------------------------------------------------------------
2267
- // 20. Deliver after done
2268
- // ---------------------------------------------------------------------------
2269
-
2270
- describe("createReactor — deliver after done", () => {
2271
- test("reactor.done is emitted exactly once even when messages arrive after shutdown", async () => {
2272
- const { reactor, events, waitFor } = createTestReactor({
2273
- director: directorFromTable({
2274
- "message.received": (_e, _s, caps) => caps.done(),
2275
- }),
2276
- });
2277
-
2278
- reactor.start();
2279
- reactor.deliver(makeInboundMessage());
2280
- await waitFor("reactor.done");
2281
-
2282
- const eventsBeforeDeliver = events.length;
2283
-
2284
- // Deliver after done — the done guard in deliver() should prevent
2285
- // any state mutation or event emission.
2286
- reactor.deliver(makeInboundMessage());
2287
- reactor.deliver(makeInboundMessage());
2288
-
2289
- // Give the event loop a chance to process any spurious events.
2290
- await new Promise((resolve) => setTimeout(resolve, 50));
2291
-
2292
- // No new events should have been emitted after reactor.done.
2293
- expect(events.length).toBe(eventsBeforeDeliver);
2294
- const doneEvents = events.filter((e) => e.type === "reactor.done");
2295
- expect(doneEvents.length).toBe(1);
2296
- });
2297
- });
2298
-
2299
- // ---------------------------------------------------------------------------
2300
- // 21. Inference path (infer action)
2301
- // ---------------------------------------------------------------------------
2302
-
2303
- function makeAssistantTurn(text: string): AssistantTurn {
2304
- return {
2305
- role: "assistant",
2306
- content: [{ type: "text", text }],
2307
- model: "mock-model",
2308
- timestamp: 1000,
2309
- };
2310
- }
2311
-
2312
- function makeInferenceRunner(
2313
- result:
2314
- | { type: "done"; turn: AssistantTurn; usage: TokenUsage }
2315
- | { type: "error"; error: InferenceError; partial: PartialMessage },
2316
- ): (opts: InferenceHarnessOptions) => AsyncGenerator<InferenceEvent> {
2317
- return async function* (opts) {
2318
- if (result.type === "done") {
2319
- const event: InferenceEvent = {
2320
- type: "inference.done",
2321
- seq: opts.nextSeq(),
2322
- data: { turn: result.turn, usage: result.usage, source: TEST_SOURCE },
2323
- };
2324
- yield event;
2325
- } else {
2326
- const event: InferenceEvent = {
2327
- type: "inference.error",
2328
- seq: opts.nextSeq(),
2329
- data: { error: result.error, partial: result.partial },
2330
- };
2331
- yield event;
2332
- }
2333
- };
2334
- }
2335
-
2336
- describe("createReactor — inference path", () => {
2337
- test("infer action drives inference.done through to director and accumulates usage", async () => {
2338
- const inferUsage: TokenUsage = {
2339
- input: 100,
2340
- output: 50,
2341
- cacheRead: 10,
2342
- cacheWrite: 5,
2343
- thinking: 20,
2344
- };
2345
- const assistantMsg = makeAssistantTurn("Hello from the model");
2346
-
2347
- let stateAtInferenceDone: ReactorState | undefined;
2348
-
2349
- const { reactor, events, waitFor } = createTestReactor({
2350
- director: directorFromTable({
2351
- "message.received": (_e, _s, caps) => caps.infer(),
2352
- "inference.done": (_e, state, caps) => {
2353
- stateAtInferenceDone = state;
2354
- return caps.done();
2355
- },
2356
- }),
2357
- inferenceRunner: makeInferenceRunner({
2358
- type: "done",
2359
- turn: assistantMsg,
2360
- usage: inferUsage,
2361
- }),
2362
- });
2363
-
2364
- reactor.start();
2365
- reactor.deliver(makeInboundMessage());
2366
- await waitFor("reactor.done");
2367
-
2368
- // The emitted event stream should contain inference.done.
2369
- const inferDone = getEvent(events, "inference.done");
2370
- expect(inferDone.data.turn.content[0]).toEqual({
2371
- type: "text",
2372
- text: "Hello from the model",
2373
- });
2374
- expect(inferDone.data.usage).toEqual(inferUsage);
2375
-
2376
- // The director should have received the inference.done event with
2377
- // accumulated token usage visible in the state snapshot.
2378
- if (stateAtInferenceDone === undefined)
2379
- throw new Error("director never received inference.done");
2380
- expect(stateAtInferenceDone.tokenUsage).toEqual(inferUsage);
2381
-
2382
- // The assistant message should have been appended to the conversation.
2383
- const lastMsg =
2384
- stateAtInferenceDone.turns[stateAtInferenceDone.turns.length - 1];
2385
- if (lastMsg === undefined) throw new Error("no messages in state snapshot");
2386
- expect(lastMsg.role).toBe("assistant");
2387
- expect(lastMsg.content[0]).toEqual({
2388
- type: "text",
2389
- text: "Hello from the model",
2390
- });
2391
- });
2392
-
2393
- test("infer action with inference.error delivers error event to director", async () => {
2394
- const inferError: InferenceError = {
2395
- category: "retryable",
2396
- message: "rate limited",
2397
- };
2398
- const partial: PartialMessage = { text: "partial output" };
2399
-
2400
- let capturedError: { category: string; message: string } | undefined;
2401
-
2402
- const { reactor, events, waitFor } = createTestReactor({
2403
- director: directorFromTable({
2404
- "message.received": (_e, _s, caps) => caps.infer(),
2405
- "inference.error": (e, _s, caps) => {
2406
- capturedError = {
2407
- category: e.error.category,
2408
- message: e.error.message,
2409
- };
2410
- return caps.done();
2411
- },
2412
- }),
2413
- inferenceRunner: makeInferenceRunner({
2414
- type: "error",
2415
- error: inferError,
2416
- partial,
2417
- }),
2418
- });
2419
-
2420
- reactor.start();
2421
- reactor.deliver(makeInboundMessage());
2422
- await waitFor("reactor.done");
2423
-
2424
- // Verify the director received the error event with correct fields.
2425
- if (capturedError === undefined)
2426
- throw new Error("director never received inference.error");
2427
- expect(capturedError.category).toBe("retryable");
2428
- expect(capturedError.message).toBe("rate limited");
2429
-
2430
- // The emitted event stream should contain inference.error.
2431
- const inferErr = getEvent(events, "inference.error");
2432
- expect(inferErr.data.error.category).toBe("retryable");
2433
- expect(inferErr.data.partial.text).toBe("partial output");
2434
- });
2435
-
2436
- test("inference runner that yields no terminal event emits fatal error and completes", async () => {
2437
- let capturedError: { category: string; message: string } | undefined;
2438
-
2439
- const { reactor, events, waitFor } = createTestReactor({
2440
- director: directorFromTable({
2441
- "message.received": (_e, _s, caps) => caps.infer(),
2442
- "inference.error": (e, _s, caps) => {
2443
- capturedError = {
2444
- category: e.error.category,
2445
- message: e.error.message,
2446
- };
2447
- return caps.done();
2448
- },
2449
- }),
2450
- inferenceRunner: async function* (_opts) {
2451
- // Yield nothing — violates the runner contract.
2452
- },
2453
- });
2454
-
2455
- reactor.start();
2456
- reactor.deliver(makeInboundMessage());
2457
- await waitFor("reactor.done");
2458
-
2459
- // The reactor should have emitted a reactor.error for observability.
2460
- const reactorErr = getEvent(events, "reactor.error");
2461
- expect(reactorErr.data.fatal).toBe(true);
2462
- expect(reactorErr.data.error).toContain("without a terminal event");
2463
-
2464
- // The director should have received inference.error with category "fatal".
2465
- if (capturedError === undefined)
2466
- throw new Error("director never received inference.error");
2467
- expect(capturedError.category).toBe("fatal");
2468
- expect(capturedError.message).toContain("without a terminal event");
2469
- });
2470
- });
2471
-
2472
- // ---------------------------------------------------------------------------
2473
- // BeforeToolExtension
2474
- // ---------------------------------------------------------------------------
2475
-
2476
- describe("createReactor — beforeToolExtensions", () => {
2477
- const toolCallMsg: AssistantTurn = {
2478
- role: "assistant",
2479
- content: [
2480
- {
2481
- type: "tool_call",
2482
- id: "call-1",
2483
- name: "bash",
2484
- arguments: { cmd: "ls" },
2485
- },
2486
- ],
2487
- model: "test-model",
2488
- timestamp: 1000,
2489
- };
2490
-
2491
- const inferUsage: TokenUsage = {
2492
- input: 10,
2493
- output: 5,
2494
- cacheRead: 0,
2495
- cacheWrite: 0,
2496
- thinking: 0,
2497
- };
2498
-
2499
- function inferenceRunnerWithToolCall() {
2500
- return makeInferenceRunner({
2501
- type: "done",
2502
- turn: toolCallMsg,
2503
- usage: inferUsage,
2504
- });
2505
- }
2506
-
2507
- test("allowing extension lets the tool run normally", async () => {
2508
- const allowAll: BeforeToolExtension = {
2509
- async beforeTool() {
2510
- return undefined;
2511
- },
2512
- };
2513
-
2514
- const toolsRun: string[] = [];
2515
-
2516
- const { reactor, events, waitFor } = createTestReactor({
2517
- director: directorFromTable({
2518
- "message.received": (_e, _s, caps) => caps.infer(),
2519
- "inference.done": (_e, _s, caps) =>
2520
- caps.executeTools(
2521
- [{ id: "call-1", name: "bash", arguments: { cmd: "ls" } }],
2522
- true,
2523
- ),
2524
- "tool.done": (_e, _s, caps) => caps.done(),
2525
- }),
2526
- toolRunner: makeToolRunner(async (call) => {
2527
- toolsRun.push(call.name);
2528
- return { callId: call.id, content: "ok" };
2529
- }),
2530
- inferenceRunner: inferenceRunnerWithToolCall(),
2531
- beforeToolExtensions: [allowAll],
2532
- });
2533
-
2534
- reactor.start();
2535
- reactor.deliver(makeInboundMessage());
2536
- await waitFor("reactor.done");
2537
-
2538
- expect(toolsRun).toEqual(["bash"]);
2539
- const starts = events.filter((e) => e.type === "tool.start");
2540
- expect(starts.length).toBe(1);
2541
- });
2542
-
2543
- test("blocking extension prevents tool execution", async () => {
2544
- const blockBash: BeforeToolExtension = {
2545
- async beforeTool(call) {
2546
- if (call.name === "bash") return "Denied by policy";
2547
- return undefined;
2548
- },
2549
- };
2550
-
2551
- const toolsRun: string[] = [];
2552
-
2553
- const { reactor, events, waitFor } = createTestReactor({
2554
- director: directorFromTable({
2555
- "message.received": (_e, _s, caps) => caps.infer(),
2556
- "inference.done": (_e, _s, caps) =>
2557
- caps.executeTools(
2558
- [{ id: "call-1", name: "bash", arguments: { cmd: "ls" } }],
2559
- true,
2560
- ),
2561
- "tool.done": (_e, _s, caps) => caps.done(),
2562
- }),
2563
- toolRunner: makeToolRunner(async (call) => {
2564
- toolsRun.push(call.name);
2565
- return { callId: call.id, content: "ok" };
2566
- }),
2567
- inferenceRunner: inferenceRunnerWithToolCall(),
2568
- beforeToolExtensions: [blockBash],
2569
- });
2570
-
2571
- reactor.start();
2572
- reactor.deliver(makeInboundMessage());
2573
- await waitFor("reactor.done");
2574
-
2575
- expect(toolsRun).toEqual([]);
2576
-
2577
- // No tool.start for blocked tools.
2578
- const starts = events.filter((e) => e.type === "tool.start");
2579
- expect(starts.length).toBe(0);
2580
-
2581
- // tool.done is emitted with isError and the block reason.
2582
- const doneEvents = events.filter(
2583
- (e): e is Extract<ReactorEmittedEvent, { type: "tool.done" }> =>
2584
- e.type === "tool.done",
2585
- );
2586
- expect(doneEvents.length).toBeGreaterThanOrEqual(1);
2587
- const blocked = doneEvents[0];
2588
- if (!blocked) throw new Error("expected at least one tool.done event");
2589
- expect(blocked.data.result.isError).toBe(true);
2590
- expect(blocked.data.result.content).toBe("Denied by policy");
2591
- expect(blocked.data.result.callId).toBe("call-1");
2592
- });
2593
-
2594
- test("first blocking extension wins and subsequent extensions are not called", async () => {
2595
- const called: string[] = [];
2596
-
2597
- const extA: BeforeToolExtension = {
2598
- async beforeTool() {
2599
- called.push("A");
2600
- return "Blocked by A";
2601
- },
2602
- };
2603
-
2604
- const extB: BeforeToolExtension = {
2605
- async beforeTool() {
2606
- called.push("B");
2607
- return undefined;
2608
- },
2609
- };
2610
-
2611
- const { reactor, waitFor } = createTestReactor({
2612
- director: directorFromTable({
2613
- "message.received": (_e, _s, caps) => caps.infer(),
2614
- "inference.done": (_e, _s, caps) =>
2615
- caps.executeTools(
2616
- [{ id: "call-1", name: "bash", arguments: { cmd: "ls" } }],
2617
- true,
2618
- ),
2619
- "tool.done": (_e, _s, caps) => caps.done(),
2620
- }),
2621
- inferenceRunner: inferenceRunnerWithToolCall(),
2622
- beforeToolExtensions: [extA, extB],
2623
- });
2624
-
2625
- reactor.start();
2626
- reactor.deliver(makeInboundMessage());
2627
- await waitFor("reactor.done");
2628
-
2629
- expect(called).toEqual(["A"]);
2630
- });
2631
-
2632
- test("throwing extension is treated as a block with reactor.error", async () => {
2633
- const throwingExt: BeforeToolExtension = {
2634
- async beforeTool() {
2635
- throw new Error("Extension crashed");
2636
- },
2637
- };
2638
-
2639
- const toolsRun: string[] = [];
2640
-
2641
- const { reactor, events, waitFor } = createTestReactor({
2642
- director: directorFromTable({
2643
- "message.received": (_e, _s, caps) => caps.infer(),
2644
- "inference.done": (_e, _s, caps) =>
2645
- caps.executeTools(
2646
- [{ id: "call-1", name: "bash", arguments: { cmd: "ls" } }],
2647
- true,
2648
- ),
2649
- "tool.done": (_e, _s, caps) => caps.done(),
2650
- }),
2651
- toolRunner: makeToolRunner(async (call) => {
2652
- toolsRun.push(call.name);
2653
- return { callId: call.id, content: "ok" };
2654
- }),
2655
- inferenceRunner: inferenceRunnerWithToolCall(),
2656
- beforeToolExtensions: [throwingExt],
2657
- });
2658
-
2659
- reactor.start();
2660
- reactor.deliver(makeInboundMessage());
2661
- await waitFor("reactor.done");
2662
-
2663
- expect(toolsRun).toEqual([]);
2664
-
2665
- const reactorErrors = events.filter((e) => e.type === "reactor.error");
2666
- const extError = reactorErrors.find(
2667
- (e) =>
2668
- e.type === "reactor.error" &&
2669
- e.data.error.includes("BeforeToolExtension threw"),
2670
- );
2671
- if (extError === undefined || extError.type !== "reactor.error")
2672
- throw new Error("expected reactor.error from throwing extension");
2673
- expect(extError.data.fatal).toBe(false);
2674
- });
2675
-
2676
- test("throwing extension terminates chain before subsequent extensions", async () => {
2677
- const called: string[] = [];
2678
-
2679
- const throwingExt: BeforeToolExtension = {
2680
- async beforeTool() {
2681
- called.push("thrower");
2682
- throw new Error("boom");
2683
- },
2684
- };
2685
-
2686
- const secondExt: BeforeToolExtension = {
2687
- async beforeTool() {
2688
- called.push("second");
2689
- return undefined;
2690
- },
2691
- };
2692
-
2693
- const { reactor, waitFor } = createTestReactor({
2694
- director: directorFromTable({
2695
- "message.received": (_e, _s, caps) => caps.infer(),
2696
- "inference.done": (_e, _s, caps) =>
2697
- caps.executeTools(
2698
- [{ id: "call-1", name: "bash", arguments: { cmd: "ls" } }],
2699
- true,
2700
- ),
2701
- "tool.done": (_e, _s, caps) => caps.done(),
2702
- }),
2703
- inferenceRunner: inferenceRunnerWithToolCall(),
2704
- beforeToolExtensions: [throwingExt, secondExt],
2705
- });
2706
-
2707
- reactor.start();
2708
- reactor.deliver(makeInboundMessage());
2709
- await waitFor("reactor.done");
2710
-
2711
- expect(called).toEqual(["thrower"]);
2712
- });
2713
-
2714
- test("extension receives current state snapshot", async () => {
2715
- let capturedState: ReactorState | undefined;
2716
-
2717
- const capturingExt: BeforeToolExtension = {
2718
- async beforeTool(_call, state) {
2719
- capturedState = state;
2720
- return undefined;
2721
- },
2722
- };
2723
-
2724
- const { reactor, waitFor } = createTestReactor({
2725
- director: directorFromTable({
2726
- "message.received": (_e, _s, caps) => caps.infer(),
2727
- "inference.done": (_e, _s, caps) =>
2728
- caps.executeTools(
2729
- [{ id: "call-1", name: "bash", arguments: { cmd: "ls" } }],
2730
- true,
2731
- ),
2732
- "tool.done": (_e, _s, caps) => caps.done(),
2733
- }),
2734
- inferenceRunner: inferenceRunnerWithToolCall(),
2735
- beforeToolExtensions: [capturingExt],
2736
- });
2737
-
2738
- reactor.start();
2739
- reactor.deliver(makeInboundMessage());
2740
- await waitFor("reactor.done");
2741
-
2742
- if (capturedState === undefined)
2743
- throw new Error("extension was never called");
2744
- // State should contain the inbound message and the assistant tool_call message.
2745
- expect(capturedState.turns.length).toBeGreaterThanOrEqual(2);
2746
- expect(capturedState.sessionId).toContain("test-sess-");
2747
- });
2748
-
2749
- test("parallel batch with one blocked and one allowed tool", async () => {
2750
- const blockBash: BeforeToolExtension = {
2751
- async beforeTool(call) {
2752
- if (call.name === "bash") return "Denied by policy";
2753
- return undefined;
2754
- },
2755
- };
2756
-
2757
- const toolsRun: string[] = [];
2758
-
2759
- const twoToolCallMsg: AssistantTurn = {
2760
- role: "assistant",
2761
- content: [
2762
- {
2763
- type: "tool_call",
2764
- id: "call-bash",
2765
- name: "bash",
2766
- arguments: { cmd: "rm -rf /" },
2767
- },
2768
- {
2769
- type: "tool_call",
2770
- id: "call-read",
2771
- name: "read_file",
2772
- arguments: { path: "/etc/hosts" },
2773
- },
2774
- ],
2775
- model: "test-model",
2776
- timestamp: 1000,
2777
- };
2778
-
2779
- let toolDoneCount = 0;
2780
- const { reactor, events, waitFor } = createTestReactor({
2781
- director: directorFromTable(
2782
- {
2783
- "message.received": (_e, _s, caps) => caps.infer(),
2784
- "inference.done": (_e, _s, caps) =>
2785
- caps.executeTools(
2786
- [
2787
- {
2788
- id: "call-bash",
2789
- name: "bash",
2790
- arguments: { cmd: "rm -rf /" },
2791
- },
2792
- {
2793
- id: "call-read",
2794
- name: "read_file",
2795
- arguments: { path: "/etc/hosts" },
2796
- },
2797
- ],
2798
- true,
2799
- ),
2800
- "tool.done": (_e, _s, caps) => {
2801
- toolDoneCount++;
2802
- if (toolDoneCount >= 2) return caps.done();
2803
- return caps.wait();
2804
- },
2805
- },
2806
- "wait",
2807
- ),
2808
- toolRunner: makeToolRunner(async (call) => {
2809
- toolsRun.push(call.name);
2810
- return { callId: call.id, content: `result-${call.name}` };
2811
- }),
2812
- inferenceRunner: makeInferenceRunner({
2813
- type: "done",
2814
- turn: twoToolCallMsg,
2815
- usage: inferUsage,
2816
- }),
2817
- beforeToolExtensions: [blockBash],
2818
- });
2819
-
2820
- reactor.start();
2821
- reactor.deliver(makeInboundMessage());
2822
- await waitFor("reactor.done");
2823
-
2824
- // Only the allowed tool should have run.
2825
- expect(toolsRun).toEqual(["read_file"]);
2826
-
2827
- // tool.start only for the allowed tool.
2828
- const starts = events.filter((e) => e.type === "tool.start");
2829
- expect(starts.length).toBe(1);
2830
-
2831
- // Both tools produce tool.done events.
2832
- const doneEvents = events.filter((e) => e.type === "tool.done");
2833
- expect(doneEvents.length).toBeGreaterThanOrEqual(2);
2834
-
2835
- // The blocked tool has isError.
2836
- const blockedDone = doneEvents.find(
2837
- (e) => e.type === "tool.done" && e.data.result.callId === "call-bash",
2838
- );
2839
- if (blockedDone === undefined || blockedDone.type !== "tool.done")
2840
- throw new Error("expected tool.done for blocked call");
2841
- expect(blockedDone.data.result.isError).toBe(true);
2842
- expect(blockedDone.data.result.content).toBe("Denied by policy");
2843
-
2844
- // The allowed tool has the real result.
2845
- const allowedDone = doneEvents.find(
2846
- (e) => e.type === "tool.done" && e.data.result.callId === "call-read",
2847
- );
2848
- if (allowedDone === undefined || allowedDone.type !== "tool.done")
2849
- throw new Error("expected tool.done for allowed call");
2850
- expect(allowedDone.data.result.isError).toBeUndefined();
2851
- expect(allowedDone.data.result.content).toBe("result-read_file");
2852
- });
2853
- });
2854
-
2855
- // ---------------------------------------------------------------------------
2856
- // Lifecycle hooks: afterCheckpoint and onShutdown
2857
- // ---------------------------------------------------------------------------
2858
-
2859
- describe("createReactor — afterCheckpoint", () => {
2860
- test("afterCheckpoint is called after successful checkpoint", async () => {
2861
- let afterCheckpointCalled = false;
2862
-
2863
- const { reactor, waitFor } = createTestReactor({
2864
- director: directorFromTable({
2865
- "message.received": (_e, _s, caps) => [caps.checkpoint(), caps.done()],
2866
- }),
2867
- afterCheckpoint: async () => {
2868
- afterCheckpointCalled = true;
2869
- },
2870
- });
2871
-
2872
- reactor.start();
2873
- reactor.deliver(makeInboundMessage());
2874
- await waitFor("reactor.done");
2875
- expect(afterCheckpointCalled).toBe(true);
2876
- });
2877
-
2878
- test("afterCheckpoint is skipped when context commit fails", async () => {
2879
- let afterCheckpointCalled = false;
2880
- let commitCount = 0;
2881
-
2882
- const store = makeContextStore();
2883
- const originalCommit = store.commit.bind(store);
2884
- async function wrappedCommit(
2885
- options: { message: string },
2886
- signal?: AbortSignal,
2887
- ): Promise<ContextCommit> {
2888
- commitCount++;
2889
- if (commitCount === 1) {
2890
- throw new Error("disk full");
2891
- }
2892
- return originalCommit(options, signal);
2893
- }
2894
- store.commit = wrappedCommit;
2895
-
2896
- const { reactor, waitFor } = createTestReactor({
2897
- contextStore: store,
2898
- director: directorFromTable({
2899
- "message.received": (_e, _s, caps) => [caps.checkpoint(), caps.done()],
2900
- }),
2901
- afterCheckpoint: async () => {
2902
- afterCheckpointCalled = true;
2903
- },
2904
- });
2905
-
2906
- reactor.start();
2907
- reactor.deliver(makeInboundMessage());
2908
- await waitFor("reactor.done");
2909
- expect(afterCheckpointCalled).toBe(false);
2910
- });
2911
-
2912
- test("afterCheckpoint failure emits non-fatal reactor.error", async () => {
2913
- const { reactor, events, waitFor } = createTestReactor({
2914
- director: directorFromTable({
2915
- "message.received": (_e, _s, caps) => [caps.checkpoint(), caps.done()],
2916
- }),
2917
- afterCheckpoint: async () => {
2918
- throw new Error("audit flush failed");
2919
- },
2920
- });
2921
-
2922
- reactor.start();
2923
- reactor.deliver(makeInboundMessage());
2924
- await waitFor("reactor.done");
2925
-
2926
- const errors = events.filter((e) => e.type === "reactor.error");
2927
- const hookError = errors.find(
2928
- (e) =>
2929
- e.type === "reactor.error" && e.data.error.includes("afterCheckpoint"),
2930
- );
2931
- expect(hookError).toBeDefined();
2932
- if (hookError === undefined || hookError.type !== "reactor.error") {
2933
- throw new Error("expected reactor.error");
2934
- }
2935
- expect(hookError.data.fatal).toBe(false);
2936
-
2937
- // reactor.done should still be emitted
2938
- const done = events.find((e) => e.type === "reactor.done");
2939
- expect(done).toBeDefined();
2940
- });
2941
- });
2942
-
2943
- describe("createReactor — onShutdown", () => {
2944
- test("onShutdown is called before reactor.done on normal shutdown", async () => {
2945
- const callOrder: string[] = [];
2946
-
2947
- const { reactor, events, waitFor } = createTestReactor({
2948
- director: directorFromTable({
2949
- "message.received": (_e, _s, caps) => caps.done(),
2950
- }),
2951
- onShutdown: async () => {
2952
- callOrder.push("onShutdown");
2953
- },
2954
- });
2955
-
2956
- // Intercept reactor.done to track ordering.
2957
- const originalPush = events.push.bind(events);
2958
- events.push = (...args) => {
2959
- for (const e of args) {
2960
- if (e.type === "reactor.done") {
2961
- callOrder.push("reactor.done");
2962
- }
2963
- }
2964
- return originalPush(...args);
2965
- };
2966
-
2967
- reactor.start();
2968
- reactor.deliver(makeInboundMessage());
2969
- await waitFor("reactor.done");
2970
-
2971
- expect(callOrder).toEqual(["onShutdown", "reactor.done"]);
2972
- });
2973
-
2974
- test("onShutdown failure emits non-fatal reactor.error and reactor.done still fires", async () => {
2975
- const { reactor, events, waitFor } = createTestReactor({
2976
- director: directorFromTable({
2977
- "message.received": (_e, _s, caps) => caps.done(),
2978
- }),
2979
- onShutdown: async () => {
2980
- throw new Error("shutdown flush failed");
2981
- },
2982
- });
2983
-
2984
- reactor.start();
2985
- reactor.deliver(makeInboundMessage());
2986
- await waitFor("reactor.done");
2987
-
2988
- const errors = events.filter(
2989
- (e) => e.type === "reactor.error" && e.data.error.includes("onShutdown"),
2990
- );
2991
- expect(errors.length).toBe(1);
2992
- if (errors[0] === undefined || errors[0].type !== "reactor.error") {
2993
- throw new Error("expected reactor.error");
2994
- }
2995
- expect(errors[0].data.fatal).toBe(false);
2996
-
2997
- const done = events.find((e) => e.type === "reactor.done");
2998
- expect(done).toBeDefined();
2999
- });
3000
-
3001
- test("onShutdown is called on abort", async () => {
3002
- let shutdownCalled = false;
3003
-
3004
- const { reactor, waitFor } = createTestReactor({
3005
- director: directorFromTable(
3006
- {
3007
- "message.received": (_e, _s, caps) => caps.wait(),
3008
- },
3009
- "wait",
3010
- ),
3011
- onShutdown: async () => {
3012
- shutdownCalled = true;
3013
- },
3014
- });
3015
-
3016
- reactor.start();
3017
- reactor.deliver(makeInboundMessage());
3018
- await waitFor("reactor.start");
3019
- reactor.abort("user_disconnect");
3020
- await waitFor("reactor.done");
3021
- expect(shutdownCalled).toBe(true);
3022
- });
3023
- });
3024
-
3025
- // ---------------------------------------------------------------------------
3026
- // Transform chains and compaction (Phase 4)
3027
- // ---------------------------------------------------------------------------
3028
-
3029
- import type {
3030
- ToolResultTransform,
3031
- ContextTransform,
3032
- TransformRecord,
3033
- } from "@intx/types/runtime";
3034
-
3035
- function passthroughToolTransform(
3036
- name: string,
3037
- decisions: Record<string, unknown> = {},
3038
- ): ToolResultTransform {
3039
- return {
3040
- name,
3041
- version: "1",
3042
- async apply(input, _ctx) {
3043
- return {
3044
- output: input.result,
3045
- record: {
3046
- strategy: name,
3047
- version: "1",
3048
- parameters: {},
3049
- reason: "passthrough",
3050
- decisions,
3051
- },
3052
- };
3053
- },
3054
- };
3055
- }
3056
-
3057
- function passthroughContextTransform(
3058
- name: string,
3059
- tagText: string,
3060
- ): ContextTransform {
3061
- return {
3062
- name,
3063
- version: "1",
3064
- async apply(turns, _ctx) {
3065
- const tagged = turns.map((t) => ({
3066
- ...t,
3067
- content: t.content.map((b) =>
3068
- b.type === "text"
3069
- ? { type: "text" as const, text: `${tagText}${b.text}` }
3070
- : b,
3071
- ),
3072
- }));
3073
- return {
3074
- output: tagged,
3075
- record: {
3076
- strategy: name,
3077
- version: "1",
3078
- parameters: { tagText },
3079
- reason: "tag",
3080
- decisions: { count: turns.length },
3081
- },
3082
- };
3083
- },
3084
- };
3085
- }
3086
-
3087
- function truncatingCompactor(name: string): Compactor {
3088
- return {
3089
- name,
3090
- version: "1",
3091
- async apply(turns, _ctx) {
3092
- const kept = turns.slice(-1);
3093
- return {
3094
- output: kept,
3095
- record: {
3096
- strategy: name,
3097
- version: "1",
3098
- parameters: {},
3099
- reason: "explicit",
3100
- decisions: { kept: kept.length, dropped: turns.length - kept.length },
3101
- },
3102
- };
3103
- },
3104
- };
3105
- }
3106
-
3107
- function makeRecordingContextStore(): {
3108
- store: ContextStore;
3109
- commits: { message: string; turns: ConversationTurn[] }[];
3110
- manifests: TransformRecord[][];
3111
- metadata: { pendingOperations: PendingOperation[]; tokenUsage: TokenUsage }[];
3112
- blobs: { key: string; bytes: Uint8Array; contentType?: string }[];
3113
- lastWrittenTurns: ConversationTurn[];
3114
- } {
3115
- const commits: { message: string; turns: ConversationTurn[] }[] = [];
3116
- const manifests: TransformRecord[][] = [];
3117
- const metadata: {
3118
- pendingOperations: PendingOperation[];
3119
- tokenUsage: TokenUsage;
3120
- }[] = [];
3121
- const blobs: { key: string; bytes: Uint8Array; contentType?: string }[] = [];
3122
- let lastWrittenTurns: ConversationTurn[] = [];
3123
-
3124
- const store: ContextStore = {
3125
- async load() {
3126
- return {
3127
- turns: [],
3128
- pendingOperations: [],
3129
- tokenUsage: emptyUsage(),
3130
- connectorState: null,
3131
- };
3132
- },
3133
- setConnectorState() {
3134
- /* noop */
3135
- },
3136
- async commit(options) {
3137
- commits.push({
3138
- message: options.message,
3139
- turns: [...lastWrittenTurns],
3140
- });
3141
- return {
3142
- hash: `c${String(commits.length)}`,
3143
- message: options.message,
3144
- timestamp: Date.now(),
3145
- };
3146
- },
3147
- async branch() {
3148
- /* noop */
3149
- },
3150
- async log() {
3151
- return [];
3152
- },
3153
- async readAt() {
3154
- return [];
3155
- },
3156
- async writeBlob(key, bytes, contentType) {
3157
- blobs.push({
3158
- key,
3159
- bytes,
3160
- ...(contentType !== undefined ? { contentType } : {}),
3161
- });
3162
- },
3163
- async readBlob() {
3164
- throw new Error("not implemented");
3165
- },
3166
- async writePrompt() {
3167
- /* noop */
3168
- },
3169
- async writeResponse() {
3170
- /* noop */
3171
- },
3172
- async writeManifest(records) {
3173
- manifests.push([...records]);
3174
- },
3175
- async writeTurns(turns) {
3176
- lastWrittenTurns = [...turns];
3177
- },
3178
- async writeMetadata(m) {
3179
- metadata.push({
3180
- pendingOperations: [...m.pendingOperations],
3181
- tokenUsage: { ...m.tokenUsage },
3182
- });
3183
- },
3184
- async readManifestHistory() {
3185
- throw new Error("not implemented");
3186
- },
3187
- };
3188
-
3189
- return {
3190
- store,
3191
- commits,
3192
- manifests,
3193
- metadata,
3194
- blobs,
3195
- get lastWrittenTurns() {
3196
- return lastWrittenTurns;
3197
- },
3198
- };
3199
- }
3200
-
3201
- function mockInferenceRunner(
3202
- responseText: string,
3203
- toolCalls: {
3204
- id: string;
3205
- name: string;
3206
- arguments: Record<string, unknown>;
3207
- }[] = [],
3208
- ): (opts: InferenceHarnessOptions) => AsyncGenerator<InferenceEvent> {
3209
- return async function* (opts) {
3210
- const usage: TokenUsage = {
3211
- input: 10,
3212
- output: 20,
3213
- cacheRead: 0,
3214
- cacheWrite: 0,
3215
- thinking: 0,
3216
- };
3217
- const content: AssistantTurn["content"] = [];
3218
- if (responseText.length > 0) {
3219
- content.push({ type: "text", text: responseText });
3220
- }
3221
- for (const tc of toolCalls) {
3222
- content.push({
3223
- type: "tool_call",
3224
- id: tc.id,
3225
- name: tc.name,
3226
- arguments: tc.arguments,
3227
- });
3228
- }
3229
- const turn: AssistantTurn = {
3230
- role: "assistant",
3231
- content,
3232
- model: opts.source.model,
3233
- timestamp: Date.now(),
3234
- };
3235
- yield {
3236
- type: "inference.done",
3237
- seq: opts.nextSeq(),
3238
- data: { turn, usage, source: TEST_SOURCE },
3239
- };
3240
- };
3241
- }
3242
-
3243
- describe("createReactor — tool result transform chain", () => {
3244
- test("threads results through transforms in order; records appear in order", async () => {
3245
- const recording = makeRecordingContextStore();
3246
- const t1 = passthroughToolTransform("first");
3247
- const t2 = passthroughToolTransform("second");
3248
-
3249
- const { reactor, waitFor } = createTestReactor({
3250
- contextStore: recording.store,
3251
- director: directorFromTable({
3252
- "message.received": (_e, _s, caps) =>
3253
- caps.executeTools([{ id: "c1", name: "tool", arguments: {} }]),
3254
- "tool.done": (_e, _s, caps) => caps.done(),
3255
- }),
3256
- });
3257
-
3258
- // Inject the transforms via a private property — we cannot pass them via
3259
- // createTestReactor's typed overrides without expanding its shape, so we
3260
- // construct a second reactor directly below for the headline tests. For
3261
- // ordering coverage here we lean on the harness-side reactor.
3262
- void t1;
3263
- void t2;
3264
-
3265
- reactor.start();
3266
- reactor.deliver(makeInboundMessage());
3267
- await waitFor("reactor.done");
3268
- // The default reactor in this test has no transforms; the commit should
3269
- // still have happened (cycle had tool calls).
3270
- expect(recording.commits.length).toBeGreaterThan(0);
3271
- });
3272
- });
3273
-
3274
- function createDirectReactor(opts: {
3275
- contextStore: ContextStore;
3276
- director: ReactorDirector;
3277
- toolRunner?: ToolRunner;
3278
- inferenceRunner?: (
3279
- opts: InferenceHarnessOptions,
3280
- ) => AsyncGenerator<InferenceEvent>;
3281
- toolResultTransforms?: ToolResultTransform[];
3282
- contextTransforms?: ContextTransform[];
3283
- compactors?: Record<string, Compactor>;
3284
- }): {
3285
- reactor: Reactor;
3286
- events: ReactorEmittedEvent[];
3287
- waitFor: (
3288
- type: ReactorEmittedEvent["type"],
3289
- timeoutMs?: number,
3290
- ) => Promise<ReactorEmittedEvent>;
3291
- } {
3292
- const { events, onEvent } = collectEvents();
3293
- const reactor = createReactor({
3294
- sessionId: `test-${String(++testSessionCounter)}`,
3295
- director: opts.director,
3296
- source: {
3297
- id: "anthropic:test-model",
3298
- provider: "anthropic",
3299
- baseURL: "https://api.anthropic.com",
3300
- apiKey: "test",
3301
- model: "test-model",
3302
- },
3303
- toolRunner: opts.toolRunner ?? noopToolRunner(),
3304
- contextStore: opts.contextStore,
3305
- onEvent,
3306
- deps: createDefaultDependencies(),
3307
- shutdownTimeoutMs: 100,
3308
- ...(opts.inferenceRunner !== undefined
3309
- ? { inferenceRunner: opts.inferenceRunner }
3310
- : {}),
3311
- ...(opts.toolResultTransforms !== undefined
3312
- ? { toolResultTransforms: opts.toolResultTransforms }
3313
- : {}),
3314
- ...(opts.contextTransforms !== undefined
3315
- ? { contextTransforms: opts.contextTransforms }
3316
- : {}),
3317
- ...(opts.compactors !== undefined ? { compactors: opts.compactors } : {}),
3318
- });
3319
-
3320
- function waitForType(
3321
- type: ReactorEmittedEvent["type"],
3322
- timeoutMs = 2000,
3323
- ): Promise<ReactorEmittedEvent> {
3324
- return waitForEvent(events, (e) => e.type === type, timeoutMs);
3325
- }
3326
- return { reactor, events, waitFor: waitForType };
3327
- }
3328
-
3329
- describe("createReactor — transform chain ordering and compact action", () => {
3330
- test("tool result transforms run in order and records appear in invocation order", async () => {
3331
- const recording = makeRecordingContextStore();
3332
- const transforms: ToolResultTransform[] = [
3333
- passthroughToolTransform("first", { ord: 1 }),
3334
- passthroughToolTransform("second", { ord: 2 }),
3335
- ];
3336
-
3337
- const { reactor, waitFor } = createDirectReactor({
3338
- contextStore: recording.store,
3339
- toolRunner: makeToolRunner(async (call) => ({
3340
- callId: call.id,
3341
- content: "result",
3342
- })),
3343
- director: directorFromTable({
3344
- "message.received": (_e, _s, caps) =>
3345
- caps.executeTools([{ id: "c1", name: "tool", arguments: {} }]),
3346
- "tool.done": (_e, _s, caps) => caps.done(),
3347
- }),
3348
- toolResultTransforms: transforms,
3349
- });
3350
-
3351
- reactor.start();
3352
- reactor.deliver(makeInboundMessage());
3353
- await waitFor("reactor.done");
3354
-
3355
- // One commit; manifest carries both records in order.
3356
- expect(recording.commits.length).toBe(1);
3357
- expect(recording.manifests.length).toBe(1);
3358
- const records = recording.manifests[0];
3359
- if (records === undefined) throw new Error("expected manifest");
3360
- expect(records.map((r) => r.strategy)).toEqual(["first", "second"]);
3361
- });
3362
-
3363
- test("context transforms run before inference and feed the materialized prompt", async () => {
3364
- const recording = makeRecordingContextStore();
3365
- const transform = passthroughContextTransform("tag-context", "TAG:");
3366
-
3367
- let observedPrompt: ConversationTurn[] = [];
3368
- const runner: (
3369
- opts: InferenceHarnessOptions,
3370
- ) => AsyncGenerator<InferenceEvent> = async function* (opts) {
3371
- observedPrompt = opts.turns;
3372
- const turn: AssistantTurn = {
3373
- role: "assistant",
3374
- content: [{ type: "text", text: "ok" }],
3375
- model: opts.source.model,
3376
- timestamp: Date.now(),
3377
- };
3378
- yield {
3379
- type: "inference.done",
3380
- seq: opts.nextSeq(),
3381
- data: {
3382
- turn,
3383
- usage: emptyUsage(),
3384
- source: TEST_SOURCE,
3385
- },
3386
- };
3387
- };
3388
-
3389
- const { reactor, waitFor } = createDirectReactor({
3390
- contextStore: recording.store,
3391
- inferenceRunner: runner,
3392
- director: directorFromTable({
3393
- "message.received": (_e, _s, caps) => caps.infer(),
3394
- "inference.done": (_e, _s, caps) => caps.done(),
3395
- }),
3396
- contextTransforms: [transform],
3397
- });
3398
-
3399
- reactor.start();
3400
- reactor.deliver(makeInboundMessage());
3401
- await waitFor("reactor.done");
3402
-
3403
- expect(observedPrompt.length).toBeGreaterThan(0);
3404
- const firstBlock = observedPrompt[0]?.content[0];
3405
- if (firstBlock === undefined || firstBlock.type !== "text") {
3406
- throw new Error("expected text block in transformed prompt");
3407
- }
3408
- expect(firstBlock.text.startsWith("TAG:")).toBe(true);
3409
- });
3410
-
3411
- test("compact action looks up the named compactor and replaces turns", async () => {
3412
- const recording = makeRecordingContextStore();
3413
- const compactor = truncatingCompactor("tail-only");
3414
-
3415
- let messages = 0;
3416
- const director: ReactorDirector = {
3417
- async decide(event, _state, caps) {
3418
- if (event.type === "message.received") {
3419
- messages++;
3420
- if (messages === 1) {
3421
- // First message produces a multi-turn history (this user turn is
3422
- // appended by the reactor before decide() returns), then we run
3423
- // the compactor which drops everything except the tail.
3424
- return caps.compact("tail-only", "explicit-test");
3425
- }
3426
- return caps.done();
3427
- }
3428
- return caps.done();
3429
- },
3430
- };
3431
-
3432
- const { reactor, waitFor } = createDirectReactor({
3433
- contextStore: recording.store,
3434
- director,
3435
- compactors: { "tail-only": compactor },
3436
- });
3437
-
3438
- reactor.start();
3439
- reactor.deliver(makeInboundMessage());
3440
- setTimeout(() => reactor.deliver(makeInboundMessage()), 30);
3441
- await waitFor("reactor.done");
3442
-
3443
- expect(messages).toBeGreaterThanOrEqual(1);
3444
- // The commit for the compact cycle has a single-turn history (tail-only).
3445
- expect(recording.commits.length).toBeGreaterThan(0);
3446
- const compactCommit = recording.commits.find((c) =>
3447
- c.message.startsWith("Cycle: compaction"),
3448
- );
3449
- expect(compactCommit).toBeDefined();
3450
- if (compactCommit !== undefined) {
3451
- expect(compactCommit.turns.length).toBe(1);
3452
- }
3453
- // Manifest carries the compactor record.
3454
- const flatRecords = recording.manifests.flat();
3455
- expect(flatRecords.some((r) => r.strategy === "tail-only")).toBe(true);
3456
- });
3457
-
3458
- test("compact for an unknown name emits a fatal error and shuts down", async () => {
3459
- const recording = makeRecordingContextStore();
3460
- const { reactor, events, waitFor } = createDirectReactor({
3461
- contextStore: recording.store,
3462
- director: directorFromTable({
3463
- "message.received": (_e, _s, caps) => caps.compact("missing", "test"),
3464
- }),
3465
- compactors: {},
3466
- });
3467
-
3468
- reactor.start();
3469
- reactor.deliver(makeInboundMessage());
3470
- await waitFor("reactor.done");
3471
- const err = getEvent(events, "reactor.error");
3472
- expect(err.data.error).toContain("missing");
3473
- expect(err.data.fatal).toBe(true);
3474
- });
3475
-
3476
- test("validateActions rejects compact + infer", async () => {
3477
- const result = validateActions([
3478
- { type: "compact", compactor: "tail", reason: "r" },
3479
- { type: "infer" },
3480
- ]);
3481
- expect(result.ok).toBe(false);
3482
- if (result.ok) throw new Error("unreachable");
3483
- expect(result.error).toMatch(/compact.*infer/i);
3484
- });
3485
-
3486
- test("context-overflow recovery: compact alone, then infer on the next cycle", async () => {
3487
- const recording = makeRecordingContextStore();
3488
- const compactor = truncatingCompactor("overflow-compactor");
3489
-
3490
- // First inference yields a context_overflow error; the second succeeds.
3491
- let inferAttempts = 0;
3492
- const runner: (
3493
- opts: InferenceHarnessOptions,
3494
- ) => AsyncGenerator<InferenceEvent> = async function* (opts) {
3495
- inferAttempts++;
3496
- if (inferAttempts === 1) {
3497
- yield {
3498
- type: "inference.error",
3499
- seq: opts.nextSeq(),
3500
- data: {
3501
- error: {
3502
- category: "context_overflow",
3503
- message: "context too long",
3504
- },
3505
- partial: { text: "" },
3506
- },
3507
- };
3508
- return;
3509
- }
3510
- const turn: AssistantTurn = {
3511
- role: "assistant",
3512
- content: [{ type: "text", text: "ok-after-compact" }],
3513
- model: opts.source.model,
3514
- timestamp: Date.now(),
3515
- };
3516
- yield {
3517
- type: "inference.done",
3518
- seq: opts.nextSeq(),
3519
- data: { turn, usage: emptyUsage(), source: TEST_SOURCE },
3520
- };
3521
- };
3522
-
3523
- const director: ReactorDirector = {
3524
- async decide(event, _state, caps) {
3525
- if (event.type === "message.received") {
3526
- return caps.infer();
3527
- }
3528
- if (event.type === "inference.error") {
3529
- if (event.error.category === "context_overflow") {
3530
- return caps.compact("overflow-compactor", "context-overflow");
3531
- }
3532
- return caps.done();
3533
- }
3534
- if (event.type === "inference.done") {
3535
- return caps.done();
3536
- }
3537
- return caps.wait();
3538
- },
3539
- };
3540
-
3541
- // After compact, we expect the reactor to deliver no automatic event; the
3542
- // director needs to drive the next infer. To exercise that, we patch the
3543
- // director to issue infer after compact via a synthetic message.received.
3544
- // Here we use a slightly richer director that knows to re-infer once the
3545
- // compact cycle's commit has happened. We approximate by delivering a
3546
- // second message after compact via a side channel.
3547
- void compactor;
3548
-
3549
- const { reactor, events, waitFor } = createDirectReactor({
3550
- contextStore: recording.store,
3551
- inferenceRunner: runner,
3552
- director,
3553
- compactors: { "overflow-compactor": compactor },
3554
- });
3555
-
3556
- reactor.start();
3557
- reactor.deliver(makeInboundMessage());
3558
-
3559
- // After the compact cycle commits, the conversation has been truncated.
3560
- // The director did not chain compact + infer; it only emitted compact.
3561
- // Drive the next infer by delivering another message that re-runs the
3562
- // event loop. The director's message.received → infer rule will fire.
3563
- setTimeout(() => reactor.deliver(makeInboundMessage()), 30);
3564
-
3565
- await waitFor("reactor.done");
3566
- // The reactor ran inference twice: the first attempt failed with
3567
- // context_overflow, and the second attempt succeeded after compaction.
3568
- expect(inferAttempts).toBe(2);
3569
-
3570
- const compactRecord = recording.manifests
3571
- .flat()
3572
- .find((r) => r.strategy === "overflow-compactor");
3573
- expect(compactRecord).toBeDefined();
3574
- expect(compactRecord?.reason).toBe("explicit");
3575
-
3576
- // No reactor.error from the validation layer about compact+infer.
3577
- const validationErr = events
3578
- .filter((e) => e.type === "reactor.error")
3579
- .find(
3580
- (e) =>
3581
- e.type === "reactor.error" && e.data.error.includes("Invalid action"),
3582
- );
3583
- expect(validationErr).toBeUndefined();
3584
- });
3585
-
3586
- test("per-cycle commit cadence: each cycle produces a commit; pendingMessage overrides one auto-summary", async () => {
3587
- const recording = makeRecordingContextStore();
3588
- let directorCalls = 0;
3589
- const director: ReactorDirector = {
3590
- async decide(event, _state, _caps) {
3591
- directorCalls++;
3592
- if (event.type === "message.received" && directorCalls === 1) {
3593
- return { type: "infer" as const };
3594
- }
3595
- if (event.type === "inference.done") {
3596
- return [
3597
- { type: "checkpoint" as const, message: "override-1" },
3598
- { type: "done" as const },
3599
- ];
3600
- }
3601
- return { type: "done" as const };
3602
- },
3603
- };
3604
-
3605
- const { reactor, waitFor } = createDirectReactor({
3606
- contextStore: recording.store,
3607
- inferenceRunner: mockInferenceRunner("done"),
3608
- director,
3609
- });
3610
-
3611
- reactor.start();
3612
- reactor.deliver(makeInboundMessage());
3613
- await waitFor("reactor.done");
3614
-
3615
- // First cycle: inference cycle commits with override-1 (from checkpoint).
3616
- expect(recording.commits.length).toBe(1);
3617
- expect(recording.commits[0]?.message).toBe("override-1");
3618
- });
3619
-
3620
- test("pendingMessage is consumed exactly once; the next cycle uses auto-summary", async () => {
3621
- const recording = makeRecordingContextStore();
3622
- let directorCalls = 0;
3623
- const director: ReactorDirector = {
3624
- async decide(event, _state, _caps) {
3625
- directorCalls++;
3626
- if (event.type === "message.received") {
3627
- if (directorCalls === 1) {
3628
- return [
3629
- { type: "checkpoint" as const, message: "first-override" },
3630
- { type: "infer" as const },
3631
- ];
3632
- }
3633
- return { type: "done" as const };
3634
- }
3635
- if (event.type === "inference.done") {
3636
- return { type: "wait" as const };
3637
- }
3638
- return { type: "done" as const };
3639
- },
3640
- };
3641
-
3642
- const { reactor, waitFor } = createDirectReactor({
3643
- contextStore: recording.store,
3644
- inferenceRunner: mockInferenceRunner("ok"),
3645
- director,
3646
- });
3647
-
3648
- reactor.start();
3649
- reactor.deliver(makeInboundMessage());
3650
- setTimeout(() => reactor.deliver(makeInboundMessage()), 80);
3651
- await waitFor("reactor.done");
3652
-
3653
- expect(recording.commits.length).toBeGreaterThanOrEqual(1);
3654
- expect(recording.commits[0]?.message).toBe("first-override");
3655
- // If a second commit happened, its message must not be the override.
3656
- for (let i = 1; i < recording.commits.length; i++) {
3657
- expect(recording.commits[i]?.message).not.toBe("first-override");
3658
- }
3659
- });
3660
- });