@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.
- package/README.md +17 -1
- package/bin/bankan.js +1 -1
- package/client/dist/assets/{index-pUZAEGtO.js → index-CHxyLFN_.js} +17 -15
- package/client/dist/index.html +1 -1
- package/docs/images/workflow/taskflow_animated.gif +0 -0
- package/package.json +14 -2
- package/scripts/setup.js +1 -5
- package/server/src/agents.js +123 -4
- package/server/src/agents.test.js +462 -76
- package/server/src/config.js +11 -4
- package/server/src/config.test.js +170 -0
- package/server/src/index.js +11 -2
- package/server/src/linting.test.js +37 -0
- package/server/src/orchestrator.js +279 -99
- package/server/src/orchestrator.test.js +431 -0
- package/server/src/paths.test.js +49 -0
- package/server/src/sessionHistory.test.js +39 -0
- package/server/src/store.js +2 -3
- package/server/src/store.test.js +186 -0
- package/server/src/workflow.js +23 -7
- package/server/src/workflow.test.js +216 -71
|
@@ -1,91 +1,477 @@
|
|
|
1
|
-
import test from '
|
|
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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
agentManager.
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
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
|
-
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
});
|
package/server/src/config.js
CHANGED
|
@@ -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') {
|