@animalabs/connectome-host 0.8.0 → 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.
@@ -902,6 +902,15 @@ export class WebUiModule implements Module {
902
902
 
903
903
  private async handleHttp(req: Request, server: ReturnType<typeof Bun.serve>): Promise<Response> {
904
904
  const url = new URL(req.url);
905
+
906
+ // Every HTTP route here is read-only — mutation happens over the WS.
907
+ // Wrong methods used to fall through to the same handlers (a POST
908
+ // /debug/context behaved exactly like the GET), which lies to API
909
+ // consumers probing the surface.
910
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
911
+ return new Response('Method Not Allowed', { status: 405, headers: { allow: 'GET, HEAD' } });
912
+ }
913
+
905
914
  const isRetrievalTraceRoute = url.pathname === '/debug/retrieval'
906
915
  || url.pathname === '/debug/retrieval/view';
907
916
 
@@ -1073,9 +1082,20 @@ export class WebUiModule implements Module {
1073
1082
  return this.serveWorkspaceFile(url.pathname.slice('/files/'.length));
1074
1083
  }
1075
1084
 
1076
- // Static SPA
1085
+ // API namespace exhausted: anything still unmatched under /debug/ is a
1086
+ // typo, wrong casing, or trailing slash — an honest 404 beats the SPA
1087
+ // shell with a 200, which API consumers can't tell from data. (This was
1088
+ // also the mechanism that swallowed real failures like the tokenizer
1089
+ // 404 — the client saw 200/HTML instead of the error.)
1090
+ if (url.pathname.startsWith('/debug/')) {
1091
+ return Response.json({ error: `Unknown debug route: ${url.pathname}` }, { status: 404 });
1092
+ }
1093
+
1094
+ // Static SPA. Bundle assets get real 404s: falling back to index.html
1095
+ // for a missing /assets/*.js serves HTML where the browser expects JS —
1096
+ // a blank page with a MIME error instead of a diagnosable miss.
1077
1097
  const requested = url.pathname === '/' ? '/index.html' : url.pathname;
1078
- return this.serveStatic(requested);
1098
+ return this.serveStatic(requested, { spaFallback: !url.pathname.startsWith('/assets/') });
1079
1099
  }
1080
1100
 
1081
1101
  /** The minimal app slice the shared panel-data layer needs, or null before
@@ -1281,7 +1301,11 @@ export class WebUiModule implements Module {
1281
1301
  }
1282
1302
  }
1283
1303
 
1284
- private async serveStatic(requestedPath: string): Promise<Response> {
1304
+ private async serveStatic(
1305
+ requestedPath: string,
1306
+ opts: { spaFallback?: boolean } = {},
1307
+ ): Promise<Response> {
1308
+ const spaFallback = opts.spaFallback ?? true;
1285
1309
  // Path containment: resolve and verify the result is still under staticRoot.
1286
1310
  // Plain startsWith without a separator is unsafe — both `<root>` and
1287
1311
  // `<root>-evil/...` pass `startsWith('<root>')`. The current callers
@@ -1297,11 +1321,15 @@ export class WebUiModule implements Module {
1297
1321
  try {
1298
1322
  const s = await stat(safePath);
1299
1323
  if (s.isDirectory()) {
1300
- return this.serveStatic(join(requestedPath, 'index.html'));
1324
+ return this.serveStatic(join(requestedPath, 'index.html'), opts);
1301
1325
  }
1302
1326
  const data = await readFile(safePath);
1303
1327
  return new Response(data, { headers: { 'content-type': mimeFor(safePath) } });
1304
1328
  } catch {
1329
+ // Missing bundle assets are honest misses, not SPA routes.
1330
+ if (!spaFallback) {
1331
+ return new Response('Not Found', { status: 404 });
1332
+ }
1305
1333
  // Fall back to index.html so the SPA can handle client-side routing.
1306
1334
  try {
1307
1335
  const indexPath = join(sharedServer!.staticRoot, 'index.html');
package/src/recipe.ts CHANGED
@@ -62,6 +62,14 @@ export interface RecipeStrategy {
62
62
  compressionMergeSourceOnly?: boolean;
63
63
  /** Preserve ordinary merge retries, then use target-only on the final attempt. */
64
64
  compressionMergeSourceOnlyFallback?: boolean;
65
+ /** Context Manager split-stitch L1 fallback rung (default off). */
66
+ compressionSplitFallback?: boolean;
67
+ /** Allow a single-message placeholder inside a split-stitched L1 (default off). */
68
+ compressionSplitPlaceholder?: boolean;
69
+ /** Split-stitch: max sub-calls per chunk (default 40). */
70
+ compressionSplitMaxCallsPerChunk?: number;
71
+ /** Split-stitch: max sub-calls per strategy instance per 10-minute in-memory window (default 80). */
72
+ compressionSplitMaxCallsPer10Min?: number;
65
73
  /** Token budget for prior recall-pair context in compression/merge
66
74
  * requests (Context Manager `compressionRecallBudgetTokens`). */
67
75
  compressionRecallBudgetTokens?: number;
@@ -69,6 +77,7 @@ export interface RecipeStrategy {
69
77
  recallHeaderTemplate?: string;
70
78
  targetChunkTokens?: number;
71
79
  mergeThreshold?: number;
80
+ mergeMaxSourceSpanMessages?: number;
72
81
  summaryTargetTokens?: number;
73
82
  /** Standing production target: keep the summary forest deep enough to fit
74
83
  * this budget, enabling a later live-budget descent with no fold-storm and
@@ -239,6 +248,8 @@ export interface RecipeAgent {
239
248
  * Default 'locus'.
240
249
  */
241
250
  proseRouting?: 'locus' | 'explicit' | 'hybrid' | 'disabled';
251
+ /** Default-off containment of whole-response prose wrappers for known tools. */
252
+ toolWrapperProseGuard?: boolean;
242
253
  /**
243
254
  * Extra Anthropic beta flags sent as the `anthropic-beta` header on every
244
255
  * request (e.g. `["context-1m-2025-08-07"]` for the 1M context window on
@@ -852,6 +863,30 @@ export interface RecipeCodeExecution {
852
863
  idleReclaimMs?: number;
853
864
  }
854
865
 
866
+ /**
867
+ * The subconscious resident (agent-framework FrameworkConfig.subconscious,
868
+ * issue agent-framework#77 — tune-out): a persistent same-model side-agent
869
+ * that receives traffic from channels the resident has tuned out and
870
+ * reports to them in its own voice. Passed through verbatim; the framework
871
+ * owns the defaults (name `Subconscious`, model = the resident's).
872
+ */
873
+ export interface RecipeSubconscious {
874
+ /** Master switch. Without it the `tune_out` tool is not offered. */
875
+ enabled: boolean;
876
+ /** Registry + participant name (default 'Subconscious'). */
877
+ name?: string;
878
+ /** Model id (default: the resident's model — same-model side-process). */
879
+ model?: string;
880
+ /** The voice/criteria mode block: report-shaped, second person toward the
881
+ * resident. Co-authored with the resident; canary before fleet use. */
882
+ systemPrompt: string;
883
+ /** Allow `speak_in_channel` (default false until the voice block has
884
+ * passed its canary). */
885
+ allowChannelSpeech?: boolean;
886
+ /** WindowedPassthroughStrategy re-anchor fraction in (0, 1] (default 0.5). */
887
+ reAnchorFraction?: number;
888
+ }
889
+
855
890
  /**
856
891
  * Per-channel conversation routing (agent-framework ConversationRouter):
857
892
  * the recipe's agent becomes a dormant "trunk" template, and qualifying
@@ -901,6 +936,8 @@ export interface Recipe {
901
936
  codeExecution?: RecipeCodeExecution;
902
937
  /** Per-channel conversation routing — fork-per-channel from this agent. */
903
938
  conversations?: RecipeConversations;
939
+ /** Tune-out's subconscious resident (agent-framework#77). */
940
+ subconscious?: RecipeSubconscious;
904
941
  }
905
942
 
906
943
  // ---------------------------------------------------------------------------
@@ -1294,6 +1331,10 @@ export function validateRecipe(raw: unknown): Recipe {
1294
1331
  throw new Error(`Recipe agent.proseRouting must be 'locus', 'explicit', 'hybrid', or 'disabled', got ${JSON.stringify(agent.proseRouting)}.`);
1295
1332
  }
1296
1333
 
1334
+ if (agent.toolWrapperProseGuard !== undefined && typeof agent.toolWrapperProseGuard !== 'boolean') {
1335
+ throw new Error(`Recipe agent.toolWrapperProseGuard must be a boolean, got ${JSON.stringify(agent.toolWrapperProseGuard)}.`);
1336
+ }
1337
+
1297
1338
  if (agent.timezone !== undefined) {
1298
1339
  if (typeof agent.timezone !== 'string' || !agent.timezone.trim()) {
1299
1340
  throw new Error('Recipe agent.timezone must be a non-empty IANA time zone string.');
@@ -1555,11 +1596,19 @@ export function validateRecipe(raw: unknown): Recipe {
1555
1596
  'compressionSourceOnlyFallback',
1556
1597
  'compressionMergeSourceOnly',
1557
1598
  'compressionMergeSourceOnlyFallback',
1599
+ 'compressionSplitFallback',
1600
+ 'compressionSplitPlaceholder',
1558
1601
  ] as const) {
1559
1602
  if (strategy[key] !== undefined && typeof strategy[key] !== 'boolean') {
1560
1603
  throw new Error(`Recipe agent.strategy.${key} must be a boolean.`);
1561
1604
  }
1562
1605
  }
1606
+ for (const key of ['compressionSplitMaxCallsPerChunk', 'compressionSplitMaxCallsPer10Min'] as const) {
1607
+ const value = strategy[key];
1608
+ if (value !== undefined && (typeof value !== 'number' || !Number.isSafeInteger(value) || value <= 0)) {
1609
+ throw new Error(`Recipe agent.strategy.${key} must be a positive safe integer.`);
1610
+ }
1611
+ }
1563
1612
  if (
1564
1613
  strategy.compressionRecallBudgetTokens !== undefined
1565
1614
  && (
@@ -1991,6 +2040,46 @@ export function validateRecipe(raw: unknown): Recipe {
1991
2040
  }
1992
2041
  }
1993
2042
 
2043
+ if (obj.subconscious !== undefined) {
2044
+ if (!obj.subconscious || typeof obj.subconscious !== 'object' || Array.isArray(obj.subconscious)) {
2045
+ throw new Error('Recipe subconscious must be an object.');
2046
+ }
2047
+ const sub = obj.subconscious as Record<string, unknown>;
2048
+ const allowedSubconsciousKeys = new Set([
2049
+ 'enabled', 'name', 'model', 'systemPrompt', 'allowChannelSpeech', 'reAnchorFraction',
2050
+ ]);
2051
+ for (const key of Object.keys(sub)) {
2052
+ if (!allowedSubconsciousKeys.has(key)) {
2053
+ throw new Error(
2054
+ `Recipe subconscious has unknown field ${JSON.stringify(key)} ` +
2055
+ `(expected one of: ${[...allowedSubconsciousKeys].join(', ')}).`,
2056
+ );
2057
+ }
2058
+ }
2059
+ if (typeof sub.enabled !== 'boolean') {
2060
+ throw new Error('Recipe subconscious.enabled must be a boolean.');
2061
+ }
2062
+ // The mode block is the subconscious's whole character; an enabled
2063
+ // subconscious without one would run on an empty system prompt.
2064
+ if (typeof sub.systemPrompt !== 'string' || !sub.systemPrompt.trim()) {
2065
+ throw new Error('Recipe subconscious.systemPrompt must be a non-empty string.');
2066
+ }
2067
+ for (const k of ['name', 'model'] as const) {
2068
+ if (sub[k] !== undefined && (typeof sub[k] !== 'string' || !(sub[k] as string).trim())) {
2069
+ throw new Error(`Recipe subconscious.${k} must be a non-empty string.`);
2070
+ }
2071
+ }
2072
+ if (sub.allowChannelSpeech !== undefined && typeof sub.allowChannelSpeech !== 'boolean') {
2073
+ throw new Error('Recipe subconscious.allowChannelSpeech must be a boolean.');
2074
+ }
2075
+ if (sub.reAnchorFraction !== undefined) {
2076
+ const f = sub.reAnchorFraction;
2077
+ if (typeof f !== 'number' || !(f > 0 && f <= 1)) {
2078
+ throw new Error('Recipe subconscious.reAnchorFraction must be a number in (0, 1].');
2079
+ }
2080
+ }
2081
+ }
2082
+
1994
2083
  if (obj.conversations !== undefined) {
1995
2084
  if (!obj.conversations || typeof obj.conversations !== 'object' || Array.isArray(obj.conversations)) {
1996
2085
  throw new Error('Recipe conversations must be an object.');
@@ -829,6 +829,23 @@ export function buildContextCoverageSnapshot(
829
829
  };
830
830
  }
831
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
+
832
849
  /** Summary-tree coverage and queued work, with no message or summary text. */
833
850
  export function buildContextCoverage(app: PanelAppRef, agentName: string): ContextCoverageSnapshot {
834
851
  const agent = requireAgent(app, agentName);
@@ -874,8 +891,15 @@ export async function buildContextMakeup(app: PanelAppRef, agentName: string): P
874
891
  : (typeof sysRaw === 'string' ? sysRaw : undefined);
875
892
 
876
893
  let exactTotalTokens: number | null = null;
877
- 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);
878
899
  let countSource = 'count_tokens';
900
+ if (!countModel) {
901
+ return { agent: agentName, stats, exactTotalTokens, countModel, countSource: 'count_tokens_unsupported_model' };
902
+ }
879
903
  try {
880
904
  const base = (process.env.ANTHROPIC_BASE_URL || 'https://api.anthropic.com').replace(/\/$/, '');
881
905
  const res = await fetch(base + '/v1/messages/count_tokens', {
@@ -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,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
+ });
@@ -61,6 +61,47 @@ describe('standard-recipe memory defaults', () => {
61
61
  expect(config.foldingStrategy).toBeUndefined();
62
62
  });
63
63
 
64
+ test('compressionSplitFallback / compressionSplitPlaceholder pass through and stay omitted when omitted', () => {
65
+ const on = buildFrameworkStrategy(
66
+ recipe({ name: 'Mira', strategy: { type: 'autobiographical', compressionSplitFallback: true, compressionSplitPlaceholder: true } }),
67
+ 'some-model',
68
+ 'America/Los_Angeles',
69
+ );
70
+ expect(configView(on).compressionSplitFallback).toBe(true);
71
+ expect(configView(on).compressionSplitPlaceholder).toBe(true);
72
+ const omitted = buildFrameworkStrategy(recipe({ name: 'Mira' }), 'some-model', 'America/Los_Angeles');
73
+ expect(configView(omitted).compressionSplitFallback).toBeUndefined();
74
+ expect(configView(omitted).compressionSplitPlaceholder).toBeUndefined();
75
+ expect(() => recipe({ name: 'Mira', strategy: { type: 'autobiographical', compressionSplitFallback: 'yes' } })).toThrow();
76
+ });
77
+
78
+ test('split-stitch cap knobs pass through and are validated as positive integers', () => {
79
+ const on = buildFrameworkStrategy(
80
+ recipe({ name: 'Mira', strategy: { type: 'autobiographical', compressionSplitFallback: true, compressionSplitMaxCallsPerChunk: 12, compressionSplitMaxCallsPer10Min: 30 } }),
81
+ 'some-model',
82
+ 'America/Los_Angeles',
83
+ );
84
+ expect(configView(on).compressionSplitMaxCallsPerChunk).toBe(12);
85
+ expect(configView(on).compressionSplitMaxCallsPer10Min).toBe(30);
86
+ expect(() => recipe({ name: 'Mira', strategy: { type: 'autobiographical', compressionSplitMaxCallsPerChunk: 0 } })).toThrow();
87
+ expect(() => recipe({ name: 'Mira', strategy: { type: 'autobiographical', compressionSplitMaxCallsPer10Min: 1.5 } })).toThrow();
88
+ });
89
+
90
+ test('mergeMaxSourceSpanMessages is passed through exactly and omission stays omitted (princess 2026-09-05: silently dropped, span guard stuck at the CM default)', () => {
91
+ const configured = buildFrameworkStrategy(
92
+ recipe({
93
+ name: 'Mira',
94
+ strategy: { type: 'autobiographical', mergeMaxSourceSpanMessages: 3000, mergeThreshold: 3 },
95
+ }),
96
+ 'some-model',
97
+ 'America/Los_Angeles',
98
+ );
99
+ expect(configView(configured).mergeMaxSourceSpanMessages).toBe(3000);
100
+ expect(configView(configured).mergeThreshold).toBe(3);
101
+ const omitted = buildFrameworkStrategy(recipe({ name: 'Mira' }), 'some-model', 'America/Los_Angeles');
102
+ expect(configView(omitted).mergeMaxSourceSpanMessages).toBeUndefined();
103
+ });
104
+
64
105
  test('productionBudgetTokens is passed through exactly and omission stays omitted', () => {
65
106
  const configured = buildFrameworkStrategy(
66
107
  recipe({
@@ -6,7 +6,7 @@
6
6
  * value went to the vendor's default endpoint.
7
7
  */
8
8
  import { describe, expect, it } from 'bun:test';
9
- import { gateTelemetryHeaders, originClass } from '../src/gate-telemetry.js';
9
+ import { gateTelemetryHeaders, originClass, stampedTrigger } from '../src/gate-telemetry.js';
10
10
 
11
11
  const debt = () => 7;
12
12
 
@@ -56,6 +56,11 @@ describe('gateTelemetryHeaders', () => {
56
56
  expect(gateTelemetryHeaders(env, debt)!({ lane: 'stream' })).toEqual({ 'x-gate-debt-chunks': 7 });
57
57
  });
58
58
 
59
+ it('a gate-batched wake reports its telemetry channel (wakeChannelId) when it set no locus', () => {
60
+ const fn = gateTelemetryHeaders(env, debt, () => ({ reason: 'gate:debounce', source: 'gate', wakeChannelId: 'discord:1:2', counterparty: 'discord:user:42' }));
61
+ expect(fn!({ lane: 'stream' })).toEqual({ 'x-gate-debt-chunks': 7, 'x-gate-origin': 'event', 'x-gate-channel': 'discord:1:2', 'x-gate-counterparty': 'discord:user:42' });
62
+ });
63
+
59
64
  it('heartbeat wakes carry no channel or counterparty (null → dropped by membrane)', () => {
60
65
  const fn = gateTelemetryHeaders(env, debt, () => ({ reason: 'heartbeat:tick', source: 'heartbeat' }));
61
66
  expect(fn!({ lane: 'stream' })).toEqual({ 'x-gate-debt-chunks': 7, 'x-gate-origin': 'heartbeat', 'x-gate-channel': null, 'x-gate-counterparty': null });
@@ -65,6 +70,7 @@ describe('gateTelemetryHeaders', () => {
65
70
  expect(originClass({ reason: 'heartbeat', source: 'heartbeat' })).toBe('heartbeat');
66
71
  expect(originClass({ reason: 'mail:incoming', source: 'fenmail' })).toBe('mail');
67
72
  expect(originClass({ reason: 'mcpl:push-event', source: 'discord' })).toBe('event');
73
+ expect(originClass({ reason: 'gate:debounce', source: 'gate' })).toBe('event'); // first live stamps came back raw
68
74
  expect(originClass({ reason: 'admin-nudge (someone)', source: 'framework' })).toBe('operator');
69
75
  expect(originClass({ reason: 'external-message', source: 'headless' })).toBe('operator');
70
76
  expect(originClass({ reason: 'external-message', source: 'tui' })).toBe('operator');
@@ -88,4 +94,31 @@ describe('gateTelemetryHeaders', () => {
88
94
  const fn = gateTelemetryHeaders(env, debt, () => ({ reason: 'mcpl:channel-incoming', source: 'discord', counterparty: 'x'.repeat(200) }));
89
95
  expect((fn!({ lane: 'stream' })['x-gate-counterparty'] as string).length).toBe(120);
90
96
  });
97
+
98
+ describe('stampedTrigger — one adapter serves every agent', () => {
99
+ const trig = (name: string) => ({ reason: 'gate:debounce', source: 'gate', counterparty: `discord:user:${name}` });
100
+
101
+ it('single agent: its trigger', () => {
102
+ expect(stampedTrigger({ agents: ['scout'], triggerOf: () => trig('scout') })).toEqual(trig('scout'));
103
+ });
104
+
105
+ it('primary + idle subconscious: the primary\'s trigger', () => {
106
+ expect(stampedTrigger({ agents: ['scout', 'scout-sub'], primary: 'scout',
107
+ triggerOf: (n) => (n === 'scout' ? trig('scout') : null) })).toEqual(trig('scout'));
108
+ });
109
+
110
+ it('primary and subconscious both mid-turn: withhold — the hook cannot tell whose request it decorates', () => {
111
+ expect(stampedTrigger({ agents: ['scout', 'scout-sub'], primary: 'scout',
112
+ triggerOf: (n) => trig(n) })).toBeNull();
113
+ });
114
+
115
+ it('only the subconscious mid-turn: nothing (its request is not the primary\'s turn)', () => {
116
+ expect(stampedTrigger({ agents: ['scout', 'scout-sub'], primary: 'scout',
117
+ triggerOf: (n) => (n === 'scout-sub' ? trig('sub') : null) })).toBeNull();
118
+ });
119
+
120
+ it('several agents and no primary known: withhold', () => {
121
+ expect(stampedTrigger({ agents: ['a', 'b'], triggerOf: () => trig('a') })).toBeNull();
122
+ });
123
+ });
91
124
  });