fix(runner): preserve Pi native assistant boundaries and notices

Pin Pi profile 8 to native message provenance, preserve explicit empty starts and ends, and keep replay/history and native notices out of final assistant aggregation. Validate fresh, warm and loaded SDK sessions and exact native closure bytes.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-09-29 16:30:28 -05:00
1 parent ceb28bdba4
commit a97b626081
29 files changed
+948 -141

No files matched your search

+27 -5
View File
@@ -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/<platform>-<arch>` 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,
+1 -1
View File
@@ -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",
@@ -106,6 +106,8 @@ pub struct AcpxProviderState {
plan_revision: u64,
assistant_text: String,
assistant_message_id: Option<String>,
pi_assistant_message: Option<String>,
pi_assistant_message_ids: BTreeSet<String>,
pending_tools: BTreeMap<String, AcpxPendingTool>,
pending_tool_input_bytes: usize,
pending_permissions: BTreeMap<String, (usize, Value)>,
@@ -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
@@ -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();
@@ -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");
@@ -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<string, string> }>();
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<ReturnType<typeof event>>();
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"));
@@ -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<AcpRuntimeEvent>();
const normalizeMessage = initializedAgent === "grok"
const piMessages = initializedAgent === "pi" ? createPiMessageProjection<AcpRuntimeEvent>() : null;
const normalizeMessage = piMessages ? piMessages.normalize : initializedAgent === "grok"
? createGrokMessageNormalizer<AcpRuntimeEvent>() : (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<string, unknown> {
function sanitizeRuntimeEvent(event: PiProjectedMessageEvent<AcpRuntimeEvent>): Record<string, unknown> {
const runtimeType = text(record(event).type);
if (runtimeType === "plan") {
return {
@@ -948,6 +951,8 @@ function sanitizeRuntimeEvent(event: AcpRuntimeEvent): Record<string, unknown> {
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")
@@ -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" },
@@ -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");
}
@@ -56,7 +56,7 @@ export const ACPX_CAPABILITY_PROFILES: Readonly<Record<QualifiedAcpxAgent, AcpxC
displayName: "Pi", qualification: "pending", models: "exact-qualified",
permissions: "interactive", questions: "form", plans: "semantic-only", tools: "owned-extension",
recovery: "session-load", usage: "reported", steering: "owned-extension-pending", followUp: "owned-extension-pending",
artifacts: "references-pending", extensionRequests: [], extensionNotifications: [],
artifacts: "references-pending", extensionRequests: [], extensionNotifications: ["paperclip/pi_notice"],
},
};
for (const profile of Object.values(ACPX_CAPABILITY_PROFILES)) {
@@ -23,8 +23,42 @@ import type {
import type { AcpxRecoveryWorkspaceLease } from "./runtime-sandbox.js";
describe("Codex ACPX harness driver", () => {
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/);
@@ -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<AcpRuntimeEvent>();
const normalizeMessage = this.#agent === "grok"
const piMessages = this.#agent === "pi" ? createPiMessageProjection<AcpRuntimeEvent>() : null;
const normalizeMessage = piMessages ? piMessages.normalize : this.#agent === "grok"
? createGrokMessageNormalizer<AcpRuntimeEvent>() : (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<AcpRuntimeEvent>,
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;
@@ -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", () => {
@@ -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 {
@@ -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",
});
@@ -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");
});
@@ -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<string, unknown>;
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,
} }];
},
};
}
@@ -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");
}
@@ -24,7 +24,7 @@ function record(value: unknown): Record<string, unknown> {
/** 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");
}
@@ -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<typeof chunk>[]) {
const projection = createPiMessageProjection<ReturnType<typeof chunk>>();
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");
});
});
@@ -0,0 +1,48 @@
type RuntimeMessageEvent = { type: string; text?: string; stream?: string; messageId?: string; meta?: Record<string, unknown> };
export interface PiMessageBoundary { phase: "start" | "end"; stopReason?: "stop" | "length" | "toolUse" | "error" | "aborted" }
export type PiProjectedMessageEvent<T> = 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<T extends RuntimeMessageEvent>() {
let active: string | null = null;
const seen = new Set<string>();
let poisoned = false;
const fail = (): never => { poisoned = true; throw new Error("Pi native message provenance is invalid or incomplete"); };
return {
normalize(event: T): PiProjectedMessageEvent<T> {
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 ?? ""));
}
@@ -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<string, unknown> {
@@ -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",
@@ -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" } });
@@ -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"
}
}
@@ -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);
}
});
@@ -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;
@@ -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 });
}
+248 -84
View File
@@ -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 {