@animalabs/connectome-host 0.7.4 → 0.8.1

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 (67) hide show
  1. package/.env.example +12 -5
  2. package/.github/PULL_REQUEST_TEMPLATE.md +3 -2
  3. package/.github/workflows/changelog.yml +9 -4
  4. package/.github/workflows/ci.yml +5 -3
  5. package/.github/workflows/publish.yml +12 -6
  6. package/CHANGELOG.md +320 -0
  7. package/CONTRIBUTING.md +47 -19
  8. package/README.md +27 -0
  9. package/bun.lock +26 -32
  10. package/changelog.d/README.md +28 -0
  11. package/package.json +6 -6
  12. package/recipes/SETUP.md +11 -5
  13. package/recipes/TRIUMVIRATE-SETUP.md +68 -14
  14. package/recipes/knowledge-miner.json +0 -30
  15. package/recipes/mock-test.json +19 -0
  16. package/recipes/triumvirate.json +6 -1
  17. package/scripts/release-changelog.ts +210 -21
  18. package/src/cache-keepalive-log.ts +41 -0
  19. package/src/commands.ts +221 -32
  20. package/src/framework-agent-config.ts +3 -0
  21. package/src/framework-strategy.ts +42 -0
  22. package/src/gate-telemetry.ts +134 -0
  23. package/src/headless.ts +10 -0
  24. package/src/index.ts +194 -55
  25. package/src/mcpl-config.ts +99 -1
  26. package/src/modules/identity-module.ts +310 -2
  27. package/src/modules/instructions-module.ts +265 -0
  28. package/src/modules/mcpl-admin-module.ts +58 -11
  29. package/src/modules/subagent-module.ts +18 -0
  30. package/src/modules/web-ui-module.ts +32 -4
  31. package/src/recipe.ts +821 -25
  32. package/src/web/panel-data.ts +44 -1
  33. package/src/workspace-mounts.ts +73 -0
  34. package/test/audit-module-optins.test.ts +10 -3
  35. package/test/cache-keepalive-log.test.ts +83 -0
  36. package/test/commands-qa-family.test.ts +239 -0
  37. package/test/conversations-recipe.test.ts +142 -0
  38. package/test/count-tokens-model.test.ts +31 -0
  39. package/test/framework-fkm-composition.test.ts +35 -3
  40. package/test/framework-strategy-defaults.test.ts +60 -0
  41. package/test/gate-telemetry-adapter.test.ts +84 -0
  42. package/test/gate-telemetry.test.ts +124 -0
  43. package/test/identity-and-surfaces.test.ts +212 -1
  44. package/test/instructions-module.test.ts +258 -0
  45. package/test/mcpl-admin-module.test.ts +41 -0
  46. package/test/mcpl-agent-overlay.test.ts +51 -3
  47. package/test/mcpl-child-env.test.ts +64 -0
  48. package/test/nudge-command.test.ts +47 -0
  49. package/test/recipe-cache-keepalive.test.ts +59 -0
  50. package/test/recipe-compression-fallback.test.ts +19 -0
  51. package/test/recipe-hybrid-prose-routing.test.ts +12 -0
  52. package/test/recipe-instructions.test.ts +176 -0
  53. package/test/recipe-kv-unified.test.ts +87 -0
  54. package/test/recipe-mcp-source.test.ts +54 -0
  55. package/test/recipe-openai-compatible.test.ts +54 -0
  56. package/test/recipe-path-resolution.test.ts +19 -8
  57. package/test/recipe-provider.test.ts +14 -0
  58. package/test/recipe-save-unresolved.test.ts +244 -0
  59. package/test/recipe-source-only.test.ts +38 -0
  60. package/test/release-changelog.test.ts +202 -0
  61. package/test/subagent-prose-routing.test.ts +109 -0
  62. package/test/subconscious-recipe.test.ts +86 -0
  63. package/test/tool-wrapper-prose-guard-recipe.test.ts +37 -0
  64. package/test/web-ui-module.test.ts +41 -0
  65. package/test/workspace-mounts.test.ts +68 -0
  66. package/web/src/App.tsx +10 -0
  67. package/web/src/Health.tsx +61 -1
@@ -589,6 +589,25 @@ export function buildHealthSnapshot(app: PanelAppRef): Record<string, unknown> {
589
589
  } catch {
590
590
  // Health reads never throw.
591
591
  }
592
+ // Compression DEBT per agent — the single-authority reduction from cm
593
+ // (healthy / degraded / critical + the numbers behind it). Every wedge of
594
+ // the 2026-08-06 five-resident day was weeks of silently-failing
595
+ // compression surfacing as a sudden budget crisis; this block is what
596
+ // lets fleet-watch (and later the resident notice, af #99) catch the
597
+ // debt while it is still cheap.
598
+ try {
599
+ const debt: Record<string, unknown> = {};
600
+ for (const agent of app.framework.getAllAgents()) {
601
+ const strategy = (agent.getContextManager() as unknown as {
602
+ getStrategy?: () => { getCompressionDebt?: () => unknown };
603
+ }).getStrategy?.();
604
+ const d = strategy?.getCompressionDebt?.();
605
+ if (d) debt[(agent as unknown as { name: string }).name] = d;
606
+ }
607
+ (snapshot as Record<string, unknown>).compressionDebt = debt;
608
+ } catch {
609
+ // Health reads never throw.
610
+ }
592
611
  // Rendered context COMPOSITION per agent — head / raw middle / summaries
593
612
  // by level / tail, as actually emitted by the last compile. Sourced from
594
613
  // the strategy's own render stats (already computed in-process), so it is
@@ -810,6 +829,23 @@ export function buildContextCoverageSnapshot(
810
829
  };
811
830
  }
812
831
 
832
+ /**
833
+ * Best-effort mapping from an agent's configured model string to a bare
834
+ * Anthropic API model id for /v1/messages/count_tokens. Handles membrane /
835
+ * OpenRouter provider prefixes ("anthropic/claude-…", "…/anthropic/claude-…")
836
+ * and Bedrock ids ("us.anthropic.claude-…-v1:0"). Returns null for
837
+ * non-Anthropic models — exact counting is unsupported there, and reporting
838
+ * that honestly beats 404ing against a wrong tokenizer.
839
+ */
840
+ export function anthropicCountModel(agentModel: string | undefined): string | null {
841
+ if (!agentModel) return null;
842
+ let m = agentModel;
843
+ const slash = m.lastIndexOf('/');
844
+ if (slash >= 0) m = m.slice(slash + 1);
845
+ m = m.replace(/^(us|eu|apac)\./, '').replace(/^anthropic\./, '').replace(/-v\d+:\d+$/, '');
846
+ return m.startsWith('claude') ? m : null;
847
+ }
848
+
813
849
  /** Summary-tree coverage and queued work, with no message or summary text. */
814
850
  export function buildContextCoverage(app: PanelAppRef, agentName: string): ContextCoverageSnapshot {
815
851
  const agent = requireAgent(app, agentName);
@@ -855,8 +891,15 @@ export async function buildContextMakeup(app: PanelAppRef, agentName: string): P
855
891
  : (typeof sysRaw === 'string' ? sysRaw : undefined);
856
892
 
857
893
  let exactTotalTokens: number | null = null;
858
- const countModel = process.env.COUNT_TOKENS_MODEL || 'anthropic/claude-opus-4.5';
894
+ // Count against the model the agent actually runs, not a hardcoded id:
895
+ // a stale/foreign id 404s and exact counts silently degrade to null on
896
+ // every install. COUNT_TOKENS_MODEL stays as an explicit operator override.
897
+ const countModel = process.env.COUNT_TOKENS_MODEL
898
+ || anthropicCountModel((agent as { model?: string }).model);
859
899
  let countSource = 'count_tokens';
900
+ if (!countModel) {
901
+ return { agent: agentName, stats, exactTotalTokens, countModel, countSource: 'count_tokens_unsupported_model' };
902
+ }
860
903
  try {
861
904
  const base = (process.env.ANTHROPIC_BASE_URL || 'https://api.anthropic.com').replace(/\/$/, '');
862
905
  const res = await fetch(base + '/v1/messages/count_tokens', {
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Workspace mount construction — the single source of truth for which mounts
3
+ * a recipe's `modules.workspace` produces and with what flags.
4
+ *
5
+ * Used by BOTH the runtime (src/index.ts, to build the real WorkspaceModule
6
+ * config) and recipe validation (validateRecipe's instructions cross-check,
7
+ * which reasons about mount names, modes, and autoMaterialize). Keeping one
8
+ * builder prevents the validator from modeling a mount differently than the
9
+ * host constructs it — the exact split-brain the `_config` mount had when
10
+ * validation assumed it auto-materialized and the runtime built it without.
11
+ */
12
+
13
+ import { join, resolve } from 'node:path';
14
+ import type { MountConfig } from '@animalabs/agent-framework';
15
+ import type { RecipeModules, RecipeWorkspaceMount } from './recipe.js';
16
+
17
+ /**
18
+ * Build the mount list for a recipe's workspace declaration.
19
+ *
20
+ * Returns null when the workspace is disabled (`workspace: false`).
21
+ * `storePath` anchors the `_config` mount (session store config dir); pass
22
+ * any placeholder when only mount names/modes/flags are needed (validation).
23
+ */
24
+ export function buildWorkspaceMounts(
25
+ workspace: RecipeModules['workspace'],
26
+ storePath: string,
27
+ ): MountConfig[] | null {
28
+ if (workspace === false) return null;
29
+
30
+ let mounts: MountConfig[];
31
+ if (typeof workspace === 'object' && workspace.mounts) {
32
+ // Only pass fields the recipe explicitly provides; let WorkspaceModule
33
+ // default the rest. watch is overridden to 'never' since the host does
34
+ // not need chokidar filesystem watchers by default.
35
+ mounts = workspace.mounts.map((m: RecipeWorkspaceMount) => {
36
+ const mount: MountConfig = {
37
+ name: m.name,
38
+ path: resolve(m.path),
39
+ mode: m.mode ?? 'read-write',
40
+ watch: m.watch ?? 'never',
41
+ };
42
+ if (m.ignore) mount.ignore = m.ignore;
43
+ if (m.maxFileSize !== undefined) mount.maxFileSize = m.maxFileSize;
44
+ if (m.wakeOnChange !== undefined) mount.wakeOnChange = m.wakeOnChange;
45
+ if (m.autoMaterialize !== undefined) mount.autoMaterialize = m.autoMaterialize;
46
+ return mount;
47
+ });
48
+ } else {
49
+ // Default: read-only input mount + read-write products mount.
50
+ mounts = [
51
+ { name: 'input', path: resolve('./input'), mode: 'read-only', watch: 'never' },
52
+ { name: 'products', path: resolve('./output'), mode: 'read-write', watch: 'never' },
53
+ ];
54
+ }
55
+
56
+ // Config mount: version-controls gate.json (and future config files) via
57
+ // Chronicle. Opt-in via recipe: workspace.configMount = true. NOTE: this
58
+ // mount is deliberately NOT autoMaterialize — the host re-materializes it
59
+ // explicitly after branch-changing commands, and ordinary agent edits stay
60
+ // Chronicle-side until then. Anything that assumes `_config` disk content
61
+ // tracks agent edits is wrong (see the instructions-path validation).
62
+ const wantConfigMount = typeof workspace === 'object' && workspace.configMount;
63
+ if (wantConfigMount) {
64
+ mounts.push({
65
+ name: '_config',
66
+ path: resolve(join(storePath, 'config')),
67
+ mode: 'read-write',
68
+ watch: 'always',
69
+ });
70
+ }
71
+
72
+ return mounts;
73
+ }
@@ -8,7 +8,7 @@
8
8
 
9
9
  import { describe, test, expect } from 'bun:test';
10
10
  import { mkdtempSync, writeFileSync, mkdirSync, readFileSync, statSync } from 'node:fs';
11
- import { join } from 'node:path';
11
+ import { basename, dirname, isAbsolute, join, resolve } from 'node:path';
12
12
  import { tmpdir } from 'node:os';
13
13
  import {
14
14
  classifyModule,
@@ -74,15 +74,22 @@ describe('auditRecipe', () => {
74
74
  });
75
75
 
76
76
  test('collects local fleet children, resolves relative paths, skips URLs', () => {
77
+ const parentPath = resolve('/deploy/recipes/parent.json');
77
78
  const a = auditRecipe(
78
79
  {
79
80
  name: 'parent',
80
81
  agent: AGENT,
81
82
  modules: { fleet: { children: [{ recipe: 'child.json' }, { recipe: 'https://x.example/c.json' }] } },
82
83
  },
83
- '/deploy/recipes/parent.json',
84
+ parentPath,
84
85
  );
85
- expect(a.childRecipePaths).toEqual(['/deploy/recipes/child.json']);
86
+
87
+ // The https child is skipped; the relative one resolves beside the parent.
88
+ expect(a.childRecipePaths).toHaveLength(1);
89
+ const [childPath] = a.childRecipePaths;
90
+ expect(isAbsolute(childPath)).toBe(true);
91
+ expect(dirname(childPath)).toBe(dirname(parentPath));
92
+ expect(basename(childPath)).toBe('child.json');
86
93
  });
87
94
  });
88
95
 
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Keepalive events must ALL reach stderr.
3
+ *
4
+ * The host unit routes StandardError to service-stderr.log and leaves stdout on
5
+ * the journal, so an event written with console.log lands where no operator
6
+ * greps. That actually happened on fable-cm 2026-08-23: the keepalive refreshed
7
+ * a 523k prefix three times, correctly, while a monitor tailing
8
+ * service-stderr.log reported zero activity for three hours.
9
+ *
10
+ * These tests fail if anyone re-introduces severity routing.
11
+ */
12
+ import { describe, test, expect } from 'bun:test';
13
+ import {
14
+ logKeepaliveEvent,
15
+ formatKeepaliveEvent,
16
+ KEEPALIVE_LOG_PREFIX,
17
+ } from '../src/cache-keepalive-log.js';
18
+ import type { KeepaliveEvent } from '@animalabs/membrane';
19
+
20
+ const EVENTS: KeepaliveEvent[] = [
21
+ { type: 'refreshed', key: 'k1', lane: 'stream', readTokens: 523102, idleMs: 2987020 },
22
+ { type: 'expired', key: 'k1', idleMs: 21600000 },
23
+ { type: 'ineffective', key: 'k1', reason: 'wrote-instead-of-read', readTokens: 0, writeTokens: 523102 },
24
+ { type: 'skipped', key: 'k1', reason: 'no-1h-breakpoint' },
25
+ { type: 'error', key: 'k1', error: '400 invalid_request_error', consecutive: 1 },
26
+ { type: 'disabled', reason: '3 consecutive keepalive failures' },
27
+ ];
28
+
29
+ describe('keepalive event logging', () => {
30
+ test('every event type is written, none silently dropped', () => {
31
+ for (const event of EVENTS) {
32
+ const lines: string[] = [];
33
+ logKeepaliveEvent(event, (l) => lines.push(l));
34
+ expect(lines.length).toBe(1);
35
+ }
36
+ });
37
+
38
+ test('routine refreshes are logged, not filtered as noise', () => {
39
+ // The regression this guards: treating `refreshed` as too chatty to log
40
+ // leaves no positive evidence the feature ran at all.
41
+ const lines: string[] = [];
42
+ logKeepaliveEvent(EVENTS[0]!, (l) => lines.push(l));
43
+ expect(lines[0]).toContain('refreshed');
44
+ expect(lines[0]).toContain('523102');
45
+ });
46
+
47
+ test('lines carry the greppable prefix', () => {
48
+ for (const event of EVENTS) {
49
+ expect(formatKeepaliveEvent(event).startsWith(KEEPALIVE_LOG_PREFIX)).toBe(true);
50
+ }
51
+ });
52
+
53
+ test('the payload is machine-readable JSON after the prefix', () => {
54
+ const line = formatKeepaliveEvent(EVENTS[2]!);
55
+ const json = line.slice(KEEPALIVE_LOG_PREFIX.length).trim();
56
+ const parsed = JSON.parse(json) as { type: string; reason: string; writeTokens: number };
57
+ expect(parsed.type).toBe('ineffective');
58
+ expect(parsed.reason).toBe('wrote-instead-of-read');
59
+ expect(parsed.writeTokens).toBe(523102);
60
+ });
61
+
62
+ test('DEFAULT SINK IS STDERR for every event type — not stdout', () => {
63
+ // The actual bug. console.error -> stderr -> service-stderr.log;
64
+ // console.log -> stdout -> journal, where nobody looks.
65
+ const origLog = console.log;
66
+ const origErr = console.error;
67
+ const origWarn = console.warn;
68
+ const toStdout: string[] = [];
69
+ const toStderr: string[] = [];
70
+ try {
71
+ console.log = (...a: unknown[]) => { toStdout.push(String(a[0])); };
72
+ console.error = (...a: unknown[]) => { toStderr.push(String(a[0])); };
73
+ console.warn = (...a: unknown[]) => { toStderr.push(String(a[0])); };
74
+ for (const event of EVENTS) logKeepaliveEvent(event);
75
+ } finally {
76
+ console.log = origLog;
77
+ console.error = origErr;
78
+ console.warn = origWarn;
79
+ }
80
+ expect(toStderr.length).toBe(EVENTS.length);
81
+ expect(toStdout.length).toBe(0);
82
+ });
83
+ });
@@ -0,0 +1,239 @@
1
+ import { describe, test, expect, afterEach } from 'bun:test';
2
+ import { existsSync, readFileSync, writeFileSync, unlinkSync } from 'node:fs';
3
+ import { handleCommand, createBranchState } from '../src/commands.js';
4
+ import { DEFAULT_CONFIG_PATH } from '../src/mcpl-config.js';
5
+
6
+ // Regression tests for the QA-reported command family:
7
+ // - first-token argument parsing truncated multi-word names on
8
+ // /checkpoint, /restore, /checkout, /session switch, /session delete
9
+ // (while /session rename accepted them — making renamed sessions
10
+ // unreachable by name);
11
+ // - /session delete executed irreversibly with no confirmation;
12
+ // - /mcp add on an existing server silently wiped its env vars;
13
+ // - head-moving commands ran while a generation was in flight, letting
14
+ // the streaming reply commit onto the wrong branch (orphaned nodes);
15
+ // - /budget displayed small values as "0k" (50 → "0k") while rejecting 0.
16
+
17
+ interface StubMessage { id: string; participant: string; content: unknown[] }
18
+
19
+ function makeStubWorld(agentStatus: string | undefined = 'idle') {
20
+ const messagesByBranch = new Map<string, StubMessage[]>([['main', []]]);
21
+ let currentName = 'main';
22
+ let msgCounter = 0;
23
+
24
+ const cm = {
25
+ currentBranch: () => ({ id: currentName, name: currentName, head: messagesByBranch.get(currentName)!.length }),
26
+ listBranches: () => [...messagesByBranch.keys()].map(name => ({ id: name, name, head: messagesByBranch.get(name)!.length })),
27
+ queryMessages: (_q: unknown) => ({ messages: messagesByBranch.get(currentName)! }),
28
+ branchAt: (messageId: string, newName: string): string => {
29
+ const msgs = messagesByBranch.get(currentName)!;
30
+ const idx = msgs.findIndex(m => m.id === messageId);
31
+ if (idx === -1) throw new Error(`Message not found: ${messageId}`);
32
+ messagesByBranch.set(newName, msgs.slice(0, idx + 1));
33
+ return newName;
34
+ },
35
+ switchBranch: async (name: string): Promise<void> => {
36
+ if (!messagesByBranch.has(name)) throw new Error(`No such branch: ${name}`);
37
+ currentName = name;
38
+ },
39
+ };
40
+
41
+ const agent = {
42
+ name: 'stub-agent',
43
+ ...(agentStatus !== undefined ? { state: { status: agentStatus } } : {}),
44
+ getContextManager: () => cm,
45
+ };
46
+
47
+ const deleted: string[] = [];
48
+ const sessions = [
49
+ { id: 'aaaa1111', name: 'Renamed Multi Word Name', manuallyNamed: true, createdAt: 't', lastAccessedAt: 't', messageCount: 3 },
50
+ { id: 'bbbb2222', name: 'other', manuallyNamed: true, createdAt: 't', lastAccessedAt: 't', messageCount: 1 },
51
+ ];
52
+
53
+ const app = {
54
+ framework: {
55
+ getAgent: () => undefined,
56
+ getAllAgents: () => [agent],
57
+ getAllModules: () => [],
58
+ },
59
+ sessionManager: {
60
+ listSessions: () => sessions,
61
+ getActiveSession: () => sessions[1],
62
+ findSession: (nameOrId: string) =>
63
+ sessions.find(s => s.id === nameOrId || s.id.startsWith(nameOrId) || s.name === nameOrId),
64
+ deleteSession: (id: string) => { deleted.push(id); },
65
+ },
66
+ branchState: createBranchState(),
67
+ } as any;
68
+
69
+ const addMessage = (participant = 'user'): StubMessage => {
70
+ const msg: StubMessage = { id: `m${++msgCounter}`, participant, content: [] };
71
+ messagesByBranch.get(currentName)!.push(msg);
72
+ return msg;
73
+ };
74
+
75
+ const text = (r: { lines: Array<{ text: string }> }) => r.lines.map(l => l.text).join('\n');
76
+
77
+ return { cm, app, addMessage, deleted, text, currentName: () => currentName };
78
+ }
79
+
80
+ describe('multi-word names: rest-of-line parsing', () => {
81
+ test('/checkpoint saves the full multi-word name', () => {
82
+ const { app, addMessage } = makeStubWorld();
83
+ addMessage(); addMessage('agent');
84
+ handleCommand('/checkpoint my test point', app);
85
+ expect(app.branchState.checkpoints.has('my test point')).toBe(true);
86
+ expect(app.branchState.checkpoints.has('my')).toBe(false);
87
+ });
88
+
89
+ test('/restore finds a multi-word checkpoint', async () => {
90
+ const { app, addMessage, text } = makeStubWorld();
91
+ addMessage(); addMessage('agent');
92
+ handleCommand('/checkpoint some check point', app);
93
+ addMessage(); addMessage('agent');
94
+ const r = handleCommand('/restore some check point', app);
95
+ expect(text(r)).not.toContain('not found');
96
+ await r.asyncWork;
97
+ });
98
+
99
+ test('/session switch reaches a multi-word-renamed session', () => {
100
+ const { app, text } = makeStubWorld();
101
+ const r = handleCommand('/session switch Renamed Multi Word Name', app);
102
+ expect(text(r)).toContain('Switching to session');
103
+ expect(r.switchToSessionId).toBe('aaaa1111');
104
+ });
105
+
106
+ test('/checkout passes the full name through (not found reported honestly)', () => {
107
+ const { app, text } = makeStubWorld();
108
+ const r = handleCommand('/checkout my branch name', app);
109
+ expect(text(r)).toContain('Branch "my branch name" not found');
110
+ });
111
+ });
112
+
113
+ describe('/session delete confirmation', () => {
114
+ test('bare delete shows the match and asks for --confirm, deletes nothing', () => {
115
+ const { app, deleted, text } = makeStubWorld();
116
+ const r = handleCommand('/session delete other', app);
117
+ expect(deleted).toEqual([]);
118
+ expect(text(r)).toContain('irreversible');
119
+ expect(text(r)).toContain('--confirm');
120
+ expect(text(r)).toContain('bbbb2222');
121
+ });
122
+
123
+ test('delete with --confirm deletes', () => {
124
+ const { app, deleted } = makeStubWorld();
125
+ handleCommand('/session delete other --confirm', app);
126
+ expect(deleted).toEqual(['bbbb2222']);
127
+ });
128
+
129
+ test('multi-word name + --confirm parses both correctly', () => {
130
+ const { app, deleted } = makeStubWorld();
131
+ handleCommand('/session delete Renamed Multi Word Name --confirm', app);
132
+ expect(deleted).toEqual(['aaaa1111']);
133
+ });
134
+ });
135
+
136
+ describe('in-flight guard on head-moving commands', () => {
137
+ for (const cmd of ['/undo', '/redo', '/checkout main', '/newtopic', '/branchto m1']) {
138
+ test(`${cmd} is refused while streaming`, () => {
139
+ const { app, addMessage, text } = makeStubWorld('streaming');
140
+ addMessage(); addMessage('agent');
141
+ const r = handleCommand(cmd, app);
142
+ expect(text(r)).toContain('refused: a turn is in flight');
143
+ expect(r.asyncWork).toBeUndefined();
144
+ });
145
+ }
146
+
147
+ test('/undo proceeds when idle', () => {
148
+ const { app, addMessage, text } = makeStubWorld('idle');
149
+ addMessage(); addMessage('agent');
150
+ const r = handleCommand('/undo', app);
151
+ expect(text(r)).toContain('Undoing');
152
+ });
153
+
154
+ test('agents without state (stubs) are treated as idle', () => {
155
+ const { app, addMessage, text } = makeStubWorld(undefined);
156
+ addMessage(); addMessage('agent');
157
+ const r = handleCommand('/undo', app);
158
+ expect(text(r)).toContain('Undoing');
159
+ });
160
+ });
161
+
162
+ describe('checkpoint visibility', () => {
163
+ test('/branches lists checkpoints alongside branches', () => {
164
+ const { app, addMessage, text } = makeStubWorld();
165
+ addMessage(); addMessage('agent');
166
+ handleCommand('/checkpoint visible point', app);
167
+ const r = handleCommand('/branches', app);
168
+ expect(text(r)).toContain('Checkpoints (1');
169
+ expect(text(r)).toContain('visible point');
170
+ });
171
+
172
+ test('bare /checkpoint lists existing checkpoints', () => {
173
+ const { app, addMessage, text } = makeStubWorld();
174
+ addMessage(); addMessage('agent');
175
+ handleCommand('/checkpoint alpha', app);
176
+ const r = handleCommand('/checkpoint', app);
177
+ expect(text(r)).toContain('alpha');
178
+ });
179
+ });
180
+
181
+ describe('/budget honest display', () => {
182
+ function makeBudgetApp(maxStreamTokens: number, last = 0) {
183
+ return {
184
+ framework: {
185
+ getAgent: () => undefined,
186
+ getAllAgents: () => [{ name: 'a', maxStreamTokens, lastStreamInputTokens: last, getContextManager: () => null }],
187
+ getAllModules: () => [],
188
+ },
189
+ branchState: createBranchState(),
190
+ } as any;
191
+ }
192
+
193
+ test('small values display exactly, not as 0k', () => {
194
+ const app = makeBudgetApp(1000);
195
+ const r = handleCommand('/budget 50', app);
196
+ expect(r.lines[0]!.text).toContain('50 tokens');
197
+ expect(r.lines[0]!.text).not.toContain('0k');
198
+ });
199
+
200
+ test('show branch displays small budgets exactly', () => {
201
+ const app = makeBudgetApp(50, 12);
202
+ const r = handleCommand('/budget', app);
203
+ expect(r.lines.map(l => l.text).join('\n')).toContain('a: 50 (last: 12');
204
+ });
205
+ });
206
+
207
+ describe('/mcp add preserves env on overwrite', () => {
208
+ // handleMcp* read/write DEFAULT_CONFIG_PATH (cwd/mcpl-servers.json, which
209
+ // is gitignored). Skip rather than clobber if a real config exists.
210
+ const hadFile = existsSync(DEFAULT_CONFIG_PATH);
211
+ const original = hadFile ? readFileSync(DEFAULT_CONFIG_PATH, 'utf-8') : null;
212
+
213
+ afterEach(() => {
214
+ if (original !== null) writeFileSync(DEFAULT_CONFIG_PATH, original);
215
+ else if (existsSync(DEFAULT_CONFIG_PATH)) unlinkSync(DEFAULT_CONFIG_PATH);
216
+ });
217
+
218
+ test('overwriting the command keeps env vars and reports them', () => {
219
+ const app = { framework: { getAllAgents: () => [], getAllModules: () => [] }, branchState: createBranchState() } as any;
220
+ handleCommand('/mcp add envtest echo hello', app);
221
+ handleCommand('/mcp env envtest FOO=bar SECRET=hunter2', app);
222
+ const r = handleCommand('/mcp add envtest echo goodbye', app);
223
+
224
+ const saved = JSON.parse(readFileSync(DEFAULT_CONFIG_PATH, 'utf-8')).mcplServers;
225
+ expect(saved.envtest.env).toEqual({ FOO: 'bar', SECRET: 'hunter2' });
226
+ expect(saved.envtest.command).toBe('echo');
227
+ expect(saved.envtest.args).toEqual(['goodbye']);
228
+ expect(r.lines.map(l => l.text).join('\n')).toContain('kept env: FOO, SECRET');
229
+ });
230
+
231
+ test('old args are dropped when the new command line has none', () => {
232
+ const app = { framework: { getAllAgents: () => [], getAllModules: () => [] }, branchState: createBranchState() } as any;
233
+ handleCommand('/mcp add argtest echo one two', app);
234
+ handleCommand('/mcp add argtest ls', app);
235
+ const saved = JSON.parse(readFileSync(DEFAULT_CONFIG_PATH, 'utf-8')).mcplServers;
236
+ expect(saved.argtest.command).toBe('ls');
237
+ expect(saved.argtest.args).toBeUndefined();
238
+ });
239
+ });
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Recipe surface for per-channel conversation routing:
3
+ * schema validation of the `conversations` block, and the host-side mapping
4
+ * to the framework's ConversationRouterConfig (templateAgent auto-filled
5
+ * from the recipe agent, strategyFactory building fresh per-fork strategy
6
+ * instances from the recipe's own strategy config).
7
+ */
8
+ import { describe, test, expect } from 'bun:test';
9
+ import { validateRecipe, type Recipe } from '../src/recipe.js';
10
+ import { buildConversationsConfig } from '../src/framework-strategy.js';
11
+
12
+ function baseRecipe(extra: Record<string, unknown> = {}): Record<string, unknown> {
13
+ return {
14
+ name: 'Test',
15
+ agent: { name: 'sherlock', systemPrompt: 'test' },
16
+ ...extra,
17
+ };
18
+ }
19
+
20
+ describe('validateRecipe — conversations schema', () => {
21
+ test('absent block is accepted', () => {
22
+ expect(() => validateRecipe(baseRecipe())).not.toThrow();
23
+ });
24
+
25
+ test('full valid block is accepted', () => {
26
+ expect(() => validateRecipe(baseRecipe({
27
+ conversations: {
28
+ bind: { dm: 'always', groupDm: 'always', channel: 'mention' },
29
+ trigger: { dm: 'always', groupDm: 'mention', channel: 'mention' },
30
+ idleTtlMs: 12 * 60 * 60 * 1000,
31
+ closurePrompt: 'Finalize the case report and post the answer.',
32
+ agentPrefix: 'case',
33
+ },
34
+ }))).not.toThrow();
35
+ });
36
+
37
+ test('empty object is accepted (all defaults come from the framework)', () => {
38
+ expect(() => validateRecipe(baseRecipe({ conversations: {} }))).not.toThrow();
39
+ });
40
+
41
+ test('non-object block is rejected', () => {
42
+ expect(() => validateRecipe(baseRecipe({ conversations: true }))).toThrow(/must be an object/);
43
+ expect(() => validateRecipe(baseRecipe({ conversations: [] }))).toThrow(/must be an object/);
44
+ });
45
+
46
+ test('unknown top-level conversation field is rejected rather than silently ignored', () => {
47
+ expect(() => validateRecipe(baseRecipe({
48
+ conversations: { idleTTLms: 1000 },
49
+ }))).toThrow(/unknown field \"idleTTLms\"/);
50
+ });
51
+
52
+ test('unknown channel kind is rejected', () => {
53
+ expect(() => validateRecipe(baseRecipe({
54
+ conversations: { bind: { thread: 'always' } },
55
+ }))).toThrow(/unknown channel kind "thread"/);
56
+ });
57
+
58
+ test('invalid bind rule is rejected', () => {
59
+ expect(() => validateRecipe(baseRecipe({
60
+ conversations: { bind: { channel: 'sometimes' } },
61
+ }))).toThrow(/conversations\.bind\.channel/);
62
+ });
63
+
64
+ test("trigger rule 'never' is rejected (bind-only rule)", () => {
65
+ expect(() => validateRecipe(baseRecipe({
66
+ conversations: { trigger: { channel: 'never' } },
67
+ }))).toThrow(/conversations\.trigger\.channel/);
68
+ });
69
+
70
+ test('non-positive idleTtlMs is rejected', () => {
71
+ expect(() => validateRecipe(baseRecipe({ conversations: { idleTtlMs: 0 } })))
72
+ .toThrow(/idleTtlMs/);
73
+ expect(() => validateRecipe(baseRecipe({ conversations: { idleTtlMs: -5 } })))
74
+ .toThrow(/idleTtlMs/);
75
+ for (const bad of [Number.NaN, Number.POSITIVE_INFINITY, 1.5]) {
76
+ expect(() => validateRecipe(baseRecipe({ conversations: { idleTtlMs: bad } })))
77
+ .toThrow(/idleTtlMs/);
78
+ }
79
+ });
80
+
81
+ test('blank closurePrompt is rejected', () => {
82
+ expect(() => validateRecipe(baseRecipe({ conversations: { closurePrompt: ' ' } })))
83
+ .toThrow(/closurePrompt/);
84
+ });
85
+
86
+ test('agentPrefix with unsafe characters is rejected', () => {
87
+ for (const bad of ['with space', 'slash/py', 'dot.seg', '']) {
88
+ expect(() => validateRecipe(baseRecipe({ conversations: { agentPrefix: bad } })))
89
+ .toThrow(/agentPrefix/);
90
+ }
91
+ expect(() => validateRecipe(baseRecipe({ conversations: { agentPrefix: 'case-fork_2' } })))
92
+ .not.toThrow();
93
+ });
94
+ });
95
+
96
+ describe('buildConversationsConfig — recipe → FrameworkConfig mapping', () => {
97
+ const recipe = validateRecipe(baseRecipe({
98
+ conversations: {
99
+ bind: { channel: 'mention' },
100
+ idleTtlMs: 3600_000,
101
+ agentPrefix: 'case',
102
+ },
103
+ })) as Recipe;
104
+
105
+ test('returns undefined when the recipe has no conversations block', () => {
106
+ const bare = validateRecipe(baseRecipe()) as Recipe;
107
+ expect(buildConversationsConfig(bare, 'sherlock', 'model-x', 'UTC')).toBeUndefined();
108
+ });
109
+
110
+ test('templateAgent is auto-filled with the host agent name', () => {
111
+ const cfg = buildConversationsConfig(recipe, 'sherlock', 'model-x', 'UTC');
112
+ expect(cfg?.templateAgent).toBe('sherlock');
113
+ });
114
+
115
+ test('recipe fields pass through; omitted fields stay absent for framework defaults', () => {
116
+ const cfg = buildConversationsConfig(recipe, 'sherlock', 'model-x', 'UTC')!;
117
+ expect(cfg.bind).toEqual({ channel: 'mention' });
118
+ expect(cfg.idleTtlMs).toBe(3600_000);
119
+ expect(cfg.agentPrefix).toBe('case');
120
+ expect('trigger' in cfg).toBe(false);
121
+ expect('closurePrompt' in cfg).toBe(false);
122
+ });
123
+
124
+ test('strategyFactory builds a FRESH strategy instance per call', () => {
125
+ const cfg = buildConversationsConfig(recipe, 'sherlock', 'model-x', 'UTC')!;
126
+ expect(cfg.strategyFactory).toBeDefined();
127
+ const a = cfg.strategyFactory!();
128
+ const b = cfg.strategyFactory!();
129
+ expect(a).toBeDefined();
130
+ expect(b).toBeDefined();
131
+ expect(a).not.toBe(b);
132
+ });
133
+
134
+ test('strategyFactory honors the recipe strategy type', () => {
135
+ const passthrough = validateRecipe(baseRecipe({
136
+ agent: { name: 'p', systemPrompt: 't', strategy: { type: 'passthrough' } },
137
+ conversations: {},
138
+ })) as Recipe;
139
+ const cfg = buildConversationsConfig(passthrough, 'p', 'model-x', 'UTC')!;
140
+ expect(cfg.strategyFactory!().constructor.name).toBe('PassthroughStrategy');
141
+ });
142
+ });
@@ -0,0 +1,31 @@
1
+ import { describe, test, expect } from 'bun:test';
2
+ import { anthropicCountModel } from '../src/web/panel-data.js';
3
+
4
+ // Regression: the makeup panel's exact token count used to call count_tokens
5
+ // with a hardcoded provider-prefixed id, which the Anthropic endpoint 404s —
6
+ // so exactTotalTokens was silently null on every install. The count model is
7
+ // now derived from the model the agent actually runs.
8
+
9
+ describe('anthropicCountModel', () => {
10
+ test('strips a membrane/OpenRouter provider prefix', () => {
11
+ expect(anthropicCountModel('anthropic/claude-opus-4-6')).toBe('claude-opus-4-6');
12
+ });
13
+
14
+ test('passes a bare Anthropic id through', () => {
15
+ expect(anthropicCountModel('claude-sonnet-5')).toBe('claude-sonnet-5');
16
+ });
17
+
18
+ test('normalizes a Bedrock id (region + vendor prefix + version suffix)', () => {
19
+ expect(anthropicCountModel('us.anthropic.claude-3-sonnet-20240229-v1:0'))
20
+ .toBe('claude-3-sonnet-20240229');
21
+ });
22
+
23
+ test('returns null for non-Anthropic models', () => {
24
+ expect(anthropicCountModel('openai/gpt-5.6-sol')).toBeNull();
25
+ expect(anthropicCountModel('gemini-2.5-pro')).toBeNull();
26
+ });
27
+
28
+ test('returns null for undefined', () => {
29
+ expect(anthropicCountModel(undefined)).toBeNull();
30
+ });
31
+ });