@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,381 @@
1
+ /**
2
+ * Empirical test for the async-subagent timeout claim in commit 2169410.
3
+ *
4
+ * Schema text (since that commit) claims:
5
+ * "Sync tasks default to 600s (auto-detaches to background).
6
+ * Async tasks have no default timeout."
7
+ *
8
+ * Rather than routing through a mock LLM (the plain text it emits leaves the
9
+ * ephemeral agent stuck in `streaming` state because `subagent--return` is
10
+ * never called, so `runEphemeralToCompletion` only resolves on a fallback
11
+ * event), we stub `runEphemeralToCompletion` directly with a promise that
12
+ * just sleeps for `subagentRunMs`. That's the smallest faithful surrogate for
13
+ * "the subagent's LLM call is long-running" and it keeps the test focused on
14
+ * SubagentModule's timeout wiring.
15
+ */
16
+ import { describe, test, expect } from 'bun:test';
17
+ import { mkdtempSync, rmSync } from 'node:fs';
18
+ import { tmpdir } from 'node:os';
19
+ import { join } from 'node:path';
20
+ import { AgentFramework } from '@animalabs/agent-framework';
21
+ import type { Module, ToolCall } from '@animalabs/agent-framework';
22
+ import { Membrane, MockAdapter, NativeFormatter } from '@animalabs/membrane';
23
+ import { SubagentModule } from '../src/modules/subagent-module.js';
24
+
25
+ async function makeHarness(opts: { maxExecutionMs: number; subagentRunMs: number }) {
26
+ const tmpDir = mkdtempSync(join(tmpdir(), 'sub-timeout-'));
27
+ const adapter = new MockAdapter({ defaultResponse: 'ok' });
28
+ const membrane = new Membrane(adapter, { formatter: new NativeFormatter() });
29
+ const subagent = new SubagentModule({
30
+ parentAgentName: 'parent',
31
+ defaultModel: 'mock',
32
+ defaultMaxTokens: 256,
33
+ maxExecutionMs: opts.maxExecutionMs,
34
+ maxRetries: 0, // don't let retries mask the timeout result
35
+ });
36
+ const framework = await AgentFramework.create({
37
+ storePath: join(tmpDir, 'store'),
38
+ membrane,
39
+ agents: [{
40
+ name: 'parent',
41
+ model: 'mock',
42
+ systemPrompt: 'parent',
43
+ maxTokens: 256,
44
+ }],
45
+ modules: [subagent as unknown as Module],
46
+ });
47
+
48
+ // Stub runEphemeralToCompletion: pretend the subagent's LLM work takes
49
+ // `subagentRunMs` to finish, then resolves like a normal completion.
50
+ // This isolates SubagentModule's timeout wiring from the agent state
51
+ // machine / membrane streaming machinery.
52
+ const fw = framework as unknown as {
53
+ runEphemeralToCompletion: (
54
+ agent: unknown,
55
+ ctxMgr: unknown,
56
+ ) => Promise<{ speech: string; toolCallsCount: number }>;
57
+ agents: Map<string, unknown>;
58
+ };
59
+ fw.runEphemeralToCompletion = (agent: unknown) => {
60
+ // Register ephemeral agent into the framework's agents map the way the
61
+ // real implementation does, so any downstream dispatch still works.
62
+ const a = agent as { name: string };
63
+ fw.agents.set(a.name, agent);
64
+ return new Promise((resolve) => {
65
+ setTimeout(() => {
66
+ fw.agents.delete(a.name);
67
+ resolve({ speech: 'ok', toolCallsCount: 0 });
68
+ }, opts.subagentRunMs);
69
+ });
70
+ };
71
+
72
+ subagent.setFramework(framework);
73
+ framework.start();
74
+
75
+ return {
76
+ framework,
77
+ subagent,
78
+ cleanup: async () => {
79
+ try { await framework.stop(); } catch { /* noop */ }
80
+ try { rmSync(tmpDir, { recursive: true, force: true }); } catch { /* noop */ }
81
+ },
82
+ };
83
+ }
84
+
85
+ function makeToolCall(name: string, input: Record<string, unknown>): ToolCall {
86
+ return {
87
+ id: `test-${name}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
88
+ name,
89
+ input,
90
+ callerAgentName: 'parent',
91
+ };
92
+ }
93
+
94
+ type InternalSubagent = SubagentModule & {
95
+ asyncHandles: Map<string, { promise: Promise<unknown> }>;
96
+ };
97
+
98
+ /**
99
+ * Harness variant focused on the maxTokens cascade.
100
+ *
101
+ * Intercepts `createEphemeralAgent` so we can observe what budget the module
102
+ * actually asked for, and short-circuits `runEphemeralToCompletion` so the
103
+ * promise settles immediately — we only care about the ephemeral config.
104
+ */
105
+ async function makeBudgetHarness(opts: {
106
+ parentMaxTokens: number;
107
+ moduleDefaultMaxTokens?: number;
108
+ }) {
109
+ const tmpDir = mkdtempSync(join(tmpdir(), 'sub-budget-'));
110
+ const adapter = new MockAdapter({ defaultResponse: 'ok' });
111
+ const membrane = new Membrane(adapter, { formatter: new NativeFormatter() });
112
+ const subagent = new SubagentModule({
113
+ parentAgentName: 'parent',
114
+ defaultModel: 'mock',
115
+ defaultMaxTokens: opts.moduleDefaultMaxTokens,
116
+ maxRetries: 0,
117
+ });
118
+ const framework = await AgentFramework.create({
119
+ storePath: join(tmpDir, 'store'),
120
+ membrane,
121
+ agents: [{
122
+ name: 'parent',
123
+ model: 'mock',
124
+ systemPrompt: 'parent',
125
+ maxTokens: opts.parentMaxTokens,
126
+ }],
127
+ modules: [subagent as unknown as Module],
128
+ });
129
+
130
+ const captured: Array<{ name: string; maxTokens: number }> = [];
131
+ const fw = framework as unknown as {
132
+ createEphemeralAgent: (config: { name: string; maxTokens: number; [k: string]: unknown }) => Promise<{
133
+ agent: { name: string; maxTokens: number };
134
+ contextManager: { addMessage: (...a: unknown[]) => void; compile: () => Promise<{ messages: unknown[] }> };
135
+ cleanup: () => void;
136
+ }>;
137
+ runEphemeralToCompletion: (agent: unknown, cm: unknown) => Promise<{ speech: string; toolCallsCount: number }>;
138
+ getAllTools: () => Array<{ name: string }>;
139
+ agents: Map<string, unknown>;
140
+ };
141
+ const originalCreate = fw.createEphemeralAgent.bind(fw);
142
+ fw.createEphemeralAgent = async (config) => {
143
+ captured.push({ name: config.name, maxTokens: config.maxTokens });
144
+ return originalCreate(config);
145
+ };
146
+ fw.runEphemeralToCompletion = (agent: unknown) => {
147
+ const a = agent as { name: string };
148
+ fw.agents.set(a.name, agent);
149
+ return new Promise((resolve) => setTimeout(() => {
150
+ fw.agents.delete(a.name);
151
+ resolve({ speech: 'ok', toolCallsCount: 0 });
152
+ }, 20));
153
+ };
154
+
155
+ subagent.setFramework(framework);
156
+ framework.start();
157
+
158
+ return {
159
+ framework,
160
+ subagent,
161
+ captured,
162
+ cleanup: async () => {
163
+ try { await framework.stop(); } catch { /* noop */ }
164
+ try { rmSync(tmpDir, { recursive: true, force: true }); } catch { /* noop */ }
165
+ },
166
+ };
167
+ }
168
+
169
+ describe('SubagentModule timeout behaviour (async vs sync default)', () => {
170
+ test('ASYNC spawn, no explicit timeoutMs, subagent runs longer than maxExecutionMs', async () => {
171
+ const MAX_EXEC = 1_000;
172
+ const SUBAGENT_WORK = 3_000;
173
+ const { subagent, cleanup } = await makeHarness({
174
+ maxExecutionMs: MAX_EXEC,
175
+ subagentRunMs: SUBAGENT_WORK,
176
+ });
177
+ try {
178
+ const t0 = Date.now();
179
+ const ack = await subagent.handleToolCall(makeToolCall('spawn', {
180
+ name: 'async-slowpoke',
181
+ systemPrompt: 'you are a test subagent',
182
+ task: 'reply',
183
+ // sync: false (default), timeoutMs: undefined
184
+ }));
185
+ expect(ack.success).toBe(true);
186
+ const ackElapsed = Date.now() - t0;
187
+ expect(ackElapsed).toBeLessThan(500); // truly returned early
188
+ const ackText = typeof ack.data === 'string' ? ack.data : JSON.stringify(ack.data);
189
+ expect(ackText).toMatch(/background/i);
190
+
191
+ const handles = (subagent as unknown as InternalSubagent).asyncHandles;
192
+ const handle = handles.get('async-slowpoke');
193
+ expect(handle).toBeDefined();
194
+
195
+ const settled = await handle!.promise.then(
196
+ r => ({ ok: true as const, r }),
197
+ e => ({ ok: false as const, e: e as Error }),
198
+ );
199
+ const elapsed = Date.now() - t0;
200
+ console.log(
201
+ `[async-no-timeout] settled=${settled.ok ? 'ok' : 'error'} ` +
202
+ `elapsed=${elapsed}ms ` +
203
+ (settled.ok ? '' : `err=${settled.e.message}`),
204
+ );
205
+
206
+ // Schema claim: "Async tasks have no default timeout."
207
+ // If the claim holds → settled.ok === true, elapsed ≈ SUBAGENT_WORK (~3s).
208
+ // If the claim is wrong → settled.ok === false, err "timed out after 1000ms".
209
+ expect(settled.ok).toBe(true);
210
+ expect(elapsed).toBeGreaterThanOrEqual(SUBAGENT_WORK - 200);
211
+ } finally {
212
+ await cleanup();
213
+ }
214
+ }, 15_000);
215
+
216
+ test('SYNC spawn, no explicit timeoutMs: hard timeout fires at maxExecutionMs', async () => {
217
+ const MAX_EXEC = 1_000;
218
+ const SUBAGENT_WORK = 3_000;
219
+ const { subagent, cleanup } = await makeHarness({
220
+ maxExecutionMs: MAX_EXEC,
221
+ subagentRunMs: SUBAGENT_WORK,
222
+ });
223
+ try {
224
+ const t0 = Date.now();
225
+ const res = await subagent.handleToolCall(makeToolCall('spawn', {
226
+ name: 'sync-slowpoke',
227
+ systemPrompt: 'you are a test subagent',
228
+ task: 'reply',
229
+ sync: true,
230
+ }));
231
+ const elapsed = Date.now() - t0;
232
+ console.log(
233
+ `[sync-default] success=${res.success} elapsed=${elapsed}ms ` +
234
+ `data=${typeof res.data === 'string' ? res.data : JSON.stringify(res.data)} ` +
235
+ `error=${res.error ?? '—'}`,
236
+ );
237
+
238
+ // Current (post-2169410) code: sync path arms withTimeout at maxExecutionMs
239
+ // and the auto-detach timer is gated on input.timeoutMs being explicit.
240
+ // Expected observation: error "timed out after 1000ms", NOT a "moved to
241
+ // background" ack — contradicting the schema's "auto-detaches" promise.
242
+ expect(res.success).toBe(false);
243
+ expect(res.error ?? '').toMatch(/timed out/i);
244
+ expect(elapsed).toBeLessThan(SUBAGENT_WORK);
245
+ } finally {
246
+ await cleanup();
247
+ }
248
+ }, 10_000);
249
+
250
+ test('SYNC spawn WITH explicit timeoutMs shorter than work: auto-detach vs hard-timeout race', async () => {
251
+ // Both `timeoutMs` parameters in handleSpawn resolve to the same value
252
+ // when input.timeoutMs is explicit — so withTimeout (reject) and
253
+ // autoDetachMs (detach→ack) race at the same deadline. This test
254
+ // documents which side wins in practice.
255
+ const { subagent, cleanup } = await makeHarness({
256
+ maxExecutionMs: 60_000, // large, so input.timeoutMs is what matters
257
+ subagentRunMs: 3_000,
258
+ });
259
+ try {
260
+ const t0 = Date.now();
261
+ const res = await subagent.handleToolCall(makeToolCall('spawn', {
262
+ name: 'sync-explicit',
263
+ systemPrompt: 'you are a test subagent',
264
+ task: 'reply',
265
+ sync: true,
266
+ timeoutMs: 500,
267
+ }));
268
+ const elapsed = Date.now() - t0;
269
+ console.log(
270
+ `[sync-explicit-race] success=${res.success} elapsed=${elapsed}ms ` +
271
+ `data=${typeof res.data === 'string' ? res.data : JSON.stringify(res.data)} ` +
272
+ `error=${res.error ?? '—'}`,
273
+ );
274
+ // Observation recorded below by the assertion the implementation actually
275
+ // passes — we intentionally don't enforce which side wins, because that's
276
+ // exactly the undefined-behaviour question we're investigating.
277
+ expect(elapsed).toBeLessThan(2_500);
278
+ // The orphaned subagent promise will itself hard-timeout shortly after;
279
+ // wait for its deliverAsyncError to flush so shutdown doesn't race a
280
+ // "Queue is closed" push. (A small but interesting latent bug: when
281
+ // autoDetachMs == hard timeout, the post-detach promise rejection
282
+ // arrives after the parent has already moved on.)
283
+ await new Promise(r => setTimeout(r, 300));
284
+ } finally {
285
+ await cleanup();
286
+ }
287
+ }, 10_000);
288
+ });
289
+
290
+ describe('SubagentModule maxTokens cascade', () => {
291
+ test('SPAWN inherits parent.maxTokens when no per-call and no module default', async () => {
292
+ const { subagent, captured, cleanup } = await makeBudgetHarness({
293
+ parentMaxTokens: 12_345,
294
+ // moduleDefaultMaxTokens intentionally omitted
295
+ });
296
+ try {
297
+ const res = await subagent.handleToolCall(makeToolCall('spawn', {
298
+ name: 'inherit-spawn',
299
+ systemPrompt: 'sp',
300
+ task: 'reply',
301
+ sync: true,
302
+ }));
303
+ expect(res.success).toBe(true);
304
+ expect(captured.length).toBe(1);
305
+ expect(captured[0].maxTokens).toBe(12_345);
306
+ } finally {
307
+ await cleanup();
308
+ }
309
+ }, 10_000);
310
+
311
+ test('FORK inherits parent.maxTokens when no per-call and no module default', async () => {
312
+ const { subagent, captured, cleanup } = await makeBudgetHarness({
313
+ parentMaxTokens: 12_345,
314
+ });
315
+ try {
316
+ const res = await subagent.handleToolCall(makeToolCall('fork', {
317
+ name: 'inherit-fork',
318
+ task: 'reply',
319
+ sync: true,
320
+ }));
321
+ expect(res.success).toBe(true);
322
+ expect(captured.length).toBe(1);
323
+ expect(captured[0].maxTokens).toBe(12_345);
324
+ } finally {
325
+ await cleanup();
326
+ }
327
+ }, 10_000);
328
+
329
+ test('per-call maxTokens wins over recipe default and parent', async () => {
330
+ const { subagent, captured, cleanup } = await makeBudgetHarness({
331
+ parentMaxTokens: 12_345,
332
+ moduleDefaultMaxTokens: 8_000,
333
+ });
334
+ try {
335
+ // spawn side
336
+ const resSpawn = await subagent.handleToolCall(makeToolCall('spawn', {
337
+ name: 'override-spawn',
338
+ systemPrompt: 'sp',
339
+ task: 'reply',
340
+ maxTokens: 3_333,
341
+ sync: true,
342
+ }));
343
+ expect(resSpawn.success).toBe(true);
344
+
345
+ // fork side
346
+ const resFork = await subagent.handleToolCall(makeToolCall('fork', {
347
+ name: 'override-fork',
348
+ task: 'reply',
349
+ maxTokens: 4_444,
350
+ sync: true,
351
+ }));
352
+ expect(resFork.success).toBe(true);
353
+
354
+ expect(captured.length).toBe(2);
355
+ expect(captured[0].maxTokens).toBe(3_333);
356
+ expect(captured[1].maxTokens).toBe(4_444);
357
+ } finally {
358
+ await cleanup();
359
+ }
360
+ }, 10_000);
361
+
362
+ test('recipe-level defaultMaxTokens wins over parent.maxTokens', async () => {
363
+ const { subagent, captured, cleanup } = await makeBudgetHarness({
364
+ parentMaxTokens: 12_345,
365
+ moduleDefaultMaxTokens: 8_000,
366
+ });
367
+ try {
368
+ const res = await subagent.handleToolCall(makeToolCall('spawn', {
369
+ name: 'recipe-default',
370
+ systemPrompt: 'sp',
371
+ task: 'reply',
372
+ sync: true,
373
+ }));
374
+ expect(res.success).toBe(true);
375
+ expect(captured.length).toBe(1);
376
+ expect(captured[0].maxTokens).toBe(8_000);
377
+ } finally {
378
+ await cleanup();
379
+ }
380
+ }, 10_000);
381
+ });
@@ -0,0 +1,241 @@
1
+ /**
2
+ * Tests for materialiseStructuralFork — the fork-context transformation that
3
+ * addresses the fork-cascade bug surfaced in data-08.05.
4
+ *
5
+ * The cascade: when the parent issues several subagent--fork tool_uses in one
6
+ * assistant turn, each child used to inherit the parent's full compiled
7
+ * context — including sibling fork tool_use blocks, sibling tool_results,
8
+ * and the parent's post-fork peek/wait turns. The overwhelming "fleet of
9
+ * forks just got dispatched" signal convinced each child it was the parent,
10
+ * and they cascaded by re-issuing the fork list.
11
+ *
12
+ * Fix (here): at fork-materialise time, locate the matching tool_use by id,
13
+ * strip sibling fork tool_uses and their results, rewrite the matching
14
+ * tool_result with intention-stream framing, and drop everything after.
15
+ */
16
+ import { describe, test, expect } from 'bun:test';
17
+ import type { ContentBlock } from '@animalabs/membrane';
18
+ import { materialiseStructuralFork, buildIntentionFramedForkResult } from '../src/modules/subagent-module.js';
19
+
20
+ interface Msg { participant: string; content: ContentBlock[]; }
21
+
22
+ function userText(text: string): Msg {
23
+ return { participant: 'user', content: [{ type: 'text', text }] };
24
+ }
25
+ function asstText(name: string, text: string): Msg {
26
+ return { participant: name, content: [{ type: 'text', text }] };
27
+ }
28
+
29
+ describe('materialiseStructuralFork', () => {
30
+ test('returns null when the matching tool_use is absent (compressed away)', () => {
31
+ const compiled: Msg[] = [
32
+ userText('go investigate things'),
33
+ asstText('researcher', 'On it.'),
34
+ ];
35
+ const out = materialiseStructuralFork(compiled, 'toolu_missing', 'scout-a', 'do a thing', 1, 3);
36
+ expect(out).toBeNull();
37
+ });
38
+
39
+ test('strips sibling fork tool_use blocks and rewrites the matching tool_result', () => {
40
+ const compiled: Msg[] = [
41
+ userText('go investigate things'),
42
+ {
43
+ participant: 'researcher',
44
+ content: [
45
+ { type: 'text', text: 'Dispatching scouts.' },
46
+ { type: 'tool_use', id: 'toolu_A', name: 'subagent--fork', input: { name: 'scout-a', task: 'A' } },
47
+ { type: 'tool_use', id: 'toolu_B', name: 'subagent--fork', input: { name: 'scout-b', task: 'B' } },
48
+ { type: 'tool_use', id: 'toolu_C', name: 'subagent--fork', input: { name: 'scout-c', task: 'C' } },
49
+ ],
50
+ },
51
+ {
52
+ participant: 'user',
53
+ content: [
54
+ { type: 'tool_result', toolUseId: 'toolu_A', content: "Subagent 'scout-a' forked. Running in background." },
55
+ { type: 'tool_result', toolUseId: 'toolu_B', content: "Subagent 'scout-b' forked. Running in background." },
56
+ { type: 'tool_result', toolUseId: 'toolu_C', content: "Subagent 'scout-c' forked. Running in background." },
57
+ ],
58
+ },
59
+ ];
60
+
61
+ const out = materialiseStructuralFork(compiled, 'toolu_B', 'scout-b', 'B', 1, 3);
62
+ expect(out).not.toBeNull();
63
+ expect(out!.length).toBe(3);
64
+
65
+ // Pre-fork history preserved.
66
+ expect(out![0]).toEqual(compiled[0]);
67
+
68
+ // Fork assistant turn keeps text + only the matching tool_use.
69
+ const asst = out![1];
70
+ expect(asst.participant).toBe('researcher');
71
+ const toolUses = asst.content.filter(b => b.type === 'tool_use');
72
+ expect(toolUses.length).toBe(1);
73
+ expect((toolUses[0] as { id: string }).id).toBe('toolu_B');
74
+ // Text narrative preserved (it's not load-bearing for cascade and removing
75
+ // it would be more invasive than necessary).
76
+ expect(asst.content.some(b => b.type === 'text')).toBe(true);
77
+
78
+ // tool_result turn: siblings stripped, matching one rewritten with intention framing.
79
+ const resultTurn = out![2];
80
+ const results = resultTurn.content.filter(b => b.type === 'tool_result');
81
+ expect(results.length).toBe(1);
82
+ const matchingResult = results[0] as { toolUseId: string; content: string };
83
+ expect(matchingResult.toolUseId).toBe('toolu_B');
84
+ expect(matchingResult.content).toContain('Two parallel streams of you');
85
+ expect(matchingResult.content).toContain('the self reading this is the fork');
86
+ expect(matchingResult.content).toContain('B'); // the task / intention
87
+ expect(matchingResult.content).not.toContain('Running in background');
88
+ });
89
+
90
+ test('drops post-fork tail (peek/wait/zombie messages)', () => {
91
+ const compiled: Msg[] = [
92
+ asstText('researcher', 'Dispatching.'),
93
+ {
94
+ participant: 'researcher',
95
+ content: [
96
+ { type: 'tool_use', id: 'toolu_A', name: 'subagent--fork', input: { name: 'scout-a', task: 'A' } },
97
+ { type: 'tool_use', id: 'toolu_B', name: 'subagent--fork', input: { name: 'scout-b', task: 'B' } },
98
+ ],
99
+ },
100
+ {
101
+ participant: 'user',
102
+ content: [
103
+ { type: 'tool_result', toolUseId: 'toolu_A', content: "Subagent 'scout-a' forked. Running in background." },
104
+ { type: 'tool_result', toolUseId: 'toolu_B', content: "Subagent 'scout-b' forked. Running in background." },
105
+ ],
106
+ },
107
+ // post-fork tail — parent peeking and waiting; child must NOT inherit any of this.
108
+ {
109
+ participant: 'researcher',
110
+ content: [
111
+ { type: 'text', text: 'Let me check on the scouts.' },
112
+ { type: 'tool_use', id: 'toolu_peek', name: 'subagent--peek', input: {} },
113
+ ],
114
+ },
115
+ {
116
+ participant: 'user',
117
+ content: [
118
+ { type: 'tool_result', toolUseId: 'toolu_peek', content: 'scout-a: running 5s, scout-b: running 5s' },
119
+ ],
120
+ },
121
+ asstText('researcher', 'All 5 scouts are running. Standing by.'),
122
+ ];
123
+
124
+ const out = materialiseStructuralFork(compiled, 'toolu_B', 'scout-b', 'B', 1, 3);
125
+ expect(out).not.toBeNull();
126
+ // pre-fork (1) + matching assistant turn (1) + matching tool_result turn (1) = 3.
127
+ expect(out!.length).toBe(3);
128
+
129
+ // None of the post-fork tail should appear.
130
+ const flatJson = JSON.stringify(out);
131
+ expect(flatJson).not.toContain('toolu_peek');
132
+ expect(flatJson).not.toContain('Let me check on the scouts');
133
+ expect(flatJson).not.toContain('Standing by');
134
+ });
135
+
136
+ test('synthesises the matching tool_result when it is not yet in compiled context', () => {
137
+ const compiled: Msg[] = [
138
+ asstText('researcher', 'Dispatching scouts.'),
139
+ {
140
+ participant: 'researcher',
141
+ content: [
142
+ { type: 'tool_use', id: 'toolu_A', name: 'subagent--fork', input: { name: 'scout-a', task: 'A' } },
143
+ { type: 'tool_use', id: 'toolu_B', name: 'subagent--fork', input: { name: 'scout-b', task: 'B' } },
144
+ ],
145
+ },
146
+ // No user turn — parent's tool_results haven't been added to its
147
+ // ContextManager yet (fork is being dispatched right now).
148
+ ];
149
+
150
+ const out = materialiseStructuralFork(compiled, 'toolu_B', 'scout-b', 'B', 1, 3);
151
+ expect(out).not.toBeNull();
152
+ expect(out!.length).toBe(3);
153
+
154
+ const last = out![out!.length - 1];
155
+ expect(last.participant).toBe('user');
156
+ const result = last.content[0] as { type: string; toolUseId: string; content: string };
157
+ expect(result.type).toBe('tool_result');
158
+ expect(result.toolUseId).toBe('toolu_B');
159
+ expect(result.content).toContain('the self reading this is the fork');
160
+ });
161
+
162
+ test('handles the single-fork case (no siblings) without breakage', () => {
163
+ const compiled: Msg[] = [
164
+ asstText('researcher', 'Dispatching one scout.'),
165
+ {
166
+ participant: 'researcher',
167
+ content: [
168
+ { type: 'text', text: 'Need a focused dive on the FTP server.' },
169
+ { type: 'tool_use', id: 'toolu_only', name: 'subagent--fork', input: { name: 'scout-ftp', task: 'check ftp' } },
170
+ ],
171
+ },
172
+ {
173
+ participant: 'user',
174
+ content: [
175
+ { type: 'tool_result', toolUseId: 'toolu_only', content: "Subagent 'scout-ftp' forked. Running in background." },
176
+ ],
177
+ },
178
+ ];
179
+
180
+ const out = materialiseStructuralFork(compiled, 'toolu_only', 'scout-ftp', 'check ftp', 1, 3);
181
+ expect(out).not.toBeNull();
182
+ expect(out!.length).toBe(3);
183
+ const result = out![2].content[0] as { content: string };
184
+ expect(result.content).toContain('check ftp');
185
+ expect(result.content).toContain('Two parallel streams of you');
186
+ });
187
+
188
+ test('non-fork tool_use blocks (peek calls etc.) in the fork assistant turn are preserved', () => {
189
+ // Edge case: the parent's assistant turn might mix fork tool_uses with
190
+ // other tool_uses (e.g. a status check). The non-fork tool_uses should
191
+ // pass through unchanged so the matching tool_results stay valid.
192
+ const compiled: Msg[] = [
193
+ {
194
+ participant: 'researcher',
195
+ content: [
196
+ { type: 'tool_use', id: 'toolu_peek_pre', name: 'subagent--peek', input: {} },
197
+ { type: 'tool_use', id: 'toolu_A', name: 'subagent--fork', input: { name: 'scout-a', task: 'A' } },
198
+ { type: 'tool_use', id: 'toolu_B', name: 'subagent--fork', input: { name: 'scout-b', task: 'B' } },
199
+ ],
200
+ },
201
+ {
202
+ participant: 'user',
203
+ content: [
204
+ { type: 'tool_result', toolUseId: 'toolu_peek_pre', content: 'no fleet running' },
205
+ { type: 'tool_result', toolUseId: 'toolu_A', content: "Subagent 'scout-a' forked. Running in background." },
206
+ { type: 'tool_result', toolUseId: 'toolu_B', content: "Subagent 'scout-b' forked. Running in background." },
207
+ ],
208
+ },
209
+ ];
210
+
211
+ const out = materialiseStructuralFork(compiled, 'toolu_A', 'scout-a', 'A', 1, 3);
212
+ expect(out).not.toBeNull();
213
+
214
+ const asst = out![0];
215
+ // peek + matching fork preserved; sibling fork stripped.
216
+ const toolUseIds = asst.content.filter(b => b.type === 'tool_use').map(b => (b as { id: string }).id);
217
+ expect(toolUseIds.sort()).toEqual(['toolu_A', 'toolu_peek_pre']);
218
+
219
+ const resultTurn = out![1];
220
+ const resultIds = resultTurn.content.filter(b => b.type === 'tool_result').map(b => (b as { toolUseId: string }).toolUseId);
221
+ expect(resultIds.sort()).toEqual(['toolu_A', 'toolu_peek_pre']);
222
+ });
223
+ });
224
+
225
+ describe('buildIntentionFramedForkResult', () => {
226
+ test('includes the task verbatim and the dual-stream framing', () => {
227
+ const text = buildIntentionFramedForkResult('scout-a', 'investigate the FTP server', 1, 3);
228
+ expect(text).toContain('investigate the FTP server');
229
+ expect(text).toContain('Two parallel streams');
230
+ expect(text).toContain('the self reading this is the fork');
231
+ expect(text).toContain('subagent--return');
232
+ });
233
+
234
+ test('flags max depth correctly', () => {
235
+ const middle = buildIntentionFramedForkResult('x', 'y', 1, 3);
236
+ expect(middle).toContain('2 sub-fork levels remaining');
237
+
238
+ const atMax = buildIntentionFramedForkResult('x', 'y', 3, 3);
239
+ expect(atMax).toContain('cannot sub-fork');
240
+ });
241
+ });