@animalabs/connectome-host 0.7.3 → 0.7.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/CHANGELOG.md +156 -10
  2. package/HEADLESS-FLEET-PLAN.md +22 -0
  3. package/README.md +12 -1
  4. package/docs/AGENT-ONBOARDING.md +1 -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 +2 -2
  9. package/scripts/audit-module-optins.ts +288 -0
  10. package/src/framework-strategy.ts +13 -4
  11. package/src/headless.ts +14 -0
  12. package/src/index.ts +12 -9
  13. package/src/modules/fleet-module.ts +60 -1
  14. package/src/modules/fleet-types.ts +30 -1
  15. package/src/modules/mcpl-admin-module.ts +33 -4
  16. package/src/modules/retrieval-module.ts +249 -51
  17. package/src/modules/retrieval-trace-page.ts +254 -0
  18. package/src/modules/retrieval-trace.ts +904 -0
  19. package/src/modules/tts-relay-module.ts +33 -18
  20. package/src/modules/web-ui-module.ts +445 -894
  21. package/src/recipe.ts +55 -4
  22. package/src/retrieval-config.ts +39 -0
  23. package/src/strategies/frontdesk-strategy.ts +34 -125
  24. package/src/tui.ts +325 -54
  25. package/src/web/panel-data.ts +1187 -0
  26. package/src/web/protocol.ts +75 -10
  27. package/test/audit-module-optins.test.ts +167 -0
  28. package/test/fleet-panel-request.test.ts +90 -0
  29. package/test/framework-strategy-defaults.test.ts +22 -0
  30. package/test/frontdesk-strategy.test.ts +25 -37
  31. package/test/headless-panel-request.test.ts +201 -0
  32. package/test/mcpl-admin-module.test.ts +23 -0
  33. package/test/mock-headless-child.ts +14 -0
  34. package/test/retrieval-auth-loopback.test.ts +49 -0
  35. package/test/retrieval-config.test.ts +74 -0
  36. package/test/retrieval-module.test.ts +821 -0
  37. package/test/tui-format.test.ts +106 -0
  38. package/test/web-ui-context-coverage.test.ts +1 -1
  39. package/test/web-ui-module.test.ts +189 -3
  40. package/test/web-ui-observers.test.ts +8 -5
  41. package/test/web-ui-protocol.test.ts +0 -0
  42. package/web/src/App.tsx +159 -44
  43. package/web/src/Context.tsx +35 -8
  44. package/web/src/ContextDocument.tsx +20 -5
  45. package/web/src/Files.tsx +2 -8
  46. package/web/src/Lessons.tsx +2 -38
  47. package/web/src/Mcpl.tsx +80 -14
  48. package/web/src/Pins.tsx +5 -0
  49. package/web/src/Settings.tsx +5 -0
  50. 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,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
+ });
@@ -39,6 +39,28 @@ describe('standard-recipe memory defaults', () => {
39
39
  expect(config.summaryParticipant).toBe('Mira');
40
40
  });
41
41
 
42
+ test('frontdesk gets adaptive + kv-stable too (2026-08-03 clerk outage: hierarchical saturates)', () => {
43
+ const strategy = buildFrameworkStrategy(
44
+ recipe({ name: 'Desk', strategy: { type: 'frontdesk' } }),
45
+ 'some-model',
46
+ 'Europe/Kyiv',
47
+ );
48
+ const config = configView(strategy);
49
+ expect(config.adaptiveResolution).toBe(true);
50
+ expect(config.foldingStrategy).toBe('kv-stable');
51
+ });
52
+
53
+ test('frontdesk adaptiveResolution: false is the hierarchical rollback lever', () => {
54
+ const strategy = buildFrameworkStrategy(
55
+ recipe({ name: 'Desk', strategy: { type: 'frontdesk', adaptiveResolution: false } }),
56
+ 'some-model',
57
+ 'Europe/Kyiv',
58
+ );
59
+ const config = configView(strategy);
60
+ expect(config.adaptiveResolution).toBe(false);
61
+ expect(config.foldingStrategy).toBeUndefined();
62
+ });
63
+
42
64
  test('explicit recipe values override the defaults', () => {
43
65
  const strategy = buildFrameworkStrategy(
44
66
  recipe({
@@ -2,7 +2,6 @@ import { describe, test, expect } from 'bun:test';
2
2
  import type {
3
3
  MessageStoreView,
4
4
  StoredMessage,
5
- SummaryEntry,
6
5
  ContextEntry,
7
6
  } from '@animalabs/context-manager';
8
7
  import { FrontdeskStrategy } from '../src/strategies/frontdesk-strategy.js';
@@ -54,12 +53,12 @@ class TestFrontdesk extends FrontdeskStrategy {
54
53
  public pub_updateSalience(store: MessageStoreView) {
55
54
  this.updateSalience(store);
56
55
  }
57
- public pub_selectL1(l1: SummaryEntry[], budget: number, maxTokens: number) {
58
- return this.selectL1Summaries(l1, budget, maxTokens);
59
- }
60
56
  public pub_isTopicBoundary(a: StoredMessage, b: StoredMessage) {
61
57
  return this.isTopicBoundary(a, b);
62
58
  }
59
+ public pub_chunkBoundaryHint(a: StoredMessage, b: StoredMessage) {
60
+ return this.chunkBoundaryHint(a, b);
61
+ }
63
62
  public pub_compressionInstruction(chunkMessages: StoredMessage[], target: number) {
64
63
  // Build a minimal Chunk shape sufficient for getCompressionInstruction
65
64
  const chunk = {
@@ -293,44 +292,33 @@ describe('compression instruction', () => {
293
292
  });
294
293
  });
295
294
 
296
- describe('salience-biased L1 selection', () => {
297
- function summary(id: string, tokens: number, sourceIds: string[]): SummaryEntry {
298
- return {
299
- id,
300
- level: 1,
301
- content: '',
302
- tokens,
303
- sourceLevel: 0,
304
- sourceIds,
305
- sourceRange: { first: sourceIds[0] ?? '', last: sourceIds[sourceIds.length - 1] ?? '' },
306
- created: Date.now(),
307
- };
308
- }
295
+ describe('chunk boundary hint (topic-aware chunking via the base seam)', () => {
296
+ // The chunking mechanics record persistence, minimum-size, tool-pairing
297
+ // guard — are context-manager's contract, gated by its
298
+ // chunk-boundary-hook tests. What is conhost's to pin is the hint policy:
299
+ // frontdesk hints exactly at topic boundaries.
309
300
 
310
- test('prefers L1 summaries whose sources contain salient messages', () => {
301
+ test('hints a close when adjacent messages change topic on one channel', () => {
311
302
  const s = makeStrategy();
312
- const q = msg('User', 'what is x?', {}); // salient: unanswered
313
- s.pub_updateSalience(makeStore([q]));
314
-
315
- // Two L1 summaries, each 100 tokens; budget fits only one
316
- const routineFirst = summary('L1-0', 100, ['unrelated-1']);
317
- const salientSecond = summary('L1-1', 100, [q.id]);
318
-
319
- const { selected } = s.pub_selectL1([routineFirst, salientSecond], 100, 100);
320
- expect(selected).toHaveLength(1);
321
- expect(selected[0].id).toBe('L1-1');
303
+ const a = msg('User', 'x', { serverId: 'zulip', channelId: 'zulip:eng', topic: 'retries' });
304
+ const b = msg('User', 'y', { serverId: 'zulip', channelId: 'zulip:eng', topic: 'deploys' });
305
+ expect(s.pub_chunkBoundaryHint(a, b)).toBe(true);
322
306
  });
323
307
 
324
- test('falls back to routine summaries once salient are exhausted', () => {
308
+ test('does not hint within a topic or when topic metadata is absent', () => {
325
309
  const s = makeStrategy();
326
- const q = msg('User', 'what is x?', {});
327
- s.pub_updateSalience(makeStore([q]));
310
+ const a = msg('User', 'x', { serverId: 'zulip', channelId: 'zulip:eng', topic: 'retries' });
311
+ const b = msg('User', 'y', { serverId: 'zulip', channelId: 'zulip:eng', topic: 'retries' });
312
+ const bare = msg('User', 'z', {});
313
+ expect(s.pub_chunkBoundaryHint(a, b)).toBe(false);
314
+ expect(s.pub_chunkBoundaryHint(a, bare)).toBe(false);
315
+ expect(s.pub_chunkBoundaryHint(bare, a)).toBe(false);
316
+ });
328
317
 
329
- const salient = summary('L1-1', 50, [q.id]);
330
- const routine = summary('L1-2', 50, ['unrelated-1']);
331
- const { selected } = s.pub_selectL1([routine, salient], 200, 200);
332
- expect(selected).toHaveLength(2);
333
- expect(selected[0].id).toBe('L1-1'); // salient first
334
- expect(selected[1].id).toBe('L1-2');
318
+ test('hint agrees with isTopicBoundary across channels (same topic name, different channel)', () => {
319
+ const s = makeStrategy();
320
+ const a = msg('User', 'x', { serverId: 'zulip', channelId: 'zulip:eng', topic: 'retries' });
321
+ const b = msg('User', 'y', { serverId: 'zulip', channelId: 'zulip:ops', topic: 'retries' });
322
+ expect(s.pub_chunkBoundaryHint(a, b)).toBe(s.pub_isTopicBoundary(a, b));
335
323
  });
336
324
  });