@stilero/bankan 1.0.13 → 1.0.17

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.
@@ -1,91 +1,477 @@
1
- import test from 'node:test';
2
- import assert from 'node:assert/strict';
1
+ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
3
2
 
4
3
  import agentManager from './agents.js';
4
+ import store from './store.js';
5
5
 
6
- test('planner with stale task binding is not treated as available', () => {
7
- const originalAgents = agentManager.agents;
8
-
9
- agentManager.agents = new Map([
10
- ['orch', { id: 'orch' }],
11
- ['plan-1', {
12
- id: 'plan-1',
13
- status: 'idle',
14
- draining: false,
15
- currentTask: 'T-123',
16
- process: null,
17
- }],
18
- ['plan-2', {
19
- id: 'plan-2',
20
- status: 'idle',
21
- draining: false,
22
- currentTask: null,
23
- process: null,
24
- }],
25
- ]);
26
-
27
- try {
28
- const planner = agentManager.getAvailablePlanner();
29
- assert.equal(planner?.id, 'plan-2');
30
- } finally {
31
- agentManager.agents = originalAgents;
32
- }
6
+ let originalAgents;
7
+ let originalMaxSettings;
8
+ let originalCliSettings;
9
+ let originalSessionCounters;
10
+
11
+ beforeEach(() => {
12
+ originalAgents = agentManager.agents;
13
+ originalMaxSettings = { ...agentManager._maxSettings };
14
+ originalCliSettings = { ...agentManager._cliSettings };
15
+ originalSessionCounters = { ...agentManager._sessionCounters };
33
16
  });
34
17
 
35
- test('planner with a live process is not treated as available', () => {
36
- const originalAgents = agentManager.agents;
37
-
38
- agentManager.agents = new Map([
39
- ['orch', { id: 'orch' }],
40
- ['plan-1', {
41
- id: 'plan-1',
42
- status: 'idle',
43
- draining: false,
44
- currentTask: null,
45
- process: { pid: 42 },
46
- }],
47
- ['plan-2', {
48
- id: 'plan-2',
49
- status: 'idle',
50
- draining: false,
51
- currentTask: null,
52
- process: null,
53
- }],
54
- ]);
55
-
56
- try {
57
- const planner = agentManager.getAvailablePlanner();
58
- assert.equal(planner?.id, 'plan-2');
59
- } finally {
60
- agentManager.agents = originalAgents;
61
- }
18
+ afterEach(() => {
19
+ agentManager.agents = originalAgents;
20
+ agentManager._maxSettings = originalMaxSettings;
21
+ agentManager._cliSettings = originalCliSettings;
22
+ agentManager._sessionCounters = originalSessionCounters;
23
+ vi.restoreAllMocks();
62
24
  });
63
25
 
64
- test('planner sessions get fresh ids after removal', () => {
65
- const originalAgents = agentManager.agents;
66
- const originalMaxSettings = { ...agentManager._maxSettings };
67
- const originalCliSettings = { ...agentManager._cliSettings };
68
- const originalSessionCounters = { ...agentManager._sessionCounters };
26
+ describe('AgentManager availability', () => {
27
+ test('planner with stale task binding is not treated as available', () => {
28
+ agentManager.agents = new Map([
29
+ ['orch', { id: 'orch' }],
30
+ ['plan-1', {
31
+ id: 'plan-1',
32
+ status: 'idle',
33
+ draining: false,
34
+ currentTask: 'T-123',
35
+ process: null,
36
+ }],
37
+ ['plan-2', {
38
+ id: 'plan-2',
39
+ status: 'idle',
40
+ draining: false,
41
+ currentTask: null,
42
+ process: null,
43
+ }],
44
+ ]);
45
+
46
+ const planner = agentManager.getAvailablePlanner();
47
+ expect(planner?.id).toBe('plan-2');
48
+ });
69
49
 
70
- agentManager.agents = new Map([
71
- ['orch', { id: 'orch' }],
72
- ]);
73
- agentManager._maxSettings = { ...originalMaxSettings, planners: 2 };
74
- agentManager._cliSettings = { ...originalCliSettings, planners: 'claude' };
75
- agentManager._sessionCounters = { ...originalSessionCounters, plan: 0 };
50
+ test('planner with a live process is not treated as available', () => {
51
+ agentManager.agents = new Map([
52
+ ['orch', { id: 'orch' }],
53
+ ['plan-1', {
54
+ id: 'plan-1',
55
+ status: 'idle',
56
+ draining: false,
57
+ currentTask: null,
58
+ process: { pid: 42 },
59
+ }],
60
+ ['plan-2', {
61
+ id: 'plan-2',
62
+ status: 'idle',
63
+ draining: false,
64
+ currentTask: null,
65
+ process: null,
66
+ }],
67
+ ]);
68
+
69
+ const planner = agentManager.getAvailablePlanner();
70
+ expect(planner?.id).toBe('plan-2');
71
+ });
72
+
73
+ test('planner sessions get fresh ids after removal', () => {
74
+ agentManager.agents = new Map([
75
+ ['orch', { id: 'orch', getStatus: () => ({ id: 'orch' }) }],
76
+ ]);
77
+ agentManager._maxSettings = { ...originalMaxSettings, planners: 2 };
78
+ agentManager._cliSettings = { ...originalCliSettings, planners: 'claude' };
79
+ agentManager._sessionCounters = { ...originalSessionCounters, plan: 0 };
76
80
 
77
- try {
78
81
  const first = agentManager.scaleUp('planners');
79
- assert.equal(first?.id, 'plan-1');
82
+ expect(first?.id).toBe('plan-1');
80
83
 
81
84
  agentManager.removeAgent('plan-1');
82
85
 
83
86
  const second = agentManager.scaleUp('planners');
84
- assert.equal(second?.id, 'plan-2');
85
- } finally {
86
- agentManager.agents = originalAgents;
87
- agentManager._maxSettings = originalMaxSettings;
88
- agentManager._cliSettings = originalCliSettings;
89
- agentManager._sessionCounters = originalSessionCounters;
90
- }
87
+ expect(second?.id).toBe('plan-2');
88
+ });
89
+ });
90
+
91
+ describe('Agent behavior through managed instances', () => {
92
+ test('spawn rejects invalid working directory and writes an error to subscribers', () => {
93
+ agentManager.agents = new Map([
94
+ ['orch', { id: 'orch', getStatus: () => ({ id: 'orch' }) }],
95
+ ]);
96
+ agentManager._maxSettings = { ...originalMaxSettings, implementors: 1 };
97
+ agentManager._cliSettings = { ...originalCliSettings, implementors: 'claude' };
98
+ agentManager._sessionCounters = { ...originalSessionCounters, imp: 0 };
99
+
100
+ const agent = agentManager.scaleUp('implementors');
101
+ const ws = { send: vi.fn() };
102
+ agent.subscribers.add(ws);
103
+
104
+ expect(agent.spawn('/definitely/missing', 'echo test')).toBe(false);
105
+ expect(agent.getBufferString()).toContain('Invalid working directory');
106
+ expect(ws.send).toHaveBeenCalledOnce();
107
+ });
108
+
109
+ test('write returns a helpful error when the agent is not running', () => {
110
+ agentManager.agents = new Map([
111
+ ['orch', { id: 'orch', getStatus: () => ({ id: 'orch' }) }],
112
+ ]);
113
+ agentManager._maxSettings = { ...originalMaxSettings, reviewers: 1 };
114
+ agentManager._cliSettings = { ...originalCliSettings, reviewers: 'claude' };
115
+ agentManager._sessionCounters = { ...originalSessionCounters, rev: 0 };
116
+
117
+ const agent = agentManager.scaleUp('reviewers');
118
+
119
+ expect(agent.write('hello')).toBe(false);
120
+ expect(agent.getBufferString()).toContain('Agent is not running');
121
+ });
122
+
123
+ test('resize normalizes invalid values and keeps the terminal size usable', () => {
124
+ agentManager.agents = new Map([
125
+ ['orch', { id: 'orch', getStatus: () => ({ id: 'orch' }) }],
126
+ ]);
127
+ agentManager._maxSettings = { ...originalMaxSettings, planners: 1 };
128
+ agentManager._cliSettings = { ...originalCliSettings, planners: 'claude' };
129
+ agentManager._sessionCounters = { ...originalSessionCounters, plan: 0 };
130
+
131
+ const agent = agentManager.scaleUp('planners');
132
+
133
+ expect(agent.resize(1, 1)).toBe(true);
134
+ expect(agent.terminalSize).toEqual({ cols: 20, rows: 5 });
135
+ });
136
+
137
+ test('token parsing keeps the highest observed count and aggregates task totals', () => {
138
+ agentManager.agents = new Map([
139
+ ['orch', { id: 'orch', getStatus: () => ({ id: 'orch' }) }],
140
+ ]);
141
+ agentManager._maxSettings = { ...originalMaxSettings, implementors: 1 };
142
+ agentManager._cliSettings = { ...originalCliSettings, implementors: 'claude' };
143
+ agentManager._sessionCounters = { ...originalSessionCounters, imp: 0 };
144
+
145
+ const agent = agentManager.scaleUp('implementors');
146
+ const taskId = 'T-123';
147
+ const updateTokens = vi.spyOn(store, 'updateTaskTokens').mockImplementation(() => ({
148
+ id: taskId,
149
+ totalTokens: 1225,
150
+ }));
151
+ const getTask = vi.spyOn(store, 'getTask').mockImplementation((id) => (
152
+ id === taskId ? { id: taskId, totalTokens: 1225 } : null
153
+ ));
154
+
155
+ agent.currentTask = taskId;
156
+ agent.taskTokenBase = 25;
157
+ agent._parseTokens('context: 1,200');
158
+ agent._parseTokens('total tokens: 900');
159
+ agent._syncTaskTokens();
160
+
161
+ expect(agent.tokens).toBe(1200);
162
+ expect(updateTokens).toHaveBeenCalledWith(taskId, 1225);
163
+ expect(getTask).toHaveBeenCalledWith(taskId);
164
+ expect(agent.getStatus().aggregatedTokens).toBe(1225);
165
+ });
166
+
167
+ test('captures a completed plan block even after the terminal tail loses the start marker', () => {
168
+ agentManager.agents = new Map([
169
+ ['orch', { id: 'orch', getStatus: () => ({ id: 'orch' }) }],
170
+ ]);
171
+ agentManager._maxSettings = { ...originalMaxSettings, planners: 1 };
172
+ agentManager._cliSettings = { ...originalCliSettings, planners: 'claude' };
173
+ agentManager._sessionCounters = { ...originalSessionCounters, plan: 0 };
174
+
175
+ const agent = agentManager.scaleUp('planners');
176
+ const filler = '0123456789'.repeat(30);
177
+
178
+ agent._captureStructuredOutput('=== PLAN START ===\n');
179
+ agent._captureStructuredOutput('SUMMARY: Keep completion detection stable.\n');
180
+ agent._captureStructuredOutput('BRANCH: feature/test-plan-capture\n');
181
+ agent._captureStructuredOutput('FILES_TO_MODIFY:\n- server/src/agents.js (store structured block state)\n');
182
+ agent._captureStructuredOutput('STEPS:\n1. Capture the block outside the PTY tail.\n');
183
+
184
+ for (let i = 0; i < 130; i += 1) {
185
+ agent.terminalBuffer.push(`noise-${i}-${filler}`);
186
+ }
187
+
188
+ agent._captureStructuredOutput('TESTS_NEEDED:\n- Run npm run test:server\n');
189
+ agent._captureStructuredOutput('RISKS:\n- none\n');
190
+ agent._captureStructuredOutput('=== PLAN END ===');
191
+
192
+ expect(agent.getStructuredBlock('plan')).toContain('SUMMARY: Keep completion detection stable.');
193
+ expect(agent.getStructuredBlock('plan')).toContain('=== PLAN END ===');
194
+ expect(agent.getBufferString(100)).not.toContain('=== PLAN START ===');
195
+ });
196
+
197
+ test('detects markers when ANSI escape is split mid-marker across chunks', () => {
198
+ agentManager.agents = new Map([
199
+ ['orch', { id: 'orch', getStatus: () => ({ id: 'orch' }) }],
200
+ ]);
201
+ agentManager._maxSettings = { ...originalMaxSettings, planners: 1 };
202
+ agentManager._cliSettings = { ...originalCliSettings, planners: 'claude' };
203
+ agentManager._sessionCounters = { ...originalSessionCounters, plan: 0 };
204
+
205
+ const agent = agentManager.scaleUp('planners');
206
+
207
+ // The plan content arrives cleanly
208
+ agent._captureStructuredOutput('=== PLAN START ===\n');
209
+ agent._captureStructuredOutput('SUMMARY: ANSI split within end marker.\n');
210
+ agent._captureStructuredOutput('BRANCH: feature/ansi-fix\n');
211
+ agent._captureStructuredOutput('FILES_TO_MODIFY:\n- agents.js (fix capture)\n');
212
+ agent._captureStructuredOutput('STEPS:\n1. Fix the bug.\n');
213
+ agent._captureStructuredOutput('TESTS_NEEDED:\n- none\n');
214
+ agent._captureStructuredOutput('RISKS:\n- none\n');
215
+ // End marker has an ANSI reset code split RIGHT in the middle:
216
+ // chunk ends with "=== PLAN END =\x1b" and next chunk starts with "[0m=="
217
+ // Per-chunk stripping leaves residual \x1b and [0m, corrupting the marker
218
+ agent._captureStructuredOutput('=== PLAN END =\x1b');
219
+ agent._captureStructuredOutput('[0m==');
220
+
221
+ const block = agent.getStructuredBlock('plan');
222
+ expect(block).not.toBeNull();
223
+ expect(block).toContain('SUMMARY: ANSI split within end marker.');
224
+ expect(block).toContain('=== PLAN END ===');
225
+ // eslint-disable-next-line no-control-regex
226
+ expect(block).not.toMatch(/\x1b/);
227
+ });
228
+
229
+ test('captured plan text contains no ANSI residue from split sequences', () => {
230
+ agentManager.agents = new Map([
231
+ ['orch', { id: 'orch', getStatus: () => ({ id: 'orch' }) }],
232
+ ]);
233
+ agentManager._maxSettings = { ...originalMaxSettings, planners: 1 };
234
+ agentManager._cliSettings = { ...originalCliSettings, planners: 'claude' };
235
+ agentManager._sessionCounters = { ...originalSessionCounters, plan: 0 };
236
+
237
+ const agent = agentManager.scaleUp('planners');
238
+
239
+ // ANSI bold applied to SUMMARY line, split across chunks
240
+ agent._captureStructuredOutput('=== PLAN START ===\n');
241
+ agent._captureStructuredOutput('SUMMARY: \x1b[1mBold summ\x1b');
242
+ agent._captureStructuredOutput('[0mary text.\n');
243
+ agent._captureStructuredOutput('BRANCH: feature/clean-text\n');
244
+ agent._captureStructuredOutput('FILES_TO_MODIFY:\n- file.js (test)\n');
245
+ agent._captureStructuredOutput('STEPS:\n1. Step one.\n');
246
+ agent._captureStructuredOutput('TESTS_NEEDED:\n- none\n');
247
+ agent._captureStructuredOutput('RISKS:\n- none\n');
248
+ agent._captureStructuredOutput('=== PLAN END ===');
249
+
250
+ const block = agent.getStructuredBlock('plan');
251
+ expect(block).not.toBeNull();
252
+ // The captured text should be free of ANSI artifacts
253
+ // eslint-disable-next-line no-control-regex
254
+ expect(block).not.toMatch(/\x1b/);
255
+ expect(block).not.toContain('[0m');
256
+ expect(block).not.toContain('[1m');
257
+ expect(block).toContain('SUMMARY: Bold summary text.');
258
+ });
259
+
260
+ test('captures plan text with spaces preserved when CLI uses cursor forward codes', () => {
261
+ agentManager.agents = new Map([
262
+ ['orch', { id: 'orch', getStatus: () => ({ id: 'orch' }) }],
263
+ ]);
264
+ agentManager._maxSettings = { ...originalMaxSettings, planners: 1 };
265
+ agentManager._cliSettings = { ...originalCliSettings, planners: 'claude' };
266
+ agentManager._sessionCounters = { ...originalSessionCounters, plan: 0 };
267
+
268
+ const agent = agentManager.scaleUp('planners');
269
+
270
+ // CLI uses cursor forward (\x1b[nC) instead of spaces
271
+ agent._captureStructuredOutput('=== PLAN START ===\n');
272
+ agent._captureStructuredOutput('SUMMARY:\x1b[1C' + 'Add\x1b[1C' + 'a\x1b[1C' + 'feature.\n');
273
+ agent._captureStructuredOutput('BRANCH: feature/test\n');
274
+ agent._captureStructuredOutput('FILES_TO_MODIFY:\n- file.js (test)\n');
275
+ agent._captureStructuredOutput('STEPS:\n1. Do it.\n');
276
+ agent._captureStructuredOutput('TESTS_NEEDED:\n- none\n');
277
+ agent._captureStructuredOutput('RISKS:\n- none\n');
278
+ agent._captureStructuredOutput('=== PLAN END ===');
279
+
280
+ const block = agent.getStructuredBlock('plan');
281
+ expect(block).toContain('SUMMARY: Add a feature.');
282
+ expect(block).not.toContain('Adda');
283
+ });
284
+
285
+ test('captures review blocks when markers are split across chunks', () => {
286
+ agentManager.agents = new Map([
287
+ ['orch', { id: 'orch', getStatus: () => ({ id: 'orch' }) }],
288
+ ]);
289
+ agentManager._maxSettings = { ...originalMaxSettings, reviewers: 1 };
290
+ agentManager._cliSettings = { ...originalCliSettings, reviewers: 'claude' };
291
+ agentManager._sessionCounters = { ...originalSessionCounters, rev: 0 };
292
+
293
+ const agent = agentManager.scaleUp('reviewers');
294
+
295
+ agent._captureStructuredOutput('=== REVIEW STA');
296
+ agent._captureStructuredOutput('RT ===\nVERDICT: PASS\nCRITICAL_ISSUES:\n- none\n');
297
+ agent._captureStructuredOutput('MINOR_ISSUES:\n- none\nSUMMARY: Completed from split markers.\n=== REVIEW ');
298
+ agent._captureStructuredOutput('END ===');
299
+
300
+ expect(agent.getStructuredBlock('review')).toContain('SUMMARY: Completed from split markers.');
301
+ expect(agent.getStructuredBlock('review')).toContain('=== REVIEW END ===');
302
+ });
303
+
304
+ test('getAllCapturedBlocks returns all completed blocks including overwritten ones', () => {
305
+ agentManager.agents = new Map([
306
+ ['orch', { id: 'orch', getStatus: () => ({ id: 'orch' }) }],
307
+ ]);
308
+ agentManager._maxSettings = { ...originalMaxSettings, planners: 1 };
309
+ agentManager._cliSettings = { ...originalCliSettings, planners: 'claude' };
310
+ agentManager._sessionCounters = { ...originalSessionCounters, plan: 0 };
311
+
312
+ const agent = agentManager.scaleUp('planners');
313
+
314
+ // First block: prompt template (placeholder)
315
+ agent._captureStructuredOutput(`=== PLAN START ===
316
+ SUMMARY: (one sentence describing what will be built)
317
+ BRANCH: (feature/t-xxx-short-descriptive-slug)
318
+ FILES_TO_MODIFY:
319
+ - path/to/file.ts (reason for modification)
320
+ STEPS:
321
+ 1. (detailed, actionable step)
322
+ TESTS_NEEDED:
323
+ - (test description, or 'none')
324
+ RISKS:
325
+ - (potential issue or edge case, or 'none')
326
+ === PLAN END ===`);
327
+
328
+ // Second block: real plan
329
+ agent._captureStructuredOutput(`=== PLAN START ===
330
+ SUMMARY: Add model selection dropdown to settings.
331
+ BRANCH: feature/t-abc123-model-selection
332
+ FILES_TO_MODIFY:
333
+ - server/src/config.js (add model field)
334
+ STEPS:
335
+ 1. Add model to defaults
336
+ TESTS_NEEDED:
337
+ - Run npm run test:server
338
+ RISKS:
339
+ - none
340
+ === PLAN END ===`);
341
+
342
+ // Third block: CLI re-renders template (overwrites real plan)
343
+ agent._captureStructuredOutput(`=== PLAN START ===
344
+ SUMMARY: (one sentence describing what will be built)
345
+ BRANCH: (feature/t-xxx-short-descriptive-slug)
346
+ FILES_TO_MODIFY:
347
+ - path/to/file.ts (reason for modification)
348
+ STEPS:
349
+ 1. (detailed, actionable step)
350
+ TESTS_NEEDED:
351
+ - (test description, or 'none')
352
+ RISKS:
353
+ - (potential issue or edge case, or 'none')
354
+ === PLAN END ===`);
355
+
356
+ // getStructuredBlock returns the LAST block (placeholder)
357
+ expect(agent.getStructuredBlock('plan')).toContain('(one sentence describing');
358
+
359
+ // getAllCapturedBlocks returns ALL blocks including the real plan
360
+ const allBlocks = agent.getAllCapturedBlocks('plan');
361
+ expect(allBlocks).toHaveLength(3);
362
+ expect(allBlocks[1]).toContain('Add model selection dropdown to settings.');
363
+ });
364
+
365
+ test('structured capture resets when the agent is killed', () => {
366
+ agentManager.agents = new Map([
367
+ ['orch', { id: 'orch', getStatus: () => ({ id: 'orch' }) }],
368
+ ]);
369
+ agentManager._maxSettings = { ...originalMaxSettings, planners: 1 };
370
+ agentManager._cliSettings = { ...originalCliSettings, planners: 'claude' };
371
+ agentManager._sessionCounters = { ...originalSessionCounters, plan: 0 };
372
+
373
+ const agent = agentManager.scaleUp('planners');
374
+
375
+ agent._captureStructuredOutput(`=== PLAN START ===
376
+ SUMMARY: Temporary plan.
377
+ BRANCH: feature/tmp
378
+ FILES_TO_MODIFY:
379
+ - server/src/agents.js (temporary)
380
+ STEPS:
381
+ 1. Demonstrate reset.
382
+ TESTS_NEEDED:
383
+ - none
384
+ RISKS:
385
+ - none
386
+ === PLAN END ===`);
387
+
388
+ expect(agent.getStructuredBlock('plan')).toContain('Temporary plan.');
389
+
390
+ agent.kill();
391
+
392
+ expect(agent.getStructuredBlock('plan')).toBeNull();
393
+ expect(agent.getStructuredBlock('review')).toBeNull();
394
+ expect(agent.getAllCapturedBlocks('plan')).toEqual([]);
395
+ expect(agent.getAllCapturedBlocks('review')).toEqual([]);
396
+ });
397
+
398
+ test('_syncTaskTokens throttles updates to avoid rapid-fire broadcasts', () => {
399
+ agentManager.agents = new Map([
400
+ ['orch', { id: 'orch', getStatus: () => ({ id: 'orch' }) }],
401
+ ]);
402
+ agentManager._maxSettings = { ...originalMaxSettings, implementors: 1 };
403
+ agentManager._cliSettings = { ...originalCliSettings, implementors: 'claude' };
404
+ agentManager._sessionCounters = { ...originalSessionCounters, imp: 0 };
405
+
406
+ const agent = agentManager.scaleUp('implementors');
407
+ const taskId = 'T-THROTTLE';
408
+ const updateTokens = vi.spyOn(store, 'updateTaskTokens').mockImplementation(() => ({
409
+ id: taskId,
410
+ totalTokens: 100,
411
+ }));
412
+ vi.spyOn(store, 'getTask').mockImplementation((id) => (
413
+ id === taskId ? { id: taskId, totalTokens: 100 } : null
414
+ ));
415
+
416
+ agent.currentTask = taskId;
417
+ agent.taskTokenBase = 0;
418
+ agent.tokens = 100;
419
+
420
+ // First call should go through
421
+ agent._syncTaskTokens();
422
+ expect(updateTokens).toHaveBeenCalledTimes(1);
423
+
424
+ // Immediate second call should be throttled
425
+ agent.tokens = 200;
426
+ agent._syncTaskTokens();
427
+ expect(updateTokens).toHaveBeenCalledTimes(1);
428
+
429
+ // Simulate 2+ seconds elapsed
430
+ agent._lastTokenSync = Date.now() - 2100;
431
+ agent.tokens = 300;
432
+ agent._syncTaskTokens();
433
+ expect(updateTokens).toHaveBeenCalledTimes(2);
434
+ });
435
+
436
+ test('reconfigure removes extra idle agents and marks active overflow agents as draining', () => {
437
+ agentManager.agents = new Map([
438
+ ['orch', { id: 'orch', getStatus: () => ({ id: 'orch' }) }],
439
+ ['imp-1', {
440
+ id: 'imp-1',
441
+ status: 'idle',
442
+ draining: false,
443
+ currentTask: null,
444
+ process: null,
445
+ cli: 'claude',
446
+ subscribers: new Set(),
447
+ getStatus() {
448
+ return { id: this.id, draining: this.draining, cli: this.cli };
449
+ },
450
+ }],
451
+ ['imp-2', {
452
+ id: 'imp-2',
453
+ status: 'active',
454
+ draining: false,
455
+ currentTask: 'T-1',
456
+ process: { pid: 1 },
457
+ cli: 'claude',
458
+ subscribers: new Set(),
459
+ getStatus() {
460
+ return { id: this.id, draining: this.draining, cli: this.cli };
461
+ },
462
+ }],
463
+ ]);
464
+
465
+ agentManager.reconfigure({
466
+ agents: {
467
+ planners: { max: 4, cli: 'claude' },
468
+ implementors: { max: 1, cli: 'codex' },
469
+ reviewers: { max: 4, cli: 'claude' },
470
+ },
471
+ });
472
+
473
+ expect(agentManager.get('imp-1').cli).toBe('codex');
474
+ expect(agentManager.get('imp-2').draining).toBe(true);
475
+ expect(agentManager.get('imp-2').cli).toBe('claude');
476
+ });
91
477
  });
@@ -71,7 +71,8 @@ Key rules:
71
71
  implementation: `Follow the plan step by step
72
72
  - If required tools or dependencies are missing in the workspace, install them before continuing
73
73
  - Commit after each logical unit of work with descriptive commit messages
74
- - Run existing tests after implementation to verify nothing broke`,
74
+ - Run existing tests after implementation to verify nothing broke
75
+ - After all work is done, make a final commit if there are any uncommitted changes`,
75
76
  review: `You are an expert code reviewer.
76
77
 
77
78
  Step 1 — Gather the diff
@@ -107,9 +108,9 @@ export function getDefaults() {
107
108
  defaultRepoPath: config.REPOS[0] || '',
108
109
  workspaceRoot: DEFAULT_WORKSPACES_DIR,
109
110
  agents: {
110
- planners: { max: 4, cli: 'claude' },
111
- implementors: { max: 8, cli: getLegacyImplementorCli() },
112
- reviewers: { max: 4, cli: 'claude' },
111
+ planners: { max: 4, cli: 'claude', model: '' },
112
+ implementors: { max: 8, cli: getLegacyImplementorCli(), model: '' },
113
+ reviewers: { max: 4, cli: 'claude', model: '' },
113
114
  },
114
115
  prompts: { ...DEFAULT_PROMPTS },
115
116
  };
@@ -139,6 +140,9 @@ function normalizeSettingsShape(data) {
139
140
  data.agents[role] = defaults.agents[role];
140
141
  } else {
141
142
  delete data.agents[role].count;
143
+ if (typeof data.agents[role].model !== 'string') {
144
+ data.agents[role].model = '';
145
+ }
142
146
  }
143
147
  }
144
148
 
@@ -204,6 +208,9 @@ export function validateSettings(settings) {
204
208
  if (!validClis.includes(cfg.cli)) {
205
209
  errors.push(`${role}.cli must be one of: ${validClis.join(', ')}`);
206
210
  }
211
+ if (cfg.model !== undefined && typeof cfg.model !== 'string') {
212
+ errors.push(`${role}.model must be a string`);
213
+ }
207
214
  }
208
215
 
209
216
  if (!settings.prompts || typeof settings.prompts !== 'object') {