@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,182 @@
1
+ /**
2
+ * Integration test for the 'describe' IPC verb.
3
+ *
4
+ * Spawns a headless daemon, sends {type:'describe'}, and asserts the
5
+ * child responds with a single 'snapshot' event whose tree shape matches
6
+ * AgentTreeReducer's output. Also verifies snapshot bypasses subscription
7
+ * filtering — the parent should always be able to recover state regardless
8
+ * of how it has narrowed the event stream.
9
+ */
10
+ import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
11
+ import { spawn, type ChildProcess } from 'node:child_process';
12
+ import { mkdtempSync, writeFileSync, rmSync, existsSync } from 'node:fs';
13
+ import { tmpdir } from 'node:os';
14
+ import { connect as netConnect, type Socket } from 'node:net';
15
+ import { join, resolve, dirname } from 'node:path';
16
+ import { fileURLToPath } from 'node:url';
17
+
18
+ const TEST_DIR = dirname(fileURLToPath(import.meta.url));
19
+ const REPO_ROOT = resolve(TEST_DIR, '..');
20
+ const INDEX_PATH = join(REPO_ROOT, 'src', 'index.ts');
21
+
22
+ const MINIMAL_RECIPE = {
23
+ name: 'Describe Test',
24
+ agent: {
25
+ name: 'commander',
26
+ systemPrompt: 'never asked to infer in this test',
27
+ },
28
+ modules: {
29
+ subagents: false,
30
+ lessons: false,
31
+ retrieval: false,
32
+ wake: false,
33
+ workspace: false,
34
+ },
35
+ };
36
+
37
+ function lineReader(socket: Socket): { events: Array<Record<string, unknown>>; stop: () => void } {
38
+ const events: Array<Record<string, unknown>> = [];
39
+ let buf = '';
40
+ const handler = (chunk: Buffer): void => {
41
+ buf += chunk.toString('utf-8');
42
+ let i: number;
43
+ while ((i = buf.indexOf('\n')) >= 0) {
44
+ const line = buf.slice(0, i).trim();
45
+ buf = buf.slice(i + 1);
46
+ if (!line) continue;
47
+ try { events.push(JSON.parse(line) as Record<string, unknown>); } catch { /* ignore malformed */ }
48
+ }
49
+ };
50
+ socket.on('data', handler);
51
+ return { events, stop: (): void => { socket.off('data', handler); } };
52
+ }
53
+
54
+ async function waitFor(check: () => boolean, timeoutMs: number, label: string): Promise<void> {
55
+ const start = Date.now();
56
+ while (Date.now() - start < timeoutMs) {
57
+ if (check()) return;
58
+ await new Promise((r) => setTimeout(r, 50));
59
+ }
60
+ throw new Error(`waitFor timed out after ${timeoutMs}ms: ${label}`);
61
+ }
62
+
63
+ async function connectSocket(path: string, timeoutMs = 3_000): Promise<Socket> {
64
+ return new Promise((resolveConn, rejectConn) => {
65
+ const s = netConnect(path);
66
+ const timer = setTimeout(() => {
67
+ s.destroy();
68
+ rejectConn(new Error(`socket connect timeout: ${path}`));
69
+ }, timeoutMs);
70
+ s.once('connect', () => { clearTimeout(timer); resolveConn(s); });
71
+ s.once('error', (err) => { clearTimeout(timer); rejectConn(err); });
72
+ });
73
+ }
74
+
75
+ describe('headless daemon — describe / snapshot', () => {
76
+ let tmpDir: string;
77
+ let recipePath: string;
78
+ let socketPath: string;
79
+ let child: ChildProcess;
80
+
81
+ beforeAll(async () => {
82
+ tmpDir = mkdtempSync(join(tmpdir(), 'fkm-describe-'));
83
+ recipePath = join(tmpDir, 'recipe.json');
84
+ socketPath = join(tmpDir, 'ipc.sock');
85
+ writeFileSync(recipePath, JSON.stringify(MINIMAL_RECIPE), 'utf-8');
86
+
87
+ child = spawn(
88
+ 'bun',
89
+ [INDEX_PATH, recipePath, '--headless'],
90
+ {
91
+ cwd: tmpDir,
92
+ env: {
93
+ ...process.env,
94
+ ANTHROPIC_API_KEY: 'sk-test-describe',
95
+ DATA_DIR: tmpDir,
96
+ },
97
+ stdio: ['ignore', 'ignore', 'ignore'],
98
+ },
99
+ );
100
+
101
+ await waitFor(() => existsSync(socketPath), 15_000, 'socket file appears');
102
+ });
103
+
104
+ afterAll(() => {
105
+ try { if (child.exitCode === null) child.kill('SIGKILL'); } catch { /* noop */ }
106
+ try { rmSync(tmpDir, { recursive: true, force: true }); } catch { /* noop */ }
107
+ });
108
+
109
+ test('describe returns a snapshot with the seeded framework agent', async () => {
110
+ const sock = await connectSocket(socketPath);
111
+ const r = lineReader(sock);
112
+
113
+ await waitFor(
114
+ () => r.events.some((e) => e.type === 'lifecycle' && (e as { phase?: string }).phase === 'ready'),
115
+ 5_000,
116
+ 'lifecycle:ready',
117
+ );
118
+
119
+ sock.write(JSON.stringify({ type: 'describe', corrId: 'test-1' }) + '\n');
120
+
121
+ await waitFor(
122
+ () => r.events.some((e) => e.type === 'snapshot'),
123
+ 3_000,
124
+ 'snapshot response',
125
+ );
126
+
127
+ const snap = r.events.find((e) => e.type === 'snapshot') as Record<string, unknown>;
128
+ expect(snap.corrId).toBe('test-1');
129
+ expect(typeof snap.asOfTs).toBe('number');
130
+
131
+ const childMeta = snap.child as Record<string, unknown>;
132
+ expect(childMeta.name).toBe('Describe Test');
133
+ expect(typeof childMeta.pid).toBe('number');
134
+ expect(typeof childMeta.startedAt).toBe('number');
135
+
136
+ const tree = snap.tree as { nodes: Array<Record<string, unknown>>; callIdIndex: Record<string, string> };
137
+ expect(Array.isArray(tree.nodes)).toBe(true);
138
+ expect(tree.nodes.length).toBeGreaterThanOrEqual(1);
139
+ const commander = tree.nodes.find(n => n.name === 'commander');
140
+ expect(commander).toBeDefined();
141
+ expect(commander!.kind).toBe('framework');
142
+ expect(commander!.phase).toBe('idle');
143
+
144
+ r.stop();
145
+ sock.destroy();
146
+ });
147
+
148
+ test('snapshot bypasses subscription filter', async () => {
149
+ const sock = await connectSocket(socketPath);
150
+ const r = lineReader(sock);
151
+
152
+ await waitFor(
153
+ () => r.events.some((e) => e.type === 'lifecycle' && (e as { phase?: string }).phase === 'ready'),
154
+ 5_000,
155
+ 'lifecycle:ready (filter test)',
156
+ );
157
+
158
+ // Narrow the subscription to something snapshot is not part of.
159
+ sock.write(JSON.stringify({ type: 'subscribe', events: ['command-output'] }) + '\n');
160
+ await new Promise((r) => setTimeout(r, 100));
161
+
162
+ const eventsBefore = r.events.length;
163
+ sock.write(JSON.stringify({ type: 'describe', corrId: 'filter-test' }) + '\n');
164
+
165
+ await waitFor(
166
+ () => r.events.slice(eventsBefore).some((e) => e.type === 'snapshot'),
167
+ 3_000,
168
+ 'snapshot delivered despite narrow subscription',
169
+ );
170
+
171
+ r.stop();
172
+ sock.destroy();
173
+ });
174
+
175
+ test('cleanup', async () => {
176
+ // Final shutdown so afterAll doesn't have to SIGKILL.
177
+ const sock = await connectSocket(socketPath);
178
+ sock.write(JSON.stringify({ type: 'shutdown' }) + '\n');
179
+ await new Promise((r) => setTimeout(r, 500));
180
+ sock.destroy();
181
+ });
182
+ });
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Phase 1 smoke test for headless daemon mode.
3
+ *
4
+ * Spawns `bun src/index.ts <recipe> --headless` as a subprocess in a
5
+ * temp dir, then exercises the JSONL-over-Unix-socket protocol:
6
+ * - lifecycle:ready emitted on connect
7
+ * - subscribe filter applied
8
+ * - /help command produces command-output events (offline-safe path)
9
+ * - client disconnect does NOT kill the child
10
+ * - reconnecting yields a fresh lifecycle:ready
11
+ * - shutdown command exits cleanly with socket + pid file removed
12
+ */
13
+ import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
14
+ import { spawn, type ChildProcess } from 'node:child_process';
15
+ import { mkdtempSync, writeFileSync, rmSync, existsSync, readFileSync } from 'node:fs';
16
+ import { tmpdir } from 'node:os';
17
+ import { connect as netConnect, type Socket } from 'node:net';
18
+ import { join, resolve, dirname } from 'node:path';
19
+ import { fileURLToPath } from 'node:url';
20
+
21
+ const TEST_DIR = dirname(fileURLToPath(import.meta.url));
22
+ const REPO_ROOT = resolve(TEST_DIR, '..');
23
+ const INDEX_PATH = join(REPO_ROOT, 'src', 'index.ts');
24
+
25
+ // Recipe with all optional modules disabled — no MCP servers, no workspace
26
+ // mounts, no lessons/retrieval, no wake gate. TimeModule + TuiModule are
27
+ // always-on in createFramework() but neither needs external resources.
28
+ const MINIMAL_RECIPE = {
29
+ name: 'Smoke Test',
30
+ agent: {
31
+ name: 'smoke',
32
+ systemPrompt: 'never asked to infer in this test',
33
+ },
34
+ modules: {
35
+ subagents: false,
36
+ lessons: false,
37
+ retrieval: false,
38
+ wake: false,
39
+ workspace: false,
40
+ },
41
+ };
42
+
43
+ // --- helpers -------------------------------------------------------------
44
+
45
+ function lineReader(socket: Socket): { events: Array<Record<string, unknown>>; stop: () => void } {
46
+ const events: Array<Record<string, unknown>> = [];
47
+ let buf = '';
48
+ const handler = (chunk: Buffer): void => {
49
+ buf += chunk.toString('utf-8');
50
+ let i: number;
51
+ while ((i = buf.indexOf('\n')) >= 0) {
52
+ const line = buf.slice(0, i).trim();
53
+ buf = buf.slice(i + 1);
54
+ if (!line) continue;
55
+ try { events.push(JSON.parse(line) as Record<string, unknown>); } catch { /* ignore malformed */ }
56
+ }
57
+ };
58
+ socket.on('data', handler);
59
+ return { events, stop: (): void => { socket.off('data', handler); } };
60
+ }
61
+
62
+ async function waitFor(check: () => boolean, timeoutMs: number, label: string): Promise<void> {
63
+ const start = Date.now();
64
+ while (Date.now() - start < timeoutMs) {
65
+ if (check()) return;
66
+ await new Promise((r) => setTimeout(r, 50));
67
+ }
68
+ throw new Error(`waitFor timed out after ${timeoutMs}ms: ${label}`);
69
+ }
70
+
71
+ async function connectSocket(path: string, timeoutMs = 3_000): Promise<Socket> {
72
+ return new Promise((resolveConn, rejectConn) => {
73
+ const s = netConnect(path);
74
+ const timer = setTimeout(() => {
75
+ s.destroy();
76
+ rejectConn(new Error(`socket connect timeout: ${path}`));
77
+ }, timeoutMs);
78
+ s.once('connect', () => { clearTimeout(timer); resolveConn(s); });
79
+ s.once('error', (err) => { clearTimeout(timer); rejectConn(err); });
80
+ });
81
+ }
82
+
83
+ // --- test ----------------------------------------------------------------
84
+
85
+ describe('headless daemon — Phase 1', () => {
86
+ let tmpDir: string;
87
+ let recipePath: string;
88
+ let socketPath: string;
89
+ let pidPath: string;
90
+ let logPath: string;
91
+ let child: ChildProcess;
92
+
93
+ beforeAll(async () => {
94
+ tmpDir = mkdtempSync(join(tmpdir(), 'fkm-headless-'));
95
+ recipePath = join(tmpDir, 'recipe.json');
96
+ socketPath = join(tmpDir, 'ipc.sock');
97
+ pidPath = join(tmpDir, 'headless.pid');
98
+ logPath = join(tmpDir, 'headless.log');
99
+ writeFileSync(recipePath, JSON.stringify(MINIMAL_RECIPE), 'utf-8');
100
+
101
+ child = spawn(
102
+ 'bun',
103
+ [INDEX_PATH, recipePath, '--headless'],
104
+ {
105
+ // cwd=tmpDir so DEFAULT_CONFIG_PATH (cwd/mcpl-servers.json) doesn't
106
+ // accidentally pick up the dev's real MCPL config.
107
+ cwd: tmpDir,
108
+ env: {
109
+ ...process.env,
110
+ ANTHROPIC_API_KEY: 'sk-test-headless-smoke',
111
+ DATA_DIR: tmpDir,
112
+ },
113
+ stdio: ['ignore', 'ignore', 'ignore'],
114
+ },
115
+ );
116
+
117
+ await waitFor(() => existsSync(socketPath), 15_000, 'socket file appears');
118
+ });
119
+
120
+ afterAll(() => {
121
+ try { if (child.exitCode === null) child.kill('SIGKILL'); } catch { /* noop */ }
122
+ try { rmSync(tmpDir, { recursive: true, force: true }); } catch { /* noop */ }
123
+ });
124
+
125
+ test('protocol: ready → subscribe → command → reconnect → shutdown', async () => {
126
+ // -- Connect 1: receive ready, run /help --
127
+ const sock1 = await connectSocket(socketPath);
128
+ const r1 = lineReader(sock1);
129
+
130
+ await waitFor(
131
+ () => r1.events.some((e) => e.type === 'lifecycle' && (e as { phase?: string }).phase === 'ready'),
132
+ 5_000,
133
+ 'first lifecycle:ready',
134
+ );
135
+
136
+ // Narrow subscription so we can assert the filter is applied.
137
+ sock1.write(JSON.stringify({ type: 'subscribe', events: ['lifecycle', 'command-output'] }) + '\n');
138
+ await new Promise((r) => setTimeout(r, 100));
139
+
140
+ // /help is offline-safe — no LLM call.
141
+ sock1.write(JSON.stringify({ type: 'command', command: '/help' }) + '\n');
142
+ await waitFor(
143
+ () => r1.events.filter((e) => e.type === 'command-output').length >= 5,
144
+ 5_000,
145
+ 'command-output lines from /help',
146
+ );
147
+
148
+ // -- Disconnect; verify child stays up --
149
+ r1.stop();
150
+ sock1.destroy();
151
+ await new Promise((r) => setTimeout(r, 200));
152
+ expect(child.exitCode).toBeNull();
153
+
154
+ // -- Connect 2: fresh ready event --
155
+ const sock2 = await connectSocket(socketPath);
156
+ const r2 = lineReader(sock2);
157
+
158
+ await waitFor(
159
+ () => r2.events.some((e) => e.type === 'lifecycle' && (e as { phase?: string }).phase === 'ready'),
160
+ 5_000,
161
+ 'second lifecycle:ready (after reconnect)',
162
+ );
163
+
164
+ // -- Shutdown; verify exit + cleanup --
165
+ sock2.write(JSON.stringify({ type: 'shutdown' }) + '\n');
166
+
167
+ await waitFor(
168
+ () => r2.events.some((e) => e.type === 'lifecycle' && (e as { phase?: string }).phase === 'exiting'),
169
+ 3_000,
170
+ 'lifecycle:exiting',
171
+ );
172
+
173
+ const exitCode = await new Promise<number | null>((resolveExit) => {
174
+ if (child.exitCode !== null) { resolveExit(child.exitCode); return; }
175
+ child.once('exit', (code) => resolveExit(code));
176
+ });
177
+
178
+ r2.stop();
179
+ sock2.destroy();
180
+
181
+ expect(exitCode).toBe(0);
182
+
183
+ // Give OS a beat for unlink to propagate
184
+ await new Promise((r) => setTimeout(r, 100));
185
+ expect(existsSync(socketPath)).toBe(false);
186
+ expect(existsSync(pidPath)).toBe(false);
187
+ expect(existsSync(logPath)).toBe(true);
188
+ expect(readFileSync(logPath, 'utf-8').length).toBeGreaterThan(0);
189
+ }, 60_000);
190
+ });
@@ -0,0 +1,87 @@
1
+ import { describe, test, expect } from 'bun:test';
2
+ import { LoggingAnthropicAdapter } from '../src/logging-adapter.js';
3
+ import type { ProviderRequest, ProviderResponse } from '@animalabs/membrane';
4
+
5
+ // Regression guard for the reasoning passthrough. `withReasoning` injects
6
+ // adaptive `thinking` into `request.extra`, which the Anthropic adapter
7
+ // forwards to the API params verbatim. This is invisible to the type system
8
+ // (membrane's ProviderRequest doesn't type `thinking`), so it's exactly the
9
+ // kind of contract that silently rots on a dependency bump — pin it here so
10
+ // the next break is a red test with a name, not a tsc error blamed on the
11
+ // wrong package (see the git history of this file).
12
+
13
+ const baseRequest: ProviderRequest = {
14
+ messages: [],
15
+ model: 'claude-opus-4-6',
16
+ maxTokens: 1024,
17
+ };
18
+
19
+ const enabled = () => ({ enabled: true, budgetTokens: 0 });
20
+ const disabled = () => ({ enabled: false, budgetTokens: 0 });
21
+
22
+ describe('LoggingAnthropicAdapter.withReasoning', () => {
23
+ test('injects adaptive thinking into request.extra when reasoning enabled', () => {
24
+ const adapter = new LoggingAnthropicAdapter({ apiKey: 'test' }, '/dev/null', enabled);
25
+ const out = (adapter as unknown as { withReasoning(r: ProviderRequest): ProviderRequest })
26
+ .withReasoning(baseRequest);
27
+ expect((out.extra as Record<string, { type?: string }> | undefined)?.thinking?.type).toBe('adaptive');
28
+ // shallow clone — original request untouched
29
+ expect((baseRequest as { extra?: unknown }).extra).toBeUndefined();
30
+ });
31
+
32
+ test('preserves pre-existing extra keys', () => {
33
+ const adapter = new LoggingAnthropicAdapter({ apiKey: 'test' }, '/dev/null', enabled);
34
+ const req = { ...baseRequest, extra: { normalizedMessages: 'keep-me' } } as ProviderRequest;
35
+ const out = (adapter as unknown as { withReasoning(r: ProviderRequest): ProviderRequest })
36
+ .withReasoning(req);
37
+ const extra = out.extra as Record<string, unknown>;
38
+ expect(extra.normalizedMessages).toBe('keep-me');
39
+ expect((extra.thinking as { type?: string }).type).toBe('adaptive');
40
+ });
41
+
42
+ test('no-op (same reference) when reasoning disabled', () => {
43
+ const adapter = new LoggingAnthropicAdapter({ apiKey: 'test' }, '/dev/null', disabled);
44
+ const out = (adapter as unknown as { withReasoning(r: ProviderRequest): ProviderRequest })
45
+ .withReasoning(baseRequest);
46
+ expect(out).toBe(baseRequest);
47
+ });
48
+
49
+ test('no-op when no reasoning getter is wired', () => {
50
+ const adapter = new LoggingAnthropicAdapter({ apiKey: 'test' }, '/dev/null');
51
+ const out = (adapter as unknown as { withReasoning(r: ProviderRequest): ProviderRequest })
52
+ .withReasoning(baseRequest);
53
+ expect(out).toBe(baseRequest);
54
+ });
55
+ });
56
+
57
+ describe('LoggingAnthropicAdapter request logging', () => {
58
+ const adapter = new LoggingAnthropicAdapter({ apiKey: 'test' }, '/dev/null');
59
+ const internals = adapter as unknown as {
60
+ requestSummary(r: ProviderRequest): Record<string, unknown>;
61
+ refusalRawRequest(r: ProviderResponse, raw: unknown): unknown;
62
+ };
63
+
64
+ test('summarizes requests without retaining message content', () => {
65
+ const request = {
66
+ ...baseRequest,
67
+ messages: [{ role: 'user', content: [{ type: 'text', text: 'large context' }] }],
68
+ tools: [{ name: 'shell', description: 'run a command', inputSchema: {} }],
69
+ } as ProviderRequest;
70
+
71
+ expect(internals.requestSummary(request)).toEqual({
72
+ model: 'claude-opus-4-6',
73
+ maxTokens: 1024,
74
+ messages: 1,
75
+ tools: 1,
76
+ });
77
+ });
78
+
79
+ test('retains the raw request only for refusals', () => {
80
+ const rawRequest = { messages: ['forensic context'] };
81
+ const success = { raw: { stop_reason: 'end_turn' } } as unknown as ProviderResponse;
82
+ const refusal = { raw: { stop_reason: 'refusal' } } as unknown as ProviderResponse;
83
+
84
+ expect(internals.refusalRawRequest(success, rawRequest)).toBeUndefined();
85
+ expect(internals.refusalRawRequest(refusal, rawRequest)).toBe(rawRequest);
86
+ });
87
+ });
@@ -0,0 +1,213 @@
1
+ /**
2
+ * McplAdminModule — agent-facing deploy/restart/unload tools, exercised
3
+ * against a stub framework. Overlay persistence is verified on disk.
4
+ */
5
+
6
+ import { test, expect, describe, beforeEach, afterEach } from 'bun:test';
7
+ import { mkdtempSync, rmSync } from 'node:fs';
8
+ import { tmpdir } from 'node:os';
9
+ import { join } from 'node:path';
10
+ import type { AgentFramework } from '@animalabs/agent-framework';
11
+
12
+ import { McplAdminModule } from '../src/modules/mcpl-admin-module.js';
13
+ import { readAgentOverlay, saveAgentOverlay } from '../src/mcpl-config.js';
14
+
15
+ interface StubServer {
16
+ id: string;
17
+ connected: boolean;
18
+ toolPrefix: string;
19
+ toolCount: number;
20
+ command?: string;
21
+ url?: string;
22
+ }
23
+
24
+ function makeStubFramework() {
25
+ const servers = new Map<string, StubServer>();
26
+ const calls: string[] = [];
27
+ const stub = {
28
+ listMcplServers: () => [...servers.values()],
29
+ connectMcplServer: async (config: { id: string; command?: string; url?: string; toolPrefix?: string }) => {
30
+ calls.push(`connect:${config.id}`);
31
+ if (servers.has(config.id)) throw new Error(`MCPL server "${config.id}" is already registered`);
32
+ servers.set(config.id, {
33
+ id: config.id,
34
+ connected: true,
35
+ toolPrefix: config.toolPrefix ?? `mcpl--${config.id}`,
36
+ toolCount: 1,
37
+ command: config.command,
38
+ url: config.url,
39
+ });
40
+ },
41
+ disconnectMcplServer: async (id: string) => {
42
+ calls.push(`disconnect:${id}`);
43
+ servers.delete(id);
44
+ },
45
+ restartMcplServer: async (id: string, config?: { id: string; command?: string }) => {
46
+ calls.push(`restart:${id}`);
47
+ if (!servers.has(id) && !config) throw new Error(`MCPL server "${id}" is not configured`);
48
+ const prev = servers.get(id);
49
+ servers.set(id, {
50
+ id,
51
+ connected: true,
52
+ toolPrefix: `mcpl--${id}`,
53
+ toolCount: 1,
54
+ command: config?.command ?? prev?.command,
55
+ });
56
+ },
57
+ };
58
+ return { stub: stub as unknown as AgentFramework, servers, calls };
59
+ }
60
+
61
+ let dir: string;
62
+ let overlayPath: string;
63
+
64
+ beforeEach(() => {
65
+ dir = mkdtempSync(join(tmpdir(), 'mcpl-admin-'));
66
+ overlayPath = join(dir, 'mcpl-servers.agent.json');
67
+ });
68
+
69
+ afterEach(() => {
70
+ rmSync(dir, { recursive: true, force: true });
71
+ });
72
+
73
+ function makeModule(framework: AgentFramework) {
74
+ const mod = new McplAdminModule({
75
+ overlayPath,
76
+ configPath: join(dir, 'mcpl-servers.json'),
77
+ });
78
+ mod.setFramework(framework);
79
+ return mod;
80
+ }
81
+
82
+ function call(mod: McplAdminModule, name: string, input: Record<string, unknown> = {}) {
83
+ return mod.handleToolCall({ id: 'c1', name, input } as never);
84
+ }
85
+
86
+ describe('mcpl_deploy', () => {
87
+ test('deploys a new server: connects it and persists to the overlay', async () => {
88
+ const { stub, servers, calls } = makeStubFramework();
89
+ const mod = makeModule(stub);
90
+
91
+ const result = await call(mod, 'mcpl_deploy', { id: 'mytool', command: 'node', args: ['tool.js'] });
92
+
93
+ expect(result.success).toBe(true);
94
+ expect(calls).toEqual(['connect:mytool']);
95
+ expect(servers.has('mytool')).toBe(true);
96
+ expect(readAgentOverlay(overlayPath).mytool).toEqual({ command: 'node', args: ['tool.js'] });
97
+ });
98
+
99
+ test('redeploying a loaded server restarts it with the new config', async () => {
100
+ const { stub, calls } = makeStubFramework();
101
+ const mod = makeModule(stub);
102
+
103
+ await call(mod, 'mcpl_deploy', { id: 'mytool', command: 'node' });
104
+ const result = await call(mod, 'mcpl_deploy', { id: 'mytool', command: 'bun' });
105
+
106
+ expect(result.success).toBe(true);
107
+ expect(calls).toEqual(['connect:mytool', 'restart:mytool']);
108
+ expect(readAgentOverlay(overlayPath).mytool).toEqual({ command: 'bun' });
109
+ });
110
+
111
+ test('rejects missing command/url, both at once, and bad ids', async () => {
112
+ const { stub } = makeStubFramework();
113
+ const mod = makeModule(stub);
114
+
115
+ expect((await call(mod, 'mcpl_deploy', { id: 'x' })).success).toBe(false);
116
+ expect((await call(mod, 'mcpl_deploy', { id: 'x', command: 'a', url: 'ws://b' })).success).toBe(false);
117
+ expect((await call(mod, 'mcpl_deploy', { id: 'bad id!', command: 'a' })).success).toBe(false);
118
+ });
119
+
120
+ test('connect failure keeps the overlay entry and reports the error', async () => {
121
+ const { stub } = makeStubFramework();
122
+ (stub as unknown as { connectMcplServer: () => Promise<void> }).connectMcplServer =
123
+ async () => { throw new Error('spawn ENOENT'); };
124
+ const mod = makeModule(stub);
125
+
126
+ const result = await call(mod, 'mcpl_deploy', { id: 'broken', command: 'nonexistent' });
127
+
128
+ expect(result.success).toBe(false);
129
+ expect(result.error).toContain('spawn ENOENT');
130
+ expect(readAgentOverlay(overlayPath).broken).toEqual({ command: 'nonexistent' });
131
+ });
132
+ });
133
+
134
+ describe('mcpl_unload', () => {
135
+ test('agent-deployed server: disconnects and deletes the overlay entry', async () => {
136
+ const { stub, servers } = makeStubFramework();
137
+ const mod = makeModule(stub);
138
+ await call(mod, 'mcpl_deploy', { id: 'mytool', command: 'node' });
139
+
140
+ const result = await call(mod, 'mcpl_unload', { id: 'mytool' });
141
+
142
+ expect(result.success).toBe(true);
143
+ expect(servers.has('mytool')).toBe(false);
144
+ expect(readAgentOverlay(overlayPath).mytool).toBeUndefined();
145
+ });
146
+
147
+ test('recipe server: disconnects and writes a tombstone', async () => {
148
+ const { stub, servers } = makeStubFramework();
149
+ // Simulate a recipe-loaded server the module didn't deploy.
150
+ await (stub as unknown as { connectMcplServer: (c: { id: string; command: string }) => Promise<void> })
151
+ .connectMcplServer({ id: 'discord', command: 'node' });
152
+ const mod = makeModule(stub);
153
+
154
+ const result = await call(mod, 'mcpl_unload', { id: 'discord' });
155
+
156
+ expect(result.success).toBe(true);
157
+ expect(servers.has('discord')).toBe(false);
158
+ expect(readAgentOverlay(overlayPath).discord).toEqual({ disabled: true });
159
+ });
160
+
161
+ test('persist:false leaves the overlay untouched', async () => {
162
+ const { stub, servers } = makeStubFramework();
163
+ await (stub as unknown as { connectMcplServer: (c: { id: string; command: string }) => Promise<void> })
164
+ .connectMcplServer({ id: 'discord', command: 'node' });
165
+ const mod = makeModule(stub);
166
+
167
+ const result = await call(mod, 'mcpl_unload', { id: 'discord', persist: false });
168
+
169
+ expect(result.success).toBe(true);
170
+ expect(servers.has('discord')).toBe(false);
171
+ expect(readAgentOverlay(overlayPath)).toEqual({});
172
+ });
173
+
174
+ test('unknown server errors', async () => {
175
+ const { stub } = makeStubFramework();
176
+ const mod = makeModule(stub);
177
+ expect((await call(mod, 'mcpl_unload', { id: 'nope' })).success).toBe(false);
178
+ });
179
+ });
180
+
181
+ describe('mcpl_restart', () => {
182
+ test('restarts a loaded server', async () => {
183
+ const { stub, calls } = makeStubFramework();
184
+ await (stub as unknown as { connectMcplServer: (c: { id: string; command: string }) => Promise<void> })
185
+ .connectMcplServer({ id: 'discord', command: 'node' });
186
+ const mod = makeModule(stub);
187
+
188
+ const result = await call(mod, 'mcpl_restart', { id: 'discord' });
189
+
190
+ expect(result.success).toBe(true);
191
+ expect(calls).toEqual(['connect:discord', 'restart:discord']);
192
+ });
193
+ });
194
+
195
+ describe('mcpl_list', () => {
196
+ test('shows live servers, sources, and tombstones', async () => {
197
+ const { stub } = makeStubFramework();
198
+ await (stub as unknown as { connectMcplServer: (c: { id: string; command: string }) => Promise<void> })
199
+ .connectMcplServer({ id: 'discord', command: 'node' });
200
+ const mod = makeModule(stub);
201
+ await call(mod, 'mcpl_deploy', { id: 'mytool', command: 'bun' });
202
+ saveAgentOverlay(overlayPath, { ...readAgentOverlay(overlayPath), gone: { disabled: true } });
203
+
204
+ const result = await call(mod, 'mcpl_list');
205
+
206
+ expect(result.success).toBe(true);
207
+ const text = String(result.data);
208
+ expect(text).toContain('discord: CONNECTED');
209
+ expect(text).toContain('mytool: CONNECTED');
210
+ expect(text).toContain('source=agent-overlay');
211
+ expect(text).toContain('gone: UNLOADED');
212
+ });
213
+ });