@animalabs/connectome-host 0.7.3 → 0.7.4
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.
- package/CHANGELOG.md +156 -10
- package/HEADLESS-FLEET-PLAN.md +22 -0
- package/README.md +12 -1
- package/docs/AGENT-ONBOARDING.md +1 -1
- package/docs/debug-context-api.md +2 -2
- package/docs/retrieval-traces.md +173 -0
- package/docs/webui-deployment.md +2 -1
- package/package.json +2 -2
- package/scripts/audit-module-optins.ts +288 -0
- package/src/framework-strategy.ts +13 -4
- package/src/headless.ts +14 -0
- package/src/index.ts +12 -9
- package/src/modules/fleet-module.ts +60 -1
- package/src/modules/fleet-types.ts +30 -1
- package/src/modules/mcpl-admin-module.ts +33 -4
- package/src/modules/retrieval-module.ts +249 -51
- package/src/modules/retrieval-trace-page.ts +254 -0
- package/src/modules/retrieval-trace.ts +904 -0
- package/src/modules/tts-relay-module.ts +33 -18
- package/src/modules/web-ui-module.ts +445 -894
- package/src/recipe.ts +55 -4
- package/src/retrieval-config.ts +39 -0
- package/src/strategies/frontdesk-strategy.ts +34 -125
- package/src/tui.ts +325 -54
- package/src/web/panel-data.ts +1187 -0
- package/src/web/protocol.ts +75 -10
- package/test/audit-module-optins.test.ts +167 -0
- package/test/fleet-panel-request.test.ts +90 -0
- package/test/framework-strategy-defaults.test.ts +22 -0
- package/test/frontdesk-strategy.test.ts +25 -37
- package/test/headless-panel-request.test.ts +201 -0
- package/test/mcpl-admin-module.test.ts +23 -0
- package/test/mock-headless-child.ts +14 -0
- package/test/retrieval-auth-loopback.test.ts +49 -0
- package/test/retrieval-config.test.ts +74 -0
- package/test/retrieval-module.test.ts +821 -0
- package/test/tui-format.test.ts +106 -0
- package/test/web-ui-context-coverage.test.ts +1 -1
- package/test/web-ui-module.test.ts +189 -3
- package/test/web-ui-observers.test.ts +8 -5
- package/test/web-ui-protocol.test.ts +0 -0
- package/web/src/App.tsx +159 -44
- package/web/src/Context.tsx +35 -8
- package/web/src/ContextDocument.tsx +20 -5
- package/web/src/Files.tsx +2 -8
- package/web/src/Lessons.tsx +2 -38
- package/web/src/Mcpl.tsx +80 -14
- package/web/src/Pins.tsx +5 -0
- package/web/src/Settings.tsx +5 -0
- package/web/vite.config.ts +8 -2
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration test for the 'panel-request' IPC verb against a REAL headless
|
|
3
|
+
* child (full framework, no inference): the same runPanelOp dispatcher the
|
|
4
|
+
* WebUI host uses locally must answer over the socket, so every operator
|
|
5
|
+
* panel (mcpl / settings / pins / health / context debug) works for fleet
|
|
6
|
+
* children exactly as it does for the host.
|
|
7
|
+
*
|
|
8
|
+
* Also verifies panel-response bypasses subscription filtering — a narrowed
|
|
9
|
+
* event stream must never eat a request/response pair.
|
|
10
|
+
*/
|
|
11
|
+
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
|
12
|
+
import { spawn, type ChildProcess } from 'node:child_process';
|
|
13
|
+
import { mkdtempSync, writeFileSync, rmSync, existsSync } from 'node:fs';
|
|
14
|
+
import { tmpdir } from 'node:os';
|
|
15
|
+
import { connect as netConnect, type Socket } from 'node:net';
|
|
16
|
+
import { join, resolve, dirname } from 'node:path';
|
|
17
|
+
import { fileURLToPath } from 'node:url';
|
|
18
|
+
|
|
19
|
+
const TEST_DIR = dirname(fileURLToPath(import.meta.url));
|
|
20
|
+
const REPO_ROOT = resolve(TEST_DIR, '..');
|
|
21
|
+
const INDEX_PATH = join(REPO_ROOT, 'src', 'index.ts');
|
|
22
|
+
|
|
23
|
+
const MINIMAL_RECIPE = {
|
|
24
|
+
name: 'Panel Test',
|
|
25
|
+
agent: {
|
|
26
|
+
name: 'commander',
|
|
27
|
+
systemPrompt: 'never asked to infer in this test',
|
|
28
|
+
},
|
|
29
|
+
modules: {
|
|
30
|
+
subagents: false,
|
|
31
|
+
lessons: false,
|
|
32
|
+
retrieval: false,
|
|
33
|
+
wake: false,
|
|
34
|
+
workspace: false,
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
function lineReader(socket: Socket): { events: Array<Record<string, unknown>>; stop: () => void } {
|
|
39
|
+
const events: Array<Record<string, unknown>> = [];
|
|
40
|
+
let buf = '';
|
|
41
|
+
const handler = (chunk: Buffer): void => {
|
|
42
|
+
buf += chunk.toString('utf-8');
|
|
43
|
+
let i: number;
|
|
44
|
+
while ((i = buf.indexOf('\n')) >= 0) {
|
|
45
|
+
const line = buf.slice(0, i).trim();
|
|
46
|
+
buf = buf.slice(i + 1);
|
|
47
|
+
if (!line) continue;
|
|
48
|
+
try { events.push(JSON.parse(line) as Record<string, unknown>); } catch { /* ignore malformed */ }
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
socket.on('data', handler);
|
|
52
|
+
return { events, stop: (): void => { socket.off('data', handler); } };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function waitFor(check: () => boolean, timeoutMs: number, label: string): Promise<void> {
|
|
56
|
+
const start = Date.now();
|
|
57
|
+
while (Date.now() - start < timeoutMs) {
|
|
58
|
+
if (check()) return;
|
|
59
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
60
|
+
}
|
|
61
|
+
throw new Error(`waitFor timed out after ${timeoutMs}ms: ${label}`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function connectSocket(path: string, timeoutMs = 3_000): Promise<Socket> {
|
|
65
|
+
return new Promise((resolveConn, rejectConn) => {
|
|
66
|
+
const s = netConnect(path);
|
|
67
|
+
const timer = setTimeout(() => {
|
|
68
|
+
s.destroy();
|
|
69
|
+
rejectConn(new Error(`socket connect timeout: ${path}`));
|
|
70
|
+
}, timeoutMs);
|
|
71
|
+
s.once('connect', () => { clearTimeout(timer); resolveConn(s); });
|
|
72
|
+
s.once('error', (err) => { clearTimeout(timer); rejectConn(err); });
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
describe('headless daemon — panel-request / panel-response', () => {
|
|
77
|
+
let tmpDir: string;
|
|
78
|
+
let socketPath: string;
|
|
79
|
+
let child: ChildProcess;
|
|
80
|
+
let sock: Socket;
|
|
81
|
+
let reader: { events: Array<Record<string, unknown>>; stop: () => void };
|
|
82
|
+
let corrSeq = 0;
|
|
83
|
+
|
|
84
|
+
/** Send one panel-request and await its panel-response. */
|
|
85
|
+
async function panel(op: string, params?: Record<string, unknown>): Promise<Record<string, unknown>> {
|
|
86
|
+
const corrId = `panel-test-${++corrSeq}`;
|
|
87
|
+
sock.write(JSON.stringify({ type: 'panel-request', op, ...(params ? { params } : {}), corrId }) + '\n');
|
|
88
|
+
await waitFor(
|
|
89
|
+
() => reader.events.some((e) => e.type === 'panel-response' && e.corrId === corrId),
|
|
90
|
+
10_000,
|
|
91
|
+
`panel-response for op=${op}`,
|
|
92
|
+
);
|
|
93
|
+
return reader.events.find((e) => e.type === 'panel-response' && e.corrId === corrId)!;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
beforeAll(async () => {
|
|
97
|
+
tmpDir = mkdtempSync(join(tmpdir(), 'fkm-panelreq-'));
|
|
98
|
+
const recipePath = join(tmpDir, 'recipe.json');
|
|
99
|
+
socketPath = join(tmpDir, 'ipc.sock');
|
|
100
|
+
writeFileSync(recipePath, JSON.stringify(MINIMAL_RECIPE), 'utf-8');
|
|
101
|
+
|
|
102
|
+
child = spawn(
|
|
103
|
+
'bun',
|
|
104
|
+
[INDEX_PATH, recipePath, '--headless'],
|
|
105
|
+
{
|
|
106
|
+
cwd: tmpDir,
|
|
107
|
+
env: {
|
|
108
|
+
...process.env,
|
|
109
|
+
ANTHROPIC_API_KEY: 'sk-test-panel',
|
|
110
|
+
DATA_DIR: tmpDir,
|
|
111
|
+
},
|
|
112
|
+
stdio: ['ignore', 'ignore', 'ignore'],
|
|
113
|
+
},
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
await waitFor(() => existsSync(socketPath), 15_000, 'socket file appears');
|
|
117
|
+
sock = await connectSocket(socketPath);
|
|
118
|
+
reader = lineReader(sock);
|
|
119
|
+
await waitFor(
|
|
120
|
+
() => reader.events.some((e) => e.type === 'lifecycle' && (e as { phase?: string }).phase === 'ready'),
|
|
121
|
+
5_000,
|
|
122
|
+
'lifecycle:ready',
|
|
123
|
+
);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
afterAll(async () => {
|
|
127
|
+
try { sock.write(JSON.stringify({ type: 'shutdown' }) + '\n'); } catch { /* noop */ }
|
|
128
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
129
|
+
try { reader.stop(); sock.destroy(); } catch { /* noop */ }
|
|
130
|
+
try { if (child.exitCode === null) child.kill('SIGKILL'); } catch { /* noop */ }
|
|
131
|
+
try { rmSync(tmpDir, { recursive: true, force: true }); } catch { /* noop */ }
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test('op=health returns the healthz assembly', async () => {
|
|
135
|
+
const res = await panel('health');
|
|
136
|
+
expect(res.ok).toBe(true);
|
|
137
|
+
expect(res.op).toBe('health');
|
|
138
|
+
const data = res.data as Record<string, unknown>;
|
|
139
|
+
// Same shape /healthz serves: framework snapshot + the panel-data extras.
|
|
140
|
+
expect(data.agents ?? data.runtimeSettings ?? data.contextComposition).toBeDefined();
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test('op=settings returns the primary agent runtime settings', async () => {
|
|
144
|
+
const res = await panel('settings');
|
|
145
|
+
expect(res.ok).toBe(true);
|
|
146
|
+
const data = res.data as { agent: string; settings: Record<string, unknown>; hotKeys: string[] };
|
|
147
|
+
expect(data.agent).toBe('commander');
|
|
148
|
+
expect(typeof data.settings).toBe('object');
|
|
149
|
+
expect(Array.isArray(data.hotKeys)).toBe(true);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test('op=mcpl returns registry + live server view', async () => {
|
|
153
|
+
const res = await panel('mcpl');
|
|
154
|
+
expect(res.ok).toBe(true);
|
|
155
|
+
const data = res.data as { configPath: string; servers: unknown[]; live: unknown[] };
|
|
156
|
+
expect(typeof data.configPath).toBe('string');
|
|
157
|
+
expect(Array.isArray(data.servers)).toBe(true);
|
|
158
|
+
expect(Array.isArray(data.live)).toBe(true);
|
|
159
|
+
expect(data.live.length).toBe(0); // minimal recipe opts into no MCPLs
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test('op=pins returns a snapshot with candidates when asked', async () => {
|
|
163
|
+
const res = await panel('pins', { withCandidates: true });
|
|
164
|
+
expect(res.ok).toBe(true);
|
|
165
|
+
const data = res.data as { agent: string; pins: unknown[]; pinsSupported: boolean; candidates?: unknown[] };
|
|
166
|
+
expect(data.agent).toBe('commander');
|
|
167
|
+
expect(Array.isArray(data.pins)).toBe(true);
|
|
168
|
+
expect(typeof data.pinsSupported).toBe('boolean');
|
|
169
|
+
expect(Array.isArray(data.candidates)).toBe(true);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test('op=context-coverage names the agent and its branch', async () => {
|
|
173
|
+
const res = await panel('context-coverage');
|
|
174
|
+
expect(res.ok).toBe(true);
|
|
175
|
+
const data = res.data as { agent: string; branch: string; totals: Record<string, number> };
|
|
176
|
+
expect(data.agent).toBe('commander');
|
|
177
|
+
expect(typeof data.branch).toBe('string');
|
|
178
|
+
expect(typeof data.totals.chunks).toBe('number');
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test('unknown agent comes back ok:false with 404', async () => {
|
|
182
|
+
const res = await panel('context-coverage', { agent: 'nonexistent' });
|
|
183
|
+
expect(res.ok).toBe(false);
|
|
184
|
+
expect(res.status).toBe(404);
|
|
185
|
+
expect(res.error).toContain('nonexistent');
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test('unknown op comes back ok:false with 400', async () => {
|
|
189
|
+
const res = await panel('no-such-op');
|
|
190
|
+
expect(res.ok).toBe(false);
|
|
191
|
+
expect(res.status).toBe(400);
|
|
192
|
+
expect(res.error).toContain('unknown panel op');
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test('panel-response bypasses subscription filter', async () => {
|
|
196
|
+
sock.write(JSON.stringify({ type: 'subscribe', events: ['command-output'] }) + '\n');
|
|
197
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
198
|
+
const res = await panel('health');
|
|
199
|
+
expect(res.ok).toBe(true);
|
|
200
|
+
});
|
|
201
|
+
});
|
|
@@ -15,8 +15,14 @@ import { readAgentOverlay, saveAgentOverlay } from '../src/mcpl-config.js';
|
|
|
15
15
|
interface StubServer {
|
|
16
16
|
id: string;
|
|
17
17
|
connected: boolean;
|
|
18
|
+
retrying: boolean;
|
|
18
19
|
toolPrefix: string;
|
|
19
20
|
toolCount: number;
|
|
21
|
+
policyEstablished: boolean;
|
|
22
|
+
effectiveGrant: string[];
|
|
23
|
+
maskedCapabilities: string[];
|
|
24
|
+
deniedCapabilities: string[];
|
|
25
|
+
allowHostCommands: boolean;
|
|
20
26
|
command?: string;
|
|
21
27
|
url?: string;
|
|
22
28
|
}
|
|
@@ -32,8 +38,14 @@ function makeStubFramework() {
|
|
|
32
38
|
servers.set(config.id, {
|
|
33
39
|
id: config.id,
|
|
34
40
|
connected: true,
|
|
41
|
+
retrying: false,
|
|
35
42
|
toolPrefix: config.toolPrefix ?? `mcpl--${config.id}`,
|
|
36
43
|
toolCount: 1,
|
|
44
|
+
policyEstablished: true,
|
|
45
|
+
effectiveGrant: ['channels.incoming'],
|
|
46
|
+
maskedCapabilities: ['channels.streaming'],
|
|
47
|
+
deniedCapabilities: ['contextHooks.beforeInference.inject.system'],
|
|
48
|
+
allowHostCommands: false,
|
|
37
49
|
command: config.command,
|
|
38
50
|
url: config.url,
|
|
39
51
|
});
|
|
@@ -49,8 +61,14 @@ function makeStubFramework() {
|
|
|
49
61
|
servers.set(id, {
|
|
50
62
|
id,
|
|
51
63
|
connected: true,
|
|
64
|
+
retrying: false,
|
|
52
65
|
toolPrefix: `mcpl--${id}`,
|
|
53
66
|
toolCount: 1,
|
|
67
|
+
policyEstablished: true,
|
|
68
|
+
effectiveGrant: ['channels.incoming'],
|
|
69
|
+
maskedCapabilities: ['channels.streaming'],
|
|
70
|
+
deniedCapabilities: ['contextHooks.beforeInference.inject.system'],
|
|
71
|
+
allowHostCommands: false,
|
|
54
72
|
command: config?.command ?? prev?.command,
|
|
55
73
|
});
|
|
56
74
|
},
|
|
@@ -207,6 +225,11 @@ describe('mcpl_list', () => {
|
|
|
207
225
|
const text = String(result.data);
|
|
208
226
|
expect(text).toContain('discord: CONNECTED');
|
|
209
227
|
expect(text).toContain('mytool: CONNECTED');
|
|
228
|
+
expect(text).toContain('policy=established');
|
|
229
|
+
expect(text).toContain('grant=[channels.incoming]');
|
|
230
|
+
expect(text).toContain('masked=[channels.streaming]');
|
|
231
|
+
expect(text).toContain('denied=[contextHooks.beforeInference.inject.system]');
|
|
232
|
+
expect(text).toContain('hostCommands=deny');
|
|
210
233
|
expect(text).toContain('source=agent-overlay');
|
|
211
234
|
expect(text).toContain('gone: UNLOADED');
|
|
212
235
|
});
|
|
@@ -83,6 +83,20 @@ function simulateFinalInference(text: string, emitIdleAfter: boolean): void {
|
|
|
83
83
|
function dispatch(msg: Record<string, unknown>): void {
|
|
84
84
|
const t = typeof msg.type === 'string' ? msg.type : '';
|
|
85
85
|
if (t === 'subscribe') return; // accept silently
|
|
86
|
+
if (t === 'panel-request') {
|
|
87
|
+
// Panel-op verbs for FleetModule.requestPanel tests:
|
|
88
|
+
// op 'hang' → never answers (timeout path)
|
|
89
|
+
// op 'fail' → ok:false with a status (error passthrough path)
|
|
90
|
+
// anything else → ok:true echoing the params back
|
|
91
|
+
const op = typeof msg.op === 'string' ? msg.op : '';
|
|
92
|
+
if (op === 'hang') return;
|
|
93
|
+
if (op === 'fail') {
|
|
94
|
+
emit({ type: 'panel-response', corrId: msg.corrId, op, ok: false, error: 'mock failure', status: 418 });
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
emit({ type: 'panel-response', corrId: msg.corrId, op, ok: true, data: { op, echo: msg.params ?? null } });
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
86
100
|
if (t === 'shutdown') {
|
|
87
101
|
emit({ type: 'lifecycle', phase: 'exiting', reason: 'shutdown' });
|
|
88
102
|
setTimeout(() => process.exit(0), 50);
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
|
|
2
|
+
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import type { ModuleContext } from '@animalabs/agent-framework';
|
|
6
|
+
import {
|
|
7
|
+
WebUiModule,
|
|
8
|
+
__getSharedServerPortForTests,
|
|
9
|
+
__resetSharedServerForTests,
|
|
10
|
+
} from '../src/modules/web-ui-module.js';
|
|
11
|
+
|
|
12
|
+
describe('retrieval operator authentication on credential-free loopback', () => {
|
|
13
|
+
let webUiModule: WebUiModule | undefined;
|
|
14
|
+
let root: string;
|
|
15
|
+
let baseUrl: string;
|
|
16
|
+
|
|
17
|
+
beforeAll(async () => {
|
|
18
|
+
root = mkdtempSync(join(tmpdir(), 'retrieval-auth-loopback-'));
|
|
19
|
+
const staticRoot = join(root, 'web');
|
|
20
|
+
mkdirSync(staticRoot);
|
|
21
|
+
writeFileSync(join(staticRoot, 'index.html'), '<!doctype html><title>loopback</title>');
|
|
22
|
+
webUiModule = new WebUiModule({
|
|
23
|
+
port: 0,
|
|
24
|
+
host: '127.0.0.1',
|
|
25
|
+
staticDir: staticRoot,
|
|
26
|
+
});
|
|
27
|
+
await webUiModule.start({} as ModuleContext);
|
|
28
|
+
const port = __getSharedServerPortForTests();
|
|
29
|
+
if (!port) throw new Error('webui server not bound; did start() succeed?');
|
|
30
|
+
baseUrl = `http://127.0.0.1:${port}`;
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
afterAll(async () => {
|
|
34
|
+
await webUiModule?.stop();
|
|
35
|
+
await __resetSharedServerForTests();
|
|
36
|
+
if (root) rmSync(root, { recursive: true, force: true });
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('denies both retrieval routes without changing ordinary loopback routes', async () => {
|
|
40
|
+
expect((await fetch(`${baseUrl}/`)).status).toBe(200);
|
|
41
|
+
expect((await fetch(`${baseUrl}/debug/context`)).status).toBe(503);
|
|
42
|
+
|
|
43
|
+
for (const path of ['/debug/retrieval', '/debug/retrieval/view']) {
|
|
44
|
+
const response = await fetch(`${baseUrl}${path}`);
|
|
45
|
+
expect(response.status).toBe(401);
|
|
46
|
+
expect(response.headers.get('cache-control')).toBe('no-store');
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import type { Membrane } from '@animalabs/membrane';
|
|
3
|
+
import { validateRecipe } from '../src/recipe.js';
|
|
4
|
+
import { buildRetrievalModuleConfig } from '../src/retrieval-config.js';
|
|
5
|
+
|
|
6
|
+
const membrane = {} as Membrane;
|
|
7
|
+
|
|
8
|
+
function recipe(retrieval: unknown, provider: string = 'openai-codex') {
|
|
9
|
+
return {
|
|
10
|
+
name: 'retrieval-config-test',
|
|
11
|
+
agent: { systemPrompt: 'sys', provider },
|
|
12
|
+
modules: { retrieval },
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe('retrieval recipe config', () => {
|
|
17
|
+
test('accepts and maps provider reasoning settings', () => {
|
|
18
|
+
const parsed = validateRecipe(recipe({
|
|
19
|
+
model: 'test-model',
|
|
20
|
+
maxInjected: 7,
|
|
21
|
+
reasoningEffort: 'minimal',
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
expect(parsed.modules?.retrieval).toEqual({
|
|
25
|
+
model: 'test-model',
|
|
26
|
+
maxInjected: 7,
|
|
27
|
+
reasoningEffort: 'minimal',
|
|
28
|
+
});
|
|
29
|
+
expect(buildRetrievalModuleConfig(membrane, parsed.modules!.retrieval!, 'openai-codex')).toEqual({
|
|
30
|
+
membrane,
|
|
31
|
+
retrievalModel: 'test-model',
|
|
32
|
+
maxInjectedLessons: 7,
|
|
33
|
+
retrievalReasoning: { effort: 'minimal' },
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('preserves boolean shorthand and omits unconfigured reasoning', () => {
|
|
38
|
+
expect(validateRecipe(recipe(true)).modules?.retrieval).toBe(true);
|
|
39
|
+
expect(validateRecipe(recipe(false)).modules?.retrieval).toBe(false);
|
|
40
|
+
expect(buildRetrievalModuleConfig(membrane, { model: 'test-model' }, 'anthropic')).toEqual({
|
|
41
|
+
membrane,
|
|
42
|
+
retrievalModel: 'test-model',
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('rejects malformed retrieval reasoning settings', () => {
|
|
47
|
+
expect(() => validateRecipe(recipe(null))).toThrow(/modules\.retrieval must be a boolean or object/);
|
|
48
|
+
expect(() => validateRecipe(recipe([]))).toThrow(/modules\.retrieval must be a boolean or object/);
|
|
49
|
+
expect(() => validateRecipe(recipe({ reasoningEffort: 'ultra' }))).toThrow(/reasoningEffort/);
|
|
50
|
+
expect(() => validateRecipe(recipe({ reasoningEffort: ['high'] }))).toThrow(/reasoningEffort/);
|
|
51
|
+
expect(() => validateRecipe(recipe({ reasoningContext: 'current_turn' }))).toThrow(
|
|
52
|
+
/independent one-shot requests/,
|
|
53
|
+
);
|
|
54
|
+
expect(() => validateRecipe(recipe({ reasoningEffort: 'high' }, 'anthropic'))).toThrow(
|
|
55
|
+
/requires agent\.provider/,
|
|
56
|
+
);
|
|
57
|
+
expect(() => buildRetrievalModuleConfig(
|
|
58
|
+
membrane,
|
|
59
|
+
{ reasoningEffort: 'high' },
|
|
60
|
+
'anthropic',
|
|
61
|
+
)).toThrow(/requires agent\.provider/);
|
|
62
|
+
expect(() => validateRecipe(recipe({ reasoningEffort: 'high' }))).toThrow(
|
|
63
|
+
/model must be a non-empty string/,
|
|
64
|
+
);
|
|
65
|
+
expect(() => validateRecipe(recipe({ model: ' ', reasoningEffort: 'high' }))).toThrow(
|
|
66
|
+
/model must be a non-empty string/,
|
|
67
|
+
);
|
|
68
|
+
expect(() => buildRetrievalModuleConfig(
|
|
69
|
+
membrane,
|
|
70
|
+
{ reasoningEffort: 'high' },
|
|
71
|
+
'openai-codex',
|
|
72
|
+
)).toThrow(/model must be a non-empty string/);
|
|
73
|
+
});
|
|
74
|
+
});
|