@animalabs/connectome-host 0.3.10 → 0.5.0

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.
@@ -391,6 +391,41 @@ export interface McplListMessage {
391
391
  }>;
392
392
  }
393
393
 
394
+ /**
395
+ * Runtime context-settings snapshot.
396
+ *
397
+ * BROADCAST to every welcomed client on change — unlike `mcpl-list`, these are
398
+ * live process state, not a config file, so two operators must not see
399
+ * divergent values.
400
+ */
401
+ export interface SettingsStateMessage {
402
+ type: 'settings-state';
403
+ agent: string;
404
+ /** Live values. `transition` is 'converging' while a paced descent runs. */
405
+ settings: {
406
+ contextBudgetTokens: number;
407
+ tailTokens?: number;
408
+ transitionPaceTokens?: number;
409
+ sameRoundThinkTextPolicy?: string;
410
+ sameRoundThinkTextPolicySource?: string;
411
+ transition: 'stable' | 'converging' | 'blocked';
412
+ /** Why a descent is stuck: pace below floor, or protected context too big. */
413
+ transitionReason?: string;
414
+ };
415
+ /** Keys currently overridden away from the recipe (i.e. persisted). */
416
+ overrides: string[];
417
+ /** Which keys this build can apply live. Everything else needs a restart —
418
+ * the UI must show that split rather than implying all knobs are hot. */
419
+ hotKeys: string[];
420
+ /** False when the strategy is not hot-configurable at all (e.g. passthrough):
421
+ * the panel should go read-only rather than offer controls that will throw. */
422
+ hotConfigurable: boolean;
423
+ /** True when this build's context-manager exposes dry-run preview. Older
424
+ * context-manager versions resolve without it, and the panel must say
425
+ * "preview unavailable" rather than silently showing nothing. */
426
+ previewAvailable: boolean;
427
+ }
428
+
394
429
  /** A page of older history — response to `request-history`, routed only to
395
430
  * the requesting client and correlated by `corrId`. */
396
431
  export interface HistoryPageMessage {
@@ -474,6 +509,7 @@ export type WebUiServerMessage =
474
509
  | QuitConfirmRequiredMessage
475
510
  | LessonsListMessage
476
511
  | McplListMessage
512
+ | SettingsStateMessage
477
513
  | WorkspaceMountsMessage
478
514
  | WorkspaceTreeMessage
479
515
  | WorkspaceFileMessage
@@ -633,6 +669,58 @@ export interface McplSetEnvMessage {
633
669
  env: Record<string, string>;
634
670
  }
635
671
 
672
+ /** Pull the current runtime context settings for an agent, plus which knobs are
673
+ * live-appliable vs restart-only. Response is a `settings-state` envelope. */
674
+ export interface RequestSettingsMessage {
675
+ type: 'request-settings';
676
+ /** Agent name; defaults to the recipe's primary agent. */
677
+ agent?: string;
678
+ }
679
+
680
+ /**
681
+ * Apply runtime context settings live.
682
+ *
683
+ * Semantics that the UI must not misrepresent:
684
+ * - RAISING contextBudgetTokens applies immediately.
685
+ * - LOWERING it starts a PACED convergence (`transition: 'converging'`)
686
+ * rather than folding everything at once; it can be cancelled.
687
+ * - Only these keys are hot. Chunk size, head window, merge threshold and
688
+ * friends are recipe-and-restart only.
689
+ *
690
+ * `persist: false` makes the change ephemeral — live now, gone on restart —
691
+ * which is the mode for operator experiments. Default persists.
692
+ *
693
+ * `notify: true` pushes a notice into the agent's context. OFF by default and
694
+ * deliberately so: the notice is new text in the very context being tuned, so
695
+ * it invalidates the KV prefix and can itself trip a refusal classifier. The
696
+ * agent can always PULL current settings via its own `agent_settings` tool.
697
+ */
698
+ export interface SettingsUpdateMessage {
699
+ type: 'settings-update';
700
+ agent?: string;
701
+ contextBudgetTokens?: number;
702
+ tailTokens?: number;
703
+ transitionPaceTokens?: number;
704
+ persist?: boolean;
705
+ notify?: boolean;
706
+ }
707
+
708
+ /** Revert named settings to their recipe values (all four when omitted). */
709
+ export interface SettingsResetMessage {
710
+ type: 'settings-reset';
711
+ agent?: string;
712
+ keys?: string[];
713
+ persist?: boolean;
714
+ notify?: boolean;
715
+ }
716
+
717
+ /** Abandon an in-flight paced descent, holding the current frontier. */
718
+ export interface SettingsCancelTransitionMessage {
719
+ type: 'settings-cancel-transition';
720
+ agent?: string;
721
+ persist?: boolean;
722
+ }
723
+
636
724
  /** Pull the list of workspace mounts. Response is `workspace-mounts`.
637
725
  * Optional `scope` selects a fleet child instead of the parent. */
638
726
  export interface RequestWorkspaceMountsMessage {
@@ -706,6 +794,10 @@ export type WebUiClientMessage =
706
794
  | McplAddMessage
707
795
  | McplRemoveMessage
708
796
  | McplSetEnvMessage
797
+ | RequestSettingsMessage
798
+ | SettingsUpdateMessage
799
+ | SettingsResetMessage
800
+ | SettingsCancelTransitionMessage
709
801
  | RequestWorkspaceMountsMessage
710
802
  | RequestWorkspaceTreeMessage
711
803
  | RequestWorkspaceFileMessage
@@ -768,6 +860,30 @@ export function isClientMessage(value: unknown): value is WebUiClientMessage {
768
860
  return isValidMcplId(v.id);
769
861
  case 'mcpl-set-env':
770
862
  return isValidMcplId(v.id) && isStringMap(v.env);
863
+ case 'request-settings':
864
+ return isOptionalNonEmptyString(v.agent);
865
+ case 'settings-update': {
866
+ if (!isOptionalNonEmptyString(v.agent)) return false;
867
+ if (!isOptionalBool(v.persist) || !isOptionalBool(v.notify)) return false;
868
+ // Positive-integer-or-absent for each knob. The semantic gate (budget must
869
+ // exceed max response tokens; tail/pace need a hot-configurable strategy)
870
+ // lives in Agent.validateRuntimeSettingsPatch — this only guarantees the
871
+ // shape so that gate gets numbers instead of strings.
872
+ for (const k of ['contextBudgetTokens', 'tailTokens', 'transitionPaceTokens']) {
873
+ if (!isOptionalPositiveInt(v[k])) return false;
874
+ }
875
+ // Require at least one actual knob: an empty patch would throw downstream.
876
+ return v.contextBudgetTokens !== undefined
877
+ || v.tailTokens !== undefined
878
+ || v.transitionPaceTokens !== undefined;
879
+ }
880
+ case 'settings-reset':
881
+ return isOptionalNonEmptyString(v.agent)
882
+ && isOptionalBool(v.persist)
883
+ && isOptionalBool(v.notify)
884
+ && isOptionalStringArray(v.keys);
885
+ case 'settings-cancel-transition':
886
+ return isOptionalNonEmptyString(v.agent) && isOptionalBool(v.persist);
771
887
  case 'request-workspace-mounts':
772
888
  return v.scope === undefined || typeof v.scope === 'string';
773
889
  case 'request-workspace-tree':
@@ -822,3 +938,17 @@ function isOptionalStringArray(v: unknown): v is string[] | undefined {
822
938
  if (!Array.isArray(v)) return false;
823
939
  return v.every((x) => typeof x === 'string');
824
940
  }
941
+
942
+ function isOptionalNonEmptyString(v: unknown): v is string | undefined {
943
+ return v === undefined || isNonEmptyString(v);
944
+ }
945
+
946
+ function isOptionalBool(v: unknown): v is boolean | undefined {
947
+ return v === undefined || typeof v === 'boolean';
948
+ }
949
+
950
+ /** Token counts: safe positive integers only. Rejects strings, NaN, Infinity,
951
+ * fractions and negatives before they reach the settings validator. */
952
+ function isOptionalPositiveInt(v: unknown): v is number | undefined {
953
+ return v === undefined || (typeof v === 'number' && Number.isSafeInteger(v) && v > 0);
954
+ }
@@ -0,0 +1,118 @@
1
+ import { describe, test, expect } from 'bun:test';
2
+ import { handleCommand, createBranchState } from '../src/commands.js';
3
+
4
+ // Regression tests for /checkpoint + /restore POSITION semantics. The
5
+ // original bug: checkpoints stored only a branch name, so /restore switched
6
+ // to the branch HEAD — including everything after the checkpoint — rolling
7
+ // back nothing, silently. These tests run over a stub ContextManager that
8
+ // mimics Chronicle's contract: branchAt resolves ids against the CURRENT
9
+ // branch's view of the log and throws for unreachable ids.
10
+
11
+ interface StubMessage { id: string; participant: string; content: unknown[] }
12
+
13
+ function makeStubWorld() {
14
+ const messagesByBranch = new Map<string, StubMessage[]>([['main', []]]);
15
+ let currentName = 'main';
16
+ let msgCounter = 0;
17
+
18
+ const cm = {
19
+ currentBranch: () => ({ id: currentName, name: currentName, head: messagesByBranch.get(currentName)!.length }),
20
+ listBranches: () => [...messagesByBranch.keys()].map(name => ({ id: name, name, head: messagesByBranch.get(name)!.length })),
21
+ queryMessages: (_q: unknown) => ({ messages: messagesByBranch.get(currentName)! }),
22
+ branchAt: (messageId: string, newName: string): string => {
23
+ const msgs = messagesByBranch.get(currentName)!;
24
+ const idx = msgs.findIndex(m => m.id === messageId);
25
+ if (idx === -1) throw new Error(`Message not found: ${messageId}`);
26
+ messagesByBranch.set(newName, msgs.slice(0, idx + 1));
27
+ return newName;
28
+ },
29
+ switchBranch: async (name: string): Promise<void> => {
30
+ if (!messagesByBranch.has(name)) throw new Error(`No such branch: ${name}`);
31
+ currentName = name;
32
+ },
33
+ };
34
+
35
+ const addMessage = (participant = 'user'): StubMessage => {
36
+ const msg: StubMessage = { id: `m${++msgCounter}`, participant, content: [] };
37
+ messagesByBranch.get(currentName)!.push(msg);
38
+ return msg;
39
+ };
40
+
41
+ const app = {
42
+ framework: {
43
+ getAgent: () => undefined,
44
+ getAllAgents: () => [{ getContextManager: () => cm }],
45
+ getAllModules: () => [],
46
+ },
47
+ branchState: createBranchState(),
48
+ userMessageCount: 0,
49
+ } as any;
50
+
51
+ return { cm, app, addMessage, branchCount: () => messagesByBranch.size, currentName: () => currentName };
52
+ }
53
+
54
+ describe('/checkpoint + /restore position semantics', () => {
55
+ test('checkpoint records the last message id, not just the branch', () => {
56
+ const { app, addMessage } = makeStubWorld();
57
+ addMessage();
58
+ const last = addMessage('agent');
59
+ handleCommand('/checkpoint cp', app);
60
+ const point = app.branchState.checkpoints.get('cp');
61
+ expect(point.branchName).toBe('main');
62
+ expect(point.messageId).toBe(last.id);
63
+ });
64
+
65
+ test('restore rolls back to the checkpoint position, not the branch head', async () => {
66
+ const { app, addMessage, currentName } = makeStubWorld();
67
+ addMessage();
68
+ const cpMsg = addMessage('agent');
69
+ handleCommand('/checkpoint cp', app);
70
+ addMessage();
71
+ addMessage('agent'); // work after the checkpoint that must be rolled back
72
+
73
+ const result = handleCommand('/restore cp', app);
74
+ const outcome = await result.asyncWork!;
75
+ expect(outcome.branchChanged).toBe(true);
76
+ expect(currentName().startsWith('restore-cp-')).toBe(true);
77
+
78
+ const { messages } = (app.framework.getAllAgents()[0].getContextManager() as any).queryMessages({});
79
+ expect(messages[messages.length - 1].id).toBe(cpMsg.id);
80
+ });
81
+
82
+ test('restoring while already at the checkpoint is a no-op — no branch minted', async () => {
83
+ const { app, addMessage, branchCount } = makeStubWorld();
84
+ addMessage();
85
+ addMessage('agent');
86
+ handleCommand('/checkpoint cp', app);
87
+ addMessage();
88
+
89
+ await handleCommand('/restore cp', app).asyncWork!;
90
+ const branchesAfterFirst = branchCount();
91
+
92
+ // Second restore at the same position: the guard must hold even though
93
+ // we're now on restore-cp-… rather than the original branch (the guard
94
+ // used to compare branch names and died after the first restore,
95
+ // minting a sibling branch per repeat).
96
+ const second = handleCommand('/restore cp', app);
97
+ expect(second.asyncWork).toBeUndefined();
98
+ expect(second.lines[0]!.text).toContain('Already at checkpoint');
99
+ expect(branchCount()).toBe(branchesAfterFirst);
100
+ });
101
+
102
+ test('unreachable checkpoint position degrades to the branch head with a note', async () => {
103
+ const { app, cm, addMessage, currentName } = makeStubWorld();
104
+ const early = addMessage();
105
+ addMessage('agent');
106
+ handleCommand('/checkpoint cp', app);
107
+
108
+ // Simulate /undo: branch truncated BEFORE the checkpoint message, so the
109
+ // checkpoint id is unreachable from the current branch's view.
110
+ const undoBranch = cm.branchAt(early.id, 'undo-1');
111
+ await cm.switchBranch(undoBranch);
112
+
113
+ const result = handleCommand('/restore cp', app);
114
+ const outcome = await result.asyncWork!;
115
+ expect(currentName()).toBe('main');
116
+ expect(outcome.lines.some(l => l.text.includes('unreachable'))).toBe(true);
117
+ });
118
+ });
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Wire validation for the context-settings messages.
3
+ *
4
+ * These messages mutate a LIVE agent's context budget, so malformed payloads
5
+ * must be rejected at the protocol edge rather than reaching
6
+ * Agent.validateRuntimeSettingsPatch as strings/NaN/fractions.
7
+ */
8
+
9
+ import { describe, it, expect } from 'bun:test';
10
+ import { isClientMessage } from '../src/web/protocol.js';
11
+
12
+ describe('settings messages — wire validation', () => {
13
+ it('accepts a minimal budget update', () => {
14
+ expect(isClientMessage({ type: 'settings-update', contextBudgetTokens: 120_000 })).toBe(true);
15
+ });
16
+
17
+ it('accepts tail-only and pace-only updates', () => {
18
+ expect(isClientMessage({ type: 'settings-update', tailTokens: 30_000 })).toBe(true);
19
+ expect(isClientMessage({ type: 'settings-update', transitionPaceTokens: 8_000 })).toBe(true);
20
+ });
21
+
22
+ it('accepts persist/notify flags and an agent name', () => {
23
+ expect(isClientMessage({
24
+ type: 'settings-update',
25
+ contextBudgetTokens: 100_000,
26
+ persist: false,
27
+ notify: true,
28
+ agent: 'mythos',
29
+ })).toBe(true);
30
+ });
31
+
32
+ it('rejects an empty patch — it would throw downstream', () => {
33
+ expect(isClientMessage({ type: 'settings-update' })).toBe(false);
34
+ expect(isClientMessage({ type: 'settings-update', persist: true })).toBe(false);
35
+ });
36
+
37
+ it('rejects non-integer / non-positive / stringly token counts', () => {
38
+ for (const bad of ['100000', 100.5, -1, 0, NaN, Infinity, null, {}]) {
39
+ expect(isClientMessage({ type: 'settings-update', contextBudgetTokens: bad }))
40
+ .toBe(false);
41
+ }
42
+ });
43
+
44
+ it('rejects non-boolean flags and empty agent names', () => {
45
+ expect(isClientMessage({ type: 'settings-update', contextBudgetTokens: 1, persist: 'yes' })).toBe(false);
46
+ expect(isClientMessage({ type: 'settings-update', contextBudgetTokens: 1, notify: 1 })).toBe(false);
47
+ expect(isClientMessage({ type: 'settings-update', contextBudgetTokens: 1, agent: '' })).toBe(false);
48
+ });
49
+
50
+ it('validates reset and cancel shapes', () => {
51
+ expect(isClientMessage({ type: 'settings-reset' })).toBe(true);
52
+ expect(isClientMessage({ type: 'settings-reset', keys: ['contextBudgetTokens'] })).toBe(true);
53
+ expect(isClientMessage({ type: 'settings-reset', keys: 'contextBudgetTokens' })).toBe(false);
54
+ expect(isClientMessage({ type: 'settings-cancel-transition' })).toBe(true);
55
+ expect(isClientMessage({ type: 'settings-cancel-transition', persist: false })).toBe(true);
56
+ expect(isClientMessage({ type: 'settings-cancel-transition', agent: 42 })).toBe(false);
57
+ });
58
+
59
+ it('validates request-settings', () => {
60
+ expect(isClientMessage({ type: 'request-settings' })).toBe(true);
61
+ expect(isClientMessage({ type: 'request-settings', agent: 'mythos' })).toBe(true);
62
+ expect(isClientMessage({ type: 'request-settings', agent: 7 })).toBe(false);
63
+ });
64
+ });
@@ -94,7 +94,7 @@ describe('SubscriptionGcModule', () => {
94
94
  await m.stop();
95
95
  });
96
96
 
97
- test('"off" override pins a channel; counters persist across restart', async () => {
97
+ test('"off" override disables auto-close; counters persist across restart', async () => {
98
98
  const m = new SubscriptionGcModule({ defaultLimitChars: 5 });
99
99
  const { ctx, toolCalls, getState } = mockCtx();
100
100
  await m.start(ctx);
@@ -136,3 +136,158 @@ describe('SubscriptionGcModule', () => {
136
136
  await m2.stop();
137
137
  });
138
138
  });
139
+
140
+ describe('agent_settings extension (channel_idle_limits)', () => {
141
+ test('declares no standalone tools but keeps the old names routable', async () => {
142
+ const mod = new SubscriptionGcModule();
143
+ await mod.start(mockCtx().ctx as unknown as ModuleContext);
144
+ expect(mod.getTools()).toEqual([]);
145
+ // Undeclared ≠ dead: agent muscle memory routes via the old name.
146
+ const result = await mod.handleToolCall({
147
+ id: 't1',
148
+ name: 'set_channel_idle_limit',
149
+ input: { channelId: 'C1', limit: 'off' },
150
+ });
151
+ expect(result.success).toBe(true);
152
+ const ext = mod.getAgentSettingsExtension();
153
+ expect(ext.get('agent').channel_idle_limits).toEqual({ C1: 'off' });
154
+ });
155
+
156
+ test('get reports read-only status: default, counters, pins', async () => {
157
+ const mod = new SubscriptionGcModule({ defaultLimitChars: 10 });
158
+ const { ctx } = mockCtx();
159
+ await mod.start(ctx);
160
+ await mod.onProcess(ambient('c1', 'abc'), PS);
161
+ await mod.handleToolCall({
162
+ id: 't1',
163
+ name: 'pin_channel_idle_limit',
164
+ input: { channelId: 'C9', pinned: true },
165
+ });
166
+ const ext = mod.getAgentSettingsExtension();
167
+ expect(ext.get('agent')).toEqual({
168
+ channel_idle_limits: {},
169
+ channel_idle_default: 10,
170
+ channel_idle_counters: { 'discord:g1:c1': 3 },
171
+ channel_idle_pinned: ['C9'],
172
+ });
173
+ await mod.stop();
174
+ });
175
+
176
+ test('update merges per entry: number, off, default/null', async () => {
177
+ const mod = new SubscriptionGcModule();
178
+ await mod.start(mockCtx().ctx as unknown as ModuleContext);
179
+ const ext = mod.getAgentSettingsExtension();
180
+ ext.update('agent', { channel_idle_limits: { A: 5000, B: 'off', C: '12000' } });
181
+ expect(ext.get('agent').channel_idle_limits).toEqual({ A: 5000, B: 'off', C: 12000 });
182
+ // merge: only mentioned entries change; default/null clear
183
+ ext.update('agent', { channel_idle_limits: { A: 'default', B: null } });
184
+ expect(ext.get('agent').channel_idle_limits).toEqual({ C: 12000 });
185
+ });
186
+
187
+ test('update rejects junk values with a clear error', async () => {
188
+ const mod = new SubscriptionGcModule();
189
+ await mod.start(mockCtx().ctx as unknown as ModuleContext);
190
+ const ext = mod.getAgentSettingsExtension();
191
+ expect(() => ext.update('agent', { channel_idle_limits: { A: -3 } })).toThrow(/positive/);
192
+ expect(() => ext.update('agent', { channel_idle_limits: 'off' })).toThrow(/object/);
193
+ expect(() => ext.update('agent', { channel_idle_limits: ['A'] })).toThrow(/object/);
194
+ });
195
+
196
+ test('a partially-invalid patch applies nothing', async () => {
197
+ const mod = new SubscriptionGcModule();
198
+ const { ctx, getState } = mockCtx();
199
+ await mod.start(ctx);
200
+ const ext = mod.getAgentSettingsExtension();
201
+ // A is valid, B is junk: the whole patch must be rejected — per-entry
202
+ // application would leave A live (limitFor reads the map directly) and
203
+ // persisted by the next flush, after the agent was told the update failed.
204
+ expect(() => ext.update('agent', { channel_idle_limits: { A: 5000, B: -3 } })).toThrow(
205
+ /positive/,
206
+ );
207
+ expect(ext.get('agent').channel_idle_limits).toEqual({});
208
+ const persisted = getState() as { overrides: Record<string, unknown> } | null;
209
+ expect(persisted?.overrides ?? {}).toEqual({});
210
+ });
211
+
212
+ test('reset clears all overrides', async () => {
213
+ const mod = new SubscriptionGcModule();
214
+ await mod.start(mockCtx().ctx as unknown as ModuleContext);
215
+ const ext = mod.getAgentSettingsExtension();
216
+ ext.update('agent', { channel_idle_limits: { A: 5000, B: 'off' } });
217
+ expect(ext.reset('agent').channel_idle_limits).toEqual({});
218
+ // keyed reset also clears
219
+ ext.update('agent', { channel_idle_limits: { A: 5000 } });
220
+ expect(ext.reset('agent', ['channel_idle_limits']).channel_idle_limits).toEqual({});
221
+ // reset for other keys leaves ours alone
222
+ ext.update('agent', { channel_idle_limits: { A: 5000 } });
223
+ expect(ext.reset('agent', ['reasoning_enabled']).channel_idle_limits).toEqual({ A: 5000 });
224
+ });
225
+ });
226
+
227
+ describe('system pins (pin_channel_idle_limit)', () => {
228
+ test('reset-all clears overrides but not pins; pinned channel stays open', async () => {
229
+ const mod = new SubscriptionGcModule({ defaultLimitChars: 5 });
230
+ const { ctx, toolCalls } = mockCtx();
231
+ await mod.start(ctx);
232
+ // ChannelMode's debounced-mode step 3.
233
+ await mod.handleToolCall({
234
+ id: 't1',
235
+ name: 'pin_channel_idle_limit',
236
+ input: { channelId: 'discord:g1:c1', pinned: true },
237
+ });
238
+ const ext = mod.getAgentSettingsExtension();
239
+ ext.update('agent', { channel_idle_limits: { A: 5000 } });
240
+ // Blanket `agent_settings reset` (e.g. to restore default reasoning).
241
+ const after = ext.reset('agent');
242
+ expect(after.channel_idle_limits).toEqual({});
243
+ expect(after.channel_idle_pinned).toEqual(['discord:g1:c1']);
244
+ // The pinned channel must NOT auto-close after the reset.
245
+ await mod.onProcess(ambient('c1', 'waytoolongambienttraffic'), PS);
246
+ expect(toolCalls.length).toBe(0);
247
+ await mod.stop();
248
+ });
249
+
250
+ test('pin/unpin round-trip preserves an agent override', async () => {
251
+ const mod = new SubscriptionGcModule({ defaultLimitChars: 5 });
252
+ const { ctx, toolCalls } = mockCtx();
253
+ await mod.start(ctx);
254
+ const ext = mod.getAgentSettingsExtension();
255
+ ext.update('agent', { channel_idle_limits: { 'discord:g1:c1': 9 } });
256
+ await mod.handleToolCall({
257
+ id: 't1',
258
+ name: 'pin_channel_idle_limit',
259
+ input: { channelId: 'discord:g1:c1', pinned: true },
260
+ });
261
+ await mod.onProcess(ambient('c1', 'longerthannine'), PS); // pinned → no close
262
+ expect(toolCalls.length).toBe(0);
263
+ await mod.handleToolCall({
264
+ id: 't2',
265
+ name: 'pin_channel_idle_limit',
266
+ input: { channelId: 'discord:g1:c1', pinned: false },
267
+ });
268
+ expect(ext.get('agent').channel_idle_limits).toEqual({ 'discord:g1:c1': 9 });
269
+ // Override (9) is live again: counter is at 14 from the pinned message,
270
+ // so the next ambient char crosses it.
271
+ await mod.onProcess(ambient('c1', 'x'), PS);
272
+ expect(toolCalls.length).toBe(1);
273
+ expect(toolCalls[0].name).toBe('channel_close');
274
+ await mod.stop();
275
+ });
276
+
277
+ test('pin tool validates its input', async () => {
278
+ const mod = new SubscriptionGcModule();
279
+ await mod.start(mockCtx().ctx as unknown as ModuleContext);
280
+ const bad1 = await mod.handleToolCall({
281
+ id: 't1',
282
+ name: 'pin_channel_idle_limit',
283
+ input: { pinned: true },
284
+ });
285
+ expect(bad1.success).toBe(false);
286
+ const bad2 = await mod.handleToolCall({
287
+ id: 't2',
288
+ name: 'pin_channel_idle_limit',
289
+ input: { channelId: 'C1', pinned: 'yes' },
290
+ });
291
+ expect(bad2.success).toBe(false);
292
+ });
293
+ });
@@ -0,0 +1,42 @@
1
+ import { describe, test, expect } from 'bun:test';
2
+ import { resolveQuitConfirm } from '../src/tui.js';
3
+
4
+ // Pins the quit-prompt semantics. The pre-fix behavior — "anything that
5
+ // isn't n/no/cancel → kill every fleet child and exit" — meant a user who
6
+ // forgot the armed prompt and typed a normal chat message killed the fleet.
7
+ // The default for arbitrary input MUST stay 'cancel-keep-input'; a refactor
8
+ // that flips it back should fail here, loudly.
9
+ describe('resolveQuitConfirm', () => {
10
+ test('only explicit consent kills', () => {
11
+ expect(resolveQuitConfirm('y')).toBe('kill');
12
+ expect(resolveQuitConfirm('yes')).toBe('kill');
13
+ expect(resolveQuitConfirm('YES')).toBe('kill');
14
+ });
15
+
16
+ test('re-typing the quit command confirms, not cancels', () => {
17
+ expect(resolveQuitConfirm('/quit')).toBe('kill');
18
+ expect(resolveQuitConfirm('/q')).toBe('kill');
19
+ expect(resolveQuitConfirm('quit')).toBe('kill');
20
+ expect(resolveQuitConfirm('q')).toBe('kill');
21
+ });
22
+
23
+ test('detach', () => {
24
+ expect(resolveQuitConfirm('d')).toBe('detach');
25
+ expect(resolveQuitConfirm('detach')).toBe('detach');
26
+ });
27
+
28
+ test('explicit and empty cancels (Enter takes the advertised [y/N/d] default)', () => {
29
+ expect(resolveQuitConfirm('')).toBe('cancel');
30
+ expect(resolveQuitConfirm(' ')).toBe('cancel');
31
+ expect(resolveQuitConfirm('n')).toBe('cancel');
32
+ expect(resolveQuitConfirm('no')).toBe('cancel');
33
+ expect(resolveQuitConfirm('cancel')).toBe('cancel');
34
+ });
35
+
36
+ test('a real message cancels AND must be restored to the input', () => {
37
+ expect(resolveQuitConfirm('actually, first summarize what miner found')).toBe('cancel-keep-input');
38
+ expect(resolveQuitConfirm('[paste #1: "…" 40000ch, 900L]')).toBe('cancel-keep-input');
39
+ expect(resolveQuitConfirm('/status')).toBe('cancel-keep-input');
40
+ expect(resolveQuitConfirm('yeah')).toBe('cancel-keep-input');
41
+ });
42
+ });
@@ -0,0 +1,36 @@
1
+ import { describe, test, expect } from 'bun:test';
2
+ import { shortAgentName } from '../src/tui.js';
3
+
4
+ // Guards full↔short agent-name resolution against the naming schemes the
5
+ // SubagentModule actually produces (see its spawn/fork paths). A helper that
6
+ // misses one scheme silently breaks fleet-tree attribution for that agent
7
+ // type — forks slipped through exactly this way once.
8
+ describe('shortAgentName', () => {
9
+ test('spawn names: spawn-{name}-{ts}', () => {
10
+ expect(shortAgentName('spawn-web-1753221234567')).toBe('web');
11
+ expect(shortAgentName('spawn-zulip-reader-1753221234567')).toBe('zulip-reader');
12
+ });
13
+
14
+ test('fork names: {name}-d{depth}-{ts}, no fork- prefix', () => {
15
+ expect(shortAgentName('web-d1-1753221234567')).toBe('web');
16
+ expect(shortAgentName('zulip-reader-d3-1753221234567')).toBe('zulip-reader');
17
+ });
18
+
19
+ test('fork retry names: {name}-d{depth}-retry{n}-{ts}', () => {
20
+ expect(shortAgentName('web-d2-retry1-1753221234567')).toBe('web');
21
+ });
22
+
23
+ test('bare trailing -retryN (historical defensive strip)', () => {
24
+ expect(shortAgentName('web-retry2')).toBe('web');
25
+ });
26
+
27
+ test('names that are substrings of each other stay distinct', () => {
28
+ expect(shortAgentName('websearch-d1-1753221234567')).toBe('websearch');
29
+ expect(shortAgentName('spawn-websearch-1753221234567')).toBe('websearch');
30
+ });
31
+
32
+ test('plain names pass through', () => {
33
+ expect(shortAgentName('miner')).toBe('miner');
34
+ expect(shortAgentName('web')).toBe('web');
35
+ });
36
+ });