diff --git a/doc/architecture/runner-pi-capabilities.md b/doc/architecture/runner-pi-capabilities.md index 56d83aef11e..2951f8ab109 100644 --- a/doc/architecture/runner-pi-capabilities.md +++ b/doc/architecture/runner-pi-capabilities.md @@ -1,6 +1,27 @@ # Pi rich ACP runtime -Status: implementation candidate, 2026-09-29. Current profile version 7 repairs +Status: implementation candidate, 2026-09-29. Current profile version 8 repairs +native assistant-message attribution. Real SDK `message_start` and `message_end` +boundaries, including empty messages, carry occurrence IDs through ACPX, the +sidecar and both terminal reducers. Tool-use, error and aborted messages remain +progress but cannot become a final answer. Missing or conflicting boundaries +fail closed. Loaded history has a separate display-only identity. Retry, +compaction, extension and command notices retain bounded, redacted severity and +metadata on `paperclip/pi_notice`; they are never assistant final content. +The [v8 declaration](../../packages/paperclip-runner/test-fixtures/pi-acp/profile-v8-identity.json) +also pins the shared ACPX parser and Pi host projections. Previous sessions must +be reopened. No paid profile-v8 qualification has run. + +The retained profile-v7 campaign passed native questions (15/15), then failed +question continuation (5/6): the model emitted the exact final marker, but the +old terminal reducer combined it with pre-tool narration. That failed Product +result remains a failure; the remaining five cells were not launched. The v8 +credential-free regressions reproduce this boundary through the actual pinned +SDK (fresh, warm and loaded sessions), patched wrapper, ACPX transport, sidecar, +TypeScript driver and Rust reducer. This is deterministic repair evidence, not +a replacement for the failed paid journey. + +Historical profile version 7 repairs MCP tool-name admission and native question labels. MCP names containing colons or periods, or exceeding model name limits, receive deterministic collision-checked aliases of at most 64 characters; authenticated calls retain the exact original @@ -30,7 +51,8 @@ all 15 native-question checks. The following question-continuation case reached Done but failed the exact final-message assertion: earlier pre-tool narration was aggregated into the final item. Three runs settled across these two cells, all owned processes exited, and five later cells did not launch. The native -message attribution defect remains open. The canonical +message attribution defect is repaired in v8; authenticated v8 qualification +remains pending. The canonical [v7 declaration](../../packages/paperclip-runner/test-fixtures/pi-acp/profile-v7-identity.json) binds the exact wrapper, helper, extension and three target closures. @@ -100,7 +122,7 @@ boundaries explicit. | Surface | Pi implementation and boundary | | --- | --- | | Sessions | Native Pi JSONL create/load; resume is confined to the execution's private session home and original workspace. Cold `prompt` cannot implicitly load an arbitrary session. | -| Text and reasoning | Upstream streams text and thought chunks. The common runner's private reasoning policy still applies; provider support does not authorize retention or display. | +| Text and reasoning | Native start/end provenance and occurrence IDs separate pre-tool narration from the last completed assistant message; empty or tool-use terminal messages cannot promote stale text. Loaded history is display-only. The common runner's private reasoning policy still applies. | | Native tools | `read`, `grep`, `find`, `ls`, `write`, `edit`, and `bash` pass the immutable extension gate before execution. File roots and read-only mode are checked before and after a permission wait. The host sandbox remains authoritative for shell commands and races. | | Permissions | Native tool approval uses ACP `session/request_permission`, with allow once, allow for the session, and deny. Only offered options are accepted. Session grants cover an identical operation and still revalidate paths. Questions never become approvals. | | Questions | The owned `paperclip_native_question` tool exposes Pi `select`, `confirm`, `input`, and `editor` through ACP form elicitation. Select is single-choice with stable option IDs. Pi returns `false` for both No and dismissal of confirm, so the tool reports `negative_or_cancelled`; it never invents a confirmed answer. Other methods return explicit cancellation. Permissions and semantic Plan approval remain separate. | @@ -109,7 +131,7 @@ boundaries explicit. | Plans and artifacts | Pi has no native structured plan or artifact channel. Paperclip plan and artifact semantic tools remain available through the MCP bridge; native file edits retain bounded, workspace-confined ACP diff projection. Tool text/image results are preserved, and resource blocks are recorded without following URLs. | | Steering | Capability-negotiated `pi/steer` issues native RPC `steer` during an active turn. `pi/follow_up` explicitly queues native RPC `follow_up`. Neither is inferred from a second ACP prompt. Each takes `{sessionId, message}` and returns `{accepted: true, sessionId, kind}` with the matching control kind. | | Usage | Prompt results sum actual assistant message usage receipts across continuations. Input, output, cache reads/writes, total tokens and Pi-reported pricing estimates have provenance. Context-window occupancy is not billed usage. No receipt means no usage assertion; absent cache or cost fields remain unknown. Pi calculates cost from its model catalog rates, so this is not an authoritative provider bill. | -| Retry and compaction | Upstream retry/compaction notices are retained. `agent_settled`, rather than a transient `agent_end`, settles a prompt. A final provider error remains a failed prompt and does not become successful completion. | +| Retry and compaction | The pinned session notice extension preserves bounded, redacted summaries, severity and known native counters/flags separately from assistant text. `agent_settled`, rather than a transient `agent_end`, settles a prompt. A final provider error remains a failed prompt. | | Images | The upstream ACP wrapper accepts image blocks, but `AcpxRuntimeTurnInput` and the common adapter currently forward text only. P1: implement typed image content through the common converter and then qualify the exact model; live testing alone cannot close this implementation gap. | | Cancellation and death | Cancellation expires live UI waits and calls native abort. Pi process exit rejects pending RPC requests and all active/queued turns. Partial RPC frames, oversized frames, and malformed JSON fail closed. | | Fork and clone | Native Pi RPC exposes `fork(entryId)`, `clone`, and `get_fork_messages`; the ACP wrapper and Paperclip controls do not map them. P2: add explicit admitted session operations and verified lineage before exposing controls. | @@ -441,7 +463,7 @@ that those targets have executed successfully. ### Installation authority and token semantics -`verifyPiInstallation(profile)` admits only profile version 7 and the source-owned +`verifyPiInstallation(profile)` admits only profile version 8 and the source-owned Pi identity. It resolves `provider-assets/pi/-` inside the verified Runner package, checks the complete runtime against source-pinned closure hashes, and opens a guarded immutable native snapshot. The snapshot bootstrap binds Node, diff --git a/packages/paperclip-runner/package.json b/packages/paperclip-runner/package.json index 596b74ad881..67283bbc2fb 100644 --- a/packages/paperclip-runner/package.json +++ b/packages/paperclip-runner/package.json @@ -74,7 +74,7 @@ "typecheck:browser": "tsc -p tsconfig.browser.json --noEmit", "test": "pnpm run test:typescript && pnpm run test:rust", "test:typescript": "pnpm run test:typescript:prep && vitest run", - "test:typescript:prep": "pnpm run ensure:eval-build-deps && pnpm run build:rust && node --test test/protocol-contract.test.mjs test/acpx-sidecar-contract.test.mjs test/acpx-codex-package-contract.test.mjs test/acpx-rich-extensions-package-contract.test.mjs test/acpx-pi-receipt-package-contract.test.mjs test/acpx-response-delivery-package-contract.test.mjs scripts/candidate-provider-pack.test.mjs scripts/aws-agentcore-provisioning.test.mjs scripts/build-verified-provider-entrypoints.test.mjs scripts/local-provider-smoke-environment.test.mjs scripts/materialize-opencode-binary.test.mjs", + "test:typescript:prep": "pnpm run ensure:eval-build-deps && pnpm run build:rust && node --test test/protocol-contract.test.mjs test/acpx-sidecar-contract.test.mjs test/acpx-codex-package-contract.test.mjs test/acpx-message-boundaries-package-contract.test.mjs test/acpx-rich-extensions-package-contract.test.mjs test/acpx-pi-receipt-package-contract.test.mjs test/acpx-response-delivery-package-contract.test.mjs scripts/candidate-provider-pack.test.mjs scripts/aws-agentcore-provisioning.test.mjs scripts/build-verified-provider-entrypoints.test.mjs scripts/local-provider-smoke-environment.test.mjs scripts/materialize-opencode-binary.test.mjs", "test:typescript:vitest": "node ./scripts/run-pr-vitest-lane.mjs", "test:rust": "cargo test --release --manifest-path runner/Cargo.toml --locked --workspace", "test:codex": "cargo test --manifest-path runner/Cargo.toml --locked -p paperclip-runner-core --test codex_provider", diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_state.rs b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_state.rs index 876cf9d00f1..867e2f808c0 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_state.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_state.rs @@ -106,6 +106,8 @@ pub struct AcpxProviderState { plan_revision: u64, assistant_text: String, assistant_message_id: Option, + pi_assistant_message: Option, + pi_assistant_message_ids: BTreeSet, pending_tools: BTreeMap, pending_tool_input_bytes: usize, pending_permissions: BTreeMap, @@ -123,6 +125,8 @@ impl AcpxProviderState { plan_revision: 0, assistant_text: String::new(), assistant_message_id: None, + pi_assistant_message: None, + pi_assistant_message_ids: BTreeSet::new(), pending_tools: BTreeMap::new(), pending_tool_input_bytes: 0, pending_permissions: BTreeMap::new(), @@ -215,6 +219,8 @@ impl AcpxProviderState { self.plan_revision = 0; self.assistant_text.clear(); self.assistant_message_id = None; + self.pi_assistant_message = None; + self.pi_assistant_message_ids.clear(); self.semantic_result = None; self.attempted_turn_controls.clear(); Ok(()) @@ -358,6 +364,11 @@ impl AcpxProviderState { }]) } AcpxEventPayload::TurnTerminal { status, error } => { + if status == AcpxTurnStatus::Completed && self.pi_assistant_message.is_some() { + return Err(LocalRunnerError::invalid( + "Pi assistant message ended without its native boundary", + )); + } let turn_id = event .turn_id .as_deref() @@ -375,6 +386,8 @@ impl AcpxProviderState { self.assistant_text.clear(); } self.assistant_message_id = None; + self.pi_assistant_message = None; + self.pi_assistant_message_ids.clear(); events.push(AcpxProviderStateEvent::TurnTerminal { turn_id, status, @@ -495,7 +508,9 @@ impl AcpxProviderState { } else { None }; - if kind == AcpxRuntimeEventKind::TextDelta { + if kind == AcpxRuntimeEventKind::TextDelta + && payload.get("piMessageHistory").and_then(Value::as_bool) != Some(true) + { let provider_message_id = payload .get("messageId") .and_then(Value::as_str) @@ -510,12 +525,64 @@ impl AcpxProviderState { .get("text") .and_then(Value::as_str) .unwrap_or_default(); + let mut native_boundary_clears = false; + if let Some(boundary) = payload.get("piMessageBoundary") { + let phase = boundary.get("phase").and_then(Value::as_str); + let reason = boundary.get("stopReason").and_then(Value::as_str); + if !raw_text.is_empty() || provider_message_id.is_none() { + return Err(LocalRunnerError::invalid( + "Pi assistant boundary contains invalid content or identity", + )); + } + match phase { + Some("start") if self.pi_assistant_message.is_none() && reason.is_none() => { + if self.pi_assistant_message_ids.len() >= 4096 + || !self + .pi_assistant_message_ids + .insert(provider_message_id.clone().unwrap()) + { + return Err(LocalRunnerError::invalid( + "Pi assistant identity is duplicated or exceeds its bound", + )); + } + self.pi_assistant_message = provider_message_id.clone(); + native_boundary_clears = true; + } + Some("end") + if self.pi_assistant_message.is_some() + && self.pi_assistant_message == provider_message_id => + { + if !matches!( + reason, + Some("stop" | "length" | "toolUse" | "error" | "aborted") + ) { + return Err(LocalRunnerError::invalid( + "Pi assistant stop reason is invalid", + )); + } + self.pi_assistant_message = None; + native_boundary_clears = !matches!(reason, Some("stop" | "length")); + } + _ => { + return Err(LocalRunnerError::invalid( + "Pi assistant boundaries are out of order", + )) + } + } + } else if !self.pi_assistant_message_ids.is_empty() + && (self.pi_assistant_message.is_none() + || self.pi_assistant_message != provider_message_id) + { + return Err(LocalRunnerError::invalid( + "Pi assistant delta has no matching native message", + )); + } // ACP can produce more than one assistant message in a turn. All // deltas remain durable progress, but only the latest compatible // provider message is eligible to become the terminal reply. // Folding earlier messages into it duplicates the transcript in // the task UI and can promote intermediate prose as final output. - if starts_new_message { + if starts_new_message || native_boundary_clears { self.assistant_text.clear(); } if self diff --git a/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_state.rs b/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_state.rs index 072a812d563..6b31593c686 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_state.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_state.rs @@ -175,6 +175,95 @@ fn promotes_only_the_latest_provider_message_as_the_terminal_reply() { )); } +#[test] +fn pi_native_boundaries_keep_progress_but_never_promote_tool_narration_or_history() { + for final_reason in ["stop", "length", "toolUse", "error", "aborted", "empty"] { + let mut state = AcpxProviderState::new("run-1").unwrap(); + state.begin_turn("turn-1").unwrap(); + let mut messages = vec![ + json!({"type":"text_delta","messageId":"history","text":"Earlier session answer","piMessageHistory":true}), + json!({"type":"text_delta","messageId":"first","text":"","piMessageBoundary":{"phase":"start"}}), + json!({"type":"text_delta","messageId":"first","text":"Calling finish."}), + json!({"type":"text_delta","messageId":"first","text":"","piMessageBoundary":{"phase":"end","stopReason":"toolUse"}}), + json!({"type":"text_delta","messageId":"last","text":"","piMessageBoundary":{"phase":"start"}}), + ]; + if final_reason != "empty" { + messages.push(json!({"type":"text_delta","messageId":"last","text":"EXACT_MARKER"})); + } + messages.push(json!({"type":"text_delta","messageId":"last","text":"","piMessageBoundary":{"phase":"end","stopReason":if final_reason == "empty" { "stop" } else { final_reason }}})); + let count = messages.len() as u64; + for (index, payload) in messages.into_iter().enumerate() { + state + .accept_event(&event( + index as u64 + 1, + GeneratedAcpxSidecarEventType::RuntimeEvent, + Some("turn-1"), + payload, + )) + .unwrap(); + } + let terminal = state + .accept_event(&event( + count + 1, + GeneratedAcpxSidecarEventType::RuntimeTurnTerminal, + Some("turn-1"), + json!({"status":"completed"}), + )) + .unwrap(); + let replies: Vec<_> = terminal + .iter() + .filter_map(|entry| { + if let AcpxProviderStateEvent::AssistantMessage { text, .. } = entry { + Some(text.as_str()) + } else { + None + } + }) + .collect(); + assert_eq!( + replies, + if matches!(final_reason, "stop" | "length") { + vec!["EXACT_MARKER"] + } else { + vec![] + } + ); + } +} + +#[test] +fn pi_native_boundary_order_and_terminal_completeness_fail_closed() { + for malformed in [ + json!({"type":"text_delta","messageId":"first","text":"","piMessageBoundary":{"phase":"start"}}), + json!({"type":"text_delta","messageId":"other","text":"","piMessageBoundary":{"phase":"end","stopReason":"stop"}}), + json!({"type":"text_delta","messageId":"first","text":"","piMessageBoundary":{"phase":"end","stopReason":"invented"}}), + json!({"type":"text_delta","messageId":"other","text":"Out of order"}), + ] { + let mut state = AcpxProviderState::new("run-1").unwrap(); + state.begin_turn("turn-1").unwrap(); + state.accept_event(&event(1, GeneratedAcpxSidecarEventType::RuntimeEvent, Some("turn-1"), json!({"type":"text_delta","messageId":"first","text":"","piMessageBoundary":{"phase":"start"}}))).unwrap(); + assert!(state + .accept_event(&event( + 2, + GeneratedAcpxSidecarEventType::RuntimeEvent, + Some("turn-1"), + malformed + )) + .is_err()); + } + let mut state = AcpxProviderState::new("run-1").unwrap(); + state.begin_turn("turn-1").unwrap(); + state.accept_event(&event(1, GeneratedAcpxSidecarEventType::RuntimeEvent, Some("turn-1"), json!({"type":"text_delta","messageId":"first","text":"","piMessageBoundary":{"phase":"start"}}))).unwrap(); + assert!(state + .accept_event(&event( + 2, + GeneratedAcpxSidecarEventType::RuntimeTurnTerminal, + Some("turn-1"), + json!({"status":"completed"}) + )) + .is_err()); +} + #[test] fn preserves_an_idless_prefix_when_the_provider_begins_identifying_deltas() { let mut state = AcpxProviderState::new("run-1").unwrap(); diff --git a/packages/paperclip-runner/scripts/materialize-pi-distribution.mjs b/packages/paperclip-runner/scripts/materialize-pi-distribution.mjs index c0b9be08308..bf8ef27aa23 100644 --- a/packages/paperclip-runner/scripts/materialize-pi-distribution.mjs +++ b/packages/paperclip-runner/scripts/materialize-pi-distribution.mjs @@ -20,8 +20,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: "0.84.2", sdk: "0.26.0", zod: "3.25.76", nodeVersion: PI_NODE_VERSION, undici: "8.10.2", nodeBundledUndici: "7.29.1", - wrapperSha256: "2b590e8e12133a77ee71cba2a688bf1e2bc73cfb771235a823ed9cdc3cd8c095", - helperSha256: "153caaa32b93313a81636a28af32ca8dcce295c200853f5897e0387f79a0d92d", + wrapperSha256: "9fd9a685fb79b4f73a8dac08cbe360a6de87ffcb596cdaff1191e3ac57c24f56", + helperSha256: "a19087c0af8fb3896fe3290280695f8f936574013bc4acde650f11f023f0f03b", }); const hash = (bytes) => createHash("sha256").update(bytes).digest("hex"); diff --git a/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.test.ts b/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.test.ts index c55375130c4..be172d4ae29 100644 --- a/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.test.ts +++ b/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.test.ts @@ -10,6 +10,7 @@ import { ACPX_CAPABILITY_PROFILES } from "../drivers/acpx/capability-profiles.js import { resolveQualifiedAcpxProfile } from "../drivers/acpx/qualified-profiles.js"; import { ACPX_SIDECAR_PROTOCOL_VERSION } from "../drivers/acpx/sidecar-protocol.js"; import { canonicalProviderEventsFromAcpxRuntimeEvent } from "../provider-events.js"; +import { createPiMessageProjection } from "../drivers/acpx/pi-message-projection.js"; import { awaitSidecarCleanupWithin, closeActiveSidecarHostWithin, @@ -35,6 +36,29 @@ afterEach(async () => { }); describe("qualified ACPX runtime sidecar", () => { + it("passes only validated Pi native boundaries and history through the real text sanitizer", () => { + const source = readFileSync(fileURLToPath(new URL("./acpx-runtime-sidecar.ts", import.meta.url)), "utf8"); + const start = source.indexOf(' if (event.type === "text_delta") {', source.indexOf("function sanitizeRuntimeEvent")); + const end = source.indexOf(' if (event.type === "status") {', start); + expect(start).toBeGreaterThan(0); + const sanitize = new Function("boundedOptionalText", "stableProviderIdentity", `return event => {${source.slice(start, end)} return null;}`)( + (value: string) => value, (value: string) => `stable-${value}`, + ); + const projection = createPiMessageProjection<{ type: string; text: string; stream: string; messageId: string; meta: Record }>(); + const event = (kind: string) => ({ type: "text_delta", text: "", stream: "output", messageId: `pi-message-${"a".repeat(64)}`, + meta: { origin: "pi-native-assistant", source: "pi-rpc-message-v1", kind, secret: "DROP_ME" } }); + const startFrame = sanitize(projection.normalize(event("start"))); + const endFrame = sanitize(projection.normalize(event("end:toolUse"))); + expect(startFrame).toMatchObject({ text: "", piMessageBoundary: { phase: "start" } }); + expect(endFrame).toMatchObject({ text: "", piMessageBoundary: { phase: "end", stopReason: "toolUse" } }); + expect(startFrame.messageId).toBe(endFrame.messageId); + expect(JSON.stringify([startFrame, endFrame])).not.toContain("DROP_ME"); + projection.settle(); + const loaded = createPiMessageProjection>(); + const history = sanitize(loaded.normalize({ ...event("history"), messageId: `pi-history-message-${"b".repeat(64)}`, + text: "old reply", meta: { origin: "pi-history-assistant", source: "pi-session-history-v1", kind: "history" } })); + expect(history).toMatchObject({ text: "old reply", piMessageHistory: true }); loaded.settle(); + }); it.each(["pi", "copilot", "cursor", "codex", "claude"])("offers verified session permission grants only for %s", async agent => { const source = readFileSync(fileURLToPath(new URL("./acpx-runtime-sidecar.ts", import.meta.url)), "utf8"); const start = source.indexOf(" const { signal } = context;", source.indexOf("async function waitForPermission")); diff --git a/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts b/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts index 917c62d2476..1921a1e1076 100644 --- a/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts +++ b/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts @@ -16,6 +16,7 @@ import type { import { acpxProfileClientCapabilities, bindAcpxExtensionTurn, validateAcpxRichEvent, createAcpxProfileExtensionAdapter, type AcpxExtensionInput } from "../drivers/acpx/profile-extensions.js"; import type { PaperclipQuestionSet } from "../contracts/question-set.js"; import { createAcpxToolEventNormalizer, createGrokMessageNormalizer } from "../provider-events.js"; +import { createPiMessageProjection, type PiProjectedMessageEvent } from "../drivers/acpx/pi-message-projection.js"; import { parseNativeRuntimeContext } from "../contracts/runtime-context.js"; import { PRP_BLOCK_TOOL_NAME, @@ -609,7 +610,8 @@ async function pumpTurn( // display metadata for later progress/completion frames before they cross // the sidecar boundary, matching the in-process ACPX driver path. const normalizeToolEvent = createAcpxToolEventNormalizer(); - const normalizeMessage = initializedAgent === "grok" + const piMessages = initializedAgent === "pi" ? createPiMessageProjection() : null; + const normalizeMessage = piMessages ? piMessages.normalize : initializedAgent === "grok" ? createGrokMessageNormalizer() : (event: AcpRuntimeEvent) => event; for await (const event of runtimeTurn.events) { toolEvidence?.tool(event); @@ -622,6 +624,7 @@ async function pumpTurn( ); } const result = await runtimeTurn.result; + if (result.status === "completed") piMessages?.settle(); await drainExtensions(); try { const usage = persistedAcpxTurnUsage( @@ -934,7 +937,7 @@ function boundRuntimeEventForNormalization( } as BoundedRuntimeToolEvent; } -function sanitizeRuntimeEvent(event: AcpRuntimeEvent): Record { +function sanitizeRuntimeEvent(event: PiProjectedMessageEvent): Record { const runtimeType = text(record(event).type); if (runtimeType === "plan") { return { @@ -948,6 +951,8 @@ function sanitizeRuntimeEvent(event: AcpRuntimeEvent): Record { text: boundedOptionalText(event.text, "", 64 * 1024), stream: event.stream, tag: event.tag ?? null, + ...(event.piMessageBoundary ? { piMessageBoundary: event.piMessageBoundary } : {}), + ...(event.piMessageHistory ? { piMessageHistory: true } : {}), messageId: typeof event.messageId === "string" && event.messageId.length > 0 ? stableProviderIdentity(event.messageId, "message") diff --git a/packages/paperclip-runner/src/contracts/native-execution.test.ts b/packages/paperclip-runner/src/contracts/native-execution.test.ts index f14285af97b..557de37d7c6 100644 --- a/packages/paperclip-runner/src/contracts/native-execution.test.ts +++ b/packages/paperclip-runner/src/contracts/native-execution.test.ts @@ -296,7 +296,7 @@ describe("NativeExecutionInputV1", () => { })).toThrow("eventExpiryDays"); }); - it.each([1, 2, 3, 4, 5, 6, 7] as const)("accepts only a closed ACPX profile matching the driver and agent at profile version %s", (agentProfileVersion) => { + it.each([1, 2, 3, 4, 5, 6, 7, 8] as const)("accepts only a closed ACPX profile matching the driver and agent at profile version %s", (agentProfileVersion) => { const provider = { kind: "acpx", agent: "pi", @@ -328,7 +328,7 @@ describe("NativeExecutionInputV1", () => { profile: provider.profile, }); expect(parseNativeExecutionInput(parsed)).toEqual(parsed); - for (const unsupportedVersion of [0, 8, 1.5, "7", null]) { + for (const unsupportedVersion of [0, 9, 1.5, "8", null]) { expect(() => parseNativeExecutionInput({ ...input, session: { ...input.session, driverKind: "acpx_runtime" }, diff --git a/packages/paperclip-runner/src/contracts/native-execution.ts b/packages/paperclip-runner/src/contracts/native-execution.ts index ce286ed27c8..b4414a8ea25 100644 --- a/packages/paperclip-runner/src/contracts/native-execution.ts +++ b/packages/paperclip-runner/src/contracts/native-execution.ts @@ -91,7 +91,7 @@ export interface NativeAcpxProfileSnapshot { protocolVersion: 1; acpxVersion: "0.13.1"; agent: NativeAcpxAgent; - agentProfileVersion: 1 | 2 | 3 | 4 | 5 | 6 | 7; + agentProfileVersion: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8; agentServerPackage: string; agentServerVersion: string; agentRuntimePackage: string | null; @@ -608,7 +608,7 @@ export function parseNativeExecutionInput(value: unknown): NativeExecutionInput || profile.protocolVersion !== 1 || profile.acpxVersion !== "0.13.1" || profile.agent !== provider.agent - || (profile.agentProfileVersion !== 1 && profile.agentProfileVersion !== 2 && profile.agentProfileVersion !== 3 && profile.agentProfileVersion !== 4 && profile.agentProfileVersion !== 5 && profile.agentProfileVersion !== 6 && profile.agentProfileVersion !== 7) + || (profile.agentProfileVersion !== 1 && profile.agentProfileVersion !== 2 && profile.agentProfileVersion !== 3 && profile.agentProfileVersion !== 4 && profile.agentProfileVersion !== 5 && profile.agentProfileVersion !== 6 && profile.agentProfileVersion !== 7 && profile.agentProfileVersion !== 8) ) { throw new NativeExecutionInputError("input.provider.profile does not match the qualified ACPX v1 profile"); } diff --git a/packages/paperclip-runner/src/drivers/acpx/capability-profiles.ts b/packages/paperclip-runner/src/drivers/acpx/capability-profiles.ts index 18f9ad57e8d..45ed92775fb 100644 --- a/packages/paperclip-runner/src/drivers/acpx/capability-profiles.ts +++ b/packages/paperclip-runner/src/drivers/acpx/capability-profiles.ts @@ -56,7 +56,7 @@ export const ACPX_CAPABILITY_PROFILES: Readonly { + it("keeps Pi retry-only activity as a rich notice without inventing a final answer", async () => { + const fixture = driverFixture({ agent: "pi", model: "openrouter/deepseek/deepseek-v4-flash-0731", providerPolicy: { readOnly: true } }, { runtimeEvents: [] }); + const session = await fixture.driver.openSession({ runId: "run-pi-notice", normalizedSessionId: "session-1", workingDirectory: "/workspace" }); + await session.startTurn({ message: { text: "Work" } }); + const input = fixture.host.startTurn.mock.calls[0]![0]; + await input.onExtensionNotification!("paperclip/pi_notice", { sessionId: "backend-1", category: "auto_retry_end", summary: "Retry did not recover.", severity: "warning", details: { attempt: 2, success: false } }); + fixture.finishTurn({ status: "completed" }); + const events = await collectUntil(session.events(), "turn.completed"); + expect(events.find(event => event.eventType === "provider.notice.recorded")?.payload).toMatchObject({ severity: "warning", category: "pi.auto_retry_end", scope: "session" }); + expect(events.some(event => event.eventType === "item.completed" && event.payload.kind === "agentMessage")).toBe(false); + await session.close({ reason: "notice-only verified" }); + }); + it.each(["exact-final", "tool-only", "empty-final", "missing-end", "cancelled"])("Pi native boundaries preserve progress without stale final attribution: %s", async scenario => { + const chunk = (n: number, kind: string, text = "", stream: "output" | "thought" = "output"): AcpRuntimeEvent => ({ + type: "text_delta", text, stream, messageId: `pi-message-${String(n).padStart(64, "0")}`, + meta: { origin: "pi-native-assistant", source: "pi-rpc-message-v1", kind }, + }); + const runtimeEvents = [chunk(1, "start"), chunk(1, "delta", "Calling finish."), chunk(1, "end:toolUse")]; + if (scenario !== "tool-only") { + runtimeEvents.push(chunk(2, "start"), chunk(2, "delta", "private thought", "thought")); + if (scenario !== "empty-final") runtimeEvents.push(chunk(2, "delta", "EXACT_MARKER")); + if (scenario !== "missing-end" && scenario !== "cancelled") runtimeEvents.push(chunk(2, "end:stop")); + } + const fixture = driverFixture({ agent: "pi", model: "openrouter/deepseek/deepseek-v4-flash-0731", providerPolicy: { readOnly: true } }, { runtimeEvents }); + const session = await fixture.driver.openSession({ runId: "run-pi-boundaries", normalizedSessionId: "session-1", workingDirectory: "/workspace" }); + await session.startTurn({ message: { text: "Answer exactly" } }); + fixture.finishTurn({ status: scenario === "cancelled" ? "cancelled" : "completed" }); + const events = await collectUntil(session.events(), scenario === "missing-end" ? "turn.failed" : scenario === "cancelled" ? "turn.interrupted" : "turn.completed"); + expect(events.some(event => event.eventType === "item.delta" && event.payload.text === "Calling finish.")).toBe(true); + const final = events.filter(event => event.eventType === "item.completed" && event.payload.kind === "agentMessage"); + expect(final.map(event => event.payload.text)).toEqual(scenario === "exact-final" ? ["EXACT_MARKER"] : []); + await session.close({ reason: "native attribution verified" }); + }); + it("delivers negotiated steering and follow-up distinctly, once, for the active turn", async () => { - const fixture = driverFixture({ agent: "pi", model: "openrouter/deepseek/deepseek-v4-flash-0731", providerPolicy: { readOnly: true } }); + const fixture = driverFixture({ agent: "pi", model: "openrouter/deepseek/deepseek-v4-flash-0731", providerPolicy: { readOnly: true } }, { runtimeEvents: [] }); fixture.host.steeringCapability.mockReturnValue({ steering: true, queuedFollowUp: true }); const session = await fixture.driver.openSession({ runId: "run-controls", normalizedSessionId: "session-1", workingDirectory: "/workspace" }); expect(fixture.hostOptions?.providerPolicy).toEqual({ readOnly: true }); @@ -33,7 +67,6 @@ describe("Codex ACPX harness driver", () => { const turnInput = fixture.host.startTurn.mock.calls[0]![0]; const context = { requestId: 0, signal: new AbortController().signal }; await expect(turnInput.onExtensionRequest!("provider/request", { sessionId: "agent-1" }, context)).rejects.toThrow(/session mismatch/); - await expect(turnInput.onExtensionRequest!("provider/request", { sessionId: "backend-1" }, context)).rejects.toThrow(/adapter is unavailable/); await session.steer!({ turnId, correlationId: "control-1", message: { text: "Change focus" } }); await session.steer!({ turnId, correlationId: "control-2", mode: "follow_up", message: { text: "Then validate" } }); expect(fixture.host.steerActiveTurn).toHaveBeenCalledExactlyOnceWith("Change focus", `run-controls:${turnId}`); @@ -50,7 +83,7 @@ describe("Codex ACPX harness driver", () => { }); it("rejects unnegotiated controls and retains ambiguous delivery attempts", async () => { - const fixture = driverFixture({ agent: "pi", model: "openrouter/deepseek/deepseek-v4-flash-0731", providerPolicy: { readOnly: true } }); + const fixture = driverFixture({ agent: "pi", model: "openrouter/deepseek/deepseek-v4-flash-0731", providerPolicy: { readOnly: true } }, { runtimeEvents: [] }); const session = await fixture.driver.openSession({ runId: "run-controls", normalizedSessionId: "session-1", workingDirectory: "/workspace" }); const { turnId } = await session.startTurn({ message: { text: "Work" } }); await expect(session.steer!({ turnId, message: { text: "No handshake" } })).rejects.toThrow(/did not negotiate/); diff --git a/packages/paperclip-runner/src/drivers/acpx/codex-acpx-driver.ts b/packages/paperclip-runner/src/drivers/acpx/codex-acpx-driver.ts index 9dbb9e53558..ca0b2967088 100644 --- a/packages/paperclip-runner/src/drivers/acpx/codex-acpx-driver.ts +++ b/packages/paperclip-runner/src/drivers/acpx/codex-acpx-driver.ts @@ -1,5 +1,6 @@ import { createCopilotToolEvidence, type CopilotToolEvidence } from "./copilot-tool-evidence.js"; import { requireAcpxResponseDelivery } from "./response-delivery.js"; +import { createPiMessageProjection, piBoundaryClearsFinal, type PiProjectedMessageEvent } from "./pi-message-projection.js"; import { acpxProfileClientCapabilities, bindAcpxExtensionTurn, validateAcpxRichEvent, createAcpxProfileExtensionAdapter, type AcpxExtensionInput } from "./profile-extensions.js"; import { createHash, randomBytes } from "node:crypto"; @@ -1442,13 +1443,15 @@ class CodexAcpxSession implements HarnessSession { let index = 0; const normalizeToolEvent = createAcpxToolEventNormalizer(); - const normalizeMessage = this.#agent === "grok" + const piMessages = this.#agent === "pi" ? createPiMessageProjection() : null; + const normalizeMessage = piMessages ? piMessages.normalize : this.#agent === "grok" ? createGrokMessageNormalizer() : (event: AcpRuntimeEvent) => event; for await (const event of turn.events) { toolEvidence?.tool(event); this.#mapRuntimeEvent(normalizeMessage(normalizeToolEvent(event)), turnId, ++index); } const result = await turn.result; + if (result.status === "completed") piMessages?.settle(); await drainExtensions(); const receipt = persistedAcpxTurnUsage(usageBefore, await readUsageStatus(this.#host), turn.requestId, this.#agent); if (receipt) { @@ -1628,7 +1631,7 @@ class CodexAcpxSession implements HarnessSession { } #mapRuntimeEvent( - event: AcpRuntimeEvent, + event: PiProjectedMessageEvent, turnId: string, index: number, ): void { @@ -1637,7 +1640,8 @@ class CodexAcpxSession implements HarnessSession { const output = boundedText(event.text, 64 * 1024); const isReasoning = event.stream === "thought" || event.tag === "agent_thought_chunk"; - if (!isReasoning) { + if (!isReasoning && !event.piMessageHistory) { + if (piBoundaryClearsFinal(event)) this.#assistantText = ""; const messageId = typeof event.messageId === "string" && event.messageId ? event.messageId : null; if (messageId && this.#assistantMessageId && messageId !== this.#assistantMessageId) this.#assistantText = ""; if (messageId) this.#assistantMessageId = messageId; diff --git a/packages/paperclip-runner/src/drivers/acpx/pi-acp-runtime.test.ts b/packages/paperclip-runner/src/drivers/acpx/pi-acp-runtime.test.ts index 56945707e05..ff9c93e0e4e 100644 --- a/packages/paperclip-runner/src/drivers/acpx/pi-acp-runtime.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/pi-acp-runtime.test.ts @@ -3,7 +3,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { normalizeAcpFormElicitation } from "./acp-question-adapter.js"; -import { createPiLaunchSpec, PiToolIdentities, PiRpcFrames, PiTurnUsage, PiUiBridge } from "./pi-acp-runtime.js"; +import { createPiLaunchSpec, PiAssistantMessages, piAssistantChunk, PiToolIdentities, PiRpcFrames, PiTurnUsage, PiUiBridge } from "./pi-acp-runtime.js"; const temporary: string[] = []; afterEach(async () => { vi.useRealTimers(); for (const path of temporary.splice(0)) await rm(path, { recursive: true, force: true }); }); @@ -13,6 +13,56 @@ function fixture() { return { connection, process, bridge: new PiUiBridge("session-a", connection, process) }; } +describe("Pi native assistant boundaries", () => { + const namespace = "00000000-0000-4000-8000-000000000000"; + const message = (timestamp = 1, stopReason = "stop") => ({ role: "assistant", timestamp, content: [], stopReason }); + it("assigns actual message occurrences across warm prompts and preserves empty boundaries", () => { + const messages = new PiAssistantMessages(namespace); const ids: unknown[] = []; + for (const reason of ["toolUse", "stop", "length", "aborted", "error"]) { + const start = messages.normalize({ type: "message_start", message: message() }); + const delta = messages.normalize({ type: "message_update", message: message(), assistantMessageEvent: { type: "thinking_delta", delta: "thought" } }); + const end = messages.normalize({ type: "message_end", message: message(1, reason) }); + const chunk = piAssistantChunk(start.paperclipAssistantMessage); + ids.push(chunk.messageId); + expect(chunk).toMatchObject({ content: { text: "" }, _meta: { kind: "start" } }); + expect(piAssistantChunk(delta.paperclipAssistantMessage, "thought", true)).toMatchObject({ messageId: chunk.messageId, sessionUpdate: "agent_thought_chunk" }); + expect(piAssistantChunk(end.paperclipAssistantMessage)).toMatchObject({ messageId: chunk.messageId, content: { text: "" }, _meta: { kind: `end:${reason}` } }); + messages.normalize({ type: "agent_settled" }); + } + expect(new Set(ids).size).toBe(5); + const loaded = new PiAssistantMessages("00000000-0000-4000-8000-000000000001"); + expect(piAssistantChunk(loaded.normalize({ type: "message_start", message: message() }).paperclipAssistantMessage).messageId).not.toBe(ids[0]); + }); + it.each(["message_update", "message_end"])("rejects missing starts for %s and poisons subsequent input", type => { + const messages = new PiAssistantMessages(namespace); + expect(() => messages.normalize({ type, message: message() })).toThrow("boundary"); + expect(() => messages.normalize({ type: "message_start", message: message() })).toThrow("boundary"); + }); + it.each([ + { type: "message_start", message: message() }, + { type: "message_end", message: message(2) }, + { type: "message_end", message: message(1, "invented") }, + { type: "message_update", message: { role: "assistant", content: [] } }, + { type: "message_start", message: { role: "toolResult" } }, + { type: "agent_settled" }, + ])("rejects duplicate, reordered, malformed or missing ends: $type", event => { + const messages = new PiAssistantMessages(namespace); + messages.normalize({ type: "message_start", message: message() }); + expect(() => messages.normalize(event)).toThrow("boundary"); + }); + it("does not mint assistant identities for history, tools, retries or compaction", () => { + const messages = new PiAssistantMessages(namespace); + for (const event of [ + { type: "message_start", message: { role: "user" } }, + { type: "message_end", message: { role: "toolResult" } }, + { type: "auto_retry_end" }, { type: "compaction_end" }, { type: "agent_settled" }, + ]) expect(messages.normalize(event)).toEqual(event); + expect(() => piAssistantChunk({ messageId: "invented", phase: "start" })).toThrow("provenance"); + const start = messages.normalize({ type: "message_start", message: message() }); + expect(() => piAssistantChunk(start.paperclipAssistantMessage, "synthetic")).toThrow("synthetic"); + }); +}); + describe("Pi occurrence identity", () => { const namespace = "00000000-0000-4000-8000-000000000000"; it("aligns independent wrapper and extension counters without resetting warm prompts", () => { 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 7c730e10c19..698b0ef233c 100644 --- a/packages/paperclip-runner/src/drivers/acpx/pi-acp-runtime.ts +++ b/packages/paperclip-runner/src/drivers/acpx/pi-acp-runtime.ts @@ -27,6 +27,80 @@ export const PI_ACP_FEATURES = Object.freeze({ pendingRequestRecovery: "live-process-only", }); +export interface PiAssistantBoundary { + messageId: string; + phase: "start" | "delta" | "end"; + stopReason?: "stop" | "length" | "toolUse" | "error" | "aborted"; +} + +/** 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 { + private ordinal = 0; + private active: { messageId: string; timestamp: number } | null = null; + private poisoned = false; + private readonly namespace: string; + constructor(namespace: string) { + this.namespace = namespace; + if (!/^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/.test(namespace)) throw new Error("Pi assistant namespace is invalid"); + } + private fail(): never { this.poisoned = true; throw new Error("Pi native assistant message boundary is invalid"); } + normalize(event: RecordValue): RecordValue { + if (this.poisoned) return this.fail(); + if (event.type === "agent_settled") { + if (this.active) return this.fail(); + return event; + } + if (!["message_start", "message_update", "message_end"].includes(String(event.type))) return event; + const message = event.message; + if (!message || typeof message !== "object" || Array.isArray(message)) return this.fail(); + const native = message as RecordValue; + if (native.role !== "assistant") { + if (event.type === "message_update" || this.active || !["user", "toolResult"].includes(String(native.role))) return this.fail(); + return event; + } + if (!Number.isSafeInteger(native.timestamp) || (native.timestamp as number) < 0 || !Array.isArray(native.content)) return this.fail(); + let boundary: PiAssistantBoundary; + if (event.type === "message_start") { + if (this.active || this.ordinal >= Number.MAX_SAFE_INTEGER) return this.fail(); + const messageId = `pi-message-${createHash("sha256").update(JSON.stringify([this.namespace, ++this.ordinal])).digest("hex")}`; + this.active = { messageId, timestamp: native.timestamp as number }; + boundary = { messageId, phase: "start" }; + } else { + if (!this.active || this.active.timestamp !== native.timestamp) return this.fail(); + boundary = { messageId: this.active.messageId, phase: event.type === "message_end" ? "end" : "delta" }; + if (event.type === "message_end") { + if (!["stop", "length", "toolUse", "error", "aborted"].includes(String(native.stopReason))) return this.fail(); + boundary.stopReason = native.stopReason as PiAssistantBoundary["stopReason"]; + this.active = null; + } + } + return { ...event, paperclipAssistantMessage: boundary }; + } +} + +export function piAssistantChunk(boundaryValue: unknown, textValue: unknown = "", thought = false): RecordValue { + const boundary = record(boundaryValue); + const messageId = text(boundary.messageId, 96); + if (!/^pi-message-[a-f0-9]{64}$/.test(messageId) || !["start", "delta", "end"].includes(String(boundary.phase))) throw new Error("Pi assistant chunk lacks native provenance"); + if (boundary.phase === "end" && !["stop", "length", "toolUse", "error", "aborted"].includes(String(boundary.stopReason))) throw new Error("Pi assistant chunk end is invalid"); + const content = text(textValue, 262_144); + if (boundary.phase !== "delta" && (content || thought)) throw new Error("Pi assistant boundary cannot carry synthetic content"); + return { sessionUpdate: thought ? "agent_thought_chunk" : "agent_message_chunk", messageId, + content: { type: "text", text: content }, + _meta: { origin: "pi-native-assistant", source: "pi-rpc-message-v1", kind: boundary.phase === "end" ? `end:${boundary.stopReason}` : boundary.phase }, + }; +} + +export function piHistoricalAssistantChunk(sessionId: string, messageIndex: number, value: string): RecordValue { + if (!sessionId || !Number.isSafeInteger(messageIndex) || messageIndex < 0) throw new Error("Pi historical assistant identity is invalid"); + return { sessionUpdate: "agent_message_chunk", + messageId: `pi-history-message-${createHash("sha256").update(JSON.stringify([text(sessionId, 1024), messageIndex])).digest("hex")}`, + content: { type: "text", text: text(value, 262_144) }, + _meta: { origin: "pi-history-assistant", source: "pi-session-history-v1", kind: "history" }, + }; +} + /** One trusted Pi model iteration, not one ACP prompt. Pi resets its own turnIndex * on warm prompts; our ordinal is monotonic for the lifetime of the child. */ export class PiToolIdentities { 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 3e91ab7554d..8081e83faa0 100644 --- a/packages/paperclip-runner/src/drivers/acpx/pi-closure-pins.ts +++ b/packages/paperclip-runner/src/drivers/acpx/pi-closure-pins.ts @@ -6,7 +6,7 @@ * three pins. Never accept a digest supplied only by an installed manifest. */ export const PI_DISTRIBUTION_CLOSURE_SHA256 = Object.freeze({ - "darwin-arm64": "3703361965d4e0b641fcd5eac4079e9b6c0de47b4b245a5b31b7da7b52c94128", - "darwin-x64": "885d9fde187bd1a877838ac309aa68df4b928e0727ca341b3b8f3b1e7467ad12", - "linux-x64": "425afafe8a1419c86333a6eaa93bf56f0e78a4dc919dcef491f13e451550c5e2", + "darwin-arm64": "0187153354338cf4919d213dd2977e1be1073ad3941c7b9b5c8a09c5b32828e4", + "darwin-x64": "22c593576e78edb27bbe6234aabd31a7d65f6be7503683f568d0730ebe6c75a0", + "linux-x64": "016ce3653bdfc1c7269e3bf7d4bf26e48b0d0b7d409fd153f3f1b882e986654d", }); diff --git a/packages/paperclip-runner/src/drivers/acpx/pi-extension-adapter.test.ts b/packages/paperclip-runner/src/drivers/acpx/pi-extension-adapter.test.ts new file mode 100644 index 00000000000..3244bbdfe31 --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/pi-extension-adapter.test.ts @@ -0,0 +1,22 @@ +import { expect, it } from "vitest"; +import { createPiProfileExtensionAdapter, PI_NOTICE_METHOD } from "./pi-extension-adapter.js"; +import { validateAcpxRichEvent } from "./profile-extensions.js"; +const context = { workspacePath: "/fixture", sessionId: "session", turnId: "turn" }; +it("preserves bounded native notice severity, category and meaningful metadata without assistant output", async () => { + const adapter = createPiProfileExtensionAdapter(context); + for (const severity of ["info", "warning", "error"]) { + const events = await adapter.notification(PI_NOTICE_METHOD, { sessionId: "session", category: "auto_retry_start", severity, + summary: "Retry scheduled", details: { attempt: 2, maxAttempts: 3, delayMs: 200, errorMessage: "Authorization: Bearer fixture-private-token", secret: "DROP_ME" } }); + expect(events).toHaveLength(1); validateAcpxRichEvent(events[0]!); + expect(events[0]).toMatchObject({ eventType: "provider.notice.recorded", payload: { severity, category: "pi.auto_retry_start", scope: "session" } }); + expect(events[0]!.payload.details).toContainEqual({ name: "attempt", value: "2" }); + expect(JSON.stringify(events)).not.toMatch(/DROP_ME|fixture-private-token/); + } +}); +it("rejects malformed/cross-session notices and cannot answer requests or emit final replies", async () => { + const adapter = createPiProfileExtensionAdapter(context); + for (const changed of [{ sessionId: "other" }, { category: "invented" }, { severity: "fatal" }, { summary: "x".repeat(4001) }]) { + await expect(adapter.notification(PI_NOTICE_METHOD, { sessionId: "session", category: "compaction_end", severity: "info", summary: "Done", ...changed })).rejects.toThrow("contract"); + } + await expect(adapter.request("exec", {})).rejects.toThrow("no qualified"); +}); diff --git a/packages/paperclip-runner/src/drivers/acpx/pi-extension-adapter.ts b/packages/paperclip-runner/src/drivers/acpx/pi-extension-adapter.ts new file mode 100644 index 00000000000..f657dcfb191 --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/pi-extension-adapter.ts @@ -0,0 +1,38 @@ +import { createHash } from "node:crypto"; +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 safeText = (value: string, limit = 4000) => String(redactPaperclipSemanticValue(value)).slice(0, limit); + +/** Notices are display-only session evidence. They carry neither assistant + * content nor authority to complete a turn, execute tools, or grant approval. */ +export function createPiProfileExtensionAdapter(context: AcpxProfileExtensionContext): AcpxProfileExtensionAdapter { + let sequence = 0; + return { + async request() { throw new Error("Pi has no qualified inbound extension request"); }, + async notification(method, params) { + if (method !== PI_NOTICE_METHOD) return []; + if (params.sessionId !== context.sessionId || typeof params.category !== "string" || !CATEGORIES.has(params.category) + || typeof params.summary !== "string" || params.summary.length > 4000 + || !["info", "warning", "error"].includes(String(params.severity)) || Buffer.byteLength(JSON.stringify(params)) > 32 * 1024) { + throw new Error("Pi native notice is outside its pinned contract"); + } + const details = [{ name: "source.method", value: PI_NOTICE_METHOD }, { name: "source.nativeEvent", value: params.category }, + { name: "source.sessionId", value: safeText(context.sessionId) }]; + if (params.details && typeof params.details === "object" && !Array.isArray(params.details)) { + const native = params.details as Record; + for (const key of ["attempt", "maxAttempts", "delayMs"]) if (Number.isSafeInteger(native[key]) && (native[key] as number) >= 0) details.push({ name: key, value: String(native[key]) }); + for (const key of ["success", "aborted", "willRetry", "enabled"]) if (typeof native[key] === "boolean") details.push({ name: key, value: String(native[key]) }); + for (const key of ["reason", "errorMessage"]) if (typeof native[key] === "string") details.push({ name: key, value: safeText(native[key] as string) }); + } + const noticeId = `pi-notice-${createHash("sha256").update(JSON.stringify([context.sessionId, context.turnId, ++sequence])).digest("hex")}`; + return [{ eventType: "provider.notice.recorded", itemId: noticeId, payload: { + schema: "paperclip.provider.notice.v1", noticeId, severity: params.severity, + category: `pi.${params.category}`, scope: "session", recoverable: params.severity !== "error", userActionable: false, + summary: safeText(params.summary), details, + } }]; + }, + }; +} 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 9450ca61579..6a29bfc552e 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: 7 }); +const candidate = (): QualifiedAcpxProfile => ({ ...QUALIFIED_ACPX_PROFILES.pi, agentProfileVersion: 8 }); 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-v7-identity.json", import.meta.url), "utf8")); + const identity = JSON.parse(await readFile(new URL("../../../test-fixtures/pi-acp/profile-v8-identity.json", import.meta.url), "utf8")); const declaration = identity.declaration; expect(identity.commandDigest).toBe(`sha256:${hash(canonical(declaration))}`); expect(QUALIFIED_ACPX_PROFILES.pi.commandDigest).toBe(identity.commandDigest); @@ -39,6 +39,9 @@ describe("Pi installation factory", () => { } expect(declaration.closure).toEqual(PI_DISTRIBUTION_CLOSURE_SHA256); expect(declaration.nodeVersion).toBe(PI_NODE_VERSION); + expect(hash(await readFile(new URL("../../../../../patches/acpx@0.13.1.patch", import.meta.url)))).toBe(declaration.acpxPatchSha256); + expect(hash(await readFile(new URL("./pi-message-projection.ts", import.meta.url)))).toBe(declaration.messageProjectionSha256); + expect(hash(await readFile(new URL("./pi-extension-adapter.ts", import.meta.url)))).toBe(declaration.noticeProjectionSha256); const helper = stripTypeScriptTypes(await readFile(new URL("./pi-acp-runtime.ts", import.meta.url), "utf8")).split("\n").map(line => line.trimEnd()).join("\n"); const extension = stripTypeScriptTypes(await readFile(new URL("./pi-runtime-extension.ts", import.meta.url), "utf8")).replace('from "./pi-acp-runtime.js"', 'from "../node_modules/pi-acp/dist/paperclip-runtime.js"'); expect(hash(helper)).toBe(declaration.helperSha256); @@ -46,19 +49,20 @@ 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 v7 wrapper/closure. + // This exact patch produced the independently verified v8 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("4f316d89e63ff47854f93faf5805420cd8b21e730b46a025406db87df86fa5fa"); + expect(hash(await readFile(new URL("../../../../../patches/pi-acp@0.0.33.patch", import.meta.url)))).toBe("821896c48f23f0a1a5c5856d9d3b87c905e261b28c3ed8e1bbd3f7242ec48b22"); }); it("rejects legacy profiles and caller-selected identities", () => { expect(() => assertPiInstallationProfile(candidate())).not.toThrow(); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 1 })).toThrow("version 7"); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 2 })).toThrow("version 7"); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 3 })).toThrow("version 7"); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 4 })).toThrow("version 7"); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 5 })).toThrow("version 7"); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 6 })).toThrow("version 7"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 1 })).toThrow("version 8"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 2 })).toThrow("version 8"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 3 })).toThrow("version 8"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 4 })).toThrow("version 8"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 5 })).toThrow("version 8"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 6 })).toThrow("version 8"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 7 })).toThrow("version 8"); 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 8b675eea8a0..24167a3afab 100644 --- a/packages/paperclip-runner/src/drivers/acpx/pi-installation.ts +++ b/packages/paperclip-runner/src/drivers/acpx/pi-installation.ts @@ -24,7 +24,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 !== 7) throw new Error("Pi rich ACP requires profile version 7; reopen the previous session"); + if (profile.agentProfileVersion !== 8) throw new Error("Pi rich ACP requires profile version 8; 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 !== "0.84.2" || 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-message-projection.test.ts b/packages/paperclip-runner/src/drivers/acpx/pi-message-projection.test.ts new file mode 100644 index 00000000000..c4a273778a8 --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/pi-message-projection.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; +import { createPiMessageProjection, piBoundaryClearsFinal } from "./pi-message-projection.js"; + +const id = (n: number) => `pi-message-${String(n).padStart(64, "0")}`; +const chunk = (n: number, kind: string, text = "", stream = "output") => ({ type: "text_delta", messageId: id(n), text, stream, + meta: { origin: "pi-native-assistant", source: "pi-rpc-message-v1", kind } }); +function project(events: ReturnType[]) { + const projection = createPiMessageProjection>(); + const progress: string[] = []; let final = ""; + for (const raw of events) { + const event = projection.normalize(raw); + if (piBoundaryClearsFinal(event)) final = ""; + if (event.stream === "output") { final += event.text; progress.push(event.text); } + } + projection.settle(); return { final, progress }; +} +describe("Pi native message projection", () => { + it("retains pre-tool progress and thoughts while finalizing only the exact final native message", () => { + const result = project([chunk(1, "start"), chunk(1, "delta", "Calling finish."), chunk(1, "end:toolUse"), + chunk(2, "start"), chunk(2, "delta", "private thought", "thought"), chunk(2, "delta", "EXACT_"), chunk(2, "delta", "MARKER"), chunk(2, "end:stop")]); + expect(result.final).toBe("EXACT_MARKER"); expect(result.progress).toContain("Calling finish."); + expect(result.progress).not.toContain("private thought"); + }); + it.each(["toolUse", "error", "aborted"])("does not finalize stale narration after native %s", reason => { + expect(project([chunk(1, "start"), chunk(1, "delta", "Not a final answer"), chunk(1, `end:${reason}`)]).final).toBe(""); + }); + it("empty final messages clear earlier output and notice-only turns produce no answer", () => { + expect(project([chunk(1, "start"), chunk(1, "delta", "old"), chunk(1, "end:stop"), chunk(2, "start"), chunk(2, "end:stop")]).final).toBe(""); + const projection = createPiMessageProjection<{ type: string }>(); + expect(projection.normalize({ type: "status" })).toEqual({ type: "status" }); projection.settle(); + }); + it.each([ + [chunk(1, "delta", "missing start")], [chunk(1, "end:stop")], [chunk(1, "start"), chunk(1, "start")], + [chunk(1, "start"), chunk(2, "delta", "wrong id")], [chunk(1, "start"), chunk(1, "end:invented")], + [chunk(1, "start"), chunk(1, "end:stop"), chunk(1, "start")], [chunk(1, "start", "invented content")], + [chunk(1, "start", "", "thought")], [chunk(1, "start")], + [{ ...chunk(1, "start"), meta: { origin: "forged", source: "pi-rpc-message-v1", kind: "start" } }], + ])("fails closed for incomplete/duplicate/malformed provenance %#", (...events) => { + expect(() => project(events)).toThrow("provenance"); + }); +}); diff --git a/packages/paperclip-runner/src/drivers/acpx/pi-message-projection.ts b/packages/paperclip-runner/src/drivers/acpx/pi-message-projection.ts new file mode 100644 index 00000000000..7c3a5bab902 --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/pi-message-projection.ts @@ -0,0 +1,48 @@ +type RuntimeMessageEvent = { type: string; text?: string; stream?: string; messageId?: string; meta?: Record }; +export interface PiMessageBoundary { phase: "start" | "end"; stopReason?: "stop" | "length" | "toolUse" | "error" | "aborted" } +export type PiProjectedMessageEvent = T & { piMessageBoundary?: PiMessageBoundary; piMessageHistory?: true }; + +/** Revalidate the pinned wrapper's actual SDK provenance. No tool event or + * message text can be used to infer a missing assistant-message boundary. */ +export function createPiMessageProjection() { + let active: string | null = null; + const seen = new Set(); + let poisoned = false; + const fail = (): never => { poisoned = true; throw new Error("Pi native message provenance is invalid or incomplete"); }; + return { + normalize(event: T): PiProjectedMessageEvent { + if (poisoned) return fail(); + if (event.type !== "text_delta") return event; + const { messageId, meta } = event; + if (meta?.origin === "pi-history-assistant") { + if (active || seen.size || meta.source !== "pi-session-history-v1" || meta.kind !== "history" + || typeof messageId !== "string" || !/^pi-history-message-[a-f0-9]{64}$/.test(messageId) + || typeof event.text !== "string" || event.stream !== "output") return fail(); + return { ...event, piMessageHistory: true }; + } + if (typeof messageId !== "string" || !/^pi-message-[a-f0-9]{64}$/.test(messageId) + || meta?.origin !== "pi-native-assistant" || meta.source !== "pi-rpc-message-v1" || typeof event.text !== "string") return fail(); + if (meta.kind === "delta") { + if (active !== messageId) return fail(); + return event; + } + if (event.text !== "" || event.stream !== "output") return fail(); + if (meta.kind === "start") { + if (active || seen.has(messageId) || seen.size >= 4096) return fail(); + seen.add(messageId); active = messageId; + return { ...event, piMessageBoundary: { phase: "start" } }; + } + if (active !== messageId || typeof meta.kind !== "string" || !/^end:(stop|length|toolUse|error|aborted)$/.test(meta.kind)) return fail(); + active = null; + return { ...event, piMessageBoundary: { phase: "end", stopReason: meta.kind.slice(4) as PiMessageBoundary["stopReason"] } }; + }, + settle(): void { if (poisoned || active) fail(); }, + }; +} + +/** A completed tool-use/error/abort message is still durable progress, but it + * cannot become an assistant final reply if the native loop ends there. */ +export function piBoundaryClearsFinal(event: { piMessageBoundary?: PiMessageBoundary }): boolean { + const boundary = event.piMessageBoundary; + return boundary?.phase === "start" || (boundary?.phase === "end" && !["stop", "length"].includes(boundary.stopReason ?? "")); +} diff --git a/packages/paperclip-runner/src/drivers/acpx/profile-extensions.ts b/packages/paperclip-runner/src/drivers/acpx/profile-extensions.ts index 56924eeffc4..cce795037af 100644 --- a/packages/paperclip-runner/src/drivers/acpx/profile-extensions.ts +++ b/packages/paperclip-runner/src/drivers/acpx/profile-extensions.ts @@ -1,4 +1,5 @@ import { createCopilotProfileExtensionAdapter } from "./copilot-extension-adapter.js"; +import { createPiProfileExtensionAdapter } from "./pi-extension-adapter.js"; import { COPILOT_ACP_CLIENT_CAPABILITIES } from "./copilot-events.js"; import { createCursorProfileExtensionAdapter, CURSOR_CLIENT_CAPABILITIES } from "./cursor-extensions.js"; import type { HarnessRuntimeRequestResolution } from "../../contracts/harness-driver.js"; @@ -41,6 +42,7 @@ export function createAcpxProfileExtensionAdapter( ): AcpxProfileExtensionAdapter | null { if (agent === "cursor") return createCursorProfileExtensionAdapter(context); if (agent === "copilot") return createCopilotProfileExtensionAdapter(context); + if (agent === "pi") return createPiProfileExtensionAdapter(context); return null; } export function acpxProfileClientCapabilities(agent: QualifiedAcpxAgent): Record { diff --git a/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.ts b/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.ts index 858e97df138..ec9afe73652 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: 7, + agentProfileVersion: 8, qualificationStatus: "pending", agentServerPackage: "pi-acp", agentServerVersion: "0.0.33", agentRuntimePackage: "@earendil-works/pi-coding-agent", agentRuntimeVersion: "0.84.2", commandDigest: - "sha256:fec5b1448f4135710887133a9192747c476a825a56c255b52b17b1780ccb3761", + "sha256:843d30e419914529755c9827d9151a306da1b50b643be7eab1abe797641a37ce", 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 bcb67a4e98e..b034a1bebbe 100644 --- a/packages/paperclip-runner/test-fixtures/pi-acp/fake-pi.mjs +++ b/packages/paperclip-runner/test-fixtures/pi-acp/fake-pi.mjs @@ -7,17 +7,26 @@ const home = process.env.PI_CODING_AGENT_DIR; mkdirSync(join(home, "sessions"), { recursive: true }); const sessionFile = join(home, "sessions", "fixture.jsonl"); writeFileSync(sessionFile, JSON.stringify({ type: "session", id: "fixture-session", cwd: process.cwd() }) + "\n"); -const output = (value) => process.stdout.write(JSON.stringify(value) + "\n"); +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"); +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; +}; let active = false; let modelIteration = 0; const invocationNamespace = JSON.parse(process.env.PAPERCLIP_PI_RUNTIME_CONFIGURATION).invocationNamespace; const toolIdentity = (id) => `pi-${createHash("sha256").update(JSON.stringify([invocationNamespace, modelIteration, id])).digest("hex")}`; -const begin = () => { modelIteration++; output({ type: "turn_start" }); }; +const begin = () => { modelIteration++; output({ type: "turn_start" }); + messageTimestamp++; assistantActive = true; output({ type: "message_start", message: nativeMessage() }); +}; let model = "fixture-model"; const compaction = () => ({ firstKeptEntryId: "fixture-entry", tokensBefore: 50, summary: "Fixture compacted", usage: { input: 5, output: 2, cacheRead: 1, cacheWrite: 0, cost: { total: 0.02 } } }); const finish = (answer = "done") => { + if (!assistantActive) { output({ type: "turn_end" }); begin(); } output({ type: "message_update", assistantMessageEvent: { type: "text_delta", delta: answer } }); - output({ type: "message_end", message: { role: "assistant", timestamp: Date.now(), stopReason: "stop", usage: { input: 11, output: 3, cacheRead: 2, cacheWrite: 0, cost: { total: 0.01 } } } }); + endMessage(); output({ type: "turn_end" }); output({ type: "agent_end" }); // An agent_end alone must not settle the ACP prompt. @@ -33,19 +42,35 @@ createInterface({ input: process.stdin }).on("line", (line) => { if (request.type === "get_messages") { response({ messages: process.env.PI_FIXTURE_HISTORY ? [ { role: "toolResult", toolName: "mcp__paperclip__paperclip_finish", toolCallId: "call_0", content: [{ type: "text", text: "correct criteria" }], isError: true }, { role: "toolResult", toolName: "mcp__paperclip__paperclip_finish", toolCallId: "call_0", content: [{ type: "text", text: "accepted" }] }, + { role: "assistant", content: [{ type: "text", text: "Historical reply" }] }, ] : [] }); return; } if (request.type === "get_commands") { response({ commands: process.env.PI_FIXTURE_EXTENSION_FAIL ? [] : [{ name: "paperclip-runtime-ready-v1", description: "Paperclip runtime gate v1", source: "extension" }] }); return; } if (request.type === "steer") { response(); output({ type: "message_update", assistantMessageEvent: { type: "text_delta", delta: `steered:${request.message}` } }); return; } if (request.type === "follow_up") { response(); finish(`follow-up:${request.message}`); return; } if (request.type === "compact") { output({ type: "compaction_start", reason: "manual" }); const result = compaction(); output({ type: "compaction_end", reason: "manual", result, aborted: false, willRetry: false }); response(result); return; } - if (request.type === "abort") { response(); if (active) { active = false; output({ type: "turn_end" }); output({ type: "agent_settled" }); } return; } + if (request.type === "abort") { response(); if (active) { active = false; if (assistantActive) endMessage("aborted", {}); output({ type: "turn_end" }); output({ type: "agent_settled" }); } return; } if (request.type === "prompt") { - response({ disposition: "started" }); active = true; output({ type: "agent_start" }); begin(); + response({ disposition: "started" }); active = true; output({ type: "agent_start" }); + if (request.message === "missing-message-start") { modelIteration++; output({ type: "turn_start" }); output({ type: "message_update", assistantMessageEvent: { type: "text_delta", delta: "invalid" } }); return; } + begin(); + if (request.message === "duplicate-message-start") { output({ type: "message_start", message: nativeMessage() }); return; } + if (request.message === "mismatched-message-end") { output({ type: "message_end", message: { ...nativeMessage(), timestamp: messageTimestamp + 1 } }); return; } + if (request.message === "missing-message-end") { output({ type: "agent_settled" }); return; } + if (["narration-final", "tool-only", "empty-final"].includes(request.message)) { + output({ type: "message_update", assistantMessageEvent: { type: "text_delta", delta: "Calling finish." } }); + endMessage("toolUse", {}); + const args = {}; output({ type: "tool_execution_start", toolCallId: "finish", toolName: "paperclip_finish", args }); + output({ type: "tool_execution_end", toolCallId: "finish", toolName: "paperclip_finish", result: { content: [{ type: "text", text: "accepted" }] } }); + if (request.message === "tool-only") { output({ type: "turn_end" }); active = false; output({ type: "agent_settled" }); } + else finish(request.message === "empty-final" ? "" : "EXACT_MARKER"); + return; + } if (request.message === "reused-tool-ids") { for (const [index, toolName] of ["mcp__paperclip__paperclip_finish", "mcp__paperclip__paperclip_finish", "mcp__paperclip__get_task_context"].entries()) { if (index) { output({ type: "turn_end" }); begin(); } const args = { revision: index + 1 }; output({ type: "message_update", assistantMessageEvent: { type: "toolcall_start", contentIndex: 0, partial: { content: [{ type: "toolCall", id: "call_0", name: toolName, arguments: args }] } } }); + endMessage("toolUse", {}); output({ type: "tool_execution_start", toolCallId: "call_0", toolName, args }); output({ type: "tool_execution_update", toolCallId: "call_0", toolName, partialResult: { content: [{ type: "text", text: "working" }] } }); output({ type: "tool_execution_end", toolCallId: "call_0", toolName, result: { content: [{ type: "text", text: index ? "accepted" : "correct criteria" }] }, isError: index === 0 }); @@ -68,7 +93,7 @@ createInterface({ input: process.stdin }).on("line", (line) => { output({ type: "auto_retry_start", attempt: 2, maxAttempts: 2, delayMs: 10, errorMessage: "Fixture provider unavailable" }); output({ type: "auto_retry_end", attempt: 2, ...(request.message === "retry-unknown" ? {} : { success: request.message === "retry-success" }) }); if (request.message === "retry-failure") { - output({ type: "message_end", message: { role: "assistant", timestamp: 2, stopReason: "error", usage: { input: 1, output: 0 } } }); + endMessage("error", { input: 1, output: 0 }); active = false; output({ type: "turn_end" }); output({ type: "agent_settled" }); } else finish("retry outcome received"); return; @@ -82,7 +107,7 @@ createInterface({ input: process.stdin }).on("line", (line) => { } if (request.message === "permission") { output({ type: "extension_ui_request", id: "permission-id", method: "select", title: "paperclip.pi.permission.v1:" + JSON.stringify({toolCallId:toolIdentity("tool-1"),nativeToolCallId:"tool-1",modelIteration,toolName:"bash",input:{command:"pwd"}}), options: ["Allow once", "Allow for this session", "Deny"] }); return; } if (request.message === "failure") { - output({ type: "message_end", message: { role: "assistant", timestamp: 1, stopReason: "error", usage: { input: 1, output: 0 } } }); + endMessage("error", { input: 1, output: 0 }); active = false; output({ type: "turn_end" }); output({ type: "agent_settled" }); return; } output({ type: "message_update", assistantMessageEvent: { type: "thinking_delta", delta: "fixture reasoning" } }); diff --git a/packages/paperclip-runner/test-fixtures/pi-acp/profile-v8-identity.json b/packages/paperclip-runner/test-fixtures/pi-acp/profile-v8-identity.json new file mode 100644 index 00000000000..434a7c0ad83 --- /dev/null +++ b/packages/paperclip-runner/test-fixtures/pi-acp/profile-v8-identity.json @@ -0,0 +1,29 @@ +{ + "commandDigest": "sha256:843d30e419914529755c9827d9151a306da1b50b643be7eab1abe797641a37ce", + "declaration": { + "schema": "paperclip.acpx_profile_declaration.v1", + "agent": "pi", + "agentProfileVersion": 8, + "acpxVersion": "0.13.1", + "agentServerVersion": "0.0.33", + "agentRuntimeVersion": "0.84.2", + "nodeVersion": "24.21.0", + "closure": { + "darwin-arm64": "0187153354338cf4919d213dd2977e1be1073ad3941c7b9b5c8a09c5b32828e4", + "darwin-x64": "22c593576e78edb27bbe6234aabd31a7d65f6be7503683f568d0730ebe6c75a0", + "linux-x64": "016ce3653bdfc1c7269e3bf7d4bf26e48b0d0b7d409fd153f3f1b882e986654d" + }, + "wrapperSha256": "9fd9a685fb79b4f73a8dac08cbe360a6de87ffcb596cdaff1191e3ac57c24f56", + "helperSha256": "a19087c0af8fb3896fe3290280695f8f936574013bc4acde650f11f023f0f03b", + "extensionSha256": "2b74a4a2e6a42def3d9918a46732e0e1b8c618791d2b22970cfb3e9d285e55ad", + "agentFilesBinding": "registered-runtime-context-v1", + "nativeQuestions": "select-confirm-input-editor-v2", + "pathPolicy": "sdk-normalization-v1", + "mcpToolNames": "provider-safe-exact-source-v1", + "acpxPatchSha256": "79aad2d688b03362e8cfcbf7a08f78a8383869f6882a9c9ed66f1d18efb94f2b", + "nativeAssistantMessages": "sdk-boundaries-v1", + "nativeNotices": "session-extension-v1", + "messageProjectionSha256": "17d1e91b948dfa6a56e1905645add52b502041ad843c8d50a13b07956f22aadd", + "noticeProjectionSha256": "c619080ec72a577889c382d44ea9024b10efdb9d83e933c03bfdd8da79ede591" + } +} diff --git a/packages/paperclip-runner/test/acpx-sidecar-contract.test.mjs b/packages/paperclip-runner/test/acpx-sidecar-contract.test.mjs index 770f9ffece4..9213288907e 100644 --- a/packages/paperclip-runner/test/acpx-sidecar-contract.test.mjs +++ b/packages/paperclip-runner/test/acpx-sidecar-contract.test.mjs @@ -135,3 +135,16 @@ test("turn control schema preserves explicit modes and rejects open-ended dispat assert.equal(validate({ ...message, params }), false); } }); + +// Runtime payloads are provider-owned; the sidecar envelope stays closed. +// Pi validates native provenance before creating these boundary/history fields. +test("Pi native empty message boundaries and replay history fit the strict sidecar envelope", () => { + for (const payload of [ + { type: "text_delta", stream: "output", text: "", messageId: "message-1", piMessageBoundary: { phase: "start" } }, + { type: "text_delta", stream: "output", text: "", messageId: "message-1", piMessageBoundary: { phase: "end", stopReason: "toolUse" } }, + { type: "text_delta", stream: "output", text: "Prior assistant reply", messageId: "history-1", piMessageHistory: true }, + ]) { + assert.equal(validate({ ...messages[2], payload }), true, JSON.stringify(validate.errors)); + assert.equal(validate({ ...messages[2], payload, piMessageBoundary: { phase: "start" } }), false); + } +}); 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 bec4c288c68..1465db39da1 100644 --- a/packages/paperclip-runner/test/pi-acp-package-contract.test.mjs +++ b/packages/paperclip-runner/test/pi-acp-package-contract.test.mjs @@ -91,6 +91,30 @@ test("actual patched ACP process streams thinking and waits for settlement with assert.ok(f.notifications.some((event) => event.params?.update?.content?.text === "hello🌒\u2028world")); }); +for (const scenario of ["narration-final", "tool-only", "empty-final"]) test(`actual patched wrapper preserves native message provenance for ${scenario}`, async t => { + const f = await fixture(t); const session = await f.call("session/new", { cwd: join(f.root, "workspace"), mcpServers: [] }); + for (let warm = 0; warm < 2; warm++) { + const from = f.notifications.length; + await f.call("session/prompt", { sessionId: session.sessionId, prompt: [{ type: "text", text: scenario }] }); + const updates = f.notifications.slice(from).map(event => event.params?.update).filter(update => update?.sessionUpdate === "agent_message_chunk"); + assert.ok(updates.every(update => /^pi-message-[a-f0-9]{64}$/.test(update.messageId))); + assert.equal(updates[0]._meta.kind, "start"); assert.equal(updates[0].content.text, ""); + assert.equal(updates[1].content.text, "Calling finish."); + assert.equal(updates[2]._meta.kind, "end:toolUse"); + if (scenario === "tool-only") assert.equal(updates.length, 3); + else { + assert.equal(updates[3]._meta.kind, "start"); assert.notEqual(updates[3].messageId, updates[0].messageId); + assert.equal(updates[4].content.text, scenario === "empty-final" ? "" : "EXACT_MARKER"); + assert.equal(updates[5]._meta.kind, "end:stop"); + } + } +}); + +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/); +}); + test("actual patched ACP process scopes recycled native IDs across iterations and warm prompts", async (t) => { const f = await fixture(t); const session = await f.call("session/new", { cwd: join(f.root, "workspace"), mcpServers: [] }); for (let turn = 0; turn < 2; turn++) await f.call("session/prompt", { sessionId: session.sessionId, prompt: [{ type: "text", text: "reused-tool-ids" }] }); @@ -105,7 +129,7 @@ test("actual patched ACP process scopes recycled native IDs across iterations an assert.equal(lifecycle.filter(update => ["failed", "completed"].includes(update.status)).length, 1); assert.ok(lifecycle.every(update => update._meta.paperclipPi.nativeToolCallId === "call_0")); } - assert.deepEqual(starts.map(update => update._meta.paperclipPi.modelIteration), [1, 2, 3, 4, 5, 6]); + assert.deepEqual(starts.map(update => update._meta.paperclipPi.modelIteration), [1, 2, 3, 5, 6, 7]); }); test("cancellation closes the iteration before a warm prompt reuses native IDs", async (t) => { @@ -130,6 +154,9 @@ test("warm load uses stable display-only history IDs distinct from live executio await f.call("session/load", { sessionId: session.sessionId, cwd: join(f.root, "workspace"), mcpServers: [] }); const starts = f.notifications.map(event => event.params?.update).filter(update => update?.sessionUpdate === "tool_call"); assert.equal(starts.length, 2); + const historicalMessage = f.notifications.map(event => event.params?.update).find(update => update?.content?.text === "Historical reply"); + assert.match(historicalMessage.messageId, /^pi-history-message-[a-f0-9]{64}$/); + assert.deepEqual(historicalMessage._meta, { origin: "pi-history-assistant", source: "pi-session-history-v1", kind: "history" }); assert.notEqual(starts[0].toolCallId, starts[1].toolCallId); for (const start of starts) { assert.match(start.toolCallId, /^pi-history-[a-f0-9]{64}$/); @@ -169,7 +196,7 @@ test("actual patched ACP reports unsupported external UI as an error and stops 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: "oversized-question" }] }), /exited/); assert.equal(f.requests.length, 0); - assert.ok(f.notifications.some(event => event.params?.update?._meta?.paperclipPi?.notice?.message === "Pi structured question is unsupported or invalid; the session was stopped")); + assert.ok(f.notifications.some(event => event.method === "paperclip/pi_notice" && event.params?.summary === "Pi structured question is unsupported or invalid; the session was stopped")); }); test("native steering is explicit and does not replace the active ACP turn", async (t) => { @@ -204,7 +231,8 @@ for (const [outcome, expected] of [ const f = await fixture(t); const session = await f.call("session/new", { cwd: join(f.root, "workspace"), mcpServers: [] }); const result = await f.call("session/prompt", { sessionId: session.sessionId, prompt: [{ type: "text", text: `retry-${outcome}` }] }); - const messages = f.notifications.map((event) => event.params?.update?.content?.text).filter((text) => typeof text === "string"); + const messages = f.notifications.filter(event => event.method === "paperclip/pi_notice").map(event => event.params.summary); + assert.ok(f.notifications.filter(event => event.params?.update?.sessionUpdate === "agent_message_chunk").every(event => !event.params.update.content.text.includes("Retry"))); assert.ok(messages.includes(expected)); if (outcome !== "success") assert.ok(messages.every((text) => !text.includes("resuming"))); if (outcome === "failure") assert.equal(result._meta.jetbrains.air.sessionFailure.severity, "error"); @@ -221,11 +249,11 @@ test("manual and automatic compaction retain Pi 0.84.2 progress and usage", asyn const automatic = await prompt("auto-compact"); assert.equal(automatic.usage.inputTokens, 16); assert.equal(automatic.usage.totalTokens, 24); assert.equal(automatic.usage._meta.paperclipPi.costUsd, 0.03); - assert.ok(f.notifications.some((event) => event.params?.update?.content?.text === "Context compaction finished.")); + assert.ok(f.notifications.some((event) => event.method === "paperclip/pi_notice" && event.params?.summary === "Context compaction finished.")); const retried = await prompt("retry-compact"); assert.equal(retried.usage.inputTokens, undefined); assert.equal(retried.usage._meta.paperclipPi.costUsd, undefined); - assert.ok(f.notifications.some((event) => event.params?.update?.content?.text === "Context summarization is retrying a provider request.")); + assert.ok(f.notifications.some((event) => event.method === "paperclip/pi_notice" && event.params?.summary === "Context summarization is retrying a provider request.")); const active = prompt("long"); await new Promise((resolve) => setTimeout(resolve, 25)); await assert.rejects(prompt("/compact"), /requires an idle session/); f.notify("session/cancel", { sessionId: session.sessionId }); await active; 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 5018fd2fe19..87ed6b0761e 100644 --- a/packages/paperclip-runner/test/pi-native-package-contract.test.mjs +++ b/packages/paperclip-runner/test/pi-native-package-contract.test.mjs @@ -126,9 +126,10 @@ test("real pinned Pi model iterations align owned identities across warm prompts let session; try { await writeFile(join(root, "helper.mjs"), stripTypeScriptTypes(await readFile(new URL("../src/drivers/acpx/pi-acp-runtime.ts", import.meta.url), "utf8"))); - const { PiToolIdentities } = await import(pathToFileURL(join(root, "helper.mjs")).href); + const { PiToolIdentities, PiAssistantMessages, piAssistantChunk } = await import(pathToFileURL(join(root, "helper.mjs")).href); const namespace = "00000000-0000-4000-8000-000000000000"; const extensionIds = new PiToolIdentities(namespace); const wrapperIds = new PiToolIdentities(namespace); + const assistantIds = new PiAssistantMessages(namespace); const boundaries = []; const events = []; const delivered = []; const displayed = []; let streams = 0; const settings = SettingsManager.inMemory({ compaction: { enabled: false }, retry: { enabled: false } }); const modelRuntime = await ModelRuntime.create({ credentials: AuthStorage.inMemory({}), modelsPath: null, refreshOnCreate: false, allowModelNetwork: false }); @@ -143,11 +144,14 @@ test("real pinned Pi model iterations align owned identities across warm prompts } }); }] }); await resourceLoader.reload(); - ({ session } = await createAgentSession({ cwd: root, agentDir: root, model, modelRuntime, settingsManager: settings, sessionManager: SessionManager.inMemory(root), resourceLoader, noTools: "builtin", thinkingLevel: "off" })); + const manager = SessionManager.create(root, join(root, "sessions")); + ({ session } = await createAgentSession({ cwd: root, agentDir: root, model, modelRuntime, settingsManager: settings, sessionManager: manager, resourceLoader, noTools: "builtin", thinkingLevel: "off" })); const extensionErrors = []; await session.bindExtensions({ onError: (error) => extensionErrors.push(error.event) }); session.subscribe((event) => { - const normalized = wrapperIds.normalize(event); + const normalized = wrapperIds.normalize(assistantIds.normalize(event)); + if (normalized.paperclipAssistantMessage && event.type !== "message_update") boundaries.push(piAssistantChunk(normalized.paperclipAssistantMessage)); + if (event.type === "message_update" && ["text_delta", "thinking_delta"].includes(event.assistantMessageEvent.type)) boundaries.push(piAssistantChunk(normalized.paperclipAssistantMessage, event.assistantMessageEvent.delta, event.assistantMessageEvent.type === "thinking_delta")); if (event.type === "turn_start") events.push({ surface: "session", type: event.type }); if (event.type === "tool_execution_start") displayed.push(normalized.toolCallId); }); @@ -156,16 +160,37 @@ test("real pinned Pi model iterations align owned identities across warm prompts // network model lookup, provider request, or inference is involved. session.agent.streamFunction = () => { const index = ++streams; const isTool = index % 3 !== 0; - const message = { role: "assistant", content: isTool ? [{ type: "toolCall", id: "call_0", name: "fixture_echo", arguments: {} }] : [{ type: "text", text: "done" }], api: model.api, provider: model.provider, model: model.id, usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } }, stopReason: isTool ? "toolUse" : "stop", timestamp: index }; - return { async *[Symbol.asyncIterator]() { yield { type: "start", partial: message }; yield { type: "done", reason: message.stopReason, message }; }, result: async () => message }; + const message = { role: "assistant", content: isTool ? [{ type: "text", text: "Calling fixture tool." }, { type: "toolCall", id: "call_0", name: "fixture_echo", arguments: {} }] : [{ type: "text", text: "done" }], api: model.api, provider: model.provider, model: model.id, usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } }, stopReason: isTool ? "toolUse" : "stop", timestamp: index }; + return { async *[Symbol.asyncIterator]() { yield { type: "start", partial: message }; yield { type: "text_delta", contentIndex: 0, delta: isTool ? "Calling fixture tool." : "done", partial: message }; yield { type: "done", reason: message.stopReason, message }; }, result: async () => message }; }; await session.agent.prompt("fixture first"); await session.agent.waitForIdle(); await session.agent.prompt("fixture warm"); await session.agent.waitForIdle(); assert.deepEqual(extensionErrors, []); assert.equal(streams, 6); assert.equal(delivered.length, 4); assert.deepEqual(displayed, delivered); assert.equal(new Set(delivered).size, 4); + assert.equal(boundaries.filter(chunk => chunk._meta.kind === "start").length, 6); + assert.equal(new Set(boundaries.map(chunk => chunk.messageId)).size, 6); + assert.deepEqual(boundaries.filter(chunk => chunk._meta.kind.startsWith("end:")).map(chunk => chunk._meta.kind), ["end:toolUse", "end:toolUse", "end:stop", "end:toolUse", "end:toolUse", "end:stop"]); assert.deepEqual(events.filter((event) => event.surface === "extension").map((event) => event.turnIndex), [0, 1, 2, 0, 1, 2]); for (let index = 0; index < events.length; index++) if (events[index].surface === "session") assert.equal(events[index - 1]?.surface, "extension"); + const lastId = boundaries.at(-1).messageId; + assert.equal(boundaries.filter(chunk => chunk.messageId === lastId).map(chunk => chunk.content.text).join(""), "done"); + assert.ok(boundaries.some(chunk => chunk.content.text === "Calling fixture tool." && chunk.messageId !== lastId)); + const persistedFile = manager.getSessionFile(); assert.ok(persistedFile); + session.dispose(); + const loadedMessages = new PiAssistantMessages("00000000-0000-4000-8000-000000000001"); const loaded = []; + ({ session } = await createAgentSession({ cwd: root, agentDir: root, model, modelRuntime, settingsManager: settings, sessionManager: SessionManager.open(persistedFile), resourceLoader, noTools: "builtin", thinkingLevel: "off" })); + await session.bindExtensions({ onError: error => extensionErrors.push(error.event) }); + assert.ok(session.agent.state.messages.some(message => message.role === "assistant")); + session.subscribe(event => { const normalized = loadedMessages.normalize(event); if (normalized.paperclipAssistantMessage && event.type !== "message_update") loaded.push(piAssistantChunk(normalized.paperclipAssistantMessage)); }); + session.agent.streamFunction = () => { + const message = { role: "assistant", content: [], api: model.api, provider: model.provider, model: model.id, usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } }, stopReason: "stop", timestamp: 100 }; + return { async *[Symbol.asyncIterator]() { yield { type: "start", partial: message }; yield { type: "done", reason: "stop", message }; }, result: async () => message }; + }; + await session.agent.prompt("loaded empty final"); await session.agent.waitForIdle(); + assert.deepEqual(loaded.map(chunk => [chunk._meta.kind, chunk.content.text]), [["start", ""], ["end:stop", ""]]); + assert.ok(loaded.every(chunk => !boundaries.some(prior => prior.messageId === chunk.messageId))); + } finally { session?.dispose(); await rm(root, { recursive: true, force: true }); } diff --git a/patches/pi-acp@0.0.33.patch b/patches/pi-acp@0.0.33.patch index 49a29facfdf..cdc4a6c10b4 100644 --- a/patches/pi-acp@0.0.33.patch +++ b/patches/pi-acp@0.0.33.patch @@ -13,17 +13,18 @@ // src/pi-rpc/process.ts import { spawn } from "child_process"; -import * as readline from "readline"; -+import { PI_ACP_FEATURES, PiToolIdentities, piHistoricalToolIdentity, PiRpcFrames, PiUiBridge, PiTurnUsage, createPiLaunchSpec, snapshotPiWorkspaceFile } from "./paperclip-runtime.js"; ++import { PI_ACP_FEATURES, 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,42 @@ +@@ -94,51 +95,43 @@ pending = /* @__PURE__ */ new Map(); eventHandlers = []; preludeLines = []; - constructor(child) { + constructor(child, invocationNamespace) { + this.toolIdentities = new PiToolIdentities(invocationNamespace); ++ this.assistantMessages = new PiAssistantMessages(invocationNamespace); this.child = child; - const rl = readline.createInterface({ input: child.stdout }); - rl.on("line", (line) => { @@ -59,7 +60,7 @@ - } - } - for (const h of this.eventHandlers) h(msg); -+ const event = this.toolIdentities.normalize(msg); ++ const event = this.toolIdentities.normalize(this.assistantMessages.normalize(msg)); + for (const handler of this.eventHandlers) handler(event); }); - child.on("exit", (code, signal) => { @@ -95,7 +96,7 @@ }); try { await new Promise((resolve4, reject) => { -@@ -172,7 +164,7 @@ +@@ -172,7 +165,7 @@ } child.stderr.on("data", () => { }); @@ -104,7 +105,7 @@ try { const state = await proc.getState(); const sessionFile = typeof state?.sessionFile === "string" ? state.sessionFile : null; -@@ -181,21 +173,30 @@ +@@ -181,21 +174,30 @@ const { dirname: dirname3 } = await import("path"); mkdirSync2(dirname3(sessionFile), { recursive: true }); } @@ -140,7 +141,7 @@ } } /** -@@ -242,7 +243,7 @@ +@@ -242,7 +244,7 @@ if (!res.success) throw new Error(`pi set_steering_mode failed: ${res.error ?? JSON.stringify(res.data)}`); } async compact(customInstructions) { @@ -149,7 +150,7 @@ if (!res.success) throw new Error(`pi compact failed: ${res.error ?? JSON.stringify(res.data)}`); return res.data; } -@@ -283,17 +284,23 @@ +@@ -283,17 +285,23 @@ await this.writeLine(`${JSON.stringify({ type: "extension_ui_response", ...response })} `); } @@ -179,7 +180,7 @@ }); } writeLine(line) { -@@ -337,7 +344,7 @@ +@@ -337,7 +345,7 @@ { authMethods: getAuthMethods() }, @@ -188,7 +189,7 @@ ); } -@@ -534,6 +541,14 @@ +@@ -534,6 +542,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; @@ -203,7 +204,7 @@ } function bashResultText(result) { const record = result; -@@ -714,6 +729,7 @@ +@@ -714,6 +730,7 @@ try { proc = await PiRpcProcess.spawn({ cwd: params.cwd, @@ -211,7 +212,7 @@ piCommand: params.piCommand }); } catch (e) { -@@ -808,6 +824,9 @@ +@@ -808,6 +825,9 @@ this.proc = opts.proc; this.conn = opts.conn; this.fileCommands = opts.fileCommands ?? []; @@ -221,7 +222,19 @@ this.proc.onEvent((ev) => this.handlePiEvent(ev)); } setStartupInfo(text) { -@@ -852,6 +871,7 @@ +@@ -822,10 +842,7 @@ + sendStartupInfoIfPending() { + if (this.startupInfoSent || !this.startupInfo) return; + this.startupInfoSent = true; +- this.emit({ +- sessionUpdate: "agent_message_chunk", +- content: { type: "text", text: this.startupInfo } +- }); ++ this.emitNotice("startup", this.startupInfo, "info"); + } + async prompt(message, images = []) { + const expandedMessage = expandSlashCommand(message, this.fileCommands); +@@ -852,6 +869,7 @@ } async cancel() { this.cancelRequested = true; @@ -229,7 +242,7 @@ if (this.turnQueue.length) { const queued = this.turnQueue.splice(0, this.turnQueue.length); for (const t of queued) t.resolve("cancelled"); -@@ -870,6 +890,8 @@ +@@ -870,6 +888,8 @@ return this.cancelRequested; } emit(update) { @@ -238,7 +251,20 @@ this.lastEmit = this.lastEmit.then( () => this.conn.sessionUpdate({ sessionId: this.sessionId, -@@ -890,6 +912,7 @@ +@@ -877,6 +897,12 @@ + }) + ).catch(() => { + }); ++ } ++ emitNotice(category, summary, severity = "info", details = {}) { ++ this.lastEmit = this.lastEmit.then(() => this.conn.extNotification("paperclip/pi_notice", { ++ sessionId: this.sessionId, category, severity, ++ summary: String(summary).slice(0, 4000), details ++ })).catch(() => {}); + } + async flushEmits() { + await this.lastEmit; +@@ -890,6 +916,7 @@ kind: "execute", status: params.status, locations: params.locations, @@ -246,7 +272,7 @@ ...params.includeTerminal ? { content: bashTerminalContent(params.toolCallId) } : {}, ...params.includeTerminal ? { _meta: bashTerminalInfoMeta(params.toolCallId, this.cwd) } : {} }); -@@ -903,6 +926,7 @@ +@@ -903,6 +930,7 @@ sessionUpdate: "tool_call_update", toolCallId: params.toolCallId, status: params.status, @@ -254,7 +280,7 @@ _meta: { ...delta ? bashTerminalOutputMeta(params.toolCallId, delta) : {}, ...params.status === "completed" || params.status === "failed" ? bashTerminalExitMeta(params.toolCallId, bashExitCode(params.result, Boolean(params.isError))) : {} -@@ -920,12 +944,16 @@ +@@ -920,12 +948,16 @@ this.cancelRequested = false; this.inAgentLoop = false; this.pendingTurn = { resolve: t.resolve, reject: t.reject }; @@ -271,7 +297,7 @@ const authErr = maybeAuthRequiredError(err); if (authErr) { this.pendingTurn?.reject(authErr); -@@ -946,6 +974,23 @@ +@@ -946,20 +978,36 @@ handlePiEvent(ev) { const type = String(ev.type ?? ""); switch (type) { @@ -283,7 +309,12 @@ + this.turnEpoch += 1; + break; + } ++ case "message_start": { ++ if (ev.paperclipAssistantMessage) this.emit(piAssistantChunk(ev.paperclipAssistantMessage)); ++ break; ++ } + case "message_end": { ++ 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")); + this.pendingTurn = null; @@ -295,7 +326,23 @@ case "message_update": { const ame = ev.assistantMessageEvent; if (ame?.type === "text_delta" && typeof ame.delta === "string") { -@@ -1047,7 +1092,7 @@ +- this.emit({ +- sessionUpdate: "agent_message_chunk", +- content: { type: "text", text: ame.delta } +- }); ++ this.emit(piAssistantChunk(ev.paperclipAssistantMessage, ame.delta)); + break; + } + if (ame?.type === "thinking_delta" && typeof ame.delta === "string") { +- this.emit({ +- sessionUpdate: "agent_thought_chunk", +- content: { type: "text", text: ame.delta } +- }); ++ this.emit(piAssistantChunk(ev.paperclipAssistantMessage, ame.delta, true)); + break; + } + if (ame?.type === "toolcall_start" || ame?.type === "toolcall_delta" || ame?.type === "toolcall_end") { +@@ -1047,7 +1095,7 @@ if (p) { try { const abs = isAbsolute(p) ? p : resolvePath(this.cwd, p); @@ -304,7 +351,7 @@ this.fileSnapshots.set(toolCallId, { path: p, oldText: snapshotOldText }); if (toolName === "edit") { for (const needle of getEditOldTexts(args)) { -@@ -1125,7 +1170,7 @@ +@@ -1125,7 +1173,7 @@ if (!isError && snapshot) { try { const abs = isAbsolute(snapshot.path) ? snapshot.path : resolvePath(this.cwd, snapshot.path); @@ -313,7 +360,7 @@ if (snapshot.oldText === null || newText !== snapshot.oldText) { hasStructuredDiff = true; content = [ -@@ -1154,14 +1199,16 @@ +@@ -1154,48 +1202,41 @@ break; } case "extension_ui_request": { @@ -324,40 +371,54 @@ - } - void this.proc.sendExtensionUiResponse({ id, cancelled: true }).catch(() => { - }); -+ if (ev.method === "notify") this.emit({ -+ sessionUpdate: "session_info_update", -+ _meta: { paperclipPi: { notice: { message: typeof ev.message === "string" ? ev.message.slice(0, 4096) : "Pi notification", level: ev.notifyType } } } - }); ++ if (ev.method === "notify") this.emitNotice("extension_notify", typeof ev.message === "string" ? ev.message : "Pi notification", ["warning", "error"].includes(ev.notifyType) ? ev.notifyType : "info"); + void this.uiBridge.handle(ev).catch(() => { -+ this.emit({ sessionUpdate: "session_info_update", _meta: { paperclipPi: { notice: { -+ message: "Pi structured question is unsupported or invalid; the session was stopped", level: "error" -+ } } } }); ++ this.emitNotice("invalid_question", "Pi structured question is unsupported or invalid; the session was stopped", "error"); + this.proc.dispose("SIGKILL"); -+ }); + }); break; } case "auto_retry_start": { -@@ -1174,28 +1216,31 @@ - case "auto_retry_end": { - this.emit({ - sessionUpdate: "agent_message_chunk", -- content: { type: "text", text: "Retry finished, resuming." } -- }); -- break; -- } -- case "auto_compaction_start": { - this.emit({ - sessionUpdate: "agent_message_chunk", - content: { - type: "text", -- text: "Context nearing limit, running automatic compaction..." -+ text: ev.success === true ? "Retry finished, resuming." -+ : ev.success === false ? "Retry failed; the provider request did not recover." -+ : "Retry finished; the provider did not report an outcome." - } +- content: { type: "text", text: formatAutoRetryMessage(ev) } ++ this.emitNotice("auto_retry_start", formatAutoRetryMessage(ev), "warning", { ++ attempt: ev.attempt, maxAttempts: ev.maxAttempts, delayMs: ev.delayMs, ++ errorMessage: typeof ev.errorMessage === "string" ? ev.errorMessage.slice(0, 4000) : undefined }); break; } + case "auto_retry_end": { +- this.emit({ +- sessionUpdate: "agent_message_chunk", +- content: { type: "text", text: "Retry finished, resuming." } ++ this.emitNotice("auto_retry_end", ev.success === true ? "Retry finished, resuming." ++ : ev.success === false ? "Retry failed; the provider request did not recover." ++ : "Retry finished; the provider did not report an outcome.", ev.success === true ? "info" : "warning", { attempt: ev.attempt, success: ev.success }); ++ break; ++ } ++ case "compaction_start": { ++ this.emitNotice("compaction_start", ev.reason === "manual" ? "Compacting context..." : "Context nearing limit, running automatic compaction...", "info", { reason: ev.reason }); ++ break; ++ } ++ case "compaction_end": { ++ (this.pendingTurn ? this.turnUsage : this.manualCompactionUsage)?.acceptCompaction(ev.result); ++ this.emitNotice("compaction_end", ev.aborted ? "Context compaction cancelled." : ev.errorMessage ? "Context compaction failed." : "Context compaction finished.", ev.errorMessage ? "error" : ev.aborted ? "warning" : "info", { ++ reason: ev.reason, aborted: ev.aborted, willRetry: ev.willRetry, ++ errorMessage: typeof ev.errorMessage === "string" ? ev.errorMessage.slice(0, 4000) : undefined + }); + break; + } +- case "auto_compaction_start": { +- this.emit({ +- sessionUpdate: "agent_message_chunk", +- content: { +- type: "text", +- text: "Context nearing limit, running automatic compaction..." +- } +- }); +- break; +- } - case "auto_compaction_end": { - this.emit({ - sessionUpdate: "agent_message_chunk", @@ -366,26 +427,13 @@ - text: "Automatic compaction finished; context was summarized to continue the session." - } - }); -+ case "compaction_start": { -+ this.emit({ sessionUpdate: "agent_message_chunk", content: { -+ type: "text", text: ev.reason === "manual" ? "Compacting context..." : "Context nearing limit, running automatic compaction..." -+ } }); -+ break; -+ } -+ case "compaction_end": { -+ (this.pendingTurn ? this.turnUsage : this.manualCompactionUsage)?.acceptCompaction(ev.result); -+ this.emit({ sessionUpdate: "agent_message_chunk", content: { -+ type: "text", text: ev.aborted ? "Context compaction cancelled." : ev.errorMessage ? "Context compaction failed." : "Context compaction finished." -+ } }); -+ break; -+ } + case "summarization_retry_scheduled": { + (this.pendingTurn ? this.turnUsage : this.manualCompactionUsage)?.markIncomplete(); -+ this.emit({ sessionUpdate: "agent_message_chunk", content: { type: "text", text: "Context summarization is retrying a provider request." } }); ++ this.emitNotice("summarization_retry_scheduled", "Context summarization is retrying a provider request.", "warning"); break; } case "agent_start": { -@@ -1210,9 +1255,12 @@ +@@ -1210,9 +1251,12 @@ break; } case "agent_settled": { @@ -399,7 +447,7 @@ this.pendingTurn = null; this.inAgentLoop = false; const next = this.turnQueue.shift(); -@@ -1725,6 +1773,7 @@ +@@ -1725,6 +1769,7 @@ return process.env.PI_CODING_AGENT_DIR ? resolve3(process.env.PI_CODING_AGENT_DIR) : join4(homedir4(), ".pi", "agent"); } function getEnableSkillCommands(cwd) { @@ -407,7 +455,7 @@ const merged = getMergedSettings(cwd); const direct = merged.enableSkillCommands; if (typeof direct === "boolean") return direct; -@@ -1733,6 +1782,7 @@ +@@ -1733,6 +1778,7 @@ return true; } function getQuietStartup(cwd) { @@ -415,7 +463,7 @@ const merged = getMergedSettings(cwd); const direct = merged.quietStartup; if (typeof direct === "boolean") return direct; -@@ -1824,6 +1874,7 @@ +@@ -1824,6 +1870,7 @@ const out = []; const seen = /* @__PURE__ */ new Set(); for (const c of [...a, ...b]) { @@ -423,7 +471,7 @@ if (seen.has(c.name)) continue; seen.add(c.name); out.push(c); -@@ -1884,11 +1935,13 @@ +@@ -1884,11 +1931,13 @@ throw RequestError3.invalidParams(`Unknown sessionId: ${sessionId}`); } const cwd = opts?.cwd ?? stored.cwd; @@ -437,7 +485,7 @@ piCommand: process.env.PI_ACP_PI_COMMAND }); } catch (e) { -@@ -1897,7 +1950,7 @@ +@@ -1897,7 +1946,7 @@ } throw e; } @@ -446,7 +494,7 @@ const session = this.sessions.getOrCreate(sessionId, { cwd, mcpServers: opts?.mcpServers ?? [], -@@ -1917,6 +1970,7 @@ +@@ -1917,6 +1966,7 @@ } } async initialize(params) { @@ -454,7 +502,7 @@ const supportedVersion = 1; const requested = params.protocolVersion; return { -@@ -1933,7 +1987,8 @@ +@@ -1933,7 +1983,8 @@ }), agentCapabilities: { loadSession: true, @@ -464,7 +512,7 @@ promptCapabilities: { image: true, audio: false, -@@ -1942,8 +1997,7 @@ +@@ -1942,8 +1993,7 @@ sessionCapabilities: { // **UNSTABLE** ACP capability used by Zed's codex-acp adapter. // Enables a native session picker in clients that support it. @@ -474,7 +522,7 @@ } } }; -@@ -1953,7 +2007,7 @@ +@@ -1953,7 +2003,7 @@ throw RequestError3.invalidParams(`cwd must be an absolute path: ${params.cwd}`); } this.lastSessionCwd = params.cwd; @@ -483,7 +531,7 @@ const enableSkillCommands = getEnableSkillCommands(params.cwd); const session = await this.sessions.create({ cwd: params.cwd, -@@ -1994,14 +2048,14 @@ +@@ -1994,14 +2044,14 @@ this.cleanupFailedNewSession(session.sessionId, state); throw RequestError3.authRequired( { authMethods: getAuthMethods() }, @@ -500,7 +548,7 @@ ); } const { configOptions, models, modes } = await getSessionConfiguration(session.proc, { -@@ -2064,34 +2118,27 @@ +@@ -2064,34 +2114,27 @@ } async prompt(params) { const session = await this.restoreSession(params.sessionId); @@ -547,7 +595,39 @@ } if (cmd === "session") { const stats = await session.proc.getSessionStats(); -@@ -2432,8 +2479,18 @@ +@@ -2112,13 +2155,8 @@ + } + const text = lines.length ? lines.join("\n") : `Session stats: + ${JSON.stringify(stats, null, 2)}`; +- await this.conn.sessionUpdate({ +- sessionId: session.sessionId, +- update: { +- sessionUpdate: "agent_message_chunk", +- content: { type: "text", text } +- } +- }); ++ session.emitNotice("session_stats", text, "info"); ++ await session.flushEmits(); + return { stopReason: "end_turn" }; + } + if (cmd === "name") { +@@ -2418,22 +2456,24 @@ + enabled = !current; + } + await session.proc.setAutoCompaction(enabled); +- await this.conn.sessionUpdate({ +- sessionId: session.sessionId, +- update: { +- sessionUpdate: "agent_message_chunk", +- content: { +- type: "text", +- text: `Auto-compaction ${enabled ? "enabled" : "disabled"}.` +- } +- } +- }); ++ session.emitNotice("auto_compaction_policy", `Auto-compaction ${enabled ? "enabled" : "disabled"}.`, "info", { enabled }); ++ await session.flushEmits(); + return { stopReason: "end_turn" }; } } const result = await session.prompt(message, images); @@ -568,7 +648,7 @@ } async cancel(params) { const session = this.sessions.maybeGet(params.sessionId); -@@ -2473,7 +2530,7 @@ +@@ -2473,7 +2513,7 @@ mcpServers: params.mcpServers }); const proc = session.proc; @@ -577,7 +657,7 @@ this.sessions.closeAllExcept?.(session.sessionId); this.store.upsert({ sessionId: params.sessionId, -@@ -2482,7 +2539,7 @@ +@@ -2482,7 +2522,7 @@ }); const data = await proc.getMessages(); const messages = Array.isArray(data?.messages) ? data.messages : []; @@ -586,7 +666,17 @@ const role = String(m?.role ?? ""); if (role === "user") { const text = normalizePiMessageText(m?.content); -@@ -2510,7 +2567,8 @@ +@@ -2501,16 +2541,14 @@ + if (text) { + await this.conn.sessionUpdate({ + sessionId: session.sessionId, +- update: { +- sessionUpdate: "agent_message_chunk", +- content: { type: "text", text } +- } ++ update: piHistoricalAssistantChunk(params.sessionId, messageIndex, text) + }); + } } if (role === "toolResult") { const toolName = String(m?.toolName ?? "tool"); @@ -596,7 +686,7 @@ const isError = Boolean(m?.isError); const isBash = isBashTool(toolName); if (isBash) { -@@ -2524,7 +2582,7 @@ +@@ -2524,7 +2562,7 @@ kind: "execute", status: "completed", content: bashTerminalContent(toolCallId), @@ -605,7 +695,7 @@ } }); await this.conn.sessionUpdate({ -@@ -2535,7 +2593,8 @@ +@@ -2535,7 +2573,8 @@ status: isError ? "failed" : "completed", _meta: { ...text2 ? bashTerminalOutputMeta(toolCallId, text2) : {}, @@ -615,7 +705,7 @@ } } }); -@@ -2549,6 +2608,7 @@ +@@ -2549,6 +2588,7 @@ title: toolName, kind: toolName === "read" ? "read" : toolName === "write" || toolName === "edit" ? "edit" : "other", status: "completed", @@ -623,7 +713,7 @@ rawInput: null, rawOutput: m } -@@ -2560,6 +2620,7 @@ +@@ -2560,6 +2600,7 @@ sessionUpdate: "tool_call_update", toolCallId, status: isError ? "failed" : "completed", @@ -631,7 +721,7 @@ content: text ? [{ type: "content", content: { type: "text", text } }] : null, rawOutput: m } -@@ -2607,6 +2668,7 @@ +@@ -2607,6 +2648,7 @@ return response; } async deleteSession(params) { @@ -639,7 +729,7 @@ const stored = this.store.get(params.sessionId); const piSession = findPiSession(params.sessionId); if (!stored && !piSession) { -@@ -2828,6 +2890,7 @@ +@@ -2828,6 +2870,7 @@ return 0; } function buildUpdateNotice() { @@ -647,7 +737,7 @@ try { const piVersion = spawnSync("pi", ["--version"], { encoding: "utf-8" }); const installed = (String(piVersion.stdout ?? "").trim() || String(piVersion.stderr ?? "").trim()).replace( -@@ -2848,6 +2911,7 @@ +@@ -2848,6 +2891,7 @@ } } function buildStartupInfo(opts) { @@ -655,7 +745,7 @@ void opts.fileCommands; const md = []; try { -@@ -2980,6 +3044,7 @@ +@@ -2980,6 +3024,7 @@ // src/index.ts if (process.argv.includes("--terminal-login")) { @@ -663,7 +753,7 @@ const { spawnSync: spawnSync2 } = await import("child_process"); const cmd = getPiCommand(process.env.PI_ACP_PI_COMMAND); const res = spawnSync2(cmd, [], { -@@ -3019,11 +3084,12 @@ +@@ -3019,11 +3064,12 @@ } }); var stream = ndJsonStream(input, output); @@ -680,7 +770,7 @@ try { --- /dev/null +++ b/dist/paperclip-runtime.js -@@ -0,0 +1,394 @@ +@@ -0,0 +1,468 @@ +/** 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"; @@ -710,6 +800,80 @@ + pendingRequestRecovery: "live-process-only", +}); + ++ ++ ++ ++ ++ ++ ++/** 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 { ++ ordinal = 0; ++ active = null; ++ poisoned = false; ++ namespace ; ++ constructor(namespace ) { ++ this.namespace = namespace; ++ if (!/^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/.test(namespace)) throw new Error("Pi assistant namespace is invalid"); ++ } ++ fail() { this.poisoned = true; throw new Error("Pi native assistant message boundary is invalid"); } ++ normalize(event ) { ++ if (this.poisoned) return this.fail(); ++ if (event.type === "agent_settled") { ++ if (this.active) return this.fail(); ++ return event; ++ } ++ if (!["message_start", "message_update", "message_end"].includes(String(event.type))) return event; ++ const message = event.message; ++ if (!message || typeof message !== "object" || Array.isArray(message)) return this.fail(); ++ const native = message ; ++ if (native.role !== "assistant") { ++ if (event.type === "message_update" || this.active || !["user", "toolResult"].includes(String(native.role))) return this.fail(); ++ return event; ++ } ++ if (!Number.isSafeInteger(native.timestamp) || (native.timestamp ) < 0 || !Array.isArray(native.content)) return this.fail(); ++ let boundary ; ++ if (event.type === "message_start") { ++ if (this.active || this.ordinal >= Number.MAX_SAFE_INTEGER) return this.fail(); ++ const messageId = `pi-message-${createHash("sha256").update(JSON.stringify([this.namespace, ++this.ordinal])).digest("hex")}`; ++ this.active = { messageId, timestamp: native.timestamp }; ++ boundary = { messageId, phase: "start" }; ++ } else { ++ if (!this.active || this.active.timestamp !== native.timestamp) return this.fail(); ++ boundary = { messageId: this.active.messageId, phase: event.type === "message_end" ? "end" : "delta" }; ++ if (event.type === "message_end") { ++ if (!["stop", "length", "toolUse", "error", "aborted"].includes(String(native.stopReason))) return this.fail(); ++ boundary.stopReason = native.stopReason ; ++ this.active = null; ++ } ++ } ++ return { ...event, paperclipAssistantMessage: boundary }; ++ } ++} ++ ++export function piAssistantChunk(boundaryValue , textValue = "", thought = false) { ++ const boundary = record(boundaryValue); ++ const messageId = text(boundary.messageId, 96); ++ if (!/^pi-message-[a-f0-9]{64}$/.test(messageId) || !["start", "delta", "end"].includes(String(boundary.phase))) throw new Error("Pi assistant chunk lacks native provenance"); ++ if (boundary.phase === "end" && !["stop", "length", "toolUse", "error", "aborted"].includes(String(boundary.stopReason))) throw new Error("Pi assistant chunk end is invalid"); ++ const content = text(textValue, 262_144); ++ if (boundary.phase !== "delta" && (content || thought)) throw new Error("Pi assistant boundary cannot carry synthetic content"); ++ return { sessionUpdate: thought ? "agent_thought_chunk" : "agent_message_chunk", messageId, ++ content: { type: "text", text: content }, ++ _meta: { origin: "pi-native-assistant", source: "pi-rpc-message-v1", kind: boundary.phase === "end" ? `end:${boundary.stopReason}` : boundary.phase }, ++ }; ++} ++ ++export function piHistoricalAssistantChunk(sessionId , messageIndex , value ) { ++ if (!sessionId || !Number.isSafeInteger(messageIndex) || messageIndex < 0) throw new Error("Pi historical assistant identity is invalid"); ++ return { sessionUpdate: "agent_message_chunk", ++ messageId: `pi-history-message-${createHash("sha256").update(JSON.stringify([text(sessionId, 1024), messageIndex])).digest("hex")}`, ++ content: { type: "text", text: text(value, 262_144) }, ++ _meta: { origin: "pi-history-assistant", source: "pi-session-history-v1", kind: "history" }, ++ }; ++} ++ +/** One trusted Pi model iteration, not one ACP prompt. Pi resets its own turnIndex + * on warm prompts; our ordinal is monotonic for the lifetime of the child. */ +export class PiToolIdentities {