@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
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
3
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { test } from "node:test";
|
|
8
|
+
import { piBinary } from "./runtime.mjs";
|
|
9
|
+
|
|
10
|
+
test("native Codemode receives start guidance and exposes it to the model", () => {
|
|
11
|
+
const directory = mkdtempSync(join(tmpdir(), "pi-bg-guidance-"));
|
|
12
|
+
try {
|
|
13
|
+
const events = join(directory, "events.jsonl");
|
|
14
|
+
const output = execFileSync(piBinary, [
|
|
15
|
+
"--no-extensions", "--no-skills", "--no-prompt-templates", "--no-themes", "--no-context-files", "--no-session", "--offline",
|
|
16
|
+
"-e", "builtin:codemode", "-e", fileURLToPath(new URL("../extensions/background-tasks.ts", import.meta.url)),
|
|
17
|
+
"-e", fileURLToPath(new URL("fixtures/provider.ts", import.meta.url)),
|
|
18
|
+
"--tools", "codemode,background_task", "--model", "bg-fixture/deterministic", "-p", "SMOKE_GUIDANCE",
|
|
19
|
+
], { cwd: directory, encoding: "utf8", timeout: 30000,
|
|
20
|
+
env: { ...process.env, PI_CODING_AGENT_DIR: directory, PI_OFFLINE: "1", BG_SMOKE_EVENTS: events }, stdio: ["ignore", "pipe", "pipe"] });
|
|
21
|
+
assert.match(output, /Main agent is ready/);
|
|
22
|
+
const records = readFileSync(events, "utf8").trim().split("\n").map((line) => JSON.parse(line));
|
|
23
|
+
const started = records.find((entry) => entry.event === "tool_end" && entry.name === "background_task");
|
|
24
|
+
assert.equal(started.isError, false);
|
|
25
|
+
const guidance = started.result.structuredContent.guidance;
|
|
26
|
+
assert.equal(guidance, "完成会自动通知。现在不要查询等待;继续其他独立工作,没有其他工作就结束当前轮,收到通知后再取结果。");
|
|
27
|
+
const codemode = records.find((entry) => entry.event === "tool_end" && entry.name === "codemode");
|
|
28
|
+
assert.equal(codemode.isError, false);
|
|
29
|
+
assert.ok(codemode.result.content.some((block) => block.type === "text" && block.text.includes(guidance)));
|
|
30
|
+
const requests = records.filter((entry) => entry.event === "request");
|
|
31
|
+
assert.ok(requests[0].backgroundDescription.includes(guidance));
|
|
32
|
+
assert.ok(requests[1].text.includes(guidance), "the next model request sees guidance printed from the programmatic result");
|
|
33
|
+
} finally { rmSync(directory, { recursive: true, force: true }); }
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
for (const mode of ["codemode", "direct"]) {
|
|
37
|
+
for (const reads of [0, 1, 4]) {
|
|
38
|
+
test(`real Pi ${mode} merges four completions before the next model call, excluding ${reads} consumed results`, () => {
|
|
39
|
+
const directory = mkdtempSync(join(tmpdir(), "pi-bg-notifications-"));
|
|
40
|
+
try {
|
|
41
|
+
const events = join(directory, "events.jsonl");
|
|
42
|
+
writeFileSync(join(directory, "settings.json"), JSON.stringify({ quietStartup: true, followUpMode: "one-at-a-time", steeringMode: "one-at-a-time" }));
|
|
43
|
+
const output = execFileSync(piBinary, [
|
|
44
|
+
"--no-extensions", "--no-skills", "--no-prompt-templates", "--no-themes", "--no-context-files", "--no-session", "--offline",
|
|
45
|
+
"-e", "builtin:codemode", "-e", process.env.BG_TEST_EXTENSION ?? fileURLToPath(new URL("../extensions/background-tasks.ts", import.meta.url)),
|
|
46
|
+
"-e", fileURLToPath(new URL("fixtures/notifications.ts", import.meta.url)),
|
|
47
|
+
"--tools", "codemode,background_task,synchronous_subagent_fixture", "--model", "bg-notifications/deterministic", "--mode", "json", "-p", "Run the synchronous subagent scenario",
|
|
48
|
+
], { cwd: directory, encoding: "utf8", timeout: 30000, env: { ...process.env, PI_CODING_AGENT_DIR: directory, PI_OFFLINE: "1", BG_NOTIFICATION_EVENTS: events, BG_NOTIFICATION_READS: String(reads), BG_NOTIFICATION_MODE: mode }, stdio: ["ignore", "pipe", "pipe"] });
|
|
49
|
+
assert.doesNotMatch(output, /Failed to load|Error loading|Fixture shell jobs did not finish/);
|
|
50
|
+
const records = readFileSync(events, "utf8").trim().split("\n").map((line) => JSON.parse(line));
|
|
51
|
+
const returned = records.find((entry) => entry.event === "returned");
|
|
52
|
+
assert.ok(returned, `the blocking tool finished all real shell jobs\n${output}`);
|
|
53
|
+
assert.equal(returned.tasks.length, 4);
|
|
54
|
+
assert.ok(returned.tasks.every((task) => task.status === "failed" && task.exit_code === 7));
|
|
55
|
+
const transcript = output.trim().split("\n").map((line) => JSON.parse(line));
|
|
56
|
+
const notifications = transcript.filter((entry) =>
|
|
57
|
+
(entry.type === "entry_appended" && entry.entry.customType === "background-task-finished") ||
|
|
58
|
+
(entry.type === "message_start" && entry.message.customType === "background-task-finished")
|
|
59
|
+
);
|
|
60
|
+
assert.equal(notifications.length, reads === 4 ? 0 : 1);
|
|
61
|
+
if (reads !== 4) {
|
|
62
|
+
const message = notifications[0].entry ?? notifications[0].message;
|
|
63
|
+
const tasks = message.details.tasks;
|
|
64
|
+
assert.equal(tasks.length, 4 - reads);
|
|
65
|
+
assert.ok(tasks.every((task) => !returned.consumed.includes(task.id)));
|
|
66
|
+
const toolEnd = transcript.findIndex((entry) => entry.type === "tool_execution_end" && entry.toolName === "synchronous_subagent_fixture");
|
|
67
|
+
assert.ok(toolEnd >= 0 && transcript.indexOf(notifications[0]) > toolEnd, "the notification cannot interrupt the synchronous tool");
|
|
68
|
+
assert.match(message.content, /Ended at: \d{4}-/);
|
|
69
|
+
}
|
|
70
|
+
const requests = records.filter((entry) => entry.event === "request");
|
|
71
|
+
assert.equal(requests.length, mode === "direct" ? 3 : 2, "no extra model turns after the final report");
|
|
72
|
+
const nextContext = JSON.stringify(requests.at(-1).messages);
|
|
73
|
+
assert.equal((nextContext.match(/\[Background task finished\]/g) ?? []).length, reads === 4 ? 0 : 1);
|
|
74
|
+
assert.doesNotMatch(nextContext, /FINAL_REPORT/, "completion is delivered before, not after, the final report");
|
|
75
|
+
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
|
+
for (const task of returned.tasks.filter((task) => !returned.consumed.includes(task.id))) assert.ok(contextText.includes(task.id));
|
|
77
|
+
} finally { rmSync(directory, { recursive: true, force: true }); }
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -138,12 +138,15 @@ try:
|
|
|
138
138
|
shot('running')
|
|
139
139
|
send('/bg output ' + first['id'])
|
|
140
140
|
send('SMOKE_CANCEL')
|
|
141
|
-
wait(lambda: bool(finished('cancelled')), 'cancel
|
|
142
|
-
wait(lambda: bool(finished('succeeded')), 'successful background
|
|
141
|
+
wait(lambda: bool(finished('cancelled')), 'cancel notification')
|
|
142
|
+
wait(lambda: bool(finished('succeeded')), 'successful background notification')
|
|
143
143
|
shot('completed')
|
|
144
144
|
send('SMOKE_OUTPUT')
|
|
145
145
|
wait(lambda: any(e.get('result', {}).get('structuredContent', {}).get('output') == 'BUILD SUCCESSFUL\n' for e in tools()), 'explicit output query')
|
|
146
|
-
wait(lambda: '
|
|
146
|
+
wait(lambda: 'BACKGROUND_NOTIFICATION_RECEIVED' in raw, 'main agent automatic response')
|
|
147
|
+
pump(0.5)
|
|
148
|
+
notifications = [e for e in events() if e.get('event') == 'custom' and e.get('type') == 'background-task-finished']
|
|
149
|
+
assert len(notifications) == 2, 'Duplicate persisted busy/idle notifications'
|
|
147
150
|
pump(5.3)
|
|
148
151
|
shot('history')
|
|
149
152
|
|
|
@@ -174,7 +177,8 @@ try:
|
|
|
174
177
|
(OUT / 'events.jsonl').write_text('\n'.join(json.dumps(e) for e in records) + '\n')
|
|
175
178
|
(OUT / 'result.json').write_text(json.dumps({'passed': True, 'screenshots': screens,
|
|
176
179
|
'checks': ['two jobs', 'third rejected', 'Codemode returns while jobs live', 'Scheduler coexistence', '/bg output',
|
|
177
|
-
'cancel notification', 'success notification', 'main agent
|
|
180
|
+
'cancel notification', 'success notification', 'main agent notification', 'no duplicate persisted notifications',
|
|
181
|
+
'5-second terminal row', 'reload cleanup', 'new-session cleanup', 'quit cleanup']}, indent=2))
|
|
178
182
|
except Exception:
|
|
179
183
|
(OUT / 'error.txt').write_text(traceback.format_exc())
|
|
180
184
|
raise
|