@crewhaus/ir 0.1.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.
@@ -0,0 +1,986 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type {
3
+ Bundle,
4
+ IrBatchQueueAdapter,
5
+ IrBatchV0,
6
+ IrBrowserBackend,
7
+ IrBrowserV0,
8
+ IrChannelV0,
9
+ IrCompaction,
10
+ IrCrewRole,
11
+ IrCrewV0,
12
+ IrEvalV0,
13
+ IrGraphEdge,
14
+ IrGraphNode,
15
+ IrGraphV0,
16
+ IrManagedTenant,
17
+ IrManagedV0,
18
+ IrMcpServerConfig,
19
+ IrMcpServers,
20
+ IrNode,
21
+ IrPermissionRule,
22
+ IrPermissions,
23
+ IrPipelineDocument,
24
+ IrPipelineV0,
25
+ IrResearchV0,
26
+ IrSecretRef,
27
+ IrSubAgentDefinition,
28
+ IrToolConfigs,
29
+ IrV0,
30
+ IrVoiceProvider,
31
+ IrVoiceV0,
32
+ IrWorkflowStep,
33
+ IrWorkflowV0,
34
+ } from "./index";
35
+
36
+ /**
37
+ * The ir package is type-only — every export is a `type`. These tests
38
+ * construct representative instances of each shape and exercise the
39
+ * discriminated unions so the structural contract is locked in. If any
40
+ * later refactor renames a field or weakens a literal, this file fails to
41
+ * compile (caught by `bun test` before the unit assertions run).
42
+ */
43
+ describe("IrV0 (cli target)", () => {
44
+ test("a minimal cli IR satisfies all required fields", () => {
45
+ const ir: IrV0 = {
46
+ version: 0,
47
+ name: "hello",
48
+ target: "cli",
49
+ agent: { model: "claude-sonnet-4-6", instructions: "be helpful" },
50
+ tools: [],
51
+ toolConfigs: {},
52
+ mcp_servers: {},
53
+ permissions: { rules: [] },
54
+ subAgents: [],
55
+ compaction: {},
56
+ };
57
+ expect(ir.version).toBe(0);
58
+ expect(ir.target).toBe("cli");
59
+ expect(ir.agent.model).toBe("claude-sonnet-4-6");
60
+ });
61
+
62
+ test("permissions.mode is one of default | plan | auto (never bypass)", () => {
63
+ const perm: IrPermissions = { mode: "plan", rules: [] };
64
+ expect(perm.mode).toBe("plan");
65
+ // @ts-expect-error — bypass is not a legal IR mode (CLI flag only)
66
+ const _illegal: IrPermissions = { mode: "bypass", rules: [] };
67
+ void _illegal;
68
+ });
69
+
70
+ test("permission rule types are restricted to the three grammar values", () => {
71
+ const rule: IrPermissionRule = { type: "alwaysAllow", pattern: "Read" };
72
+ expect(rule.type).toBe("alwaysAllow");
73
+ // @ts-expect-error — typo not allowed
74
+ const _bad: IrPermissionRule = { type: "alwaysAllowed", pattern: "x" };
75
+ void _bad;
76
+ });
77
+ });
78
+
79
+ describe("IrWorkflowV0 (workflow target)", () => {
80
+ test("workflow IR carries an ordered step array, each with a resolved model", () => {
81
+ const step1: IrWorkflowStep = {
82
+ name: "list",
83
+ instructions: "list the files",
84
+ model: "claude-sonnet-4-6",
85
+ tools: ["bash"],
86
+ toolConfigs: {},
87
+ };
88
+ const step2: IrWorkflowStep = {
89
+ name: "summarise",
90
+ instructions: "summarise the listing",
91
+ model: "claude-sonnet-4-6",
92
+ tools: [],
93
+ toolConfigs: {},
94
+ };
95
+ const ir: IrWorkflowV0 = {
96
+ version: 0,
97
+ name: "two-step",
98
+ target: "workflow",
99
+ steps: [step1, step2],
100
+ mcp_servers: {},
101
+ permissions: { rules: [] },
102
+ compaction: {},
103
+ };
104
+ expect(ir.steps.length).toBe(2);
105
+ expect(ir.steps[0]?.model).toBe(step1.model);
106
+ });
107
+ });
108
+
109
+ describe("IrChannelV0 (channel target)", () => {
110
+ test("channel IR carries channels + routing + secret refs", () => {
111
+ const literal: IrSecretRef = { kind: "literal", value: "xoxb-fake" };
112
+ const envRef: IrSecretRef = { kind: "env", name: "SLACK_SIGNING_SECRET" };
113
+ const ir: IrChannelV0 = {
114
+ version: 0,
115
+ name: "slackbot",
116
+ target: "channel",
117
+ agent: { model: "claude-sonnet-4-6", instructions: "be helpful" },
118
+ tools: [],
119
+ toolConfigs: {},
120
+ channels: { slack: { botToken: literal, signingSecret: envRef } },
121
+ routing: { sessionKey: "thread" },
122
+ mcp_servers: {},
123
+ permissions: { rules: [] },
124
+ subAgents: [],
125
+ compaction: {},
126
+ };
127
+ expect(ir.target).toBe("channel");
128
+ expect(ir.channels.slack?.botToken.kind).toBe("literal");
129
+ expect(ir.channels.slack?.signingSecret.kind).toBe("env");
130
+ expect(ir.routing.sessionKey).toBe("thread");
131
+ });
132
+
133
+ test("sessionKey is restricted to thread | user | channel", () => {
134
+ // @ts-expect-error — random string is not a legal sessionKey
135
+ const _bad: IrChannelV0["routing"] = { sessionKey: "random" };
136
+ void _bad;
137
+ });
138
+ });
139
+
140
+ describe("IrNode discriminated union narrowing", () => {
141
+ test("switching on `target` narrows correctly", () => {
142
+ const cliNode: IrNode = {
143
+ version: 0,
144
+ name: "x",
145
+ target: "cli",
146
+ agent: { model: "m", instructions: "i" },
147
+ tools: [],
148
+ toolConfigs: {},
149
+ mcp_servers: {},
150
+ permissions: { rules: [] },
151
+ subAgents: [],
152
+ compaction: {},
153
+ };
154
+ const workflowNode: IrNode = {
155
+ version: 0,
156
+ name: "x",
157
+ target: "workflow",
158
+ steps: [],
159
+ mcp_servers: {},
160
+ permissions: { rules: [] },
161
+ compaction: {},
162
+ };
163
+ const channelNode: IrNode = {
164
+ version: 0,
165
+ name: "x",
166
+ target: "channel",
167
+ agent: { model: "m", instructions: "i" },
168
+ tools: [],
169
+ toolConfigs: {},
170
+ channels: {},
171
+ routing: { sessionKey: "thread" },
172
+ mcp_servers: {},
173
+ permissions: { rules: [] },
174
+ subAgents: [],
175
+ compaction: {},
176
+ };
177
+ const graphNode: IrNode = {
178
+ version: 0,
179
+ name: "x",
180
+ target: "graph",
181
+ entry: "plan",
182
+ nodes: [
183
+ {
184
+ name: "plan",
185
+ instructions: "plan the work",
186
+ model: "m",
187
+ tools: [],
188
+ toolConfigs: {},
189
+ },
190
+ {
191
+ name: "act",
192
+ instructions: "act on the plan",
193
+ model: "m",
194
+ tools: [],
195
+ toolConfigs: {},
196
+ },
197
+ ],
198
+ edges: [{ from: "plan", to: "act" }],
199
+ permissions: { rules: [] },
200
+ compaction: {},
201
+ };
202
+ const managedNode: IrNode = {
203
+ version: 0,
204
+ name: "x",
205
+ target: "managed",
206
+ agent: { model: "m", instructions: "i" },
207
+ tenants: [
208
+ { id: "t1", budget: { maxInputTokens: 1000, maxOutputTokens: 1000 } },
209
+ { id: "t2", budget: { maxInputTokens: 2000, maxOutputTokens: 2000 } },
210
+ ],
211
+ permissions: { rules: [] },
212
+ compaction: {},
213
+ };
214
+ const pipelineNode: IrNode = {
215
+ version: 0,
216
+ name: "x",
217
+ target: "pipeline",
218
+ agent: { model: "m", instructions: "i" },
219
+ retrieve: {
220
+ embedderModel: "openai/text-embedding-3-small",
221
+ vectorBackend: "in-memory",
222
+ defaultK: 5,
223
+ },
224
+ indexing: {
225
+ chunkStrategy: "fixed",
226
+ chunkSize: 512,
227
+ chunkOverlap: 50,
228
+ documents: [
229
+ { id: "d1", text: "hello" },
230
+ { id: "d2", text: "world" },
231
+ { id: "d3", text: "of pipelines" },
232
+ ],
233
+ },
234
+ permissions: { rules: [] },
235
+ compaction: {},
236
+ };
237
+ const crewNode: IrNode = {
238
+ version: 0,
239
+ name: "x",
240
+ target: "crew",
241
+ entry: "researcher",
242
+ roles: [
243
+ {
244
+ name: "researcher",
245
+ model: "m",
246
+ instructions: "research",
247
+ tools: [],
248
+ toolConfigs: {},
249
+ subAgents: [],
250
+ },
251
+ {
252
+ name: "writer",
253
+ model: "m",
254
+ instructions: "write",
255
+ tools: [],
256
+ toolConfigs: {},
257
+ subAgents: [],
258
+ },
259
+ ],
260
+ mcp_servers: {},
261
+ permissions: { rules: [] },
262
+ compaction: {},
263
+ };
264
+ const researchNode: IrNode = {
265
+ version: 0,
266
+ name: "x",
267
+ target: "research",
268
+ agent: { model: "m", instructions: "i" },
269
+ goal: "study GRPH risks",
270
+ branchingFactor: 3,
271
+ maxDurationMs: 300_000,
272
+ retrieve: { allowedOrigins: ["https://example.com"], allowedFileRoots: [] },
273
+ tools: [],
274
+ toolConfigs: {},
275
+ mcp_servers: {},
276
+ permissions: { rules: [] },
277
+ compaction: {},
278
+ };
279
+ const batchNode: IrNode = {
280
+ version: 0,
281
+ name: "x",
282
+ target: "batch",
283
+ agent: { model: "m", instructions: "i" },
284
+ queue: {
285
+ adapter: "in-memory",
286
+ visibilityTimeoutMs: 30_000,
287
+ maxRetries: 3,
288
+ seedJobs: ["job-1"],
289
+ },
290
+ concurrency: 4,
291
+ idempotencyWindowMs: 60_000,
292
+ tools: [],
293
+ toolConfigs: {},
294
+ mcp_servers: {},
295
+ permissions: { rules: [] },
296
+ compaction: {},
297
+ };
298
+ const voiceNode: IrNode = {
299
+ version: 0,
300
+ name: "x",
301
+ target: "voice",
302
+ agent: { model: "m", instructions: "i" },
303
+ voice: {
304
+ provider: "openai",
305
+ voiceId: "alloy",
306
+ vad: "server",
307
+ bargeInTriggerFrames: 3,
308
+ bargeInWindowMs: 200,
309
+ },
310
+ tools: [],
311
+ toolConfigs: {},
312
+ mcp_servers: {},
313
+ permissions: { rules: [] },
314
+ compaction: {},
315
+ };
316
+ const browserNode: IrNode = {
317
+ version: 0,
318
+ name: "x",
319
+ target: "browser",
320
+ agent: { model: "m", instructions: "i" },
321
+ driver: {
322
+ backend: "chromium",
323
+ viewport: { width: 1280, height: 800 },
324
+ startUrl: "https://example.com",
325
+ },
326
+ groundingModel: "m",
327
+ tools: [],
328
+ toolConfigs: {},
329
+ mcp_servers: {},
330
+ permissions: { rules: [] },
331
+ compaction: {},
332
+ };
333
+ const evalNode: IrNode = {
334
+ version: 0,
335
+ name: "x",
336
+ target: "eval",
337
+ agent: { model: "m", instructions: "i", tools: [] },
338
+ dataset: { name: "smoke", version: "v1", split: "dev" },
339
+ graders: [{ name: "exact_match" }, { name: "judge", opts: { rubric: "concise" } }],
340
+ concurrency: 4,
341
+ };
342
+
343
+ function describeNode(n: IrNode): string {
344
+ switch (n.target) {
345
+ case "cli":
346
+ return `cli:${n.agent.model}`;
347
+ case "workflow":
348
+ return `workflow:${n.steps.length}`;
349
+ case "channel":
350
+ return `channel:${n.routing.sessionKey}`;
351
+ case "graph":
352
+ return `graph:${n.nodes.length}`;
353
+ case "managed":
354
+ return `managed:${n.tenants.length}`;
355
+ case "pipeline":
356
+ return `pipeline:${n.indexing.documents.length}`;
357
+ case "crew":
358
+ return `crew:${n.roles.length}`;
359
+ case "research":
360
+ return `research:${n.branchingFactor}`;
361
+ case "batch":
362
+ return `batch:${n.queue.adapter}`;
363
+ case "voice":
364
+ return `voice:${n.voice.provider}`;
365
+ case "browser":
366
+ return `browser:${n.driver.backend}`;
367
+ case "eval":
368
+ return `eval:${n.dataset.name}@${n.dataset.version}`;
369
+ case "onchain":
370
+ return `onchain:${n.triggers.length}`;
371
+ case "onchain-game":
372
+ return `onchain-game:${n.game.turnSemantics}`;
373
+ }
374
+ }
375
+
376
+ expect(describeNode(cliNode)).toBe("cli:m");
377
+ expect(describeNode(workflowNode)).toBe("workflow:0");
378
+ expect(describeNode(channelNode)).toBe("channel:thread");
379
+ expect(describeNode(graphNode)).toBe("graph:2");
380
+ expect(describeNode(managedNode)).toBe("managed:2");
381
+ expect(describeNode(pipelineNode)).toBe("pipeline:3");
382
+ expect(describeNode(crewNode)).toBe("crew:2");
383
+ expect(describeNode(researchNode)).toBe("research:3");
384
+ expect(describeNode(batchNode)).toBe("batch:in-memory");
385
+ expect(describeNode(voiceNode)).toBe("voice:openai");
386
+ expect(describeNode(browserNode)).toBe("browser:chromium");
387
+ expect(describeNode(evalNode)).toBe("eval:smoke@v1");
388
+ });
389
+ });
390
+
391
+ describe("IrMcpServers", () => {
392
+ test("stdio + sse configs both satisfy IrMcpServerConfig", () => {
393
+ const stdio: IrMcpServerConfig = {
394
+ transport: "stdio",
395
+ command: "npx",
396
+ args: ["-y", "@modelcontextprotocol/server-everything"],
397
+ };
398
+ const sse: IrMcpServerConfig = {
399
+ transport: "sse",
400
+ url: "https://mcp.example.com/sse",
401
+ headers: { Authorization: "Bearer x" },
402
+ };
403
+ const servers: IrMcpServers = { everything: stdio, remote: sse };
404
+ expect(servers["everything"]?.transport).toBe("stdio");
405
+ expect(servers["remote"]?.transport).toBe("sse");
406
+ });
407
+ });
408
+
409
+ describe("Bundle shape", () => {
410
+ test("Bundle carries a list of {path, content} files", () => {
411
+ const bundle: Bundle = {
412
+ files: [
413
+ { path: "agent.ts", content: "// hello" },
414
+ { path: "package.json", content: "{}" },
415
+ ],
416
+ };
417
+ expect(bundle.files.length).toBe(2);
418
+ expect(bundle.files[0]?.path).toBe("agent.ts");
419
+ });
420
+ });
421
+
422
+ describe("IrSubAgentDefinition (Section 13)", () => {
423
+ test("a sub-agent with `inherit` permissions has no allow/deny lists", () => {
424
+ const def: IrSubAgentDefinition = {
425
+ name: "summariser",
426
+ description: "summarise its input in two sentences",
427
+ instructions: "be concise",
428
+ tools: [],
429
+ permissions: "inherit",
430
+ inheritBypass: false,
431
+ };
432
+ expect(def.permissions).toBe("inherit");
433
+ expect(def.inheritBypass).toBe(false);
434
+ });
435
+
436
+ test("a sub-agent with `scoped` permissions limits to listed tools", () => {
437
+ const def: IrSubAgentDefinition = {
438
+ name: "code-reviewer",
439
+ description: "review diffs",
440
+ instructions: "look for bugs",
441
+ tools: ["read", "grep"],
442
+ permissions: "scoped",
443
+ inheritBypass: false,
444
+ };
445
+ expect(def.permissions).toBe("scoped");
446
+ expect(def.tools).toEqual(["read", "grep"]);
447
+ });
448
+
449
+ test("a sub-agent with explicit allow/deny lists narrows further", () => {
450
+ const def: IrSubAgentDefinition = {
451
+ name: "auditor",
452
+ description: "audits a folder",
453
+ instructions: "report findings",
454
+ tools: ["read", "grep"],
455
+ permissions: { allow: ["Read", "Grep(**/src/**)"], deny: ["Bash"] },
456
+ inheritBypass: false,
457
+ };
458
+ if (typeof def.permissions === "string") expect.unreachable();
459
+ expect(def.permissions.allow).toEqual(["Read", "Grep(**/src/**)"]);
460
+ expect(def.permissions.deny).toEqual(["Bash"]);
461
+ });
462
+
463
+ test("inheritBypass: true is allowed for explicit opt-in propagation", () => {
464
+ const def: IrSubAgentDefinition = {
465
+ name: "trusted",
466
+ description: "trusted helper",
467
+ instructions: "do its job",
468
+ tools: [],
469
+ permissions: "inherit",
470
+ inheritBypass: true,
471
+ };
472
+ expect(def.inheritBypass).toBe(true);
473
+ });
474
+
475
+ test("optional model override is allowed", () => {
476
+ const def: IrSubAgentDefinition = {
477
+ name: "haiku-helper",
478
+ description: "fast helper",
479
+ instructions: "be quick",
480
+ tools: [],
481
+ model: "claude-haiku-4-5",
482
+ permissions: "inherit",
483
+ inheritBypass: false,
484
+ };
485
+ expect(def.model).toBe("claude-haiku-4-5");
486
+ });
487
+ });
488
+
489
+ describe("IrToolConfigs (Section 14)", () => {
490
+ test("opaque per-tool config blob is keyed by tool name", () => {
491
+ const configs: IrToolConfigs = {
492
+ WebFetch: { allowed_domains: ["example.com"], timeoutMs: 30_000 },
493
+ Fetch: { allowed_origins: ["https://api.github.com"] },
494
+ };
495
+ expect((configs["WebFetch"] as { allowed_domains: string[] }).allowed_domains).toEqual([
496
+ "example.com",
497
+ ]);
498
+ expect(Object.keys(configs).length).toBe(2);
499
+ });
500
+
501
+ test("empty toolConfigs is valid (lower-time default)", () => {
502
+ const configs: IrToolConfigs = {};
503
+ expect(Object.keys(configs).length).toBe(0);
504
+ });
505
+ });
506
+
507
+ describe("IrCompaction (Section 17)", () => {
508
+ test("an empty compaction block is valid (defaults to agent model)", () => {
509
+ const c: IrCompaction = {};
510
+ expect(c.model).toBeUndefined();
511
+ });
512
+
513
+ test("compaction.model overrides the autocompact summarisation model", () => {
514
+ const c: IrCompaction = { model: "openai/gpt-4o-mini" };
515
+ expect(c.model).toBe("openai/gpt-4o-mini");
516
+ });
517
+ });
518
+
519
+ describe("IrGraphV0 (Section 19)", () => {
520
+ test("a 3-node graph with an entry + edges has every required field", () => {
521
+ const plan: IrGraphNode = {
522
+ name: "plan",
523
+ instructions: "plan the steps",
524
+ model: "claude-sonnet-4-6",
525
+ tools: [],
526
+ toolConfigs: {},
527
+ };
528
+ const act: IrGraphNode = {
529
+ name: "act",
530
+ instructions: "carry out the plan",
531
+ model: "claude-sonnet-4-6",
532
+ tools: ["bash"],
533
+ toolConfigs: {},
534
+ };
535
+ const summarise: IrGraphNode = {
536
+ name: "summarise",
537
+ instructions: "summarise the result",
538
+ model: "claude-sonnet-4-6",
539
+ tools: [],
540
+ toolConfigs: {},
541
+ };
542
+ const planAct: IrGraphEdge = { from: "plan", to: "act" };
543
+ const actSum: IrGraphEdge = { from: "act", to: "summarise" };
544
+ const ir: IrGraphV0 = {
545
+ version: 0,
546
+ name: "three-step",
547
+ target: "graph",
548
+ entry: "plan",
549
+ nodes: [plan, act, summarise],
550
+ edges: [planAct, actSum],
551
+ permissions: { rules: [] },
552
+ compaction: {},
553
+ };
554
+ expect(ir.entry).toBe("plan");
555
+ expect(ir.nodes.length).toBe(3);
556
+ expect(ir.edges.length).toBe(2);
557
+ expect(ir.nodes[0]?.hitlPrompt).toBeUndefined();
558
+ });
559
+
560
+ test("a node with hitlPrompt opts the run into HITL pause/resume", () => {
561
+ const node: IrGraphNode = {
562
+ name: "approve",
563
+ instructions: "ask the user before continuing",
564
+ model: "m",
565
+ tools: [],
566
+ toolConfigs: {},
567
+ hitlPrompt: "ok to proceed?",
568
+ };
569
+ expect(node.hitlPrompt).toBe("ok to proceed?");
570
+ });
571
+ });
572
+
573
+ describe("IrManagedV0 (Section 20)", () => {
574
+ test("a managed daemon IR carries a tenant table and per-tenant budgets", () => {
575
+ const t1: IrManagedTenant = {
576
+ id: "tenant-a",
577
+ budget: { maxInputTokens: 100_000, maxOutputTokens: 50_000 },
578
+ };
579
+ const t2: IrManagedTenant = {
580
+ id: "tenant-b",
581
+ budget: { maxInputTokens: 200_000, maxOutputTokens: 100_000 },
582
+ };
583
+ const ir: IrManagedV0 = {
584
+ version: 0,
585
+ name: "saas-bot",
586
+ target: "managed",
587
+ agent: { model: "claude-sonnet-4-6", instructions: "be helpful" },
588
+ tenants: [t1, t2],
589
+ permissions: { rules: [] },
590
+ compaction: {},
591
+ };
592
+ expect(ir.tenants.length).toBe(2);
593
+ expect(ir.tenants[0]?.id).toBe("tenant-a");
594
+ expect(ir.tenants[1]?.budget.maxInputTokens).toBe(200_000);
595
+ });
596
+
597
+ test("an empty tenant table is structurally valid (lower-time fail-loud lives in the spec parser)", () => {
598
+ const ir: IrManagedV0 = {
599
+ version: 0,
600
+ name: "empty",
601
+ target: "managed",
602
+ agent: { model: "m", instructions: "i" },
603
+ tenants: [],
604
+ permissions: { rules: [] },
605
+ compaction: {},
606
+ };
607
+ expect(ir.tenants.length).toBe(0);
608
+ });
609
+ });
610
+
611
+ describe("IrPipelineV0 (Section 21)", () => {
612
+ test("a pipeline IR carries retrieve config + indexing pipeline + agent block", () => {
613
+ const docs: IrPipelineDocument[] = [
614
+ { id: "doc-1", text: "hello world" },
615
+ { id: "doc-2", text: "rag stack", metadata: { source: "readme" } },
616
+ ];
617
+ const ir: IrPipelineV0 = {
618
+ version: 0,
619
+ name: "doc-bot",
620
+ target: "pipeline",
621
+ agent: { model: "claude-sonnet-4-6", instructions: "answer using Retrieve" },
622
+ retrieve: {
623
+ embedderModel: "openai/text-embedding-3-small",
624
+ vectorBackend: "in-memory",
625
+ defaultK: 5,
626
+ },
627
+ indexing: {
628
+ chunkStrategy: "markdown",
629
+ chunkSize: 1024,
630
+ chunkOverlap: 100,
631
+ documents: docs,
632
+ },
633
+ permissions: { rules: [] },
634
+ compaction: {},
635
+ };
636
+ expect(ir.retrieve.vectorBackend).toBe("in-memory");
637
+ expect(ir.indexing.documents.length).toBe(2);
638
+ expect(ir.indexing.documents[1]?.metadata?.["source"]).toBe("readme");
639
+ });
640
+
641
+ test("chunkStrategy is restricted to fixed | semantic | markdown", () => {
642
+ const _bad: IrPipelineV0["indexing"] = {
643
+ // @ts-expect-error — random string is not a legal chunkStrategy
644
+ chunkStrategy: "random",
645
+ chunkSize: 0,
646
+ chunkOverlap: 0,
647
+ documents: [],
648
+ };
649
+ void _bad;
650
+ });
651
+
652
+ test("vectorBackend is restricted to in-memory in v0", () => {
653
+ const _bad: IrPipelineV0["retrieve"] = {
654
+ embedderModel: "x",
655
+ // @ts-expect-error — qdrant/pinecone/lance are roadmap, not yet in v0
656
+ vectorBackend: "qdrant",
657
+ defaultK: 5,
658
+ };
659
+ void _bad;
660
+ });
661
+ });
662
+
663
+ describe("IrCrewV0 (Section 22)", () => {
664
+ test("a crew IR carries an entry role + role table; sub-agents inherited per role", () => {
665
+ const researcher: IrCrewRole = {
666
+ name: "researcher",
667
+ model: "claude-sonnet-4-6",
668
+ instructions: "gather facts",
669
+ tools: ["WebFetch"],
670
+ toolConfigs: {},
671
+ subAgents: [],
672
+ };
673
+ const writer: IrCrewRole = {
674
+ name: "writer",
675
+ model: "claude-sonnet-4-6",
676
+ instructions: "synthesise into a post",
677
+ tools: [],
678
+ toolConfigs: {},
679
+ subAgents: [
680
+ {
681
+ name: "outline-helper",
682
+ description: "draft an outline",
683
+ instructions: "produce a 5-bullet outline",
684
+ tools: [],
685
+ permissions: "inherit",
686
+ inheritBypass: false,
687
+ },
688
+ ],
689
+ };
690
+ const ir: IrCrewV0 = {
691
+ version: 0,
692
+ name: "research-crew",
693
+ target: "crew",
694
+ entry: "researcher",
695
+ roles: [researcher, writer],
696
+ mcp_servers: {},
697
+ permissions: { rules: [] },
698
+ compaction: {},
699
+ };
700
+ expect(ir.entry).toBe("researcher");
701
+ expect(ir.roles.length).toBe(2);
702
+ expect(ir.roles[1]?.subAgents.length).toBe(1);
703
+ });
704
+
705
+ test("optional routing.match maps source-role → next-role on substring match", () => {
706
+ const ir: IrCrewV0 = {
707
+ version: 0,
708
+ name: "router-crew",
709
+ target: "crew",
710
+ entry: "researcher",
711
+ roles: [],
712
+ routing: {
713
+ kind: "match",
714
+ match: {
715
+ researcher: [{ contains: "DONE", to: "writer" }],
716
+ },
717
+ },
718
+ mcp_servers: {},
719
+ permissions: { rules: [] },
720
+ compaction: {},
721
+ };
722
+ expect(ir.routing?.kind).toBe("match");
723
+ expect(ir.routing?.match?.["researcher"]?.[0]?.to).toBe("writer");
724
+ });
725
+
726
+ test("routing.kind is restricted to match | llm", () => {
727
+ // @ts-expect-error — random string is not a legal routing kind
728
+ const _bad: IrCrewV0["routing"] = { kind: "random" };
729
+ void _bad;
730
+ });
731
+ });
732
+
733
+ describe("IrResearchV0 (Section 23 — RES)", () => {
734
+ test("a research IR carries goal + branchingFactor + retrieve allowlists", () => {
735
+ const ir: IrResearchV0 = {
736
+ version: 0,
737
+ name: "doc-research",
738
+ target: "research",
739
+ agent: { model: "claude-sonnet-4-6", instructions: "research deeply" },
740
+ goal: "what gates the BROW shape",
741
+ branchingFactor: 4,
742
+ maxDurationMs: 30 * 60 * 1000,
743
+ retrieve: {
744
+ allowedOrigins: ["https://docs.example.com"],
745
+ allowedFileRoots: ["/tmp/research"],
746
+ vectorBackend: "in-memory",
747
+ },
748
+ tools: [],
749
+ toolConfigs: {},
750
+ mcp_servers: {},
751
+ permissions: { rules: [] },
752
+ compaction: {},
753
+ };
754
+ expect(ir.branchingFactor).toBe(4);
755
+ expect(ir.retrieve.allowedOrigins).toEqual(["https://docs.example.com"]);
756
+ expect(ir.retrieve.vectorBackend).toBe("in-memory");
757
+ });
758
+
759
+ test("retrieve.allowedOrigins / allowedFileRoots can both be empty (fail-closed)", () => {
760
+ const ir: IrResearchV0 = {
761
+ version: 0,
762
+ name: "locked-down",
763
+ target: "research",
764
+ agent: { model: "m", instructions: "i" },
765
+ goal: "x",
766
+ branchingFactor: 1,
767
+ maxDurationMs: 1000,
768
+ retrieve: { allowedOrigins: [], allowedFileRoots: [] },
769
+ tools: [],
770
+ toolConfigs: {},
771
+ mcp_servers: {},
772
+ permissions: { rules: [] },
773
+ compaction: {},
774
+ };
775
+ expect(ir.retrieve.allowedOrigins.length).toBe(0);
776
+ expect(ir.retrieve.allowedFileRoots.length).toBe(0);
777
+ });
778
+ });
779
+
780
+ describe("IrBatchV0 (Section 23 — BATCH)", () => {
781
+ test("a batch IR carries queue config + concurrency + idempotency window", () => {
782
+ const ir: IrBatchV0 = {
783
+ version: 0,
784
+ name: "queue-worker",
785
+ target: "batch",
786
+ agent: { model: "claude-haiku-4-5", instructions: "process the job" },
787
+ queue: {
788
+ adapter: "redis-streams",
789
+ visibilityTimeoutMs: 30_000,
790
+ visibilityRenewIntervalMs: 10_000,
791
+ maxRetries: 5,
792
+ },
793
+ concurrency: 8,
794
+ idempotencyWindowMs: 24 * 60 * 60 * 1000,
795
+ tools: [],
796
+ toolConfigs: {},
797
+ mcp_servers: {},
798
+ permissions: { rules: [] },
799
+ compaction: {},
800
+ };
801
+ expect(ir.queue.adapter).toBe("redis-streams");
802
+ expect(ir.concurrency).toBe(8);
803
+ expect(ir.idempotencyWindowMs).toBe(86_400_000);
804
+ });
805
+
806
+ test("queue.adapter is restricted to in-memory | sqs | redis-streams | postgres", () => {
807
+ const valid: IrBatchQueueAdapter[] = ["in-memory", "sqs", "redis-streams", "postgres"];
808
+ expect(valid.length).toBe(4);
809
+ // @ts-expect-error — kafka is not a legal queue adapter in v0
810
+ const _bad: IrBatchQueueAdapter = "kafka";
811
+ void _bad;
812
+ });
813
+
814
+ test("seedJobs only populated when adapter === 'in-memory' (test/smoke convenience)", () => {
815
+ const ir: IrBatchV0 = {
816
+ version: 0,
817
+ name: "seeded",
818
+ target: "batch",
819
+ agent: { model: "m", instructions: "i" },
820
+ queue: {
821
+ adapter: "in-memory",
822
+ visibilityTimeoutMs: 30_000,
823
+ maxRetries: 3,
824
+ seedJobs: ["job-a", "job-b", "job-c"],
825
+ },
826
+ concurrency: 2,
827
+ idempotencyWindowMs: 60_000,
828
+ tools: [],
829
+ toolConfigs: {},
830
+ mcp_servers: {},
831
+ permissions: { rules: [] },
832
+ compaction: {},
833
+ };
834
+ expect(ir.queue.seedJobs?.length).toBe(3);
835
+ });
836
+ });
837
+
838
+ describe("IrVoiceV0 (Section 24 — VOICE)", () => {
839
+ test("a voice IR carries provider + voiceId + barge-in trigger config", () => {
840
+ const ir: IrVoiceV0 = {
841
+ version: 0,
842
+ name: "phone-bot",
843
+ target: "voice",
844
+ agent: { model: "claude-sonnet-4-6", instructions: "greet the caller" },
845
+ voice: {
846
+ provider: "openai",
847
+ voiceId: "alloy",
848
+ vad: "server",
849
+ bargeInTriggerFrames: 3,
850
+ bargeInWindowMs: 200,
851
+ },
852
+ tools: [],
853
+ toolConfigs: {},
854
+ mcp_servers: {},
855
+ permissions: { rules: [] },
856
+ compaction: {},
857
+ };
858
+ expect(ir.voice.provider).toBe("openai");
859
+ expect(ir.voice.bargeInTriggerFrames).toBe(3);
860
+ expect(ir.voice.vad).toBe("server");
861
+ });
862
+
863
+ test("optional telephony adapter wires Twilio / LiveKit / in-memory smoke", () => {
864
+ const ir: IrVoiceV0 = {
865
+ version: 0,
866
+ name: "twilio-bot",
867
+ target: "voice",
868
+ agent: { model: "m", instructions: "i" },
869
+ voice: {
870
+ provider: "vapi",
871
+ voiceId: "default",
872
+ vad: "server",
873
+ bargeInTriggerFrames: 2,
874
+ bargeInWindowMs: 150,
875
+ },
876
+ telephony: { provider: "twilio" },
877
+ tools: [],
878
+ toolConfigs: {},
879
+ mcp_servers: {},
880
+ permissions: { rules: [] },
881
+ compaction: {},
882
+ };
883
+ expect(ir.telephony?.provider).toBe("twilio");
884
+ });
885
+
886
+ test("provider is restricted to openai | vapi", () => {
887
+ const valid: IrVoiceProvider[] = ["openai", "vapi"];
888
+ expect(valid.length).toBe(2);
889
+ // @ts-expect-error — google realtime is not yet in v0
890
+ const _bad: IrVoiceProvider = "google";
891
+ void _bad;
892
+ });
893
+ });
894
+
895
+ describe("IrBrowserV0 (Section 25 — BROW)", () => {
896
+ test("a browser IR carries driver backend + viewport + grounding model", () => {
897
+ const ir: IrBrowserV0 = {
898
+ version: 0,
899
+ name: "ops-bot",
900
+ target: "browser",
901
+ agent: { model: "claude-sonnet-4-6", instructions: "navigate the dashboard" },
902
+ driver: {
903
+ backend: "chromium",
904
+ viewport: { width: 1280, height: 800 },
905
+ startUrl: "https://example.com/login",
906
+ },
907
+ groundingModel: "claude-sonnet-4-6",
908
+ tools: [],
909
+ toolConfigs: {},
910
+ mcp_servers: {},
911
+ permissions: { rules: [] },
912
+ compaction: {},
913
+ };
914
+ expect(ir.driver.backend).toBe("chromium");
915
+ expect(ir.driver.viewport.width).toBe(1280);
916
+ expect(ir.driver.startUrl).toBe("https://example.com/login");
917
+ });
918
+
919
+ test("driver.backend is restricted to host | chromium | remote", () => {
920
+ const valid: IrBrowserBackend[] = ["host", "chromium", "remote"];
921
+ expect(valid.length).toBe(3);
922
+ // @ts-expect-error — firefox-driver isn't a legal backend in v0
923
+ const _bad: IrBrowserBackend = "firefox-driver";
924
+ void _bad;
925
+ });
926
+
927
+ test("startUrl is optional; daemon skips goto() when absent", () => {
928
+ const ir: IrBrowserV0 = {
929
+ version: 0,
930
+ name: "no-start",
931
+ target: "browser",
932
+ agent: { model: "m", instructions: "i" },
933
+ driver: { backend: "chromium", viewport: { width: 800, height: 600 } },
934
+ groundingModel: "m",
935
+ tools: [],
936
+ toolConfigs: {},
937
+ mcp_servers: {},
938
+ permissions: { rules: [] },
939
+ compaction: {},
940
+ };
941
+ expect(ir.driver.startUrl).toBeUndefined();
942
+ });
943
+ });
944
+
945
+ describe("IrEvalV0 (Section 29 — EVAL target)", () => {
946
+ test("an eval IR carries dataset reference + graders + concurrency", () => {
947
+ const ir: IrEvalV0 = {
948
+ version: 0,
949
+ name: "smoke-eval",
950
+ target: "eval",
951
+ agent: {
952
+ model: "claude-sonnet-4-6",
953
+ instructions: "answer concisely",
954
+ tools: ["read"],
955
+ },
956
+ dataset: { name: "qa-bench", version: "v1", split: "dev" },
957
+ graders: [{ name: "exact_match" }, { name: "judge", opts: { rubric: "concise + correct" } }],
958
+ concurrency: 4,
959
+ seed: 42,
960
+ };
961
+ expect(ir.dataset.split).toBe("dev");
962
+ expect(ir.graders.length).toBe(2);
963
+ expect(ir.graders[1]?.opts?.["rubric"]).toBe("concise + correct");
964
+ expect(ir.concurrency).toBe(4);
965
+ expect(ir.seed).toBe(42);
966
+ });
967
+
968
+ test("dataset.split is restricted to train | dev | test", () => {
969
+ // @ts-expect-error — random string is not a legal split
970
+ const _bad: IrEvalV0["dataset"] = { name: "x", version: "v1", split: "validation" };
971
+ void _bad;
972
+ });
973
+
974
+ test("seed is optional; runner falls back to provider-default temperature", () => {
975
+ const ir: IrEvalV0 = {
976
+ version: 0,
977
+ name: "no-seed",
978
+ target: "eval",
979
+ agent: { model: "m", instructions: "i", tools: [] },
980
+ dataset: { name: "x", version: "v1", split: "dev" },
981
+ graders: [],
982
+ concurrency: 1,
983
+ };
984
+ expect(ir.seed).toBeUndefined();
985
+ });
986
+ });