@intx/inference 0.1.2 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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 +38 -0
  5. package/dist/adapter.js +31 -0
  6. package/dist/assembly.d.ts +68 -0
  7. package/dist/assembly.js +132 -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 +32 -0
  13. package/dist/authz-extension.js +100 -0
  14. package/dist/correlation.d.ts +25 -0
  15. package/dist/correlation.js +32 -0
  16. package/dist/default-director.d.ts +111 -0
  17. package/dist/default-director.js +199 -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 +27 -0
  23. package/dist/gates.js +80 -0
  24. package/dist/harness.d.ts +147 -0
  25. package/dist/harness.js +1319 -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 +33 -0
  31. package/dist/providers/anthropic.js +670 -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 +3 -0
  35. package/dist/providers/google-genai.js +1196 -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 +3 -0
  39. package/dist/providers/openai.js +609 -0
  40. package/dist/reactor.d.ts +50 -0
  41. package/dist/reactor.js +920 -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 +117 -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 +21 -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,728 +0,0 @@
1
- import { describe, test, expect } from "bun:test";
2
-
3
- import { createReactorAssembly } from "./assembly";
4
- import { createInboundMessage } from "@intx/mime";
5
- import type { AuthzCallResult } from "./authz-extension";
6
- import type { ReactorEmittedEvent } from "./reactor";
7
-
8
- import type {
9
- AuditStore,
10
- BeforeToolExtension,
11
- ContextCommit,
12
- ContextStore,
13
- ConversationTurn,
14
- InboundMessage,
15
- InferenceSource,
16
- ReactorCapabilities,
17
- ReactorDirector,
18
- ReactorState,
19
- TokenUsage,
20
- ToolResultTransform,
21
- ToolRunner,
22
- } from "@intx/types/runtime";
23
-
24
- import type { AuditRecord } from "@intx/types/audit";
25
-
26
- // ---------------------------------------------------------------------------
27
- // Fakes
28
- // ---------------------------------------------------------------------------
29
-
30
- function emptyUsage(): TokenUsage {
31
- return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, thinking: 0 };
32
- }
33
-
34
- type RecordingContextStore = ContextStore & {
35
- blobs: Map<string, { bytes: Uint8Array; contentType: string | undefined }>;
36
- };
37
-
38
- function makeContextStore(
39
- turns: ConversationTurn[] = [],
40
- ): RecordingContextStore {
41
- const blobs = new Map<
42
- string,
43
- { bytes: Uint8Array; contentType: string | undefined }
44
- >();
45
- async function commit(
46
- options: { message: string },
47
- _signal?: AbortSignal,
48
- ): Promise<ContextCommit> {
49
- return { hash: "abc", message: options.message, timestamp: Date.now() };
50
- }
51
- return {
52
- blobs,
53
- async load() {
54
- return {
55
- turns,
56
- pendingOperations: [],
57
- tokenUsage: emptyUsage(),
58
- connectorState: null,
59
- };
60
- },
61
- setConnectorState() {
62
- /* noop */
63
- },
64
- commit,
65
- async branch() {
66
- /* noop */
67
- },
68
- async log() {
69
- return [];
70
- },
71
- async readAt() {
72
- return [];
73
- },
74
- async writeBlob(key, bytes, contentType) {
75
- blobs.set(key, { bytes, contentType });
76
- },
77
- async readBlob(key) {
78
- const entry = blobs.get(key);
79
- if (entry === undefined) {
80
- throw new Error(`no blob for key ${key}`);
81
- }
82
- return entry.bytes;
83
- },
84
- async writePrompt() {
85
- /* noop */
86
- },
87
- async writeResponse() {
88
- /* noop */
89
- },
90
- async writeManifest() {
91
- /* noop */
92
- },
93
- async writeTurns() {
94
- /* noop */
95
- },
96
- async writeMetadata() {
97
- /* noop */
98
- },
99
- async readManifestHistory() {
100
- return [];
101
- },
102
- };
103
- }
104
-
105
- function makeToolRunner(content: string | Record<string, unknown>): ToolRunner {
106
- return {
107
- async run(call) {
108
- return { callId: call.id, content };
109
- },
110
- };
111
- }
112
-
113
- function makeInboundMessage(): InboundMessage {
114
- return createInboundMessage({
115
- from: "test@example.com",
116
- to: "agent@example.com",
117
- content: "hello",
118
- });
119
- }
120
-
121
- // Director that executes a single tool call on message.received and ends on
122
- // tool.done, optionally with a checkpoint to trigger afterCheckpoint flush.
123
- function makeToolExecDirector(
124
- toolName: string,
125
- toolArgs: Record<string, unknown> = {},
126
- options: { checkpoint?: boolean; callId?: string } = {},
127
- ): ReactorDirector {
128
- const callId = options.callId ?? `call-${toolName}`;
129
- return {
130
- async decide(
131
- event: { type: string },
132
- _state: ReactorState,
133
- caps: ReactorCapabilities,
134
- ) {
135
- if (event.type === "message.received") {
136
- return caps.executeTools([
137
- { id: callId, name: toolName, arguments: toolArgs },
138
- ]);
139
- }
140
- if (event.type === "tool.done") {
141
- if (options.checkpoint === true) {
142
- return [caps.checkpoint(), caps.done()];
143
- }
144
- return caps.done();
145
- }
146
- return caps.done();
147
- },
148
- };
149
- }
150
-
151
- function waitForDone(events: ReactorEmittedEvent[]): Promise<void> {
152
- return new Promise((resolve, reject) => {
153
- const deadline = setTimeout(
154
- () => reject(new Error("Timed out waiting for reactor.done")),
155
- 5000,
156
- );
157
- const check = () => {
158
- if (events.some((e) => e.type === "reactor.done")) {
159
- clearTimeout(deadline);
160
- resolve();
161
- return;
162
- }
163
- setTimeout(check, 10);
164
- };
165
- check();
166
- });
167
- }
168
-
169
- function source(): InferenceSource {
170
- return {
171
- id: "anthropic:test-model",
172
- provider: "anthropic",
173
- baseURL: "https://api.anthropic.com",
174
- apiKey: "test",
175
- model: "test-model",
176
- };
177
- }
178
-
179
- function makeRecordingAuditStore(): AuditStore & {
180
- getCommitted(): AuditRecord[][];
181
- } {
182
- const committed: AuditRecord[][] = [];
183
- return {
184
- async commitAudit(records: AuditRecord[]) {
185
- committed.push([...records]);
186
- },
187
- async loadAudit() {
188
- return committed.flat();
189
- },
190
- async commitErrors() {
191
- /* noop */
192
- },
193
- getCommitted() {
194
- return committed;
195
- },
196
- };
197
- }
198
-
199
- function allowAuthorize(): Promise<AuthzCallResult> {
200
- return Promise.resolve({
201
- effect: "allow" as const,
202
- matchingGrants: [],
203
- resolvedBy: null,
204
- });
205
- }
206
-
207
- // A recording before-tool extension that captures every call and the order in
208
- // which it ran relative to peers via the shared trace array.
209
- function makeRecordingExtension(
210
- name: string,
211
- trace: string[],
212
- ): BeforeToolExtension {
213
- return {
214
- async beforeTool(_call) {
215
- trace.push(name);
216
- return undefined;
217
- },
218
- };
219
- }
220
-
221
- // A recording tool-result transform that captures the order of invocation.
222
- function makeRecordingTransform(
223
- name: string,
224
- trace: string[],
225
- ): ToolResultTransform {
226
- return {
227
- name,
228
- version: "1",
229
- async apply(input) {
230
- trace.push(name);
231
- return {
232
- output: input.result,
233
- record: {
234
- strategy: name,
235
- version: "1",
236
- parameters: {},
237
- reason: "noop",
238
- decisions: {},
239
- },
240
- };
241
- },
242
- };
243
- }
244
-
245
- function collectEvents(): {
246
- events: ReactorEmittedEvent[];
247
- onEvent: (e: ReactorEmittedEvent) => void;
248
- } {
249
- const events: ReactorEmittedEvent[] = [];
250
- return { events, onEvent: (e) => events.push(e) };
251
- }
252
-
253
- // ---------------------------------------------------------------------------
254
- // Tests
255
- // ---------------------------------------------------------------------------
256
-
257
- describe("createReactorAssembly", () => {
258
- test("default size-cap transform is the first tool-result transform when no caller transforms are provided", async () => {
259
- // We observe the size-cap transform via its side effect: a large tool
260
- // result spills to ContextStore.writeBlob. With no caller transforms,
261
- // the helper-supplied size-cap is the only transform and the spill must
262
- // appear in the blob store.
263
- const contextStore = makeContextStore();
264
- const events = collectEvents();
265
- const big = "x".repeat(200);
266
-
267
- const { reactor } = createReactorAssembly({
268
- sessionId: "s1",
269
- director: makeToolExecDirector("t", {}, { callId: "c1" }),
270
- source: source(),
271
- toolRunner: makeToolRunner(big),
272
- contextStore,
273
- onEvent: events.onEvent,
274
- sizeCapMaxChars: 50,
275
- shutdownTimeoutMs: 100,
276
- });
277
-
278
- reactor.start();
279
- reactor.deliver(makeInboundMessage());
280
- await waitForDone(events.events);
281
-
282
- const spill = contextStore.blobs.get("c1");
283
- expect(spill).toBeDefined();
284
- if (spill === undefined) throw new Error("expected spill");
285
- expect(new TextDecoder().decode(spill.bytes)).toBe(big);
286
- });
287
-
288
- test("caller's toolResultTransforms run after the default size-cap transform (order preserved)", async () => {
289
- // Drive an over-cap result. The caller's first transform should observe
290
- // an already-truncated payload (size-cap ran first), and both caller
291
- // transforms should run in the order supplied.
292
- const trace: string[] = [];
293
- const seen: string[] = [];
294
- const captureTransform: ToolResultTransform = {
295
- name: "capture",
296
- version: "1",
297
- async apply(input) {
298
- trace.push("capture");
299
- seen.push(
300
- typeof input.result.content === "string"
301
- ? input.result.content.slice(0, 5)
302
- : "non-string",
303
- );
304
- return {
305
- output: input.result,
306
- record: {
307
- strategy: "capture",
308
- version: "1",
309
- parameters: {},
310
- reason: "noop",
311
- decisions: {},
312
- },
313
- };
314
- },
315
- };
316
- const tailTransform = makeRecordingTransform("tail", trace);
317
-
318
- const contextStore = makeContextStore();
319
- const events = collectEvents();
320
- const big = "y".repeat(200);
321
-
322
- const { reactor } = createReactorAssembly({
323
- sessionId: "s2",
324
- director: makeToolExecDirector("t", {}, { callId: "c2" }),
325
- source: source(),
326
- toolRunner: makeToolRunner(big),
327
- contextStore,
328
- onEvent: events.onEvent,
329
- sizeCapMaxChars: 20,
330
- toolResultTransforms: [captureTransform, tailTransform],
331
- shutdownTimeoutMs: 100,
332
- });
333
-
334
- reactor.start();
335
- reactor.deliver(makeInboundMessage());
336
- await waitForDone(events.events);
337
-
338
- expect(trace).toEqual(["capture", "tail"]);
339
- // The captured prefix is from `big`, confirming the caller transform
340
- // saw the size-cap's truncated marker (which begins with the kept
341
- // characters from `big`).
342
- expect(seen[0]).toBe("yyyyy");
343
- });
344
-
345
- test("custom sizeCapMaxChars flows into the size-cap transform", async () => {
346
- // Drive an over-cap result with maxChars=10 and assert the spilled blob's
347
- // bytes equal the original content (size-cap always spills the full
348
- // payload when it caps).
349
- const contextStore = makeContextStore();
350
- const events = collectEvents();
351
- const payload = "z".repeat(50);
352
-
353
- const { reactor } = createReactorAssembly({
354
- sessionId: "s3",
355
- director: makeToolExecDirector("t", {}, { callId: "c3" }),
356
- source: source(),
357
- toolRunner: makeToolRunner(payload),
358
- contextStore,
359
- onEvent: events.onEvent,
360
- sizeCapMaxChars: 10,
361
- shutdownTimeoutMs: 100,
362
- });
363
-
364
- reactor.start();
365
- reactor.deliver(makeInboundMessage());
366
- await waitForDone(events.events);
367
-
368
- const spill = contextStore.blobs.get("c3");
369
- expect(spill).toBeDefined();
370
- if (spill === undefined) throw new Error("expected spill");
371
- expect(spill.bytes.byteLength).toBe(50);
372
- });
373
-
374
- test("with authorize set, an authz before-tool extension is composed in front of caller's beforeToolExtensions", async () => {
375
- // No caller extensions: a deny authorize should still block the call by
376
- // virtue of the assembly-built authz extension running first.
377
- const contextStore = makeContextStore();
378
- const events = collectEvents();
379
- const auditStore = makeRecordingAuditStore();
380
-
381
- const { reactor } = createReactorAssembly({
382
- sessionId: "s4",
383
- director: makeToolExecDirector("forbidden", {}, { callId: "c4" }),
384
- source: source(),
385
- toolRunner: makeToolRunner("never runs"),
386
- contextStore,
387
- onEvent: events.onEvent,
388
- authorize: () =>
389
- Promise.resolve({
390
- effect: "deny" as const,
391
- matchingGrants: [],
392
- resolvedBy: null,
393
- }),
394
- auditStore,
395
- shutdownTimeoutMs: 100,
396
- });
397
-
398
- reactor.start();
399
- reactor.deliver(makeInboundMessage());
400
- await waitForDone(events.events);
401
-
402
- const records = auditStore.getCommitted().flat();
403
- expect(records.length).toBe(1);
404
- const record = records[0];
405
- if (record === undefined) throw new Error("expected record");
406
- expect(record.tool).toBe("forbidden");
407
- if (record.authz === null) throw new Error("expected authz");
408
- expect(record.authz.blocked).toBe(true);
409
- expect(record.authz.effect).toBe("deny");
410
- });
411
-
412
- test("with authorize + caller's beforeToolExtensions, authz runs first; caller's extensions follow in order", async () => {
413
- // The authz extension allows; the recording extension records that it
414
- // saw the call. If authz had been ordered after the recording extension,
415
- // a deny would still block — but we want to verify ordering specifically,
416
- // so we use allow and assert the recording extension still ran (proves
417
- // authz didn't block) and we observe the order via the trace.
418
- const contextStore = makeContextStore();
419
- const events = collectEvents();
420
- const trace: string[] = [];
421
- const recordingExt = makeRecordingExtension("caller-ext", trace);
422
-
423
- let authzRan = false;
424
- const authorize = (): Promise<AuthzCallResult> => {
425
- // Authz runs synchronously here; appending before the await guarantees
426
- // ordering relative to the caller-ext (which appends in beforeTool).
427
- authzRan = true;
428
- trace.push("authz");
429
- return Promise.resolve({
430
- effect: "allow" as const,
431
- matchingGrants: [],
432
- resolvedBy: null,
433
- });
434
- };
435
-
436
- const { reactor } = createReactorAssembly({
437
- sessionId: "s5",
438
- director: makeToolExecDirector("t", {}, { callId: "c5" }),
439
- source: source(),
440
- toolRunner: makeToolRunner("ok"),
441
- contextStore,
442
- onEvent: events.onEvent,
443
- authorize,
444
- beforeToolExtensions: [recordingExt],
445
- shutdownTimeoutMs: 100,
446
- });
447
-
448
- reactor.start();
449
- reactor.deliver(makeInboundMessage());
450
- await waitForDone(events.events);
451
-
452
- expect(authzRan).toBe(true);
453
- expect(trace).toEqual(["authz", "caller-ext"]);
454
- });
455
-
456
- test("with auditStore set, an audit collector is returned and afterCheckpoint flushes records to the store", async () => {
457
- const contextStore = makeContextStore();
458
- const events = collectEvents();
459
- const auditStore = makeRecordingAuditStore();
460
-
461
- const { reactor, auditCollector } = createReactorAssembly({
462
- sessionId: "s6",
463
- director: makeToolExecDirector(
464
- "t",
465
- { k: "v" },
466
- {
467
- checkpoint: true,
468
- callId: "c6",
469
- },
470
- ),
471
- source: source(),
472
- toolRunner: makeToolRunner("ok"),
473
- contextStore,
474
- onEvent: events.onEvent,
475
- auditStore,
476
- authorize: () => allowAuthorize(),
477
- shutdownTimeoutMs: 100,
478
- });
479
-
480
- expect(auditCollector).toBeDefined();
481
-
482
- reactor.start();
483
- reactor.deliver(makeInboundMessage());
484
- await waitForDone(events.events);
485
-
486
- const batches = auditStore.getCommitted();
487
- // Director checkpoints then done — flush happens at checkpoint and again
488
- // at shutdown. The shutdown flush finds an empty buffer and skips.
489
- expect(batches.length).toBe(1);
490
- expect(batches[0]?.length).toBe(1);
491
- expect(batches[0]?.[0]?.callId).toBe("c6");
492
- });
493
-
494
- test("with auditStore + authorize, authz onDecision decisions appear in the audit collector's flushed records", async () => {
495
- const contextStore = makeContextStore();
496
- const events = collectEvents();
497
- const auditStore = makeRecordingAuditStore();
498
-
499
- const { reactor } = createReactorAssembly({
500
- sessionId: "s7",
501
- director: makeToolExecDirector(
502
- "t",
503
- { x: 1 },
504
- {
505
- checkpoint: true,
506
- callId: "c7",
507
- },
508
- ),
509
- source: source(),
510
- toolRunner: makeToolRunner("ok"),
511
- contextStore,
512
- onEvent: events.onEvent,
513
- auditStore,
514
- authorize: () => allowAuthorize(),
515
- shutdownTimeoutMs: 100,
516
- });
517
-
518
- reactor.start();
519
- reactor.deliver(makeInboundMessage());
520
- await waitForDone(events.events);
521
-
522
- const records = auditStore.getCommitted().flat();
523
- expect(records.length).toBe(1);
524
- const record = records[0];
525
- if (record === undefined) throw new Error("expected record");
526
- if (record.authz === null) throw new Error("expected authz on record");
527
- expect(record.authz.effect).toBe("allow");
528
- expect(record.authz.blocked).toBe(false);
529
- });
530
-
531
- test("caller's afterCheckpoint runs after the helper's audit flush", async () => {
532
- const contextStore = makeContextStore();
533
- const events = collectEvents();
534
- const auditStore = makeRecordingAuditStore();
535
-
536
- const trace: string[] = [];
537
- // Wrap commitAudit so we can record the moment of the helper's flush.
538
- const wrappedAuditStore: AuditStore & { getCommitted(): AuditRecord[][] } =
539
- {
540
- ...auditStore,
541
- async commitAudit(records) {
542
- trace.push("helper-flush");
543
- await auditStore.commitAudit(records);
544
- },
545
- };
546
-
547
- const { reactor } = createReactorAssembly({
548
- sessionId: "s8",
549
- director: makeToolExecDirector(
550
- "t",
551
- {},
552
- {
553
- checkpoint: true,
554
- callId: "c8",
555
- },
556
- ),
557
- source: source(),
558
- toolRunner: makeToolRunner("ok"),
559
- contextStore,
560
- onEvent: events.onEvent,
561
- auditStore: wrappedAuditStore,
562
- authorize: () => allowAuthorize(),
563
- afterCheckpoint: async () => {
564
- trace.push("caller-after-checkpoint");
565
- },
566
- shutdownTimeoutMs: 100,
567
- });
568
-
569
- reactor.start();
570
- reactor.deliver(makeInboundMessage());
571
- await waitForDone(events.events);
572
-
573
- expect(trace).toEqual(["helper-flush", "caller-after-checkpoint"]);
574
- });
575
-
576
- test("caller's onShutdown runs after the helper's audit-flush-on-shutdown", async () => {
577
- const contextStore = makeContextStore();
578
- const events = collectEvents();
579
- const auditStore = makeRecordingAuditStore();
580
-
581
- const trace: string[] = [];
582
- const wrappedAuditStore: AuditStore & { getCommitted(): AuditRecord[][] } =
583
- {
584
- ...auditStore,
585
- async commitAudit(records) {
586
- trace.push("helper-flush");
587
- await auditStore.commitAudit(records);
588
- },
589
- };
590
-
591
- // Director that does NOT checkpoint — only the shutdown path flushes.
592
- const director: ReactorDirector = {
593
- async decide(
594
- event: { type: string },
595
- _state: ReactorState,
596
- caps: ReactorCapabilities,
597
- ) {
598
- if (event.type === "message.received") {
599
- return caps.executeTools([{ id: "c9", name: "t", arguments: {} }]);
600
- }
601
- if (event.type === "tool.done") {
602
- return caps.done();
603
- }
604
- return caps.done();
605
- },
606
- };
607
-
608
- const { reactor } = createReactorAssembly({
609
- sessionId: "s9",
610
- director,
611
- source: source(),
612
- toolRunner: makeToolRunner("ok"),
613
- contextStore,
614
- onEvent: events.onEvent,
615
- auditStore: wrappedAuditStore,
616
- authorize: () => allowAuthorize(),
617
- onShutdown: async () => {
618
- trace.push("caller-shutdown");
619
- },
620
- shutdownTimeoutMs: 100,
621
- });
622
-
623
- reactor.start();
624
- reactor.deliver(makeInboundMessage());
625
- await waitForDone(events.events);
626
-
627
- expect(trace).toEqual(["helper-flush", "caller-shutdown"]);
628
- });
629
-
630
- test("blobReader is wired against the supplied contextStore", async () => {
631
- const contextStore = makeContextStore();
632
- const { blobReader } = createReactorAssembly({
633
- sessionId: "s10",
634
- director: {
635
- async decide(_e, _s, caps) {
636
- return caps.done();
637
- },
638
- },
639
- source: source(),
640
- toolRunner: makeToolRunner("ok"),
641
- contextStore,
642
- onEvent: () => {
643
- /* noop */
644
- },
645
- shutdownTimeoutMs: 100,
646
- });
647
-
648
- const bytes = new TextEncoder().encode("hello-blob");
649
- await contextStore.writeBlob("k1", bytes, "text/plain");
650
- const out = await blobReader.read("tool-output:///k1");
651
- expect(new TextDecoder().decode(out)).toBe("hello-blob");
652
- });
653
-
654
- test("without auditStore, auditCollector is undefined and afterCheckpoint is only wired when caller provided one", async () => {
655
- const contextStore = makeContextStore();
656
- const events = collectEvents();
657
- let callerAfterCheckpointCalls = 0;
658
-
659
- const { auditCollector, reactor } = createReactorAssembly({
660
- sessionId: "s11",
661
- director: makeToolExecDirector(
662
- "t",
663
- {},
664
- {
665
- checkpoint: true,
666
- callId: "c11",
667
- },
668
- ),
669
- source: source(),
670
- toolRunner: makeToolRunner("ok"),
671
- contextStore,
672
- onEvent: events.onEvent,
673
- afterCheckpoint: async () => {
674
- callerAfterCheckpointCalls++;
675
- },
676
- shutdownTimeoutMs: 100,
677
- });
678
-
679
- expect(auditCollector).toBeUndefined();
680
-
681
- reactor.start();
682
- reactor.deliver(makeInboundMessage());
683
- await waitForDone(events.events);
684
-
685
- // Caller's afterCheckpoint still fires — the helper passes it through
686
- // unchanged when there is no audit collector to chain in front of it.
687
- expect(callerAfterCheckpointCalls).toBe(1);
688
- });
689
-
690
- test("without authorize, the reactor's beforeToolExtensions is exactly the caller's array", async () => {
691
- // Observable assertion: a caller-supplied extension that blocks should
692
- // produce a blocked tool result, and no authz-supplied extension should
693
- // be interposed (we'd otherwise see two extension invocations).
694
- const contextStore = makeContextStore();
695
- const events = collectEvents();
696
- const trace: string[] = [];
697
- const ext: BeforeToolExtension = {
698
- async beforeTool() {
699
- trace.push("caller-ext");
700
- return "blocked-by-caller";
701
- },
702
- };
703
-
704
- const { reactor } = createReactorAssembly({
705
- sessionId: "s12",
706
- director: makeToolExecDirector("t", {}, { callId: "c12" }),
707
- source: source(),
708
- toolRunner: makeToolRunner("never reached"),
709
- contextStore,
710
- onEvent: events.onEvent,
711
- beforeToolExtensions: [ext],
712
- shutdownTimeoutMs: 100,
713
- });
714
-
715
- reactor.start();
716
- reactor.deliver(makeInboundMessage());
717
- await waitForDone(events.events);
718
-
719
- expect(trace).toEqual(["caller-ext"]);
720
- const toolDone = events.events.find(
721
- (e): e is Extract<ReactorEmittedEvent, { type: "tool.done" }> =>
722
- e.type === "tool.done",
723
- );
724
- if (toolDone === undefined) throw new Error("expected tool.done");
725
- expect(toolDone.data.result.isError).toBe(true);
726
- expect(toolDone.data.result.content).toBe("blocked-by-caller");
727
- });
728
- });