@maxiaochao/pi-toolkit 0.13.12 → 0.13.13
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 +6 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/packages/background-tasks/CHANGELOG.md +6 -0
- package/packages/background-tasks/README.md +9 -18
- package/packages/background-tasks/TESTING.md +3 -3
- package/packages/background-tasks/extensions/background-tasks.ts +4 -3
- package/packages/background-tasks/package.json +1 -1
- package/packages/background-tasks/tests/extension.mjs +4 -3
- package/packages/background-tasks/tests/fixtures/notifications.ts +46 -36
- package/packages/background-tasks/tests/fixtures/provider.ts +35 -20
- package/packages/background-tasks/tests/notifications.mjs +48 -22
- package/packages/background-tasks/tests/tui-smoke.py +10 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
## 2026-10-26 - v0.13.13
|
|
2
|
+
|
|
3
|
+
- background-tasks v0.1.2: restrict background tasks to model-only and use concise automatic-notification guidance
|
|
4
|
+
|
|
1
5
|
## 2026-10-23 - v0.13.12
|
|
2
6
|
|
|
3
7
|
- worktree v0.4.1: return the worktree root from already-active nested directories
|
|
@@ -54,6 +58,8 @@
|
|
|
54
58
|
|
|
55
59
|
## Unreleased
|
|
56
60
|
|
|
61
|
+
- Background Tasks: fix tool exposure to `model-only` and shorten startup guidance to English, emphasizing automatic notification without sleep or polling.
|
|
62
|
+
|
|
57
63
|
- OpenAI Account Pool: clear stranded `openai-codex` references after deleting the last real account and on startup, including stale membership left by external credential deletion.
|
|
58
64
|
- OpenAI Account Pool: recover email labels from both profile and root-level token claims; cover removal persistence and last-account cleanup through the production extension and Pi credential cache.
|
|
59
65
|
- Background Tasks: expose the approved non-polling startup guidance to direct and Codemode callers. Retain results after a user interrupt without restarting the agent, then hand them to the next user-message run; verify native tool batches, interrupts and compaction cancellation without changing Pi itself.
|
package/README.md
CHANGED
|
@@ -40,7 +40,7 @@ Restart Pi after updating. `pi update --extensions` has the same effect when upd
|
|
|
40
40
|
|
|
41
41
|
Each child README owns its feature description, motivation or upstream alignment, examples, screenshots/demos, configuration, and test documentation.
|
|
42
42
|
|
|
43
|
-
Background Tasks combines unread completions after any current tool batch, before the next model request, including when the main agent waits for a synchronous subagent. Idle completions wake the main agent automatically, except after a user interrupt: results remain available until the next user-message run. Reading a finished task's `output` through the tool consumes its pending reminder.
|
|
43
|
+
Background Tasks combines unread completions after any current tool batch, before the next model request, including when the main agent waits for a synchronous subagent. Idle completions wake the main agent automatically, except after a user interrupt: results remain available until the next user-message run. Reading a finished task's `output` through the tool consumes its pending reminder. The tool is fixed to `model-only`, not callable through Codemode. Startup guidance emphasizes automatic notification without sleep or polling. See [completion delivery](packages/background-tasks/README.md#user-controls-and-status).
|
|
44
44
|
|
|
45
45
|
Codex Edit personal routing rules belong in `~/.pi/agent/codex-edit.json`, not inside `node_modules`. User fields override bundled defaults; `models` replaces the complete default list. The file survives package updates and is shared by parent sessions and explicitly configured subagents using the same agent directory. Use `/reload` after editing it, and start new subagents. See [Codex Edit configuration](packages/codex-edit/README.md#configuration).
|
|
46
46
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@maxiaochao/pi-toolkit",
|
|
3
|
-
"version": "0.13.
|
|
3
|
+
"version": "0.13.13",
|
|
4
4
|
"description": "Personal pi coding agent toolkit: background tasks, scheduler, /btw, /cache_export, apply_patch, browser automation, and bundled theme/skills.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"workspaces": [
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 2026-10-26 - v0.1.2
|
|
4
|
+
|
|
5
|
+
- restrict background tasks to model-only and use concise automatic-notification guidance
|
|
6
|
+
- Fix `background_task` exposure to `model-only`; reject Codemode and other nested callers without adding a configuration file.
|
|
7
|
+
- Use concise English startup guidance emphasizing automatic notification without sleep or polling.
|
|
8
|
+
|
|
3
9
|
## 2026-10-22 - v0.1.1
|
|
4
10
|
|
|
5
11
|
- batch unread completions at tool boundaries, preserve interrupted runs, and provide non-polling startup guidance
|
|
@@ -31,9 +31,11 @@ The complete `@maxiaochao/pi-toolkit` package **bundles and enables Background T
|
|
|
31
31
|
background_task({ action: "start", command: "sleep 10; printf 'checks complete\\n'", description: "Delayed check", timeout: 120 })
|
|
32
32
|
```
|
|
33
33
|
|
|
34
|
-
|
|
34
|
+
The tool is fixed to `exposure: "model-only"`: the model calls it directly; Codemode and other tools cannot call it. There is no exposure setting.
|
|
35
35
|
|
|
36
|
-
|
|
36
|
+
Each accepted start includes this guidance in the tool description, result text and `structuredContent.guidance`:
|
|
37
|
+
|
|
38
|
+
> Notifies automatically on completion. Do not sleep or poll; continue other work or end the turn.
|
|
37
39
|
|
|
38
40
|
After the completion notification, read the result:
|
|
39
41
|
|
|
@@ -79,7 +81,7 @@ Completion, failure, cancellation, timeout and output-limit termination update t
|
|
|
79
81
|
- While idle, pending completions are combined on the next event-loop tick and wake the main agent once. Jobs that finish after that batch can produce another notification. Pi's global steering/follow-up queue settings are unchanged.
|
|
80
82
|
- If another operation such as manual compaction keeps the session busy without an agent boundary, one local timer retries after 1, 2, 4, 8, 16, then 30 seconds, staying at 30 seconds thereafter. There is no retry-count limit or expiry that discards unread results. This does not call a model or poll shell output.
|
|
81
83
|
- Normal tool/settlement boundaries still deliver immediately, without waiting for the retry timer. Without a boundary, an idle session can wait up to the current retry interval (at most 30 seconds, subject to native timer delays). Once a batch is delivered or fully consumed, the next batch starts with an immediate check and fresh backoff.
|
|
82
|
-
- A successful tool `output` read of a finished task consumes its pending reminder, including failed tasks.
|
|
84
|
+
- A successful tool `output` read of a finished task consumes its pending reminder, including failed tasks. A running snapshot, `list`, a failed log read, or a user `/bg output` inspection does not consume the main agent's reminder. Consumption does not mean the task passed validation.
|
|
83
85
|
- Aborted/error boundaries do not request continuation or replay a previously scheduled wake. An error does not suppress later completions.
|
|
84
86
|
- A user interrupt keeps the agent stopped: accepted jobs still run, but even later completions cannot restart it. Unread results remain queryable and are handed to the next user-message run before its first model request. Another extension's custom wake does not release this interrupt state; a new user-message run or session replacement does. This adds no notification pause switch.
|
|
85
87
|
- Cancelling manual compaction is separate from interrupting the agent. Pending completions still wake an otherwise-idle session after compaction succeeds, fails or is cancelled.
|
|
@@ -95,7 +97,7 @@ Pi reports asynchronous message/run-start failures through its extension error c
|
|
|
95
97
|
| View | Limit |
|
|
96
98
|
| --- | --- |
|
|
97
99
|
| Model-visible output | Last 2000 lines or 50 KiB, whichever is reached first, like Bash |
|
|
98
|
-
|
|
|
100
|
+
| Internal `structuredContent.output` | Up to 1 MiB of raw log bytes; larger logs keep their first and last 512 KiB with an omission marker |
|
|
99
101
|
| Disk capture | 32 MiB per task, 256 MiB total per session |
|
|
100
102
|
|
|
101
103
|
Display/program-view truncation does **not** stop the command. Disk limits **do** stop the overflowing task with `output_limit`; a full session budget rejects new starts. Nothing silently evicts old logs.
|
|
@@ -104,22 +106,11 @@ Output includes a log path for further reads. `truncated` describes the program
|
|
|
104
106
|
|
|
105
107
|
Output streams to private temporary logs (directory 0700, files 0600 on Unix), with bounded memory and range-limited reads. Log errors stop the task and are reported. Stdout and stderr are merged; strict ordering across the two streams is not promised. Logs and command arguments can contain secrets: choose commands accordingly.
|
|
106
108
|
|
|
107
|
-
##
|
|
108
|
-
|
|
109
|
-
Use Bash when you need a command's result before proceeding. Codemode composes tool calls and filters their results; it does not itself own background jobs.
|
|
109
|
+
## Bash and background execution
|
|
110
110
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
```javascript
|
|
114
|
-
const started = await tools.background_task({
|
|
115
|
-
action: "start",
|
|
116
|
-
command: "sleep 10; printf 'done\\n'",
|
|
117
|
-
timeout: 120,
|
|
118
|
-
});
|
|
119
|
-
text(started);
|
|
120
|
-
```
|
|
111
|
+
Use Bash when you need a command's result before proceeding. Use `background_task` directly when the command should keep running after the model's turn ends. Completion notifies the model automatically; do not add sleep or polling calls to wait for it.
|
|
121
112
|
|
|
122
|
-
|
|
113
|
+
Codemode cannot call this model-only tool. Starting an unawaited Bash call in Codemode is not a substitute: Codemode cancels unfinished nested calls when its script ends.
|
|
123
114
|
|
|
124
115
|
The tool description is compact. This package adds no `promptGuidelines` or extra global system-prompt rules.
|
|
125
116
|
|
|
@@ -13,8 +13,8 @@ Tests use Node's built-in test runner. They resolve host peers from the installe
|
|
|
13
13
|
- `tasks.mjs`: fake execution and clock at the task-manager interface; immediate return, two slots, validation, timeout/cancel races, final failure, disk budgets, write errors, bounded history and shutdown.
|
|
14
14
|
- `output.mjs`: compares both output views with the real host Bash tool. Covers line/byte limits, long lines, empty output, missing final newline, UTF-8 split across chunks, invalid UTF-8 and logs larger than 1 MiB.
|
|
15
15
|
- `processes.mjs`: the native backend, a multi-line Python log check, non-blocking concurrency, PID-observed process-tree cleanup, silent busy-loop timeout and the default 32 MiB disk limit.
|
|
16
|
-
- `extension.mjs`: real extension registration with fake execution/clock; schema errors, command parity, parent-signal independence, merged busy/idle notifications, terminal-output consumption and failed-read retention, abort/error boundaries, manual-compaction-style busy intervals without agent boundaries, other extensions' draft preservation, no output in notifications, above-editor placement, five-second retention, narrow/CJK rendering, coexistence and non-TUI modes. Checks the
|
|
17
|
-
- `notifications.mjs`: isolated real Pi with
|
|
16
|
+
- `extension.mjs`: real extension registration with fake execution/clock; schema errors, command parity, parent-signal independence, merged busy/idle notifications, terminal-output consumption and failed-read retention, abort/error boundaries, manual-compaction-style busy intervals without agent boundaries, other extensions' draft preservation, no output in notifications, above-editor placement, five-second retention, narrow/CJK rendering, coexistence and non-TUI modes. Checks fixed `model-only` exposure and the concise English startup guidance in both return paths and the tool description; verifies 24-hour retention after an interrupt, output consumption while stopped, custom-wake isolation and restoration of automatic delivery by a new user-message run.
|
|
17
|
+
- `notifications.mjs`: isolated real Pi with model-issued background calls, default one-at-a-time queues and an offline provider. Sequential blocking fixtures hold one native tool batch open across two waves of actual failing shell jobs. The blocking fixture runs directly or through native Codemode, but background calls always come from the model. The next model request gets one merged notification for four completions, excluding zero, one or two first-wave terminal outputs read directly by the model before the boundary; no extra requests follow the final report. Complete pending-batch consumption remains covered by the deterministic extension tests. Also verifies that the model receives the English guidance without a Codemode suffix, and that native Codemode cannot discover or call `background_task`. The blocking tool is a fixture, not a real Herdr child. `BG_TEST_EXTENSION=/absolute/path/to/background-tasks.ts` can run the queue regressions against an older implementation.
|
|
18
18
|
- `compaction.mjs`: isolated native Pi SDK sessions call `session.compact()`, the same manual-compaction entry point used by `/compact` and RPC. Real shell jobs exit during a held offline summary request. Covers successful native compaction, a provider summary error and `abortCompaction()` cancellation; each must retain the pending completion while busy and automatically deliver it once afterward, without another user prompt. Checks actual compaction lifecycle/events, saved compaction entries, completion timestamps/logs and exactly one automatic conversation request. Uses real timers and native `isIdle`/`isCompacting`, not injected busy state or replacement compaction results; no paid model call or TUI keyboard invocation of `/compact`. `BG_TEST_EXTENSION` also supports a negative-control extension.
|
|
19
19
|
- `interruptions.mjs`: isolated native Pi SDK sessions call the real `session.abort()` while a foreground tool is held. A real shell finishes before or after the interrupt; terminal timestamps verify the ordering. Native `agent_settled.aborted` must confirm the stop; no completion may start another model request. A new user prompt sees the retained completion in its first request, reads the real failed task's output, and finishes without duplicate delivery or extension errors. A third case starts actual manual compaction while the foreground tool is held: Pi itself interrupts the active agent, which must stay stopped even after compaction and background completion. The public abort event has no reason field, so the extension preserves this native interruption rather than guessing its source. `BG_TEST_EXTENSION` supports a negative control without interrupt suppression.
|
|
20
20
|
|
|
@@ -32,7 +32,7 @@ Requires Python 3 with the existing `pexpect` module; process fixtures use Bash,
|
|
|
32
32
|
python3 packages/background-tasks/tests/tui-smoke.py .cache/verification/background-tui
|
|
33
33
|
```
|
|
34
34
|
|
|
35
|
-
This starts a real Pi in a PTY with the production extension, Scheduler, the built-in Codemode tool and a deterministic fixture provider. It checks two jobs, rejection of a third,
|
|
35
|
+
This starts a real Pi in a PTY with the production extension, Scheduler, the built-in Codemode tool and a deterministic fixture provider. It checks two jobs, rejection of a third, model turn completion while jobs remain alive and rejection of Codemode background calls, explicit output, cancellation, automatic main-agent notification, reload/new/quit cleanup and log removal. It observes both persisted boundary entries and idle custom messages by unique session-entry ID, and rejects duplicate notifications. No network model calls occur. Mocked UI tests separately verify the exact five-second boundary.
|
|
36
36
|
|
|
37
37
|
Results and the actual ANSI stream are stored under the chosen evidence directory. Real screenshots require an isolated X11 terminal window, `gnome-terminal.real`, `xwininfo` and ImageMagick `import`:
|
|
38
38
|
|
|
@@ -7,7 +7,7 @@ import { BackgroundTasks, errorText, summary, systemClock, type Clock, type Task
|
|
|
7
7
|
import { BackgroundWidget, FINISHED_VISIBLE_MS, WIDGET_KEY, duration, visibleTasks } from "../src/widget.ts";
|
|
8
8
|
|
|
9
9
|
const actions = ["start", "list", "output", "cancel"] as const;
|
|
10
|
-
const startGuidance = "
|
|
10
|
+
const startGuidance = "Notifies automatically on completion. Do not sleep or poll; continue other work or end the turn.";
|
|
11
11
|
type Action = typeof actions[number];
|
|
12
12
|
interface Params {
|
|
13
13
|
action: Action;
|
|
@@ -23,7 +23,7 @@ const Parameters = Type.Object({
|
|
|
23
23
|
description: Type.Optional(Type.String({ description: "Short display label for start" })),
|
|
24
24
|
cwd: Type.Optional(Type.String({ description: "Working directory for start; defaults to the session directory. Relative paths resolve from it." })),
|
|
25
25
|
timeout: Type.Optional(Type.Integer({ minimum: 1, maximum: 7200, description: "Runtime limit for start, in seconds: default 1800 (30 minutes), maximum 7200 (2 hours); no unlimited mode" })),
|
|
26
|
-
id: Type.Optional(Type.String({ description: "Task ID for output or cancel; output shows the last 2000 lines or 50 KiB
|
|
26
|
+
id: Type.Optional(Type.String({ description: "Task ID for output or cancel; output shows the last 2000 lines or 50 KiB, plus a log path." })),
|
|
27
27
|
}, { additionalProperties: false });
|
|
28
28
|
const TaskSchema = Type.Object({
|
|
29
29
|
id: Type.String(), status: StringEnum(["running", "succeeded", "failed", "cancelled", "timed_out", "output_limit"] as const),
|
|
@@ -240,6 +240,7 @@ export default function (pi: ExtensionAPI, dependencies: { operations?: BashOper
|
|
|
240
240
|
|
|
241
241
|
pi.registerTool({
|
|
242
242
|
name: "background_task", label: "Background task",
|
|
243
|
+
exposure: "model-only",
|
|
243
244
|
description: `Start shell commands as background tasks and return task IDs immediately. Supports status, output, and cancellation. After start: ${startGuidance}`,
|
|
244
245
|
parameters: Parameters, outputSchema: OutputSchema,
|
|
245
246
|
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
@@ -247,7 +248,7 @@ export default function (pi: ExtensionAPI, dependencies: { operations?: BashOper
|
|
|
247
248
|
try {
|
|
248
249
|
if (signal?.aborted) throw new Error("Tool call aborted before task acceptance");
|
|
249
250
|
const result = await perform(params, ctx?.cwd ?? context?.cwd ?? process.cwd());
|
|
250
|
-
// A terminal result was consumed by
|
|
251
|
+
// A terminal result was consumed by the model.
|
|
251
252
|
// User /bg inspections and running snapshots do not acknowledge completion.
|
|
252
253
|
if (params.action === "output" && "task" in result.data && result.data.task.status !== "running") {
|
|
253
254
|
pending.delete(result.data.task.id);
|
|
@@ -325,6 +325,7 @@ test("registration is compact, does not inject prompt rules or start session res
|
|
|
325
325
|
const h = harness();
|
|
326
326
|
try {
|
|
327
327
|
assert.equal(h.tool.name, "background_task");
|
|
328
|
+
assert.equal(h.tool.exposure, "model-only");
|
|
328
329
|
assert.match(h.tool.description, /return task IDs immediately/);
|
|
329
330
|
assert.ok(h.tool.description.length < 500);
|
|
330
331
|
assert.equal(h.tool.promptGuidelines, undefined);
|
|
@@ -344,9 +345,9 @@ test("start provides non-blocking next steps in both model text and structured r
|
|
|
344
345
|
const started = await h.tool.execute("start", { action: "start", command: "sleep 60" });
|
|
345
346
|
const { task, guidance } = started.structuredContent;
|
|
346
347
|
assert.equal(task.status, "running");
|
|
347
|
-
assert.equal(guidance, "
|
|
348
|
-
assert.ok(started.content[0].text.includes(guidance), "
|
|
349
|
-
assert.ok(h.tool.description.includes(guidance), "
|
|
348
|
+
assert.equal(guidance, "Notifies automatically on completion. Do not sleep or poll; continue other work or end the turn.");
|
|
349
|
+
assert.ok(started.content[0].text.includes(guidance), "the start result must include the guidance");
|
|
350
|
+
assert.ok(h.tool.description.includes(guidance), "the tool description must include the same guidance");
|
|
350
351
|
assert.equal(h.tool.outputSchema.properties.guidance.type, "string");
|
|
351
352
|
assert.equal(h.tool.outputSchema.required?.includes("guidance") ?? false, false);
|
|
352
353
|
const list = await h.tool.execute("list", { action: "list" });
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
//
|
|
2
|
-
import { appendFileSync, writeFileSync } from "node:fs";
|
|
1
|
+
// Sequential fixture tools hold one native batch open; background calls remain model-issued.
|
|
2
|
+
import { appendFileSync, existsSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { setTimeout as delay } from "node:timers/promises";
|
|
5
5
|
import { createAssistantMessageEventStream } from "@earendil-works/pi-ai";
|
|
@@ -8,32 +8,40 @@ import { Type } from "typebox";
|
|
|
8
8
|
|
|
9
9
|
export default function (pi: ExtensionAPI) {
|
|
10
10
|
const record = (entry: unknown) => appendFileSync(process.env.BG_NOTIFICATION_EVENTS!, JSON.stringify(entry) + "\n");
|
|
11
|
+
const tasks = new Map<string, any>();
|
|
12
|
+
const consumed: string[] = [];
|
|
13
|
+
let finished = false;
|
|
11
14
|
pi.registerTool({
|
|
12
15
|
name: "synchronous_subagent_fixture", label: "Synchronous subagent fixture",
|
|
13
|
-
description: "Wait for
|
|
14
|
-
parameters: Type.Object({
|
|
15
|
-
async execute(_id, {
|
|
16
|
-
record({ event: "waiting" });
|
|
17
|
-
writeFileSync(join(ctx.cwd,
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
if (
|
|
23
|
-
|
|
16
|
+
description: "Wait for two real shell jobs while holding one synchronous tool call open.",
|
|
17
|
+
parameters: Type.Object({ wave: Type.Number() }), executionMode: "sequential",
|
|
18
|
+
async execute(_id, { wave }, _signal, _update, ctx) {
|
|
19
|
+
record({ event: "waiting", wave });
|
|
20
|
+
writeFileSync(join(ctx.cwd, `release-${wave}`), "ready");
|
|
21
|
+
for (let i = 0; i < 1000; i++) {
|
|
22
|
+
if ([0, 1].every((job) => existsSync(join(ctx.cwd, `exited-${wave}-${job}`)))) {
|
|
23
|
+
// Exit markers precede process exit. Let the native shell backend settle.
|
|
24
|
+
await delay(100);
|
|
25
|
+
if (wave === 1) finished = true;
|
|
26
|
+
return { content: [{ type: "text", text: "Synchronous subagent returned; shell wave finished." }], details: undefined };
|
|
24
27
|
}
|
|
25
|
-
|
|
26
|
-
}
|
|
27
|
-
|
|
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 };
|
|
28
|
+
await delay(5);
|
|
29
|
+
}
|
|
30
|
+
throw new Error("Fixture shell jobs did not finish");
|
|
35
31
|
},
|
|
36
32
|
});
|
|
33
|
+
pi.on("tool_execution_end", (event) => {
|
|
34
|
+
if (event.toolName !== "background_task") return;
|
|
35
|
+
const data = event.result.structuredContent as any;
|
|
36
|
+
if (data?.task) tasks.set(data.task.id, data.task);
|
|
37
|
+
if (data?.action === "output" && data.task.status !== "running") consumed.push(data.task.id);
|
|
38
|
+
});
|
|
39
|
+
pi.on("context", () => {
|
|
40
|
+
if (finished) {
|
|
41
|
+
record({ event: "returned", tasks: [...tasks.values()], consumed });
|
|
42
|
+
finished = false;
|
|
43
|
+
}
|
|
44
|
+
});
|
|
37
45
|
let requests = 0;
|
|
38
46
|
pi.registerProvider("bg-notifications", {
|
|
39
47
|
name: "Offline notification fixture", baseUrl: "http://127.0.0.1:1", api: "bg-notifications", apiKey: "fixture-not-a-secret",
|
|
@@ -43,21 +51,23 @@ export default function (pi: ExtensionAPI) {
|
|
|
43
51
|
queueMicrotask(() => {
|
|
44
52
|
requests++;
|
|
45
53
|
record({ event: "request", number: requests, messages: context.messages });
|
|
46
|
-
const
|
|
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",
|
|
54
|
+
const message: any = { role: "assistant", api: model.api, provider: model.provider, model: model.id, timestamp: Date.now(), content: [], stopReason: requests <= 2 ? "toolUse" : "stop",
|
|
49
55
|
usage: { input: 1, output: 1, cacheRead: 0, cacheWrite: 0, totalTokens: 2, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } } };
|
|
50
56
|
stream.push({ type: "start", partial: message });
|
|
51
|
-
if (
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
message.content.push({ type: "toolCall", id: "
|
|
57
|
+
if (requests <= 2) {
|
|
58
|
+
// First start a pair to obtain IDs. The second native batch waits, starts
|
|
59
|
+
// another pair, waits again, and reads selected outputs before its boundary.
|
|
60
|
+
const wave = requests - 1;
|
|
61
|
+
if (requests === 2) message.content.push({ type: "toolCall", id: "wait-first-wave", name: "synchronous_subagent_fixture", arguments: { wave: 0 } });
|
|
62
|
+
for (let i = 0; i < 2; i++) message.content.push({ type: "toolCall", id: `start-${wave}-${i}`, name: "background_task", arguments: {
|
|
63
|
+
action: "start", command: `while [ ! -f release-${wave} ]; do sleep 0.01; done; printf 'wave ${wave}\\n'; touch exited-${wave}-${i}; exit 7`, description: `wave ${wave} job ${i}`, timeout: 10,
|
|
64
|
+
} });
|
|
65
|
+
if (requests === 2) {
|
|
66
|
+
if (process.env.BG_NOTIFICATION_MODE === "codemode") message.content.push({ type: "toolCall", id: "wait-second-wave", name: "codemode", arguments: { code: "text(await tools.synchronous_subagent_fixture({ wave: 1 }));" } });
|
|
67
|
+
else message.content.push({ type: "toolCall", id: "wait-second-wave", name: "synchronous_subagent_fixture", arguments: { wave: 1 } });
|
|
68
|
+
// Only first-wave IDs are known to the model before this batch returns.
|
|
69
|
+
const reads = Number(process.env.BG_NOTIFICATION_READS);
|
|
70
|
+
for (const task of [...tasks.values()].slice(0, reads)) message.content.push({ type: "toolCall", id: `read-${task.id}`, name: "background_task", arguments: { action: "output", id: task.id } });
|
|
61
71
|
}
|
|
62
72
|
for (let i = 0; i < message.content.length; i++) {
|
|
63
73
|
stream.push({ type: "toolcall_start", contentIndex: i, partial: message });
|
|
@@ -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 tasks = new Map<string, any>();
|
|
11
12
|
const observedNotifications = new Set<string>();
|
|
12
13
|
pi.registerProvider("bg-fixture", {
|
|
13
14
|
name: "Offline background-task fixture", baseUrl: "http://127.0.0.1:1", api: "bg-fixture",
|
|
@@ -21,29 +22,38 @@ export default function (pi: ExtensionAPI) {
|
|
|
21
22
|
const tools = getCurrentTools(context.messages);
|
|
22
23
|
record({ event: "request", lastRole: last?.role, text: text.slice(0, 2000), tools: tools.map((tool) => tool.name),
|
|
23
24
|
backgroundDescription: tools.find((tool) => tool.name === "background_task")?.description });
|
|
24
|
-
|
|
25
|
+
const calls: { name: string; arguments: any }[] = [];
|
|
26
|
+
const call = (args: any) => calls.push({ name: "background_task", arguments: args });
|
|
25
27
|
if (last?.role !== "toolResult") {
|
|
26
|
-
if (text.includes("SMOKE_START"))
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
else if (text.includes("SMOKE_CANCEL"))
|
|
31
|
-
|
|
32
|
-
text
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
else if (text.includes("
|
|
28
|
+
if (text.includes("SMOKE_START")) {
|
|
29
|
+
call({ action: "start", command: "sleep 9; printf 'BUILD SUCCESSFUL\\n'", description: "Release build", timeout: 30 });
|
|
30
|
+
call({ action: "start", command: "sleep 300", description: "Cancel this task", timeout: 300 });
|
|
31
|
+
call({ action: "start", command: "printf third" });
|
|
32
|
+
} else if (text.includes("SMOKE_CANCEL")) {
|
|
33
|
+
call({ action: "cancel", id: tasks.get("Cancel this task").id });
|
|
34
|
+
} else if (text.includes("SMOKE_OUTPUT")) {
|
|
35
|
+
call({ action: "output", id: tasks.get("Release build").id });
|
|
36
|
+
} else if (text.includes("SMOKE_LONG")) {
|
|
37
|
+
call({ 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")) {
|
|
39
|
+
call({ action: "start", command: "sleep 300", description: "Guidance check", timeout: 300 });
|
|
40
|
+
} else if (text.includes("SMOKE_EXPOSURE")) {
|
|
41
|
+
calls.push({ name: "codemode", arguments: { code: `
|
|
42
|
+
text({ discovered: ALL_TOOLS.some(t => t.name === "background_task"), available: "background_task" in tools });
|
|
43
|
+
try { await tools.background_task({ action: "start", command: "touch should-not-exist" }); text("UNEXPECTED_SUCCESS"); }
|
|
44
|
+
catch { text("BACKGROUND_CALL_BLOCKED"); }` } });
|
|
45
|
+
}
|
|
39
46
|
}
|
|
40
|
-
const message: any = { role: "assistant", api: model.api, provider: model.provider, model: model.id, timestamp: Date.now(), content: [], stopReason:
|
|
47
|
+
const message: any = { role: "assistant", api: model.api, provider: model.provider, model: model.id, timestamp: Date.now(), content: [], stopReason: calls.length ? "toolUse" : "stop",
|
|
41
48
|
usage: { input: 1, output: 1, cacheRead: 0, cacheWrite: 0, totalTokens: 2, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } } };
|
|
42
49
|
stream.push({ type: "start", partial: message });
|
|
43
|
-
if (
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
50
|
+
if (calls.length) {
|
|
51
|
+
for (const call of calls) {
|
|
52
|
+
const index = message.content.length;
|
|
53
|
+
message.content.push({ type: "toolCall", id: `fixture-${++sequence}`, ...call });
|
|
54
|
+
stream.push({ type: "toolcall_start", contentIndex: index, partial: message });
|
|
55
|
+
stream.push({ type: "toolcall_end", contentIndex: index, toolCall: message.content[index], partial: message });
|
|
56
|
+
}
|
|
47
57
|
} else {
|
|
48
58
|
const reply = text.includes("[Background task finished]") ? "BACKGROUND_NOTIFICATION_RECEIVED" : "Main agent is ready; accepted background tasks keep running.";
|
|
49
59
|
message.content.push({ type: "text", text: reply });
|
|
@@ -60,7 +70,12 @@ text({ status: result.task.status, output: result.output });`;
|
|
|
60
70
|
record({ event: "loaded", tools: pi.getAllTools().map((tool) => tool.name) });
|
|
61
71
|
if (ctx.hasUI) ctx.ui.setTitle("Pi Background Tasks Smoke");
|
|
62
72
|
});
|
|
63
|
-
pi.on("
|
|
73
|
+
pi.on("agent_settled", () => record({ event: "settled" }));
|
|
74
|
+
pi.on("tool_execution_end", (event) => {
|
|
75
|
+
const data = event.result.structuredContent as any;
|
|
76
|
+
if (event.toolName === "background_task" && data?.action === "start" && data.task) tasks.set(data.task.description, data.task);
|
|
77
|
+
record({ event: "tool_end", name: event.toolName, isError: event.isError, result: event.result });
|
|
78
|
+
});
|
|
64
79
|
pi.on("context", (_event, ctx) => {
|
|
65
80
|
// Both boundary entries and idle messages are persisted before the provider request.
|
|
66
81
|
// Key by entry ID so two actual notifications for the same tasks remain observable.
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { execFileSync } from "node:child_process";
|
|
3
|
-
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
5
|
import { join } from "node:path";
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
7
7
|
import { test } from "node:test";
|
|
8
8
|
import { piBinary } from "./runtime.mjs";
|
|
9
9
|
|
|
10
|
-
test("native
|
|
10
|
+
test("native model receives concise start guidance without Codemode instructions", () => {
|
|
11
11
|
const directory = mkdtempSync(join(tmpdir(), "pi-bg-guidance-"));
|
|
12
12
|
try {
|
|
13
13
|
const events = join(directory, "events.jsonl");
|
|
@@ -23,19 +23,44 @@ test("native Codemode receives start guidance and exposes it to the model", () =
|
|
|
23
23
|
const started = records.find((entry) => entry.event === "tool_end" && entry.name === "background_task");
|
|
24
24
|
assert.equal(started.isError, false);
|
|
25
25
|
const guidance = started.result.structuredContent.guidance;
|
|
26
|
-
assert.equal(guidance, "
|
|
27
|
-
|
|
28
|
-
assert.equal(codemode.isError, false);
|
|
29
|
-
assert.ok(codemode.result.content.some((block) => block.type === "text" && block.text.includes(guidance)));
|
|
26
|
+
assert.equal(guidance, "Notifies automatically on completion. Do not sleep or poll; continue other work or end the turn.");
|
|
27
|
+
assert.equal(records.some((entry) => entry.event === "tool_end" && entry.name === "codemode"), false);
|
|
30
28
|
const requests = records.filter((entry) => entry.event === "request");
|
|
31
29
|
assert.ok(requests[0].backgroundDescription.includes(guidance));
|
|
32
|
-
assert.ok(requests[
|
|
30
|
+
assert.ok(!requests[0].backgroundDescription.includes("Codemode:"));
|
|
31
|
+
assert.ok(requests[1].text.includes(guidance), "the next model request sees the startup guidance");
|
|
32
|
+
} finally { rmSync(directory, { recursive: true, force: true }); }
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("native Codemode cannot discover or call the model-only background tool", () => {
|
|
36
|
+
const directory = mkdtempSync(join(tmpdir(), "pi-bg-exposure-"));
|
|
37
|
+
try {
|
|
38
|
+
const events = join(directory, "events.jsonl");
|
|
39
|
+
const output = execFileSync(piBinary, [
|
|
40
|
+
"--no-extensions", "--no-skills", "--no-prompt-templates", "--no-themes", "--no-context-files", "--no-session", "--offline",
|
|
41
|
+
"-e", "builtin:codemode", "-e", fileURLToPath(new URL("../extensions/background-tasks.ts", import.meta.url)),
|
|
42
|
+
"-e", fileURLToPath(new URL("fixtures/provider.ts", import.meta.url)),
|
|
43
|
+
"--tools", "codemode,background_task", "--model", "bg-fixture/deterministic", "-p", "SMOKE_EXPOSURE",
|
|
44
|
+
], { cwd: directory, encoding: "utf8", timeout: 30000,
|
|
45
|
+
env: { ...process.env, PI_CODING_AGENT_DIR: directory, PI_OFFLINE: "1", BG_SMOKE_EVENTS: events }, stdio: ["ignore", "pipe", "pipe"] });
|
|
46
|
+
assert.match(output, /Main agent is ready/);
|
|
47
|
+
const records = readFileSync(events, "utf8").trim().split("\n").map((line) => JSON.parse(line));
|
|
48
|
+
const codemode = records.find((entry) => entry.event === "tool_end" && entry.name === "codemode");
|
|
49
|
+
assert.equal(codemode.isError, false);
|
|
50
|
+
const text = codemode.result.content.filter((block) => block.type === "text").map((block) => block.text).join("\n");
|
|
51
|
+
assert.match(text, /"discovered":false/);
|
|
52
|
+
assert.match(text, /"available":false/);
|
|
53
|
+
assert.match(text, /BACKGROUND_CALL_BLOCKED/);
|
|
54
|
+
assert.doesNotMatch(text, /UNEXPECTED_SUCCESS/);
|
|
55
|
+
assert.equal(records.some((entry) => entry.event === "tool_end" && entry.name === "background_task"), false);
|
|
56
|
+
assert.equal(existsSync(join(directory, "should-not-exist")), false);
|
|
57
|
+
assert.ok(records.find((entry) => entry.event === "request").tools.includes("background_task"));
|
|
33
58
|
} finally { rmSync(directory, { recursive: true, force: true }); }
|
|
34
59
|
});
|
|
35
60
|
|
|
36
61
|
for (const mode of ["codemode", "direct"]) {
|
|
37
|
-
for (const reads of [0, 1,
|
|
38
|
-
test(`real Pi ${mode} merges four completions
|
|
62
|
+
for (const reads of [0, 1, 2]) {
|
|
63
|
+
test(`real Pi ${mode} blocker merges four completions, excluding ${reads} model-read results`, () => {
|
|
39
64
|
const directory = mkdtempSync(join(tmpdir(), "pi-bg-notifications-"));
|
|
40
65
|
try {
|
|
41
66
|
const events = join(directory, "events.jsonl");
|
|
@@ -51,26 +76,27 @@ for (const mode of ["codemode", "direct"]) {
|
|
|
51
76
|
const returned = records.find((entry) => entry.event === "returned");
|
|
52
77
|
assert.ok(returned, `the blocking tool finished all real shell jobs\n${output}`);
|
|
53
78
|
assert.equal(returned.tasks.length, 4);
|
|
54
|
-
assert.
|
|
79
|
+
assert.equal(returned.consumed.length, reads);
|
|
80
|
+
assert.ok(returned.tasks.filter((task) => returned.consumed.includes(task.id)).every((task) => task.status === "failed" && task.exit_code === 7));
|
|
55
81
|
const transcript = output.trim().split("\n").map((line) => JSON.parse(line));
|
|
56
82
|
const notifications = transcript.filter((entry) =>
|
|
57
83
|
(entry.type === "entry_appended" && entry.entry.customType === "background-task-finished") ||
|
|
58
84
|
(entry.type === "message_start" && entry.message.customType === "background-task-finished")
|
|
59
85
|
);
|
|
60
|
-
assert.equal(notifications.length,
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
}
|
|
86
|
+
assert.equal(notifications.length, 1);
|
|
87
|
+
const message = notifications[0].entry ?? notifications[0].message;
|
|
88
|
+
const tasks = message.details.tasks;
|
|
89
|
+
assert.equal(tasks.length, 4 - reads);
|
|
90
|
+
assert.ok(tasks.every((task) => task.status === "failed" && task.exit_code === 7));
|
|
91
|
+
assert.ok(tasks.every((task) => !returned.consumed.includes(task.id)));
|
|
92
|
+
assert.deepEqual([...returned.consumed, ...tasks.map((task) => task.id)].sort(), returned.tasks.map((task) => task.id).sort());
|
|
93
|
+
const toolEnd = transcript.findLastIndex((entry) => entry.type === "tool_execution_end" && ["synchronous_subagent_fixture", "codemode"].includes(entry.toolName));
|
|
94
|
+
assert.ok(toolEnd >= 0 && transcript.indexOf(notifications[0]) > toolEnd, "the notification cannot interrupt the synchronous tool");
|
|
95
|
+
assert.match(message.content, /Ended at: \d{4}-/);
|
|
70
96
|
const requests = records.filter((entry) => entry.event === "request");
|
|
71
|
-
assert.equal(requests.length,
|
|
97
|
+
assert.equal(requests.length, 3, "no extra model turns after the final report");
|
|
72
98
|
const nextContext = JSON.stringify(requests.at(-1).messages);
|
|
73
|
-
assert.equal((nextContext.match(/\[Background task finished\]/g) ?? []).length,
|
|
99
|
+
assert.equal((nextContext.match(/\[Background task finished\]/g) ?? []).length, 1);
|
|
74
100
|
assert.doesNotMatch(nextContext, /FINAL_REPORT/, "completion is delivered before, not after, the final report");
|
|
75
101
|
const contextText = requests.at(-1).messages.flatMap((message) => typeof message.content === "string" ? [message.content] : (message.content ?? []).filter((block) => block.type === "text").map((block) => block.text)).join("\n");
|
|
76
102
|
for (const task of returned.tasks.filter((task) => !returned.consumed.includes(task.id))) assert.ok(contextText.includes(task.id));
|
|
@@ -126,15 +126,21 @@ try:
|
|
|
126
126
|
assert loaded['tools'].count('background_task') == 1
|
|
127
127
|
send('/schedule add 1h Check release status')
|
|
128
128
|
wait(lambda: 'active schedule' in raw, 'Scheduler widget')
|
|
129
|
+
send('SMOKE_EXPOSURE')
|
|
130
|
+
wait(lambda: bool(tools('codemode')), 'Codemode exposure check')
|
|
131
|
+
assert 'BACKGROUND_CALL_BLOCKED' in str(tools('codemode')[-1]['result'])
|
|
132
|
+
assert not (work / 'should-not-exist').exists()
|
|
133
|
+
wait(lambda: any(e.get('event') == 'settled' for e in events()), 'exposure turn completion')
|
|
134
|
+
settled_count = len([e for e in events() if e.get('event') == 'settled'])
|
|
129
135
|
send('SMOKE_START')
|
|
130
|
-
wait(lambda: len(tools()) >= 3
|
|
136
|
+
wait(lambda: len(tools()) >= 3, 'model startup batch')
|
|
131
137
|
starts = tools()[:3]
|
|
132
138
|
assert not starts[0]['isError'] and not starts[1]['isError'] and starts[2]['isError'], starts
|
|
133
139
|
first = starts[0]['result']['structuredContent']['task']
|
|
134
140
|
second = starts[1]['result']['structuredContent']['task']
|
|
135
141
|
assert first['status'] == second['status'] == 'running'
|
|
136
|
-
|
|
137
|
-
assert not finished('succeeded'), 'Sleeping job must survive
|
|
142
|
+
wait(lambda: len([e for e in events() if e.get('event') == 'settled']) > settled_count, 'foreground turn completion')
|
|
143
|
+
assert not finished('succeeded'), 'Sleeping job must survive foreground turn completion'
|
|
138
144
|
shot('running')
|
|
139
145
|
send('/bg output ' + first['id'])
|
|
140
146
|
send('SMOKE_CANCEL')
|
|
@@ -176,7 +182,7 @@ try:
|
|
|
176
182
|
assert all(not Path(path).exists() for path in logs), 'Session logs were not removed'
|
|
177
183
|
(OUT / 'events.jsonl').write_text('\n'.join(json.dumps(e) for e in records) + '\n')
|
|
178
184
|
(OUT / 'result.json').write_text(json.dumps({'passed': True, 'screenshots': screens,
|
|
179
|
-
'checks': ['two jobs', 'third rejected', 'Codemode returns while jobs live', 'Scheduler coexistence', '/bg output',
|
|
185
|
+
'checks': ['two jobs', 'third rejected', 'Codemode background calls blocked', 'model returns while jobs live', 'Scheduler coexistence', '/bg output',
|
|
180
186
|
'cancel notification', 'success notification', 'main agent notification', 'no duplicate persisted notifications',
|
|
181
187
|
'5-second terminal row', 'reload cleanup', 'new-session cleanup', 'quit cleanup']}, indent=2))
|
|
182
188
|
except Exception:
|