@animalabs/connectome-host 0.7.2 → 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 +203 -10
- package/HEADLESS-FLEET-PLAN.md +22 -0
- package/README.md +22 -11
- package/docs/AGENT-ONBOARDING.md +20 -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 +3 -3
- package/scripts/audit-module-optins.ts +288 -0
- package/scripts/warmup-session.ts +17 -3
- package/src/codex-subscription-adapter.ts +13 -1
- package/src/framework-agent-config.ts +59 -4
- package/src/framework-strategy.ts +33 -3
- package/src/headless.ts +14 -0
- package/src/index.ts +95 -35
- package/src/logging-adapter.ts +13 -2
- package/src/mcpl-config.ts +8 -0
- package/src/modules/fleet-module.ts +60 -1
- package/src/modules/fleet-types.ts +30 -1
- package/src/modules/identity-module.ts +274 -0
- package/src/modules/mcpl-admin-module.ts +78 -5
- package/src/modules/observers-module.ts +12 -0
- package/src/modules/retrieval-module.ts +254 -52
- package/src/modules/retrieval-trace-page.ts +254 -0
- package/src/modules/retrieval-trace.ts +904 -0
- package/src/modules/settings-module.ts +28 -2
- package/src/modules/subscription-gc-module.ts +54 -1
- package/src/modules/tts-relay-module.ts +33 -18
- package/src/modules/web-ui-module.ts +445 -894
- package/src/recipe.ts +137 -12
- 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/bedrock-prompt-caching.test.ts +170 -0
- package/test/fleet-panel-request.test.ts +90 -0
- package/test/framework-strategy-defaults.test.ts +110 -0
- package/test/frontdesk-strategy.test.ts +25 -37
- package/test/headless-panel-request.test.ts +201 -0
- package/test/identity-and-surfaces.test.ts +157 -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/subscription-gc-module.test.ts +152 -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/bun.lock +345 -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
package/src/web/protocol.ts
CHANGED
|
@@ -323,6 +323,10 @@ export interface PeekMessage {
|
|
|
323
323
|
* surface a "module not loaded" hint rather than appearing broken. */
|
|
324
324
|
export interface LessonsListMessage {
|
|
325
325
|
type: 'lessons-list';
|
|
326
|
+
/** Which process this snapshot describes: 'local' or a fleet-child name.
|
|
327
|
+
* Lets the SPA drop stale replies after a scope switch instead of
|
|
328
|
+
* rendering child A's data under child B's header. */
|
|
329
|
+
scope?: string;
|
|
326
330
|
loaded: boolean;
|
|
327
331
|
lessons: Array<{
|
|
328
332
|
id: string;
|
|
@@ -339,6 +343,8 @@ export interface LessonsListMessage {
|
|
|
339
343
|
/** Workspace mount summary — response to request-workspace-mounts. */
|
|
340
344
|
export interface WorkspaceMountsMessage {
|
|
341
345
|
type: 'workspace-mounts';
|
|
346
|
+
/** Process this snapshot describes ('local' or fleet-child name). */
|
|
347
|
+
scope?: string;
|
|
342
348
|
/** True iff WorkspaceModule is loaded in the recipe. */
|
|
343
349
|
loaded: boolean;
|
|
344
350
|
mounts: Array<{
|
|
@@ -353,6 +359,8 @@ export interface WorkspaceMountsMessage {
|
|
|
353
359
|
/** Recursive file listing for one mount — response to request-workspace-tree. */
|
|
354
360
|
export interface WorkspaceTreeMessage {
|
|
355
361
|
type: 'workspace-tree';
|
|
362
|
+
/** Process this listing came from ('local' or fleet-child name). */
|
|
363
|
+
scope?: string;
|
|
356
364
|
mount: string;
|
|
357
365
|
entries: Array<{ path: string; size: number }>;
|
|
358
366
|
}
|
|
@@ -361,6 +369,8 @@ export interface WorkspaceTreeMessage {
|
|
|
361
369
|
* line-numbered (cat -n style) as returned by the workspace `read` tool. */
|
|
362
370
|
export interface WorkspaceFileMessage {
|
|
363
371
|
type: 'workspace-file';
|
|
372
|
+
/** Process this file came from ('local' or fleet-child name). */
|
|
373
|
+
scope?: string;
|
|
364
374
|
path: string;
|
|
365
375
|
totalLines: number;
|
|
366
376
|
fromLine: number;
|
|
@@ -376,6 +386,10 @@ export interface WorkspaceFileMessage {
|
|
|
376
386
|
* changes are file-only and require restart anyway. */
|
|
377
387
|
export interface McplListMessage {
|
|
378
388
|
type: 'mcpl-list';
|
|
389
|
+
/** Process whose MCPL view this is ('local' or fleet-child name). The
|
|
390
|
+
* registry FILE is shared cwd-wide, but which entries a process actually
|
|
391
|
+
* loads is per-recipe — see `live`. */
|
|
392
|
+
scope?: string;
|
|
379
393
|
/** Path to the config file (informational — operator may want to grep
|
|
380
394
|
* for it locally). */
|
|
381
395
|
configPath: string;
|
|
@@ -389,6 +403,18 @@ export interface McplListMessage {
|
|
|
389
403
|
enabledFeatureSets?: string[];
|
|
390
404
|
disabledFeatureSets?: string[];
|
|
391
405
|
}>;
|
|
406
|
+
/** Servers the scoped process actually LOADED (recipe opt-in + agent
|
|
407
|
+
* overlay), with live connection status from its framework. Absent on
|
|
408
|
+
* older hosts. This is the per-scope truth the file registry can't give:
|
|
409
|
+
* a conductor with zero MCPLs and a clerk with five share one file. */
|
|
410
|
+
live?: Array<{
|
|
411
|
+
id: string;
|
|
412
|
+
connected: boolean;
|
|
413
|
+
toolCount: number;
|
|
414
|
+
toolPrefix?: string;
|
|
415
|
+
/** command or url — whatever the transport targets. */
|
|
416
|
+
target?: string;
|
|
417
|
+
}>;
|
|
392
418
|
}
|
|
393
419
|
|
|
394
420
|
/**
|
|
@@ -399,6 +425,8 @@ export interface McplListMessage {
|
|
|
399
425
|
*/
|
|
400
426
|
export interface PinsListMessage {
|
|
401
427
|
type: 'pins-list';
|
|
428
|
+
/** Process these pins live in ('local' or fleet-child name). */
|
|
429
|
+
scope?: string;
|
|
402
430
|
agent: string;
|
|
403
431
|
pins: Array<{
|
|
404
432
|
id: string;
|
|
@@ -418,6 +446,11 @@ export interface PinsListMessage {
|
|
|
418
446
|
levelHonored: boolean;
|
|
419
447
|
/** Deepest fold level currently present, so the UI can bound level inputs. */
|
|
420
448
|
deepestLevel?: number;
|
|
449
|
+
/** Recent pinnable messages (REAL store ids) from the scoped process.
|
|
450
|
+
* Shipped for fleet-child scopes, where the SPA has no message window of
|
|
451
|
+
* its own to build a picker from; local scope keeps using the client-side
|
|
452
|
+
* candidate list. */
|
|
453
|
+
candidates?: Array<{ id: string; index: number; participant: string; text: string }>;
|
|
421
454
|
}
|
|
422
455
|
|
|
423
456
|
/**
|
|
@@ -429,6 +462,8 @@ export interface PinsListMessage {
|
|
|
429
462
|
*/
|
|
430
463
|
export interface SettingsStateMessage {
|
|
431
464
|
type: 'settings-state';
|
|
465
|
+
/** Process these settings belong to ('local' or fleet-child name). */
|
|
466
|
+
scope?: string;
|
|
432
467
|
agent: string;
|
|
433
468
|
/** Live values. `transition` is 'converging' while a paced descent runs. */
|
|
434
469
|
settings: {
|
|
@@ -661,11 +696,14 @@ export interface RequestLessonsMessage {
|
|
|
661
696
|
scope?: string;
|
|
662
697
|
}
|
|
663
698
|
|
|
664
|
-
/** Pull the
|
|
665
|
-
*
|
|
699
|
+
/** Pull the MCPL server view: the shared file registry plus the scoped
|
|
700
|
+
* process's LIVE loaded servers. Response is an `mcpl-list` envelope.
|
|
701
|
+
* Recipe-defined servers appear only in `live` — their definitions live in
|
|
666
702
|
* the recipe file and aren't editable from here. */
|
|
667
703
|
export interface RequestMcplMessage {
|
|
668
704
|
type: 'request-mcpl';
|
|
705
|
+
/** Fleet child name, or 'local'/undefined for the parent process. */
|
|
706
|
+
scope?: string;
|
|
669
707
|
}
|
|
670
708
|
|
|
671
709
|
/** Pull the Chronicle branch listing. Response is a `branches-list` envelope
|
|
@@ -699,9 +737,13 @@ export interface McplSetEnvMessage {
|
|
|
699
737
|
env: Record<string, string>;
|
|
700
738
|
}
|
|
701
739
|
|
|
702
|
-
/** Pull the agent's protected ranges. Response is a `pins-list` envelope.
|
|
740
|
+
/** Pull the agent's protected ranges. Response is a `pins-list` envelope.
|
|
741
|
+
* With a fleet-child `scope` the child also ships picker `candidates`
|
|
742
|
+
* (recent real store ids) since the SPA has no window into its store. */
|
|
703
743
|
export interface RequestPinsMessage {
|
|
704
744
|
type: 'request-pins';
|
|
745
|
+
/** Fleet child name, or 'local'/undefined for the parent process. */
|
|
746
|
+
scope?: string;
|
|
705
747
|
agent?: string;
|
|
706
748
|
}
|
|
707
749
|
|
|
@@ -723,6 +765,8 @@ export interface RequestPinsMessage {
|
|
|
723
765
|
*/
|
|
724
766
|
export interface PinAddMessage {
|
|
725
767
|
type: 'pin-add';
|
|
768
|
+
/** Fleet child name, or 'local'/undefined for the parent process. */
|
|
769
|
+
scope?: string;
|
|
726
770
|
agent?: string;
|
|
727
771
|
kind?: 'pin' | 'document';
|
|
728
772
|
firstMessageId: string;
|
|
@@ -735,6 +779,8 @@ export interface PinAddMessage {
|
|
|
735
779
|
|
|
736
780
|
export interface PinRemoveMessage {
|
|
737
781
|
type: 'pin-remove';
|
|
782
|
+
/** Fleet child name, or 'local'/undefined for the parent process. */
|
|
783
|
+
scope?: string;
|
|
738
784
|
agent?: string;
|
|
739
785
|
pinId: string;
|
|
740
786
|
}
|
|
@@ -743,7 +789,9 @@ export interface PinRemoveMessage {
|
|
|
743
789
|
* live-appliable vs restart-only. Response is a `settings-state` envelope. */
|
|
744
790
|
export interface RequestSettingsMessage {
|
|
745
791
|
type: 'request-settings';
|
|
746
|
-
/**
|
|
792
|
+
/** Fleet child name, or 'local'/undefined for the parent process. */
|
|
793
|
+
scope?: string;
|
|
794
|
+
/** Agent name; defaults to the scoped process's primary agent. */
|
|
747
795
|
agent?: string;
|
|
748
796
|
}
|
|
749
797
|
|
|
@@ -767,6 +815,8 @@ export interface RequestSettingsMessage {
|
|
|
767
815
|
*/
|
|
768
816
|
export interface SettingsUpdateMessage {
|
|
769
817
|
type: 'settings-update';
|
|
818
|
+
/** Fleet child name, or 'local'/undefined for the parent process. */
|
|
819
|
+
scope?: string;
|
|
770
820
|
agent?: string;
|
|
771
821
|
contextBudgetTokens?: number;
|
|
772
822
|
tailTokens?: number;
|
|
@@ -782,6 +832,8 @@ export interface SettingsUpdateMessage {
|
|
|
782
832
|
/** Revert named settings to their recipe values (all four when omitted). */
|
|
783
833
|
export interface SettingsResetMessage {
|
|
784
834
|
type: 'settings-reset';
|
|
835
|
+
/** Fleet child name, or 'local'/undefined for the parent process. */
|
|
836
|
+
scope?: string;
|
|
785
837
|
agent?: string;
|
|
786
838
|
keys?: string[];
|
|
787
839
|
persist?: boolean;
|
|
@@ -791,6 +843,8 @@ export interface SettingsResetMessage {
|
|
|
791
843
|
/** Abandon an in-flight paced descent, holding the current frontier. */
|
|
792
844
|
export interface SettingsCancelTransitionMessage {
|
|
793
845
|
type: 'settings-cancel-transition';
|
|
846
|
+
/** Fleet child name, or 'local'/undefined for the parent process. */
|
|
847
|
+
scope?: string;
|
|
794
848
|
agent?: string;
|
|
795
849
|
persist?: boolean;
|
|
796
850
|
}
|
|
@@ -898,9 +952,10 @@ export function isClientMessage(value: unknown): value is WebUiClientMessage {
|
|
|
898
952
|
switch (v.type) {
|
|
899
953
|
case 'ping':
|
|
900
954
|
case 'interrupt':
|
|
901
|
-
case 'request-mcpl':
|
|
902
955
|
case 'request-branches':
|
|
903
956
|
return true;
|
|
957
|
+
case 'request-mcpl':
|
|
958
|
+
return isOptionalScope(v.scope);
|
|
904
959
|
case 'user-message':
|
|
905
960
|
return typeof v.content === 'string';
|
|
906
961
|
case 'observer-hello': {
|
|
@@ -938,8 +993,9 @@ export function isClientMessage(value: unknown): value is WebUiClientMessage {
|
|
|
938
993
|
case 'mcpl-set-env':
|
|
939
994
|
return isValidMcplId(v.id) && isStringMap(v.env);
|
|
940
995
|
case 'request-pins':
|
|
941
|
-
return isOptionalNonEmptyString(v.agent);
|
|
996
|
+
return isOptionalScope(v.scope) && isOptionalNonEmptyString(v.agent);
|
|
942
997
|
case 'pin-add': {
|
|
998
|
+
if (!isOptionalScope(v.scope)) return false;
|
|
943
999
|
if (!isOptionalNonEmptyString(v.agent)) return false;
|
|
944
1000
|
if (!isNonEmptyString(v.firstMessageId)) return false;
|
|
945
1001
|
if (v.lastMessageId !== undefined && !isNonEmptyString(v.lastMessageId)) return false;
|
|
@@ -957,10 +1013,11 @@ export function isClientMessage(value: unknown): value is WebUiClientMessage {
|
|
|
957
1013
|
return true;
|
|
958
1014
|
}
|
|
959
1015
|
case 'pin-remove':
|
|
960
|
-
return isOptionalNonEmptyString(v.agent) && isNonEmptyString(v.pinId);
|
|
1016
|
+
return isOptionalScope(v.scope) && isOptionalNonEmptyString(v.agent) && isNonEmptyString(v.pinId);
|
|
961
1017
|
case 'request-settings':
|
|
962
|
-
return isOptionalNonEmptyString(v.agent);
|
|
1018
|
+
return isOptionalScope(v.scope) && isOptionalNonEmptyString(v.agent);
|
|
963
1019
|
case 'settings-update': {
|
|
1020
|
+
if (!isOptionalScope(v.scope)) return false;
|
|
964
1021
|
if (!isOptionalNonEmptyString(v.agent)) return false;
|
|
965
1022
|
if (!isOptionalBool(v.persist) || !isOptionalBool(v.notify) || !isOptionalBool(v.immediate)) return false;
|
|
966
1023
|
// Positive-integer-or-absent for each knob. The semantic gate (budget must
|
|
@@ -976,12 +1033,13 @@ export function isClientMessage(value: unknown): value is WebUiClientMessage {
|
|
|
976
1033
|
|| v.transitionPaceTokens !== undefined;
|
|
977
1034
|
}
|
|
978
1035
|
case 'settings-reset':
|
|
979
|
-
return
|
|
1036
|
+
return isOptionalScope(v.scope)
|
|
1037
|
+
&& isOptionalNonEmptyString(v.agent)
|
|
980
1038
|
&& isOptionalBool(v.persist)
|
|
981
1039
|
&& isOptionalBool(v.notify)
|
|
982
1040
|
&& isOptionalStringArray(v.keys);
|
|
983
1041
|
case 'settings-cancel-transition':
|
|
984
|
-
return isOptionalNonEmptyString(v.agent) && isOptionalBool(v.persist);
|
|
1042
|
+
return isOptionalScope(v.scope) && isOptionalNonEmptyString(v.agent) && isOptionalBool(v.persist);
|
|
985
1043
|
case 'request-workspace-mounts':
|
|
986
1044
|
return v.scope === undefined || typeof v.scope === 'string';
|
|
987
1045
|
case 'request-workspace-tree':
|
|
@@ -1041,6 +1099,13 @@ function isOptionalNonEmptyString(v: unknown): v is string | undefined {
|
|
|
1041
1099
|
return v === undefined || isNonEmptyString(v);
|
|
1042
1100
|
}
|
|
1043
1101
|
|
|
1102
|
+
/** Scope fields: 'local' or a fleet-child name. Same lax shape the existing
|
|
1103
|
+
* lessons/workspace requests use — the server resolves unknown children to
|
|
1104
|
+
* a clean error rather than the validator guessing the fleet roster. */
|
|
1105
|
+
function isOptionalScope(v: unknown): v is string | undefined {
|
|
1106
|
+
return v === undefined || typeof v === 'string';
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1044
1109
|
function isOptionalBool(v: unknown): v is boolean | undefined {
|
|
1045
1110
|
return v === undefined || typeof v === 'boolean';
|
|
1046
1111
|
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the report-only module opt-in audit (Sol's #32 closure gate,
|
|
3
|
+
* step 3). The contract under test: explicit enables are surfaced for an
|
|
4
|
+
* operator decision, omissions are explained as the defaults flip working,
|
|
5
|
+
* resolved .recipe.json snapshots are pointers rather than findings, and
|
|
6
|
+
* nothing is ever modified.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, test, expect } from 'bun:test';
|
|
10
|
+
import { mkdtempSync, writeFileSync, mkdirSync, readFileSync, statSync } from 'node:fs';
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
import { tmpdir } from 'node:os';
|
|
13
|
+
import {
|
|
14
|
+
classifyModule,
|
|
15
|
+
looksLikeRecipe,
|
|
16
|
+
auditRecipe,
|
|
17
|
+
auditPaths,
|
|
18
|
+
renderReport,
|
|
19
|
+
AUDITED_MODULES,
|
|
20
|
+
} from '../scripts/audit-module-optins.js';
|
|
21
|
+
|
|
22
|
+
function tmp(): string {
|
|
23
|
+
return mkdtempSync(join(tmpdir(), 'optin-audit-'));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const AGENT = { name: 'test-agent', systemPrompt: 'hi' };
|
|
27
|
+
|
|
28
|
+
describe('classifyModule', () => {
|
|
29
|
+
test('true and config objects are explicit enables', () => {
|
|
30
|
+
expect(classifyModule(true)).toBe('explicit-enable');
|
|
31
|
+
expect(classifyModule({ model: 'x' })).toBe('explicit-enable');
|
|
32
|
+
});
|
|
33
|
+
test('false is an explicit disable; absent is omitted', () => {
|
|
34
|
+
expect(classifyModule(false)).toBe('explicit-disable');
|
|
35
|
+
expect(classifyModule(undefined)).toBe('omitted');
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe('looksLikeRecipe', () => {
|
|
40
|
+
test('recipe-shaped objects pass; package.json-shaped ones do not', () => {
|
|
41
|
+
expect(looksLikeRecipe({ name: 'r', agent: AGENT })).toBe(true);
|
|
42
|
+
expect(looksLikeRecipe({ name: 'pkg', version: '1.0.0' })).toBe(false);
|
|
43
|
+
expect(looksLikeRecipe(['name'])).toBe(false);
|
|
44
|
+
expect(looksLikeRecipe(null)).toBe(false);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe('auditRecipe', () => {
|
|
49
|
+
test('classifies each audited module independently', () => {
|
|
50
|
+
const a = auditRecipe(
|
|
51
|
+
{ name: 'r', agent: AGENT, modules: { lessons: true, retrieval: false, wake: true } },
|
|
52
|
+
'r.json',
|
|
53
|
+
);
|
|
54
|
+
expect(a.states).toEqual({ subagents: 'omitted', lessons: 'explicit-enable', retrieval: 'explicit-disable' });
|
|
55
|
+
expect(a.isSnapshot).toBe(false);
|
|
56
|
+
expect(a.matchesOldDefaultBoilerplate).toBe(false);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('flags retrieval-without-lessons as inert after upgrade', () => {
|
|
60
|
+
const a = auditRecipe({ name: 'r', agent: AGENT, modules: { retrieval: true } }, 'r.json');
|
|
61
|
+
expect(a.inertRetrieval).toBe(true);
|
|
62
|
+
// With lessons enabled the combination is coherent, not inert.
|
|
63
|
+
const ok = auditRecipe({ name: 'r', agent: AGENT, modules: { retrieval: true, lessons: true } }, 'r.json');
|
|
64
|
+
expect(ok.inertRetrieval).toBe(false);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('a .recipe.json with all three true is old-default boilerplate; a source recipe never is', () => {
|
|
68
|
+
const modules = { subagents: true, lessons: true, retrieval: true };
|
|
69
|
+
const snap = auditRecipe({ name: 'r', agent: AGENT, modules }, 'data/.recipe.json');
|
|
70
|
+
expect(snap.isSnapshot).toBe(true);
|
|
71
|
+
expect(snap.matchesOldDefaultBoilerplate).toBe(true);
|
|
72
|
+
const src = auditRecipe({ name: 'r', agent: AGENT, modules }, 'data/recipe.json');
|
|
73
|
+
expect(src.matchesOldDefaultBoilerplate).toBe(false);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('collects local fleet children, resolves relative paths, skips URLs', () => {
|
|
77
|
+
const a = auditRecipe(
|
|
78
|
+
{
|
|
79
|
+
name: 'parent',
|
|
80
|
+
agent: AGENT,
|
|
81
|
+
modules: { fleet: { children: [{ recipe: 'child.json' }, { recipe: 'https://x.example/c.json' }] } },
|
|
82
|
+
},
|
|
83
|
+
'/deploy/recipes/parent.json',
|
|
84
|
+
);
|
|
85
|
+
expect(a.childRecipePaths).toEqual(['/deploy/recipes/child.json']);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe('auditPaths', () => {
|
|
90
|
+
test('scans directories, follows fleet children, skips non-recipes, modifies nothing', () => {
|
|
91
|
+
const dir = tmp();
|
|
92
|
+
const recipesDir = join(dir, 'recipes');
|
|
93
|
+
mkdirSync(recipesDir);
|
|
94
|
+
writeFileSync(
|
|
95
|
+
join(recipesDir, 'parent.json'),
|
|
96
|
+
JSON.stringify({
|
|
97
|
+
name: 'parent',
|
|
98
|
+
agent: AGENT,
|
|
99
|
+
modules: { lessons: true, fleet: { children: [{ recipe: '../elsewhere/child.json' }] } },
|
|
100
|
+
}),
|
|
101
|
+
);
|
|
102
|
+
// Child lives OUTSIDE the scanned dir — reachable only by following the reference.
|
|
103
|
+
mkdirSync(join(dir, 'elsewhere'));
|
|
104
|
+
const childPath = join(dir, 'elsewhere', 'child.json');
|
|
105
|
+
writeFileSync(childPath, JSON.stringify({ name: 'child', agent: AGENT, modules: { retrieval: true } }));
|
|
106
|
+
writeFileSync(join(recipesDir, 'package.json'), JSON.stringify({ name: 'pkg', version: '1.0.0' }));
|
|
107
|
+
writeFileSync(join(recipesDir, 'broken.json'), '{nope');
|
|
108
|
+
|
|
109
|
+
const before = statSync(join(recipesDir, 'parent.json')).mtimeMs;
|
|
110
|
+
const { audits, unreadable } = auditPaths([recipesDir]);
|
|
111
|
+
expect(unreadable).toEqual([]);
|
|
112
|
+
expect(audits.map((a) => a.recipeName).sort()).toEqual(['child', 'parent']);
|
|
113
|
+
// Report-only, literally: nothing was rewritten.
|
|
114
|
+
expect(statSync(join(recipesDir, 'parent.json')).mtimeMs).toBe(before);
|
|
115
|
+
expect(readFileSync(childPath, 'utf-8')).toContain('"retrieval":true');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('finds dot-prefixed .recipe.json snapshots in scanned directories', () => {
|
|
119
|
+
const dir = tmp();
|
|
120
|
+
writeFileSync(
|
|
121
|
+
join(dir, '.recipe.json'),
|
|
122
|
+
JSON.stringify({ name: 'snap', agent: AGENT, modules: { subagents: true, lessons: true, retrieval: true } }),
|
|
123
|
+
);
|
|
124
|
+
const { audits } = auditPaths([dir]);
|
|
125
|
+
expect(audits.length).toBe(1);
|
|
126
|
+
expect(audits[0].isSnapshot).toBe(true);
|
|
127
|
+
expect(audits[0].matchesOldDefaultBoilerplate).toBe(true);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('reports unreadable path arguments', () => {
|
|
131
|
+
const { audits, unreadable } = auditPaths([join(tmp(), 'nope.json')]);
|
|
132
|
+
expect(audits).toEqual([]);
|
|
133
|
+
expect(unreadable.length).toBe(1);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
describe('renderReport', () => {
|
|
138
|
+
test('explicit enables and inert retrieval need decisions; omissions and disables do not', () => {
|
|
139
|
+
const clean = auditRecipe({ name: 'clean', agent: AGENT, modules: { lessons: false } }, 'clean.json');
|
|
140
|
+
expect(renderReport([clean]).needsDecision).toBe(0);
|
|
141
|
+
|
|
142
|
+
const enabled = auditRecipe({ name: 'e', agent: AGENT, modules: { lessons: true, retrieval: true } }, 'e.json');
|
|
143
|
+
// two explicit enables, no inert-retrieval (lessons present)
|
|
144
|
+
expect(renderReport([enabled]).needsDecision).toBe(2);
|
|
145
|
+
|
|
146
|
+
const inert = auditRecipe({ name: 'i', agent: AGENT, modules: { retrieval: true } }, 'i.json');
|
|
147
|
+
// one explicit enable + one inert-retrieval warning
|
|
148
|
+
expect(renderReport([inert]).needsDecision).toBe(2);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test('snapshot enables are informational, never decision items', () => {
|
|
152
|
+
const snap = auditRecipe(
|
|
153
|
+
{ name: 's', agent: AGENT, modules: { subagents: true, lessons: true, retrieval: true } },
|
|
154
|
+
'data/.recipe.json',
|
|
155
|
+
);
|
|
156
|
+
const { text, needsDecision } = renderReport([snap]);
|
|
157
|
+
expect(needsDecision).toBe(0);
|
|
158
|
+
expect(text).toContain('not authoritative');
|
|
159
|
+
expect(text).toContain('old DEFAULT_RECIPE shape');
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test('every audited module appears in a source-recipe report', () => {
|
|
163
|
+
const a = auditRecipe({ name: 'r', agent: AGENT }, 'r.json');
|
|
164
|
+
const { text } = renderReport([a]);
|
|
165
|
+
for (const m of AUDITED_MODULES) expect(text).toContain(`${m}:`);
|
|
166
|
+
});
|
|
167
|
+
});
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bedrock prompt caching is model-gated, not suppressed transport-wide
|
|
3
|
+
* (issue #35). The deny-list is the pre-GA FAMILIES (Claude v2/instant,
|
|
4
|
+
* Claude 3, 3.5 Sonnet — Bedrock's caching GA never covered them), matched
|
|
5
|
+
* at the family boundary so dated ids, bare aliases, -latest, and
|
|
6
|
+
* inference-profile forms all resolve the same. Everything currently
|
|
7
|
+
* invokable on Bedrock caches (verified by live probe 2026-07-31; the
|
|
8
|
+
* 3.5 era is EOL there). The old blanket promptCaching:false made every
|
|
9
|
+
* modern Bedrock agent re-pay its full context on every call.
|
|
10
|
+
*
|
|
11
|
+
* The recipe override must land at BOTH layers — per-agent config for
|
|
12
|
+
* agent inference AND Membrane defaultPromptCaching for internal callers
|
|
13
|
+
* (compression/merge) — on every provider, per Sol's #69 review.
|
|
14
|
+
*/
|
|
15
|
+
import { describe, expect, test } from 'bun:test';
|
|
16
|
+
import { validateRecipe } from '../src/recipe.js';
|
|
17
|
+
import {
|
|
18
|
+
buildFrameworkAgentConfig,
|
|
19
|
+
bedrockModelSupportsPromptCaching,
|
|
20
|
+
membraneCachingOverride,
|
|
21
|
+
} from '../src/framework-agent-config.js';
|
|
22
|
+
|
|
23
|
+
function recipe(agent: Record<string, unknown> = {}) {
|
|
24
|
+
return { name: 'bedrock-caching-test', agent: { systemPrompt: 'sys', ...agent } };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe('bedrockModelSupportsPromptCaching', () => {
|
|
28
|
+
test('ids without GA caching support are gated off', () => {
|
|
29
|
+
for (const id of [
|
|
30
|
+
'anthropic.claude-3-opus-20240229-v1:0',
|
|
31
|
+
'anthropic.claude-3-sonnet-20240229-v1:0',
|
|
32
|
+
'anthropic.claude-3-haiku-20240307-v1:0',
|
|
33
|
+
'us.anthropic.claude-3-5-sonnet-20240620-v1:0',
|
|
34
|
+
// "3.6" — its caching was preview-only on Bedrock, dropped at GA
|
|
35
|
+
// (and the model itself is EOL there as of 7/2026).
|
|
36
|
+
'us.anthropic.claude-3-5-sonnet-20241022-v2:0',
|
|
37
|
+
'claude-3-5-sonnet-20241022',
|
|
38
|
+
'claude-3-opus-20240229',
|
|
39
|
+
'anthropic.claude-v2:1',
|
|
40
|
+
'anthropic.claude-instant-v1',
|
|
41
|
+
]) {
|
|
42
|
+
expect(bedrockModelSupportsPromptCaching(id)).toBe(false);
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('the deny-list matches families, not single dated spellings', () => {
|
|
47
|
+
// Sol's #69 review: bare aliases, -latest, and profile forms of the
|
|
48
|
+
// pre-GA families must not fall through to caching-on.
|
|
49
|
+
for (const id of [
|
|
50
|
+
'claude-3-opus',
|
|
51
|
+
'claude-3-opus-latest',
|
|
52
|
+
'claude-3-sonnet',
|
|
53
|
+
'claude-3-haiku-latest',
|
|
54
|
+
'claude-3-5-sonnet',
|
|
55
|
+
'claude-3-5-sonnet-latest',
|
|
56
|
+
'us.anthropic.claude-3-opus-latest-v1:0',
|
|
57
|
+
'apac.anthropic.claude-3-5-sonnet-v2:0',
|
|
58
|
+
'CLAUDE-3-OPUS',
|
|
59
|
+
]) {
|
|
60
|
+
expect(bedrockModelSupportsPromptCaching(id)).toBe(false);
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('models with GA Bedrock caching support stay on', () => {
|
|
65
|
+
for (const id of [
|
|
66
|
+
'anthropic.claude-3-5-haiku-20241022-v1:0',
|
|
67
|
+
'claude-3-5-haiku-latest',
|
|
68
|
+
'us.anthropic.claude-3-7-sonnet-20250219-v1:0',
|
|
69
|
+
'claude-3-7-sonnet',
|
|
70
|
+
'us.anthropic.claude-sonnet-4-20250514-v1:0',
|
|
71
|
+
'claude-sonnet-4-20250514',
|
|
72
|
+
'claude-opus-4-6',
|
|
73
|
+
'bedrock:us.anthropic.claude-opus-4-20250514-v1:0',
|
|
74
|
+
]) {
|
|
75
|
+
expect(bedrockModelSupportsPromptCaching(id)).toBe(true);
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('non-Claude Bedrock ids are conservatively off, not accidentally on', () => {
|
|
80
|
+
for (const id of ['amazon.nova-pro-v1:0', 'meta.llama3-70b-instruct-v1:0', 'mistral.mistral-large-2402-v1:0']) {
|
|
81
|
+
expect(bedrockModelSupportsPromptCaching(id)).toBe(false);
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe('bedrock agent config', () => {
|
|
87
|
+
test('GA-supported bedrock model gets caching on, without cacheTtl', () => {
|
|
88
|
+
const parsed = validateRecipe(recipe({ provider: 'bedrock' }));
|
|
89
|
+
const config = buildFrameworkAgentConfig(
|
|
90
|
+
parsed, 'agent', 'us.anthropic.claude-3-7-sonnet-20250219-v1:0', undefined,
|
|
91
|
+
);
|
|
92
|
+
expect(config.promptCaching).toBe(true);
|
|
93
|
+
// Bedrock rejects cache_control.ttl; the recipe default ('1h') must not
|
|
94
|
+
// ride along even though membrane also strips it.
|
|
95
|
+
expect(Object.prototype.hasOwnProperty.call(config, 'cacheTtl')).toBe(false);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('legacy bedrock model keeps caching suppressed', () => {
|
|
99
|
+
const parsed = validateRecipe(recipe({ provider: 'bedrock' }));
|
|
100
|
+
const config = buildFrameworkAgentConfig(
|
|
101
|
+
parsed, 'agent', 'us.anthropic.claude-3-5-sonnet-20240620-v1:0', undefined,
|
|
102
|
+
);
|
|
103
|
+
expect(config.promptCaching).toBe(false);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('recipe promptCaching overrides the model gate in both directions', () => {
|
|
107
|
+
const forcedOff = buildFrameworkAgentConfig(
|
|
108
|
+
validateRecipe(recipe({ provider: 'bedrock', promptCaching: false })),
|
|
109
|
+
'agent', 'us.anthropic.claude-3-7-sonnet-20250219-v1:0', undefined,
|
|
110
|
+
);
|
|
111
|
+
expect(forcedOff.promptCaching).toBe(false);
|
|
112
|
+
|
|
113
|
+
// Explicit opt-in for an account/region whose entitlements differ
|
|
114
|
+
// from the GA table (the gate is a default, not a hard ceiling).
|
|
115
|
+
const forcedOn = buildFrameworkAgentConfig(
|
|
116
|
+
validateRecipe(recipe({ provider: 'bedrock', promptCaching: true })),
|
|
117
|
+
'agent', 'us.anthropic.claude-3-5-sonnet-20241022-v2:0', undefined,
|
|
118
|
+
);
|
|
119
|
+
expect(forcedOn.promptCaching).toBe(true);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('explicit override lands at BOTH layers on any provider (composition)', () => {
|
|
123
|
+
// Anthropic recipe, explicit false: agent config off AND membrane
|
|
124
|
+
// default off — internal callers (compression/merge) read the latter.
|
|
125
|
+
const anthOff = validateRecipe(recipe({ promptCaching: false }));
|
|
126
|
+
expect(buildFrameworkAgentConfig(anthOff, 'agent', 'claude-opus-4-6', undefined).promptCaching).toBe(false);
|
|
127
|
+
expect(membraneCachingOverride(anthOff, 'claude-opus-4-6')).toEqual({ defaultPromptCaching: false });
|
|
128
|
+
|
|
129
|
+
// Bedrock explicit true and false: both layers agree with the override.
|
|
130
|
+
const bedOn = validateRecipe(recipe({ provider: 'bedrock', promptCaching: true }));
|
|
131
|
+
expect(buildFrameworkAgentConfig(bedOn, 'agent', 'us.anthropic.claude-3-5-sonnet-20241022-v2:0', undefined).promptCaching).toBe(true);
|
|
132
|
+
expect(membraneCachingOverride(bedOn, 'us.anthropic.claude-3-5-sonnet-20241022-v2:0')).toEqual({ defaultPromptCaching: true });
|
|
133
|
+
|
|
134
|
+
const bedOff = validateRecipe(recipe({ provider: 'bedrock', promptCaching: false }));
|
|
135
|
+
expect(buildFrameworkAgentConfig(bedOff, 'agent', 'us.anthropic.claude-opus-4-1-20250805-v1:0', undefined).promptCaching).toBe(false);
|
|
136
|
+
expect(membraneCachingOverride(bedOff, 'us.anthropic.claude-opus-4-1-20250805-v1:0')).toEqual({ defaultPromptCaching: false });
|
|
137
|
+
|
|
138
|
+
// Bedrock with no explicit override: the model gate supplies the
|
|
139
|
+
// answer at both layers too.
|
|
140
|
+
const bedGate = validateRecipe(recipe({ provider: 'bedrock' }));
|
|
141
|
+
expect(membraneCachingOverride(bedGate, 'us.anthropic.claude-3-7-sonnet-20250219-v1:0')).toEqual({ defaultPromptCaching: true });
|
|
142
|
+
expect(membraneCachingOverride(bedGate, 'anthropic.claude-3-opus-20240229-v1:0')).toEqual({ defaultPromptCaching: false });
|
|
143
|
+
|
|
144
|
+
// Anthropic with no override: BOTH layers stay silent — membrane's
|
|
145
|
+
// own default (on) governs, and we don't bake it in here.
|
|
146
|
+
const anthDefault = validateRecipe(recipe());
|
|
147
|
+
expect(Object.prototype.hasOwnProperty.call(
|
|
148
|
+
buildFrameworkAgentConfig(anthDefault, 'agent', 'claude-opus-4-6', undefined), 'promptCaching',
|
|
149
|
+
)).toBe(false);
|
|
150
|
+
expect(membraneCachingOverride(anthDefault, 'claude-opus-4-6')).toEqual({});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test('non-bedrock providers keep prior behavior: caching omitted, cacheTtl forwarded', () => {
|
|
154
|
+
const parsed = validateRecipe(recipe());
|
|
155
|
+
const config = buildFrameworkAgentConfig(parsed, 'agent', 'claude-opus-4-6', undefined);
|
|
156
|
+
expect(Object.prototype.hasOwnProperty.call(config, 'promptCaching')).toBe(false);
|
|
157
|
+
expect(config.cacheTtl).toBe('1h');
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test('recipe promptCaching applies on non-bedrock providers too', () => {
|
|
161
|
+
const parsed = validateRecipe(recipe({ promptCaching: false }));
|
|
162
|
+
const config = buildFrameworkAgentConfig(parsed, 'agent', 'claude-opus-4-6', undefined);
|
|
163
|
+
expect(config.promptCaching).toBe(false);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test('non-boolean promptCaching is rejected at validation', () => {
|
|
167
|
+
expect(() => validateRecipe(recipe({ promptCaching: 'yes' }))).toThrow(/promptCaching/);
|
|
168
|
+
expect(() => validateRecipe(recipe({ promptCaching: 1 }))).toThrow(/promptCaching/);
|
|
169
|
+
});
|
|
170
|
+
});
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FleetModule.requestPanel — the promise-based panel-op verb the WebUI's
|
|
3
|
+
* fleet-scope routing rides on (WS panels and the HTTP ?scope= proxy).
|
|
4
|
+
*
|
|
5
|
+
* Uses the mock headless child, which answers panel-request with:
|
|
6
|
+
* op 'hang' → nothing (timeout path)
|
|
7
|
+
* op 'fail' → {ok:false, error, status:418} (error passthrough)
|
|
8
|
+
* others → {ok:true, data:{op, echo:params}}
|
|
9
|
+
*/
|
|
10
|
+
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
|
11
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
12
|
+
import { tmpdir } from 'node:os';
|
|
13
|
+
import { join, dirname } from 'node:path';
|
|
14
|
+
import { fileURLToPath } from 'node:url';
|
|
15
|
+
import { FleetModule, type FleetModuleConfig } from '../src/modules/fleet-module.js';
|
|
16
|
+
|
|
17
|
+
const TEST_DIR = dirname(fileURLToPath(import.meta.url));
|
|
18
|
+
const MOCK_CHILD_PATH = join(TEST_DIR, 'mock-headless-child.ts');
|
|
19
|
+
|
|
20
|
+
function makeFleet(overrides: Partial<FleetModuleConfig> = {}): FleetModule {
|
|
21
|
+
return new FleetModule({
|
|
22
|
+
childIndexPath: MOCK_CHILD_PATH,
|
|
23
|
+
socketWaitTimeoutMs: 10_000,
|
|
24
|
+
readyTimeoutMs: 5_000,
|
|
25
|
+
gracefulShutdownMs: 3_000,
|
|
26
|
+
sigtermEscalationMs: 1_000,
|
|
27
|
+
...overrides,
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe('FleetModule.requestPanel', () => {
|
|
32
|
+
let tmpDir: string;
|
|
33
|
+
let fleet: FleetModule;
|
|
34
|
+
|
|
35
|
+
beforeAll(async () => {
|
|
36
|
+
tmpDir = mkdtempSync(join(tmpdir(), 'fkm-panel-'));
|
|
37
|
+
fleet = makeFleet();
|
|
38
|
+
const res = await fleet.handleToolCall({
|
|
39
|
+
id: 'launch-panelkid',
|
|
40
|
+
name: 'launch',
|
|
41
|
+
input: { name: 'panelkid', recipe: 'mock-recipe', dataDir: join(tmpDir, 'panelkid') },
|
|
42
|
+
});
|
|
43
|
+
if (!res.success) throw new Error(`launch failed: ${res.error}`);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
afterAll(async () => {
|
|
47
|
+
try { await fleet.stop(); } catch { /* noop */ }
|
|
48
|
+
try { rmSync(tmpDir, { recursive: true, force: true }); } catch { /* noop */ }
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('round-trips op + params and resolves the child data', async () => {
|
|
52
|
+
const result = await fleet.requestPanel('panelkid', 'settings', { agent: 'clerk' });
|
|
53
|
+
expect(result.ok).toBe(true);
|
|
54
|
+
expect(result.data).toEqual({ op: 'settings', echo: { agent: 'clerk' } });
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('concurrent requests correlate independently', async () => {
|
|
58
|
+
const [a, b] = await Promise.all([
|
|
59
|
+
fleet.requestPanel('panelkid', 'health', { n: 1 }),
|
|
60
|
+
fleet.requestPanel('panelkid', 'pins', { n: 2 }),
|
|
61
|
+
]);
|
|
62
|
+
expect(a.ok).toBe(true);
|
|
63
|
+
expect((a.data as { op: string }).op).toBe('health');
|
|
64
|
+
expect(b.ok).toBe(true);
|
|
65
|
+
expect((b.data as { op: string }).op).toBe('pins');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('child-reported failure passes error and status through', async () => {
|
|
69
|
+
const result = await fleet.requestPanel('panelkid', 'fail');
|
|
70
|
+
expect(result.ok).toBe(false);
|
|
71
|
+
expect(result.error).toBe('mock failure');
|
|
72
|
+
expect(result.status).toBe(418);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('unanswered request resolves ok:false with 504 after timeoutMs', async () => {
|
|
76
|
+
const started = Date.now();
|
|
77
|
+
const result = await fleet.requestPanel('panelkid', 'hang', undefined, 400);
|
|
78
|
+
expect(Date.now() - started).toBeGreaterThanOrEqual(380);
|
|
79
|
+
expect(result.ok).toBe(false);
|
|
80
|
+
expect(result.status).toBe(504);
|
|
81
|
+
expect(result.error).toContain('timed out');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('unknown child resolves ok:false with 404 without touching the wire', async () => {
|
|
85
|
+
const result = await fleet.requestPanel('nobody', 'settings');
|
|
86
|
+
expect(result.ok).toBe(false);
|
|
87
|
+
expect(result.status).toBe(404);
|
|
88
|
+
expect(result.error).toContain('nobody');
|
|
89
|
+
});
|
|
90
|
+
});
|