@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,443 @@
1
+ /**
2
+ * Unit tests for AgentTreeReducer.
3
+ *
4
+ * Strategy: feed representative trace event sequences and assert the resulting
5
+ * tree state matches the canonical fold currently scattered across tui.ts and
6
+ * subagent-module.ts. These streams are hand-built rather than recorded so the
7
+ * test stays stable across framework changes — but the event shapes mirror
8
+ * agent-framework/src/types/trace.ts exactly.
9
+ */
10
+ import { describe, test, expect } from 'bun:test';
11
+ import { AgentTreeReducer, REDUCER_REQUIRED_EVENTS } from '../src/state/agent-tree-reducer.js';
12
+
13
+ function ts(offset: number): number {
14
+ return 1_700_000_000_000 + offset;
15
+ }
16
+
17
+ describe('AgentTreeReducer', () => {
18
+ test('seed creates framework nodes with zero state', () => {
19
+ const r = new AgentTreeReducer();
20
+ r.seedFrameworkAgents(['commander', 'observer']);
21
+ const nodes = r.getNodes();
22
+ expect(nodes.length).toBe(2);
23
+ const commander = r.getNode('commander');
24
+ expect(commander).toBeDefined();
25
+ expect(commander!.kind).toBe('framework');
26
+ expect(commander!.phase).toBe('idle');
27
+ expect(commander!.tokens).toEqual({ input: 0, output: 0, cacheRead: 0, cacheWrite: 0 });
28
+ expect(commander!.toolCallsCount).toBe(0);
29
+ expect(commander!.parent).toBeUndefined();
30
+ });
31
+
32
+ test('phase transitions follow the canonical mapping', () => {
33
+ const r = new AgentTreeReducer();
34
+ r.applyEvent({ type: 'inference:started', agentName: 'a', timestamp: ts(0) });
35
+ expect(r.getNode('a')!.phase).toBe('sending');
36
+
37
+ r.applyEvent({ type: 'inference:tokens', agentName: 'a', content: 'hi', timestamp: ts(1) });
38
+ expect(r.getNode('a')!.phase).toBe('streaming');
39
+
40
+ r.applyEvent({
41
+ type: 'inference:tool_calls_yielded',
42
+ agentName: 'a',
43
+ calls: [{ id: 'c1', name: 'files--read', input: {} }],
44
+ timestamp: ts(2),
45
+ });
46
+ expect(r.getNode('a')!.phase).toBe('invoking');
47
+
48
+ r.applyEvent({ type: 'tool:started', callId: 'c1', tool: 'files--read', module: 'files', timestamp: ts(3) });
49
+ expect(r.getNode('a')!.phase).toBe('executing');
50
+ expect(r.getNode('a')!.toolCallsCount).toBe(1);
51
+
52
+ r.applyEvent({ type: 'tool:completed', callId: 'c1', tool: 'files--read', module: 'files', durationMs: 5, timestamp: ts(4) });
53
+ // tool:completed does NOT transition phase (matches tui.ts behavior)
54
+ expect(r.getNode('a')!.phase).toBe('executing');
55
+
56
+ r.applyEvent({ type: 'inference:completed', agentName: 'a', durationMs: 10, timestamp: ts(5) });
57
+ expect(r.getNode('a')!.phase).toBe('done');
58
+ });
59
+
60
+ test('inference:failed sets phase=failed and status=failed', () => {
61
+ const r = new AgentTreeReducer();
62
+ r.applyEvent({ type: 'inference:started', agentName: 'a', timestamp: ts(0) });
63
+ r.applyEvent({ type: 'inference:failed', agentName: 'a', error: 'boom', timestamp: ts(1) });
64
+ const node = r.getNode('a')!;
65
+ expect(node.phase).toBe('failed');
66
+ expect(node.status).toBe('failed');
67
+ expect(node.completedAt).toBe(ts(1));
68
+ });
69
+
70
+ test('inference:aborted produces terminal cancelled state (P1 #3 — distinct from failed)', () => {
71
+ // Postmortem 2026-05-28 P1 #3: inference:aborted = user cancel /
72
+ // zombie-reclaim / supersession / agent reboot. Not strictly a fault. The
73
+ // previous regression guard (pre-2026-05-28) flipped status to 'failed'
74
+ // because the fleet-child-agent renderer was status-keyed and aborts left
75
+ // it stuck on 'running'. The right answer is a third terminal state,
76
+ // 'cancelled' — terminal (renderer must not show as 'running'), but
77
+ // neutral (renderer must not paint red). Both fields settle to 'cancelled'
78
+ // so consumers of either status or phase get the same answer.
79
+ const r = new AgentTreeReducer();
80
+ r.applyEvent({ type: 'inference:started', agentName: 'a', timestamp: ts(0) });
81
+ r.applyEvent({ type: 'inference:aborted', agentName: 'a', reason: 'user', timestamp: ts(1) });
82
+ const node = r.getNode('a')!;
83
+ expect(node.phase).toBe('cancelled');
84
+ expect(node.status).toBe('cancelled');
85
+ expect(node.completedAt).toBe(ts(1));
86
+ });
87
+
88
+ test('inference:exhausted from in-band stream abort also produces cancelled (P1 #3)', () => {
89
+ // Postmortem 2026-05-28 F1: framework.ts:2108 emits inference:exhausted
90
+ // for budget-restart / stream-side cancel paths. Same semantics as
91
+ // aborted — benign termination, not a fault.
92
+ const r = new AgentTreeReducer();
93
+ r.applyEvent({ type: 'inference:started', agentName: 'a', timestamp: ts(0) });
94
+ r.applyEvent({ type: 'inference:exhausted', agentName: 'a', error: 'budget', timestamp: ts(1) });
95
+ const node = r.getNode('a')!;
96
+ expect(node.phase).toBe('cancelled');
97
+ expect(node.status).toBe('cancelled');
98
+ });
99
+
100
+ // Postmortem 2026-05-28 F1: the reducer never transitions status to 'completed'.
101
+ // inference:completed only sets phase='done', leaving status='running' for the
102
+ // entire lifetime of the agent — which is fine until a later aborted/exhausted/
103
+ // failed event latches status='failed', at which point only a fresh
104
+ // inference:started can clear it. For ephemeral subagents whose terminal event
105
+ // is a benign abort, terminal RED is then permanent.
106
+ test('inference:completed sets status=completed (F1 — closes one-way failed latch)', () => {
107
+ const r = new AgentTreeReducer();
108
+ r.applyEvent({ type: 'inference:started', agentName: 'a', timestamp: ts(0) });
109
+ r.applyEvent({
110
+ type: 'inference:completed',
111
+ agentName: 'a',
112
+ durationMs: 5,
113
+ timestamp: ts(1),
114
+ });
115
+ const node = r.getNode('a')!;
116
+ expect(node.phase).toBe('done');
117
+ expect(node.status).toBe('completed');
118
+ });
119
+
120
+ test('inference:completed after a prior aborted clears the cancelled status (F1 — recovery un-latches)', () => {
121
+ // The "successful turn clears earlier transient terminal status" behavior.
122
+ // Without this, the admin UI shows long-running agents non-running for
123
+ // hours after a single transient cancel even though they have since
124
+ // completed many turns. Combined with the P1 #3 cancelled-vs-failed split
125
+ // (the prior aborted now lands as 'cancelled', not 'failed').
126
+ const r = new AgentTreeReducer();
127
+ r.applyEvent({ type: 'inference:started', agentName: 'a', timestamp: ts(0) });
128
+ r.applyEvent({ type: 'inference:aborted', agentName: 'a', reason: 'cancel', timestamp: ts(1) });
129
+ expect(r.getNode('a')!.status).toBe('cancelled');
130
+
131
+ // A subsequent successful round should heal the latch.
132
+ r.applyEvent({ type: 'inference:started', agentName: 'a', timestamp: ts(2) });
133
+ r.applyEvent({
134
+ type: 'inference:completed',
135
+ agentName: 'a',
136
+ durationMs: 5,
137
+ timestamp: ts(3),
138
+ });
139
+ expect(r.getNode('a')!.status).toBe('completed');
140
+ });
141
+
142
+ test('REDUCER_REQUIRED_EVENTS is derived from the dispatch table — every entry actually does something', () => {
143
+ // Forward direction: each event listed in the constant must observably
144
+ // affect a fresh reducer (i.e. it has a real handler, not a stale entry).
145
+ // This catches "constant added but handler never written" and also makes
146
+ // the derived-from-handler-keys design self-checking.
147
+ for (const eventType of REDUCER_REQUIRED_EVENTS) {
148
+ const r = new AgentTreeReducer();
149
+ const event = makeRepresentativeEvent(eventType);
150
+ r.applyEvent(event as never);
151
+ // We expect either a node was created/mutated, or a callId mapping was
152
+ // installed. The simplest observable: a non-empty getNodes() OR a
153
+ // tool-event ref that wouldn't have been routable without
154
+ // tool_calls_yielded establishing the index.
155
+ const nodes = r.getNodes();
156
+ const observable = nodes.length > 0 || hasCallIdSideEffect(eventType);
157
+ expect(observable).toBe(true);
158
+ }
159
+ });
160
+ });
161
+
162
+ /** Synthesize a minimal valid event of the given type for the invariant test. */
163
+ function makeRepresentativeEvent(eventType: string): Record<string, unknown> {
164
+ // Tool events route via callId, not agentName. Pre-establish a binding by
165
+ // priming the reducer with a tool_calls_yielded that sets up an index — the
166
+ // test driver below handles this case separately.
167
+ switch (eventType) {
168
+ case 'inference:tool_calls_yielded':
169
+ return {
170
+ type: eventType,
171
+ agentName: 'agent',
172
+ calls: [{ id: 'c1', name: 'x', input: {} }],
173
+ timestamp: 1000,
174
+ };
175
+ case 'tool:started':
176
+ case 'tool:completed':
177
+ case 'tool:failed':
178
+ return { type: eventType, callId: 'c1', tool: 'x', module: 'm', timestamp: 1000, durationMs: 1, error: '' };
179
+ case 'inference:usage':
180
+ return {
181
+ type: eventType,
182
+ agentName: 'agent',
183
+ tokenUsage: { input: 100, output: 10 },
184
+ timestamp: 1000,
185
+ };
186
+ case 'inference:completed':
187
+ return {
188
+ type: eventType,
189
+ agentName: 'agent',
190
+ durationMs: 5,
191
+ tokenUsage: { input: 100, output: 10 },
192
+ timestamp: 1000,
193
+ };
194
+ case 'inference:failed':
195
+ case 'inference:exhausted':
196
+ case 'inference:aborted':
197
+ return { type: eventType, agentName: 'agent', error: 'x', reason: 'x', timestamp: 1000 };
198
+ case 'inference:stream_restarted':
199
+ return { type: eventType, agentName: 'agent', reason: 'x', inputTokens: 0, budget: 0, timestamp: 1000 };
200
+ default:
201
+ return { type: eventType, agentName: 'agent', content: '', timestamp: 1000 };
202
+ }
203
+ }
204
+
205
+ /** Tool events that arrive without a prior tool_calls_yielded just no-op. We
206
+ * don't pre-prime the reducer in the invariant test, so for tool:* events the
207
+ * observable check is the inverse: the reducer correctly does nothing when
208
+ * the callId is unknown. We accept that as "wired up" because the no-op path
209
+ * itself proves the case is in the dispatch table — falling through to
210
+ * default would never reach the lookup. */
211
+ function hasCallIdSideEffect(eventType: string): boolean {
212
+ return eventType === 'tool:started' || eventType === 'tool:completed' || eventType === 'tool:failed';
213
+ }
214
+
215
+ describe('AgentTreeReducer (continued)', () => {
216
+
217
+ test('input tokens overwrite (current context size); output/cache accumulate', () => {
218
+ const r = new AgentTreeReducer();
219
+ r.applyEvent({
220
+ type: 'inference:usage',
221
+ agentName: 'a',
222
+ tokenUsage: { input: 1000, output: 50, cacheRead: 200, cacheCreation: 100 },
223
+ timestamp: ts(0),
224
+ });
225
+ r.applyEvent({
226
+ type: 'inference:usage',
227
+ agentName: 'a',
228
+ tokenUsage: { input: 1500, output: 80, cacheRead: 300, cacheCreation: 50 },
229
+ timestamp: ts(1),
230
+ });
231
+ const tokens = r.getNode('a')!.tokens;
232
+ expect(tokens.input).toBe(1500); // overwrite (current context size)
233
+ expect(tokens.output).toBe(130); // accumulated
234
+ expect(tokens.cacheRead).toBe(500); // accumulated
235
+ expect(tokens.cacheWrite).toBe(150); // accumulated
236
+ });
237
+
238
+ test('inference:completed final tokenUsage applies same accumulation rules', () => {
239
+ const r = new AgentTreeReducer();
240
+ r.applyEvent({
241
+ type: 'inference:usage',
242
+ agentName: 'a',
243
+ tokenUsage: { input: 500, output: 20, cacheRead: 0, cacheCreation: 0 },
244
+ timestamp: ts(0),
245
+ });
246
+ r.applyEvent({
247
+ type: 'inference:completed',
248
+ agentName: 'a',
249
+ durationMs: 100,
250
+ tokenUsage: { input: 700, output: 30, cacheRead: 50, cacheCreation: 10 },
251
+ timestamp: ts(1),
252
+ });
253
+ const tokens = r.getNode('a')!.tokens;
254
+ expect(tokens.input).toBe(700);
255
+ expect(tokens.output).toBe(50);
256
+ expect(tokens.cacheRead).toBe(50);
257
+ expect(tokens.cacheWrite).toBe(10);
258
+ });
259
+
260
+ test('subagent--spawn tool call creates a child node with parent edge', () => {
261
+ const r = new AgentTreeReducer();
262
+ r.applyEvent({
263
+ type: 'inference:tool_calls_yielded',
264
+ agentName: 'commander',
265
+ calls: [{
266
+ id: 'c1',
267
+ name: 'subagent--spawn',
268
+ input: { name: 'researcher', task: 'investigate X' },
269
+ }],
270
+ timestamp: ts(0),
271
+ });
272
+ const child = r.getNode('researcher');
273
+ expect(child).toBeDefined();
274
+ expect(child!.kind).toBe('subagent');
275
+ expect(child!.subagentType).toBe('spawn');
276
+ expect(child!.task).toBe('investigate X');
277
+ expect(child!.parent).toBe('commander');
278
+ expect(r.getChildren('commander').map(n => n.name)).toContain('researcher');
279
+ });
280
+
281
+ test('subagent--fork tool call records type=fork', () => {
282
+ const r = new AgentTreeReducer();
283
+ r.applyEvent({
284
+ type: 'inference:tool_calls_yielded',
285
+ agentName: 'commander',
286
+ calls: [{
287
+ id: 'c1',
288
+ name: 'subagent--fork',
289
+ input: { name: 'side-quest', task: 'try a different approach' },
290
+ }],
291
+ timestamp: ts(0),
292
+ });
293
+ expect(r.getNode('side-quest')!.subagentType).toBe('fork');
294
+ });
295
+
296
+ test('fleet--launch tool call creates a framework-kind child', () => {
297
+ const r = new AgentTreeReducer();
298
+ r.applyEvent({
299
+ type: 'inference:tool_calls_yielded',
300
+ agentName: 'conductor',
301
+ calls: [{
302
+ id: 'c1',
303
+ name: 'fleet--launch',
304
+ input: { name: 'miner-1', recipe: 'knowledge-miner.json' },
305
+ }],
306
+ timestamp: ts(0),
307
+ });
308
+ const child = r.getNode('miner-1');
309
+ expect(child).toBeDefined();
310
+ expect(child!.kind).toBe('framework');
311
+ expect(child!.parent).toBe('conductor');
312
+ });
313
+
314
+ test('tool events without a known callId are dropped silently', () => {
315
+ const r = new AgentTreeReducer();
316
+ r.applyEvent({ type: 'tool:started', callId: 'unknown-id', tool: 'x', module: 'y', timestamp: ts(0) });
317
+ expect(r.getNodes().length).toBe(0);
318
+ });
319
+
320
+ test('tool events route to the agent that yielded the call', () => {
321
+ const r = new AgentTreeReducer();
322
+ r.applyEvent({
323
+ type: 'inference:tool_calls_yielded',
324
+ agentName: 'a',
325
+ calls: [{ id: 'c1', name: 'x', input: {} }],
326
+ timestamp: ts(0),
327
+ });
328
+ r.applyEvent({
329
+ type: 'inference:tool_calls_yielded',
330
+ agentName: 'b',
331
+ calls: [{ id: 'c2', name: 'y', input: {} }],
332
+ timestamp: ts(1),
333
+ });
334
+ r.applyEvent({ type: 'tool:started', callId: 'c1', tool: 'x', module: 'm', timestamp: ts(2) });
335
+ r.applyEvent({ type: 'tool:started', callId: 'c2', tool: 'y', module: 'm', timestamp: ts(3) });
336
+ r.applyEvent({ type: 'tool:started', callId: 'c2', tool: 'y', module: 'm', timestamp: ts(4) });
337
+ expect(r.getNode('a')!.toolCallsCount).toBe(1);
338
+ expect(r.getNode('b')!.toolCallsCount).toBe(2);
339
+ });
340
+
341
+ test('applySnapshot replaces all state', () => {
342
+ const r = new AgentTreeReducer();
343
+ r.applyEvent({ type: 'inference:started', agentName: 'old-agent', timestamp: ts(0) });
344
+ expect(r.getNode('old-agent')).toBeDefined();
345
+
346
+ r.applySnapshot({
347
+ asOfTs: ts(100),
348
+ nodes: [{
349
+ name: 'new-agent',
350
+ kind: 'framework',
351
+ status: 'running',
352
+ phase: 'streaming',
353
+ tokens: { input: 5000, output: 200, cacheRead: 100, cacheWrite: 50 },
354
+ toolCallsCount: 3,
355
+ findingsCount: 0,
356
+ }],
357
+ callIdIndex: { 'pre-existing-call': 'new-agent' },
358
+ });
359
+ expect(r.getNode('old-agent')).toBeUndefined();
360
+ const recovered = r.getNode('new-agent')!;
361
+ expect(recovered.tokens.input).toBe(5000);
362
+ expect(recovered.toolCallsCount).toBe(3);
363
+
364
+ // Tool events for callIds in the snapshot's callIdIndex should still route correctly.
365
+ r.applyEvent({ type: 'tool:started', callId: 'pre-existing-call', tool: 'x', module: 'm', timestamp: ts(101) });
366
+ expect(r.getNode('new-agent')!.toolCallsCount).toBe(4);
367
+ });
368
+
369
+ test('applySnapshot is deep-copy safe (mutating returned nodes does not affect reducer)', () => {
370
+ const r = new AgentTreeReducer();
371
+ r.applyEvent({ type: 'inference:started', agentName: 'a', timestamp: ts(0) });
372
+ r.applyEvent({
373
+ type: 'inference:usage',
374
+ agentName: 'a',
375
+ tokenUsage: { input: 100, output: 10 },
376
+ timestamp: ts(1),
377
+ });
378
+ const snap = r.getSnapshot();
379
+ snap.nodes[0]!.tokens.input = 999;
380
+ snap.nodes[0]!.toolCallsCount = 999;
381
+ expect(r.getNode('a')!.tokens.input).toBe(100);
382
+ expect(r.getNode('a')!.toolCallsCount).toBe(0);
383
+ });
384
+
385
+ test('reset clears everything', () => {
386
+ const r = new AgentTreeReducer();
387
+ r.applyEvent({ type: 'inference:started', agentName: 'a', timestamp: ts(0) });
388
+ r.reset();
389
+ expect(r.getNodes().length).toBe(0);
390
+ });
391
+
392
+ test('getRoots returns parentless nodes only', () => {
393
+ const r = new AgentTreeReducer();
394
+ r.seedFrameworkAgents(['commander']);
395
+ r.applyEvent({
396
+ type: 'inference:tool_calls_yielded',
397
+ agentName: 'commander',
398
+ calls: [{
399
+ id: 'c1',
400
+ name: 'subagent--spawn',
401
+ input: { name: 'child', task: 't' },
402
+ }],
403
+ timestamp: ts(0),
404
+ });
405
+ const roots = r.getRoots();
406
+ expect(roots.map(n => n.name)).toEqual(['commander']);
407
+ });
408
+
409
+ test('lazy node creation on first event for an unseen agent', () => {
410
+ const r = new AgentTreeReducer();
411
+ r.applyEvent({ type: 'inference:started', agentName: 'lazy-agent', timestamp: ts(0) });
412
+ const node = r.getNode('lazy-agent');
413
+ expect(node).toBeDefined();
414
+ expect(node!.kind).toBe('framework');
415
+ expect(node!.startedAt).toBe(ts(0));
416
+ });
417
+
418
+ test('startedAt is set on first inference, not overwritten on subsequent', () => {
419
+ const r = new AgentTreeReducer();
420
+ r.applyEvent({ type: 'inference:started', agentName: 'a', timestamp: ts(0) });
421
+ r.applyEvent({ type: 'inference:completed', agentName: 'a', durationMs: 5, timestamp: ts(10) });
422
+ r.applyEvent({ type: 'inference:started', agentName: 'a', timestamp: ts(20) });
423
+ expect(r.getNode('a')!.startedAt).toBe(ts(0));
424
+ });
425
+
426
+ test('lastEventAt tracks the most recent event timestamp', () => {
427
+ const r = new AgentTreeReducer();
428
+ r.applyEvent({ type: 'inference:started', agentName: 'a', timestamp: ts(0) });
429
+ r.applyEvent({ type: 'inference:tokens', agentName: 'a', content: 'x', timestamp: ts(50) });
430
+ expect(r.getNode('a')!.lastEventAt).toBe(ts(50));
431
+ });
432
+
433
+ test('unknown event types are ignored without affecting state', () => {
434
+ const r = new AgentTreeReducer();
435
+ r.applyEvent({ type: 'inference:started', agentName: 'a', timestamp: ts(0) });
436
+ const before = r.getNode('a')!;
437
+ r.applyEvent({ type: 'gate:decision', eventType: 'foo', matchedPolicy: null, trigger: false, behavior: 'allow' } as never);
438
+ r.applyEvent({ type: 'process:received', processEvent: { type: 'whatever' } } as never);
439
+ r.applyEvent({ type: 'message:added', messageId: 'm1', source: 's' } as never);
440
+ const after = r.getNode('a')!;
441
+ expect(after).toEqual(before);
442
+ });
443
+ });
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Phase rollup logic — picks the busiest active phase from a list of
3
+ * AgentTreeReducer nodes, used by the TUI to colour fleet-child header rows
4
+ * with what's *actually happening inside the child*, not just lifecycle
5
+ * status. The helper itself lives inside tui.ts as a closure, so this test
6
+ * mirrors its behaviour against canonical reducer state.
7
+ */
8
+ import { describe, test, expect } from 'bun:test';
9
+ import { AgentTreeReducer } from '../src/state/agent-tree-reducer.js';
10
+ import type { AgentNode } from '../src/state/agent-tree-reducer.js';
11
+
12
+ type ActivePhase = 'sending' | 'streaming' | 'invoking' | 'executing';
13
+
14
+ /** Reference implementation of the rollup. Mirrors tui.ts:rollupActivePhase. */
15
+ function rollupActivePhase(nodes: AgentNode[]): ActivePhase | null {
16
+ const PRIORITY: Record<ActivePhase, number> = {
17
+ streaming: 5,
18
+ invoking: 4,
19
+ executing: 3,
20
+ sending: 2,
21
+ };
22
+ let best: ActivePhase | null = null;
23
+ let bestScore = -1;
24
+ for (const n of nodes) {
25
+ const phase = n.phase;
26
+ if (phase === 'streaming' || phase === 'invoking' ||
27
+ phase === 'executing' || phase === 'sending') {
28
+ const score = PRIORITY[phase];
29
+ if (score > bestScore) {
30
+ best = phase;
31
+ bestScore = score;
32
+ }
33
+ }
34
+ }
35
+ return best;
36
+ }
37
+
38
+ const t = (offset: number) => 1_700_000_000_000 + offset;
39
+
40
+ describe('phase rollup for fleet-child header status', () => {
41
+ test('returns null when nothing is active', () => {
42
+ const r = new AgentTreeReducer();
43
+ r.applyEvent({ type: 'inference:started', agentName: 'a', timestamp: t(0) });
44
+ r.applyEvent({ type: 'inference:completed', agentName: 'a', durationMs: 5, timestamp: t(1) });
45
+ expect(rollupActivePhase(r.getNodes())).toBeNull();
46
+ });
47
+
48
+ test('returns the agent phase when one agent is mid-stream', () => {
49
+ const r = new AgentTreeReducer();
50
+ r.applyEvent({ type: 'inference:started', agentName: 'a', timestamp: t(0) });
51
+ r.applyEvent({ type: 'inference:tokens', agentName: 'a', content: 'hi', timestamp: t(1) });
52
+ expect(rollupActivePhase(r.getNodes())).toBe('streaming');
53
+ });
54
+
55
+ test('streaming wins over executing when multiple agents are busy', () => {
56
+ const r = new AgentTreeReducer();
57
+ r.applyEvent({ type: 'inference:started', agentName: 'a', timestamp: t(0) });
58
+ r.applyEvent({
59
+ type: 'inference:tool_calls_yielded',
60
+ agentName: 'a',
61
+ calls: [{ id: 'c1', name: 'x', input: {} }],
62
+ timestamp: t(1),
63
+ });
64
+ r.applyEvent({ type: 'tool:started', callId: 'c1', tool: 'x', module: 'm', timestamp: t(2) });
65
+ // Now agent 'a' is in 'executing'. Add agent 'b' that starts streaming.
66
+ r.applyEvent({ type: 'inference:tokens', agentName: 'b', content: 'hi', timestamp: t(3) });
67
+ expect(rollupActivePhase(r.getNodes())).toBe('streaming');
68
+ });
69
+
70
+ test('done and idle do not register as active', () => {
71
+ const r = new AgentTreeReducer();
72
+ r.applyEvent({ type: 'inference:started', agentName: 'a', timestamp: t(0) });
73
+ r.applyEvent({ type: 'inference:completed', agentName: 'a', durationMs: 5, timestamp: t(1) });
74
+ r.seedFrameworkAgents(['idle-agent']); // phase=idle by default
75
+ expect(rollupActivePhase(r.getNodes())).toBeNull();
76
+ });
77
+
78
+ test('invoking and executing rank between sending and streaming', () => {
79
+ const reduce = (phase: 'sending' | 'streaming' | 'invoking' | 'executing'): AgentTreeReducer => {
80
+ const r = new AgentTreeReducer();
81
+ r.applyEvent({ type: 'inference:started', agentName: 'a', timestamp: t(0) });
82
+ if (phase === 'streaming') {
83
+ r.applyEvent({ type: 'inference:tokens', agentName: 'a', content: 'x', timestamp: t(1) });
84
+ } else if (phase === 'invoking' || phase === 'executing') {
85
+ r.applyEvent({
86
+ type: 'inference:tool_calls_yielded',
87
+ agentName: 'a',
88
+ calls: [{ id: 'c1', name: 'x', input: {} }],
89
+ timestamp: t(1),
90
+ });
91
+ if (phase === 'executing') {
92
+ r.applyEvent({ type: 'tool:started', callId: 'c1', tool: 'x', module: 'm', timestamp: t(2) });
93
+ }
94
+ }
95
+ return r;
96
+ };
97
+ expect(rollupActivePhase(reduce('streaming').getNodes())).toBe('streaming');
98
+ expect(rollupActivePhase(reduce('invoking').getNodes())).toBe('invoking');
99
+ expect(rollupActivePhase(reduce('executing').getNodes())).toBe('executing');
100
+ expect(rollupActivePhase(reduce('sending').getNodes())).toBe('sending');
101
+ });
102
+
103
+ test('failed agents do not count as active (the activity rolled up is *current* work, not historical state)', () => {
104
+ const r = new AgentTreeReducer();
105
+ r.applyEvent({ type: 'inference:started', agentName: 'a', timestamp: t(0) });
106
+ r.applyEvent({ type: 'inference:failed', agentName: 'a', error: 'boom', timestamp: t(1) });
107
+ expect(rollupActivePhase(r.getNodes())).toBeNull();
108
+ });
109
+ });
@@ -0,0 +1,40 @@
1
+ import { describe, test, expect } from 'bun:test';
2
+ import { AutobiographicalStrategy } from '@animalabs/agent-framework';
3
+
4
+ // Guards against silent breakage if upstream renames the protected fields
5
+ // that getProgressSnapshot reads. The shape is what warmup-session.ts relies on.
6
+ describe('AutobiographicalStrategy.getProgressSnapshot', () => {
7
+ test('returns the expected shape on a fresh strategy', () => {
8
+ const strategy = new AutobiographicalStrategy({
9
+ compressionModel: 'claude-sonnet-4-5-20250929',
10
+ autoTickOnNewMessage: false,
11
+ });
12
+ const snapshot = strategy.getProgressSnapshot();
13
+ expect(snapshot).toEqual({
14
+ totalChunks: 0,
15
+ chunksCompressed: 0,
16
+ l1QueueLength: 0,
17
+ mergeQueueLength: 0,
18
+ summaryCounts: { l1: 0, l2: 0, l3: 0 },
19
+ pending: false,
20
+ });
21
+ });
22
+
23
+ test('exposes the keys warmup-session.ts depends on', () => {
24
+ const strategy = new AutobiographicalStrategy({
25
+ compressionModel: 'claude-sonnet-4-5-20250929',
26
+ autoTickOnNewMessage: false,
27
+ });
28
+ const s = strategy.getProgressSnapshot();
29
+ // Property access — if any rename happens upstream these become undefined
30
+ // and the test fails loudly.
31
+ expect(typeof s.totalChunks).toBe('number');
32
+ expect(typeof s.chunksCompressed).toBe('number');
33
+ expect(typeof s.l1QueueLength).toBe('number');
34
+ expect(typeof s.mergeQueueLength).toBe('number');
35
+ expect(typeof s.pending).toBe('boolean');
36
+ expect(typeof s.summaryCounts.l1).toBe('number');
37
+ expect(typeof s.summaryCounts.l2).toBe('number');
38
+ expect(typeof s.summaryCounts.l3).toBe('number');
39
+ });
40
+ });