@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.
- package/CHANGELOG.md +131 -0
- package/HEADLESS-FLEET-PLAN.md +1 -1
- package/UNIFIED-TREE-PLAN.md +2 -0
- package/bun.lock +6 -6
- package/docs/AGENT-ONBOARDING.md +9 -8
- package/docs/DEPLOYMENTS.md +2 -2
- package/docs/DEV-ENVIRONMENT.md +28 -26
- package/docs/LOCUS-ROUTING-DESIGN.md +8 -2
- package/package.json +4 -4
- package/src/commands.ts +50 -4
- package/src/framework-agent-config.ts +3 -0
- package/src/framework-strategy.ts +3 -0
- package/src/index.ts +9 -0
- package/src/modules/channel-mode-module.ts +9 -6
- package/src/modules/subscription-gc-module.ts +163 -34
- package/src/modules/tts-relay-module.ts +481 -0
- package/src/modules/web-ui-module.ts +280 -1
- package/src/recipe.ts +58 -1
- package/src/tui.ts +363 -104
- package/src/web/protocol.ts +130 -0
- package/test/commands-checkpoint-restore.test.ts +118 -0
- package/test/settings-protocol.test.ts +64 -0
- package/test/subscription-gc-module.test.ts +156 -1
- package/test/tui-quit-confirm.test.ts +42 -0
- package/test/tui-short-agent-name.test.ts +36 -0
- package/web/src/App.tsx +35 -2
- package/web/src/Settings.tsx +434 -0
package/src/web/protocol.ts
CHANGED
|
@@ -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
|
|
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
|
+
});
|