@animalabs/connectome-host 0.3.9 → 0.4.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/.env.example +7 -0
- package/.github/PULL_REQUEST_TEMPLATE.md +26 -0
- package/.github/workflows/changelog.yml +32 -0
- package/.github/workflows/publish.yml +46 -0
- package/CHANGELOG.md +140 -0
- package/CONTRIBUTING.md +126 -0
- package/HEADLESS-FLEET-PLAN.md +1 -1
- package/README.md +52 -5
- package/UNIFIED-TREE-PLAN.md +2 -0
- 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 +5 -4
- package/scripts/release-changelog.ts +32 -0
- package/src/codex-subscription-adapter.ts +938 -0
- package/src/commands.ts +95 -4
- package/src/extensions.ts +201 -0
- package/src/framework-agent-config.ts +20 -9
- package/src/framework-strategy.ts +24 -0
- package/src/index.ts +111 -19
- package/src/logging-bedrock-adapter.ts +145 -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 +45 -1
- package/src/recipe.ts +196 -10
- package/src/tui.ts +527 -137
- package/src/web/protocol.ts +35 -0
- package/test/codex-subscription-adapter.test.ts +226 -0
- package/test/commands-checkpoint-restore.test.ts +118 -0
- package/test/fast-command.test.ts +39 -0
- package/test/recipe-extensions.test.ts +295 -0
- package/test/recipe-provider.test.ts +14 -1
- 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/test/web-ui-observers.test.ts +14 -0
- package/test/web-ui-protocol.test.ts +0 -0
- package/web/src/App.tsx +235 -20
- package/web/src/Branches.tsx +150 -0
- package/web/src/Context.tsx +21 -13
- package/web/src/Health.tsx +274 -0
- package/web/src/Lessons.tsx +2 -1
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the recipe `extensions` seam: schema validation, load-time path
|
|
3
|
+
* resolution, the extension loader/registry, and custom strategy dispatch
|
|
4
|
+
* through buildFrameworkStrategy.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
|
|
7
|
+
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
|
|
8
|
+
import { tmpdir } from 'node:os';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
import { loadRecipe, validateRecipe, type Recipe } from '../src/recipe.js';
|
|
11
|
+
import {
|
|
12
|
+
loadExtensions,
|
|
13
|
+
emptyExtensionRegistry,
|
|
14
|
+
isBuiltinStrategyType,
|
|
15
|
+
} from '../src/extensions.js';
|
|
16
|
+
import { buildFrameworkStrategy } from '../src/framework-strategy.js';
|
|
17
|
+
|
|
18
|
+
let dir: string;
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
dir = mkdtempSync(join(tmpdir(), 'recipe-ext-'));
|
|
21
|
+
});
|
|
22
|
+
afterEach(() => {
|
|
23
|
+
rmSync(dir, { recursive: true, force: true });
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
function baseRecipe(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
|
27
|
+
return {
|
|
28
|
+
name: 'Ext Test',
|
|
29
|
+
agent: { systemPrompt: 'x' },
|
|
30
|
+
...overrides,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Write an extension module file and return its absolute path. */
|
|
35
|
+
function writeExtension(filename: string, body: string): string {
|
|
36
|
+
const path = join(dir, filename);
|
|
37
|
+
writeFileSync(path, body, 'utf-8');
|
|
38
|
+
return path;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
// validateRecipe
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
describe('validateRecipe extensions block', () => {
|
|
46
|
+
test('accepts a well-formed extensions block', () => {
|
|
47
|
+
const recipe = validateRecipe(baseRecipe({
|
|
48
|
+
extensions: {
|
|
49
|
+
zk: { kind: 'strategy', path: './ext.ts', config: { a: 1 } },
|
|
50
|
+
feeder: { kind: 'module', path: '/abs/feeder.ts' },
|
|
51
|
+
},
|
|
52
|
+
}));
|
|
53
|
+
expect(Object.keys(recipe.extensions!)).toEqual(['zk', 'feeder']);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('rejects bad kind', () => {
|
|
57
|
+
expect(() => validateRecipe(baseRecipe({
|
|
58
|
+
extensions: { zk: { kind: 'plugin', path: './ext.ts' } },
|
|
59
|
+
}))).toThrow(/kind must be "strategy" or "module"/);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('rejects missing path', () => {
|
|
63
|
+
expect(() => validateRecipe(baseRecipe({
|
|
64
|
+
extensions: { zk: { kind: 'strategy' } },
|
|
65
|
+
}))).toThrow(/path must be a non-empty string/);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('rejects non-object config', () => {
|
|
69
|
+
expect(() => validateRecipe(baseRecipe({
|
|
70
|
+
extensions: { zk: { kind: 'strategy', path: './e.ts', config: [1] } },
|
|
71
|
+
}))).toThrow(/config must be an object/);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('rejects array extensions block', () => {
|
|
75
|
+
expect(() => validateRecipe(baseRecipe({ extensions: [] })))
|
|
76
|
+
.toThrow(/must be an object mapping names/);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('custom strategy type without a strategy-kind extension is rejected', () => {
|
|
80
|
+
expect(() => validateRecipe(baseRecipe({
|
|
81
|
+
agent: { systemPrompt: 'x', strategy: { type: 'zk' } },
|
|
82
|
+
}))).toThrow(/Invalid strategy type "zk"/);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('custom strategy type with only module-kind extensions is rejected', () => {
|
|
86
|
+
expect(() => validateRecipe(baseRecipe({
|
|
87
|
+
agent: { systemPrompt: 'x', strategy: { type: 'zk' } },
|
|
88
|
+
extensions: { feeder: { kind: 'module', path: './f.ts' } },
|
|
89
|
+
}))).toThrow(/Invalid strategy type "zk"/);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('custom strategy type with a strategy-kind extension is accepted', () => {
|
|
93
|
+
const recipe = validateRecipe(baseRecipe({
|
|
94
|
+
agent: { systemPrompt: 'x', strategy: { type: 'zk' } },
|
|
95
|
+
extensions: { zk: { kind: 'strategy', path: './e.ts' } },
|
|
96
|
+
}));
|
|
97
|
+
expect(recipe.agent.strategy?.type).toBe('zk');
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('built-in strategy types still validate without extensions', () => {
|
|
101
|
+
const recipe = validateRecipe(baseRecipe({
|
|
102
|
+
agent: { systemPrompt: 'x', strategy: { type: 'frontdesk' } },
|
|
103
|
+
}));
|
|
104
|
+
expect(recipe.agent.strategy?.type).toBe('frontdesk');
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
// loadRecipe path resolution
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
describe('loadRecipe extension path resolution', () => {
|
|
113
|
+
test('relative extension paths resolve against the recipe dir', async () => {
|
|
114
|
+
const recipePath = join(dir, 'r.json');
|
|
115
|
+
writeFileSync(recipePath, JSON.stringify(baseRecipe({
|
|
116
|
+
extensions: { zk: { kind: 'module', path: './exts/zk.ts' } },
|
|
117
|
+
})), 'utf-8');
|
|
118
|
+
const recipe = await loadRecipe(recipePath);
|
|
119
|
+
expect(recipe.extensions!.zk!.path).toBe(join(dir, 'exts', 'zk.ts'));
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('absolute extension paths pass through', async () => {
|
|
123
|
+
const recipePath = join(dir, 'r.json');
|
|
124
|
+
writeFileSync(recipePath, JSON.stringify(baseRecipe({
|
|
125
|
+
extensions: { zk: { kind: 'module', path: '/opt/zk/index.ts' } },
|
|
126
|
+
})), 'utf-8');
|
|
127
|
+
const recipe = await loadRecipe(recipePath);
|
|
128
|
+
expect(recipe.extensions!.zk!.path).toBe('/opt/zk/index.ts');
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
// loadExtensions
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
|
|
136
|
+
describe('loadExtensions', () => {
|
|
137
|
+
test('empty when no extensions declared', async () => {
|
|
138
|
+
const recipe = validateRecipe(baseRecipe());
|
|
139
|
+
const registry = await loadExtensions(recipe);
|
|
140
|
+
expect(registry.strategies.size).toBe(0);
|
|
141
|
+
expect(registry.modules.length).toBe(0);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('collects strategy and module registrations, passing config', async () => {
|
|
145
|
+
const path = writeExtension('combo.mjs', `
|
|
146
|
+
export function register(api, config) {
|
|
147
|
+
api.registerStrategy('zk', (ctx) => ({ kind: 'zk-strategy', opts: ctx }));
|
|
148
|
+
api.registerModule((ctx) => ({ name: 'zk-feeder', ctx }));
|
|
149
|
+
if (config.marker !== 'hello') throw new Error('config not passed');
|
|
150
|
+
}
|
|
151
|
+
`);
|
|
152
|
+
const recipe = validateRecipe(baseRecipe({
|
|
153
|
+
extensions: { zk: { kind: 'strategy', path, config: { marker: 'hello' } } },
|
|
154
|
+
}));
|
|
155
|
+
const registry = await loadExtensions(recipe);
|
|
156
|
+
expect(registry.strategies.has('zk')).toBe(true);
|
|
157
|
+
expect(registry.modules.length).toBe(1);
|
|
158
|
+
expect(registry.modules[0]!.extensionName).toBe('zk');
|
|
159
|
+
expect(registry.modules[0]!.config).toEqual({ marker: 'hello' });
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test('accepts a default-export register function', async () => {
|
|
163
|
+
const path = writeExtension('default.mjs', `
|
|
164
|
+
export default function (api) {
|
|
165
|
+
api.registerStrategy('dflt', () => ({ kind: 'dflt' }));
|
|
166
|
+
}
|
|
167
|
+
`);
|
|
168
|
+
const recipe = validateRecipe(baseRecipe({
|
|
169
|
+
extensions: { d: { kind: 'strategy', path } },
|
|
170
|
+
}));
|
|
171
|
+
const registry = await loadExtensions(recipe);
|
|
172
|
+
expect(registry.strategies.has('dflt')).toBe(true);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test('supports async register', async () => {
|
|
176
|
+
const path = writeExtension('async.mjs', `
|
|
177
|
+
export async function register(api) {
|
|
178
|
+
await Promise.resolve();
|
|
179
|
+
api.registerModule(() => ({ name: 'late' }));
|
|
180
|
+
}
|
|
181
|
+
`);
|
|
182
|
+
const recipe = validateRecipe(baseRecipe({
|
|
183
|
+
extensions: { a: { kind: 'module', path } },
|
|
184
|
+
}));
|
|
185
|
+
const registry = await loadExtensions(recipe);
|
|
186
|
+
expect(registry.modules.length).toBe(1);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test('rejects a module without a register function', async () => {
|
|
190
|
+
const path = writeExtension('bad.mjs', `export const nothing = 1;`);
|
|
191
|
+
const recipe = validateRecipe(baseRecipe({
|
|
192
|
+
extensions: { bad: { kind: 'module', path } },
|
|
193
|
+
}));
|
|
194
|
+
await expect(loadExtensions(recipe)).rejects.toThrow(/must export a "register" function/);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test('rejects relative paths at load time', async () => {
|
|
198
|
+
const recipe = validateRecipe(baseRecipe({
|
|
199
|
+
extensions: { rel: { kind: 'module', path: './never-resolved.ts' } },
|
|
200
|
+
}));
|
|
201
|
+
await expect(loadExtensions(recipe)).rejects.toThrow(/not absolute/);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test('rejects registering a built-in strategy name', async () => {
|
|
205
|
+
const path = writeExtension('builtin-clash.mjs', `
|
|
206
|
+
export function register(api) {
|
|
207
|
+
api.registerStrategy('autobiographical', () => ({}));
|
|
208
|
+
}
|
|
209
|
+
`);
|
|
210
|
+
const recipe = validateRecipe(baseRecipe({
|
|
211
|
+
extensions: { clash: { kind: 'strategy', path } },
|
|
212
|
+
}));
|
|
213
|
+
await expect(loadExtensions(recipe)).rejects.toThrow(/collides with a built-in/);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test('rejects duplicate strategy registrations across extensions', async () => {
|
|
217
|
+
const p1 = writeExtension('one.mjs', `
|
|
218
|
+
export function register(api) { api.registerStrategy('zk', () => ({})); }
|
|
219
|
+
`);
|
|
220
|
+
const p2 = writeExtension('two.mjs', `
|
|
221
|
+
export function register(api) { api.registerStrategy('zk', () => ({})); }
|
|
222
|
+
`);
|
|
223
|
+
const recipe = validateRecipe(baseRecipe({
|
|
224
|
+
extensions: {
|
|
225
|
+
one: { kind: 'strategy', path: p1 },
|
|
226
|
+
two: { kind: 'strategy', path: p2 },
|
|
227
|
+
},
|
|
228
|
+
}));
|
|
229
|
+
await expect(loadExtensions(recipe)).rejects.toThrow(/already registered/);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test('wraps import failures with the extension name', async () => {
|
|
233
|
+
const path = writeExtension('boom.mjs', `throw new Error('kaboom at import');`);
|
|
234
|
+
const recipe = validateRecipe(baseRecipe({
|
|
235
|
+
extensions: { boom: { kind: 'module', path } },
|
|
236
|
+
}));
|
|
237
|
+
await expect(loadExtensions(recipe)).rejects.toThrow(/extension "boom".*failed to import/);
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
// ---------------------------------------------------------------------------
|
|
242
|
+
// buildFrameworkStrategy custom dispatch
|
|
243
|
+
// ---------------------------------------------------------------------------
|
|
244
|
+
|
|
245
|
+
describe('buildFrameworkStrategy with extensions', () => {
|
|
246
|
+
test('dispatches a custom type through the registry with full config', async () => {
|
|
247
|
+
const path = writeExtension('strat.mjs', `
|
|
248
|
+
export function register(api) {
|
|
249
|
+
api.registerStrategy('zk', (ctx) => ({
|
|
250
|
+
kind: 'zk',
|
|
251
|
+
model: ctx.model,
|
|
252
|
+
timeZone: ctx.timeZone,
|
|
253
|
+
customKnob: ctx.config.customKnob,
|
|
254
|
+
}));
|
|
255
|
+
}
|
|
256
|
+
`);
|
|
257
|
+
const recipe = validateRecipe(baseRecipe({
|
|
258
|
+
agent: {
|
|
259
|
+
systemPrompt: 'x',
|
|
260
|
+
strategy: { type: 'zk', customKnob: 42 },
|
|
261
|
+
},
|
|
262
|
+
extensions: { zk: { kind: 'strategy', path } },
|
|
263
|
+
})) as Recipe;
|
|
264
|
+
const registry = await loadExtensions(recipe);
|
|
265
|
+
const strategy = buildFrameworkStrategy(recipe, 'test-model', 'UTC', registry) as unknown as Record<string, unknown>;
|
|
266
|
+
expect(strategy.kind).toBe('zk');
|
|
267
|
+
expect(strategy.model).toBe('test-model');
|
|
268
|
+
expect(strategy.timeZone).toBe('UTC');
|
|
269
|
+
expect(strategy.customKnob).toBe(42);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test('throws a precise error when a declared type was never registered', () => {
|
|
273
|
+
const recipe = validateRecipe(baseRecipe({
|
|
274
|
+
agent: { systemPrompt: 'x', strategy: { type: 'ghost' } },
|
|
275
|
+
extensions: { zk: { kind: 'strategy', path: '/tmp/unused.ts' } },
|
|
276
|
+
})) as Recipe;
|
|
277
|
+
expect(() => buildFrameworkStrategy(recipe, 'm', 'UTC', emptyExtensionRegistry()))
|
|
278
|
+
.toThrow(/"ghost" is not built-in and no loaded extension registered it/);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
test('built-in dispatch is unaffected by an empty registry', () => {
|
|
282
|
+
const recipe = validateRecipe(baseRecipe()) as Recipe;
|
|
283
|
+
const strategy = buildFrameworkStrategy(recipe, 'm', 'UTC', emptyExtensionRegistry());
|
|
284
|
+
expect(strategy).toBeDefined();
|
|
285
|
+
});
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
describe('isBuiltinStrategyType', () => {
|
|
289
|
+
test('recognizes the three built-ins and nothing else', () => {
|
|
290
|
+
expect(isBuiltinStrategyType('autobiographical')).toBe(true);
|
|
291
|
+
expect(isBuiltinStrategyType('passthrough')).toBe(true);
|
|
292
|
+
expect(isBuiltinStrategyType('frontdesk')).toBe(true);
|
|
293
|
+
expect(isBuiltinStrategyType('zk')).toBe(false);
|
|
294
|
+
});
|
|
295
|
+
});
|
|
@@ -6,10 +6,19 @@ function recipe(agent: Record<string, unknown> = {}) {
|
|
|
6
6
|
}
|
|
7
7
|
|
|
8
8
|
describe('recipe provider validation', () => {
|
|
9
|
-
test('preserves Anthropic as the omitted provider and accepts
|
|
9
|
+
test('preserves Anthropic as the omitted provider and accepts OpenAI providers', () => {
|
|
10
10
|
expect(validateRecipe(recipe()).agent.provider).toBeUndefined();
|
|
11
11
|
expect(validateRecipe(recipe({ provider: 'openai-responses' })).agent.provider)
|
|
12
12
|
.toBe('openai-responses');
|
|
13
|
+
expect(validateRecipe(recipe({ provider: 'openai-codex' })).agent.provider)
|
|
14
|
+
.toBe('openai-codex');
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test('accepts Codex subscription settings', () => {
|
|
18
|
+
expect(validateRecipe(recipe({
|
|
19
|
+
provider: 'openai-codex',
|
|
20
|
+
codex: { fastMode: true },
|
|
21
|
+
})).agent.codex).toEqual({ fastMode: true });
|
|
13
22
|
});
|
|
14
23
|
|
|
15
24
|
test('accepts Responses reasoning and compaction settings', () => {
|
|
@@ -19,11 +28,13 @@ describe('recipe provider validation', () => {
|
|
|
19
28
|
reasoningEffort: 'xhigh',
|
|
20
29
|
reasoningContext: 'all_turns',
|
|
21
30
|
compactThreshold: 100_000,
|
|
31
|
+
serviceTier: 'priority',
|
|
22
32
|
},
|
|
23
33
|
})).agent.responses).toEqual({
|
|
24
34
|
reasoningEffort: 'xhigh',
|
|
25
35
|
reasoningContext: 'all_turns',
|
|
26
36
|
compactThreshold: 100_000,
|
|
37
|
+
serviceTier: 'priority',
|
|
27
38
|
});
|
|
28
39
|
});
|
|
29
40
|
|
|
@@ -32,5 +43,7 @@ describe('recipe provider validation', () => {
|
|
|
32
43
|
expect(() => validateRecipe(recipe({ responses: { reasoningEffort: 'ultra' } }))).toThrow(/reasoningEffort/);
|
|
33
44
|
expect(() => validateRecipe(recipe({ responses: { reasoningContext: 'previous_turn' } }))).toThrow(/reasoningContext/);
|
|
34
45
|
expect(() => validateRecipe(recipe({ responses: { compactThreshold: 0 } }))).toThrow(/compactThreshold/);
|
|
46
|
+
expect(() => validateRecipe(recipe({ responses: { serviceTier: 'fast' } }))).toThrow(/serviceTier/);
|
|
47
|
+
expect(() => validateRecipe(recipe({ codex: { fastMode: 'yes' } }))).toThrow(/fastMode/);
|
|
35
48
|
});
|
|
36
49
|
});
|
|
@@ -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
|
+
});
|
|
@@ -326,6 +326,20 @@ describe('WebUiModule observer flow (e2e)', () => {
|
|
|
326
326
|
const errFrame = await got('error');
|
|
327
327
|
expect(String(errFrame.message)).toContain('forbidden');
|
|
328
328
|
|
|
329
|
+
// Branch listing rides the 'messages' scope, which this grant lacks.
|
|
330
|
+
ws.send(JSON.stringify({ type: 'request-branches' }));
|
|
331
|
+
const branchErr = await new Promise<Record<string, unknown>>((resolvePromise, reject) => {
|
|
332
|
+
const t = setTimeout(() => reject(new Error('timeout waiting for request-branches refusal')), 5000);
|
|
333
|
+
const check = () => {
|
|
334
|
+
const f = frames.find((m) => m.type === 'error' && String(m.message).includes('request-branches'));
|
|
335
|
+
if (f) { clearTimeout(t); resolvePromise(f); return true; }
|
|
336
|
+
return false;
|
|
337
|
+
};
|
|
338
|
+
if (check()) return;
|
|
339
|
+
ws.addEventListener('message', () => { check(); });
|
|
340
|
+
});
|
|
341
|
+
expect(String(branchErr.message)).toContain('forbidden');
|
|
342
|
+
|
|
329
343
|
// Session cookie: health allowed, debug denied (not in scopes).
|
|
330
344
|
const cookie = `fkm_obs=${ack.sessionToken}`;
|
|
331
345
|
// healthz returns 503 (app not bound in this harness) — auth passed.
|
|
Binary file
|