@animalabs/connectome-host 0.7.3 → 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.
- package/.env.example +12 -5
- package/.github/PULL_REQUEST_TEMPLATE.md +3 -2
- package/.github/workflows/changelog.yml +9 -4
- package/.github/workflows/ci.yml +5 -3
- package/.github/workflows/publish.yml +12 -6
- package/CHANGELOG.md +401 -10
- package/CONTRIBUTING.md +47 -19
- package/HEADLESS-FLEET-PLAN.md +22 -0
- package/README.md +39 -1
- package/bun.lock +27 -31
- package/changelog.d/README.md +28 -0
- 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 +6 -6
- package/recipes/SETUP.md +11 -5
- package/recipes/TRIUMVIRATE-SETUP.md +68 -14
- package/recipes/knowledge-miner.json +0 -30
- package/recipes/mock-test.json +19 -0
- package/recipes/triumvirate.json +6 -1
- package/scripts/audit-module-optins.ts +288 -0
- package/scripts/release-changelog.ts +210 -21
- package/src/cache-keepalive-log.ts +41 -0
- package/src/commands.ts +96 -0
- package/src/framework-strategy.ts +50 -4
- package/src/gate-telemetry.ts +106 -0
- package/src/headless.ts +24 -0
- package/src/index.ts +179 -64
- package/src/mcpl-config.ts +99 -1
- package/src/modules/fleet-module.ts +60 -1
- package/src/modules/fleet-types.ts +30 -1
- package/src/modules/identity-module.ts +310 -2
- package/src/modules/instructions-module.ts +265 -0
- package/src/modules/mcpl-admin-module.ts +89 -13
- 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/subagent-module.ts +18 -0
- package/src/modules/tts-relay-module.ts +33 -18
- package/src/modules/web-ui-module.ts +445 -894
- package/src/recipe.ts +787 -29
- 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 +1206 -0
- package/src/web/protocol.ts +75 -10
- package/src/workspace-mounts.ts +73 -0
- package/test/audit-module-optins.test.ts +174 -0
- package/test/cache-keepalive-log.test.ts +83 -0
- package/test/conversations-recipe.test.ts +142 -0
- package/test/fleet-panel-request.test.ts +90 -0
- package/test/framework-fkm-composition.test.ts +35 -3
- package/test/framework-strategy-defaults.test.ts +41 -0
- package/test/frontdesk-strategy.test.ts +25 -37
- package/test/gate-telemetry-adapter.test.ts +84 -0
- package/test/gate-telemetry.test.ts +91 -0
- package/test/headless-panel-request.test.ts +201 -0
- package/test/identity-and-surfaces.test.ts +212 -1
- package/test/instructions-module.test.ts +258 -0
- package/test/mcpl-admin-module.test.ts +64 -0
- package/test/mcpl-agent-overlay.test.ts +51 -3
- package/test/mcpl-child-env.test.ts +64 -0
- package/test/mock-headless-child.ts +14 -0
- package/test/nudge-command.test.ts +47 -0
- package/test/recipe-cache-keepalive.test.ts +59 -0
- package/test/recipe-compression-fallback.test.ts +19 -0
- package/test/recipe-hybrid-prose-routing.test.ts +12 -0
- package/test/recipe-instructions.test.ts +176 -0
- package/test/recipe-kv-unified.test.ts +87 -0
- package/test/recipe-mcp-source.test.ts +54 -0
- package/test/recipe-openai-compatible.test.ts +54 -0
- package/test/recipe-path-resolution.test.ts +19 -8
- package/test/recipe-provider.test.ts +14 -0
- package/test/recipe-save-unresolved.test.ts +244 -0
- package/test/recipe-source-only.test.ts +38 -0
- package/test/release-changelog.test.ts +202 -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/subagent-prose-routing.test.ts +109 -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/test/workspace-mounts.test.ts +68 -0
- package/web/src/App.tsx +160 -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/Health.tsx +61 -1
- 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,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
|
-
|
|
52
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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(
|
|
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',
|