Skip to content

Commit 75dc4f3

Browse files
osolmazclaude
andcommitted
fix: adopt Pi 1.1.0 and require a started agent prompt
Development and CI now use Pi 1.1.0, so CI runs the deferred agent_settled timing behind the resumed-step incident. On Pi 1.1.0 the new E2E test hangs without the delivery fix and passes with it. Since Pi 0.99.0, preflightResult reports a disposition for an accepted prompt (started, handled, or queued) and nothing for a rejected one. The agent group waited for false, so a handled or queued prompt passed as accepted. It now requires started, and it picks the chat entry of a model ID that also has image or classifier entries. The Pi peer ranges move to >=0.99.0, the first release with both APIs. The live E2E isolation check accepts the extensions that Pi 1.0 and later ship built in, such as llama.cpp and MCP, and still rejects every other foreign resource. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 32b88ad commit 75dc4f3

9 files changed

Lines changed: 964 additions & 3014 deletions

‎docs/plans/2026-10-08-resumed-step-delivery-plan.md‎

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -79,15 +79,23 @@ With these rules, the result no longer depends on when Pi writes the entry.
7979
- [Workflow server](../WORKFLOW_SERVER.md) and [Workflow step messages](../WORKFLOW_STEP_MESSAGES.md):
8080
state the rules above, and fix the stale sentence that says the branch report covers the complete
8181
view window.
82+
- Pi 1.1.0 for development and CI, at Onur's request after the first review. The development
83+
dependencies move from 0.85.0 to 1.1.0, so CI runs the deferred `agent_settled` timing that caused
84+
this incident. The new E2E test hangs on Pi 1.1.0 without the fix.
85+
- `src/builtins/pi-agent-group.ts`: since Pi 0.99.0, `preflightResult` reports a disposition for an
86+
accepted prompt (`started`, `handled`, or `queued`) and nothing for a rejected one. The old check
87+
waited for `false`, so a handled or queued prompt passed as accepted. The agent group now requires
88+
`started`, and it picks the chat entry of a model ID that also has image or classifier entries.
89+
The Pi peer ranges move to `>=0.99.0`, the first release with both APIs.
90+
- `scripts/live-e2e.mjs`: the isolation check accepts the extensions that Pi 1.0 and later ship built
91+
in, such as llama.cpp and MCP, and still rejects every other foreign resource.
8292

8393
## Non-goals
8494

8595
- Pi and its session files stay unchanged, and the branch report keeps its format with one message
8696
ID.
8797
- No change to reports that name no message. They keep their current rule.
8898
- No change to the status text for a pending step.
89-
- No upgrade of the Pi development dependency from 0.85.0. The unit tests model the deferred Pi
90-
delivery directly, and the E2E suite can run on a newer Pi through `PI_WORKFLOWS_E2E_PI_ENTRY`.
9199
- Post-workflow turns and missing-submission reminders keep their behavior. Reminder steps are a
92100
second message for a request, so this fix also stops the server from cancelling them.
93101

@@ -119,7 +127,8 @@ The E2E test "delivers a step resumed from a model turn and the step after it" r
119127
on a real Pi with a local mock model. `PI_WORKFLOWS_E2E_PI_ENTRY` points the E2E suite at another Pi
120128
`cli.js`. With Pi 1.0.4 and without this fix, the test hangs at the second step with "A step is
121129
pending delivery. It starts a new model turn after this turn ends." With the fix, it passes, and so
122-
does the whole workflow E2E file:
130+
does the whole workflow E2E file. The pinned Pi 1.1.0 gives the same results, so CI runs this check
131+
without the variable:
123132

124133
```bash
125134
PI_WORKFLOWS_E2E_PI_ENTRY=/path/to/pi-coding-agent/dist/cli.js \

‎package-lock.json‎

Lines changed: 837 additions & 2966 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎package.json‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -77,9 +77,9 @@
7777
"tsx": "^4.23.13"
7878
},
7979
"devDependencies": {
80-
"@earendil-works/pi-ai": "0.85.0",
81-
"@earendil-works/pi-coding-agent": "0.85.0",
82-
"@earendil-works/pi-tui": "0.85.0",
80+
"@earendil-works/pi-ai": "1.1.0",
81+
"@earendil-works/pi-coding-agent": "1.1.0",
82+
"@earendil-works/pi-tui": "1.1.0",
8383
"@types/better-sqlite3": "^7.6.13",
8484
"@types/node": "^26.1.1",
8585
"@vitest/coverage-istanbul": "^4.1.10",
@@ -90,9 +90,9 @@
9090
"vitest": "^4.1.10"
9191
},
9292
"peerDependencies": {
93-
"@earendil-works/pi-ai": ">=0.84.2",
94-
"@earendil-works/pi-coding-agent": ">=0.84.2",
95-
"@earendil-works/pi-tui": ">=0.84.2",
93+
"@earendil-works/pi-ai": ">=0.99.0",
94+
"@earendil-works/pi-coding-agent": ">=0.99.0",
95+
"@earendil-works/pi-tui": ">=0.99.0",
9696
"typebox": "*"
9797
},
9898
"engines": {

‎scripts/live-e2e.d.mts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,10 @@ export function isExpectedWorkflowAbort(
2727
): boolean;
2828
export function parseArgs(argv: string[]): LiveE2eOptions;
2929
export function configureModelBudget(profile: string, options: LiveE2eOptions): Promise<void>;
30+
export function assertPackageIsolation(
31+
rpc: { request(command: string): Promise<unknown> },
32+
candidate: string,
33+
): Promise<void>;
3034
export function assertSafeTempRoot(root: string, temporaryDirectory?: string): string;
3135
export function removeTemporaryRoot(root: string, temporaryDirectory?: string): Promise<void>;
3236
export function withTemporaryRoot<T>(

‎scripts/live-e2e.mjs‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -686,7 +686,7 @@ async function startPi(context) {
686686
return rpc;
687687
}
688688

689-
async function assertPackageIsolation(rpc, candidate) {
689+
export async function assertPackageIsolation(rpc, candidate) {
690690
const data = requireObject(await rpc.request("get_commands"), "Pi commands response");
691691
if (!Array.isArray(data.commands)) throw new Error("Pi commands response has no command list");
692692
const commandPath = (command) => command.path ?? command.sourceInfo?.path;
@@ -707,6 +707,9 @@ async function assertPackageIsolation(rpc, candidate) {
707707
}
708708
const unrelated = data.commands.filter((command) => {
709709
if (command.source !== "extension") return true;
710+
// Pi 1.0 and later ship built-in extensions, such as llama.cpp and MCP. They
711+
// belong to base Pi, so they do not break isolation from user resources.
712+
if (command.sourceInfo?.source === "builtin") return false;
710713
const sourcePath = commandPath(command);
711714
if (typeof sourcePath === "string" && sourcePath.startsWith("<inline:")) return false;
712715
return (

‎src/builtins/pi-agent-group.ts‎

Lines changed: 30 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import fs from "node:fs/promises";
22
import path from "node:path";
33
import { fileURLToPath } from "node:url";
4+
import { isModelType } from "@earendil-works/pi-ai";
45
import {
56
createAgentSession,
67
createAgentSessionFromServices,
@@ -79,8 +80,17 @@ export type PiAgentLifecycleEvent = {
7980

8081
type PiAgentEvent = Record<string, unknown>;
8182

83+
/**
84+
* How Pi dispatched an accepted prompt. Pi reports no disposition for a rejected
85+
* prompt, and only a `started` prompt runs the model.
86+
*/
87+
type PromptDisposition = "started" | "handled" | "queued";
88+
8289
type PiAgentSession = {
83-
prompt(text: string, options?: { preflightResult?: (accepted: boolean) => void }): Promise<void>;
90+
prompt(
91+
text: string,
92+
options?: { preflightResult?: (disposition: PromptDisposition) => void },
93+
): Promise<void>;
8494
subscribe(listener: (event: PiAgentEvent) => void): () => void;
8595
abort(): Promise<void>;
8696
dispose(): void | Promise<void>;
@@ -823,7 +833,7 @@ async function runOneAgent(
823833
let session: PiAgentSession | undefined;
824834
let unsubscribe: (() => void) | undefined;
825835
let finalMessage: Record<string, unknown> | undefined;
826-
let preflightAccepted: boolean | undefined;
836+
let preflightDisposition: PromptDisposition | undefined;
827837
let abortKind: "timeout" | "cancelled" | undefined;
828838
let abortFailure: unknown;
829839
let abortWork = Promise.resolve();
@@ -875,8 +885,8 @@ async function runOneAgent(
875885
}
876886

877887
await session.prompt(request.prompt, {
878-
preflightResult: (accepted) => {
879-
preflightAccepted = accepted;
888+
preflightResult: (disposition) => {
889+
preflightDisposition = disposition;
880890
},
881891
});
882892
await abortWork;
@@ -894,8 +904,14 @@ async function runOneAgent(
894904
`${cancellationReason(options.signal.reason)}${abortSuffix(abortFailure)}`,
895905
);
896906
}
897-
if (preflightAccepted === false) {
898-
throw new PiAgentGroupError(request.id, "rejected prompt", "prompt preflight failed");
907+
if (preflightDisposition !== "started") {
908+
throw new PiAgentGroupError(
909+
request.id,
910+
"rejected prompt",
911+
preflightDisposition === undefined
912+
? "prompt preflight failed"
913+
: `Pi ${preflightDisposition} the prompt without running the model`,
914+
);
899915
}
900916
lifecycle.emit("running", "finalizing", true);
901917
const text = finalAssistantText(request.id, finalMessage, options.maxFinalChars!);
@@ -980,10 +996,14 @@ async function createSdkSession(
980996
signal: AbortSignal,
981997
): Promise<PiAgentSession> {
982998
if (signal.aborted) throw cancellationError(request.id, signal.reason);
983-
const model = plan.modelSnapshot.models.find(
984-
(candidate) =>
985-
candidate.provider === plan.dispatch.provider && candidate.id === plan.dispatch.modelId,
986-
);
999+
// One provider model ID can name a chat entry and an image or classifier
1000+
// entry. An agent session needs the chat entry.
1001+
const model = plan.modelSnapshot.models
1002+
.filter((candidate) => isModelType(candidate, "chat"))
1003+
.find(
1004+
(candidate) =>
1005+
candidate.provider === plan.dispatch.provider && candidate.id === plan.dispatch.modelId,
1006+
);
9871007
if (model === undefined) {
9881008
throw new PiAgentGroupError(
9891009
request.id,

‎test/live-e2e-script.test.ts‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import os from "node:os";
55
import path from "node:path";
66
import { describe, expect, it } from "vitest";
77
import {
8+
assertPackageIsolation,
89
assertSafeTempRoot,
910
configureModelBudget,
1011
isExpectedWorkflowAbort,
@@ -196,6 +197,36 @@ describe("installed live E2E script", () => {
196197
}
197198
});
198199

200+
it("accepts base Pi built-in extensions but no other foreign resource", async () => {
201+
const candidate = path.join(os.tmpdir(), "live-e2e-candidate");
202+
const own = ["workflow", "resource-manager", "piw"].map((name) => ({
203+
name,
204+
source: "extension",
205+
sourceInfo: { path: path.join(candidate, "src", "extension", "index.ts") },
206+
}));
207+
const builtin = {
208+
name: "llama",
209+
source: "extension",
210+
sourceInfo: { path: "builtin:llama.cpp", source: "builtin" },
211+
};
212+
const rpc = (commands: unknown[]) => ({ request: async () => ({ commands }) });
213+
await expect(
214+
assertPackageIsolation(rpc([...own, builtin]), candidate),
215+
).resolves.toBeUndefined();
216+
await expect(
217+
assertPackageIsolation(
218+
rpc([
219+
...own,
220+
{ name: "user", source: "extension", sourceInfo: { path: "/home/user/ext.ts" } },
221+
]),
222+
candidate,
223+
),
224+
).rejects.toThrow("Base Pi loaded unrelated resources");
225+
await expect(
226+
assertPackageIsolation(rpc([...own, { name: "skill:x", source: "skill" }]), candidate),
227+
).rejects.toThrow("Base Pi loaded unrelated resources");
228+
});
229+
199230
it("refuses cleanup outside one direct guarded temporary root", () => {
200231
expect(() => assertSafeTempRoot("/", "/tmp")).toThrow("Refusing unsafe");
201232
expect(() => assertSafeTempRoot(os.homedir(), "/tmp")).toThrow("Refusing unsafe");

‎test/package-resources.test.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,8 +89,8 @@ describe("Pi package resources", () => {
8989
"@earendil-works/pi-coding-agent",
9090
"@earendil-works/pi-tui",
9191
]) {
92-
expect(manifest.devDependencies?.[packageName]).toBe("0.85.0");
93-
expect(manifest.peerDependencies?.[packageName]).toBe(">=0.84.2");
92+
expect(manifest.devDependencies?.[packageName]).toBe("1.1.0");
93+
expect(manifest.peerDependencies?.[packageName]).toBe(">=0.99.0");
9494
}
9595
expect(manifest.dependencies?.["@earendil-works/pi-server"]).toBe(">=0.85.0");
9696
});

‎test/pi-agent-group.test.ts‎

Lines changed: 38 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ function successfulFactory(
9393
return () => listeners.delete(listener);
9494
},
9595
async prompt(_prompt, options) {
96-
options?.preflightResult?.(true);
96+
options?.preflightResult?.("started");
9797
active += 1;
9898
hooks.active?.(active);
9999
for (const listener of listeners) {
@@ -218,7 +218,7 @@ describe("Pi agent groups", () => {
218218
return () => listeners.delete(listener);
219219
},
220220
async prompt(_prompt, options) {
221-
options?.preflightResult?.(true);
221+
options?.preflightResult?.("started");
222222
for (const listener of listeners) listener(message("", "error", "provider failed"));
223223
},
224224
async abort() {},
@@ -257,7 +257,7 @@ describe("Pi agent groups", () => {
257257
thinkingLevel: "off",
258258
subscribe: () => () => {},
259259
async prompt(_prompt, options) {
260-
options?.preflightResult?.(true);
260+
options?.preflightResult?.("started");
261261
await new Promise<void>((resolve) => {
262262
release = resolve;
263263
});
@@ -414,30 +414,38 @@ describe("Pi agent groups", () => {
414414
});
415415

416416
it("reports prompt rejection and missing final output", async () => {
417-
const rejected: PiAgentSessionFactory = async () => ({
418-
model: { provider: "mock", id: "model" },
419-
thinkingLevel: "off",
420-
subscribe: () => () => {},
421-
async prompt(_prompt, options) {
422-
options?.preflightResult?.(false);
423-
},
424-
async abort() {},
425-
dispose() {},
426-
});
427-
await expect(
428-
runPiAgentGroup([request("rejected")], {
429-
maxConcurrency: 1,
430-
signal: new AbortController().signal,
431-
sessionFactory: rejected,
432-
}),
433-
).rejects.toThrow(/rejected prompt/);
417+
// Pi reports no disposition for a rejected prompt, and an extension can
418+
// handle a prompt or Pi can queue it without running the model.
419+
for (const disposition of [undefined, "handled", "queued"] as const) {
420+
const rejected: PiAgentSessionFactory = async () => ({
421+
model: { provider: "mock", id: "model" },
422+
thinkingLevel: "off",
423+
subscribe: () => () => {},
424+
async prompt(_prompt, options) {
425+
if (disposition !== undefined) options?.preflightResult?.(disposition);
426+
},
427+
async abort() {},
428+
dispose() {},
429+
});
430+
await expect(
431+
runPiAgentGroup([request("rejected")], {
432+
maxConcurrency: 1,
433+
signal: new AbortController().signal,
434+
sessionFactory: rejected,
435+
}),
436+
).rejects.toThrow(
437+
disposition === undefined
438+
? /rejected prompt: prompt preflight failed/
439+
: new RegExp(`rejected prompt: Pi ${disposition} the prompt`),
440+
);
441+
}
434442

435443
const missing: PiAgentSessionFactory = async () => ({
436444
model: { provider: "mock", id: "model" },
437445
thinkingLevel: "off",
438446
subscribe: () => () => {},
439447
async prompt(_prompt, options) {
440-
options?.preflightResult?.(true);
448+
options?.preflightResult?.("started");
441449
},
442450
async abort() {},
443451
dispose() {},
@@ -462,7 +470,7 @@ describe("Pi agent groups", () => {
462470
return () => listeners.delete(listener);
463471
},
464472
async prompt(_prompt, options) {
465-
options?.preflightResult?.(true);
473+
options?.preflightResult?.("started");
466474
for (const listener of listeners) {
467475
listener({
468476
type: "message_end",
@@ -498,7 +506,8 @@ describe("Pi agent groups", () => {
498506
listeners.add(listener);
499507
return () => listeners.delete(listener);
500508
},
501-
async prompt() {
509+
async prompt(_prompt, options) {
510+
options?.preflightResult?.("started");
502511
for (const listener of listeners) {
503512
listener({ type: "tool_execution_start", toolName: "bash" });
504513
listener(message("old failure", "error", "old"));
@@ -545,7 +554,8 @@ describe("Pi agent groups", () => {
545554
listeners.add(listener);
546555
return () => listeners.delete(listener);
547556
},
548-
async prompt() {
557+
async prompt(_prompt, options) {
558+
options?.preflightResult?.("started");
549559
for (const event of events) for (const listener of listeners) listener(event);
550560
},
551561
async abort() {},
@@ -660,7 +670,8 @@ describe("Pi agent groups", () => {
660670
listeners.add(listener);
661671
return () => listeners.delete(listener);
662672
},
663-
async prompt() {
673+
async prompt(_prompt, options) {
674+
options?.preflightResult?.("started");
664675
for (const listener of listeners) listener(message("ok"));
665676
},
666677
async abort() {},
@@ -818,7 +829,8 @@ describe("Pi agent groups", () => {
818829
listeners.add(listener);
819830
return () => listeners.delete(listener);
820831
},
821-
async prompt() {
832+
async prompt(_prompt, options) {
833+
options?.preflightResult?.("started");
822834
for (let index = 0; index < 100; index += 1) {
823835
for (const listener of listeners) {
824836
listener({

0 commit comments

Comments
 (0)