@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.
- package/CHANGELOG.md +156 -10
- package/HEADLESS-FLEET-PLAN.md +22 -0
- package/README.md +12 -1
- package/docs/AGENT-ONBOARDING.md +1 -1
- package/docs/debug-context-api.md +2 -2
- package/docs/retrieval-traces.md +173 -0
- package/docs/webui-deployment.md +2 -1
- package/package.json +2 -2
- package/scripts/audit-module-optins.ts +288 -0
- package/src/framework-strategy.ts +13 -4
- package/src/headless.ts +14 -0
- package/src/index.ts +12 -9
- package/src/modules/fleet-module.ts +60 -1
- package/src/modules/fleet-types.ts +30 -1
- package/src/modules/mcpl-admin-module.ts +33 -4
- package/src/modules/retrieval-module.ts +249 -51
- package/src/modules/retrieval-trace-page.ts +254 -0
- package/src/modules/retrieval-trace.ts +904 -0
- package/src/modules/tts-relay-module.ts +33 -18
- package/src/modules/web-ui-module.ts +445 -894
- package/src/recipe.ts +55 -4
- 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/fleet-panel-request.test.ts +90 -0
- package/test/framework-strategy-defaults.test.ts +22 -0
- package/test/frontdesk-strategy.test.ts +25 -37
- package/test/headless-panel-request.test.ts +201 -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/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/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,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('
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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('
|
|
301
|
+
test('hints a close when adjacent messages change topic on one channel', () => {
|
|
311
302
|
const s = makeStrategy();
|
|
312
|
-
const
|
|
313
|
-
|
|
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('
|
|
308
|
+
test('does not hint within a topic or when topic metadata is absent', () => {
|
|
325
309
|
const s = makeStrategy();
|
|
326
|
-
const
|
|
327
|
-
|
|
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
|
-
|
|
330
|
-
const
|
|
331
|
-
const
|
|
332
|
-
|
|
333
|
-
expect(
|
|
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
|
});
|