diff --git a/doc/architecture/runner-pi-capabilities.md b/doc/architecture/runner-pi-capabilities.md index b42f0a22521..1926a380193 100644 --- a/doc/architecture/runner-pi-capabilities.md +++ b/doc/architecture/runner-pi-capabilities.md @@ -1,9 +1,9 @@ # Pi rich ACP runtime -## Pi 1.0 candidate (2026-10-01, profile v11) +## Pi 1.0 candidate (2026-10-02, profile v12) The candidate now pins **`@earendil-works/pi-coding-agent@1.0.0`** with -`pi-acp@0.0.33`, ACPX `0.13.1`, and portable Node `24.21.0`. Pi v10 and older +`pi-acp@0.0.33`, ACPX `0.13.1`, and portable Node `24.21.0`. Pi v11 and older sessions must reopen. Cursor v10, Copilot v12, and legacy adapters are unchanged. This is a new runtime candidate: historical Pi 0.84.2 passes do not qualify it. Local Product, Runner protocol, and Daytona paid qualification remain pending. @@ -17,6 +17,27 @@ The isolated npm lock preserves the complete upstream shrinkwrap, supplementing seven Pi-family entries' omitted integrities from their exact npm 1.0.0 records. Upstream now pins Undici 8.10.2 itself; the previous 8.9.0 replacement is removed. +Profile v11's first local hello failed on its first streamed update: Pi 1 RPC +removes `message` and `partial` from deltas, while the wrapper required the old +shape. The run stopped and the task became blocked; this was a runtime +compatibility failure, not a successful hello. V12 binds deltas to the original +message occurrence, tracks indexed tool fragments, and checks completed text, +thinking, tool identity, and arguments against native final receipts. Real Pi 1 +RPC/owned-extension tests reproduce the failure and pass the repaired local-only +get-context → finish path, interleaved tools, and a sanitized provider error. +These synthetic loopback responses establish compatibility, not paid qualification. +Pi may also send a native final message without streamed blocks. That receipt +is preserved without inventing deltas; only observed completed stream blocks +are checked against its indexed content. Error or aborted receipts may retire +incomplete blocks, but cannot contradict blocks already completed. + +Runtime failures now emit bounded known reasons before prompt rejection. Raw +exception bodies, credentials, paths, and unknown text stay withheld. Actual +provider token usage still comes from native completed receipts; fixture usage +is not live billing evidence. New wrapper/helper closure pins and Rust admission +constants require fresh platform packs and daemons. Historical v11 artifacts +must not be relabeled as v12. + Compatibility changes preserve the existing rich runtime surface: - Native steering and follow-up acknowledgements now carry `disposition`. diff --git a/doc/architecture/runner-rich-acp-capabilities.md b/doc/architecture/runner-rich-acp-capabilities.md index d3d20607c25..826478c83ed 100644 --- a/doc/architecture/runner-rich-acp-capabilities.md +++ b/doc/architecture/runner-rich-acp-capabilities.md @@ -741,7 +741,7 @@ configuration defaults to full auto; it never overrides read-only task policy. ## Distribution and isolation Cursor pins `2026.09.26-dd393fe`; Copilot pins `1.0.88`; Pi pins wrapper `0.0.33`, -runtime `1.0.0` (Pi profile v11), portable Node and its full npm lock. Native distribution hashes +runtime `1.0.0` (Pi profile v12), portable Node and its full npm lock. Native distribution hashes cover macOS ARM64, macOS x64 and Linux x64. Source-owned closure pins remain separate from profile declaration digests. Native admission reads held files, verifies every admitted byte, creates a private immutable snapshot and retains diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs index 829efa9603d..14b96e98cd7 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs @@ -207,7 +207,7 @@ impl AcpxProviderDescriptor { "0.0.33", Some("@earendil-works/pi-coding-agent"), Some("1.0.0"), - "sha256:5e276f48c8a87b3e6165369faac62d3925282c84b98934575b1b7b97ad50b309", + "sha256:47306e6d2a9b59e8f9189f725ebb7a0a7f91826044d1739e1a35ab31f228ba1f", ), "cursor" => ( self.model.as_str(), @@ -2821,7 +2821,7 @@ mod tests { "pi", "pi-acp", "0.0.33", - "sha256:5e276f48c8a87b3e6165369faac62d3925282c84b98934575b1b7b97ad50b309", + "sha256:47306e6d2a9b59e8f9189f725ebb7a0a7f91826044d1739e1a35ab31f228ba1f", Some("@earendil-works/pi-coding-agent"), Some("1.0.0"), "openrouter/deepseek/deepseek-v4-flash-0731", diff --git a/packages/paperclip-runner/runner/crates/runner-core/tests/native_provider_backend.rs b/packages/paperclip-runner/runner/crates/runner-core/tests/native_provider_backend.rs index 513642bda60..7ddf69ad380 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/tests/native_provider_backend.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/tests/native_provider_backend.rs @@ -17,7 +17,7 @@ use sha2::{Digest, Sha256}; const CODEX_ACPX_DIGEST: &str = "sha256:c4538599d1ab767db5dff50934f13bb5ba313a59d9c4a83e993fac4617ea63d3"; const PI_ACPX_DIGEST: &str = - "sha256:5e276f48c8a87b3e6165369faac62d3925282c84b98934575b1b7b97ad50b309"; + "sha256:47306e6d2a9b59e8f9189f725ebb7a0a7f91826044d1739e1a35ab31f228ba1f"; fn temporary_directory(label: &str) -> PathBuf { let nonce = SystemTime::now() diff --git a/packages/paperclip-runner/scripts/materialize-pi-distribution.mjs b/packages/paperclip-runner/scripts/materialize-pi-distribution.mjs index f58b7e5544c..d61cfbc0730 100644 --- a/packages/paperclip-runner/scripts/materialize-pi-distribution.mjs +++ b/packages/paperclip-runner/scripts/materialize-pi-distribution.mjs @@ -19,8 +19,8 @@ const patchPath = join(workspaceRoot, "patches/pi-acp@0.0.33.patch"); const supportedTargets = new Set(["darwin-arm64", "darwin-x64", "linux-x64"]); export const PI_DISTRIBUTION_PINS = Object.freeze({ wrapper: "0.0.33", runtime: "1.0.0", sdk: "0.26.0", zod: "3.25.76", nodeVersion: PI_NODE_VERSION, undici: "8.10.2", nodeBundledUndici: "7.29.1", - wrapperSha256: "f881b0461d706780953919b791aeb9614921c3be2d7cc41477f2a9afa2cbfb69", - helperSha256: "a9f9d489ca0f14765ac1c4ab80c6bd0bd3fef46a8f41b8899ed2d9e78c115aba", + wrapperSha256: "c41750802680543d72a5a43e21eaf91c31ca8cec833bbf5f27330614936810fe", + helperSha256: "2191b1e5f281d24508ec7eaff39011ee863a8a70bf54f4aa0490ad0172b323da", }); const hash = (bytes) => createHash("sha256").update(bytes).digest("hex"); diff --git a/packages/paperclip-runner/src/drivers/acpx/pi-acp-runtime.ts b/packages/paperclip-runner/src/drivers/acpx/pi-acp-runtime.ts index d63a7d0d5df..e05311a41b8 100644 --- a/packages/paperclip-runner/src/drivers/acpx/pi-acp-runtime.ts +++ b/packages/paperclip-runner/src/drivers/acpx/pi-acp-runtime.ts @@ -33,6 +33,111 @@ export interface PiAssistantBoundary { stopReason?: "stop" | "length" | "toolUse" | "error" | "aborted"; } +/** Pi 1 RPC serializes message_update without message/partial. Reconstruct only + * the streaming view, anchored to the actual message_start; message_end remains + * the native authoritative receipt. Never infer an executable tool from text. */ +export class PiRpcMessageDeltas { + private active: RecordValue | null = null; + private blocks = new Map(); + private streamedBytes = 0; + private failed = false; + private fail(): never { this.failed = true; throw new Error("Pi RPC message delta boundary is invalid"); } + normalize(event: RecordValue): RecordValue { + if (this.failed) return this.fail(); + if (event.type === "message_start" && event.message && typeof event.message === "object" && (event.message as RecordValue).role === "assistant") { + if (this.active) return this.fail(); + this.active = event.message as RecordValue; this.blocks.clear(); this.streamedBytes = 0; + return event; + } + if (event.type === "message_end" && event.message && typeof event.message === "object" && (event.message as RecordValue).role === "assistant") { + const message = event.message as RecordValue; + if (!this.active || message.timestamp !== this.active.timestamp) return this.fail(); + if (!["error", "aborted"].includes(String(message.stopReason)) && [...this.blocks.values()].some(block => !block.ended)) return this.fail(); + for (const [index, block] of this.blocks) if (block.ended) { + const final = Array.isArray(message.content) ? message.content[index] : undefined; + if (!final || typeof final !== "object") return this.fail(); + if (block.kind === "toolcall") { + if (final.type !== "toolCall" || toolSignature(final.id, final.name, final.arguments) !== block.final) return this.fail(); + } else if (final.type !== block.kind || final[block.kind === "text" ? "text" : "thinking"] !== block.json) return this.fail(); + } + this.active = null; this.blocks.clear(); return event; + } + if (event.type !== "message_update") return event; + if (!this.active || event.message !== undefined || !event.assistantMessageEvent || typeof event.assistantMessageEvent !== "object" || Array.isArray(event.assistantMessageEvent)) return this.fail(); + const update = event.assistantMessageEvent as RecordValue; + if (update.partial !== undefined || typeof update.type !== "string") return this.fail(); + const match = /^(text|thinking|toolcall)_(start|delta|end)$/.exec(update.type); + const index = update.contentIndex; + if (!match || !Number.isSafeInteger(index) || (index as number) < 0 || (index as number) >= 4096) return this.fail(); + const [, kind, phase] = match; + let block = this.blocks.get(index as number); + if (phase === "start") { + if (block) return this.fail(); + block = { kind: kind!, ended: false, json: "" }; + if (kind === "toolcall") { + if (typeof update.id !== "string" || Buffer.byteLength(update.id) > 256 || typeof update.toolName !== "string" || Buffer.byteLength(update.toolName) > 256) return this.fail(); + if (update.id && [...this.blocks.values()].some(other => other.id === update.id)) return this.fail(); + block.id = update.id; block.name = update.toolName; + } + this.blocks.set(index as number, block); + } else if (!block || block.ended || block.kind !== kind) return this.fail(); + if (phase === "delta") { + if (typeof update.delta !== "string" || Buffer.byteLength(update.delta) > 262_144) return this.fail(); + this.streamedBytes += Buffer.byteLength(update.delta); + if (this.streamedBytes > 4 * 1024 * 1024) return this.fail(); + block!.json += update.delta; + if (kind === "toolcall" && Buffer.byteLength(block!.json) > 1_048_576) return this.fail(); + } + let toolCall: RecordValue | undefined; + if (kind === "toolcall") { + if (phase === "end") { + if (!update.toolCall || typeof update.toolCall !== "object" || Array.isArray(update.toolCall)) return this.fail(); + const final = update.toolCall as RecordValue; + if (final.type !== "toolCall" || typeof final.id !== "string" || !final.id || Buffer.byteLength(final.id) > 256 || typeof final.name !== "string" || !final.name || Buffer.byteLength(final.name) > 256 + || block!.id && block!.id !== final.id || block!.name && block!.name !== final.name + || [...this.blocks.entries()].some(([otherIndex, other]) => otherIndex !== index && other.id === final.id) + || !final.arguments || typeof final.arguments !== "object" || Array.isArray(final.arguments) || Buffer.byteLength(JSON.stringify(final.arguments)) > 1_048_576) return this.fail(); + block!.id = final.id; block!.name = final.name; block!.final = toolSignature(final.id, final.name, final.arguments); toolCall = final; + } else toolCall = { type: "toolCall", id: block!.id, name: block!.name, partialArgs: block!.json }; + } + if (phase === "end") { + if (kind !== "toolcall" && (typeof update.content !== "string" || update.content !== block!.json)) return this.fail(); + block!.ended = true; + } + return { ...event, message: { ...this.active, content: [] }, assistantMessageEvent: { ...update, ...(toolCall ? { toolCall } : {}) } }; + } +} + +function toolSignature(id: unknown, name: unknown, args: unknown): string { + const canonical = (value: unknown): unknown => Array.isArray(value) ? value.map(canonical) + : value && typeof value === "object" ? Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b)).map(([key, item]) => [key, canonical(item)])) : value; + return JSON.stringify([id, name, canonical(args)]); +} + +/** A closed diagnostic vocabulary: raw exception text, paths, provider bodies + * and credentials never cross the wrapper boundary. Unknown failures stay + * generic rather than masquerading as a known local or provider condition. */ +export function piNativeFailure(value: unknown): { reason: string; summary: string } { + const message = value instanceof Error ? value.message : typeof value === "string" ? value : ""; + const known: Record = { + "Pi RPC message delta boundary is invalid": "rpc_delta_boundary_invalid", + "Pi native assistant message boundary is invalid": "assistant_boundary_invalid", + "Pi native tool invocation identity conflict": "tool_identity_conflict", + "Pi model iteration identity is ambiguous": "model_iteration_ambiguous", + "Pi model iteration identity is unavailable": "model_iteration_unavailable", + "Pi tool invocation has no active model iteration": "tool_iteration_missing", + "Pi tool identity is outside its iteration bound": "tool_iteration_bound", + "Pi RPC frame exceeds its bound": "rpc_frame_oversized", + "Pi RPC stream ended inside a frame": "rpc_frame_incomplete", + "Pi usage receipt is invalid": "usage_receipt_invalid", + "Pi process exited before its next request": "provider_process_exited", + }; + if (Object.hasOwn(known, message)) return { reason: known[message]!, summary: message }; + const status = /^(?:pi prompt failed: )?(401|403|429|500|502|503|504)(?::|\b)/.exec(message.slice(0, 128))?.[1]; + if (status) return { reason: `provider_http_${status}`, summary: `Pi provider request failed (HTTP ${status})` }; + return { reason: "unknown_native_failure", summary: "Pi native runtime failed; diagnostic details were withheld" }; +} + /** IDs are allocated only at actual SDK assistant message_start events. Tool * iterations, retries, notices and ACP prompts cannot create assistant IDs. */ export class PiAssistantMessages { @@ -170,7 +275,8 @@ export class PiToolIdentities { const block = record(value); // Empty IDs during initial streaming do not identify an executable call. return (direct || block.type === "toolCall") && typeof block.id === "string" && block.id - ? { ...block, id: this.identity(block.id) } : value; + ? { ...block, id: direct && update.type === "toolcall_end" + ? this.bind(block.id, text(block.name, 256), block.arguments) : this.identity(block.id) } : value; }; const partial = update.partial && typeof update.partial === "object" ? record(update.partial) : undefined; return { ...event, assistantMessageEvent: { ...update, diff --git a/packages/paperclip-runner/src/drivers/acpx/pi-closure-pins.ts b/packages/paperclip-runner/src/drivers/acpx/pi-closure-pins.ts index 4b14229c0d3..6c67b250bc1 100644 --- a/packages/paperclip-runner/src/drivers/acpx/pi-closure-pins.ts +++ b/packages/paperclip-runner/src/drivers/acpx/pi-closure-pins.ts @@ -7,7 +7,7 @@ * three pins. Never accept a digest supplied only by an installed manifest. */ export const PI_DISTRIBUTION_CLOSURE_SHA256 = Object.freeze({ - "darwin-arm64": "a3e43c23f9603983a59fd256ca1dcf8bbd5cc2728dad095f7bcd3b3e62278597", - "darwin-x64": "8c6e7802d47da9410af56e367f6cf7af572fcbf3e361627cee026e8cffc9bdd3", - "linux-x64": "40e186c53db60d1ab5673ba34f33002e93cea518725a93395d241393b984caef", + "darwin-arm64": "e17be4d27c589b8f8b5de7d00686c39873fe6f902a3f133bdaf1a9f94dee00a2", + "darwin-x64": "03351f4a250a8db0e79411a9079b43a0ff05f72a2aff41fae17f1fc2de24bd41", + "linux-x64": "29dfc829700c57392f1d56373078dcfc80674cbd50f65674ec64fced81dffb68", }); diff --git a/packages/paperclip-runner/src/drivers/acpx/pi-extension-adapter.ts b/packages/paperclip-runner/src/drivers/acpx/pi-extension-adapter.ts index f657dcfb191..b2f320937a2 100644 --- a/packages/paperclip-runner/src/drivers/acpx/pi-extension-adapter.ts +++ b/packages/paperclip-runner/src/drivers/acpx/pi-extension-adapter.ts @@ -3,7 +3,7 @@ import { redactPaperclipSemanticValue } from "../../semantic-tools/redaction.js" import type { AcpxProfileExtensionAdapter, AcpxProfileExtensionContext } from "./profile-extensions.js"; export const PI_NOTICE_METHOD = "paperclip/pi_notice"; -const CATEGORIES = new Set(["startup", "extension_notify", "invalid_question", "auto_retry_start", "auto_retry_end", "compaction_start", "compaction_end", "summarization_retry_scheduled", "session_stats", "auto_compaction_policy"]); +const CATEGORIES = new Set(["startup", "extension_notify", "invalid_question", "auto_retry_start", "auto_retry_end", "compaction_start", "compaction_end", "summarization_retry_scheduled", "session_stats", "auto_compaction_policy", "runtime_failure"]); const safeText = (value: string, limit = 4000) => String(redactPaperclipSemanticValue(value)).slice(0, limit); /** Notices are display-only session evidence. They carry neither assistant diff --git a/packages/paperclip-runner/src/drivers/acpx/pi-installation.test.ts b/packages/paperclip-runner/src/drivers/acpx/pi-installation.test.ts index df339295c2d..24cbe527f80 100644 --- a/packages/paperclip-runner/src/drivers/acpx/pi-installation.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/pi-installation.test.ts @@ -13,7 +13,7 @@ import { QUALIFIED_ACPX_PROFILES, type QualifiedAcpxProfile } from "./qualified- const roots: string[] = []; afterEach(async () => { vi.unstubAllEnvs(); await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))); }); -const candidate = (): QualifiedAcpxProfile => ({ ...QUALIFIED_ACPX_PROFILES.pi, agentProfileVersion: 11 }); +const candidate = (): QualifiedAcpxProfile => ({ ...QUALIFIED_ACPX_PROFILES.pi, agentProfileVersion: 12 }); async function fixture() { const root = await mkdtemp(join(tmpdir(), "pi-installation-test-")); roots.push(root); @@ -30,7 +30,7 @@ describe("Pi installation factory", () => { const hash = (bytes: string | Uint8Array) => createHash("sha256").update(bytes).digest("hex"); const canonical = (value: any): string => Array.isArray(value) ? `[${value.map(canonical).join(",")}]` : value && typeof value === "object" ? `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${canonical(value[key])}`).join(",")}}` : JSON.stringify(value); - const identity = JSON.parse(await readFile(new URL("../../../test-fixtures/pi-acp/profile-v11-identity.json", import.meta.url), "utf8")); + const identity = JSON.parse(await readFile(new URL("../../../test-fixtures/pi-acp/profile-v12-identity.json", import.meta.url), "utf8")); const declaration = identity.declaration; expect(declaration.sharedRuntimeContract).toBe("paperclip.acpx-runtime-contract.v1"); expect(identity.commandDigest).toBe(`sha256:${hash(canonical(declaration))}`); @@ -50,23 +50,24 @@ describe("Pi installation factory", () => { const materializer = await readFile(new URL("../../../scripts/materialize-pi-distribution.mjs", import.meta.url), "utf8"); expect(materializer).toContain(`wrapperSha256: "${declaration.wrapperSha256}"`); expect(materializer).toContain(`helperSha256: "${declaration.helperSha256}"`); - // This exact patch produced the independently verified v8 wrapper/closure. + // This exact patch produced the reviewed current wrapper/closure. // Pin it separately so wrapper-only edits cannot keep an unchanged declaration. - expect(hash(await readFile(new URL("../../../../../patches/pi-acp@0.0.33.patch", import.meta.url)))).toBe("429184a462f9acef5bef890f3537db3ef385ee7c06d9fe0c335158590005ab0a"); + expect(hash(await readFile(new URL("../../../../../patches/pi-acp@0.0.33.patch", import.meta.url)))).toBe("93254de82ae779587611748cfb963475c3439e033f1582f06dfdcc7f7b937546"); }); it("rejects legacy profiles and caller-selected identities", () => { expect(() => assertPiInstallationProfile(candidate())).not.toThrow(); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 1 })).toThrow("version 11"); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 2 })).toThrow("version 11"); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 3 })).toThrow("version 11"); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 4 })).toThrow("version 11"); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 5 })).toThrow("version 11"); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 6 })).toThrow("version 11"); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 7 })).toThrow("version 11"); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 8 })).toThrow("version 11"); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 9 })).toThrow("version 11"); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 10 })).toThrow("version 11"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 1 })).toThrow("version 12"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 2 })).toThrow("version 12"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 3 })).toThrow("version 12"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 4 })).toThrow("version 12"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 5 })).toThrow("version 12"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 6 })).toThrow("version 12"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 7 })).toThrow("version 12"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 8 })).toThrow("version 12"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 9 })).toThrow("version 12"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 10 })).toThrow("version 12"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 11 })).toThrow("version 12"); for (const changed of [{ agentServerVersion: "latest" }, { commandDigest: `sha256:${"0".repeat(64)}` }, { reportedModelId: "different" }, { agentRuntimePackage: "ambient-pi" }]) { expect(() => assertPiInstallationProfile({ ...candidate(), ...changed })).toThrow("trusted declaration"); } diff --git a/packages/paperclip-runner/src/drivers/acpx/pi-installation.ts b/packages/paperclip-runner/src/drivers/acpx/pi-installation.ts index 654ddfa4763..2588b7d9d26 100644 --- a/packages/paperclip-runner/src/drivers/acpx/pi-installation.ts +++ b/packages/paperclip-runner/src/drivers/acpx/pi-installation.ts @@ -25,7 +25,7 @@ function record(value: unknown): Record { /** Candidate identity is build-owned; model selection cannot substitute a CLI. */ export function assertPiInstallationProfile(profile: QualifiedAcpxProfile): void { const trusted = QUALIFIED_ACPX_PROFILES.pi; - if (profile.agentProfileVersion !== 11) throw new Error("Pi rich ACP requires profile version 11; reopen the previous session"); + if (profile.agentProfileVersion !== 12) throw new Error("Pi rich ACP requires profile version 12; reopen the previous session"); if (profile.agent !== "pi" || profile.driverKind !== trusted.driverKind || profile.protocolVersion !== trusted.protocolVersion || profile.acpxVersion !== trusted.acpxVersion || profile.agentServerPackage !== "pi-acp" || profile.agentServerVersion !== "0.0.33" || profile.agentRuntimePackage !== "@earendil-works/pi-coding-agent" || profile.agentRuntimeVersion !== "1.0.0" || profile.commandDigest !== trusted.commandDigest || profile.permissionPolicy !== "interactive" || profile.qualificationModel !== PI_MODEL || profile.reportedModelId !== PI_MODEL) throw new Error("Pi distribution profile differs from its trusted declaration"); } diff --git a/packages/paperclip-runner/src/drivers/acpx/pi-rpc-deltas.test.ts b/packages/paperclip-runner/src/drivers/acpx/pi-rpc-deltas.test.ts new file mode 100644 index 00000000000..08539b6abd7 --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/pi-rpc-deltas.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it } from "vitest"; +import { PiAssistantMessages, PiRpcMessageDeltas, PiToolIdentities, piNativeFailure } from "./pi-acp-runtime.js"; + +// Pi 1 modes/json-event.js strips message and partial from message_update. +// The real SDK/package contract separately executes that actual serializer. +const start = (timestamp = 1) => ({ type: "message_start", message: { role: "assistant", timestamp, content: [], stopReason: "pending" } }); +const end = (stopReason = "stop", timestamp = 1) => ({ type: "message_end", message: { role: "assistant", timestamp, content: [], stopReason } }); +const delta = (type: string, contentIndex = 0, extra = {}) => ({ type: "message_update", usage: {}, assistantMessageEvent: { type, contentIndex, ...extra } }); +function fixture() { + const wire = new PiRpcMessageDeltas(); const messages = new PiAssistantMessages("00000000-0000-4000-8000-000000000001"); const tools = new PiToolIdentities("00000000-0000-4000-8000-000000000001"); + return (event: Record) => tools.normalize(messages.normalize(wire.normalize(event))); +} + +describe("pinned Pi 1 serialized message deltas", () => { + it.each(["text", "thinking"])("binds %s deltas to the native occurrence without synthetic final content", kind => { + const accept = fixture(); accept({ type: "turn_start" }); const first = accept(start()); + accept(delta(`${kind}_start`)); + const streamed = accept(delta(`${kind}_delta`, 0, { delta: "native text" })); + expect(streamed.paperclipAssistantMessage).toMatchObject({ messageId: (first.paperclipAssistantMessage as any).messageId, phase: "delta" }); + expect(streamed.assistantMessageEvent).toMatchObject({ delta: "native text" }); + accept(delta(`${kind}_end`, 0, { content: "native text" })); + expect(accept({ ...end(), message: { ...end().message, content: [{ type: kind, [kind === "text" ? "text" : "thinking"]: "native text" }] } }).paperclipAssistantMessage).toMatchObject({ phase: "end", stopReason: "stop" }); + accept({ type: "turn_end" }); accept({ type: "agent_settled" }); + }); + it.each(["text", "thinking"])("rejects a mismatched %s end and remains poisoned", kind => { + const wire = new PiRpcMessageDeltas(); wire.normalize(start()); wire.normalize(delta(`${kind}_start`)); + wire.normalize(delta(`${kind}_delta`, 0, { delta: "observed" })); + expect(() => wire.normalize(delta(`${kind}_end`, 0, { content: "different" }))).toThrow("boundary"); + expect(() => wire.normalize(start(2))).toThrow("boundary"); + }); + for (const kind of ["text", "thinking"]) for (const reason of ["stop", "error", "aborted"]) { + it.each(["missing", "wrong-type", "different-content"])(`rejects %s final ${kind} content after a valid end (${reason})`, change => { + const wire = new PiRpcMessageDeltas(); wire.normalize(start()); wire.normalize(delta(`${kind}_start`)); + wire.normalize(delta(`${kind}_delta`, 0, { delta: "observed" })); wire.normalize(delta(`${kind}_end`, 0, { content: "observed" })); + const block = { type: change === "wrong-type" ? "toolCall" : kind, [kind === "text" ? "text" : "thinking"]: change === "different-content" ? "different" : "observed" }; + expect(() => wire.normalize({ ...end(reason), message: { ...end(reason).message, content: change === "missing" ? [] : [block] } })).toThrow("boundary"); + expect(() => wire.normalize(start(2))).toThrow("boundary"); + }); + } + it("bounds aggregate streaming across blocks at exactly 4 MiB", () => { + const wire = new PiRpcMessageDeltas(); wire.normalize(start()); + wire.normalize(delta("text_start", 0)); wire.normalize(delta("thinking_start", 1)); + const chunk = "x".repeat(262_144); + for (let index = 0; index < 16; index++) expect(() => wire.normalize(delta(index % 2 ? "thinking_delta" : "text_delta", index % 2, { delta: chunk }))).not.toThrow(); + expect(() => wire.normalize(delta("text_delta", 0, { delta: "x" }))).toThrow("boundary"); + expect(() => wire.normalize(start(2))).toThrow("boundary"); + }); + it.each(["stop", "error", "aborted"])("retains a native final-only %s receipt without synthesizing streamed blocks", reason => { + // Pi 1 agent-loop may emit start(finalMessage), end(finalMessage) when a + // provider supplies a terminal result without a stream start. Unobserved + // final blocks are native receipt content, not attested streamed deltas. + const accept = fixture(); accept({ type: "turn_start" }); + const message = { ...end(reason).message, content: [{ type: "text", text: "native terminal body" }] }; + expect(accept({ type: "message_start", message }).assistantMessageEvent).toBeUndefined(); + const terminal = accept({ type: "message_end", message }); + expect(terminal.message).toEqual(message); expect(terminal.assistantMessageEvent).toBeUndefined(); + expect(terminal.paperclipAssistantMessage).toMatchObject({ phase: "end", stopReason: reason }); + accept({ type: "turn_end" }); accept({ type: "agent_settled" }); + }); + it("correlates interleaved tool indices and preserves final arguments and native IDs", () => { + const accept = fixture(); accept({ type: "turn_start" }); accept(start()); + const ids = [0, 1].map(index => (accept(delta("toolcall_start", index, { id: `call_${index}`, toolName: `tool_${index}` })).assistantMessageEvent as any).toolCall.id); + for (const index of [1, 0]) { + expect((accept(delta("toolcall_delta", index, { delta: '{"value":' })).assistantMessageEvent as any).toolCall).toMatchObject({ id: ids[index], partialArgs: '{"value":' }); + accept(delta("toolcall_delta", index, { delta: `${index}}` })); + const final = accept(delta("toolcall_end", index, { toolCall: { type: "toolCall", id: `call_${index}`, name: `tool_${index}`, arguments: { value: index } } })); + expect((final.assistantMessageEvent as any).toolCall).toMatchObject({ id: ids[index], arguments: { value: index } }); + } + accept({ ...end("toolUse"), message: { ...end("toolUse").message, content: [0, 1].map(index => ({ type: "toolCall", id: `call_${index}`, name: `tool_${index}`, arguments: { value: index } })) } }); + for (const index of [0, 1]) expect(accept({ type: "tool_execution_start", toolCallId: `call_${index}`, toolName: `tool_${index}`, args: { value: index } }).toolCallId).toBe(ids[index]); + }); + it("defers an initially absent tool identity until its native final receipt", () => { + const accept = fixture(); accept({ type: "turn_start" }); accept(start()); + expect((accept(delta("toolcall_start", 0, { id: "", toolName: "" })).assistantMessageEvent as any).toolCall.id).toBe(""); + accept(delta("toolcall_delta", 0, { delta: "{}" })); + expect((accept(delta("toolcall_end", 0, { toolCall: { type: "toolCall", id: "final-id", name: "tool", arguments: {} } })).assistantMessageEvent as any).toolCall.id).toMatch(/^pi-/); + }); + it.each(["missing", "foreign-id", "foreign-name", "foreign-arguments"])("rejects %s in the final native tool receipt", change => { + const accept = fixture(); accept({ type: "turn_start" }); accept(start()); + const call = { type: "toolCall", id: "a", name: "tool", arguments: { value: 1 } }; + accept(delta("toolcall_start", 0, { id: "a", toolName: "tool" })); accept(delta("toolcall_end", 0, { toolCall: call })); + const changed = { ...call, ...(change === "foreign-id" ? { id: "b" } : change === "foreign-name" ? { name: "other" } : change === "foreign-arguments" ? { arguments: { value: 2 } } : {}) }; + expect(() => accept({ ...end("toolUse"), message: { ...end("toolUse").message, content: change === "missing" ? [] : [changed] } })).toThrow("boundary"); + }); + it("rejects actual execution arguments that disagree with the completed native stream", () => { + const accept = fixture(); accept({ type: "turn_start" }); accept(start()); + const call = { type: "toolCall", id: "a", name: "tool", arguments: { value: 1 } }; + accept(delta("toolcall_start", 0, { id: "a", toolName: "tool" })); accept(delta("toolcall_end", 0, { toolCall: call })); + accept({ ...end("toolUse"), message: { ...end("toolUse").message, content: [call] } }); + expect(() => accept({ type: "tool_execution_start", toolCallId: "a", toolName: "tool", args: { value: 2 } })).toThrow("identity conflict"); + }); + it.each(["error", "aborted"])("closes incomplete stream blocks on authoritative %s, then admits a fresh occurrence", reason => { + const accept = fixture(); accept({ type: "turn_start" }); accept(start()); accept(delta("text_start")); accept(end(reason)); accept({ type: "turn_end" }); accept({ type: "agent_settled" }); + accept({ type: "turn_start" }); expect(accept(start(2)).paperclipAssistantMessage).toMatchObject({ phase: "start" }); + }); + it.each([ + [delta("text_delta", 0, { delta: "orphan" })], + [start(), delta("text_delta", 0, { delta: "no block" })], + [start(), delta("text_start"), delta("text_start")], + [start(), delta("text_start"), delta("thinking_delta", 0, { delta: "wrong kind" })], + [start(), delta("text_start"), end()], + [start(), end(), delta("text_start")], + [start(), start()], + [start(), delta("text_start", -1)], + [start(), delta("text_start", 4096)], + [start(), delta("text_start", 0.5)], + [start(), delta("text_start"), delta("text_end", 0, { content: "" }), delta("text_delta", 0, { delta: "stale" })], + [start(), { ...delta("text_start"), message: start().message }], + [start(), delta("text_start", 0, { partial: {} })], + [start(), delta("toolcall_start", 0, { id: "a", toolName: "tool" }), delta("toolcall_start", 1, { id: "a", toolName: "tool" })], + [start(), delta("toolcall_start", 0, { id: "a", toolName: "tool" }), delta("toolcall_end", 0, { toolCall: { type: "toolCall", id: "b", name: "tool", arguments: {} } })], + [start(), delta("toolcall_start", 0, { id: "a", toolName: "tool" }), delta("toolcall_end", 0, { toolCall: { type: "toolCall", id: "a", name: "other", arguments: {} } })], + [start(), delta("text_start"), delta("text_delta", 0, { delta: "x".repeat(262_145) })], + ])("rejects malformed, duplicate or stale wire sequences %# and remains poisoned", (...events) => { + const wire = new PiRpcMessageDeltas(); + expect(() => { for (const event of events) wire.normalize(event as any); }).toThrow("boundary"); + expect(() => wire.normalize(start(2))).toThrow("boundary"); + }); +}); + +describe("Pi diagnostic privacy", () => { + it("retains exact known reasons while withholding arbitrary native content", () => { + expect(piNativeFailure(new Error("Pi RPC message delta boundary is invalid"))).toMatchObject({ reason: "rpc_delta_boundary_invalid" }); + for (const sensitive of ["Bearer secret-canary", "/private/secret-canary", "https://example.invalid/?key=secret-canary"]) { + expect(piNativeFailure(new Error(`401: ${sensitive}`))).toEqual({ reason: "provider_http_401", summary: "Pi provider request failed (HTTP 401)" }); + expect(JSON.stringify(piNativeFailure(new Error(sensitive)))).not.toContain("secret-canary"); + expect(piNativeFailure(new Error(sensitive)).reason).toBe("unknown_native_failure"); + } + expect(piNativeFailure(new Error("Pi RPC message delta boundary is invalid Bearer secret-canary")).reason).toBe("unknown_native_failure"); + }); +}); diff --git a/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.ts b/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.ts index 98e7dc02f92..3583b2fffc6 100644 --- a/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.ts +++ b/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.ts @@ -47,14 +47,14 @@ export const QUALIFIED_ACPX_PROFILES: Readonly< protocolVersion: ACPX_DRIVER_PROTOCOL_VERSION, acpxVersion: QUALIFIED_ACPX_VERSION, agent: "pi", - agentProfileVersion: 11, + agentProfileVersion: 12, qualificationStatus: "pending", agentServerPackage: "pi-acp", agentServerVersion: "0.0.33", agentRuntimePackage: "@earendil-works/pi-coding-agent", agentRuntimeVersion: "1.0.0", commandDigest: - "sha256:5e276f48c8a87b3e6165369faac62d3925282c84b98934575b1b7b97ad50b309", + "sha256:47306e6d2a9b59e8f9189f725ebb7a0a7f91826044d1739e1a35ab31f228ba1f", qualificationModel: "openrouter/deepseek/deepseek-v4-flash-0731", reportedModelId: "openrouter/deepseek/deepseek-v4-flash-0731", permissionPolicy: "interactive", diff --git a/packages/paperclip-runner/test-fixtures/pi-acp/fake-pi.mjs b/packages/paperclip-runner/test-fixtures/pi-acp/fake-pi.mjs index b845b4d417e..eea087d93f6 100644 --- a/packages/paperclip-runner/test-fixtures/pi-acp/fake-pi.mjs +++ b/packages/paperclip-runner/test-fixtures/pi-acp/fake-pi.mjs @@ -10,7 +10,41 @@ writeFileSync(sessionFile, JSON.stringify({ type: "session", id: "fixture-sessio let assistantActive = false; let messageTimestamp = 0; const nativeMessage = (stopReason = "stop", usage = {}) => ({ role: "assistant", timestamp: messageTimestamp, content: [], stopReason, usage }); -const output = (value) => process.stdout.write(JSON.stringify(value.type === "message_update" && !value.message ? { ...value, message: nativeMessage() } : value) + "\n"); +// Protocol fixture emits Pi 1's serialized delta form. Actual SDK serialization +// and the owned extension are exercised separately by the local-only package test. +const streamBlocks = new Map(); +const emit = value => process.stdout.write(JSON.stringify(value) + "\n"); +const output = value => { + if (value.type === "message_start" && value.message?.role === "assistant") streamBlocks.clear(); + if (value.type === "message_update") { + const original = value.assistantMessageEvent; + const kind = original.type.split("_")[0]; + const index = original.contentIndex ?? (kind === "thinking" ? 1 : 0); + if (original.type === "toolcall_start") { + const call = original.toolCall ?? original.partial?.content?.[index]; + streamBlocks.set(index, { kind, call }); + emit({ type: "message_update", usage: {}, assistantMessageEvent: { type: "toolcall_start", contentIndex: index, id: call.id, toolName: call.name } }); + return; + } + if (!streamBlocks.has(index)) { + streamBlocks.set(index, { kind, text: "" }); + emit({ type: "message_update", usage: {}, assistantMessageEvent: { type: `${kind}_start`, contentIndex: index } }); + } + if (typeof original.delta === "string") streamBlocks.get(index).text += original.delta; + const { partial: _partial, ...delta } = original; + emit({ type: "message_update", usage: {}, assistantMessageEvent: { ...delta, contentIndex: index } }); return; + } + if (value.type === "message_end" && value.message?.role === "assistant") { + const content = []; + for (const [index, block] of streamBlocks) { + content[index] = block.call ?? { type: block.kind, [block.kind === "text" ? "text" : "thinking"]: block.text }; + emit({ type: "message_update", usage: {}, assistantMessageEvent: { type: `${block.kind}_end`, contentIndex: index, ...(block.call ? { toolCall: block.call } : { content: block.text }) } }); + } + value = { ...value, message: { ...value.message, content } }; + streamBlocks.clear(); + } + emit(value); +}; const endMessage = (stopReason = "stop", usage = { input: 11, output: 3, cacheRead: 2, cacheWrite: 0, cost: { total: 0.01 } }) => { output({ type: "message_end", message: nativeMessage(stopReason, usage) }); assistantActive = false; }; diff --git a/packages/paperclip-runner/test-fixtures/pi-acp/profile-v12-identity.json b/packages/paperclip-runner/test-fixtures/pi-acp/profile-v12-identity.json new file mode 100644 index 00000000000..784e8816b78 --- /dev/null +++ b/packages/paperclip-runner/test-fixtures/pi-acp/profile-v12-identity.json @@ -0,0 +1,32 @@ +{ + "commandDigest": "sha256:47306e6d2a9b59e8f9189f725ebb7a0a7f91826044d1739e1a35ab31f228ba1f", + "declaration": { + "schema": "paperclip.acpx_profile_declaration.v1", + "agent": "pi", + "agentProfileVersion": 12, + "acpxVersion": "0.13.1", + "agentServerVersion": "0.0.33", + "agentRuntimeVersion": "1.0.0", + "nodeVersion": "24.21.0", + "closure": { + "darwin-arm64": "e17be4d27c589b8f8b5de7d00686c39873fe6f902a3f133bdaf1a9f94dee00a2", + "darwin-x64": "03351f4a250a8db0e79411a9079b43a0ff05f72a2aff41fae17f1fc2de24bd41", + "linux-x64": "29dfc829700c57392f1d56373078dcfc80674cbd50f65674ec64fced81dffb68" + }, + "wrapperSha256": "c41750802680543d72a5a43e21eaf91c31ca8cec833bbf5f27330614936810fe", + "helperSha256": "2191b1e5f281d24508ec7eaff39011ee863a8a70bf54f4aa0490ad0172b323da", + "extensionSha256": "8f9b538dbe924a314099f9597ee3876f7ccd88211c4f4b2e52f46b6c7ffa76ba", + "agentFilesBinding": "registered-runtime-context-v1", + "nativeQuestions": "select-confirm-input-editor-v2", + "pathPolicy": "sdk-normalization-v1", + "mcpToolNames": "provider-safe-exact-source-v1", + "acpxPatchSha256": "bd5393058a218040d217fa85449d59a6f30507de54cd645bf0ef21422823f85e", + "nativeAssistantMessages": "pi1-rpc-deltas-v3-bound-message-and-tool-receipts", + "nativeNotices": "session-extension-v2-bounded-runtime-failure", + "messageProjectionSha256": "17d1e91b948dfa6a56e1905645add52b502041ad843c8d50a13b07956f22aadd", + "noticeProjectionSha256": "014485adc690998e395d334ea3ae67f72be6060a2fc78a1185df7b8d1da28fbf", + "sharedRuntimeContract": "paperclip.acpx-runtime-contract.v1", + "nativeInputDisposition": "queued-only-v1", + "cacheWarming": "disabled-owned-decision-v1" + } +} diff --git a/packages/paperclip-runner/test/pi-acp-package-contract.test.mjs b/packages/paperclip-runner/test/pi-acp-package-contract.test.mjs index d3921f36224..4c52910c77c 100644 --- a/packages/paperclip-runner/test/pi-acp-package-contract.test.mjs +++ b/packages/paperclip-runner/test/pi-acp-package-contract.test.mjs @@ -113,6 +113,10 @@ for (const scenario of ["narration-final", "tool-only", "empty-final"]) test(`ac for (const scenario of ["missing-message-start", "duplicate-message-start", "mismatched-message-end", "missing-message-end"]) test(`actual wrapper rejects malformed native boundaries: ${scenario}`, async t => { const f = await fixture(t); const session = await f.call("session/new", { cwd: join(f.root, "workspace"), mcpServers: [] }); await assert.rejects(f.call("session/prompt", { sessionId: session.sessionId, prompt: [{ type: "text", text: scenario }] }), /exited|Internal error/); + const failure = f.notifications.find(event => event.method === "paperclip/pi_notice" && event.params.category === "runtime_failure"); + assert.ok(failure, "native failure notice is flushed before prompt rejection"); + assert.ok(["rpc_delta_boundary_invalid", "assistant_boundary_invalid"].includes(failure.params.details.reason)); + assert.equal(failure.params.severity, "error"); }); test("actual patched ACP process scopes recycled native IDs across iterations and warm prompts", async (t) => { diff --git a/packages/paperclip-runner/test/pi-native-package-contract.test.mjs b/packages/paperclip-runner/test/pi-native-package-contract.test.mjs index 5b813116f1d..d85f49984d5 100644 --- a/packages/paperclip-runner/test/pi-native-package-contract.test.mjs +++ b/packages/paperclip-runner/test/pi-native-package-contract.test.mjs @@ -10,7 +10,8 @@ import { StringDecoder } from "node:string_decoder"; import { fileURLToPath, pathToFileURL } from "node:url"; import test from "node:test"; -// This test runs real pinned Pi RPC without issuing a prompt or loading keys. +// Tests run real pinned Pi RPC without live provider credentials. The final +// stream regressions use synthetic loopback responses and a socket deny guard. const packageRoot = process.env.PAPERCLIP_TEST_PI_RUNTIME_ROOT ?? dirname(dirname(fileURLToPath(import.meta.resolve("@earendil-works/pi-coding-agent")))); const metadata = JSON.parse(await readFile(join(packageRoot, "package.json"), "utf8")); @@ -354,3 +355,163 @@ test("real pinned Pi tool dispatch admits registered agent files and rejects una assert.equal(dialogs.length, 2); assert.ok(dialogs.every(title => title.startsWith("paperclip.pi.permission.v1:"))); } finally { session?.dispose(); await rm(root, { recursive: true, force: true }); } }); + +// This regression crosses Pi 1's real RPC serializer, the owned extension and +// the patched ACP wrapper. Only an owned loopback HTTP fixture can be reached; +// dummy authentication is unrelated to any provider account. +for (const scenario of ["hello", "interleaved-tools", "provider-error", "provider-unknown"]) { + test(`real Pi 1 serialized RPC ${scenario} through owned ACP/MCP bridge`, { timeout: 30_000 }, async t => { + const root = await realpath(await mkdtemp(join(tmpdir(), "paperclip-pi-stream-"))); + let child; let server; + t.after(async () => { + if (child) { + child.stdin.end(); + if (child.exitCode === null) { const timer = setTimeout(() => child.kill("SIGTERM"), 3000); await once(child, "exit"); clearTimeout(timer); } + } + if (server?.listening) { server.closeAllConnections(); await new Promise(resolve => server.close(resolve)); } + try { + if (child) assert.equal(JSON.parse(await readFile(join(root, "owned-retirement.json"), "utf8")).allOwnedChildrenClosed, true); + } finally { await rm(root, { recursive: true, force: true }); } + }); + await mkdir(join(root, "workspace")); await mkdir(join(root, "agent")); + const wrapperRoot = process.env.PAPERCLIP_TEST_PI_ACP_PACKAGE + ? dirname(process.env.PAPERCLIP_TEST_PI_ACP_PACKAGE) + : join(dirname(dirname(packageRoot)), "pi-acp"); + const calls = []; const modelRequests = []; + const tools = ["paperclip_get_context", "paperclip_finish"].map(name => ({ name, description: name, inputSchema: { type: "object", properties: {}, additionalProperties: true } })); + server = createServer(async (request, response) => { + let body = ""; for await (const chunk of request) { body += chunk; assert.ok(body.length < 1_048_576); } + const value = JSON.parse(body); + if (request.url === "/v1/chat/completions") { + modelRequests.push(value.model); assert.ok(modelRequests.length <= 3); + if (scenario.startsWith("provider-")) { + response.writeHead(scenario === "provider-error" ? 401 : 418, { "content-type": "application/json" }); + response.end(JSON.stringify({ error: { message: "Bearer sensitive-canary /private/sensitive-canary", type: "authentication_error" } })); return; + } + const n = modelRequests.length; + const toolTurn = n === 1 || scenario === "hello" && n === 2; + const names = scenario === "interleaved-tools" ? tools.map(tool => tool.name) : [n === 1 ? tools[0].name : tools[1].name]; + const chunks = []; + if (toolTurn) { + chunks.push({ choices: [{ index: 0, delta: { tool_calls: names.map((name, index) => ({ index, id: `call_${n}_${index}`, type: "function", function: { name: `mcp__paperclip__${name}`, arguments: '{"fixture":' } })) } }] }); + // Reverse delivery order proves index correlation, not FIFO guessing. + for (const index of names.map((_, index) => index).reverse()) chunks.push({ choices: [{ index: 0, delta: { tool_calls: [{ index, function: { arguments: `${index}}` } }] } }] }); + } else chunks.push({ choices: [{ index: 0, delta: { reasoning: "Fixture thought", content: "HELLO_COMPLETE" } }] }); + chunks.push({ choices: [{ index: 0, delta: {}, finish_reason: toolTurn ? "tool_calls" : "stop" }], usage: { prompt_tokens: 10, completion_tokens: 3, total_tokens: 13 } }); + response.writeHead(200, { "content-type": "text/event-stream" }); + response.end(chunks.map(chunk => `data: ${JSON.stringify({ id: "offline", ...chunk })}\n\n`).join("") + "data: [DONE]\n\n"); return; + } + assert.equal(request.url, "/mcp"); + calls.push({ method: value.method, name: value.params?.name, args: value.params?.arguments }); + const result = value.method === "initialize" + ? { protocolVersion: "2025-03-26", capabilities: { tools: {} }, serverInfo: { name: "fixture", version: "1" } } + : value.method === "tools/list" ? { tools } + : { content: [{ type: "text", text: JSON.stringify({ accepted: true }) }] }; + response.writeHead(200, { "content-type": "application/json" }); response.end(JSON.stringify({ jsonrpc: "2.0", id: value.id, result })); + }); + await new Promise(resolve => server.listen(0, "127.0.0.1", resolve)); + const port = server.address().port; + await writeFile(join(root, "agent/models.json"), JSON.stringify({ providers: { openrouter: { baseUrl: `http://127.0.0.1:${port}/v1`, apiKey: "offline-fixture-not-a-key" } } })); + const bootstrap = join(root, "bootstrap.mjs"); + await writeFile(bootstrap, ` +import net from "node:net"; +import { writeFileSync } from "node:fs"; +const original = net.Socket.prototype.connect; let admitted = 0; +net.Socket.prototype.connect = function(...args) { + const a = Array.isArray(args[0]) ? args[0] : args; + const options = typeof a[0] === "object" ? a[0] : { port: a[0], host: a[1] }; + if (options.path || options.host !== "127.0.0.1" || Number(options.port) !== ${port}) throw new Error("OFFLINE_NETWORK_DENIED_BEFORE_CONNECT"); + admitted++; return original.apply(this, args); +}; +try { new net.Socket().connect({ host: "203.0.113.1", port: 443 }); throw new Error("guard failed"); } +catch (error) { if (error.message !== "OFFLINE_NETWORK_DENIED_BEFORE_CONNECT" || admitted !== 0) throw error; } +writeFileSync(${JSON.stringify(join(root, "network-denial.json"))}, JSON.stringify({ deniedBeforeConnect: true, underlyingConnections: admitted })); +await import(${JSON.stringify(pathToFileURL(join(packageRoot, "dist/cli.js")).href)}); +`); + const extension = join(root, "extension.mjs"); + await writeFile(extension, stripTypeScriptTypes(extensionSource)); + await writeFile(join(root, "pi-acp-runtime.js"), stripTypeScriptTypes(await readFile(new URL("../src/drivers/acpx/pi-acp-runtime.ts", import.meta.url), "utf8"))); + // The fixture owns each real Pi ChildProcess handle even if the wrapper + // elects to exit early. No numeric PID/group selector is used for cleanup. + const owner = join(root, "wrapper-owner.mjs"); + await writeFile(owner, ` +import childProcess from "node:child_process"; +import { syncBuiltinESMExports } from "node:module"; +import { writeFileSync } from "node:fs"; +const spawn = childProcess.spawn; const children = []; +childProcess.spawn = (...args) => { + const child = spawn(...args); const owned = { child, closed: false, spawnError: false, completion: null }; + child.on("error", () => { owned.spawnError = true; }); + owned.completion = new Promise(resolve => child.once("close", () => { owned.closed = true; resolve(); })); + children.push(owned); return child; +}; +syncBuiltinESMExports(); +const exit = process.exit.bind(process); let retiring; +process.exit = code => { + retiring ??= (async () => { + const retirements = await Promise.allSettled(children.map(async owned => { + const child = owned.child; + if (child.exitCode === null && child.signalCode === null) child.kill("SIGTERM"); + const timer = setTimeout(() => { if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL"); }, 1500); + try { await owned.completion; } finally { clearTimeout(timer); } + })); + writeFileSync(${JSON.stringify(join(root, "owned-retirement.json"))}, JSON.stringify({ allOwnedChildrenClosed: children.every(owned => owned.closed) && retirements.every(result => result.status === "fulfilled"), spawnErrors: children.filter(owned => owned.spawnError).length, count: children.length })); + })().finally(() => exit(code)); +}; +process.on("SIGTERM", () => process.exit(143)); process.on("SIGINT", () => process.exit(130)); +await import(${JSON.stringify(pathToFileURL(join(wrapperRoot, "dist/index.js")).href)}); +`); + child = spawn(process.execPath, [owner], { + cwd: join(root, "workspace"), stdio: "pipe", env: { + PATH: "/usr/bin:/bin", HOME: root, PI_CODING_AGENT_DIR: join(root, "agent"), PI_SKIP_VERSION_CHECK: "1", PI_TELEMETRY: "0", + PAPERCLIP_ACPX_ISOLATED_CONTEXT: "1", PAPERCLIP_PI_READ_ONLY: "0", + PAPERCLIP_PI_NODE_EXECUTABLE: await realpath(process.execPath), PAPERCLIP_PI_ENTRYPOINT: bootstrap, PAPERCLIP_PI_EXTENSION_PATH: extension, + }, + }); + let buffered = ""; let stderr = ""; let sequence = 0; const pending = new Map(); const notifications = []; + child.stderr.on("data", chunk => { stderr += chunk; }); + const decoder = new StringDecoder("utf8"); + child.stdout.on("data", chunk => { + buffered += decoder.write(chunk); + for (;;) { + const index = buffered.indexOf("\n"); if (index < 0) break; + const message = JSON.parse(buffered.slice(0, index)); buffered = buffered.slice(index + 1); + const waiter = pending.get(message.id); + if (waiter) { pending.delete(message.id); message.error ? waiter.reject(new Error(JSON.stringify(message.error))) : waiter.resolve(message.result); } + else notifications.push(message); + } + }); + child.once("exit", () => { for (const waiter of pending.values()) waiter.reject(new Error(`wrapper exited: ${stderr}`)); pending.clear(); }); + const call = (method, params) => new Promise((resolve, reject) => { + const id = ++sequence; const timer = setTimeout(() => { pending.delete(id); reject(new Error(`timeout: ${method}`)); }, 15_000); + pending.set(id, { resolve(value) { clearTimeout(timer); resolve(value); }, reject(error) { clearTimeout(timer); reject(error); } }); + child.stdin.write(JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n"); + }); + await call("initialize", { protocolVersion: 1, clientCapabilities: {} }); + const session = await call("session/new", { cwd: join(root, "workspace"), mcpServers: [{ type: "http", name: "paperclip", url: `http://127.0.0.1:${port}/mcp`, headers: [{ name: "Authorization", value: "Bearer 0123456789abcdef0123456789abcdef" }] }] }); + const model = "openrouter/deepseek/deepseek-v4-flash-0731"; + await call("session/set_config_option", { sessionId: session.sessionId, configId: "model", value: model }); + const result = await call("session/prompt", { sessionId: session.sessionId, prompt: [{ type: "text", text: "Call paperclip_get_context, then paperclip_finish. Finally say HELLO_COMPLETE." }] }); + assert.deepEqual(JSON.parse(await readFile(join(root, "network-denial.json"), "utf8")), { deniedBeforeConnect: true, underlyingConnections: 0 }); + assert.ok(modelRequests.every(value => value === "deepseek/deepseek-v4-flash-0731")); + assert.doesNotMatch(JSON.stringify({ result, notifications, stderr }), /sensitive-canary/); + if (scenario.startsWith("provider-")) { + assert.equal(modelRequests.length, 1); assert.equal(calls.filter(call => call.method === "tools/call").length, 0); + assert.equal(result._meta.jetbrains.air.sessionFailure.severity, "error"); + const notice = notifications.find(event => event.method === "paperclip/pi_notice" && event.params.category === "runtime_failure"); + assert.equal(notice?.params.details.reason, scenario === "provider-error" ? "provider_http_401" : "unknown_native_failure"); return; + } + assert.equal(result.stopReason, "end_turn"); assert.equal(result._meta?.jetbrains, undefined); + assert.deepEqual(calls.filter(call => call.method === "tools/call").map(call => call.name), ["paperclip_get_context", "paperclip_finish"]); + assert.equal(result.usage.totalTokens, (scenario === "hello" ? 3 : 2) * 13); + const updates = notifications.map(event => event.params?.update).filter(Boolean); + assert.equal(updates.filter(update => update.content?.text === "HELLO_COMPLETE").length, 1); + assert.ok(updates.some(update => update.sessionUpdate === "agent_thought_chunk" && update.content.text === "Fixture thought")); + const starts = updates.filter(update => update.sessionUpdate === "tool_call"); + assert.equal(starts.length, 2); assert.notEqual(starts[0].toolCallId, starts[1].toolCallId); + for (const start of starts) { + assert.equal(updates.filter(update => update.toolCallId === start.toolCallId && update.status === "completed").length, 1); + assert.ok(updates.some(update => update.toolCallId === start.toolCallId && update.status === "in_progress")); + } + }); +} diff --git a/patches/pi-acp@0.0.33.patch b/patches/pi-acp@0.0.33.patch index c46a410d796..4ee3c3c76fb 100644 --- a/patches/pi-acp@0.0.33.patch +++ b/patches/pi-acp@0.0.33.patch @@ -13,11 +13,11 @@ // src/pi-rpc/process.ts import { spawn } from "child_process"; -import * as readline from "readline"; -+import { PI_ACP_FEATURES, PiAssistantMessages, piAssistantChunk, piHistoricalAssistantChunk, PiToolIdentities, piHistoricalToolIdentity, PiRpcFrames, PiUiBridge, PiTurnUsage, createPiLaunchSpec, snapshotPiWorkspaceFile } from "./paperclip-runtime.js"; ++import { PI_ACP_FEATURES, PiRpcMessageDeltas, piNativeFailure, PiAssistantMessages, piAssistantChunk, piHistoricalAssistantChunk, PiToolIdentities, piHistoricalToolIdentity, PiRpcFrames, PiUiBridge, PiTurnUsage, createPiLaunchSpec, snapshotPiWorkspaceFile } from "./paperclip-runtime.js"; // src/pi-rpc/command.ts import { platform } from "os"; -@@ -94,51 +95,43 @@ +@@ -94,51 +95,44 @@ pending = /* @__PURE__ */ new Map(); eventHandlers = []; preludeLines = []; @@ -25,6 +25,7 @@ + constructor(child, invocationNamespace) { + this.toolIdentities = new PiToolIdentities(invocationNamespace); + this.assistantMessages = new PiAssistantMessages(invocationNamespace); ++ this.rpcMessages = new PiRpcMessageDeltas(); this.child = child; - const rl = readline.createInterface({ input: child.stdout }); - rl.on("line", (line) => { @@ -41,7 +42,7 @@ + this.exited = error; + for (const [, pending] of this.pending) pending.reject(error); + this.pending.clear(); -+ for (const handler of this.eventHandlers) handler({ type: "paperclip_process_exit" }); ++ for (const handler of this.eventHandlers) handler({ type: "paperclip_process_exit", failure: piNativeFailure(error) }); + }; + const frames = new PiRpcFrames((msg) => { + if (msg.type === "response") { @@ -60,7 +61,7 @@ - } - } - for (const h of this.eventHandlers) h(msg); -+ const event = this.toolIdentities.normalize(this.assistantMessages.normalize(msg)); ++ const event = this.toolIdentities.normalize(this.assistantMessages.normalize(this.rpcMessages.normalize(msg))); + for (const handler of this.eventHandlers) handler(event); }); - child.on("exit", (code, signal) => { @@ -96,7 +97,7 @@ }); try { await new Promise((resolve4, reject) => { -@@ -172,7 +165,7 @@ +@@ -172,7 +166,7 @@ } child.stderr.on("data", () => { }); @@ -105,7 +106,7 @@ try { const state = await proc.getState(); const sessionFile = typeof state?.sessionFile === "string" ? state.sessionFile : null; -@@ -181,21 +174,30 @@ +@@ -181,21 +175,30 @@ const { dirname: dirname3 } = await import("path"); mkdirSync2(dirname3(sessionFile), { recursive: true }); } @@ -120,7 +121,7 @@ } onEvent(handler) { this.eventHandlers.push(handler); -+ if (this.exited) queueMicrotask(() => handler({ type: "paperclip_process_exit" })); ++ if (this.exited) queueMicrotask(() => handler({ type: "paperclip_process_exit", failure: piNativeFailure(this.exited) })); return () => { this.eventHandlers = this.eventHandlers.filter((h) => h !== handler); }; @@ -141,7 +142,7 @@ } } /** -@@ -242,7 +244,7 @@ +@@ -242,7 +245,7 @@ if (!res.success) throw new Error(`pi set_steering_mode failed: ${res.error ?? JSON.stringify(res.data)}`); } async compact(customInstructions) { @@ -150,7 +151,7 @@ if (!res.success) throw new Error(`pi compact failed: ${res.error ?? JSON.stringify(res.data)}`); return res.data; } -@@ -283,17 +285,23 @@ +@@ -283,17 +286,23 @@ await this.writeLine(`${JSON.stringify({ type: "extension_ui_response", ...response })} `); } @@ -180,7 +181,7 @@ }); } writeLine(line) { -@@ -337,7 +345,7 @@ +@@ -337,7 +346,7 @@ { authMethods: getAuthMethods() }, @@ -189,7 +190,7 @@ ); } -@@ -534,6 +542,14 @@ +@@ -534,6 +543,14 @@ const record = value; const command = record?.command ?? record?.cmd ?? record?.args?.command ?? record?.args?.cmd ?? record?.input?.command ?? record?.input?.cmd ?? record?.rawInput?.command ?? record?.rawInput?.cmd ?? record?.toolInput?.command ?? record?.toolInput?.cmd ?? record?.details?.command ?? record?.details?.cmd; return typeof command === "string" && command.trim() ? command : void 0; @@ -204,7 +205,7 @@ } function bashResultText(result) { const record = result; -@@ -714,6 +730,7 @@ +@@ -714,6 +731,7 @@ try { proc = await PiRpcProcess.spawn({ cwd: params.cwd, @@ -212,7 +213,7 @@ piCommand: params.piCommand }); } catch (e) { -@@ -808,6 +825,9 @@ +@@ -808,6 +826,9 @@ this.proc = opts.proc; this.conn = opts.conn; this.fileCommands = opts.fileCommands ?? []; @@ -222,7 +223,7 @@ this.proc.onEvent((ev) => this.handlePiEvent(ev)); } setStartupInfo(text) { -@@ -822,10 +842,7 @@ +@@ -822,10 +843,7 @@ sendStartupInfoIfPending() { if (this.startupInfoSent || !this.startupInfo) return; this.startupInfoSent = true; @@ -234,7 +235,7 @@ } async prompt(message, images = []) { const expandedMessage = expandSlashCommand(message, this.fileCommands); -@@ -852,6 +869,7 @@ +@@ -852,6 +870,7 @@ } async cancel() { this.cancelRequested = true; @@ -242,7 +243,7 @@ if (this.turnQueue.length) { const queued = this.turnQueue.splice(0, this.turnQueue.length); for (const t of queued) t.resolve("cancelled"); -@@ -870,6 +888,8 @@ +@@ -870,6 +889,8 @@ return this.cancelRequested; } emit(update) { @@ -251,7 +252,7 @@ this.lastEmit = this.lastEmit.then( () => this.conn.sessionUpdate({ sessionId: this.sessionId, -@@ -877,6 +897,12 @@ +@@ -877,6 +898,12 @@ }) ).catch(() => { }); @@ -264,7 +265,7 @@ } async flushEmits() { await this.lastEmit; -@@ -890,6 +916,7 @@ +@@ -890,6 +917,7 @@ kind: "execute", status: params.status, locations: params.locations, @@ -272,7 +273,7 @@ ...params.includeTerminal ? { content: bashTerminalContent(params.toolCallId) } : {}, ...params.includeTerminal ? { _meta: bashTerminalInfoMeta(params.toolCallId, this.cwd) } : {} }); -@@ -903,6 +930,7 @@ +@@ -903,6 +931,7 @@ sessionUpdate: "tool_call_update", toolCallId: params.toolCallId, status: params.status, @@ -280,7 +281,7 @@ _meta: { ...delta ? bashTerminalOutputMeta(params.toolCallId, delta) : {}, ...params.status === "completed" || params.status === "failed" ? bashTerminalExitMeta(params.toolCallId, bashExitCode(params.result, Boolean(params.isError))) : {} -@@ -920,12 +948,16 @@ +@@ -920,18 +949,24 @@ this.cancelRequested = false; this.inAgentLoop = false; this.pendingTurn = { resolve: t.resolve, reject: t.reject }; @@ -291,22 +292,36 @@ _meta: { piAcp: { queueDepth: this.turnQueue.length, running: true } } }); this.proc.prompt(t.message, t.images).catch((err) => { ++ const failure = piNativeFailure(err); ++ if (!this.cancelRequested) this.emitNotice("runtime_failure", failure.summary, "error", { reason: failure.reason }); void this.flushEmits().finally(() => { + if (epoch !== this.turnEpoch || !this.pendingTurn) return; + this.uiBridge.cancelAll(); const authErr = maybeAuthRequiredError(err); if (authErr) { this.pendingTurn?.reject(authErr); -@@ -946,20 +978,36 @@ + } else { +- const reason = this.cancelRequested ? "cancelled" : "error"; +- this.pendingTurn?.resolve(reason); ++ if (this.cancelRequested) this.pendingTurn?.resolve("cancelled"); ++ else this.pendingTurn?.reject(RequestError3.internalError({ paperclipPi: failure }, failure.summary)); + } + this.pendingTurn = null; + this.inAgentLoop = false; +@@ -946,20 +981,47 @@ handlePiEvent(ev) { const type = String(ev.type ?? ""); switch (type) { + case "paperclip_process_exit": { + this.uiBridge.cancelAll(); -+ this.pendingTurn?.reject(new Error("Pi provider process exited")); ++ const failure = piNativeFailure(ev.failure?.summary); ++ this.emitNotice("runtime_failure", failure.summary, "error", { reason: failure.reason }); ++ const turns = [...(this.pendingTurn ? [this.pendingTurn] : []), ...this.turnQueue.splice(0)]; + this.pendingTurn = null; -+ for (const turn of this.turnQueue.splice(0)) turn.reject(new Error("Pi provider process exited")); + this.turnEpoch += 1; ++ void this.flushEmits().finally(() => { ++ for (const turn of turns) turn.reject(RequestError3.internalError({ paperclipPi: failure }, failure.summary)); ++ }); + break; + } + case "message_start": { @@ -314,10 +329,17 @@ + break; + } + case "message_end": { ++ if (ev.message?.role === "assistant" && ev.message.stopReason === "error") { ++ const failure = piNativeFailure(ev.message.errorMessage); ++ this.emitNotice("runtime_failure", failure.summary, "error", { reason: failure.reason }); ++ } + if (ev.paperclipAssistantMessage) this.emit(piAssistantChunk(ev.paperclipAssistantMessage)); + try { this.turnUsage.accept(ev.message); } catch { -+ this.pendingTurn?.reject(new Error("Pi usage receipt is invalid")); ++ const failure = piNativeFailure("Pi usage receipt is invalid"); ++ this.emitNotice("runtime_failure", failure.summary, "error", { reason: failure.reason }); ++ const pending = this.pendingTurn; + this.pendingTurn = null; ++ void this.flushEmits().finally(() => pending?.reject(RequestError3.internalError({ paperclipPi: failure }, failure.summary))); + this.uiBridge.cancelAll(); + this.proc.dispose("SIGKILL"); + } @@ -342,7 +364,7 @@ break; } if (ame?.type === "toolcall_start" || ame?.type === "toolcall_delta" || ame?.type === "toolcall_end") { -@@ -1047,7 +1095,7 @@ +@@ -1047,7 +1109,7 @@ if (p) { try { const abs = isAbsolute(p) ? p : resolvePath(this.cwd, p); @@ -351,7 +373,7 @@ this.fileSnapshots.set(toolCallId, { path: p, oldText: snapshotOldText }); if (toolName === "edit") { for (const needle of getEditOldTexts(args)) { -@@ -1125,7 +1173,7 @@ +@@ -1125,7 +1187,7 @@ if (!isError && snapshot) { try { const abs = isAbsolute(snapshot.path) ? snapshot.path : resolvePath(this.cwd, snapshot.path); @@ -360,7 +382,7 @@ if (snapshot.oldText === null || newText !== snapshot.oldText) { hasStructuredDiff = true; content = [ -@@ -1154,48 +1202,41 @@ +@@ -1154,48 +1216,41 @@ break; } case "extension_ui_request": { @@ -433,7 +455,7 @@ break; } case "agent_start": { -@@ -1210,9 +1251,12 @@ +@@ -1210,9 +1265,12 @@ break; } case "agent_settled": { @@ -447,7 +469,7 @@ this.pendingTurn = null; this.inAgentLoop = false; const next = this.turnQueue.shift(); -@@ -1725,6 +1769,7 @@ +@@ -1725,6 +1783,7 @@ return process.env.PI_CODING_AGENT_DIR ? resolve3(process.env.PI_CODING_AGENT_DIR) : join4(homedir4(), ".pi", "agent"); } function getEnableSkillCommands(cwd) { @@ -455,7 +477,7 @@ const merged = getMergedSettings(cwd); const direct = merged.enableSkillCommands; if (typeof direct === "boolean") return direct; -@@ -1733,6 +1778,7 @@ +@@ -1733,6 +1792,7 @@ return true; } function getQuietStartup(cwd) { @@ -463,7 +485,7 @@ const merged = getMergedSettings(cwd); const direct = merged.quietStartup; if (typeof direct === "boolean") return direct; -@@ -1824,6 +1870,7 @@ +@@ -1824,6 +1884,7 @@ const out = []; const seen = /* @__PURE__ */ new Set(); for (const c of [...a, ...b]) { @@ -471,7 +493,7 @@ if (seen.has(c.name)) continue; seen.add(c.name); out.push(c); -@@ -1884,11 +1931,13 @@ +@@ -1884,11 +1945,13 @@ throw RequestError3.invalidParams(`Unknown sessionId: ${sessionId}`); } const cwd = opts?.cwd ?? stored.cwd; @@ -485,7 +507,7 @@ piCommand: process.env.PI_ACP_PI_COMMAND }); } catch (e) { -@@ -1897,7 +1946,7 @@ +@@ -1897,7 +1960,7 @@ } throw e; } @@ -494,7 +516,7 @@ const session = this.sessions.getOrCreate(sessionId, { cwd, mcpServers: opts?.mcpServers ?? [], -@@ -1917,6 +1966,7 @@ +@@ -1917,6 +1980,7 @@ } } async initialize(params) { @@ -502,7 +524,7 @@ const supportedVersion = 1; const requested = params.protocolVersion; return { -@@ -1933,7 +1983,8 @@ +@@ -1933,7 +1997,8 @@ }), agentCapabilities: { loadSession: true, @@ -512,7 +534,7 @@ promptCapabilities: { image: true, audio: false, -@@ -1942,8 +1993,7 @@ +@@ -1942,8 +2007,7 @@ sessionCapabilities: { // **UNSTABLE** ACP capability used by Zed's codex-acp adapter. // Enables a native session picker in clients that support it. @@ -522,7 +544,7 @@ } } }; -@@ -1953,7 +2003,7 @@ +@@ -1953,7 +2017,7 @@ throw RequestError3.invalidParams(`cwd must be an absolute path: ${params.cwd}`); } this.lastSessionCwd = params.cwd; @@ -531,7 +553,7 @@ const enableSkillCommands = getEnableSkillCommands(params.cwd); const session = await this.sessions.create({ cwd: params.cwd, -@@ -1994,14 +2044,14 @@ +@@ -1994,14 +2058,14 @@ this.cleanupFailedNewSession(session.sessionId, state); throw RequestError3.authRequired( { authMethods: getAuthMethods() }, @@ -548,7 +570,7 @@ ); } const { configOptions, models, modes } = await getSessionConfiguration(session.proc, { -@@ -2064,34 +2114,27 @@ +@@ -2064,34 +2128,27 @@ } async prompt(params) { const session = await this.restoreSession(params.sessionId); @@ -595,7 +617,7 @@ } if (cmd === "session") { const stats = await session.proc.getSessionStats(); -@@ -2112,13 +2155,8 @@ +@@ -2112,13 +2169,8 @@ } const text = lines.length ? lines.join("\n") : `Session stats: ${JSON.stringify(stats, null, 2)}`; @@ -611,7 +633,7 @@ return { stopReason: "end_turn" }; } if (cmd === "name") { -@@ -2418,22 +2456,24 @@ +@@ -2418,22 +2470,24 @@ enabled = !current; } await session.proc.setAutoCompaction(enabled); @@ -648,7 +670,7 @@ } async cancel(params) { const session = this.sessions.maybeGet(params.sessionId); -@@ -2473,7 +2513,7 @@ +@@ -2473,7 +2527,7 @@ mcpServers: params.mcpServers }); const proc = session.proc; @@ -657,7 +679,7 @@ this.sessions.closeAllExcept?.(session.sessionId); this.store.upsert({ sessionId: params.sessionId, -@@ -2482,7 +2522,7 @@ +@@ -2482,7 +2536,7 @@ }); const data = await proc.getMessages(); const messages = Array.isArray(data?.messages) ? data.messages : []; @@ -666,7 +688,7 @@ const role = String(m?.role ?? ""); if (role === "user") { const text = normalizePiMessageText(m?.content); -@@ -2501,16 +2541,14 @@ +@@ -2501,16 +2555,14 @@ if (text) { await this.conn.sessionUpdate({ sessionId: session.sessionId, @@ -686,7 +708,7 @@ const isError = Boolean(m?.isError); const isBash = isBashTool(toolName); if (isBash) { -@@ -2524,7 +2562,7 @@ +@@ -2524,7 +2576,7 @@ kind: "execute", status: "completed", content: bashTerminalContent(toolCallId), @@ -695,7 +717,7 @@ } }); await this.conn.sessionUpdate({ -@@ -2535,7 +2573,8 @@ +@@ -2535,7 +2587,8 @@ status: isError ? "failed" : "completed", _meta: { ...text2 ? bashTerminalOutputMeta(toolCallId, text2) : {}, @@ -705,7 +727,7 @@ } } }); -@@ -2549,6 +2588,7 @@ +@@ -2549,6 +2602,7 @@ title: toolName, kind: toolName === "read" ? "read" : toolName === "write" || toolName === "edit" ? "edit" : "other", status: "completed", @@ -713,7 +735,7 @@ rawInput: null, rawOutput: m } -@@ -2560,6 +2600,7 @@ +@@ -2560,6 +2614,7 @@ sessionUpdate: "tool_call_update", toolCallId, status: isError ? "failed" : "completed", @@ -721,7 +743,7 @@ content: text ? [{ type: "content", content: { type: "text", text } }] : null, rawOutput: m } -@@ -2607,6 +2648,7 @@ +@@ -2607,6 +2662,7 @@ return response; } async deleteSession(params) { @@ -729,7 +751,7 @@ const stored = this.store.get(params.sessionId); const piSession = findPiSession(params.sessionId); if (!stored && !piSession) { -@@ -2828,6 +2870,7 @@ +@@ -2828,6 +2884,7 @@ return 0; } function buildUpdateNotice() { @@ -737,7 +759,7 @@ try { const piVersion = spawnSync("pi", ["--version"], { encoding: "utf-8" }); const installed = (String(piVersion.stdout ?? "").trim() || String(piVersion.stderr ?? "").trim()).replace( -@@ -2848,6 +2891,7 @@ +@@ -2848,6 +2905,7 @@ } } function buildStartupInfo(opts) { @@ -745,7 +767,7 @@ void opts.fileCommands; const md = []; try { -@@ -2980,6 +3024,7 @@ +@@ -2980,6 +3038,7 @@ // src/index.ts if (process.argv.includes("--terminal-login")) { @@ -753,7 +775,7 @@ const { spawnSync: spawnSync2 } = await import("child_process"); const cmd = getPiCommand(process.env.PI_ACP_PI_COMMAND); const res = spawnSync2(cmd, [], { -@@ -3019,11 +3064,12 @@ +@@ -3019,11 +3078,12 @@ } }); var stream = ndJsonStream(input, output); @@ -770,7 +792,7 @@ try { --- /dev/null +++ b/dist/paperclip-runtime.js -@@ -0,0 +1,471 @@ +@@ -0,0 +1,577 @@ +/** Source for the helper embedded in patches/pi-acp@0.0.33.patch. */ +import { createHash, randomUUID } from "node:crypto"; +import { StringDecoder } from "node:string_decoder"; @@ -806,6 +828,111 @@ + + + ++/** Pi 1 RPC serializes message_update without message/partial. Reconstruct only ++ * the streaming view, anchored to the actual message_start; message_end remains ++ * the native authoritative receipt. Never infer an executable tool from text. */ ++export class PiRpcMessageDeltas { ++ active = null; ++ blocks = new Map (); ++ streamedBytes = 0; ++ failed = false; ++ fail() { this.failed = true; throw new Error("Pi RPC message delta boundary is invalid"); } ++ normalize(event ) { ++ if (this.failed) return this.fail(); ++ if (event.type === "message_start" && event.message && typeof event.message === "object" && (event.message ).role === "assistant") { ++ if (this.active) return this.fail(); ++ this.active = event.message ; this.blocks.clear(); this.streamedBytes = 0; ++ return event; ++ } ++ if (event.type === "message_end" && event.message && typeof event.message === "object" && (event.message ).role === "assistant") { ++ const message = event.message ; ++ if (!this.active || message.timestamp !== this.active.timestamp) return this.fail(); ++ if (!["error", "aborted"].includes(String(message.stopReason)) && [...this.blocks.values()].some(block => !block.ended)) return this.fail(); ++ for (const [index, block] of this.blocks) if (block.ended) { ++ const final = Array.isArray(message.content) ? message.content[index] : undefined; ++ if (!final || typeof final !== "object") return this.fail(); ++ if (block.kind === "toolcall") { ++ if (final.type !== "toolCall" || toolSignature(final.id, final.name, final.arguments) !== block.final) return this.fail(); ++ } else if (final.type !== block.kind || final[block.kind === "text" ? "text" : "thinking"] !== block.json) return this.fail(); ++ } ++ this.active = null; this.blocks.clear(); return event; ++ } ++ if (event.type !== "message_update") return event; ++ if (!this.active || event.message !== undefined || !event.assistantMessageEvent || typeof event.assistantMessageEvent !== "object" || Array.isArray(event.assistantMessageEvent)) return this.fail(); ++ const update = event.assistantMessageEvent ; ++ if (update.partial !== undefined || typeof update.type !== "string") return this.fail(); ++ const match = /^(text|thinking|toolcall)_(start|delta|end)$/.exec(update.type); ++ const index = update.contentIndex; ++ if (!match || !Number.isSafeInteger(index) || (index ) < 0 || (index ) >= 4096) return this.fail(); ++ const [, kind, phase] = match; ++ let block = this.blocks.get(index ); ++ if (phase === "start") { ++ if (block) return this.fail(); ++ block = { kind: kind , ended: false, json: "" }; ++ if (kind === "toolcall") { ++ if (typeof update.id !== "string" || Buffer.byteLength(update.id) > 256 || typeof update.toolName !== "string" || Buffer.byteLength(update.toolName) > 256) return this.fail(); ++ if (update.id && [...this.blocks.values()].some(other => other.id === update.id)) return this.fail(); ++ block.id = update.id; block.name = update.toolName; ++ } ++ this.blocks.set(index , block); ++ } else if (!block || block.ended || block.kind !== kind) return this.fail(); ++ if (phase === "delta") { ++ if (typeof update.delta !== "string" || Buffer.byteLength(update.delta) > 262_144) return this.fail(); ++ this.streamedBytes += Buffer.byteLength(update.delta); ++ if (this.streamedBytes > 4 * 1024 * 1024) return this.fail(); ++ block .json += update.delta; ++ if (kind === "toolcall" && Buffer.byteLength(block .json) > 1_048_576) return this.fail(); ++ } ++ let toolCall ; ++ if (kind === "toolcall") { ++ if (phase === "end") { ++ if (!update.toolCall || typeof update.toolCall !== "object" || Array.isArray(update.toolCall)) return this.fail(); ++ const final = update.toolCall ; ++ if (final.type !== "toolCall" || typeof final.id !== "string" || !final.id || Buffer.byteLength(final.id) > 256 || typeof final.name !== "string" || !final.name || Buffer.byteLength(final.name) > 256 ++ || block .id && block .id !== final.id || block .name && block .name !== final.name ++ || [...this.blocks.entries()].some(([otherIndex, other]) => otherIndex !== index && other.id === final.id) ++ || !final.arguments || typeof final.arguments !== "object" || Array.isArray(final.arguments) || Buffer.byteLength(JSON.stringify(final.arguments)) > 1_048_576) return this.fail(); ++ block .id = final.id; block .name = final.name; block .final = toolSignature(final.id, final.name, final.arguments); toolCall = final; ++ } else toolCall = { type: "toolCall", id: block .id, name: block .name, partialArgs: block .json }; ++ } ++ if (phase === "end") { ++ if (kind !== "toolcall" && (typeof update.content !== "string" || update.content !== block .json)) return this.fail(); ++ block .ended = true; ++ } ++ return { ...event, message: { ...this.active, content: [] }, assistantMessageEvent: { ...update, ...(toolCall ? { toolCall } : {}) } }; ++ } ++} ++ ++function toolSignature(id , name , args ) { ++ const canonical = (value ) => Array.isArray(value) ? value.map(canonical) ++ : value && typeof value === "object" ? Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b)).map(([key, item]) => [key, canonical(item)])) : value; ++ return JSON.stringify([id, name, canonical(args)]); ++} ++ ++/** A closed diagnostic vocabulary: raw exception text, paths, provider bodies ++ * and credentials never cross the wrapper boundary. Unknown failures stay ++ * generic rather than masquerading as a known local or provider condition. */ ++export function piNativeFailure(value ) { ++ const message = value instanceof Error ? value.message : typeof value === "string" ? value : ""; ++ const known = { ++ "Pi RPC message delta boundary is invalid": "rpc_delta_boundary_invalid", ++ "Pi native assistant message boundary is invalid": "assistant_boundary_invalid", ++ "Pi native tool invocation identity conflict": "tool_identity_conflict", ++ "Pi model iteration identity is ambiguous": "model_iteration_ambiguous", ++ "Pi model iteration identity is unavailable": "model_iteration_unavailable", ++ "Pi tool invocation has no active model iteration": "tool_iteration_missing", ++ "Pi tool identity is outside its iteration bound": "tool_iteration_bound", ++ "Pi RPC frame exceeds its bound": "rpc_frame_oversized", ++ "Pi RPC stream ended inside a frame": "rpc_frame_incomplete", ++ "Pi usage receipt is invalid": "usage_receipt_invalid", ++ "Pi process exited before its next request": "provider_process_exited", ++ }; ++ if (Object.hasOwn(known, message)) return { reason: known[message] , summary: message }; ++ const status = /^(?:pi prompt failed: )?(401|403|429|500|502|503|504)(?::|\b)/.exec(message.slice(0, 128))?.[1]; ++ if (status) return { reason: `provider_http_${status}`, summary: `Pi provider request failed (HTTP ${status})` }; ++ return { reason: "unknown_native_failure", summary: "Pi native runtime failed; diagnostic details were withheld" }; ++} ++ +/** IDs are allocated only at actual SDK assistant message_start events. Tool + * iterations, retries, notices and ACP prompts cannot create assistant IDs. */ +export class PiAssistantMessages { @@ -943,7 +1070,8 @@ + const block = record(value); + // Empty IDs during initial streaming do not identify an executable call. + return (direct || block.type === "toolCall") && typeof block.id === "string" && block.id -+ ? { ...block, id: this.identity(block.id) } : value; ++ ? { ...block, id: direct && update.type === "toolcall_end" ++ ? this.bind(block.id, text(block.name, 256), block.arguments) : this.identity(block.id) } : value; + }; + const partial = update.partial && typeof update.partial === "object" ? record(update.partial) : undefined; + return { ...event, assistantMessageEvent: { ...update, diff --git a/tests/runner-e2e/FIXTURES.md b/tests/runner-e2e/FIXTURES.md index 3f1867bdca2..6cab7446227 100644 --- a/tests/runner-e2e/FIXTURES.md +++ b/tests/runner-e2e/FIXTURES.md @@ -384,7 +384,7 @@ The explicit-only `pi-controls` suite adds `pending-permission-stop` and `same-turn-steering` on local and Daytona, each with one provider run, a 120-second active-turn timeout and a 300-second attempt budget. These four control cases retain their behavior; the current matrix totals 26 Pi cells. -Pi 1/profile 11 and coverage revisions intentionally change the affected suite +Pi 1/profile 12 and coverage revisions intentionally change the affected suite fingerprints, so older qualification receipts cannot be reused. Catalog presence and deterministic calibration do not constitute paid qualification. diff --git a/tests/runner-e2e/README.md b/tests/runner-e2e/README.md index 14fa6fadb72..4bb5da377b7 100644 --- a/tests/runner-e2e/README.md +++ b/tests/runner-e2e/README.md @@ -39,8 +39,9 @@ native-write evidence, caller-owned control acknowledgments, independent no-effect/retirement proof and the actual production task UI. Daytona also requires the existing immutable image and executable digests. These cells do not establish native follow-up queue persistence or provider-death recovery. -The current Pi matrix has 25 explicit cells (13 local and 12 Daytona), including -these four controls and the pending-native-question restart cases. Pi's profile +The current Pi matrix has 26 explicit cells (13 local and 13 Daytona), including +these four controls, the pending-native-question restart cases, and one Daytona +provider-death case. Pi's profile remains pending; catalog presence is not live qualification. ## Provider-free browser bootstrap regression diff --git a/tests/runner-e2e/pi-controls-evidence.test.ts b/tests/runner-e2e/pi-controls-evidence.test.ts index 557f076002e..10b1174bfed 100644 --- a/tests/runner-e2e/pi-controls-evidence.test.ts +++ b/tests/runner-e2e/pi-controls-evidence.test.ts @@ -129,15 +129,15 @@ describe("Pi controls catalog admission", () => { expect(() => assertRemoteNativeEvidencePrerequisites(cells, {})).toThrow(); }); it("pins the Pi 1 profile and versioned coverage while retaining active Stop identity", () => { - // Pi 1/profile 11 changes profile-bearing definitions. Coverage v4/v2 adds + // Pi 1/profile 12 changes profile-bearing definitions. Coverage v4/v2 adds // provider death, pending restart and the strict file oracle; no runtime admission is promoted. const pi = runnerMatrix.find(c => c.profile.qualificationCandidate === "pi")!.profile; expect(pi.modelQualification?.qualificationId).toBe("pi:0.0.33:1.0.0:openrouter"); - expect(runnerSuites.find(s => s.id === "pi-native")!.definitionMetadata).toMatchObject({ version: 4, profileVersion: 11 }); + expect(runnerSuites.find(s => s.id === "pi-native")!.definitionMetadata).toMatchObject({ version: 4, profileVersion: 12 }); expect(runnerSuites.find(s => s.id === "extended-harnesses")!.definitionMetadata).toMatchObject({ version: 2 }); const hashes = Object.fromEntries(runnerSuites.filter(s => ["pi-native", "native-active-stop", "extended-harnesses", "rich-acp-warm-continuity"].includes(s.id)).map(s => [s.id, suiteDefinitionHash(s)])); expect(hashes).toEqual({ - "pi-native": "8c4d4f1cba234223ee54b5a19d5fe3f815424e7335cf15f43bd3f77d342aec74", + "pi-native": "eec3b2c140d81561e69c13c1b193b66f7790af0cfc90bbed76c57af2d1e3cdde", "native-active-stop": "99682b2b106d816a011834fae5a944ed7729958893709d5b83a19b6f595e7e4d", "rich-acp-warm-continuity": "25e69f031696aeb459e6722a056ae0802379f9137799648fdd1945881f32f41a", "extended-harnesses": "e50f79cf604bffb2cdee3ad4e8bc1d106045a4f44ef07e877032ff5b8960c8ab",