@forwardimpact/libeval 0.1.6 → 0.1.9
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/bin/fit-eval.js +2 -2
- package/index.js +2 -0
- package/package.json +1 -1
- package/src/agent-runner.js +178 -43
- package/src/commands/run.js +43 -18
- package/src/commands/supervise.js +59 -37
- package/src/supervisor.js +298 -59
- package/test/agent-runner-batching.test.js +271 -0
- package/test/mock-runner.js +113 -0
- package/test/supervisor-batching.test.js +175 -0
- package/test/supervisor-intervention.test.js +365 -0
- package/test/{supervisor.test.js → supervisor-output.test.js} +121 -306
- package/test/supervisor-run.test.js +310 -0
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import { describe, test } from "node:test";
|
|
2
|
+
import assert from "node:assert";
|
|
3
|
+
import { PassThrough } from "node:stream";
|
|
4
|
+
|
|
5
|
+
import { AgentRunner } from "@forwardimpact/libeval";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Create a mock query function that yields canned messages.
|
|
9
|
+
* @param {object[]} messages - Messages to yield
|
|
10
|
+
* @returns {function}
|
|
11
|
+
*/
|
|
12
|
+
function mockQuery(messages) {
|
|
13
|
+
return async function* () {
|
|
14
|
+
for (const msg of messages) {
|
|
15
|
+
yield msg;
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const textBlock = (t) => ({
|
|
21
|
+
type: "assistant",
|
|
22
|
+
message: { content: [{ type: "text", text: t }] },
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const toolOnly = (name) => ({
|
|
26
|
+
type: "assistant",
|
|
27
|
+
message: {
|
|
28
|
+
content: [{ type: "tool_use", id: "tu_" + name, name, input: {} }],
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe("AgentRunner - onBatch batching", () => {
|
|
33
|
+
test("batchSize defaults to 3", () => {
|
|
34
|
+
const runner = new AgentRunner({
|
|
35
|
+
cwd: "/tmp",
|
|
36
|
+
query: async function* () {},
|
|
37
|
+
output: new PassThrough(),
|
|
38
|
+
});
|
|
39
|
+
assert.strictEqual(runner.batchSize, 3);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("onBatch fires every 3 assistant text-block messages by default", async () => {
|
|
43
|
+
// 5 text-block messages + terminal result. With the default batchSize
|
|
44
|
+
// of 3, onBatch should fire on the 3rd text message and again on the
|
|
45
|
+
// terminal result (flushing the remaining 2).
|
|
46
|
+
const messages = [
|
|
47
|
+
{ type: "system", subtype: "init", session_id: "sess-batch" },
|
|
48
|
+
textBlock("one"),
|
|
49
|
+
textBlock("two"),
|
|
50
|
+
textBlock("three"),
|
|
51
|
+
textBlock("four"),
|
|
52
|
+
textBlock("five"),
|
|
53
|
+
{ type: "result", subtype: "success", result: "Done." },
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
const batches = [];
|
|
57
|
+
const runner = new AgentRunner({
|
|
58
|
+
cwd: "/tmp",
|
|
59
|
+
query: mockQuery(messages),
|
|
60
|
+
output: new PassThrough(),
|
|
61
|
+
});
|
|
62
|
+
runner.onBatch = async (lines) => {
|
|
63
|
+
batches.push(lines.map((l) => JSON.parse(l)));
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
await runner.run("Task");
|
|
67
|
+
|
|
68
|
+
// First flush carries init + first 3 text messages; second carries
|
|
69
|
+
// remaining 2 text messages + the result.
|
|
70
|
+
assert.strictEqual(batches.length, 2);
|
|
71
|
+
assert.strictEqual(batches[0].length, 4);
|
|
72
|
+
assert.strictEqual(batches[1].length, 3);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("onBatch honours custom batchSize", async () => {
|
|
76
|
+
// batchSize = 2: 4 text messages produce 2 flushes; result adds a 3rd.
|
|
77
|
+
const messages = [
|
|
78
|
+
textBlock("a"),
|
|
79
|
+
textBlock("b"),
|
|
80
|
+
textBlock("c"),
|
|
81
|
+
textBlock("d"),
|
|
82
|
+
{ type: "result", subtype: "success", result: "Done." },
|
|
83
|
+
];
|
|
84
|
+
|
|
85
|
+
const batches = [];
|
|
86
|
+
const runner = new AgentRunner({
|
|
87
|
+
cwd: "/tmp",
|
|
88
|
+
query: mockQuery(messages),
|
|
89
|
+
output: new PassThrough(),
|
|
90
|
+
batchSize: 2,
|
|
91
|
+
});
|
|
92
|
+
runner.onBatch = async (lines) => {
|
|
93
|
+
batches.push(lines.length);
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
await runner.run("Task");
|
|
97
|
+
|
|
98
|
+
assert.deepStrictEqual(batches, [2, 2, 1]);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("tool-only assistant messages ride along in the next flush", async () => {
|
|
102
|
+
// Tool-only assistant messages accumulate without incrementing the
|
|
103
|
+
// counter. The supervisor sees the preceding tool calls when the
|
|
104
|
+
// flush eventually fires.
|
|
105
|
+
const messages = [
|
|
106
|
+
toolOnly("Read"),
|
|
107
|
+
toolOnly("Grep"),
|
|
108
|
+
textBlock("found it"),
|
|
109
|
+
{ type: "result", subtype: "success", result: "Done." },
|
|
110
|
+
];
|
|
111
|
+
|
|
112
|
+
const batches = [];
|
|
113
|
+
const runner = new AgentRunner({
|
|
114
|
+
cwd: "/tmp",
|
|
115
|
+
query: mockQuery(messages),
|
|
116
|
+
output: new PassThrough(),
|
|
117
|
+
batchSize: 1,
|
|
118
|
+
});
|
|
119
|
+
runner.onBatch = async (lines) => {
|
|
120
|
+
batches.push(lines.map((l) => JSON.parse(l)));
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
await runner.run("Task");
|
|
124
|
+
|
|
125
|
+
// First flush triggered by the single text-block message; it carries
|
|
126
|
+
// the two preceding tool-only messages with it.
|
|
127
|
+
assert.strictEqual(batches.length, 2);
|
|
128
|
+
assert.strictEqual(batches[0].length, 3);
|
|
129
|
+
assert.strictEqual(batches[0][0].message.content[0].type, "tool_use");
|
|
130
|
+
assert.strictEqual(batches[0][1].message.content[0].type, "tool_use");
|
|
131
|
+
assert.strictEqual(batches[0][2].message.content[0].type, "text");
|
|
132
|
+
assert.strictEqual(batches[1].length, 1);
|
|
133
|
+
assert.strictEqual(batches[1][0].type, "result");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("terminal result always flushes even if batchSize not yet reached", async () => {
|
|
137
|
+
// 1 text-block + result, batchSize = 5. The counter only reaches 1
|
|
138
|
+
// but the terminal result must still flush.
|
|
139
|
+
const messages = [
|
|
140
|
+
textBlock("only one"),
|
|
141
|
+
{ type: "result", subtype: "success", result: "Done." },
|
|
142
|
+
];
|
|
143
|
+
|
|
144
|
+
const batches = [];
|
|
145
|
+
const runner = new AgentRunner({
|
|
146
|
+
cwd: "/tmp",
|
|
147
|
+
query: mockQuery(messages),
|
|
148
|
+
output: new PassThrough(),
|
|
149
|
+
batchSize: 5,
|
|
150
|
+
});
|
|
151
|
+
runner.onBatch = async (lines) => {
|
|
152
|
+
batches.push(lines.length);
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
await runner.run("Task");
|
|
156
|
+
|
|
157
|
+
assert.deepStrictEqual(batches, [2]);
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
describe("AgentRunner - terminal flush on abnormal end", () => {
|
|
162
|
+
test("iterator crash before a flush boundary still delivers the pending batch", async () => {
|
|
163
|
+
// batchSize = 3: the first two text messages accumulate without
|
|
164
|
+
// flushing. The iterator then throws before the threshold — the
|
|
165
|
+
// pending batch must ship in a terminal flush.
|
|
166
|
+
async function* crashingQuery() {
|
|
167
|
+
yield { type: "system", subtype: "init", session_id: "sess-crash" };
|
|
168
|
+
yield textBlock("step 1");
|
|
169
|
+
yield textBlock("step 2");
|
|
170
|
+
throw new Error("Claude Code process exited with code 1");
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const batches = [];
|
|
174
|
+
const runner = new AgentRunner({
|
|
175
|
+
cwd: "/tmp",
|
|
176
|
+
query: () => crashingQuery(),
|
|
177
|
+
output: new PassThrough(),
|
|
178
|
+
});
|
|
179
|
+
runner.onBatch = async (lines) => {
|
|
180
|
+
batches.push(lines.map((l) => JSON.parse(l)));
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
const result = await runner.run("Task");
|
|
184
|
+
|
|
185
|
+
assert.ok(result.error);
|
|
186
|
+
assert.match(result.error.message, /exited with code 1/);
|
|
187
|
+
assert.strictEqual(batches.length, 1);
|
|
188
|
+
assert.strictEqual(batches[0].length, 3);
|
|
189
|
+
assert.strictEqual(batches[0][0].type, "system");
|
|
190
|
+
assert.strictEqual(batches[0][1].type, "assistant");
|
|
191
|
+
assert.strictEqual(batches[0][2].type, "assistant");
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test("iterator crash after a completed batch does not re-flush", async () => {
|
|
195
|
+
// batchSize = 2: two text messages trigger a normal flush, emptying
|
|
196
|
+
// the pending batch. The iterator then throws with nothing pending —
|
|
197
|
+
// the terminal flush must be a no-op, not an empty call.
|
|
198
|
+
async function* crashingQuery() {
|
|
199
|
+
yield textBlock("a");
|
|
200
|
+
yield textBlock("b");
|
|
201
|
+
throw new Error("boom");
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const batches = [];
|
|
205
|
+
const runner = new AgentRunner({
|
|
206
|
+
cwd: "/tmp",
|
|
207
|
+
query: () => crashingQuery(),
|
|
208
|
+
output: new PassThrough(),
|
|
209
|
+
batchSize: 2,
|
|
210
|
+
});
|
|
211
|
+
runner.onBatch = async (lines) => {
|
|
212
|
+
batches.push(lines.length);
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
const result = await runner.run("Task");
|
|
216
|
+
assert.ok(result.error);
|
|
217
|
+
assert.match(result.error.message, /boom/);
|
|
218
|
+
assert.deepStrictEqual(batches, [2]);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test("natural-end iterator without a result does not trigger terminal flush", async () => {
|
|
222
|
+
// The real SDK always terminates with `result`. A mock that ends
|
|
223
|
+
// naturally with pending lines is treated as an incomplete stub —
|
|
224
|
+
// no phantom flush, since nothing about a natural end warrants a
|
|
225
|
+
// new mid-turn review.
|
|
226
|
+
async function* noResultQuery() {
|
|
227
|
+
yield textBlock("one");
|
|
228
|
+
yield textBlock("two");
|
|
229
|
+
// No result, no error — just ends.
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const batches = [];
|
|
233
|
+
const runner = new AgentRunner({
|
|
234
|
+
cwd: "/tmp",
|
|
235
|
+
query: () => noResultQuery(),
|
|
236
|
+
output: new PassThrough(),
|
|
237
|
+
batchSize: 3,
|
|
238
|
+
});
|
|
239
|
+
runner.onBatch = async (lines) => {
|
|
240
|
+
batches.push(lines.length);
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
const result = await runner.run("Task");
|
|
244
|
+
assert.strictEqual(result.error, null);
|
|
245
|
+
assert.strictEqual(batches.length, 0);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test("onBatch throw during terminal flush does not mask an earlier error", async () => {
|
|
249
|
+
// The iterator threw first; the terminal flush also throws. The
|
|
250
|
+
// original iterator error must win — it is the more actionable
|
|
251
|
+
// condition to surface to the caller.
|
|
252
|
+
async function* crashingQuery() {
|
|
253
|
+
yield textBlock("partial");
|
|
254
|
+
throw new Error("original failure");
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const runner = new AgentRunner({
|
|
258
|
+
cwd: "/tmp",
|
|
259
|
+
query: () => crashingQuery(),
|
|
260
|
+
output: new PassThrough(),
|
|
261
|
+
batchSize: 3,
|
|
262
|
+
});
|
|
263
|
+
runner.onBatch = async () => {
|
|
264
|
+
throw new Error("flush failure");
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
const result = await runner.run("Task");
|
|
268
|
+
assert.ok(result.error);
|
|
269
|
+
assert.match(result.error.message, /original failure/);
|
|
270
|
+
});
|
|
271
|
+
});
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test-only mock factory for AgentRunner. Yields pre-scripted responses,
|
|
3
|
+
* and (when an `onBatch` callback is set) fires it at the same boundaries
|
|
4
|
+
* the real AgentRunner would: every `runner.batchSize` assistant messages
|
|
5
|
+
* with a text block, and the terminal `result` message. Tool-only
|
|
6
|
+
* assistant messages accumulate into the pending batch without counting
|
|
7
|
+
* toward the threshold. If the callback calls `abort()`, the mock stops
|
|
8
|
+
* iterating that response's messages and reports `aborted: true` — any
|
|
9
|
+
* lines that never made it through a flush boundary then ship in a
|
|
10
|
+
* terminal batch, mirroring the real runner's finally-flush.
|
|
11
|
+
*
|
|
12
|
+
* Intentionally a regular module (not a test file) so describe/test blocks
|
|
13
|
+
* here would not run. Lives under test/ to make its scope explicit.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { PassThrough } from "node:stream";
|
|
17
|
+
import { AgentRunner } from "@forwardimpact/libeval";
|
|
18
|
+
import { hasTextBlock } from "../src/agent-runner.js";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Create a mock AgentRunner that yields pre-scripted responses. Each call
|
|
22
|
+
* to `run()` or `resume()` pops the next response from the array.
|
|
23
|
+
* @param {object[]} responses - Array of {text, success} objects
|
|
24
|
+
* @param {object[]} [messages] - Messages to buffer per response
|
|
25
|
+
* @returns {AgentRunner}
|
|
26
|
+
*/
|
|
27
|
+
export function createMockRunner(responses, messages) {
|
|
28
|
+
const output = new PassThrough();
|
|
29
|
+
let callIndex = 0;
|
|
30
|
+
|
|
31
|
+
const runner = new AgentRunner({
|
|
32
|
+
cwd: "/tmp",
|
|
33
|
+
query: async function* () {},
|
|
34
|
+
output,
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const consume = async (msgs) => {
|
|
38
|
+
let aborted = false;
|
|
39
|
+
const pendingBatch = [];
|
|
40
|
+
let assistantTextCount = 0;
|
|
41
|
+
for (const m of msgs) {
|
|
42
|
+
const line = JSON.stringify(m);
|
|
43
|
+
runner.buffer.push(line);
|
|
44
|
+
if (runner.onLine) runner.onLine(line);
|
|
45
|
+
if (runner.onBatch) pendingBatch.push(line);
|
|
46
|
+
|
|
47
|
+
if (hasTextBlock(m)) {
|
|
48
|
+
assistantTextCount++;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const shouldFlush =
|
|
52
|
+
runner.onBatch &&
|
|
53
|
+
(m.type === "result" || assistantTextCount >= runner.batchSize);
|
|
54
|
+
if (shouldFlush) {
|
|
55
|
+
assistantTextCount = 0;
|
|
56
|
+
const batchLines = pendingBatch.splice(0);
|
|
57
|
+
await runner.onBatch(batchLines, {
|
|
58
|
+
abort: () => {
|
|
59
|
+
aborted = true;
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
if (aborted) break;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
// Terminal flush: mirror the real AgentRunner's abnormal-end path —
|
|
66
|
+
// an aborted scripted run delivers any pending tail so the supervisor
|
|
67
|
+
// sees the partial state. Natural-end without a `result` marker is
|
|
68
|
+
// treated as a simplified stub (no phantom flush), matching the real
|
|
69
|
+
// runner's rule that terminal flush only fires on error/abort.
|
|
70
|
+
if (aborted && runner.onBatch && pendingBatch.length > 0) {
|
|
71
|
+
const batchLines = pendingBatch.splice(0);
|
|
72
|
+
await runner.onBatch(batchLines, {
|
|
73
|
+
abort: () => {
|
|
74
|
+
aborted = true;
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
return aborted;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
runner.run = async (_task) => {
|
|
82
|
+
const resp = responses[callIndex++];
|
|
83
|
+
const msgs = messages?.[callIndex - 1] ?? [
|
|
84
|
+
{ type: "assistant", content: resp.text },
|
|
85
|
+
];
|
|
86
|
+
const aborted = await consume(msgs);
|
|
87
|
+
runner.sessionId = "mock-session";
|
|
88
|
+
return {
|
|
89
|
+
success: resp.success ?? true,
|
|
90
|
+
text: resp.text,
|
|
91
|
+
sessionId: "mock-session",
|
|
92
|
+
aborted,
|
|
93
|
+
error: null,
|
|
94
|
+
};
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
runner.resume = async (_prompt) => {
|
|
98
|
+
const resp = responses[callIndex++];
|
|
99
|
+
const msgs = messages?.[callIndex - 1] ?? [
|
|
100
|
+
{ type: "assistant", content: resp.text },
|
|
101
|
+
];
|
|
102
|
+
const aborted = await consume(msgs);
|
|
103
|
+
return {
|
|
104
|
+
success: resp.success ?? true,
|
|
105
|
+
text: resp.text,
|
|
106
|
+
sessionId: runner.sessionId,
|
|
107
|
+
aborted,
|
|
108
|
+
error: null,
|
|
109
|
+
};
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
return runner;
|
|
113
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { describe, test } from "node:test";
|
|
2
|
+
import assert from "node:assert";
|
|
3
|
+
import { PassThrough } from "node:stream";
|
|
4
|
+
|
|
5
|
+
import { Supervisor } from "@forwardimpact/libeval";
|
|
6
|
+
import { createMockRunner } from "./mock-runner.js";
|
|
7
|
+
|
|
8
|
+
const textBlock = (t) => ({
|
|
9
|
+
type: "assistant",
|
|
10
|
+
message: { content: [{ type: "text", text: t }] },
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
describe("Supervisor - batching at the default batchSize", () => {
|
|
14
|
+
test("mid-turn review fires once per 3 agent text messages", async () => {
|
|
15
|
+
// Agent emits 7 text-block assistant messages in one turn. With the
|
|
16
|
+
// default batchSize of 3 the supervisor's mid-turn review should fire
|
|
17
|
+
// twice (after messages 3 and 6) plus once more from the terminal
|
|
18
|
+
// result flush carrying the remaining message — not seven times, as
|
|
19
|
+
// the old per-message flushing would have done.
|
|
20
|
+
const agentMessages = [
|
|
21
|
+
[
|
|
22
|
+
textBlock("step 1"),
|
|
23
|
+
textBlock("step 2"),
|
|
24
|
+
textBlock("step 3"),
|
|
25
|
+
textBlock("step 4"),
|
|
26
|
+
textBlock("step 5"),
|
|
27
|
+
textBlock("step 6"),
|
|
28
|
+
textBlock("step 7"),
|
|
29
|
+
{ type: "result", subtype: "success", result: "Done." },
|
|
30
|
+
],
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
const agentRunner = createMockRunner(
|
|
34
|
+
[{ text: "Finished." }],
|
|
35
|
+
agentMessages,
|
|
36
|
+
);
|
|
37
|
+
// Leave batchSize at the default (3) — this is the behaviour we're
|
|
38
|
+
// verifying end-to-end through the supervisor loop.
|
|
39
|
+
assert.strictEqual(agentRunner.batchSize, 3);
|
|
40
|
+
|
|
41
|
+
const supervisorRunner = createMockRunner([
|
|
42
|
+
{ text: "Welcome. Begin." },
|
|
43
|
+
{ text: "Keep going." }, // mid-turn batch 1 (messages 1-3)
|
|
44
|
+
{ text: "Keep going." }, // mid-turn batch 2 (messages 4-6)
|
|
45
|
+
{ text: "Keep going." }, // terminal result flush (message 7 + result)
|
|
46
|
+
{ text: "Good work.\n\nEVALUATION_COMPLETE" }, // end-of-turn review
|
|
47
|
+
]);
|
|
48
|
+
|
|
49
|
+
const output = new PassThrough();
|
|
50
|
+
const supervisor = new Supervisor({
|
|
51
|
+
agentRunner,
|
|
52
|
+
supervisorRunner,
|
|
53
|
+
output,
|
|
54
|
+
maxTurns: 10,
|
|
55
|
+
});
|
|
56
|
+
agentRunner.onLine = (line) => supervisor.emitLine(line);
|
|
57
|
+
supervisorRunner.onLine = (line) => supervisor.emitLine(line);
|
|
58
|
+
|
|
59
|
+
const result = await supervisor.run("Do the task");
|
|
60
|
+
assert.strictEqual(result.success, true);
|
|
61
|
+
|
|
62
|
+
const midTurnReviews = (output.read()?.toString() ?? "")
|
|
63
|
+
.trim()
|
|
64
|
+
.split("\n")
|
|
65
|
+
.filter((l) => l.length > 0)
|
|
66
|
+
.map((l) => JSON.parse(l))
|
|
67
|
+
.filter(
|
|
68
|
+
(l) =>
|
|
69
|
+
l.source === "orchestrator" && l.event?.type === "mid_turn_review",
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
// 3 flushes total: two at the batchSize threshold (messages 3 and 6),
|
|
73
|
+
// one at the terminal result (trailing message + result marker).
|
|
74
|
+
assert.strictEqual(
|
|
75
|
+
midTurnReviews.length,
|
|
76
|
+
3,
|
|
77
|
+
"Supervisor should review 3 times per turn, not 7",
|
|
78
|
+
);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("EVALUATION_INTERVENTION at the default batchSize still aborts and relays", async () => {
|
|
82
|
+
// Companion to the observation test above: the 3-message batching and
|
|
83
|
+
// the intervention path exercised together.
|
|
84
|
+
//
|
|
85
|
+
// Agent call 1 emits 3 text-block messages (triggering a flush at the
|
|
86
|
+
// 3rd). The supervisor intervenes; the agent SDK session aborts and
|
|
87
|
+
// the supervisor's intervention text is relayed into resume(). Agent
|
|
88
|
+
// call 2 has 1 text block — below the batchSize threshold — so no
|
|
89
|
+
// extra mid-turn flush fires, and the supervisor jumps straight to
|
|
90
|
+
// the end-of-turn review.
|
|
91
|
+
const agentMessages = [
|
|
92
|
+
[
|
|
93
|
+
textBlock("reading docs"),
|
|
94
|
+
textBlock("running Bash"),
|
|
95
|
+
textBlock("found the wrong path"),
|
|
96
|
+
],
|
|
97
|
+
[textBlock("corrected, using the documented path")],
|
|
98
|
+
];
|
|
99
|
+
|
|
100
|
+
const agentRunner = createMockRunner(
|
|
101
|
+
[{ text: "wrong path" }, { text: "corrected" }],
|
|
102
|
+
agentMessages,
|
|
103
|
+
);
|
|
104
|
+
assert.strictEqual(agentRunner.batchSize, 3);
|
|
105
|
+
|
|
106
|
+
const supervisorMessages = [
|
|
107
|
+
undefined,
|
|
108
|
+
[
|
|
109
|
+
{
|
|
110
|
+
type: "assistant",
|
|
111
|
+
message: {
|
|
112
|
+
content: [
|
|
113
|
+
{
|
|
114
|
+
type: "text",
|
|
115
|
+
text: "EVALUATION_INTERVENTION Use the documented path.",
|
|
116
|
+
},
|
|
117
|
+
],
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
],
|
|
121
|
+
undefined,
|
|
122
|
+
];
|
|
123
|
+
|
|
124
|
+
const supervisorRunner = createMockRunner(
|
|
125
|
+
[
|
|
126
|
+
{ text: "Welcome. Begin." },
|
|
127
|
+
{ text: "EVALUATION_INTERVENTION Use the documented path." },
|
|
128
|
+
{ text: "Good.\n\nEVALUATION_COMPLETE" },
|
|
129
|
+
],
|
|
130
|
+
supervisorMessages,
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
const output = new PassThrough();
|
|
134
|
+
const supervisor = new Supervisor({
|
|
135
|
+
agentRunner,
|
|
136
|
+
supervisorRunner,
|
|
137
|
+
output,
|
|
138
|
+
maxTurns: 10,
|
|
139
|
+
});
|
|
140
|
+
agentRunner.onLine = (line) => supervisor.emitLine(line);
|
|
141
|
+
supervisorRunner.onLine = (line) => supervisor.emitLine(line);
|
|
142
|
+
|
|
143
|
+
let resumePrompt = null;
|
|
144
|
+
const origResume = agentRunner.resume;
|
|
145
|
+
agentRunner.resume = async (prompt) => {
|
|
146
|
+
resumePrompt = prompt;
|
|
147
|
+
return origResume.call(agentRunner, prompt);
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const result = await supervisor.run("Install");
|
|
151
|
+
assert.strictEqual(result.success, true);
|
|
152
|
+
assert.strictEqual(result.turns, 1);
|
|
153
|
+
assert.ok(
|
|
154
|
+
resumePrompt && resumePrompt.includes("documented path"),
|
|
155
|
+
"Resume prompt should carry the supervisor's intervention text",
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
const orchestratorEvents = (output.read()?.toString() ?? "")
|
|
159
|
+
.trim()
|
|
160
|
+
.split("\n")
|
|
161
|
+
.filter((l) => l.length > 0)
|
|
162
|
+
.map((l) => JSON.parse(l))
|
|
163
|
+
.filter((e) => e.source === "orchestrator");
|
|
164
|
+
assert.ok(
|
|
165
|
+
orchestratorEvents.some(
|
|
166
|
+
(e) => e.event?.type === "intervention_requested",
|
|
167
|
+
),
|
|
168
|
+
"Trace should contain intervention_requested",
|
|
169
|
+
);
|
|
170
|
+
assert.ok(
|
|
171
|
+
orchestratorEvents.some((e) => e.event?.type === "intervention_relayed"),
|
|
172
|
+
"Trace should contain intervention_relayed",
|
|
173
|
+
);
|
|
174
|
+
});
|
|
175
|
+
});
|