@animalabs/connectome-host 0.3.1

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 (123) hide show
  1. package/.env.example +20 -0
  2. package/.github/workflows/publish.yml +65 -0
  3. package/ARCHITECTURE.md +355 -0
  4. package/CHANGELOG.md +30 -0
  5. package/HEADLESS-FLEET-PLAN.md +330 -0
  6. package/README.md +189 -0
  7. package/UNIFIED-TREE-PLAN.md +242 -0
  8. package/bun.lock +288 -0
  9. package/docs/AGENT-MEMORY-GUIDE.md +214 -0
  10. package/docs/AGENT-ONBOARDING.md +541 -0
  11. package/docs/ATTENTION-AND-GATING.md +120 -0
  12. package/docs/DEPLOYMENTS.md +130 -0
  13. package/docs/DEV-ENVIRONMENT.md +215 -0
  14. package/docs/LIBRARY-PIPELINE.md +286 -0
  15. package/docs/LOCUS-ROUTING-DESIGN.md +115 -0
  16. package/docs/claudeai-evacuation.md +228 -0
  17. package/docs/debug-context-api.md +208 -0
  18. package/docs/webui-deployment.md +219 -0
  19. package/package.json +33 -0
  20. package/recipes/SETUP.md +308 -0
  21. package/recipes/TRIUMVIRATE-SETUP.md +467 -0
  22. package/recipes/claude-export-revive.json +19 -0
  23. package/recipes/clerk.json +157 -0
  24. package/recipes/knowledge-miner.json +174 -0
  25. package/recipes/knowledge-reviewer.json +76 -0
  26. package/recipes/mcpl-editor-test.json +38 -0
  27. package/recipes/prompts/transplant-addendum.md +17 -0
  28. package/recipes/triumvirate.json +63 -0
  29. package/recipes/webui-fleet-test.json +27 -0
  30. package/recipes/webui-test.json +16 -0
  31. package/recipes/zulip-miner.json +87 -0
  32. package/scripts/connectome-doctor +373 -0
  33. package/scripts/evacuator.ts +956 -0
  34. package/scripts/import-claudeai-export.ts +747 -0
  35. package/scripts/lib/line-reader.ts +53 -0
  36. package/scripts/test-historical-thinking.ts +148 -0
  37. package/scripts/warmup-session.ts +336 -0
  38. package/src/agent-name.ts +58 -0
  39. package/src/commands.ts +1074 -0
  40. package/src/headless.ts +528 -0
  41. package/src/index.ts +867 -0
  42. package/src/logging-adapter.ts +208 -0
  43. package/src/mcpl-config.ts +199 -0
  44. package/src/modules/activity-module.ts +270 -0
  45. package/src/modules/channel-mode-module.ts +260 -0
  46. package/src/modules/fleet-module.ts +1705 -0
  47. package/src/modules/fleet-types.ts +225 -0
  48. package/src/modules/lessons-module.ts +465 -0
  49. package/src/modules/mcpl-admin-module.ts +345 -0
  50. package/src/modules/retrieval-module.ts +327 -0
  51. package/src/modules/settings-module.ts +143 -0
  52. package/src/modules/subagent-module.ts +2074 -0
  53. package/src/modules/subscription-gc-module.ts +322 -0
  54. package/src/modules/time-module.ts +85 -0
  55. package/src/modules/tui-module.ts +76 -0
  56. package/src/modules/web-ui-curve-page.ts +249 -0
  57. package/src/modules/web-ui-module.ts +2595 -0
  58. package/src/recipe.ts +1003 -0
  59. package/src/session-manager.ts +278 -0
  60. package/src/state/agent-tree-reducer.ts +431 -0
  61. package/src/state/fleet-tree-aggregator.ts +195 -0
  62. package/src/strategies/frontdesk-strategy.ts +364 -0
  63. package/src/synesthete.ts +68 -0
  64. package/src/tui.ts +2200 -0
  65. package/src/types/bun-ffi.d.ts +6 -0
  66. package/src/web/protocol.ts +648 -0
  67. package/test/agent-name.test.ts +62 -0
  68. package/test/agent-tree-fleet-launch.test.ts +64 -0
  69. package/test/agent-tree-reducer-parity.test.ts +194 -0
  70. package/test/agent-tree-reducer.test.ts +443 -0
  71. package/test/agent-tree-rollup.test.ts +109 -0
  72. package/test/autobio-progress-snapshot.test.ts +40 -0
  73. package/test/claudeai-export-importer.test.ts +386 -0
  74. package/test/evacuator.test.ts +332 -0
  75. package/test/fleet-commands.test.ts +244 -0
  76. package/test/fleet-no-subfleets.test.ts +147 -0
  77. package/test/fleet-orchestration.test.ts +353 -0
  78. package/test/fleet-recipe.test.ts +124 -0
  79. package/test/fleet-route.test.ts +44 -0
  80. package/test/fleet-smoke.test.ts +479 -0
  81. package/test/fleet-subscribe-union-e2e.test.ts +123 -0
  82. package/test/fleet-subscribe-union.test.ts +85 -0
  83. package/test/fleet-tree-aggregator-e2e.test.ts +110 -0
  84. package/test/fleet-tree-aggregator.test.ts +215 -0
  85. package/test/frontdesk-strategy.test.ts +326 -0
  86. package/test/headless-describe.test.ts +182 -0
  87. package/test/headless-smoke.test.ts +190 -0
  88. package/test/logging-adapter.test.ts +87 -0
  89. package/test/mcpl-admin-module.test.ts +213 -0
  90. package/test/mcpl-agent-overlay.test.ts +126 -0
  91. package/test/mock-headless-child.ts +133 -0
  92. package/test/recipe-cache-ttl.test.ts +37 -0
  93. package/test/recipe-env.test.ts +157 -0
  94. package/test/recipe-path-resolution.test.ts +170 -0
  95. package/test/subagent-async-timeout.test.ts +381 -0
  96. package/test/subagent-fork-materialise.test.ts +241 -0
  97. package/test/subagent-peek-scoping.test.ts +180 -0
  98. package/test/subagent-peek-zombie.test.ts +148 -0
  99. package/test/subagent-reaper-in-flight.test.ts +229 -0
  100. package/test/subscription-gc-module.test.ts +136 -0
  101. package/test/warmup-handoff.test.ts +150 -0
  102. package/test/web-ui-bind.test.ts +51 -0
  103. package/test/web-ui-module.test.ts +246 -0
  104. package/test/web-ui-protocol.test.ts +0 -0
  105. package/tsconfig.json +14 -0
  106. package/web/bun.lock +357 -0
  107. package/web/index.html +12 -0
  108. package/web/package.json +24 -0
  109. package/web/src/App.tsx +1931 -0
  110. package/web/src/Context.tsx +158 -0
  111. package/web/src/ContextDocument.tsx +150 -0
  112. package/web/src/Files.tsx +271 -0
  113. package/web/src/Lessons.tsx +164 -0
  114. package/web/src/Mcpl.tsx +310 -0
  115. package/web/src/Stream.tsx +119 -0
  116. package/web/src/TreeSidebar.tsx +283 -0
  117. package/web/src/Usage.tsx +182 -0
  118. package/web/src/main.tsx +7 -0
  119. package/web/src/styles.css +26 -0
  120. package/web/src/tree.ts +268 -0
  121. package/web/src/wire.ts +120 -0
  122. package/web/tsconfig.json +21 -0
  123. package/web/vite.config.ts +32 -0
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Phase 6 — no-subfleets invariant.
3
+ *
4
+ * A fleet child recipe may not itself declare a `modules.fleet` entry.
5
+ * Validation happens before subprocess spawn so the failure surfaces as a
6
+ * clean synchronous tool error.
7
+ */
8
+ import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
9
+ import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
10
+ import { tmpdir } from 'node:os';
11
+ import { join, resolve, dirname } from 'node:path';
12
+ import { fileURLToPath } from 'node:url';
13
+ import { FleetModule } from '../src/modules/fleet-module.js';
14
+
15
+ const TEST_DIR = dirname(fileURLToPath(import.meta.url));
16
+ const REPO_ROOT = resolve(TEST_DIR, '..');
17
+ const INDEX_PATH = join(REPO_ROOT, 'src', 'index.ts');
18
+
19
+ interface ToolResultLike {
20
+ success?: boolean;
21
+ isError?: boolean;
22
+ error?: string;
23
+ }
24
+
25
+ describe('FleetModule — no-subfleets invariant', () => {
26
+ let tmpDir: string;
27
+ // Tests 3 and 4 pass validation and actually spawn a (detached) bun child;
28
+ // without explicit cleanup the children orphan to PID 1. Track every
29
+ // FleetModule we construct so afterAll can reap them.
30
+ const activeFleets: FleetModule[] = [];
31
+
32
+ beforeAll(() => {
33
+ tmpDir = mkdtempSync(join(tmpdir(), 'fkm-no-subfleets-'));
34
+ });
35
+
36
+ afterAll(async () => {
37
+ // Reap in parallel — production shutdown timeouts (graceful 10s + SIGTERM 5s
38
+ // + SIGKILL 2s ≈ 17s per fleet) would blow Bun's 5s hook budget if awaited
39
+ // serially. The constructor below also overrides those to snappy values.
40
+ await Promise.all(activeFleets.map((f) => f.stop().catch(() => { /* noop */ })));
41
+ try { rmSync(tmpDir, { recursive: true, force: true }); } catch { /* noop */ }
42
+ }, 30_000);
43
+
44
+ async function runLaunch(recipeBody: object): Promise<ToolResultLike> {
45
+ const recipePath = join(tmpDir, `recipe-${Date.now()}-${Math.random().toString(36).slice(2)}.json`);
46
+ writeFileSync(recipePath, JSON.stringify(recipeBody), 'utf-8');
47
+
48
+ const fleet = new FleetModule({
49
+ childRuntimePath: 'bun',
50
+ childIndexPath: INDEX_PATH,
51
+ // Snappy shutdown so afterAll reaping stays within the hook budget
52
+ // even if a spawned child is uncooperative (no API key, etc.).
53
+ gracefulShutdownMs: 1_000,
54
+ sigtermEscalationMs: 500,
55
+ });
56
+ activeFleets.push(fleet);
57
+ // FleetModule.start() reads/writes Chronicle state; for a pure validation
58
+ // test we drive handleToolCall directly without start(). The launch path
59
+ // we exercise here checks the recipe before any subprocess spawn.
60
+ const result = await fleet.handleToolCall({
61
+ id: 'test-call',
62
+ name: 'launch',
63
+ input: {
64
+ name: 'subfleet-child',
65
+ recipe: recipePath,
66
+ dataDir: join(tmpDir, 'subfleet-child'),
67
+ },
68
+ }) as ToolResultLike;
69
+
70
+ return result;
71
+ }
72
+
73
+ test('recipe with modules.fleet=true is rejected', async () => {
74
+ const result = await runLaunch({
75
+ name: 'NestedFleetTrue',
76
+ agent: { name: 'inner', systemPrompt: 'x' },
77
+ modules: {
78
+ fleet: true,
79
+ subagents: false,
80
+ lessons: false,
81
+ retrieval: false,
82
+ wake: false,
83
+ workspace: false,
84
+ },
85
+ });
86
+ expect(result.success).toBe(false);
87
+ expect(result.isError).toBe(true);
88
+ expect(result.error).toContain('nested fleets are not supported');
89
+ });
90
+
91
+ test('recipe with modules.fleet object is rejected', async () => {
92
+ const result = await runLaunch({
93
+ name: 'NestedFleetObject',
94
+ agent: { name: 'inner', systemPrompt: 'x' },
95
+ modules: {
96
+ fleet: { children: [] },
97
+ subagents: false,
98
+ lessons: false,
99
+ retrieval: false,
100
+ wake: false,
101
+ workspace: false,
102
+ },
103
+ });
104
+ expect(result.success).toBe(false);
105
+ expect(result.isError).toBe(true);
106
+ expect(result.error).toContain('nested fleets are not supported');
107
+ });
108
+
109
+ test('recipe without fleet module is accepted (passes validation, may fail later in spawn path)', async () => {
110
+ const result = await runLaunch({
111
+ name: 'PlainChild',
112
+ agent: { name: 'inner', systemPrompt: 'x' },
113
+ modules: {
114
+ subagents: false,
115
+ lessons: false,
116
+ retrieval: false,
117
+ wake: false,
118
+ workspace: false,
119
+ },
120
+ });
121
+ // We don't care if the spawn itself succeeds in this test environment —
122
+ // we care that the *invariant validation* doesn't reject it. Any rejection
123
+ // here would be from a different code path (spawn-time errors), not the
124
+ // no-subfleets check we just added.
125
+ if (result.success === false && result.error) {
126
+ expect(result.error).not.toContain('nested fleets');
127
+ }
128
+ });
129
+
130
+ test('recipe with modules.fleet=false is accepted (explicit opt-out is fine)', async () => {
131
+ const result = await runLaunch({
132
+ name: 'ExplicitlyDisabledFleet',
133
+ agent: { name: 'inner', systemPrompt: 'x' },
134
+ modules: {
135
+ fleet: false,
136
+ subagents: false,
137
+ lessons: false,
138
+ retrieval: false,
139
+ wake: false,
140
+ workspace: false,
141
+ },
142
+ });
143
+ if (result.success === false && result.error) {
144
+ expect(result.error).not.toContain('nested fleets');
145
+ }
146
+ });
147
+ });
@@ -0,0 +1,353 @@
1
+ /**
2
+ * Tests for the orchestration primitives added alongside the Phase-5 caveat fixes:
3
+ * - child-side synthetic events (lifecycle:idle, inference:speech)
4
+ * - fleet--relay (sibling messaging)
5
+ * - fleet--await (fan-out wait)
6
+ *
7
+ * Uses a minimal mock child (test/mock-headless-child.ts) so the tests don't
8
+ * need an ANTHROPIC_API_KEY or a working framework/Membrane/Chronicle stack.
9
+ */
10
+ import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
11
+ import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
12
+ import { tmpdir } from 'node:os';
13
+ import { join, resolve, dirname } from 'node:path';
14
+ import { fileURLToPath } from 'node:url';
15
+ import { FleetModule, type FleetModuleConfig } from '../src/modules/fleet-module.js';
16
+
17
+ const TEST_DIR = dirname(fileURLToPath(import.meta.url));
18
+ const MOCK_CHILD_PATH = join(TEST_DIR, 'mock-headless-child.ts');
19
+
20
+ function makeFleet(overrides: Partial<FleetModuleConfig> = {}): FleetModule {
21
+ return new FleetModule({
22
+ childIndexPath: MOCK_CHILD_PATH,
23
+ socketWaitTimeoutMs: 10_000,
24
+ readyTimeoutMs: 5_000,
25
+ gracefulShutdownMs: 3_000,
26
+ sigtermEscalationMs: 1_000,
27
+ ...overrides,
28
+ });
29
+ }
30
+
31
+ async function launchChild(fleet: FleetModule, name: string, dataDir: string): Promise<void> {
32
+ const res = await fleet.handleToolCall({
33
+ id: `launch-${name}`,
34
+ name: 'launch',
35
+ input: { name, recipe: 'mock-recipe', dataDir },
36
+ });
37
+ if (!res.success) {
38
+ throw new Error(`launch ${name} failed: ${res.error}`);
39
+ }
40
+ }
41
+
42
+ async function send(fleet: FleetModule, name: string, content: string): Promise<void> {
43
+ const res = await fleet.handleToolCall({
44
+ id: `send-${name}-${Date.now()}`,
45
+ name: 'send',
46
+ input: { name, content },
47
+ });
48
+ if (!res.success) throw new Error(`send to ${name} failed: ${res.error}`);
49
+ }
50
+
51
+ async function command(fleet: FleetModule, name: string, cmd: string): Promise<void> {
52
+ const res = await fleet.handleToolCall({
53
+ id: `cmd-${name}-${Date.now()}`,
54
+ name: 'command',
55
+ input: { name, command: cmd },
56
+ });
57
+ if (!res.success) throw new Error(`command ${cmd} to ${name} failed: ${res.error}`);
58
+ }
59
+
60
+ async function waitFor(check: () => boolean, timeoutMs: number, label: string): Promise<void> {
61
+ const start = Date.now();
62
+ while (Date.now() - start < timeoutMs) {
63
+ if (check()) return;
64
+ await new Promise((r) => setTimeout(r, 30));
65
+ }
66
+ throw new Error(`waitFor timed out: ${label}`);
67
+ }
68
+
69
+ describe('lifecycle:idle + inference:speech from mock child', () => {
70
+ let tmpDir: string;
71
+ let fleet: FleetModule;
72
+
73
+ beforeAll(async () => {
74
+ tmpDir = mkdtempSync(join(tmpdir(), 'fkm-orch-synth-'));
75
+ fleet = makeFleet();
76
+ await fleet.start({} as unknown as Parameters<typeof fleet.start>[0]);
77
+ await launchChild(fleet, 'a', join(tmpDir, 'a'));
78
+ });
79
+
80
+ afterAll(async () => {
81
+ try { await fleet.stop(); } catch { /* noop */ }
82
+ try { rmSync(tmpDir, { recursive: true, force: true }); } catch { /* noop */ }
83
+ });
84
+
85
+ test('text command triggers inference:speech and lifecycle:idle', async () => {
86
+ await send(fleet, 'a', 'hello there');
87
+
88
+ await waitFor(
89
+ () => fleet.getChildren().get('a')!.lastCompletedSpeech.length > 0,
90
+ 2_000,
91
+ 'lastCompletedSpeech populated from inference:speech',
92
+ );
93
+ expect(fleet.getChildren().get('a')!.lastCompletedSpeech).toBe('echo: hello there');
94
+
95
+ await waitFor(
96
+ () => fleet.getChildren().get('a')!.events.some(
97
+ (e) => e.type === 'lifecycle' && (e as { phase?: string }).phase === 'idle',
98
+ ),
99
+ 2_000,
100
+ 'lifecycle:idle event recorded',
101
+ );
102
+ });
103
+
104
+ test('tool-ending round does NOT update lastCompletedSpeech; final round does', async () => {
105
+ // Before: we have "echo: hello there" from the previous test.
106
+ const before = fleet.getChildren().get('a')!.lastCompletedSpeech;
107
+ expect(before).toContain('echo: hello there');
108
+
109
+ await command(fleet, 'a', '/tool-use-then-speak FINAL-ANSWER');
110
+
111
+ await waitFor(
112
+ () => fleet.getChildren().get('a')!.lastCompletedSpeech === 'FINAL-ANSWER',
113
+ 3_000,
114
+ 'lastCompletedSpeech updated to FINAL-ANSWER (tool-use round ignored)',
115
+ );
116
+ });
117
+ });
118
+
119
+ describe('fleet--relay', () => {
120
+ let tmpDir: string;
121
+ let fleet: FleetModule;
122
+
123
+ beforeAll(async () => {
124
+ tmpDir = mkdtempSync(join(tmpdir(), 'fkm-orch-relay-'));
125
+ fleet = makeFleet();
126
+ await fleet.start({} as unknown as Parameters<typeof fleet.start>[0]);
127
+ await launchChild(fleet, 'src', join(tmpDir, 'src'));
128
+ await launchChild(fleet, 'dst', join(tmpDir, 'dst'));
129
+ });
130
+
131
+ afterAll(async () => {
132
+ try { await fleet.stop(); } catch { /* noop */ }
133
+ try { rmSync(tmpDir, { recursive: true, force: true }); } catch { /* noop */ }
134
+ });
135
+
136
+ test('errors when source has no completed speech yet', async () => {
137
+ const res = await fleet.handleToolCall({
138
+ id: 'relay-empty',
139
+ name: 'relay',
140
+ input: { from: 'src', to: 'dst' },
141
+ });
142
+ expect(res.success).toBe(false);
143
+ expect(res.error).toMatch(/No completed speech/i);
144
+ });
145
+
146
+ test('relays source speech to target (with prefix)', async () => {
147
+ await send(fleet, 'src', 'findings summary');
148
+ await waitFor(
149
+ () => fleet.getChildren().get('src')!.lastCompletedSpeech === 'echo: findings summary',
150
+ 2_000,
151
+ 'src lastCompletedSpeech set',
152
+ );
153
+
154
+ const res = await fleet.handleToolCall({
155
+ id: 'relay-ok',
156
+ name: 'relay',
157
+ input: { from: 'src', to: 'dst', prefix: 'Miner says:' },
158
+ });
159
+ expect(res.success).toBe(true);
160
+ expect(res.data).toMatchObject({ from: 'src', to: 'dst' });
161
+
162
+ // The mock echoes whatever text it receives, so the destination's
163
+ // lastCompletedSpeech should reflect our prefixed payload.
164
+ await waitFor(
165
+ () => fleet.getChildren().get('dst')!.lastCompletedSpeech.startsWith('echo: Miner says:'),
166
+ 3_000,
167
+ 'dst received relayed message and echoed',
168
+ );
169
+ expect(fleet.getChildren().get('dst')!.lastCompletedSpeech).toContain('findings summary');
170
+ });
171
+
172
+ test('rejects same-child relay', async () => {
173
+ const res = await fleet.handleToolCall({
174
+ id: 'relay-self',
175
+ name: 'relay',
176
+ input: { from: 'src', to: 'src' },
177
+ });
178
+ expect(res.success).toBe(false);
179
+ expect(res.error).toMatch(/different children/);
180
+ });
181
+
182
+ test('rejects unknown children', async () => {
183
+ const res1 = await fleet.handleToolCall({
184
+ id: 'relay-nosrc', name: 'relay', input: { from: 'ghost', to: 'dst' },
185
+ });
186
+ expect(res1.success).toBe(false);
187
+ expect(res1.error).toMatch(/Unknown source child/);
188
+
189
+ const res2 = await fleet.handleToolCall({
190
+ id: 'relay-nodst', name: 'relay', input: { from: 'src', to: 'ghost' },
191
+ });
192
+ expect(res2.success).toBe(false);
193
+ expect(res2.error).toMatch(/Unknown child/);
194
+ });
195
+ });
196
+
197
+ describe('fleet--await', () => {
198
+ let tmpDir: string;
199
+
200
+ beforeAll(() => {
201
+ tmpDir = mkdtempSync(join(tmpdir(), 'fkm-orch-await-'));
202
+ });
203
+
204
+ afterAll(() => {
205
+ try { rmSync(tmpDir, { recursive: true, force: true }); } catch { /* noop */ }
206
+ });
207
+
208
+ test('returns immediately when all named children are already idle', async () => {
209
+ const fleet = makeFleet();
210
+ await fleet.start({} as unknown as Parameters<typeof fleet.start>[0]);
211
+ await launchChild(fleet, 'x', join(tmpDir, 'x1'));
212
+
213
+ // Trigger one round so child emits lifecycle:idle.
214
+ await send(fleet, 'x', 'hi');
215
+ await waitFor(
216
+ () => fleet.getChildren().get('x')!.events.some(
217
+ (e) => e.type === 'lifecycle' && (e as { phase?: string }).phase === 'idle',
218
+ ),
219
+ 2_000,
220
+ 'x became idle',
221
+ );
222
+
223
+ const t0 = Date.now();
224
+ const res = await fleet.handleToolCall({
225
+ id: 'await-idle-fast',
226
+ name: 'await',
227
+ input: { names: ['x'], timeoutMs: 1_000 },
228
+ });
229
+ const elapsed = Date.now() - t0;
230
+ expect(res.success).toBe(true);
231
+ expect((res.data as { completed?: boolean }).completed).toBe(true);
232
+ expect(elapsed).toBeLessThan(500);
233
+
234
+ await fleet.stop();
235
+ });
236
+
237
+ test('blocks until all named children emit idle', async () => {
238
+ const fleet = makeFleet();
239
+ await fleet.start({} as unknown as Parameters<typeof fleet.start>[0]);
240
+ await launchChild(fleet, 'p', join(tmpDir, 'p'));
241
+ await launchChild(fleet, 'q', join(tmpDir, 'q'));
242
+
243
+ // Kick both, then await — the await should resolve after both idle up.
244
+ const awaitPromise = (async () => {
245
+ // tiny delay to make sure the sends land before the wait checks "already idle"
246
+ await new Promise((r) => setTimeout(r, 50));
247
+ return fleet.handleToolCall({
248
+ id: 'await-both',
249
+ name: 'await',
250
+ input: { names: ['p', 'q'], timeoutMs: 5_000 },
251
+ });
252
+ })();
253
+
254
+ await send(fleet, 'p', 'work');
255
+ await send(fleet, 'q', 'work');
256
+
257
+ const res = await awaitPromise;
258
+ expect(res.success).toBe(true);
259
+ const data = res.data as { names: string[]; completed: boolean };
260
+ expect(data.completed).toBe(true);
261
+ expect(data.names.sort()).toEqual(['p', 'q']);
262
+
263
+ await fleet.stop();
264
+ }, 15_000);
265
+
266
+ test('returns on any idle when requireAll is false', async () => {
267
+ const fleet = makeFleet();
268
+ await fleet.start({} as unknown as Parameters<typeof fleet.start>[0]);
269
+ await launchChild(fleet, 'fast', join(tmpDir, 'fast'));
270
+ await launchChild(fleet, 'slow', join(tmpDir, 'slow'));
271
+
272
+ const awaitPromise = (async () => {
273
+ await new Promise((r) => setTimeout(r, 50));
274
+ return fleet.handleToolCall({
275
+ id: 'await-any',
276
+ name: 'await',
277
+ input: { names: ['fast', 'slow'], timeoutMs: 5_000, requireAll: false },
278
+ });
279
+ })();
280
+
281
+ await send(fleet, 'fast', 'quick');
282
+ // 'slow' left unbothered — requireAll:false means we return as soon as fast idles.
283
+
284
+ const res = await awaitPromise;
285
+ expect(res.success).toBe(true);
286
+ const data = res.data as { names: string[]; completed: boolean };
287
+ expect(data.completed).toBe(true);
288
+ expect(data.names).toContain('fast');
289
+
290
+ await fleet.stop();
291
+ }, 15_000);
292
+
293
+ test('times out returning partial set', async () => {
294
+ const fleet = makeFleet();
295
+ await fleet.start({} as unknown as Parameters<typeof fleet.start>[0]);
296
+ await launchChild(fleet, 'stuck', join(tmpDir, 'stuck'));
297
+
298
+ await command(fleet, 'stuck', '/hang');
299
+
300
+ const res = await fleet.handleToolCall({
301
+ id: 'await-timeout',
302
+ name: 'await',
303
+ input: { names: ['stuck'], timeoutMs: 300 },
304
+ });
305
+ expect(res.success).toBe(false);
306
+ expect(res.error).toMatch(/timed out/i);
307
+
308
+ await fleet.stop();
309
+ }, 15_000);
310
+
311
+ test('fails fast when waited-for child crashes', async () => {
312
+ const fleet = makeFleet();
313
+ await fleet.start({} as unknown as Parameters<typeof fleet.start>[0]);
314
+ await launchChild(fleet, 'doomed', join(tmpDir, 'doomed'));
315
+
316
+ const awaitPromise = (async () => {
317
+ await new Promise((r) => setTimeout(r, 50));
318
+ return fleet.handleToolCall({
319
+ id: 'await-crash',
320
+ name: 'await',
321
+ input: { names: ['doomed'], timeoutMs: 5_000 },
322
+ });
323
+ })();
324
+
325
+ // Send the crash command.
326
+ await command(fleet, 'doomed', '/crash');
327
+
328
+ const res = await awaitPromise;
329
+ expect(res.success).toBe(false);
330
+ expect(res.error).toMatch(/crashed|exited/);
331
+
332
+ await fleet.stop();
333
+ }, 15_000);
334
+
335
+ test('rejects empty or unknown names', async () => {
336
+ const fleet = makeFleet();
337
+ await fleet.start({} as unknown as Parameters<typeof fleet.start>[0]);
338
+
339
+ const empty = await fleet.handleToolCall({
340
+ id: 'await-empty', name: 'await', input: { names: [] },
341
+ });
342
+ expect(empty.success).toBe(false);
343
+ expect(empty.error).toMatch(/non-empty/);
344
+
345
+ const unknown = await fleet.handleToolCall({
346
+ id: 'await-unk', name: 'await', input: { names: ['ghost'] },
347
+ });
348
+ expect(unknown.success).toBe(false);
349
+ expect(unknown.error).toMatch(/Unknown child/);
350
+
351
+ await fleet.stop();
352
+ });
353
+ });
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Schema-validation tests for modules.fleet and the triumvirate.json recipe.
3
+ * Pure JSON-in / validator-out tests — no subprocess needed.
4
+ */
5
+ import { describe, test, expect } from 'bun:test';
6
+ import { readFileSync } from 'node:fs';
7
+ import { join, resolve, dirname } from 'node:path';
8
+ import { fileURLToPath } from 'node:url';
9
+ import { validateRecipe } from '../src/recipe.js';
10
+
11
+ const TEST_DIR = dirname(fileURLToPath(import.meta.url));
12
+ const REPO_ROOT = resolve(TEST_DIR, '..');
13
+
14
+ function baseRecipe(extra: Record<string, unknown> = {}): Record<string, unknown> {
15
+ return {
16
+ name: 'Test',
17
+ agent: { systemPrompt: 'test' },
18
+ ...extra,
19
+ };
20
+ }
21
+
22
+ describe('validateRecipe — fleet schema', () => {
23
+ test('boolean form is accepted', () => {
24
+ expect(() => validateRecipe(baseRecipe({ modules: { fleet: true } }))).not.toThrow();
25
+ expect(() => validateRecipe(baseRecipe({ modules: { fleet: false } }))).not.toThrow();
26
+ });
27
+
28
+ test('valid object form is accepted', () => {
29
+ expect(() => validateRecipe(baseRecipe({
30
+ modules: {
31
+ fleet: {
32
+ children: [
33
+ { name: 'miner', recipe: 'recipes/miner.json', autoStart: true },
34
+ { name: 'clerk', recipe: 'recipes/clerk.json' },
35
+ ],
36
+ allowedRecipes: ['recipes/*', 'https://trusted.example/agents/*'],
37
+ defaultSubscription: ['lifecycle'],
38
+ },
39
+ },
40
+ }))).not.toThrow();
41
+ });
42
+
43
+ test('rejects mid-string * in allowedRecipes', () => {
44
+ expect(() => validateRecipe(baseRecipe({
45
+ modules: { fleet: { allowedRecipes: ['recipes/*.json'] } },
46
+ }))).toThrow(/mid-string "\*"/);
47
+ expect(() => validateRecipe(baseRecipe({
48
+ modules: { fleet: { allowedRecipes: ['rec*/miner.json'] } },
49
+ }))).toThrow(/mid-string "\*"/);
50
+ });
51
+
52
+ test('children must be an array', () => {
53
+ expect(() => validateRecipe(baseRecipe({
54
+ modules: { fleet: { children: 'not-an-array' } },
55
+ }))).toThrow(/children must be an array/);
56
+ });
57
+
58
+ test('each child must have a non-empty name', () => {
59
+ expect(() => validateRecipe(baseRecipe({
60
+ modules: { fleet: { children: [{ recipe: 'r.json' }] } },
61
+ }))).toThrow(/name must be a non-empty string/);
62
+ });
63
+
64
+ test('each child must have a non-empty recipe', () => {
65
+ expect(() => validateRecipe(baseRecipe({
66
+ modules: { fleet: { children: [{ name: 'x' }] } },
67
+ }))).toThrow(/recipe must be a non-empty string/);
68
+ });
69
+
70
+ test('duplicate child names are rejected', () => {
71
+ expect(() => validateRecipe(baseRecipe({
72
+ modules: {
73
+ fleet: {
74
+ children: [
75
+ { name: 'miner', recipe: 'a.json' },
76
+ { name: 'miner', recipe: 'b.json' },
77
+ ],
78
+ },
79
+ },
80
+ }))).toThrow(/duplicated/);
81
+ });
82
+
83
+ test('allowedRecipes must be an array of strings', () => {
84
+ expect(() => validateRecipe(baseRecipe({
85
+ modules: { fleet: { allowedRecipes: 'recipes/*.json' } },
86
+ }))).toThrow(/allowedRecipes must be an array/);
87
+ expect(() => validateRecipe(baseRecipe({
88
+ modules: { fleet: { allowedRecipes: [1, 2, 3] } },
89
+ }))).toThrow(/allowedRecipes must be an array of strings/);
90
+ });
91
+
92
+ test('subscription must be an array when present', () => {
93
+ expect(() => validateRecipe(baseRecipe({
94
+ modules: { fleet: { children: [{ name: 'x', recipe: 'r.json', subscription: 'not-array' }] } },
95
+ }))).toThrow(/subscription must be an array/);
96
+ });
97
+
98
+ test('autoStart must be boolean when present', () => {
99
+ expect(() => validateRecipe(baseRecipe({
100
+ modules: { fleet: { children: [{ name: 'x', recipe: 'r.json', autoStart: 'yes' }] } },
101
+ }))).toThrow(/autoStart must be a boolean/);
102
+ });
103
+ });
104
+
105
+ describe('recipes/triumvirate.json', () => {
106
+ test('parses and validates cleanly', () => {
107
+ const triumviratePath = join(REPO_ROOT, 'recipes', 'triumvirate.json');
108
+ const raw = JSON.parse(readFileSync(triumviratePath, 'utf-8'));
109
+ const recipe = validateRecipe(raw);
110
+
111
+ expect(recipe.name).toBe('Knowledge Mining Triumvirate');
112
+ expect(recipe.agent.name).toBe('conductor');
113
+ expect(recipe.modules?.fleet).toBeDefined();
114
+ const fleet = recipe.modules?.fleet;
115
+ if (typeof fleet === 'object' && fleet !== null) {
116
+ expect(fleet.children).toHaveLength(3);
117
+ const names = fleet.children!.map((c) => c.name).sort();
118
+ expect(names).toEqual(['clerk', 'miner', 'reviewer']);
119
+ expect(fleet.children!.every((c) => c.autoStart === true)).toBe(true);
120
+ } else {
121
+ throw new Error('fleet should be an object');
122
+ }
123
+ });
124
+ });
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Unit tests for the @childname direct-routing parser used by the TUI.
3
+ */
4
+ import { describe, test, expect } from 'bun:test';
5
+ import { parseFleetRoute } from '../src/modules/fleet-types.js';
6
+
7
+ describe('parseFleetRoute', () => {
8
+ test('routes plain @name content', () => {
9
+ expect(parseFleetRoute('@miner hello there')).toEqual({ childName: 'miner', content: 'hello there' });
10
+ });
11
+
12
+ test('strips colon after name', () => {
13
+ expect(parseFleetRoute('@miner: hello')).toEqual({ childName: 'miner', content: 'hello' });
14
+ });
15
+
16
+ test('accepts hyphens and dots in names', () => {
17
+ expect(parseFleetRoute('@my-bot list channels')).toEqual({ childName: 'my-bot', content: 'list channels' });
18
+ expect(parseFleetRoute('@bot.v2 ping')).toEqual({ childName: 'bot.v2', content: 'ping' });
19
+ });
20
+
21
+ test('preserves multi-line payloads', () => {
22
+ const r = parseFleetRoute('@miner first line\nsecond line');
23
+ expect(r?.childName).toBe('miner');
24
+ expect(r?.content).toBe('first line\nsecond line');
25
+ });
26
+
27
+ test('returns null for non-@ inputs', () => {
28
+ expect(parseFleetRoute('plain text')).toBeNull();
29
+ expect(parseFleetRoute(' leading whitespace then text')).toBeNull();
30
+ });
31
+
32
+ test('returns null for @ with no payload', () => {
33
+ expect(parseFleetRoute('@miner')).toBeNull();
34
+ expect(parseFleetRoute('@miner ')).toBeNull();
35
+ });
36
+
37
+ test('returns null for @@ literal escape', () => {
38
+ expect(parseFleetRoute('@@example.com email-like')).toBeNull();
39
+ });
40
+
41
+ test('tolerates leading whitespace before @', () => {
42
+ expect(parseFleetRoute(' @miner go')).toEqual({ childName: 'miner', content: 'go' });
43
+ });
44
+ });