mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-11 13:24:55 +01:00
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:
1 parent
ceb28bdba4
commit
a97b626081
29 files changed
+948
-141
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user