@animalabs/connectome-host 0.7.4 → 0.8.0

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 (60) hide show
  1. package/.env.example +12 -5
  2. package/.github/PULL_REQUEST_TEMPLATE.md +3 -2
  3. package/.github/workflows/changelog.yml +9 -4
  4. package/.github/workflows/ci.yml +5 -3
  5. package/.github/workflows/publish.yml +12 -6
  6. package/CHANGELOG.md +245 -0
  7. package/CONTRIBUTING.md +47 -19
  8. package/README.md +27 -0
  9. package/bun.lock +27 -31
  10. package/changelog.d/README.md +28 -0
  11. package/package.json +5 -5
  12. package/recipes/SETUP.md +11 -5
  13. package/recipes/TRIUMVIRATE-SETUP.md +68 -14
  14. package/recipes/knowledge-miner.json +0 -30
  15. package/recipes/mock-test.json +19 -0
  16. package/recipes/triumvirate.json +6 -1
  17. package/scripts/release-changelog.ts +210 -21
  18. package/src/cache-keepalive-log.ts +41 -0
  19. package/src/commands.ts +96 -0
  20. package/src/framework-strategy.ts +37 -0
  21. package/src/gate-telemetry.ts +106 -0
  22. package/src/headless.ts +10 -0
  23. package/src/index.ts +167 -55
  24. package/src/mcpl-config.ts +99 -1
  25. package/src/modules/identity-module.ts +310 -2
  26. package/src/modules/instructions-module.ts +265 -0
  27. package/src/modules/mcpl-admin-module.ts +58 -11
  28. package/src/modules/subagent-module.ts +18 -0
  29. package/src/recipe.ts +732 -25
  30. package/src/web/panel-data.ts +19 -0
  31. package/src/workspace-mounts.ts +73 -0
  32. package/test/audit-module-optins.test.ts +10 -3
  33. package/test/cache-keepalive-log.test.ts +83 -0
  34. package/test/conversations-recipe.test.ts +142 -0
  35. package/test/framework-fkm-composition.test.ts +35 -3
  36. package/test/framework-strategy-defaults.test.ts +19 -0
  37. package/test/gate-telemetry-adapter.test.ts +84 -0
  38. package/test/gate-telemetry.test.ts +91 -0
  39. package/test/identity-and-surfaces.test.ts +212 -1
  40. package/test/instructions-module.test.ts +258 -0
  41. package/test/mcpl-admin-module.test.ts +41 -0
  42. package/test/mcpl-agent-overlay.test.ts +51 -3
  43. package/test/mcpl-child-env.test.ts +64 -0
  44. package/test/nudge-command.test.ts +47 -0
  45. package/test/recipe-cache-keepalive.test.ts +59 -0
  46. package/test/recipe-compression-fallback.test.ts +19 -0
  47. package/test/recipe-hybrid-prose-routing.test.ts +12 -0
  48. package/test/recipe-instructions.test.ts +176 -0
  49. package/test/recipe-kv-unified.test.ts +87 -0
  50. package/test/recipe-mcp-source.test.ts +54 -0
  51. package/test/recipe-openai-compatible.test.ts +54 -0
  52. package/test/recipe-path-resolution.test.ts +19 -8
  53. package/test/recipe-provider.test.ts +14 -0
  54. package/test/recipe-save-unresolved.test.ts +244 -0
  55. package/test/recipe-source-only.test.ts +38 -0
  56. package/test/release-changelog.test.ts +202 -0
  57. package/test/subagent-prose-routing.test.ts +109 -0
  58. package/test/workspace-mounts.test.ts +68 -0
  59. package/web/src/App.tsx +1 -0
  60. package/web/src/Health.tsx +61 -1
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Host composition of stdio MCPL child env (composeMcplChildEnv): the
3
+ * framework's refusal-annotation baseline is injected for never-configured
4
+ * deployments, and an operator's explicit value always supersedes it — the
5
+ * host provides a default, never overrides a decision. The enforced
6
+ * precedence below this (file key incl. [] → legacy operator env → baseline)
7
+ * lives in the Discord adapter; what the host owns is that the injected
8
+ * value is EXACTLY the set the framework stamps (REFUSAL_REACTION_BASELINE,
9
+ * comma-joined) and that it rides in env — process plumbing below the model
10
+ * line, never agent-visible text.
11
+ */
12
+ import { describe, test, expect } from 'bun:test';
13
+ import { REFUSAL_REACTION_BASELINE } from '@animalabs/agent-framework';
14
+ import { composeMcplChildEnv } from '../src/mcpl-config.js';
15
+
16
+ const BASELINE = REFUSAL_REACTION_BASELINE.join(',');
17
+
18
+ describe('composeMcplChildEnv', () => {
19
+ test('injects the framework baseline when the server entry does not set it', () => {
20
+ const env = composeMcplChildEnv({ SOME_VAR: 'x' }, 'UTC');
21
+ expect(env.DISCORD_SUPPRESSED_REACTIONS_BASELINE).toBe(BASELINE);
22
+ expect(env.SOME_VAR).toBe('x');
23
+ });
24
+
25
+ test('injected value round-trips to the exact framework annotation set', () => {
26
+ const env = composeMcplChildEnv(undefined, 'UTC');
27
+ expect(env.DISCORD_SUPPRESSED_REACTIONS_BASELINE!.split(',')).toEqual([
28
+ ...REFUSAL_REACTION_BASELINE,
29
+ ]);
30
+ expect(REFUSAL_REACTION_BASELINE.length).toBeGreaterThan(0);
31
+ });
32
+
33
+ test('operator-set baseline on the server entry supersedes the house value', () => {
34
+ const env = composeMcplChildEnv(
35
+ { DISCORD_SUPPRESSED_REACTIONS_BASELINE: '🈲' },
36
+ 'UTC',
37
+ );
38
+ expect(env.DISCORD_SUPPRESSED_REACTIONS_BASELINE).toBe('🈲');
39
+ });
40
+
41
+ test('operator empty-string baseline is preserved, not re-defaulted', () => {
42
+ // An operator who explicitly set the var to empty chose "no baseline";
43
+ // the house value must not reappear underneath that decision.
44
+ const env = composeMcplChildEnv(
45
+ { DISCORD_SUPPRESSED_REACTIONS_BASELINE: '' },
46
+ 'UTC',
47
+ );
48
+ expect(env.DISCORD_SUPPRESSED_REACTIONS_BASELINE).toBe('');
49
+ });
50
+
51
+ test('AGENT_TIMEZONE stays host-resolved (recipe wall clock, not a per-server knob)', () => {
52
+ const env = composeMcplChildEnv({ AGENT_TIMEZONE: 'Mars/Olympus' }, 'America/Los_Angeles');
53
+ expect(env.AGENT_TIMEZONE).toBe('America/Los_Angeles');
54
+ });
55
+
56
+ test('adds nothing beyond the two host-owned keys', () => {
57
+ const env = composeMcplChildEnv({ A: '1' }, 'UTC');
58
+ expect(Object.keys(env).sort()).toEqual([
59
+ 'A',
60
+ 'AGENT_TIMEZONE',
61
+ 'DISCORD_SUPPRESSED_REACTIONS_BASELINE',
62
+ ]);
63
+ });
64
+ });
@@ -0,0 +1,47 @@
1
+ /**
2
+ * /nudge — admin-level: run inference on the agent's CURRENT context without
3
+ * adding any message or event. Thin shim over framework.nudgeAgent(); these
4
+ * tests pin the argument pass-through and the operator-facing wording.
5
+ */
6
+ import { describe, expect, test } from 'bun:test';
7
+ import type { AgentFramework } from '@animalabs/agent-framework';
8
+ import { handleCommand } from '../src/commands.js';
9
+
10
+ function app(nudgeAgent: AgentFramework['nudgeAgent']): Parameters<typeof handleCommand>[1] {
11
+ return {
12
+ framework: { nudgeAgent } as AgentFramework,
13
+ sessionManager: {} as never,
14
+ recipe: { name: 'test' } as never,
15
+ branchState: {} as never,
16
+ switchSession: async () => {},
17
+ };
18
+ }
19
+
20
+ describe('/nudge', () => {
21
+ test('nudges the default agent and reports immediate run when idle', () => {
22
+ const calls: Array<[string | undefined, string | undefined]> = [];
23
+ const result = handleCommand('/nudge', app((name, by) => {
24
+ calls.push([name, by]);
25
+ return { ok: true, agentName: 'main', agentStatus: 'idle' };
26
+ }));
27
+ expect(calls).toEqual([[undefined, 'host-console']]);
28
+ expect(result.lines[0]?.text).toMatch(/Nudged main/);
29
+ expect(result.lines[0]?.text).toMatch(/no new events/);
30
+ expect(result.lines[0]?.text).toMatch(/running now/);
31
+ });
32
+
33
+ test('passes an explicit agent name and reports queueing when busy', () => {
34
+ const result = handleCommand('/nudge sidekick', app((name) => {
35
+ expect(name).toBe('sidekick');
36
+ return { ok: true, agentName: 'sidekick', agentStatus: 'streaming' };
37
+ }));
38
+ expect(result.lines[0]?.text).toMatch(/queued — runs when current turn settles \(agent is streaming\)/);
39
+ });
40
+
41
+ test('surfaces framework errors', () => {
42
+ const result = handleCommand('/nudge ghost', app(() => (
43
+ { ok: false, error: 'Unknown agent: ghost' }
44
+ )));
45
+ expect(result.lines[0]?.text).toBe('Nudge failed: Unknown agent: ghost');
46
+ });
47
+ });
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Tests for recipe.agent.cacheKeepalive validation.
3
+ *
4
+ * The load-bearing case is `refreshAfterMinutes >= the cache TTL`: such a
5
+ * keepalive always fires AFTER the entry has expired, so every poke pays a full
6
+ * 2x cache write instead of a 0.1x read — while still looking like a healthy
7
+ * successful call. That must fail at recipe-load time, not on a bill.
8
+ */
9
+ import { describe, test, expect } from 'bun:test';
10
+ import { validateRecipe } from '../src/recipe.js';
11
+
12
+ function recipeWith(cacheKeepalive?: unknown, cacheTtl?: unknown) {
13
+ return {
14
+ name: 'keepalive-test',
15
+ agent: {
16
+ systemPrompt: 'sys',
17
+ ...(cacheTtl !== undefined && { cacheTtl }),
18
+ ...(cacheKeepalive !== undefined && { cacheKeepalive }),
19
+ },
20
+ };
21
+ }
22
+
23
+ describe('recipe agent.cacheKeepalive validation', () => {
24
+ test('omitting it is valid — keepalive is on by default', () => {
25
+ expect(validateRecipe(recipeWith()).agent.cacheKeepalive).toBeUndefined();
26
+ });
27
+
28
+ test('accepts an explicit opt-out', () => {
29
+ const r = validateRecipe(recipeWith({ enabled: false }));
30
+ expect(r.agent.cacheKeepalive?.enabled).toBe(false);
31
+ });
32
+
33
+ test('accepts sane tuning', () => {
34
+ const r = validateRecipe(recipeWith({ maxIdleHours: 12, refreshAfterMinutes: 50 }));
35
+ expect(r.agent.cacheKeepalive?.maxIdleHours).toBe(12);
36
+ expect(r.agent.cacheKeepalive?.refreshAfterMinutes).toBe(50);
37
+ });
38
+
39
+ test('rejects a refresh interval at or past the 1h TTL (every poke would be a cache WRITE)', () => {
40
+ expect(() => validateRecipe(recipeWith({ refreshAfterMinutes: 60 }, '1h'))).toThrow(/less than the 1h cache TTL/);
41
+ expect(() => validateRecipe(recipeWith({ refreshAfterMinutes: 90 }, '1h'))).toThrow(/less than the 1h cache TTL/);
42
+ });
43
+
44
+ test('rejects a refresh interval at or past the 5m TTL', () => {
45
+ expect(() => validateRecipe(recipeWith({ refreshAfterMinutes: 45 }, '5m'))).toThrow(/less than the 5m cache TTL/);
46
+ expect(validateRecipe(recipeWith({ refreshAfterMinutes: 4 }, '5m')).agent.cacheKeepalive?.refreshAfterMinutes).toBe(4);
47
+ });
48
+
49
+ test('rejects non-positive and non-numeric values', () => {
50
+ expect(() => validateRecipe(recipeWith({ refreshAfterMinutes: 0 }))).toThrow(/positive number/);
51
+ expect(() => validateRecipe(recipeWith({ refreshAfterMinutes: '45' }))).toThrow(/positive number/);
52
+ expect(() => validateRecipe(recipeWith({ maxIdleHours: -1 }))).toThrow(/positive number/);
53
+ expect(() => validateRecipe(recipeWith({ maxIdleHours: 'lots' }))).toThrow(/positive number/);
54
+ });
55
+
56
+ test('rejects a non-object', () => {
57
+ expect(() => validateRecipe(recipeWith('yes'))).toThrow(/must be an object/);
58
+ });
59
+ });
@@ -13,9 +13,19 @@ describe('compression recall-curve recipe settings', () => {
13
13
  const parsed = validateRecipe(recipe({
14
14
  compressionRefusalCurveFallbacks: 3,
15
15
  compressionContextBudgetTokens: 200_000,
16
+ compressionRecallBudgetTokens: 40_000,
17
+ compressionSourceOnly: false,
18
+ compressionSourceOnlyFallback: true,
19
+ compressionMergeSourceOnly: false,
20
+ compressionMergeSourceOnlyFallback: true,
16
21
  }));
17
22
  expect(parsed.agent.strategy?.compressionRefusalCurveFallbacks).toBe(3);
18
23
  expect(parsed.agent.strategy?.compressionContextBudgetTokens).toBe(200_000);
24
+ expect(parsed.agent.strategy?.compressionRecallBudgetTokens).toBe(40_000);
25
+ expect(parsed.agent.strategy?.compressionSourceOnly).toBe(false);
26
+ expect(parsed.agent.strategy?.compressionSourceOnlyFallback).toBe(true);
27
+ expect(parsed.agent.strategy?.compressionMergeSourceOnly).toBe(false);
28
+ expect(parsed.agent.strategy?.compressionMergeSourceOnlyFallback).toBe(true);
19
29
  });
20
30
 
21
31
  test('accepts zero as an explicit fallback disable', () => {
@@ -32,5 +42,14 @@ describe('compression recall-curve recipe settings', () => {
32
42
  .toThrow(/compressionContextBudgetTokens/);
33
43
  expect(() => validateRecipe(recipe({ compressionContextBudgetTokens: '200000' })))
34
44
  .toThrow(/compressionContextBudgetTokens/);
45
+ expect(() => validateRecipe(recipe({ compressionRecallBudgetTokens: 0 })))
46
+ .toThrow(/compressionRecallBudgetTokens/);
47
+ expect(() => validateRecipe(recipe({ compressionRecallBudgetTokens: 1.5 })))
48
+ .toThrow(/compressionRecallBudgetTokens/);
49
+ expect(() => validateRecipe(recipe({ compressionRecallBudgetTokens: '40000' })))
50
+ .toThrow(/compressionRecallBudgetTokens/);
51
+ for (const key of ['compressionSourceOnly', 'compressionSourceOnlyFallback', 'compressionMergeSourceOnly', 'compressionMergeSourceOnlyFallback']) {
52
+ expect(() => validateRecipe(recipe({ [key]: 'yes' }))).toThrow(new RegExp(key));
53
+ }
35
54
  });
36
55
  });
@@ -0,0 +1,12 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+ import { validateRecipe } from '../src/recipe.js';
3
+ const base = (proseRouting?: unknown) => ({ name: 'test', agent: { systemPrompt: '', ...(proseRouting === undefined ? {} : { proseRouting }) } });
4
+ describe('prose routing recipe', () => {
5
+ it('accepts locus, explicit, hybrid, disabled, and omission', () => {
6
+ expect(validateRecipe(base()).agent.proseRouting).toBeUndefined();
7
+ for (const mode of ['locus', 'explicit', 'hybrid', 'disabled'] as const) expect(validateRecipe(base(mode)).agent.proseRouting).toBe(mode);
8
+ });
9
+ it('rejects unknown modes', () => {
10
+ expect(() => validateRecipe(base('triple-magic'))).toThrow(/proseRouting/);
11
+ });
12
+ });
@@ -0,0 +1,176 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { validateRecipe } from '../src/recipe.js';
3
+
4
+ function recipe(modules?: Record<string, unknown>) {
5
+ return {
6
+ name: 'instructions-test',
7
+ agent: { systemPrompt: 'test' },
8
+ ...(modules === undefined ? {} : { modules }),
9
+ };
10
+ }
11
+
12
+ /** A workspace declaration that satisfies the instructions cross-check for
13
+ * the given mount name: read-write + autoMaterialize (the validated shape
14
+ * for a mount agents curate). */
15
+ function wsFor(mountName: string) {
16
+ return {
17
+ mounts: [
18
+ { name: mountName, path: `./${mountName}`, mode: 'read-write', autoMaterialize: true },
19
+ ],
20
+ };
21
+ }
22
+
23
+ describe('recipe modules.instructions validation', () => {
24
+ test('allows the field to be omitted', () => {
25
+ expect(validateRecipe(recipe()).modules?.instructions).toBeUndefined();
26
+ });
27
+
28
+ test('accepts boolean shorthand', () => {
29
+ expect(validateRecipe(recipe({ instructions: true, workspace: wsFor('instructions') }))
30
+ .modules?.instructions).toBe(true);
31
+ expect(validateRecipe(recipe({ instructions: false })).modules?.instructions).toBe(false);
32
+ });
33
+
34
+ test('accepts a full object config', () => {
35
+ const parsed = validateRecipe(recipe({
36
+ workspace: wsFor('shared'),
37
+ instructions: {
38
+ path: 'shared/HOUSE-RULES.md',
39
+ header: '## House rules',
40
+ maxBytes: 4096,
41
+ position: 'afterUser',
42
+ },
43
+ }));
44
+ expect(parsed.modules?.instructions).toEqual({
45
+ path: 'shared/HOUSE-RULES.md',
46
+ header: '## House rules',
47
+ maxBytes: 4096,
48
+ position: 'afterUser',
49
+ });
50
+ });
51
+
52
+ test('accepts an empty object (all defaults)', () => {
53
+ expect(validateRecipe(recipe({ instructions: {}, workspace: wsFor('instructions') }))
54
+ .modules?.instructions).toEqual({});
55
+ });
56
+
57
+ test('rejects non-boolean, non-object values', () => {
58
+ expect(() => validateRecipe(recipe({ instructions: 'yes' }))).toThrow(/boolean or object/);
59
+ expect(() => validateRecipe(recipe({ instructions: ['x'] }))).toThrow(/boolean or object/);
60
+ });
61
+
62
+ test('rejects unknown fields', () => {
63
+ expect(() => validateRecipe(recipe({ instructions: { file: 'AGENTS.md' } })))
64
+ .toThrow(/unknown field "file"/);
65
+ });
66
+
67
+ test('rejects a path without a "<mountName>/<relativePath>" shape', () => {
68
+ expect(() => validateRecipe(recipe({ instructions: { path: 'AGENTS.md' } })))
69
+ .toThrow(/<mountName>\/<relativePath>/);
70
+ expect(() => validateRecipe(recipe({ instructions: { path: '/etc/passwd' } })))
71
+ .toThrow(/<mountName>\/<relativePath>/);
72
+ expect(() => validateRecipe(recipe({ instructions: { path: 'mount/' } })))
73
+ .toThrow(/<mountName>\/<relativePath>/);
74
+ expect(() => validateRecipe(recipe({ instructions: { path: '' } })))
75
+ .toThrow(/non-empty string/);
76
+ });
77
+
78
+ test('rejects invalid maxBytes', () => {
79
+ expect(() => validateRecipe(recipe({ instructions: { maxBytes: 0 } })))
80
+ .toThrow(/positive integer/);
81
+ expect(() => validateRecipe(recipe({ instructions: { maxBytes: 1.5 } })))
82
+ .toThrow(/positive integer/);
83
+ expect(() => validateRecipe(recipe({ instructions: { maxBytes: '32768' } })))
84
+ .toThrow(/positive integer/);
85
+ });
86
+
87
+ test('rejects invalid position', () => {
88
+ expect(() => validateRecipe(recipe({ instructions: { position: 'prepend' } })))
89
+ .toThrow(/'system', 'beforeUser', or 'afterUser'/);
90
+ });
91
+
92
+ test('rejects non-string header', () => {
93
+ expect(() => validateRecipe(recipe({ instructions: { header: 42 } })))
94
+ .toThrow(/header must be a string/);
95
+ });
96
+
97
+ test('rejects instructions when workspace is disabled', () => {
98
+ expect(() => validateRecipe(recipe({ instructions: true, workspace: false })))
99
+ .toThrow(/requires modules\.workspace/);
100
+ expect(() => validateRecipe(recipe({ instructions: {}, workspace: false })))
101
+ .toThrow(/requires modules\.workspace/);
102
+ });
103
+
104
+ test('accepts a properly declared instructions mount (rw + autoMaterialize)', () => {
105
+ expect(() => validateRecipe(recipe({
106
+ instructions: true,
107
+ workspace: wsFor('instructions'),
108
+ }))).not.toThrow();
109
+ });
110
+
111
+ test('cross-checks the path mount against explicitly declared workspace mounts', () => {
112
+ const mounts = [
113
+ { name: 'input', path: './input', mode: 'read-only' },
114
+ { name: 'products', path: './output', mode: 'read-write' },
115
+ ];
116
+ // Default path 'instructions/AGENTS.md' names a mount that isn't declared.
117
+ expect(() => validateRecipe(recipe({ instructions: true, workspace: { mounts } })))
118
+ .toThrow(/requires a workspace mount named\s+"instructions"/);
119
+ // Same for an explicit path with a typo'd mount name.
120
+ expect(() => validateRecipe(recipe({
121
+ instructions: { path: 'shared/AGENTS.md' },
122
+ workspace: { mounts },
123
+ }))).toThrow(/names workspace mount "shared"/);
124
+ // A read-only declared mount passes without autoMaterialize (disk is its
125
+ // only write path).
126
+ expect(() => validateRecipe(recipe({
127
+ instructions: { path: 'input/AGENTS.md' },
128
+ workspace: { mounts },
129
+ }))).not.toThrow();
130
+ // The '_config' mount exists under configMount but is rejected for
131
+ // instructions: it does not auto-materialize (agent edits reach disk only
132
+ // after branch-changing commands), so the injection would serve stale
133
+ // content — the same split-brain the autoMaterialize check prevents.
134
+ expect(() => validateRecipe(recipe({
135
+ instructions: { path: '_config/AGENTS.md' },
136
+ workspace: { mounts, configMount: true },
137
+ }))).toThrow(/host-managed "_config"/);
138
+ });
139
+
140
+ test('rejects a read-write instructions mount without autoMaterialize (split-brain guard)', () => {
141
+ // Workspace writes are Chronicle-first; the injection reads disk. A rw
142
+ // mount that never materializes would silently freeze the injection at
143
+ // the pre-curation content.
144
+ expect(() => validateRecipe(recipe({
145
+ instructions: { path: 'products/AGENTS.md' },
146
+ workspace: { mounts: [{ name: 'products', path: './output', mode: 'read-write' }] },
147
+ }))).toThrow(/autoMaterialize/);
148
+ // Mode defaults to read-write, so an unmoded mount needs it too.
149
+ expect(() => validateRecipe(recipe({
150
+ instructions: true,
151
+ workspace: { mounts: [{ name: 'instructions', path: './instructions' }] },
152
+ }))).toThrow(/autoMaterialize/);
153
+ });
154
+
155
+ test('cross-checks against the implicit default workspace too', () => {
156
+ // `instructions: true` with the implicit workspace (mounts input +
157
+ // products) can never inject — the default path's mount cannot exist.
158
+ // That used to validate and be dead config; now it fails at load.
159
+ expect(() => validateRecipe(recipe({ instructions: true })))
160
+ .toThrow(/implicit default\s+mounts/);
161
+ expect(() => validateRecipe(recipe({ instructions: true, workspace: true })))
162
+ .toThrow(/implicit default\s+mounts/);
163
+ // The implicit read-only 'input' mount is a valid target (file maintained
164
+ // outside the agent).
165
+ expect(() => validateRecipe(recipe({ instructions: { path: 'input/AGENTS.md' } })))
166
+ .not.toThrow();
167
+ // The implicit 'products' mount is rw without autoMaterialize — and the
168
+ // implicit workspace cannot set it, so this directs to explicit mounts.
169
+ expect(() => validateRecipe(recipe({ instructions: { path: 'products/AGENTS.md' } })))
170
+ .toThrow(/implicit default workspace cannot/);
171
+ });
172
+
173
+ test('allows instructions: false alongside workspace: false', () => {
174
+ expect(() => validateRecipe(recipe({ instructions: false, workspace: false }))).not.toThrow();
175
+ });
176
+ });
@@ -0,0 +1,87 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { buildFrameworkStrategy } from '../src/framework-strategy.js';
3
+ import { validateRecipe, type RecipeKvUnifiedConfig } from '../src/recipe.js';
4
+
5
+ function config(): RecipeKvUnifiedConfig {
6
+ return {
7
+ policy: {
8
+ alpha: 0.7,
9
+ budgetLowRatio: 0.7,
10
+ budgetHighRatio: 0.935,
11
+ budgetUnderLambda: 1_000,
12
+ budgetOverLambda: 4_000,
13
+ cacheLambda: 1,
14
+ cacheScale: 100_000,
15
+ cacheReadPrice: 0.1,
16
+ cacheWritePrice: 1.25,
17
+ continuityLambda: 1,
18
+ continuityScale: 100_000,
19
+ continuityRecencyHalfLifeTokens: 100_000,
20
+ continuityRecencyFloor: 0.2,
21
+ continuityStableHalfLife: 16,
22
+ continuityStableFloor: 0.25,
23
+ },
24
+ tokenBucketSize: 10_000,
25
+ continuityBucketSize: 50_000,
26
+ fidelityBucketSize: 100_000,
27
+ labelCeiling: 100_000,
28
+ adoptEpsilon: 2_000,
29
+ treeifyNonContiguousSummaries: false,
30
+ };
31
+ }
32
+
33
+ function recipe(kvUnified: unknown = config()) {
34
+ return {
35
+ name: 'kv-unified-test',
36
+ agent: {
37
+ name: 'fable',
38
+ systemPrompt: 'system',
39
+ strategy: {
40
+ type: 'autobiographical',
41
+ foldingStrategy: 'kv-unified',
42
+ kvUnified,
43
+ },
44
+ },
45
+ };
46
+ }
47
+
48
+ describe('kv-unified recipe plumbing', () => {
49
+ test('forwards the complete fail-closed policy unchanged', () => {
50
+ const validated = validateRecipe(recipe());
51
+ const strategy = buildFrameworkStrategy(validated, 'model', 'UTC') as unknown as {
52
+ config: { foldingStrategy?: string; kvUnified?: RecipeKvUnifiedConfig };
53
+ };
54
+ expect(strategy.config.foldingStrategy).toBe('kv-unified');
55
+ expect(strategy.config.kvUnified).toEqual(config());
56
+ });
57
+
58
+ test('rejects selecting kv-unified without a complete policy', () => {
59
+ const missing = recipe() as ReturnType<typeof recipe>;
60
+ delete (missing.agent.strategy as { kvUnified?: unknown }).kvUnified;
61
+ expect(() => validateRecipe(missing)).toThrow(/complete.*kvUnified/i);
62
+ const incomplete = config() as unknown as Record<string, unknown>;
63
+ incomplete.policy = { ...(incomplete.policy as object) };
64
+ delete (incomplete.policy as Record<string, unknown>).cacheLambda;
65
+ expect(() => validateRecipe(recipe(incomplete))).toThrow(/cacheLambda/);
66
+ });
67
+
68
+ test('rejects invalid bands, grids, and implicit treeification', () => {
69
+ const badBand = config();
70
+ badBand.policy.budgetLowRatio = 0.95;
71
+ expect(() => validateRecipe(recipe(badBand))).toThrow(/budget ratios/);
72
+
73
+ const badGrid = config();
74
+ badGrid.tokenBucketSize = 0;
75
+ expect(() => validateRecipe(recipe(badGrid))).toThrow(/tokenBucketSize/);
76
+
77
+ const missingTreeification = config() as unknown as Record<string, unknown>;
78
+ delete missingTreeification.treeifyNonContiguousSummaries;
79
+ expect(() => validateRecipe(recipe(missingTreeification))).toThrow(/explicit boolean/);
80
+ });
81
+
82
+ test('rejects a kvUnified object when another solver is selected', () => {
83
+ const raw = recipe() as ReturnType<typeof recipe>;
84
+ raw.agent.strategy.foldingStrategy = 'kv-stable';
85
+ expect(() => validateRecipe(raw)).toThrow(/requires foldingStrategy/);
86
+ });
87
+ });
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Tests for mcpServers.<id>.source validation: both of connectome-cook's
3
+ * source grammars (git `url` form and registry `npm` form) must pass — the
4
+ * shipped knowledge-miner recipe uses `source.npm` for its gitlab server,
5
+ * and a validator that only accepts `url` makes that recipe unloadable.
6
+ */
7
+ import { describe, test, expect } from 'bun:test';
8
+ import { validateRecipe } from '../src/recipe.js';
9
+
10
+ function recipeWithSource(source: unknown) {
11
+ return {
12
+ name: 'source-test',
13
+ agent: { systemPrompt: 'sys' },
14
+ mcpServers: {
15
+ srv: {
16
+ command: 'npx',
17
+ args: ['-y', 'some-pkg'],
18
+ ...(source !== undefined ? { source } : {}),
19
+ },
20
+ },
21
+ };
22
+ }
23
+
24
+ describe('mcpServers source validation', () => {
25
+ test('accepts the git url form', () => {
26
+ expect(() => validateRecipe(recipeWithSource({
27
+ url: 'https://github.com/x/y.git',
28
+ install: 'npm',
29
+ }))).not.toThrow();
30
+ });
31
+
32
+ test('accepts the npm registry form', () => {
33
+ expect(() => validateRecipe(recipeWithSource({
34
+ npm: '@zereight/mcp-gitlab@2.1.25',
35
+ }))).not.toThrow();
36
+ });
37
+
38
+ test('rejects a source with neither url nor npm', () => {
39
+ expect(() => validateRecipe(recipeWithSource({ install: 'npm' })))
40
+ .toThrow(/source must have a non-empty "url" \(git clone\) or "npm"/);
41
+ });
42
+
43
+ test('rejects a source with both url and npm', () => {
44
+ expect(() => validateRecipe(recipeWithSource({
45
+ url: 'https://github.com/x/y.git',
46
+ npm: 'y@1.0.0',
47
+ }))).toThrow(/must not set both "url" and "npm"/);
48
+ });
49
+
50
+ test('rejects empty-string url and npm', () => {
51
+ expect(() => validateRecipe(recipeWithSource({ url: '' }))).toThrow(/source/);
52
+ expect(() => validateRecipe(recipeWithSource({ npm: '' }))).toThrow(/source/);
53
+ });
54
+ });
@@ -0,0 +1,54 @@
1
+ /**
2
+ * `agent.provider: 'openai-compatible'` — membrane has shipped a generic
3
+ * OpenAI chat-completions adapter (Ollama, vLLM, Together, Groq, local
4
+ * servers...) that no host wired. The recipe must name the endpoint and the
5
+ * model, and both must be checked at load time: a missing baseUrl would
6
+ * otherwise surface as a fetch to `undefined/chat/completions` at first
7
+ * inference, and a missing model as whatever the endpoint's default happens
8
+ * to be.
9
+ */
10
+ import { describe, expect, test } from 'bun:test';
11
+ import { validateRecipe } from '../src/recipe.js';
12
+
13
+ function recipe(agent: Record<string, unknown>) {
14
+ return { name: 'compat-test', agent: { systemPrompt: 'sys', ...agent } };
15
+ }
16
+
17
+ describe('recipe agent.provider openai-compatible', () => {
18
+ test('accepts an http local endpoint with a model', () => {
19
+ const r = validateRecipe(recipe({ provider: 'openai-compatible', baseUrl: 'http://localhost:11434/v1', model: 'qwen3:32b' }));
20
+ expect(r.agent.provider).toBe('openai-compatible');
21
+ expect(r.agent.baseUrl).toBe('http://localhost:11434/v1');
22
+ expect(r.agent.model).toBe('qwen3:32b');
23
+ });
24
+
25
+ test('accepts an https gateway', () => {
26
+ const r = validateRecipe(recipe({ provider: 'openai-compatible', baseUrl: 'https://nano-gpt.com/api/v1', model: 'xiaomi/mimo-v2.5-pro:thinking' }));
27
+ expect(r.agent.baseUrl).toBe('https://nano-gpt.com/api/v1');
28
+ });
29
+
30
+ test('requires baseUrl', () => {
31
+ expect(() => validateRecipe(recipe({ provider: 'openai-compatible', model: 'm' }))).toThrow(/agent\.baseUrl is required/);
32
+ expect(() => validateRecipe(recipe({ provider: 'openai-compatible', baseUrl: ' ', model: 'm' }))).toThrow(/agent\.baseUrl is required/);
33
+ });
34
+
35
+ test('requires an absolute http(s) URL', () => {
36
+ expect(() => validateRecipe(recipe({ provider: 'openai-compatible', baseUrl: 'localhost:11434/v1', model: 'm' }))).toThrow(/absolute http\(s\) URL|http or https/);
37
+ expect(() => validateRecipe(recipe({ provider: 'openai-compatible', baseUrl: 'ftp://host/v1', model: 'm' }))).toThrow(/http or https/);
38
+ });
39
+
40
+ test('requires an explicit model (no default for an arbitrary endpoint)', () => {
41
+ expect(() => validateRecipe(recipe({ provider: 'openai-compatible', baseUrl: 'http://localhost:11434/v1' }))).toThrow(/agent\.model is required/);
42
+ });
43
+
44
+ test('rejects baseUrl with any other provider', () => {
45
+ expect(() => validateRecipe(recipe({ provider: 'anthropic', baseUrl: 'http://x/v1' }))).toThrow(/only applies to agent\.provider 'openai-compatible'/);
46
+ expect(() => validateRecipe(recipe({ baseUrl: 'http://x/v1' }))).toThrow(/got provider "anthropic"/);
47
+ expect(() => validateRecipe(recipe({ provider: 'openai-codex', baseUrl: 'http://x/v1' }))).toThrow(/got provider "openai-codex"/);
48
+ });
49
+
50
+ test('other providers are untouched', () => {
51
+ expect(validateRecipe(recipe({ provider: 'openai-codex', model: 'gpt-5.4' })).agent.baseUrl).toBeUndefined();
52
+ expect(validateRecipe(recipe({})).agent.provider ?? 'anthropic').toBe('anthropic');
53
+ });
54
+ });
@@ -8,7 +8,7 @@
8
8
  import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
9
9
  import { mkdtempSync, writeFileSync, rmSync, mkdirSync } from 'node:fs';
10
10
  import { tmpdir } from 'node:os';
11
- import { dirname, join, resolve } from 'node:path';
11
+ import { basename, dirname, isAbsolute, join, resolve } from 'node:path';
12
12
  import { fileURLToPath } from 'node:url';
13
13
  import { loadRecipe, resolveRecipeRelative, type Recipe } from '../src/recipe.js';
14
14
 
@@ -48,15 +48,26 @@ describe('resolveRecipeRelative', () => {
48
48
  });
49
49
 
50
50
  test('bare filename resolves against parent dir', () => {
51
- expect(resolveRecipeRelative('child.json', { kind: 'file', dir: '/opt/recipes' }))
52
- .toBe('/opt/recipes/child.json');
51
+ const dir = resolve('/opt/recipes');
52
+ const out = resolveRecipeRelative('child.json', { kind: 'file', dir });
53
+
54
+ expect(isAbsolute(out)).toBe(true);
55
+ expect(dirname(out)).toBe(dir);
56
+ expect(basename(out)).toBe('child.json');
53
57
  });
54
58
 
55
59
  test('dotted prefix resolves against parent dir', () => {
56
- expect(resolveRecipeRelative('./child.json', { kind: 'file', dir: '/opt/recipes' }))
57
- .toBe('/opt/recipes/child.json');
58
- expect(resolveRecipeRelative('../other/child.json', { kind: 'file', dir: '/opt/recipes' }))
59
- .toBe('/opt/other/child.json');
60
+ const dir = resolve('/opt/recipes');
61
+
62
+ const here = resolveRecipeRelative('./child.json', { kind: 'file', dir });
63
+ expect(dirname(here)).toBe(dir);
64
+ expect(basename(here)).toBe('child.json');
65
+
66
+ // `..` must climb out of `dir` and land in a sibling directory.
67
+ const up = resolveRecipeRelative('../other/child.json', { kind: 'file', dir });
68
+ expect(basename(up)).toBe('child.json');
69
+ expect(basename(dirname(up))).toBe('other');
70
+ expect(dirname(dirname(up))).toBe(dirname(dir));
60
71
  });
61
72
 
62
73
  test('URL base resolves relative child to sibling URL', () => {
@@ -97,7 +108,7 @@ describe('loadRecipe — children[].recipe resolution', () => {
97
108
 
98
109
  const originalCwd = process.cwd();
99
110
  try {
100
- process.chdir('/tmp');
111
+ process.chdir(tmpdir());
101
112
  const a = await loadRecipe(parentPath);
102
113
  process.chdir(tmpDir);
103
114
  const b = await loadRecipe(parentPath);
@@ -14,6 +14,20 @@ describe('recipe provider validation', () => {
14
14
  .toBe('openai-codex');
15
15
  });
16
16
 
17
+ test('accepts the mock provider and its settings', () => {
18
+ expect(validateRecipe(recipe({ provider: 'mock' })).agent.provider).toBe('mock');
19
+ expect(validateRecipe(recipe({
20
+ provider: 'mock',
21
+ mock: { echoMode: false, defaultResponse: 'canned' },
22
+ })).agent.mock).toEqual({ echoMode: false, defaultResponse: 'canned' });
23
+ });
24
+
25
+ test('rejects malformed mock settings', () => {
26
+ expect(() => validateRecipe(recipe({ mock: 'echo' }))).toThrow(/agent.mock/);
27
+ expect(() => validateRecipe(recipe({ mock: { echoMode: 'yes' } }))).toThrow(/echoMode/);
28
+ expect(() => validateRecipe(recipe({ mock: { defaultResponse: '' } }))).toThrow(/defaultResponse/);
29
+ });
30
+
17
31
  test('accepts Codex subscription settings', () => {
18
32
  expect(validateRecipe(recipe({
19
33
  provider: 'openai-codex',