@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.
Files changed (63) hide show
  1. package/CHANGELOG.md +203 -10
  2. package/HEADLESS-FLEET-PLAN.md +22 -0
  3. package/README.md +22 -11
  4. package/docs/AGENT-ONBOARDING.md +20 -1
  5. package/docs/debug-context-api.md +2 -2
  6. package/docs/retrieval-traces.md +173 -0
  7. package/docs/webui-deployment.md +2 -1
  8. package/package.json +3 -3
  9. package/scripts/audit-module-optins.ts +288 -0
  10. package/scripts/warmup-session.ts +17 -3
  11. package/src/codex-subscription-adapter.ts +13 -1
  12. package/src/framework-agent-config.ts +59 -4
  13. package/src/framework-strategy.ts +33 -3
  14. package/src/headless.ts +14 -0
  15. package/src/index.ts +95 -35
  16. package/src/logging-adapter.ts +13 -2
  17. package/src/mcpl-config.ts +8 -0
  18. package/src/modules/fleet-module.ts +60 -1
  19. package/src/modules/fleet-types.ts +30 -1
  20. package/src/modules/identity-module.ts +274 -0
  21. package/src/modules/mcpl-admin-module.ts +78 -5
  22. package/src/modules/observers-module.ts +12 -0
  23. package/src/modules/retrieval-module.ts +254 -52
  24. package/src/modules/retrieval-trace-page.ts +254 -0
  25. package/src/modules/retrieval-trace.ts +904 -0
  26. package/src/modules/settings-module.ts +28 -2
  27. package/src/modules/subscription-gc-module.ts +54 -1
  28. package/src/modules/tts-relay-module.ts +33 -18
  29. package/src/modules/web-ui-module.ts +445 -894
  30. package/src/recipe.ts +137 -12
  31. package/src/retrieval-config.ts +39 -0
  32. package/src/strategies/frontdesk-strategy.ts +34 -125
  33. package/src/tui.ts +325 -54
  34. package/src/web/panel-data.ts +1187 -0
  35. package/src/web/protocol.ts +75 -10
  36. package/test/audit-module-optins.test.ts +167 -0
  37. package/test/bedrock-prompt-caching.test.ts +170 -0
  38. package/test/fleet-panel-request.test.ts +90 -0
  39. package/test/framework-strategy-defaults.test.ts +110 -0
  40. package/test/frontdesk-strategy.test.ts +25 -37
  41. package/test/headless-panel-request.test.ts +201 -0
  42. package/test/identity-and-surfaces.test.ts +157 -0
  43. package/test/mcpl-admin-module.test.ts +23 -0
  44. package/test/mock-headless-child.ts +14 -0
  45. package/test/retrieval-auth-loopback.test.ts +49 -0
  46. package/test/retrieval-config.test.ts +74 -0
  47. package/test/retrieval-module.test.ts +821 -0
  48. package/test/subscription-gc-module.test.ts +152 -0
  49. package/test/tui-format.test.ts +106 -0
  50. package/test/web-ui-context-coverage.test.ts +1 -1
  51. package/test/web-ui-module.test.ts +189 -3
  52. package/test/web-ui-observers.test.ts +8 -5
  53. package/test/web-ui-protocol.test.ts +0 -0
  54. package/web/bun.lock +345 -0
  55. package/web/src/App.tsx +159 -44
  56. package/web/src/Context.tsx +35 -8
  57. package/web/src/ContextDocument.tsx +20 -5
  58. package/web/src/Files.tsx +2 -8
  59. package/web/src/Lessons.tsx +2 -38
  60. package/web/src/Mcpl.tsx +80 -14
  61. package/web/src/Pins.tsx +5 -0
  62. package/web/src/Settings.tsx +5 -0
  63. package/web/vite.config.ts +8 -2
@@ -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 configured MCPL servers from mcpl-servers.json. Response is an
665
- * `mcpl-list` envelope. Recipe-defined servers are excluded those live in
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
- /** Agent name; defaults to the recipe's primary agent. */
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 isOptionalNonEmptyString(v.agent)
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
+ });