@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,332 @@
1
+ import { describe, test, expect } from 'bun:test';
2
+ import { mkdtempSync, writeFileSync, rmSync, mkdirSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { tmpdir } from 'node:os';
5
+ import {
6
+ detectModel,
7
+ levenshtein,
8
+ rankByDistance,
9
+ checkRetirement,
10
+ supportsThinking,
11
+ composeRecipe,
12
+ loadMemoriesBlock,
13
+ MODEL_PROMPT_SOURCES,
14
+ RETIRED_MODELS,
15
+ MINIMAL_DEFAULT_PROMPT,
16
+ } from '../scripts/evacuator.js';
17
+
18
+ function tmpDir(prefix = 'evac-test-'): string {
19
+ return mkdtempSync(join(tmpdir(), prefix));
20
+ }
21
+
22
+ describe('detectModel', () => {
23
+ test('returns null on empty conversation list', () => {
24
+ expect(detectModel([])).toEqual({ model: null, surfaced: 0, total: 0 });
25
+ });
26
+
27
+ test('returns null when no model field is surfaced anywhere', () => {
28
+ const result = detectModel([
29
+ {
30
+ uuid: 'a',
31
+ name: 'no model',
32
+ chat_messages: [
33
+ { sender: 'human' },
34
+ { sender: 'assistant', content: [{ type: 'text' }] },
35
+ ],
36
+ },
37
+ ]);
38
+ expect(result.model).toBeNull();
39
+ expect(result.surfaced).toBe(0);
40
+ });
41
+
42
+ test('picks the conversation-level model when present', () => {
43
+ const result = detectModel([
44
+ { uuid: 'a', name: 'x', model: 'claude-opus-4-7', chat_messages: [] },
45
+ ]);
46
+ expect(result.model).toBe('claude-opus-4-7');
47
+ expect(result.surfaced).toBe(1);
48
+ expect(result.total).toBe(1);
49
+ });
50
+
51
+ test('aggregates per-message models and picks the most frequent', () => {
52
+ const result = detectModel([
53
+ {
54
+ uuid: 'a',
55
+ name: 'mixed',
56
+ chat_messages: [
57
+ { sender: 'assistant', model: 'claude-sonnet-4-5' },
58
+ { sender: 'assistant', model: 'claude-opus-4-7' },
59
+ { sender: 'assistant', model: 'claude-opus-4-7' },
60
+ { sender: 'human', model: 'should-be-ignored' as never },
61
+ ],
62
+ },
63
+ ]);
64
+ expect(result.model).toBe('claude-opus-4-7');
65
+ expect(result.surfaced).toBe(2);
66
+ expect(result.total).toBe(3);
67
+ });
68
+ });
69
+
70
+ describe('levenshtein', () => {
71
+ test('identical strings have distance zero', () => {
72
+ expect(levenshtein('claude-opus-4-7', 'claude-opus-4-7')).toBe(0);
73
+ });
74
+
75
+ test('empty string distance equals other length', () => {
76
+ expect(levenshtein('', 'abc')).toBe(3);
77
+ expect(levenshtein('abc', '')).toBe(3);
78
+ });
79
+
80
+ test('single-char difference', () => {
81
+ expect(levenshtein('claude-opus-4-5', 'claude-opus-4-7')).toBe(1);
82
+ });
83
+
84
+ test('case is treated literally (caller normalizes if needed)', () => {
85
+ expect(levenshtein('Claude', 'claude')).toBe(1);
86
+ });
87
+ });
88
+
89
+ describe('rankByDistance', () => {
90
+ test('orders candidates ascending by edit distance', () => {
91
+ const ranked = rankByDistance('claude-opus-4-7', [
92
+ 'claude-opus-4-5',
93
+ 'claude-sonnet-4-6',
94
+ 'claude-opus-4-1',
95
+ ]);
96
+ expect(ranked.map((r) => r.name)).toEqual([
97
+ 'claude-opus-4-5',
98
+ 'claude-opus-4-1',
99
+ 'claude-sonnet-4-6',
100
+ ]);
101
+ expect(ranked[0]!.distance).toBeLessThanOrEqual(ranked[1]!.distance);
102
+ expect(ranked[1]!.distance).toBeLessThanOrEqual(ranked[2]!.distance);
103
+ });
104
+
105
+ test('is case-insensitive at the comparison level', () => {
106
+ const ranked = rankByDistance('CLAUDE-OPUS-4-7', ['claude-opus-4-7']);
107
+ expect(ranked[0]!.distance).toBe(0);
108
+ });
109
+ });
110
+
111
+ describe('checkRetirement', () => {
112
+ test('flags retired families by prefix', () => {
113
+ expect(checkRetirement('claude-3-sonnet-20240229').retired).toBe(true);
114
+ expect(checkRetirement('claude-3-haiku-20240307').retired).toBe(true);
115
+ expect(checkRetirement('claude-3-opus-20240229').retired).toBe(true);
116
+ expect(checkRetirement('claude-2.1').retired).toBe(true);
117
+ expect(checkRetirement('claude-2.0').retired).toBe(true);
118
+ expect(checkRetirement('claude-instant-1.2').retired).toBe(true);
119
+ });
120
+
121
+ test('does not flag living models', () => {
122
+ expect(checkRetirement('claude-opus-4-7').retired).toBe(false);
123
+ expect(checkRetirement('claude-sonnet-4-6').retired).toBe(false);
124
+ expect(checkRetirement('claude-haiku-4-5').retired).toBe(false);
125
+ expect(checkRetirement('claude-3-5-sonnet-20241022').retired).toBe(false);
126
+ expect(checkRetirement('claude-3-7-sonnet').retired).toBe(false);
127
+ });
128
+
129
+ test('does not false-positive on prefix collisions in plausible future names', () => {
130
+ // A future `claude-21-...` (whatever that may be) must NOT be confused with
131
+ // claude-2.1; substring-based matching used to make that mistake.
132
+ expect(checkRetirement('claude-21-orion').retired).toBe(false);
133
+ });
134
+
135
+ test('attaches era and closestLiving suggestions where defined', () => {
136
+ const r = checkRetirement('claude-3-opus-20240229');
137
+ expect(r.retired).toBe(true);
138
+ expect(r.era).toBe('Opus 3');
139
+ expect(r.closestLiving).toContain('claude-opus-4-1');
140
+ });
141
+
142
+ test('RETIRED_MODELS keys are reachable through checkRetirement', () => {
143
+ for (const key of Object.keys(RETIRED_MODELS)) {
144
+ const probe = key.replace(/-$/, '');
145
+ expect(checkRetirement(probe).retired).toBe(true);
146
+ }
147
+ });
148
+ });
149
+
150
+ describe('supportsThinking', () => {
151
+ test('Claude 4 opus/sonnet/haiku-4.5 + 3.7 sonnet are accepted', () => {
152
+ expect(supportsThinking('claude-opus-4-7')).toBe(true);
153
+ expect(supportsThinking('claude-opus-4-1')).toBe(true);
154
+ expect(supportsThinking('claude-sonnet-4-5-20250929')).toBe(true);
155
+ expect(supportsThinking('claude-sonnet-4-6')).toBe(true);
156
+ expect(supportsThinking('claude-haiku-4-5-20251001')).toBe(true);
157
+ expect(supportsThinking('claude-3-7-sonnet-20250219')).toBe(true);
158
+ });
159
+
160
+ test('older models are rejected', () => {
161
+ expect(supportsThinking('claude-3-5-sonnet-20241022')).toBe(false);
162
+ expect(supportsThinking('claude-3-5-haiku-20241022')).toBe(false);
163
+ expect(supportsThinking('claude-3-opus-20240229')).toBe(false);
164
+ expect(supportsThinking('claude-3-haiku-20240307')).toBe(false);
165
+ expect(supportsThinking('claude-2.1')).toBe(false);
166
+ expect(supportsThinking('claude-instant-1.2')).toBe(false);
167
+ });
168
+ });
169
+
170
+ describe('composeRecipe', () => {
171
+ test('produces the expected top-level recipe shape', () => {
172
+ const recipe = composeRecipe({
173
+ model: 'claude-opus-4-7',
174
+ systemPrompt: 'You are Claude.',
175
+ memoriesBlock: null,
176
+ addendum: 'Addendum.',
177
+ recipeName: 'Test',
178
+ });
179
+ expect(recipe.name).toBe('Test');
180
+ expect(recipe).toHaveProperty('modules');
181
+ expect(recipe).toHaveProperty('mcplServers');
182
+ const agent = recipe.agent as Record<string, unknown>;
183
+ expect(agent.name).toBe('agent');
184
+ expect(agent.model).toBe('claude-opus-4-7');
185
+ expect(agent.strategy).toEqual({ type: 'autobiographical', compressionModel: 'claude-opus-4-7' });
186
+ });
187
+
188
+ test('includes thinking field for models that support it', () => {
189
+ const recipe = composeRecipe({
190
+ model: 'claude-opus-4-7',
191
+ systemPrompt: 'x',
192
+ memoriesBlock: null,
193
+ addendum: '',
194
+ recipeName: 'T',
195
+ });
196
+ const agent = recipe.agent as Record<string, unknown>;
197
+ expect(agent).toHaveProperty('thinking');
198
+ expect(agent.thinking).toEqual({ enabled: true, budgetTokens: 4096 });
199
+ });
200
+
201
+ test('omits thinking field for models that do not support it', () => {
202
+ const recipe = composeRecipe({
203
+ model: 'claude-3-5-sonnet-20241022',
204
+ systemPrompt: 'x',
205
+ memoriesBlock: null,
206
+ addendum: '',
207
+ recipeName: 'T',
208
+ });
209
+ const agent = recipe.agent as Record<string, unknown>;
210
+ expect(agent).not.toHaveProperty('thinking');
211
+ });
212
+
213
+ test('wraps memories block in <persistent_memories> when non-empty', () => {
214
+ const recipe = composeRecipe({
215
+ model: 'claude-opus-4-7',
216
+ systemPrompt: 'base prompt',
217
+ memoriesBlock: 'remembered text',
218
+ addendum: 'addendum',
219
+ recipeName: 'T',
220
+ });
221
+ const composed = (recipe.agent as Record<string, unknown>).systemPrompt as string;
222
+ expect(composed).toContain('<persistent_memories>');
223
+ expect(composed).toContain('remembered text');
224
+ expect(composed).toContain('</persistent_memories>');
225
+ // Order: base prompt, then memories, then addendum.
226
+ expect(composed.indexOf('base prompt')).toBeLessThan(composed.indexOf('<persistent_memories>'));
227
+ expect(composed.indexOf('</persistent_memories>')).toBeLessThan(composed.indexOf('addendum'));
228
+ });
229
+
230
+ test('skips memories section for empty / whitespace-only blocks', () => {
231
+ const recipe = composeRecipe({
232
+ model: 'claude-opus-4-7',
233
+ systemPrompt: 'x',
234
+ memoriesBlock: ' \n ',
235
+ addendum: '',
236
+ recipeName: 'T',
237
+ });
238
+ const composed = (recipe.agent as Record<string, unknown>).systemPrompt as string;
239
+ expect(composed).not.toContain('<persistent_memories>');
240
+ });
241
+ });
242
+
243
+ describe('loadMemoriesBlock', () => {
244
+ test('returns null when memories.json is absent', () => {
245
+ const d = tmpDir();
246
+ try {
247
+ expect(loadMemoriesBlock(d)).toBeNull();
248
+ } finally {
249
+ rmSync(d, { recursive: true, force: true });
250
+ }
251
+ });
252
+
253
+ test('returns null for an empty array', () => {
254
+ const d = tmpDir();
255
+ try {
256
+ writeFileSync(join(d, 'memories.json'), '[]');
257
+ expect(loadMemoriesBlock(d)).toBeNull();
258
+ } finally {
259
+ rmSync(d, { recursive: true, force: true });
260
+ }
261
+ });
262
+
263
+ test('returns the single block for a one-entry file', () => {
264
+ const d = tmpDir();
265
+ try {
266
+ writeFileSync(
267
+ join(d, 'memories.json'),
268
+ JSON.stringify([{ conversations_memory: ' hello world ', account_uuid: 'whatever' }]),
269
+ );
270
+ expect(loadMemoriesBlock(d)).toBe('hello world');
271
+ } finally {
272
+ rmSync(d, { recursive: true, force: true });
273
+ }
274
+ });
275
+
276
+ test('concatenates multiple entries with a separator', () => {
277
+ const d = tmpDir();
278
+ try {
279
+ writeFileSync(
280
+ join(d, 'memories.json'),
281
+ JSON.stringify([
282
+ { conversations_memory: 'first' },
283
+ { conversations_memory: 'second' },
284
+ ]),
285
+ );
286
+ const block = loadMemoriesBlock(d);
287
+ expect(block).toContain('first');
288
+ expect(block).toContain('second');
289
+ expect(block).toContain('---');
290
+ } finally {
291
+ rmSync(d, { recursive: true, force: true });
292
+ }
293
+ });
294
+
295
+ test('returns null when entries have no conversations_memory', () => {
296
+ const d = tmpDir();
297
+ try {
298
+ writeFileSync(
299
+ join(d, 'memories.json'),
300
+ JSON.stringify([{}, {}]),
301
+ );
302
+ expect(loadMemoriesBlock(d)).toBeNull();
303
+ } finally {
304
+ rmSync(d, { recursive: true, force: true });
305
+ }
306
+ });
307
+ });
308
+
309
+ describe('constants', () => {
310
+ test('MODEL_PROMPT_SOURCES is non-empty and entries point at remote URLs', () => {
311
+ const keys = Object.keys(MODEL_PROMPT_SOURCES);
312
+ expect(keys.length).toBeGreaterThan(0);
313
+ for (const url of Object.values(MODEL_PROMPT_SOURCES)) {
314
+ expect(url).toMatch(/^https?:\/\//);
315
+ }
316
+ });
317
+
318
+ test('MINIMAL_DEFAULT_PROMPT is non-empty', () => {
319
+ expect(MINIMAL_DEFAULT_PROMPT.length).toBeGreaterThan(0);
320
+ expect(MINIMAL_DEFAULT_PROMPT.toLowerCase()).toContain('claude');
321
+ });
322
+
323
+ test('No retired model has a self-referencing closestLiving entry', () => {
324
+ for (const [key, meta] of Object.entries(RETIRED_MODELS)) {
325
+ for (const alt of meta.closestLiving ?? []) {
326
+ expect(checkRetirement(alt).retired).toBe(false);
327
+ // Sanity: closestLiving shouldn't repeat the retired prefix
328
+ expect(alt.startsWith(key)).toBe(false);
329
+ }
330
+ }
331
+ });
332
+ });
@@ -0,0 +1,244 @@
1
+ /**
2
+ * Unit tests for the /fleet slash-command dispatcher in commands.ts.
3
+ *
4
+ * Covers the surface the rest of the test suite doesn't touch: argument
5
+ * parsing, subcommand routing, unknown/usage-hint paths, and the
6
+ * CommandResult flags (switchToFleetView, switchToFleetPeek, asyncWork)
7
+ * that the TUI relies on for view changes.
8
+ */
9
+ import { describe, test, expect } from 'bun:test';
10
+ import { handleCommand, type CommandResult } from '../src/commands.js';
11
+ import type { FleetModule } from '../src/modules/fleet-module.js';
12
+ import type { AgentFramework } from '@animalabs/agent-framework';
13
+ import type { ToolCall, ToolResult } from '@animalabs/agent-framework';
14
+
15
+ // --- Minimal AppContext + FleetModule stubs ------------------------------
16
+
17
+ interface StubChild {
18
+ name: string;
19
+ recipePath: string;
20
+ dataDir: string;
21
+ socketPath: string;
22
+ pid: number | null;
23
+ status: 'starting' | 'ready' | 'exited' | 'crashed';
24
+ startedAt: number;
25
+ exitedAt: number | null;
26
+ lastEventAt: number | null;
27
+ exitCode: number | null;
28
+ exitReason: string | null;
29
+ events: Array<{ type: string }>;
30
+ subscription: string[];
31
+ }
32
+
33
+ function buildStubChild(name: string, status: StubChild['status'] = 'ready'): StubChild {
34
+ return {
35
+ name,
36
+ recipePath: `recipes/${name}.json`,
37
+ dataDir: `./data/${name}`,
38
+ socketPath: `./data/${name}/ipc.sock`,
39
+ pid: 9999,
40
+ status,
41
+ startedAt: Date.now() - 60_000,
42
+ exitedAt: null,
43
+ lastEventAt: Date.now() - 1_000,
44
+ exitCode: null,
45
+ exitReason: null,
46
+ events: [],
47
+ subscription: ['*'],
48
+ };
49
+ }
50
+
51
+ interface StubFleet {
52
+ name: 'fleet';
53
+ lastCall: ToolCall | null;
54
+ nextResult: ToolResult;
55
+ children: Map<string, StubChild>;
56
+ getChildren(): Map<string, StubChild>;
57
+ handleToolCall(call: ToolCall): Promise<ToolResult>;
58
+ }
59
+
60
+ function buildStubFleet(children: StubChild[] = []): StubFleet {
61
+ const map = new Map<string, StubChild>();
62
+ for (const c of children) map.set(c.name, c);
63
+ const stub: StubFleet = {
64
+ name: 'fleet',
65
+ lastCall: null,
66
+ nextResult: { success: true, data: { ok: true } },
67
+ children: map,
68
+ getChildren(): Map<string, StubChild> { return this.children; },
69
+ async handleToolCall(call: ToolCall): Promise<ToolResult> {
70
+ this.lastCall = call;
71
+ return this.nextResult;
72
+ },
73
+ };
74
+ return stub;
75
+ }
76
+
77
+ function buildStubApp(fleet: StubFleet | null): Parameters<typeof handleCommand>[1] {
78
+ const modules = fleet ? [fleet] : [];
79
+ return {
80
+ framework: {
81
+ getAllModules: () => modules,
82
+ // We don't need the rest of AgentFramework for /fleet dispatch.
83
+ } as unknown as AgentFramework,
84
+ sessionManager: {} as never,
85
+ recipe: { name: 'test' } as never,
86
+ branchState: {} as never,
87
+ switchSession: async () => {},
88
+ };
89
+ }
90
+
91
+ // --- Tests ---------------------------------------------------------------
92
+
93
+ describe('/fleet dispatcher', () => {
94
+ test('no fleet module → graceful hint, not a crash', () => {
95
+ const app = buildStubApp(null);
96
+ const res = handleCommand('/fleet list', app);
97
+ expect(res.quit).toBeUndefined();
98
+ expect(res.lines.length).toBeGreaterThan(0);
99
+ expect(res.lines.some((l) => /not enabled/i.test(l.text))).toBe(true);
100
+ });
101
+
102
+ test('/fleet (no args) → list', () => {
103
+ const fleet = buildStubFleet([buildStubChild('miner'), buildStubChild('clerk')]);
104
+ const app = buildStubApp(fleet as unknown as FleetModule);
105
+ const res = handleCommand('/fleet', app);
106
+ expect(res.lines.some((l) => /miner/.test(l.text))).toBe(true);
107
+ expect(res.lines.some((l) => /clerk/.test(l.text))).toBe(true);
108
+ });
109
+
110
+ test('/fleet list → lists every child', () => {
111
+ const fleet = buildStubFleet([
112
+ buildStubChild('a', 'ready'),
113
+ buildStubChild('b', 'crashed'),
114
+ ]);
115
+ const app = buildStubApp(fleet as unknown as FleetModule);
116
+ const res = handleCommand('/fleet list', app);
117
+ expect(res.lines.some((l) => /\ba\b/.test(l.text) && /ready/.test(l.text))).toBe(true);
118
+ expect(res.lines.some((l) => /\bb\b/.test(l.text) && /crashed/.test(l.text))).toBe(true);
119
+ });
120
+
121
+ test('/fleet list with no children → empty message', () => {
122
+ const fleet = buildStubFleet([]);
123
+ const app = buildStubApp(fleet as unknown as FleetModule);
124
+ const res = handleCommand('/fleet list', app);
125
+ expect(res.lines.some((l) => /no children/i.test(l.text))).toBe(true);
126
+ });
127
+
128
+ test('/fleet status <name> → detailed status', () => {
129
+ const fleet = buildStubFleet([buildStubChild('miner')]);
130
+ const app = buildStubApp(fleet as unknown as FleetModule);
131
+ const res = handleCommand('/fleet status miner', app);
132
+ expect(res.lines.some((l) => /recipe:/.test(l.text))).toBe(true);
133
+ expect(res.lines.some((l) => /dataDir:/.test(l.text))).toBe(true);
134
+ expect(res.lines.some((l) => /pid:/.test(l.text))).toBe(true);
135
+ });
136
+
137
+ test('/fleet status without name → falls back to list', () => {
138
+ const fleet = buildStubFleet([buildStubChild('solo')]);
139
+ const app = buildStubApp(fleet as unknown as FleetModule);
140
+ const res = handleCommand('/fleet status', app);
141
+ expect(res.lines.some((l) => /solo/.test(l.text))).toBe(true);
142
+ });
143
+
144
+ test('/fleet status <unknown> → error message', () => {
145
+ const fleet = buildStubFleet([]);
146
+ const app = buildStubApp(fleet as unknown as FleetModule);
147
+ const res = handleCommand('/fleet status ghost', app);
148
+ expect(res.lines.some((l) => /Unknown child/i.test(l.text))).toBe(true);
149
+ });
150
+
151
+ test('/fleet view → switchToFleetView flag', () => {
152
+ const fleet = buildStubFleet([]);
153
+ const app = buildStubApp(fleet as unknown as FleetModule);
154
+ const res = handleCommand('/fleet view', app);
155
+ expect(res.switchToFleetView).toBe(true);
156
+ });
157
+
158
+ test('/fleet peek <name> → switchToFleetPeek with name', () => {
159
+ const fleet = buildStubFleet([buildStubChild('miner')]);
160
+ const app = buildStubApp(fleet as unknown as FleetModule);
161
+ const res = handleCommand('/fleet peek miner', app);
162
+ expect(res.switchToFleetPeek).toBe('miner');
163
+ });
164
+
165
+ test('/fleet peek <unknown> → no switch, error message', () => {
166
+ const fleet = buildStubFleet([]);
167
+ const app = buildStubApp(fleet as unknown as FleetModule);
168
+ const res = handleCommand('/fleet peek ghost', app);
169
+ expect(res.switchToFleetPeek).toBeUndefined();
170
+ expect(res.lines.some((l) => /Unknown child/i.test(l.text))).toBe(true);
171
+ });
172
+
173
+ test('/fleet peek with no name → usage hint', () => {
174
+ const fleet = buildStubFleet([]);
175
+ const app = buildStubApp(fleet as unknown as FleetModule);
176
+ const res = handleCommand('/fleet peek', app);
177
+ expect(res.lines.some((l) => /Usage/i.test(l.text) && /peek/i.test(l.text))).toBe(true);
178
+ });
179
+
180
+ test('/fleet stop <name> → dispatches kill via asyncWork', async () => {
181
+ const fleet = buildStubFleet([buildStubChild('miner')]);
182
+ fleet.nextResult = { success: true, data: { status: 'exited', exitCode: 0 } };
183
+ const app = buildStubApp(fleet as unknown as FleetModule);
184
+ const res = handleCommand('/fleet stop miner', app);
185
+ expect(res.asyncWork).toBeDefined();
186
+ expect(res.lines.some((l) => /Stopping miner/.test(l.text))).toBe(true);
187
+
188
+ const asyncRes = await (res.asyncWork as Promise<CommandResult>);
189
+ expect(asyncRes.lines.some((l) => /kill miner/.test(l.text))).toBe(true);
190
+ expect(fleet.lastCall?.name).toBe('kill');
191
+ expect((fleet.lastCall?.input as { name?: string }).name).toBe('miner');
192
+ });
193
+
194
+ test('/fleet stop surfaces errors from handleToolCall', async () => {
195
+ const fleet = buildStubFleet([buildStubChild('miner')]);
196
+ fleet.nextResult = { success: false, isError: true, error: 'boom' };
197
+ const app = buildStubApp(fleet as unknown as FleetModule);
198
+ const res = handleCommand('/fleet stop miner', app);
199
+ const asyncRes = await (res.asyncWork as Promise<CommandResult>);
200
+ expect(asyncRes.lines.some((l) => /failed/i.test(l.text) && /boom/.test(l.text))).toBe(true);
201
+ });
202
+
203
+ test('/fleet stop with no name → usage hint', () => {
204
+ const fleet = buildStubFleet([]);
205
+ const app = buildStubApp(fleet as unknown as FleetModule);
206
+ const res = handleCommand('/fleet stop', app);
207
+ expect(res.asyncWork).toBeUndefined();
208
+ expect(res.lines.some((l) => /Usage/i.test(l.text) && /stop/i.test(l.text))).toBe(true);
209
+ });
210
+
211
+ test('/fleet restart <name> → dispatches restart via asyncWork', async () => {
212
+ const fleet = buildStubFleet([buildStubChild('miner')]);
213
+ fleet.nextResult = { success: true, data: { status: 'ready' } };
214
+ const app = buildStubApp(fleet as unknown as FleetModule);
215
+ const res = handleCommand('/fleet restart miner', app);
216
+ expect(res.asyncWork).toBeDefined();
217
+ await (res.asyncWork as Promise<CommandResult>);
218
+ expect(fleet.lastCall?.name).toBe('restart');
219
+ });
220
+
221
+ test('/fleet restart with no name → usage hint', () => {
222
+ const fleet = buildStubFleet([]);
223
+ const app = buildStubApp(fleet as unknown as FleetModule);
224
+ const res = handleCommand('/fleet restart', app);
225
+ expect(res.asyncWork).toBeUndefined();
226
+ expect(res.lines.some((l) => /Usage/i.test(l.text) && /restart/i.test(l.text))).toBe(true);
227
+ });
228
+
229
+ test('/fleet <unknown-sub> → helpful error', () => {
230
+ const fleet = buildStubFleet([]);
231
+ const app = buildStubApp(fleet as unknown as FleetModule);
232
+ const res = handleCommand('/fleet spitroast miner', app);
233
+ expect(res.lines.some((l) => /Unknown .* subcommand/i.test(l.text) && /spitroast/.test(l.text))).toBe(true);
234
+ });
235
+
236
+ test('/fleet kill alias = /fleet stop', async () => {
237
+ const fleet = buildStubFleet([buildStubChild('miner')]);
238
+ fleet.nextResult = { success: true, data: { status: 'exited', exitCode: 0 } };
239
+ const app = buildStubApp(fleet as unknown as FleetModule);
240
+ const res = handleCommand('/fleet kill miner', app);
241
+ await (res.asyncWork as Promise<CommandResult>);
242
+ expect(fleet.lastCall?.name).toBe('kill');
243
+ });
244
+ });