@maxiaochao/pi-toolkit 0.13.5 → 0.13.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +8 -0
- package/README.md +2 -0
- package/package.json +1 -1
- package/packages/background-tasks/CHANGELOG.md +12 -0
- package/packages/background-tasks/README.md +37 -6
- package/packages/background-tasks/TESTING.md +9 -2
- package/packages/background-tasks/docs/demo.gif +0 -0
- package/packages/background-tasks/docs/screenshot.png +0 -0
- package/packages/background-tasks/extensions/background-tasks.ts +88 -8
- package/packages/background-tasks/package.json +2 -2
- package/packages/background-tasks/tests/compaction.mjs +167 -0
- package/packages/background-tasks/tests/extension.mjs +309 -9
- package/packages/background-tasks/tests/fixtures/notifications.ts +77 -0
- package/packages/background-tasks/tests/fixtures/provider.ts +14 -4
- package/packages/background-tasks/tests/interruptions.mjs +140 -0
- package/packages/background-tasks/tests/notifications.mjs +80 -0
- package/packages/background-tasks/tests/tui-smoke.py +8 -4
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
|
+
import { unlinkSync } from "node:fs";
|
|
2
3
|
import { test } from "node:test";
|
|
3
4
|
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
4
5
|
import register from "../extensions/background-tasks.ts";
|
|
@@ -13,10 +14,11 @@ function harness(mode = "tui", hasUI = true) {
|
|
|
13
14
|
const widgets = new Map([["scheduler", { render: () => ["Scheduler"] }], ["herdr-subagents", { render: () => ["Fleet"] }]]);
|
|
14
15
|
const placements = new Map();
|
|
15
16
|
const statuses = new Map();
|
|
16
|
-
|
|
17
|
+
const idleChecks = [];
|
|
18
|
+
let tool, renders = 0, idle = true;
|
|
17
19
|
const theme = { fg: (_color, text) => text, bold: (text) => text };
|
|
18
20
|
const ctx = {
|
|
19
|
-
cwd: f.root, mode, hasUI,
|
|
21
|
+
cwd: f.root, mode, hasUI, isIdle: () => { idleChecks.push(f.clock.now()); return idle; },
|
|
20
22
|
ui: {
|
|
21
23
|
setWidget(key, factory, options) {
|
|
22
24
|
if (!factory) { widgets.delete(key); return; }
|
|
@@ -35,7 +37,8 @@ function harness(mode = "tui", hasUI = true) {
|
|
|
35
37
|
};
|
|
36
38
|
register(pi, { operations: f.operations, clock: f.clock, logRoot: f.root });
|
|
37
39
|
return {
|
|
38
|
-
...f, handlers, commands, messages, notices, widgets, placements, statuses, ctx,
|
|
40
|
+
...f, handlers, commands, messages, notices, widgets, placements, statuses, idleChecks, ctx,
|
|
41
|
+
setIdle(value) { idle = value; },
|
|
39
42
|
get tool() { return tool; }, get renders() { return renders; },
|
|
40
43
|
async close() {
|
|
41
44
|
const closing = handlers.get("session_shutdown")({}, ctx);
|
|
@@ -46,24 +49,318 @@ function harness(mode = "tui", hasUI = true) {
|
|
|
46
49
|
};
|
|
47
50
|
}
|
|
48
51
|
|
|
52
|
+
test("completions during a synchronous tool are merged before the next model request", async () => {
|
|
53
|
+
const h = harness();
|
|
54
|
+
try {
|
|
55
|
+
await h.handlers.get("session_start")({}, h.ctx);
|
|
56
|
+
h.setIdle(false);
|
|
57
|
+
for (const command of ["first", "second"]) await h.tool.execute(command, { action: "start", command });
|
|
58
|
+
h.calls[0].resolve(1);
|
|
59
|
+
h.calls[1].resolve(2);
|
|
60
|
+
await flush();
|
|
61
|
+
assert.equal(h.messages.length, 0, "busy completions must not enter an irrevocable host queue");
|
|
62
|
+
assert.match(h.widgets.get("background-tasks").render(140).join("\n"), /failed/);
|
|
63
|
+
const existing = { type: "custom", customType: "other-extension", data: { preserved: true } };
|
|
64
|
+
const boundary = h.handlers.get("turn_end")({ entries: [existing], continue: true, outcome: "completed" }, h.ctx);
|
|
65
|
+
assert.equal(boundary.continue, true);
|
|
66
|
+
assert.equal(boundary.entries.length, 2);
|
|
67
|
+
assert.deepEqual(boundary.entries[0], existing);
|
|
68
|
+
assert.equal(boundary.entries[1].details.tasks.length, 2);
|
|
69
|
+
assert.match(boundary.entries[1].content, /Ended at: 1970-01-01T00:00:01.000Z/);
|
|
70
|
+
assert.equal(h.handlers.get("agent_before_settle")({ entries: [], outcome: "completed" }, h.ctx), undefined);
|
|
71
|
+
} finally { await h.close(); }
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("a completion during manual compaction wakes after idle without another agent boundary", async () => {
|
|
75
|
+
const h = harness("print", false);
|
|
76
|
+
try {
|
|
77
|
+
await h.handlers.get("session_start")({}, h.ctx);
|
|
78
|
+
await h.tool.execute("start", { action: "start", command: "first" });
|
|
79
|
+
h.setIdle(false);
|
|
80
|
+
h.calls[0].resolve();
|
|
81
|
+
await flush();
|
|
82
|
+
h.clock.advance(0);
|
|
83
|
+
h.clock.advance(1000);
|
|
84
|
+
assert.equal(h.messages.length, 0);
|
|
85
|
+
h.setIdle(true); // Manual compaction succeeds, fails or is cancelled; none emits agent_settled.
|
|
86
|
+
h.clock.advance(2000);
|
|
87
|
+
assert.equal(h.messages.length, 1);
|
|
88
|
+
assert.equal(h.clock.timers.size, 0);
|
|
89
|
+
} finally { await h.close(); }
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("busy notification checks back off to 30 seconds without expiring unread results", async () => {
|
|
93
|
+
const h = harness("print", false);
|
|
94
|
+
try {
|
|
95
|
+
await h.handlers.get("session_start")({}, h.ctx);
|
|
96
|
+
h.setIdle(false);
|
|
97
|
+
await h.tool.execute("start", { action: "start", command: "first" });
|
|
98
|
+
h.calls[0].resolve();
|
|
99
|
+
await flush();
|
|
100
|
+
const started = h.clock.now();
|
|
101
|
+
h.clock.advance(91000);
|
|
102
|
+
assert.deepEqual(h.idleChecks.map((time) => time - started), [0, 1000, 3000, 7000, 15000, 31000, 61000, 91000]);
|
|
103
|
+
assert.equal(h.messages.length, 0);
|
|
104
|
+
assert.equal(h.clock.timers.size, 1);
|
|
105
|
+
h.clock.advance(24 * 60 * 60 * 1000);
|
|
106
|
+
assert.equal(h.messages.length, 0, "no timeout may silently drop the unread batch");
|
|
107
|
+
assert.equal(h.clock.timers.size, 1);
|
|
108
|
+
h.setIdle(true);
|
|
109
|
+
const checks = h.idleChecks.length;
|
|
110
|
+
h.clock.advance(29999);
|
|
111
|
+
assert.equal(h.idleChecks.length, checks);
|
|
112
|
+
h.clock.advance(1);
|
|
113
|
+
assert.equal(h.messages.length, 1);
|
|
114
|
+
assert.equal(h.clock.timers.size, 0);
|
|
115
|
+
} finally { await h.close(); }
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("new completions share the backed-off timer, while tool boundaries deliver immediately and reset backoff", async () => {
|
|
119
|
+
const h = harness("print", false);
|
|
120
|
+
try {
|
|
121
|
+
await h.handlers.get("session_start")({}, h.ctx);
|
|
122
|
+
h.setIdle(false);
|
|
123
|
+
await h.tool.execute("start", { action: "start", command: "first" });
|
|
124
|
+
h.calls[0].resolve();
|
|
125
|
+
await flush();
|
|
126
|
+
h.clock.advance(61000);
|
|
127
|
+
await h.tool.execute("second", { action: "start", command: "second" });
|
|
128
|
+
h.calls[1].resolve();
|
|
129
|
+
await flush();
|
|
130
|
+
assert.equal(h.clock.timers.size, 1);
|
|
131
|
+
const checks = h.idleChecks.length;
|
|
132
|
+
h.clock.advance(0);
|
|
133
|
+
assert.equal(h.idleChecks.length, checks, "a new completion must not restart an active backoff");
|
|
134
|
+
const boundary = h.handlers.get("turn_end")({ entries: [], outcome: "completed" }, h.ctx);
|
|
135
|
+
assert.equal(boundary.entries[0].details.tasks.length, 2);
|
|
136
|
+
assert.equal(h.clock.timers.size, 0, "the boundary must not wait for the 30-second timer");
|
|
137
|
+
await h.tool.execute("third", { action: "start", command: "third" });
|
|
138
|
+
h.calls[2].resolve();
|
|
139
|
+
await flush();
|
|
140
|
+
h.clock.advance(0);
|
|
141
|
+
assert.equal(h.idleChecks.length, checks + 1, "a fresh batch starts with an immediate check");
|
|
142
|
+
h.clock.advance(999);
|
|
143
|
+
assert.equal(h.idleChecks.length, checks + 1);
|
|
144
|
+
h.clock.advance(1);
|
|
145
|
+
assert.equal(h.idleChecks.length, checks + 2, "a fresh batch retries after one second, not thirty");
|
|
146
|
+
} finally { await h.close(); }
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
for (const action of ["agent_before_settle", "output", "aborted", "error", "session_shutdown"]) {
|
|
150
|
+
test(`${action} clears a capped notification retry without stale wakes`, async () => {
|
|
151
|
+
const h = harness("print", false);
|
|
152
|
+
try {
|
|
153
|
+
await h.handlers.get("session_start")({}, h.ctx);
|
|
154
|
+
h.setIdle(false);
|
|
155
|
+
const task = (await h.tool.execute("start", { action: "start", command: "first" })).structuredContent.task;
|
|
156
|
+
h.calls[0].resolve();
|
|
157
|
+
await flush();
|
|
158
|
+
h.clock.advance(61000);
|
|
159
|
+
assert.equal(h.clock.timers.size, 1);
|
|
160
|
+
if (action === "output") {
|
|
161
|
+
await h.tool.execute("read", { action, id: task.id });
|
|
162
|
+
} else if (action === "session_shutdown") {
|
|
163
|
+
await h.handlers.get(action)({}, h.ctx);
|
|
164
|
+
await h.handlers.get("session_start")({}, h.ctx);
|
|
165
|
+
} else {
|
|
166
|
+
const event = { entries: [], outcome: action === "agent_before_settle" ? "completed" : action };
|
|
167
|
+
const boundary = h.handlers.get("agent_before_settle")(event, h.ctx);
|
|
168
|
+
if (action === "agent_before_settle") assert.equal(boundary.entries[0].details.tasks[0].id, task.id);
|
|
169
|
+
else assert.equal(boundary, undefined);
|
|
170
|
+
}
|
|
171
|
+
assert.equal(h.clock.timers.size, 0);
|
|
172
|
+
h.setIdle(true);
|
|
173
|
+
const checks = h.idleChecks.length;
|
|
174
|
+
h.clock.advance(24 * 60 * 60 * 1000);
|
|
175
|
+
assert.equal(h.idleChecks.length, checks);
|
|
176
|
+
assert.equal(h.messages.length, 0);
|
|
177
|
+
} finally { await h.close(); }
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
test("failed terminal log reads do not consume pending completion", async () => {
|
|
182
|
+
const h = harness("print", false);
|
|
183
|
+
try {
|
|
184
|
+
await h.handlers.get("session_start")({}, h.ctx);
|
|
185
|
+
h.setIdle(false);
|
|
186
|
+
const task = (await h.tool.execute("start", { action: "start", command: "first" })).structuredContent.task;
|
|
187
|
+
h.calls[0].resolve();
|
|
188
|
+
await flush();
|
|
189
|
+
unlinkSync(task.log_path);
|
|
190
|
+
const result = await h.tool.execute("read", { action: "output", id: task.id });
|
|
191
|
+
assert.equal(result.isError, true);
|
|
192
|
+
assert.match(result.structuredContent.error, /Cannot read task log/);
|
|
193
|
+
const boundary = h.handlers.get("turn_end")({ entries: [], outcome: "completed" }, h.ctx);
|
|
194
|
+
assert.equal(boundary.entries[0].details.tasks[0].id, task.id);
|
|
195
|
+
} finally { await h.close(); }
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test("terminal output consumed by the agent removes only that pending reminder", async () => {
|
|
199
|
+
const h = harness();
|
|
200
|
+
try {
|
|
201
|
+
await h.handlers.get("session_start")({}, h.ctx);
|
|
202
|
+
h.setIdle(false);
|
|
203
|
+
const a = (await h.tool.execute("a", { action: "start", command: "first" })).structuredContent.task;
|
|
204
|
+
const b = (await h.tool.execute("b", { action: "start", command: "second" })).structuredContent.task;
|
|
205
|
+
await h.tool.execute("running", { action: "output", id: b.id });
|
|
206
|
+
h.calls[0].resolve(7);
|
|
207
|
+
h.calls[1].resolve();
|
|
208
|
+
await flush();
|
|
209
|
+
await h.tool.execute("list", { action: "list" });
|
|
210
|
+
await h.commands.get("bg").handler(`output ${b.id}`, h.ctx);
|
|
211
|
+
const output = await h.tool.execute("read", { action: "output", id: a.id });
|
|
212
|
+
assert.equal(output.isError, true, "a nonzero command is still a successful log read");
|
|
213
|
+
const boundary = h.handlers.get("turn_end")({ entries: [], outcome: "completed" }, h.ctx);
|
|
214
|
+
assert.deepEqual(boundary.entries[0].details.tasks.map((task) => task.id), [b.id]);
|
|
215
|
+
assert.equal(h.messages.length, 1, "only the user's inspection message was sent");
|
|
216
|
+
} finally { await h.close(); }
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test("idle completions coalesce, but a newly busy session receives them at its next boundary", async () => {
|
|
220
|
+
const h = harness();
|
|
221
|
+
try {
|
|
222
|
+
await h.handlers.get("session_start")({}, h.ctx);
|
|
223
|
+
for (const command of ["first", "second"]) await h.tool.execute(command, { action: "start", command });
|
|
224
|
+
h.calls[0].resolve();
|
|
225
|
+
h.calls[1].resolve(1);
|
|
226
|
+
await flush();
|
|
227
|
+
assert.equal(h.messages.length, 0);
|
|
228
|
+
h.setIdle(false);
|
|
229
|
+
h.clock.advance(0);
|
|
230
|
+
assert.equal(h.messages.length, 0, "idle wake must recheck state before sending");
|
|
231
|
+
const boundary = h.handlers.get("agent_before_settle")({ entries: [], outcome: "completed" }, h.ctx);
|
|
232
|
+
assert.equal(boundary.entries[0].details.tasks.length, 2);
|
|
233
|
+
assert.equal(boundary.continue, true);
|
|
234
|
+
assert.equal(h.handlers.get("turn_end")({ entries: [], outcome: "completed" }, h.ctx), undefined);
|
|
235
|
+
} finally { await h.close(); }
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
test("idle wake batches current completions and a terminal read can remove the whole batch", async () => {
|
|
239
|
+
const h = harness("print", false);
|
|
240
|
+
try {
|
|
241
|
+
await h.handlers.get("session_start")({}, h.ctx);
|
|
242
|
+
for (const command of ["first", "second"]) await h.tool.execute(command, { action: "start", command });
|
|
243
|
+
h.calls[0].resolve();
|
|
244
|
+
h.calls[1].resolve(1);
|
|
245
|
+
await flush();
|
|
246
|
+
h.clock.advance(0);
|
|
247
|
+
assert.equal(h.messages.length, 1);
|
|
248
|
+
assert.equal(h.messages[0].message.details.tasks.length, 2);
|
|
249
|
+
const task = (await h.tool.execute("third", { action: "start", command: "third" })).structuredContent.task;
|
|
250
|
+
h.calls[2].resolve();
|
|
251
|
+
await flush();
|
|
252
|
+
await h.tool.execute("read", { action: "output", id: task.id });
|
|
253
|
+
h.clock.advance(0);
|
|
254
|
+
assert.equal(h.messages.length, 1);
|
|
255
|
+
assert.equal(h.clock.timers.size, 0);
|
|
256
|
+
} finally { await h.close(); }
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
for (const outcome of ["aborted", "error"]) {
|
|
260
|
+
test(`${outcome} boundaries do not force continuation or replay a scheduled idle wake`, async () => {
|
|
261
|
+
const h = harness();
|
|
262
|
+
try {
|
|
263
|
+
await h.handlers.get("session_start")({}, h.ctx);
|
|
264
|
+
await h.tool.execute("start", { action: "start", command: "first" });
|
|
265
|
+
h.calls[0].resolve();
|
|
266
|
+
await flush();
|
|
267
|
+
h.setIdle(false);
|
|
268
|
+
assert.equal(h.handlers.get("turn_end")({ entries: [], outcome }, h.ctx), undefined);
|
|
269
|
+
assert.equal(h.handlers.get("agent_before_settle")({ entries: [], outcome }, h.ctx), undefined);
|
|
270
|
+
h.handlers.get("agent_settled")({ aborted: outcome === "aborted" }, h.ctx);
|
|
271
|
+
h.setIdle(true);
|
|
272
|
+
h.clock.advance(0);
|
|
273
|
+
assert.equal(h.messages.length, 0);
|
|
274
|
+
if (outcome === "aborted") {
|
|
275
|
+
const resumed = h.handlers.get("before_agent_start")({ messageType: "user", prompt: "Continue" }, h.ctx);
|
|
276
|
+
assert.equal(resumed.message.details.tasks.length, 1, "the next user prompt receives retained results");
|
|
277
|
+
assert.equal(h.handlers.get("turn_end")({ entries: [], outcome: "completed" }, h.ctx), undefined);
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
const boundary = h.handlers.get("turn_end")({ entries: [], outcome: "completed" }, h.ctx);
|
|
281
|
+
assert.equal(boundary.entries[0].details.tasks.length, 1, "unread results remain available");
|
|
282
|
+
} finally { await h.close(); }
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
test("jobs finishing after a user interrupt stay queryable without restarting the agent until the next user prompt", async () => {
|
|
287
|
+
const h = harness("print", false);
|
|
288
|
+
try {
|
|
289
|
+
await h.handlers.get("session_start")({}, h.ctx);
|
|
290
|
+
const tasks = [];
|
|
291
|
+
for (const command of ["first", "second"]) {
|
|
292
|
+
tasks.push((await h.tool.execute(command, { action: "start", command })).structuredContent.task);
|
|
293
|
+
}
|
|
294
|
+
h.setIdle(false);
|
|
295
|
+
h.handlers.get("turn_end")({ entries: [], outcome: "aborted" }, h.ctx);
|
|
296
|
+
h.handlers.get("agent_before_settle")({ entries: [], outcome: "aborted" }, h.ctx);
|
|
297
|
+
h.handlers.get("agent_settled")({ aborted: true }, h.ctx);
|
|
298
|
+
h.setIdle(true);
|
|
299
|
+
for (const call of h.calls) {
|
|
300
|
+
assert.equal(call.signal.aborted, false, "interrupting the agent must not cancel accepted jobs");
|
|
301
|
+
call.resolve(7);
|
|
302
|
+
}
|
|
303
|
+
await flush();
|
|
304
|
+
h.clock.advance(24 * 60 * 60 * 1000);
|
|
305
|
+
assert.equal(h.messages.length, 0, "late completions must not restart the interrupted agent");
|
|
306
|
+
assert.equal(h.clock.timers.size, 0);
|
|
307
|
+
const list = await h.tool.execute("list", { action: "list" });
|
|
308
|
+
assert.ok(list.structuredContent.tasks.every((task) => task.status === "failed"));
|
|
309
|
+
assert.equal(h.handlers.get("before_agent_start")?.({ messageType: "custom" }, h.ctx), undefined);
|
|
310
|
+
assert.equal(h.handlers.get("turn_end")({ entries: [], outcome: "completed" }, h.ctx), undefined, "another extension's run must not release the interrupt");
|
|
311
|
+
const output = await h.tool.execute("read", { action: "output", id: tasks[0].id });
|
|
312
|
+
assert.equal(output.structuredContent.task.exit_code, 7);
|
|
313
|
+
const resumed = h.handlers.get("before_agent_start")({ messageType: "user", prompt: "Continue" }, h.ctx);
|
|
314
|
+
assert.deepEqual(resumed.message.details.tasks.map((task) => task.id), [tasks[1].id]);
|
|
315
|
+
assert.equal(h.handlers.get("turn_end")({ entries: [], outcome: "completed" }, h.ctx), undefined);
|
|
316
|
+
await h.tool.execute("new", { action: "start", command: "new" });
|
|
317
|
+
h.calls[2].resolve();
|
|
318
|
+
await flush();
|
|
319
|
+
h.clock.advance(0);
|
|
320
|
+
assert.equal(h.messages.length, 1, "the user prompt restores normal automatic delivery");
|
|
321
|
+
} finally { await h.close(); }
|
|
322
|
+
});
|
|
323
|
+
|
|
49
324
|
test("registration is compact, does not inject prompt rules or start session resources", async () => {
|
|
50
325
|
const h = harness();
|
|
51
326
|
try {
|
|
52
327
|
assert.equal(h.tool.name, "background_task");
|
|
53
328
|
assert.match(h.tool.description, /return task IDs immediately/);
|
|
54
|
-
assert.ok(h.tool.description.length <
|
|
329
|
+
assert.ok(h.tool.description.length < 500);
|
|
55
330
|
assert.equal(h.tool.promptGuidelines, undefined);
|
|
56
331
|
assert.ok(h.tool.outputSchema);
|
|
57
332
|
assert.equal(h.tool.annotations.readOnlyHint, false);
|
|
58
333
|
assert.equal(h.tool.annotations.destructiveHint, true);
|
|
59
|
-
assert.ok(!h.handlers.has("before_agent_start"));
|
|
60
334
|
assert.equal(h.clock.timers.size, 0);
|
|
61
335
|
assert.equal(h.calls.length, 0);
|
|
62
336
|
assert.equal((await h.tool.execute("early", { action: "list" })).isError, true);
|
|
63
337
|
} finally { await h.close(); }
|
|
64
338
|
});
|
|
65
339
|
|
|
66
|
-
test("
|
|
340
|
+
test("start provides non-blocking next steps in both model text and structured results", async () => {
|
|
341
|
+
const h = harness();
|
|
342
|
+
try {
|
|
343
|
+
await h.handlers.get("session_start")({}, h.ctx);
|
|
344
|
+
const started = await h.tool.execute("start", { action: "start", command: "sleep 60" });
|
|
345
|
+
const { task, guidance } = started.structuredContent;
|
|
346
|
+
assert.equal(task.status, "running");
|
|
347
|
+
assert.equal(guidance, "完成会自动通知。现在不要查询等待;继续其他独立工作,没有其他工作就结束当前轮,收到通知后再取结果。");
|
|
348
|
+
assert.ok(started.content[0].text.includes(guidance), "ordinary tool callers must see the same guidance as Codemode");
|
|
349
|
+
assert.ok(h.tool.description.includes(guidance), "scripts that filter out guidance still discover the lifecycle in the tool description");
|
|
350
|
+
assert.equal(h.tool.outputSchema.properties.guidance.type, "string");
|
|
351
|
+
assert.equal(h.tool.outputSchema.required?.includes("guidance") ?? false, false);
|
|
352
|
+
const list = await h.tool.execute("list", { action: "list" });
|
|
353
|
+
const output = await h.tool.execute("output", { action: "output", id: task.id });
|
|
354
|
+
assert.equal(list.structuredContent.guidance, undefined);
|
|
355
|
+
assert.equal(output.structuredContent.guidance, undefined);
|
|
356
|
+
h.calls[0].resolve();
|
|
357
|
+
await flush();
|
|
358
|
+
h.clock.advance(0);
|
|
359
|
+
assert.equal(h.messages.filter((entry) => entry.message.customType === "background-task-finished").length, 1);
|
|
360
|
+
} finally { await h.close(); }
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
test("tools, commands, widget and idle notifications share the same session jobs", async () => {
|
|
67
364
|
const h = harness();
|
|
68
365
|
try {
|
|
69
366
|
await h.handlers.get("session_start")({}, h.ctx);
|
|
@@ -91,9 +388,10 @@ test("tools, commands, widget and followUp share the same session jobs", async (
|
|
|
91
388
|
|
|
92
389
|
h.calls[0].resolve(7);
|
|
93
390
|
await flush();
|
|
391
|
+
h.clock.advance(0);
|
|
94
392
|
const completed = h.messages.filter((m) => m.message.customType === "background-task-finished");
|
|
95
393
|
assert.equal(completed.length, 1);
|
|
96
|
-
assert.deepEqual(completed[0].options, { triggerTurn: true, deliverAs: "
|
|
394
|
+
assert.deepEqual(completed[0].options, { triggerTurn: true, deliverAs: "steer" });
|
|
97
395
|
assert.match(completed[0].message.content, /failed.*exit 7/);
|
|
98
396
|
assert.ok(!completed[0].message.content.includes("logs only"));
|
|
99
397
|
assert.match(h.widgets.get("background-tasks").render(140).join("\n"), /failed/);
|
|
@@ -117,6 +415,7 @@ test("cancel and shutdown are idempotent; new sessions cannot receive old comple
|
|
|
117
415
|
const cancel = h.tool.execute("cancel", { action: "cancel", id: a.id });
|
|
118
416
|
h.calls[0].reject(new Error("aborted"));
|
|
119
417
|
assert.equal((await cancel).structuredContent.task.status, "cancelled");
|
|
418
|
+
h.clock.advance(0);
|
|
120
419
|
const count = h.messages.length;
|
|
121
420
|
await h.commands.get("bg").handler(`cancel ${a.id}`, h.ctx);
|
|
122
421
|
assert.equal(h.messages.filter((m) => m.message.customType === "background-task-finished").length, 1);
|
|
@@ -145,9 +444,10 @@ for (const [mode, hasUI] of [["rpc", true], ["print", false], ["json", false]])
|
|
|
145
444
|
if (hasUI) assert.match(h.statuses.get("background-tasks"), /1\/2/);
|
|
146
445
|
h.calls[0].resolve();
|
|
147
446
|
await flush();
|
|
447
|
+
h.clock.advance(0);
|
|
148
448
|
assert.equal(h.clock.timers.size, 0);
|
|
149
449
|
assert.equal(h.statuses.has("background-tasks"), false);
|
|
150
|
-
assert.equal(h.messages.at(-1).options.deliverAs, "
|
|
450
|
+
assert.equal(h.messages.at(-1).options.deliverAs, "steer");
|
|
151
451
|
} finally { await h.close(); }
|
|
152
452
|
});
|
|
153
453
|
}
|
|
@@ -157,7 +457,7 @@ test("bad actions, irrelevant fields, aborted acceptance and command syntax fail
|
|
|
157
457
|
try {
|
|
158
458
|
await h.handlers.get("session_start")({}, h.ctx);
|
|
159
459
|
for (const params of [
|
|
160
|
-
{ action: "other" }, { action: "start" }, { action: "list", command: "ignored" },
|
|
460
|
+
{ action: "other" }, { action: "notifications", enabled: false }, { action: "start" }, { action: "list", command: "ignored" },
|
|
161
461
|
{ action: "output" }, { action: "cancel", id: "unknown" },
|
|
162
462
|
{ action: "start", command: "x", id: "irrelevant" },
|
|
163
463
|
]) {
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// A blocking tool stands in for a synchronous subagent. Pi queues and shell jobs are real.
|
|
2
|
+
import { appendFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
5
|
+
import { createAssistantMessageEventStream } from "@earendil-works/pi-ai";
|
|
6
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import { Type } from "typebox";
|
|
8
|
+
|
|
9
|
+
export default function (pi: ExtensionAPI) {
|
|
10
|
+
const record = (entry: unknown) => appendFileSync(process.env.BG_NOTIFICATION_EVENTS!, JSON.stringify(entry) + "\n");
|
|
11
|
+
pi.registerTool({
|
|
12
|
+
name: "synchronous_subagent_fixture", label: "Synchronous subagent fixture",
|
|
13
|
+
description: "Wait for four real shell jobs while holding one synchronous tool call open.",
|
|
14
|
+
parameters: Type.Object({ reads: Type.Number() }),
|
|
15
|
+
async execute(_id, { reads }, _signal, _update, ctx) {
|
|
16
|
+
record({ event: "waiting" });
|
|
17
|
+
writeFileSync(join(ctx.cwd, "release"), "ready");
|
|
18
|
+
const list = async () => (await ctx.executeTool("background_task", { action: "list" })).result.structuredContent as any;
|
|
19
|
+
const wait = async () => {
|
|
20
|
+
for (let i = 0; i < 1000; i++) {
|
|
21
|
+
const tasks = await list();
|
|
22
|
+
if (tasks.running === 0) return tasks.tasks;
|
|
23
|
+
await delay(5);
|
|
24
|
+
}
|
|
25
|
+
throw new Error("Fixture shell jobs did not finish");
|
|
26
|
+
};
|
|
27
|
+
await wait();
|
|
28
|
+
for (let i = 0; i < 2; i++) await ctx.executeTool("background_task", {
|
|
29
|
+
action: "start", command: "printf 'second wave\\n'; exit 7", description: `second wave ${i}`, timeout: 10,
|
|
30
|
+
});
|
|
31
|
+
const tasks = await wait();
|
|
32
|
+
for (const task of tasks.slice(0, reads)) await ctx.executeTool("background_task", { action: "output", id: task.id });
|
|
33
|
+
record({ event: "returned", tasks, consumed: tasks.slice(0, reads).map((task: any) => task.id) });
|
|
34
|
+
return { content: [{ type: "text", text: "Synchronous subagent returned; final build report is ready." }], details: undefined };
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
let requests = 0;
|
|
38
|
+
pi.registerProvider("bg-notifications", {
|
|
39
|
+
name: "Offline notification fixture", baseUrl: "http://127.0.0.1:1", api: "bg-notifications", apiKey: "fixture-not-a-secret",
|
|
40
|
+
models: [{ id: "deterministic", name: "Offline notification fixture", reasoning: false, input: ["text"], contextWindow: 128000, maxTokens: 4096, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 } }],
|
|
41
|
+
streamSimple(model, context) {
|
|
42
|
+
const stream = createAssistantMessageEventStream();
|
|
43
|
+
queueMicrotask(() => {
|
|
44
|
+
requests++;
|
|
45
|
+
record({ event: "request", number: requests, messages: context.messages });
|
|
46
|
+
const direct = process.env.BG_NOTIFICATION_MODE === "direct";
|
|
47
|
+
const toolTurn = requests === 1 || (direct && requests === 2);
|
|
48
|
+
const message: any = { role: "assistant", api: model.api, provider: model.provider, model: model.id, timestamp: Date.now(), content: [], stopReason: toolTurn ? "toolUse" : "stop",
|
|
49
|
+
usage: { input: 1, output: 1, cacheRead: 0, cacheWrite: 0, totalTokens: 2, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } } };
|
|
50
|
+
stream.push({ type: "start", partial: message });
|
|
51
|
+
if (toolTurn) {
|
|
52
|
+
const reads = Number(process.env.BG_NOTIFICATION_READS);
|
|
53
|
+
if (direct && requests === 1) {
|
|
54
|
+
for (let i = 0; i < 2; i++) message.content.push({ type: "toolCall", id: `direct-start-${i}`, name: "background_task", arguments: {
|
|
55
|
+
action: "start", command: "while [ ! -f release ]; do sleep 0.01; done; printf 'first wave\\n'; exit 7", description: `first wave ${i}`, timeout: 10,
|
|
56
|
+
} });
|
|
57
|
+
} else if (direct) message.content.push({ type: "toolCall", id: "direct-blocking-tool", name: "synchronous_subagent_fixture", arguments: { reads } });
|
|
58
|
+
else {
|
|
59
|
+
const code = `for (let i = 0; i < 2; i++) text(await tools.background_task({ action: "start", command: "while [ ! -f release ]; do sleep 0.01; done; printf 'first wave\\\\n'; exit 7", description: "first wave " + i, timeout: 10 }));\ntext(await tools.synchronous_subagent_fixture({ reads: ${reads} }));`;
|
|
60
|
+
message.content.push({ type: "toolCall", id: "notification-fixture", name: "codemode", arguments: { code } });
|
|
61
|
+
}
|
|
62
|
+
for (let i = 0; i < message.content.length; i++) {
|
|
63
|
+
stream.push({ type: "toolcall_start", contentIndex: i, partial: message });
|
|
64
|
+
stream.push({ type: "toolcall_end", contentIndex: i, toolCall: message.content[i], partial: message });
|
|
65
|
+
}
|
|
66
|
+
} else {
|
|
67
|
+
message.content.push({ type: "text", text: "FINAL_REPORT" });
|
|
68
|
+
stream.push({ type: "text_start", contentIndex: 0, partial: message });
|
|
69
|
+
stream.push({ type: "text_delta", contentIndex: 0, delta: "FINAL_REPORT", partial: message });
|
|
70
|
+
stream.push({ type: "text_end", contentIndex: 0, content: "FINAL_REPORT", partial: message });
|
|
71
|
+
}
|
|
72
|
+
stream.push({ type: "done", reason: message.stopReason, message });
|
|
73
|
+
});
|
|
74
|
+
return stream;
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
}
|
|
@@ -8,6 +8,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
8
8
|
if (process.env.BG_SMOKE_EVENTS) appendFileSync(process.env.BG_SMOKE_EVENTS, JSON.stringify(entry) + "\n");
|
|
9
9
|
};
|
|
10
10
|
let sequence = 0;
|
|
11
|
+
const observedNotifications = new Set<string>();
|
|
11
12
|
pi.registerProvider("bg-fixture", {
|
|
12
13
|
name: "Offline background-task fixture", baseUrl: "http://127.0.0.1:1", api: "bg-fixture",
|
|
13
14
|
apiKey: "fixture-not-a-secret",
|
|
@@ -17,7 +18,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
17
18
|
queueMicrotask(() => {
|
|
18
19
|
const last = context.messages.at(-1) as any;
|
|
19
20
|
const text = typeof last?.content === "string" ? last.content : (last?.content ?? []).filter((block: any) => block.type === "text").map((block: any) => block.text).join("\n");
|
|
20
|
-
|
|
21
|
+
const tools = getCurrentTools(context.messages);
|
|
22
|
+
record({ event: "request", lastRole: last?.role, text: text.slice(0, 2000), tools: tools.map((tool) => tool.name),
|
|
23
|
+
backgroundDescription: tools.find((tool) => tool.name === "background_task")?.description });
|
|
21
24
|
let code: string | undefined;
|
|
22
25
|
if (last?.role !== "toolResult") {
|
|
23
26
|
if (text.includes("SMOKE_START")) code = `const a = await tools.background_task({ action: "start", command: "sleep 9; printf 'BUILD SUCCESSFUL\\\\n'", description: "Release build", timeout: 30 });
|
|
@@ -32,6 +35,7 @@ const task = list.tasks.find(t => t.description === "Release build");
|
|
|
32
35
|
const result = await tools.background_task({ action: "output", id: task.id });
|
|
33
36
|
text({ status: result.task.status, output: result.output });`;
|
|
34
37
|
else if (text.includes("SMOKE_LONG")) code = `text(await tools.background_task({ action: "start", command: "echo $$ > shell.pid; sleep 300 & echo $! > child.pid; wait", description: "Lifecycle cleanup check", timeout: 300 }));`;
|
|
38
|
+
else if (text.includes("SMOKE_GUIDANCE")) code = `text(await tools.background_task({ action: "start", command: "sleep 300", description: "Guidance check", timeout: 300 }));`;
|
|
35
39
|
}
|
|
36
40
|
const message: any = { role: "assistant", api: model.api, provider: model.provider, model: model.id, timestamp: Date.now(), content: [], stopReason: code ? "toolUse" : "stop",
|
|
37
41
|
usage: { input: 1, output: 1, cacheRead: 0, cacheWrite: 0, totalTokens: 2, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } } };
|
|
@@ -41,7 +45,7 @@ text({ status: result.task.status, output: result.output });`;
|
|
|
41
45
|
stream.push({ type: "toolcall_start", contentIndex: 0, partial: message });
|
|
42
46
|
stream.push({ type: "toolcall_end", contentIndex: 0, toolCall: message.content[0], partial: message });
|
|
43
47
|
} else {
|
|
44
|
-
const reply = text.includes("[Background task finished]") ? "
|
|
48
|
+
const reply = text.includes("[Background task finished]") ? "BACKGROUND_NOTIFICATION_RECEIVED" : "Main agent is ready; accepted background tasks keep running.";
|
|
45
49
|
message.content.push({ type: "text", text: reply });
|
|
46
50
|
stream.push({ type: "text_start", contentIndex: 0, partial: message });
|
|
47
51
|
stream.push({ type: "text_delta", contentIndex: 0, delta: reply, partial: message });
|
|
@@ -57,7 +61,13 @@ text({ status: result.task.status, output: result.output });`;
|
|
|
57
61
|
if (ctx.hasUI) ctx.ui.setTitle("Pi Background Tasks Smoke");
|
|
58
62
|
});
|
|
59
63
|
pi.on("tool_execution_end", (event) => record({ event: "tool_end", name: event.toolName, isError: event.isError, result: event.result }));
|
|
60
|
-
pi.on("
|
|
61
|
-
|
|
64
|
+
pi.on("context", (_event, ctx) => {
|
|
65
|
+
// Both boundary entries and idle messages are persisted before the provider request.
|
|
66
|
+
// Key by entry ID so two actual notifications for the same tasks remain observable.
|
|
67
|
+
for (const entry of ctx.sessionManager.getBranch()) {
|
|
68
|
+
if (entry.type !== "custom_message" || observedNotifications.has(entry.id)) continue;
|
|
69
|
+
observedNotifications.add(entry.id);
|
|
70
|
+
record({ event: "custom", entryId: entry.id, type: entry.customType, details: entry.details });
|
|
71
|
+
}
|
|
62
72
|
});
|
|
63
73
|
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
6
|
+
import { test } from "node:test";
|
|
7
|
+
import { pathToFileURL } from "node:url";
|
|
8
|
+
import "./runtime.mjs";
|
|
9
|
+
|
|
10
|
+
const { createAgentSession, DefaultResourceLoader, ModelRuntime, SessionManager, SettingsManager } = await import("@earendil-works/pi-coding-agent");
|
|
11
|
+
const { createAssistantMessageEventStream } = await import("@earendil-works/pi-ai");
|
|
12
|
+
const { Type } = await import("typebox");
|
|
13
|
+
const { default: register } = await import(process.env.BG_TEST_EXTENSION
|
|
14
|
+
? pathToFileURL(process.env.BG_TEST_EXTENSION).href : new URL("../extensions/background-tasks.ts", import.meta.url).href);
|
|
15
|
+
|
|
16
|
+
for (const timing of ["before interrupt", "after interrupt", "after active compaction"]) {
|
|
17
|
+
test(`real Pi completion ${timing} stays stopped until the next user prompt, then delivers once`, { timeout: 20000 }, async (t) => {
|
|
18
|
+
const directory = mkdtempSync(join(tmpdir(), "pi-bg-interrupt-"));
|
|
19
|
+
const release = join(directory, "release");
|
|
20
|
+
const exited = join(directory, "exited");
|
|
21
|
+
const requests = [], settlements = [], errors = [];
|
|
22
|
+
let session, task, output, holding = false, summaries = 0;
|
|
23
|
+
const waitFor = async (check, label) => {
|
|
24
|
+
for (let i = 0; i < 1000; i++) {
|
|
25
|
+
if (check()) return;
|
|
26
|
+
await delay(10);
|
|
27
|
+
}
|
|
28
|
+
throw new Error(`Timed out waiting for ${label}`);
|
|
29
|
+
};
|
|
30
|
+
try {
|
|
31
|
+
const modelRuntime = await ModelRuntime.create({ authPath: join(directory, "auth.json"), modelsPath: join(directory, "models.json"), refreshOnCreate: false });
|
|
32
|
+
modelRuntime.registerProvider("bg-interrupt", {
|
|
33
|
+
name: "Offline interrupt fixture", baseUrl: "http://127.0.0.1:1", api: "bg-interrupt", apiKey: "fixture-not-a-secret",
|
|
34
|
+
models: [{ id: "deterministic", name: "Offline fixture", reasoning: false, input: ["text"], contextWindow: 128000, maxTokens: 4096, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 } }],
|
|
35
|
+
streamSimple(model, context) {
|
|
36
|
+
const compacting = session.isCompacting;
|
|
37
|
+
if (compacting) summaries++;
|
|
38
|
+
else requests.push(context.messages);
|
|
39
|
+
const stream = createAssistantMessageEventStream();
|
|
40
|
+
queueMicrotask(() => {
|
|
41
|
+
const message = { role: "assistant", api: model.api, provider: model.provider, model: model.id, timestamp: Date.now(), content: [], stopReason: "toolUse",
|
|
42
|
+
usage: { input: 1, output: 1, cacheRead: 0, cacheWrite: 0, totalTokens: 2, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } } };
|
|
43
|
+
if (compacting) {
|
|
44
|
+
message.stopReason = "stop";
|
|
45
|
+
message.content.push({ type: "text", text: "## Goal\nKeep the interrupted agent stopped. A shell job remains owned by this session." });
|
|
46
|
+
} else if (requests.length === 1) message.content.push({ type: "toolCall", id: "start-job", name: "background_task", arguments: {
|
|
47
|
+
action: "start", command: "while [ ! -f release ]; do sleep 0.01; done; printf 'late shell result\\n'; printf done > exited; exit 7", timeout: 10,
|
|
48
|
+
} });
|
|
49
|
+
else if (requests.length === 2) message.content.push({ type: "toolCall", id: "hold-turn", name: "interrupt_fixture", arguments: {} });
|
|
50
|
+
else if (requests.length === 3) message.content.push({ type: "toolCall", id: "read-result", name: "background_task", arguments: { action: "output", id: task.id } });
|
|
51
|
+
else {
|
|
52
|
+
message.stopReason = "stop";
|
|
53
|
+
message.content.push({ type: "text", text: "RESUMED_RESULT_ACK" });
|
|
54
|
+
}
|
|
55
|
+
stream.push({ type: "start", partial: message });
|
|
56
|
+
stream.push({ type: "done", reason: message.stopReason, message });
|
|
57
|
+
});
|
|
58
|
+
return stream;
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
const settingsManager = SettingsManager.inMemory({ compaction: { enabled: false, keepRecentTokens: 128 }, retry: { enabled: false }, cacheWarming: "off" });
|
|
62
|
+
const resourceLoader = new DefaultResourceLoader({
|
|
63
|
+
cwd: directory, agentDir: directory, settingsManager,
|
|
64
|
+
noExtensions: true, noSkills: true, noPromptTemplates: true, noThemes: true, noContextFiles: true,
|
|
65
|
+
extensionFactories: [register, (pi) => {
|
|
66
|
+
pi.on("agent_settled", (event) => { settlements.push(event.aborted); });
|
|
67
|
+
pi.registerTool({
|
|
68
|
+
name: "interrupt_fixture", label: "Interrupt fixture", description: "Hold a real tool until the user interrupts the agent.",
|
|
69
|
+
parameters: Type.Object({}),
|
|
70
|
+
async execute(_id, _params, signal) {
|
|
71
|
+
holding = true;
|
|
72
|
+
assert.ok(signal);
|
|
73
|
+
if (!signal.aborted) await new Promise((resolve) => signal.addEventListener("abort", resolve, { once: true }));
|
|
74
|
+
return { content: [{ type: "text", text: "User interrupted the held tool." }], details: undefined };
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
}],
|
|
78
|
+
});
|
|
79
|
+
await resourceLoader.reload();
|
|
80
|
+
const created = await createAgentSession({ cwd: directory, agentDir: directory, modelRuntime, model: modelRuntime.getModel("bg-interrupt", "deterministic"), thinkingLevel: "off", resourceLoader, settingsManager, sessionManager: SessionManager.inMemory(directory), tools: ["background_task", "interrupt_fixture"] });
|
|
81
|
+
session = created.session;
|
|
82
|
+
assert.deepEqual(created.extensionsResult.errors, []);
|
|
83
|
+
session.extensionRunner.onError((error) => errors.push(error));
|
|
84
|
+
session.subscribe((event) => {
|
|
85
|
+
if (event.type !== "tool_execution_end" || event.toolName !== "background_task") return;
|
|
86
|
+
if (event.result.structuredContent.action === "start") task = event.result.structuredContent.task;
|
|
87
|
+
else if (event.result.structuredContent.action === "output") output = event.result.structuredContent;
|
|
88
|
+
});
|
|
89
|
+
await session.bindExtensions({});
|
|
90
|
+
const prompting = session.prompt("Start a job and hold the agent so the user can interrupt. " + "Prior history to compact. ".repeat(200));
|
|
91
|
+
await waitFor(() => holding, "the real blocking tool");
|
|
92
|
+
assert.equal(task.status, "running");
|
|
93
|
+
if (timing === "before interrupt") {
|
|
94
|
+
writeFileSync(release, "finish while the tool is held");
|
|
95
|
+
await waitFor(() => existsSync(exited), "the background shell exit marker");
|
|
96
|
+
await delay(100);
|
|
97
|
+
}
|
|
98
|
+
const interruptRequestedAt = Date.now();
|
|
99
|
+
if (timing === "after active compaction") {
|
|
100
|
+
await session.compact();
|
|
101
|
+
assert.equal(summaries, 1, "manual compaction must run its native summary after interrupting the active agent");
|
|
102
|
+
} else await session.abort();
|
|
103
|
+
await prompting;
|
|
104
|
+
const interruptedAt = Date.now();
|
|
105
|
+
assert.deepEqual(settlements, [true], "native settlement must confirm the user interrupt");
|
|
106
|
+
assert.equal(session.isIdle, true);
|
|
107
|
+
writeFileSync(release, "finish after user interruption");
|
|
108
|
+
await waitFor(() => existsSync(exited), "the background shell surviving the interrupt");
|
|
109
|
+
await delay(1250);
|
|
110
|
+
assert.equal(requests.length, 2, "late completion must not start a model request");
|
|
111
|
+
assert.match(readFileSync(task.log_path, "utf8"), /late shell result/);
|
|
112
|
+
assert.equal(session.isIdle, true);
|
|
113
|
+
await session.prompt("Continue and inspect the completed job.");
|
|
114
|
+
assert.equal(requests.length, 4);
|
|
115
|
+
const resumedContext = JSON.stringify(requests[2]);
|
|
116
|
+
assert.equal((resumedContext.match(/\[Background task finished\]/g) ?? []).length, 1, "retained completion enters context before the resumed model request");
|
|
117
|
+
assert.ok(resumedContext.includes(task.id));
|
|
118
|
+
assert.equal(output.task.exit_code, 7);
|
|
119
|
+
assert.equal(output.output, "late shell result\n");
|
|
120
|
+
await delay(50);
|
|
121
|
+
assert.equal(requests.length, 4, "consumed completion must not trigger another turn");
|
|
122
|
+
const notifications = session.sessionManager.getBranch().filter((entry) => entry.type === "custom_message" && entry.customType === "background-task-finished");
|
|
123
|
+
assert.equal(notifications.length, 1);
|
|
124
|
+
assert.equal(notifications[0].details.tasks[0].id, task.id);
|
|
125
|
+
assert.equal(notifications[0].details.tasks[0].status, "failed");
|
|
126
|
+
if (timing === "before interrupt") assert.ok(notifications[0].details.tasks[0].ended_at <= interruptRequestedAt);
|
|
127
|
+
else assert.ok(notifications[0].details.tasks[0].ended_at >= interruptedAt);
|
|
128
|
+
assert.equal(session.getLastAssistantText(), "RESUMED_RESULT_ACK");
|
|
129
|
+
assert.deepEqual(errors, []);
|
|
130
|
+
t.diagnostic(JSON.stringify({ timing, requestsBeforeResume: 2, requestsAfterResume: 4, pendingDeliveredOnce: true, lateResultRecovered: true }));
|
|
131
|
+
} finally {
|
|
132
|
+
if (session) {
|
|
133
|
+
await session.abort();
|
|
134
|
+
await session.extensionRunner.emit({ type: "session_shutdown", reason: "quit" });
|
|
135
|
+
session.dispose();
|
|
136
|
+
}
|
|
137
|
+
rmSync(directory, { recursive: true, force: true });
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
}
|