mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 00:05:49 +02:00
Preserve partial Cursor native usage diagnostics and version ACP profiles
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
3d21d375de
commit
e95e28d9c8
42 files changed
+1383
-109
No files matched your search
@@ -1,6 +1,15 @@
|
||||
# Copilot 1.0.88 rich ACP capability audit
|
||||
|
||||
Current checkpoint (2026-09-30): **Copilot profile v7 remains unqualified**. Paid controller/Product harness source is `40064d28522de25fea85c1297f35b41bb8a8897a`; runtime source is `5b8e4454ef0bf12d0bb068c2e41d8c9df9356a1c`. All three target runtime builds are complete. Local attached-async attempt `40064-copilot-async-01` passed all eight matchers on `gpt-5.6-luna`, including exact one-command identity, trusted child exit and independent completion observations before terminal settlement, one run and one exact final marker. Done was visible, and full end integrity, owned process/semaphore retirement and temporary-root cleanup passed. The exact local native-permission-deny-write case `40064-copilot-deny-01` also passed all ten checks, with no target side effect through process retirement, correlated cancellation and successful cleanup/end integrity. These two local cases pass; the provider remains unqualified. Native per-run USD is unknown; included credits stayed 16→16 after async and reached 17 after denial, with possible dashboard lag, and extra usage remained disabled at $0. Result SHA-256: `d1d074bc9df4644794dfa8b020fdeba8a9b0de4618e1f55d1845f7726a2ece33`; reconciliation SHA-256: `8e9b9233ed987e9e522b17a271922fd409d311e04ee5c3422a4b5bd1fa2fce68`. The prior exact head passed CI and Greptile for [draft #14676](https://github.com/paperclipai/paperclip/pull/14676). The earlier v6 denial pass and attached-command failures remain historical, including the doubled final marker and missing settlement observations; they do not qualify v7. See the [comparative capability report](runner-rich-acp-capabilities.md) for exact source identities and remaining local/Daytona, billing and cleanup gates.
|
||||
Current source candidate (2026-09-30): **Copilot profile v8 is unqualified**.
|
||||
Its native executable, owned mapper and native distribution closures are unchanged
|
||||
from v7, but its declaration binds the full shared ACPX patch. The patch's additive
|
||||
Cursor metadata persistence changes those bytes, so v8 has a distinct command
|
||||
digest and rejects v7 sessions. Copilot gains no usage or cost capability from this
|
||||
change. Retained v7 paid cases below remain exact historical evidence, not v8
|
||||
qualification. Fresh pack/image admission and the required exact-runtime local
|
||||
and Daytona qualification remain outstanding.
|
||||
|
||||
Historical v7 checkpoint (2026-09-30): **Copilot profile v7 remains unqualified**. Paid controller/Product harness source is `40064d28522de25fea85c1297f35b41bb8a8897a`; runtime source is `5b8e4454ef0bf12d0bb068c2e41d8c9df9356a1c`. All three target runtime builds are complete. Local attached-async attempt `40064-copilot-async-01` passed all eight matchers on `gpt-5.6-luna`, including exact one-command identity, trusted child exit and independent completion observations before terminal settlement, one run and one exact final marker. Done was visible, and full end integrity, owned process/semaphore retirement and temporary-root cleanup passed. The exact local native-permission-deny-write case `40064-copilot-deny-01` also passed all ten checks, with no target side effect through process retirement, correlated cancellation and successful cleanup/end integrity. These two local cases pass; the provider remains unqualified. Native per-run USD is unknown; included credits stayed 16→16 after async and reached 17 after denial, with possible dashboard lag, and extra usage remained disabled at $0. Result SHA-256: `d1d074bc9df4644794dfa8b020fdeba8a9b0de4618e1f55d1845f7726a2ece33`; reconciliation SHA-256: `8e9b9233ed987e9e522b17a271922fd409d311e04ee5c3422a4b5bd1fa2fce68`. The prior exact head passed CI and Greptile for [draft #14676](https://github.com/paperclipai/paperclip/pull/14676). The earlier v6 denial pass and attached-command failures remain historical, including the doubled final marker and missing settlement observations; they do not qualify v7. See the [comparative capability report](runner-rich-acp-capabilities.md) for exact source identities and remaining local/Daytona, billing and cleanup gates.
|
||||
|
||||
Message identity implementation (2026-09-30, **deterministic evidence and one paid async case; full qualification pending**): the pinned 1.0.88
|
||||
ACP mapper discards `assistant.message_start` and removes native `messageId`
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# Cursor ACP capability inventory
|
||||
|
||||
Current source candidate (2026-09-30): **Cursor profile v8 is unqualified**.
|
||||
Its declaration binds `paperclip-cursor-usage-v3`, all three newly verified native
|
||||
closures and the shared ACPX patch that persists bounded diagnostic observations.
|
||||
Every native counter receipt remains partial with unverified semantics; it cannot
|
||||
satisfy token or dollar accounting. The exact model remains unpriced until a
|
||||
verified rate is available. Prior v7 source, builds and paid observations below
|
||||
are historical and do not qualify v8. Old v7 sessions must be reopened.
|
||||
|
||||
Current checkpoint (2026-09-30): **Cursor profile v7 remains unqualified**. Paid controller/Product harness source is `40064d28522de25fea85c1297f35b41bb8a8897a`; runtime source is `5b8e4454ef0bf12d0bb068c2e41d8c9df9356a1c`. All three target runtime builds are complete. Local native-plan attempt `40064-cursor-plan-01` passed all 21 bridge checks and retained empty workspace snapshots (`{}`), but failed passive accepted-plan settlement. Owned cleanup and the full end integrity audit passed; the overall case remains failed. Controller fix `c58a8881f` repairs a body-only hash comparison to use the production schema/policy/contract envelope; 67 focused tests, ten production-contract database tests and server typecheck pass. The native runtime/profile stays unchanged; corrected-controller paid proof remains pending. Native AskQuestion availability remains unverified. The prior exact head passed CI and Greptile for [draft #14669](https://github.com/paperclipai/paperclip/pull/14669); they do not qualify paid behavior. See the [comparative capability report](runner-rich-acp-capabilities.md) for current source identities and remaining gates. The dated observations below retain their original source/profile identities.
|
||||
|
||||
Evidence updated: 2026-09-29. Candidate: `2026.09.26-dd393fe`. This profile is **not live-qualified**. One authorized task-context prompt on the initial free account returned an upgrade requirement, invoked no semantic tools, and supplied no usage receipt. Its cost is unknown; the account dashboard remained unchanged at coarse precision. The user then selected another account, whose exact-model local Runner Eval passed task-context and history tools with clean terminal settlement. Its dashboard attributed 56K rounded tokens to included usage and no incremental charge; ACP still supplied no token or dollar receipt. The local Product hello subsequently passed through the real browser, server, database, native runner, and authenticated completion tool. A second local Product case passed file creation, editing, command validation, an independent exact-byte matcher, and visible workspace artifact presentation. The merged-source local Product question and revision-bound Plan approval also passed browser response and semantic-tool continuation. A serialized follow-up passed pending semantic-question recovery across a server restart. A verified native ACP probe also denied a shell write before any observed side effect. Native Cursor questions/plans and their recovery, durable permission presentation, interruption, and Daytona execution remain required qualification gates.
|
||||
@@ -46,19 +54,71 @@ The reference is the runner's Codex app-server integration and its closed thread
|
||||
| Session metadata | `session_info_update` title after automatic naming | Capability documented | Runner owns normalized session identity; provider title is currently not surfaced |
|
||||
| Prompt media | Image=true, audio=false, embeddedContext=false in observed initialize | The shared runner turn contract accepts text only; it does not forward image attachment blocks to ACP | P1: implement bounded, validated attachment-to-ACP conversion with capability checks, then qualify the exact model. Audio/embedded context are explicitly unsupported by this advertisement |
|
||||
| MCP and semantic tools | Session MCP injection supports stdio/HTTP/SSE; user/project config also loaded | Runner-owned authenticated MCP bridge only; the owned distribution patch never initializes ambient MCP | Exact tool discovery and company/token boundary remain live tests |
|
||||
| Usage/cost | No `usage_update`/token-usage emission found in pinned ACP implementation; prompt result contains stopReason | Must report usage unavailable, never zero or estimates presented as actual | Dollar accounting unavailable without another explicit measured source. Live spend needs a measurable bound before starting |
|
||||
| Usage/cost | Vendor ACP emits only prompt `stopReason`; native `TurnEndedUpdate` separately exposes optional input/output/cache/reasoning counters | The observation-only patch below preserves bounded native counters as partial diagnostic metadata; it emits no standard usage or dollar receipt | Native counter aggregation semantics and exact-model pricing remain unverified; accounting stays unavailable |
|
||||
| Reviews, compaction, hooks, memory | Native mode changes, hooks present internally; no dedicated ACP counterparts to Codex context/hook/memory lifecycle found | No fabricated event families | Distinguish confirmed absence of ACP event mapping from unverified native internals |
|
||||
| Goal controls / lineage | No ACP goal or fork lineage protocol found | Unsupported | No equivalent of Codex native goals/thread lineage |
|
||||
|
||||
## Wire and isolation findings
|
||||
|
||||
The observation-only usage candidate adds `_meta.paperclipCursorUsage` to a
|
||||
prompt response. Its `paperclip.cursor.native-usage.v1` envelope contains a fresh
|
||||
opaque prompt ID and separate native invocation/run observations. It allows at
|
||||
most 64 observations, 64 invocation identities and 16 KiB of JSON. Only observed,
|
||||
safe nonnegative numeric input/output/cache-read/cache-write/reasoning counters
|
||||
are retained; absent or invalid fields are omitted. Every envelope remains
|
||||
`completeness: "partial"` with `native_counter_semantics_unverified`, even when
|
||||
all fields are present. There are no standard ACP usage fields, counter sums,
|
||||
estimated dollars or billed dollars. A reused child ID has no native callback
|
||||
generation, so its counters are omitted with `child_run_attribution_unverified`.
|
||||
The `invocationId` is a generated per-prompt ordinal. `nativeRun` is likewise a
|
||||
generated invocation ordinal for parents, and the vendor's child run ordinal
|
||||
for children; neither is advertised as a native request ID. Finalization closes
|
||||
all tracked invocations, marks any missing terminal observation and enforces the
|
||||
15,744-byte emission limit after all reasons are present, trimming observations
|
||||
explicitly. The declared 16 KiB limit includes a 640-byte reserve for the bounded
|
||||
ACPX request/message identity wrapper persisted alongside the envelope.
|
||||
History and callbacks arriving after their invocation/prompt closes cannot add
|
||||
observations to another prompt. Cancelled prompts return their own partial
|
||||
envelope after the existing cancellation handling; they do not claim complete
|
||||
cancelled-work accounting. A thrown JSON-RPC error returns no prompt envelope,
|
||||
so these observations are not retained on that error path. This is a new source candidate, not a change to
|
||||
the frozen v7 runtime or a regrading of historical evidence.
|
||||
|
||||
The `paperclip-cursor-usage-v3` candidate retains the vendor version and archive
|
||||
pins. Private full-tree materialization verifies 446 files per macOS target and
|
||||
454 files on Linux; only the ACP implementation chunk differs from each vendor
|
||||
tree. Its patched closures are:
|
||||
|
||||
| Platform | Patched closure SHA-256 |
|
||||
| --- | --- |
|
||||
| macOS ARM64 | `0681abb675b46054155e0b3f28850bf4668cf24fb1aab457dad1f24b61b3ee6b` |
|
||||
| macOS x64 | `18caa80f90f70f6a7d815335062b0858327891ec854aebe90b585a42eac578e6` |
|
||||
| Linux x64 | `0ee4ceb24062e991650bfb4c0b4ec5fbd7f1a01487b8c70aef0cf257509701a4` |
|
||||
|
||||
`usage-v3-runtime-patch-offline-proof.json` retains fresh isolation/instruction
|
||||
checks, and `native-usage-v3-offline-proof.json` records the usage checks. Earlier
|
||||
proof files remain historical. These are offline source proofs; native emission
|
||||
semantics, accounting completeness and live qualification remain unproven.
|
||||
|
||||
The pinned-source offline harness executes the patched prompt method, native
|
||||
invocation expression and child-update method on all three platform chunks with
|
||||
transport/session dependency doubles. It proves routing and bounds, not actual
|
||||
backend emissions, retry/subagent inclusion, cache/reasoning overlap or billing.
|
||||
Shared metadata persistence and new distribution/profile identities are separate
|
||||
integration requirements. Until semantics and the exact selected model's pricing
|
||||
are verified, these observations grant no accounting coverage. The vendor package
|
||||
declares no license grant; its bundled license notices describe third-party
|
||||
components. [Cursor's terms](https://cursor.com/en-US/terms-of-service) and the
|
||||
applicable agreement remain release-review evidence for modified distributions;
|
||||
this source work does not establish redistribution permission.
|
||||
|
||||
The documentation calls todo/task/image methods notifications. The pinned presenter actually calls `connection.extMethod(...).catch(...)`: these messages can carry JSON-RPC IDs. They need immediate acknowledgement plus activity normalization. They must never become human input requests or block awaiting an unnecessary decision.
|
||||
|
||||
Question and plan requests lack a session ID. The shared hook binds them to the retained connection, active execution/session/turn and originating request ID; it rejects stale/duplicate answers, cancels on turn termination, persists requests before exposing them, and awaits exact JSON-RPC pipe-write delivery before recording a resolved interaction. This proves transport handoff, not an additional provider application-level acknowledgement. The provider module does not substitute a best-guess session.
|
||||
|
||||
Fixed launch arguments are `--disable-project-configs --disable-auto-update acp`, using the verified bundled Node and absolute verified `index.js`. Private HOME/XDG roots are required together with CURSOR_CONFIG_DIR, CURSOR_DATA_DIR, disabled compilation caching, `AGENT_CLI_CREDENTIAL_STORE=memory`, and `NO_OPEN_BROWSER=1`. Only explicitly bound CURSOR_API_KEY or CURSOR_AUTH_TOKEN may enter. The controller creates a provider/session-scoped credential-name binding from the explicit task environment, ignoring inherited or caller-supplied markers. Rust forwards it through the closed sidecar environment boundary. Before host admission, the sidecar rejects missing, stale, wrong-provider or unbound credentials and removes the marker before provider launch. Tests cover this full boundary and preserve legacy credential behavior. Do not use `--force`, `--trust`, or `--approve-mcps` to paper over governance.
|
||||
|
||||
`--disable-project-configs` only suppresses `.cursor/cli.json`. It does not suppress project MCP, Cursor/Claude hooks, or installed plugins. `assertCursorWorkspacePolicy` refuses ambient project execution config from the workspace through its nearest Git root, including symlinks and unreadable configuration. Ordinary Claude settings with neither hooks nor plugins remain admissible. Enterprise system hooks are checked too. The host repeats admission checks before native launch. The vendor admission check alone cannot prevent a concurrent file mutation. The current `paperclip-cursor-instructions-v2` distribution patch removes local hook loading, asynchronous team hook synchronization, ambient MCP loader initialization, and ambient-client merging from the ACP source. The retained admission check remains defense in depth. This closes those discovery paths independently of filesystem polling; authenticated validation on the patched final runtime remains required.
|
||||
`--disable-project-configs` only suppresses `.cursor/cli.json`. It does not suppress project MCP, Cursor/Claude hooks, or installed plugins. `assertCursorWorkspacePolicy` refuses ambient project execution config from the workspace through its nearest Git root, including symlinks and unreadable configuration. Ordinary Claude settings with neither hooks nor plugins remain admissible. Enterprise system hooks are checked too. The host repeats admission checks before native launch. The vendor admission check alone cannot prevent a concurrent file mutation. The current `paperclip-cursor-usage-v3` distribution patch removes local hook loading, asynchronous team hook synchronization, ambient MCP loader initialization, and ambient-client merging from the ACP source. The retained admission check remains defense in depth. This closes those discovery paths independently of filesystem polling; authenticated validation on the patched final runtime remains required.
|
||||
|
||||
Artifact paths are never automatically read or uploaded. References require a present regular file under the physical workspace, reject traversal and symbolic links, and retain `registered:false`. Private/outside paths are omitted and produce a visible warning. This is metadata validation, not durable file ownership or a replacement for artifact registration checks.
|
||||
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
# Pi rich ACP runtime
|
||||
|
||||
Current checkpoint (2026-09-30): **Pi profile v9 remains unqualified**. Current runtime source is `5b8e4454ef0bf12d0bb068c2e41d8c9df9356a1c`; controller/Product harness source is `40064d28522de25fea85c1297f35b41bb8a8897a`. Runtime builds for macOS ARM64/x64 and Linux x64 are complete. No paid profile-v9 pass is claimed. A credential with a verifiable spend limit is still needed for the remaining paid qualification. The optional transport-budget candidate is frozen on a separate branch and is not integrated or a live spending guarantee. See the [comparative capability report](runner-rich-acp-capabilities.md) for current qualification gates and the field audit. The dated observations below retain their original profile identities.
|
||||
Current source candidate (2026-09-30): **Pi profile v10 is unqualified**.
|
||||
The native wrapper/runtime and their platform closures remain unchanged from v9.
|
||||
The declaration also pins the full shared ACPX patch, whose additive Cursor
|
||||
metadata persistence changes its digest. Therefore v10 rejects v9 sessions;
|
||||
retained evidence is not relabeled. No provider budget, token accounting or model
|
||||
pricing behavior changes. Paid qualification still requires the existing enforced
|
||||
provider spending bound and fresh exact-runtime pack/image admission.
|
||||
|
||||
Historical v9 checkpoint (2026-09-30): **Pi profile v9 remains unqualified**. Current runtime source is `5b8e4454ef0bf12d0bb068c2e41d8c9df9356a1c`; controller/Product harness source is `40064d28522de25fea85c1297f35b41bb8a8897a`. Runtime builds for macOS ARM64/x64 and Linux x64 are complete. No paid profile-v9 pass is claimed. A credential with a verifiable spend limit is still needed for the remaining paid qualification. The optional transport-budget candidate is frozen on a separate branch and is not integrated or a live spending guarantee. See the [comparative capability report](runner-rich-acp-capabilities.md) for current qualification gates and the field audit. The dated observations below retain their original profile identities.
|
||||
|
||||
Historical implementation candidate, 2026-09-29: profile version 8 repairs
|
||||
native assistant-message attribution. Real SDK `message_start` and `message_end`
|
||||
|
||||
@@ -1,6 +1,27 @@
|
||||
# Rich ACP integration and qualification report
|
||||
|
||||
Updated: 2026-09-30 UTC. **Cursor v7, Copilot v7 and Pi v9 remain unqualified.** Current paid controller and Product harness source is `40064d28522de25fea85c1297f35b41bb8a8897a`; runtime source is `5b8e4454ef0bf12d0bb068c2e41d8c9df9356a1c`. Runtime builds for macOS ARM64, macOS x64 and Linux x64 are complete. Builds and credential-free admission do not qualify authenticated behavior. Reviewed PR head `3f1933db551103e6b9e3e5fa1b3bc905becb6e71` differs from controller40064 only by the OpenCode test fixture's explicit Node-copy dereference option; it does not relabel the frozen execution source.
|
||||
Current source checkpoint (2026-09-30): **Cursor v8, Copilot v8 and Pi v10
|
||||
remain unqualified**. All three declarations bind shared ACPX patch SHA-256
|
||||
`bd5393058a218040d217fa85449d59a6f30507de54cd645bf0ef21422823f85e`.
|
||||
Cursor additionally binds newly materialized `paperclip-cursor-usage-v3` closures;
|
||||
Copilot and Pi native distribution bytes are unchanged. The new identities reject
|
||||
retained 7/7/9 sessions. Claude, Codex and Grok profile declarations stay unchanged.
|
||||
The metadata is observation-only: native counters always remain partial, absent
|
||||
fields stay absent, and no token totals or dollar costs are inferred. A supported
|
||||
schema does not prove native counter aggregation semantics. No new paid pass,
|
||||
build, publication or production qualification is claimed for these identities.
|
||||
The external eval definitions still pin 7/7/9 and need a separate reviewed update.
|
||||
|
||||
| Source candidate | Remaining production gate |
|
||||
| --- | --- |
|
||||
| Cursor v8 | Qualify the exact new runtime locally and on Daytona; resolve native counter semantics and verified exact-model pricing before treating observations as accounting. Native input, permissions, recovery, isolation and warm continuity remain required. |
|
||||
| Copilot v8 | Admit fresh exact-runtime packs/images and qualify the required local/Daytona cases. Prior v7 passes remain case-specific historical proof. Native external-tool/sampling/limits callbacks must not leave unresolved admitted requests. |
|
||||
| Pi v10 | Retain the enforced spending-bound requirement, then qualify the exact runtime locally and on Daytona. The optional transport-budget candidate remains unintegrated. |
|
||||
|
||||
The following checkpoint and chronology retain their original source/profile
|
||||
identities; their builds and paid outcomes do not qualify the new candidates.
|
||||
|
||||
Historical 7/7/9 checkpoint, 2026-09-30 UTC. **Cursor v7, Copilot v7 and Pi v9 remain unqualified.** Current paid controller and Product harness source is `40064d28522de25fea85c1297f35b41bb8a8897a`; runtime source is `5b8e4454ef0bf12d0bb068c2e41d8c9df9356a1c`. Runtime builds for macOS ARM64, macOS x64 and Linux x64 are complete. Builds and credential-free admission do not qualify authenticated behavior. Reviewed PR head `3f1933db551103e6b9e3e5fa1b3bc905becb6e71` differs from controller40064 only by the OpenCode test fixture's explicit Node-copy dereference option; it does not relabel the frozen execution source.
|
||||
|
||||
Before the controller hash fix, exact-head CI passed for Cursor `b74ca5eb976a48e35027be6eb8ea1fb09d181b82` (53 successes, two skips) and Copilot `3f1933db551103e6b9e3e5fa1b3bc905becb6e71` (54 successes, four skips; two superseded duplicate checks cancelled). Those two reviewed heads had no failed or pending checks; Greptile reported 5/5 for both. The subsequent controller fix needs its own CI review. Foundation [#14430](https://github.com/paperclipai/paperclip/pull/14430) and Runner Eval definitions [#33](https://github.com/paperclipai/paperclip-evals/pull/33) are merged. The v7/v7/v9 definition update [#34](https://github.com/paperclipai/paperclip-evals/pull/34) merged as `52f6e897c08d236776a21723257e12c909d71137`, with 136 deterministic tests and 21 validated cells. These definitions are not paid proof. Provider PRs remain drafts.
|
||||
|
||||
@@ -8,7 +29,7 @@ Current Cursor v7 local attempt `40064-cursor-plan-01` failed passive accepted-p
|
||||
|
||||
The [prior evidence checkpoint](runner-rich-acp-validation-2026-09-30-current.json) retains controller/harness `ca7026182c2b861e3badbcb7e5b733445a6ee403` and runtime `e822b614fc368043f4b3d5e34a8d9bbda644e055`; despite its filename, it is historical for the current source. The [earlier September 30 checkpoint](runner-rich-acp-validation-2026-09-30.json) is also historical. Original failures, unknown costs and exact source/profile identities remain unchanged.
|
||||
|
||||
| Current production gate | Required evidence or decision |
|
||||
| Gate recorded at the historical checkpoint | Required evidence or decision |
|
||||
| --- | --- |
|
||||
| Cursor v7 | Resolve the failed accepted-plan settlement and qualify the exact corrected bytes. Native AskQuestion availability remains unverified; semantic questions cannot substitute for its callback. Complete the required local/Daytona native input, permission, recovery, isolation and warm-continuity cases. |
|
||||
| Copilot v7 | The exact local attached-async and native-denial cases passed; broader provider qualification remains pending. Complete the remaining local/Daytona denial, recovery, isolation and warm-continuity cases. Explicit detached work remains unsupported. Native external-tool/sampling/limits callbacks require proof that admitted tools cannot leave an unresolved request, or a qualified responder. |
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"darwin-arm64": {
|
||||
"url": "https://downloads.cursor.com/lab/2026.09.26-dd393fe/darwin/arm64/agent-cli-package.tar.gz",
|
||||
"archiveSha256": "538827d96a779bab854a865c8e42859e8e87db34b5f69770261b90fc8cfff191",
|
||||
"closureSha256": "912a37edb67fd7737809c24049dce2db20b1330f98b15a6e43809f0e0943cb6c",
|
||||
"closureSha256": "0681abb675b46054155e0b3f28850bf4668cf24fb1aab457dad1f24b61b3ee6b",
|
||||
"executable": "node",
|
||||
"entrypoint": "index.js",
|
||||
"vendorClosureSha256": "77394184a89b0e7384971181da19c3c82d83a399b04e7944b3f94fe3d7e62b25"
|
||||
@@ -13,7 +13,7 @@
|
||||
"darwin-x64": {
|
||||
"url": "https://downloads.cursor.com/lab/2026.09.26-dd393fe/darwin/x64/agent-cli-package.tar.gz",
|
||||
"archiveSha256": "ed1771c44cbf0f8059c67cac0d939e6a60eb730bd066d7dc4cbb14de314cfb6e",
|
||||
"closureSha256": "322f54c7bd533b202a311ce8e2c8916bfe0202790ef8a40fbb36bb89cbd506cb",
|
||||
"closureSha256": "18caa80f90f70f6a7d815335062b0858327891ec854aebe90b585a42eac578e6",
|
||||
"executable": "node",
|
||||
"entrypoint": "index.js",
|
||||
"vendorClosureSha256": "7c8775160af74e5fb8d25a30e8412c1a6476b0661529078a686e5118fd1c5e5f"
|
||||
@@ -21,11 +21,11 @@
|
||||
"linux-x64": {
|
||||
"url": "https://downloads.cursor.com/lab/2026.09.26-dd393fe/linux/x64/agent-cli-package.tar.gz",
|
||||
"archiveSha256": "8085fd120f5c71f4eae7fea26a043718e5644e3071e4fab3220a0e58c51f9593",
|
||||
"closureSha256": "a375fc771dfe86b9b24532f95956ec4b02c5f4f24d80f5db06565160eb3b672c",
|
||||
"closureSha256": "0ee4ceb24062e991650bfb4c0b4ec5fbd7f1a01487b8c70aef0cf257509701a4",
|
||||
"executable": "node",
|
||||
"entrypoint": "index.js",
|
||||
"vendorClosureSha256": "bf04ef8ea6a63191067a6b3e15139aa2c793d8419daab246aa3f0a012e1bbcd9"
|
||||
}
|
||||
},
|
||||
"patchVersion": "paperclip-cursor-instructions-v2"
|
||||
"patchVersion": "paperclip-cursor-usage-v3"
|
||||
}
|
||||
@@ -207,7 +207,7 @@ impl AcpxProviderDescriptor {
|
||||
"0.0.33",
|
||||
Some("@earendil-works/pi-coding-agent"),
|
||||
Some("0.84.2"),
|
||||
"sha256:edf058835ee84de3869c4a8e8bdb71a934ffdb9f34daa37ae6faeb92371d1cdf",
|
||||
"sha256:5e1a4357fc108fa79a66111413f85f6353b3dd3ab802ddfb80fc66719bc12571",
|
||||
),
|
||||
"cursor" => (
|
||||
self.model.as_str(),
|
||||
@@ -215,7 +215,7 @@ impl AcpxProviderDescriptor {
|
||||
"2026.09.26-dd393fe",
|
||||
None,
|
||||
None,
|
||||
"sha256:f4c7af914738149cf868d071e53ac4917658fb055224715a2c89d3f59b335503",
|
||||
"sha256:b9e94cbcdce2783665612c85caf019c4defe37d3d245a694de590ebdf5b1f1a3",
|
||||
),
|
||||
"copilot" => (
|
||||
self.model.as_str(),
|
||||
@@ -223,7 +223,7 @@ impl AcpxProviderDescriptor {
|
||||
"1.0.88",
|
||||
None,
|
||||
None,
|
||||
"sha256:b11721382293b39c1d6dd363eae547b5eae0ccca2cd5dcafc9127cc060ee9526",
|
||||
"sha256:3b97dac7020396a84997040ee2864a47af615d69aaf1c951de391e850038157a",
|
||||
),
|
||||
"grok" => (
|
||||
"grok-4.7",
|
||||
@@ -2803,7 +2803,7 @@ mod tests {
|
||||
"cursor",
|
||||
"cursor-agent",
|
||||
"2026.09.26-dd393fe",
|
||||
"sha256:f4c7af914738149cf868d071e53ac4917658fb055224715a2c89d3f59b335503",
|
||||
"sha256:b9e94cbcdce2783665612c85caf019c4defe37d3d245a694de590ebdf5b1f1a3",
|
||||
None,
|
||||
None,
|
||||
"explicit-model",
|
||||
@@ -2812,7 +2812,7 @@ mod tests {
|
||||
"copilot",
|
||||
"@github/copilot",
|
||||
"1.0.88",
|
||||
"sha256:b11721382293b39c1d6dd363eae547b5eae0ccca2cd5dcafc9127cc060ee9526",
|
||||
"sha256:3b97dac7020396a84997040ee2864a47af615d69aaf1c951de391e850038157a",
|
||||
None,
|
||||
None,
|
||||
"explicit-model",
|
||||
@@ -2821,7 +2821,7 @@ mod tests {
|
||||
"pi",
|
||||
"pi-acp",
|
||||
"0.0.33",
|
||||
"sha256:edf058835ee84de3869c4a8e8bdb71a934ffdb9f34daa37ae6faeb92371d1cdf",
|
||||
"sha256:5e1a4357fc108fa79a66111413f85f6353b3dd3ab802ddfb80fc66719bc12571",
|
||||
Some("@earendil-works/pi-coding-agent"),
|
||||
Some("0.84.2"),
|
||||
"openrouter/deepseek/deepseek-v4-flash-0731",
|
||||
@@ -2843,6 +2843,18 @@ mod tests {
|
||||
}
|
||||
let valid: AcpxProviderDescriptor = serde_json::from_value(value.clone()).unwrap();
|
||||
valid.validate(&context()).unwrap();
|
||||
let mut previous_identity = value.clone();
|
||||
previous_identity["commandDigest"] = json!(match agent {
|
||||
"cursor" =>
|
||||
"sha256:f4c7af914738149cf868d071e53ac4917658fb055224715a2c89d3f59b335503",
|
||||
"copilot" =>
|
||||
"sha256:b11721382293b39c1d6dd363eae547b5eae0ccca2cd5dcafc9127cc060ee9526",
|
||||
"pi" => "sha256:edf058835ee84de3869c4a8e8bdb71a934ffdb9f34daa37ae6faeb92371d1cdf",
|
||||
_ => unreachable!(),
|
||||
});
|
||||
let previous_identity: AcpxProviderDescriptor =
|
||||
serde_json::from_value(previous_identity).unwrap();
|
||||
assert!(previous_identity.validate(&context()).is_err());
|
||||
let mut previous_contract = value.clone();
|
||||
previous_contract["commandDigest"] = json!(match agent {
|
||||
"cursor" =>
|
||||
|
||||
+1
-1
@@ -17,7 +17,7 @@ use sha2::{Digest, Sha256};
|
||||
const CODEX_ACPX_DIGEST: &str =
|
||||
"sha256:c4538599d1ab767db5dff50934f13bb5ba313a59d9c4a83e993fac4617ea63d3";
|
||||
const PI_ACPX_DIGEST: &str =
|
||||
"sha256:edf058835ee84de3869c4a8e8bdb71a934ffdb9f34daa37ae6faeb92371d1cdf";
|
||||
"sha256:5e1a4357fc108fa79a66111413f85f6353b3dd3ab802ddfb80fc66719bc12571";
|
||||
|
||||
fn temporary_directory(label: &str) -> PathBuf {
|
||||
let nonce = SystemTime::now()
|
||||
|
||||
@@ -7,7 +7,7 @@ import { COPILOT_VERSION, materializePinnedCopilotBinary, resolveCopilotDistribu
|
||||
|
||||
const MAX_ARCHIVE_BYTES = 128 * 1024 * 1024;
|
||||
const MAX_EXPANDED_BYTES = 384 * 1024 * 1024;
|
||||
const PROFILE_DIGEST = "sha256:b11721382293b39c1d6dd363eae547b5eae0ccca2cd5dcafc9127cc060ee9526";
|
||||
const PROFILE_DIGEST = "sha256:3b97dac7020396a84997040ee2864a47af615d69aaf1c951de391e850038157a";
|
||||
|
||||
/** Build-time only; outputRoot is the exact runner-owned platform asset directory. */
|
||||
export async function buildPinnedCopilotDistribution({ outputRoot, platform = process.platform, architecture = process.arch }, { fetchImpl = fetch } = {}) {
|
||||
|
||||
@@ -28,7 +28,7 @@ export async function materializeCandidateProviderPack({ provider, outputRoot })
|
||||
if (provider === "cursor") {
|
||||
const result = await materializePinnedCursorDistribution({ destination: outputRoot });
|
||||
return { version: result.version,
|
||||
profileDigest: "sha256:f4c7af914738149cf868d071e53ac4917658fb055224715a2c89d3f59b335503",
|
||||
profileDigest: "sha256:b9e94cbcdce2783665612c85caf019c4defe37d3d245a694de590ebdf5b1f1a3",
|
||||
closureDigest: `sha256:${result.closureSha256}` };
|
||||
}
|
||||
throw new Error(`The ${provider} candidate distribution builder is not included in this source revision`);
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
/** Self-contained: embedded verbatim into the digest-pinned ACP module.
|
||||
* Observations are NOT usage accounting. Native counter semantics remain unknown.
|
||||
*/
|
||||
export function createCursorNativeUsage(promptId) {
|
||||
if (typeof promptId !== "string" || !/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(promptId)) throw new Error("Invalid Cursor usage prompt identity");
|
||||
const limits = { maxObservations: 64, maxInvocations: 64, maxBytes: 16384 };
|
||||
// Reserve bounded ACPX request/message identities and wrapper keys within 16 KiB.
|
||||
const envelopeMaxBytes = limits.maxBytes - 640;
|
||||
const fields = ["inputTokens", "outputTokens", "cacheReadTokens", "cacheWriteTokens", "reasoningTokens"];
|
||||
const reasons = new Set(["native_counter_semantics_unverified"]);
|
||||
const observations = [];
|
||||
const children = new WeakMap();
|
||||
const trackedInvocations = new Set(); // At most maxInvocations, including children.
|
||||
let closed = false, truncated = false, invocations = 0;
|
||||
let finished;
|
||||
const envelope = () => ({
|
||||
schema: "paperclip.cursor.native-usage.v1", source: "native_turn_ended", promptId,
|
||||
completeness: "partial", reasons: [...reasons], observations, limits, truncated,
|
||||
});
|
||||
const truncate = () => { truncated = true; reasons.add("observation_limit_reached"); };
|
||||
function begin(role, nativeRun) {
|
||||
if (closed) return null;
|
||||
if (invocations >= limits.maxInvocations) { truncate(); return null; }
|
||||
const invocationId = `invocation-${++invocations}`;
|
||||
let ended = false, sequence = 0;
|
||||
const collector = {
|
||||
observe(update) {
|
||||
if (closed || ended || truncated || update?.message?.case !== "turnEnded") return;
|
||||
if (observations.length >= limits.maxObservations) { truncate(); return; }
|
||||
const value = update.message.value;
|
||||
const counters = {};
|
||||
for (const field of fields) {
|
||||
const raw = value?.[field];
|
||||
if (raw === undefined) continue;
|
||||
const number = typeof raw === "bigint" && raw >= 0n && raw <= BigInt(Number.MAX_SAFE_INTEGER) ? Number(raw) : raw;
|
||||
if (typeof number === "number" && Number.isSafeInteger(number) && number >= 0) counters[field] = number;
|
||||
else reasons.add("invalid_native_counter");
|
||||
}
|
||||
if (Object.keys(counters).length < fields.length) reasons.add("native_counters_missing");
|
||||
if (sequence > 0) reasons.add("multiple_terminal_observations");
|
||||
observations.push({ invocationId, role, nativeRun, sequence: ++sequence, counters });
|
||||
},
|
||||
end() { if (!ended && sequence === 0 && !closed) reasons.add("native_terminal_not_observed"); ended = true; },
|
||||
};
|
||||
trackedInvocations.add(collector);
|
||||
return collector;
|
||||
}
|
||||
return {
|
||||
beginParent() { return begin("parent", invocations + 1); },
|
||||
observeChild(child, update) {
|
||||
if (closed || !child || child.terminal || !Number.isSafeInteger(child.runs) || child.runs < 1) return;
|
||||
// The native callback identifies only the child, not its run generation.
|
||||
// After ID reuse, delayed old-run updates cannot be distinguished safely.
|
||||
if (child.runs !== 1) { reasons.add("child_run_attribution_unverified"); return; }
|
||||
let run = children.get(child);
|
||||
if (!run || run.nativeRun !== child.runs) {
|
||||
run?.collector?.end();
|
||||
run = { nativeRun: child.runs, collector: begin("child", child.runs) };
|
||||
children.set(child, run);
|
||||
}
|
||||
run.collector?.observe(update);
|
||||
},
|
||||
finish() {
|
||||
if (finished !== undefined) return JSON.parse(finished);
|
||||
for (const invocation of trackedInvocations) invocation.end();
|
||||
trackedInvocations.clear();
|
||||
closed = true;
|
||||
if (observations.length === 0) reasons.add("native_terminal_not_observed");
|
||||
// Bound the FINAL envelope after every reason has been added. Its fixed
|
||||
// ASCII keys/IDs/reasons make JSON length equal to its UTF-8 byte length.
|
||||
finished = JSON.stringify(envelope());
|
||||
while (finished.length > envelopeMaxBytes && observations.length > 0) {
|
||||
observations.pop(); truncate(); finished = JSON.stringify(envelope());
|
||||
}
|
||||
// Detached snapshot: late provider callbacks cannot mutate a returned receipt.
|
||||
return JSON.parse(finished);
|
||||
},
|
||||
close() { closed = true; trackedInvocations.clear(); },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { createCursorNativeUsage } from "./cursor-native-usage.mjs";
|
||||
|
||||
const id = "00000000-0000-0000-0000-000000000001";
|
||||
const event = counters => ({ message: { case: "turnEnded", value: counters } });
|
||||
test("observes numeric counters without accounting, totals, or invented fields", () => {
|
||||
const usage = createCursorNativeUsage(id), invocation = usage.beginParent();
|
||||
invocation.observe(event({ inputTokens: 100n, outputTokens: 3, reasoningTokens: 2n, cost: 9, text: "private" }));
|
||||
invocation.end();
|
||||
const result = usage.finish();
|
||||
assert.equal(result.completeness, "partial");
|
||||
assert.ok(result.reasons.includes("native_counter_semantics_unverified"));
|
||||
assert.deepEqual(result.observations[0].counters, { inputTokens: 100, outputTokens: 3, reasoningTokens: 2 });
|
||||
assert.ok(!JSON.stringify(result).includes("private"));
|
||||
assert.equal(result.usage, undefined);
|
||||
});
|
||||
test("all native fields including explicit zeros still remain semantically unverified", () => {
|
||||
const usage = createCursorNativeUsage(id);
|
||||
usage.beginParent().observe(event({ inputTokens: 0n, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, reasoningTokens: 0 }));
|
||||
assert.equal(usage.finish().completeness, "partial");
|
||||
assert.deepEqual(usage.finish().reasons, ["native_counter_semantics_unverified"]);
|
||||
});
|
||||
test("rejects invalid identity and omits invalid counters", () => {
|
||||
assert.throws(() => createCursorNativeUsage("private arbitrary text"));
|
||||
for (const value of [-1, 1.2, NaN, Infinity, null, "20", {}, -1n, BigInt(Number.MAX_SAFE_INTEGER) + 1n]) {
|
||||
const usage = createCursorNativeUsage(id);
|
||||
usage.beginParent().observe(event({ inputTokens: value, outputTokens: 1 }));
|
||||
const result = usage.finish();
|
||||
assert.deepEqual(result.observations[0].counters, { outputTokens: 1 });
|
||||
assert.ok(result.reasons.includes("invalid_native_counter"));
|
||||
}
|
||||
});
|
||||
test("separate native invocations retain repeated snapshots without adding or deduplicating counts", () => {
|
||||
const usage = createCursorNativeUsage(id), first = usage.beginParent();
|
||||
first.observe(event({ inputTokens: 4 })); first.observe(event({ inputTokens: 4 })); first.end();
|
||||
first.observe(event({ inputTokens: 999 }));
|
||||
usage.beginParent().observe(event({ inputTokens: 2 }));
|
||||
const result = usage.finish();
|
||||
assert.deepEqual(result.observations.map(row => row.counters.inputTokens), [4, 4, 2]);
|
||||
assert.deepEqual(result.observations.map(row => row.invocationId), ["invocation-1", "invocation-1", "invocation-2"]);
|
||||
assert.ok(result.reasons.includes("multiple_terminal_observations"));
|
||||
});
|
||||
test("history, late callbacks, closed prompts and reused child runs stay isolated", () => {
|
||||
const usage = createCursorNativeUsage(id), child = { runs: 1, terminal: false };
|
||||
const first = usage.beginParent();
|
||||
first.observe({ message: { case: "tokenDelta", value: { tokens: 100 } } });
|
||||
usage.observeChild(child, event({ outputTokens: 5 }));
|
||||
child.runs = 2; usage.observeChild(child, event({ outputTokens: 6 }));
|
||||
child.terminal = true; usage.observeChild(child, event({ outputTokens: 999 }));
|
||||
const result = usage.finish();
|
||||
first.observe(event({ outputTokens: 999 })); child.terminal = false; usage.observeChild(child, event({ outputTokens: 999 }));
|
||||
assert.deepEqual(result.observations.map(row => [row.role, row.nativeRun, row.counters.outputTokens]), [["child", 1, 5]]);
|
||||
assert.ok(result.reasons.includes("child_run_attribution_unverified"));
|
||||
assert.deepEqual(usage.finish(), result);
|
||||
assert.equal(usage.beginParent(), null);
|
||||
assert.deepEqual(createCursorNativeUsage(id).finish().observations, []);
|
||||
});
|
||||
test("bounds observation count, invocation count, and UTF-8 envelope bytes", () => {
|
||||
const usage = createCursorNativeUsage(id);
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
const invocation = usage.beginParent();
|
||||
for (let j = 0; j < 3; j++) invocation?.observe(event(Object.fromEntries(
|
||||
["inputTokens", "outputTokens", "cacheReadTokens", "cacheWriteTokens", "reasoningTokens"].map(k => [k, Number.MAX_SAFE_INTEGER]))));
|
||||
}
|
||||
const result = usage.finish();
|
||||
assert.ok(result.observations.length <= 64);
|
||||
assert.ok(Buffer.byteLength(JSON.stringify(result)) <= 15744);
|
||||
assert.equal(result.truncated, true);
|
||||
assert.ok(result.reasons.includes("observation_limit_reached"));
|
||||
});
|
||||
test("finish finalizes missing children and open parents even when another invocation reported", () => {
|
||||
for (const missing of ["child", "parent"]) {
|
||||
const usage = createCursorNativeUsage(id);
|
||||
usage.beginParent().observe(event({ inputTokens: 1 }));
|
||||
if (missing === "child") usage.observeChild({ runs: 1, terminal: false }, { message: { case: "textDelta" } });
|
||||
else usage.beginParent();
|
||||
const result = usage.finish();
|
||||
assert.ok(result.reasons.includes("native_terminal_not_observed"));
|
||||
assert.equal(result.observations.length, 1);
|
||||
assert.deepEqual(usage.finish(), result);
|
||||
result.reasons.length = 0;
|
||||
assert.ok(usage.finish().reasons.includes("native_terminal_not_observed"));
|
||||
}
|
||||
});
|
||||
test("final reason growth cannot exceed the envelope byte cap", () => {
|
||||
let trimmed = false;
|
||||
for (let count = 48; count <= 64; count++) {
|
||||
const usage = createCursorNativeUsage(id), parent = usage.beginParent();
|
||||
for (let i = 0; i < count; i++) parent.observe(event(Object.fromEntries(
|
||||
["inputTokens", "outputTokens", "cacheReadTokens", "cacheWriteTokens", "reasoningTokens"].map(k => [k, Number.MAX_SAFE_INTEGER]))));
|
||||
usage.observeChild({ runs: 1, terminal: false }, { message: { case: "textDelta" } });
|
||||
usage.observeChild({ runs: 2, terminal: false }, event({ inputTokens: 1 }));
|
||||
const result = usage.finish();
|
||||
assert.ok(result.reasons.includes("native_terminal_not_observed"));
|
||||
assert.ok(result.reasons.includes("child_run_attribution_unverified"));
|
||||
assert.ok(Buffer.byteLength(JSON.stringify(result)) <= 15744);
|
||||
if (result.observations.length < count) {
|
||||
trimmed = true; assert.equal(result.truncated, true);
|
||||
assert.ok(result.reasons.includes("observation_limit_reached"));
|
||||
}
|
||||
}
|
||||
assert.equal(trimmed, true);
|
||||
});
|
||||
|
||||
test("maximum bounded ACPX identities and envelope fit the final stored object", () => {
|
||||
const usage = createCursorNativeUsage(id), parent = usage.beginParent();
|
||||
for (let count = 0; count < 64; count++) parent.observe(event(Object.fromEntries(
|
||||
["inputTokens", "outputTokens", "cacheReadTokens", "cacheWriteTokens", "reasoningTokens"].map(k => [k, Number.MAX_SAFE_INTEGER]))));
|
||||
usage.beginParent();
|
||||
const receipt = usage.finish();
|
||||
assert.equal(receipt.limits.maxBytes, 16384);
|
||||
assert.equal(receipt.truncated, true);
|
||||
assert.ok(Buffer.byteLength(JSON.stringify(receipt)) <= 15744);
|
||||
const stored = { request_id: "r".repeat(240), prompt_message_id: "m".repeat(240), receipt };
|
||||
assert.ok(Buffer.byteLength(JSON.stringify(stored)) <= 16384);
|
||||
assert.ok(Buffer.byteLength(JSON.stringify(stored)) - Buffer.byteLength(JSON.stringify(receipt)) <= 640);
|
||||
});
|
||||
@@ -1,14 +1,15 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { createCursorNativeUsage } from "./cursor-native-usage.mjs";
|
||||
|
||||
// This is an owned ACP-only patch of the immutable vendor archive. The legacy
|
||||
// Cursor adapter and the vendor's interactive CLI are not changed.
|
||||
export const CURSOR_RUNTIME_PATCH_VERSION = "paperclip-cursor-instructions-v2";
|
||||
export const CURSOR_RUNTIME_PATCH_VERSION = "paperclip-cursor-usage-v3";
|
||||
export const CURSOR_RUNTIME_PATCH_PINS = Object.freeze({
|
||||
"darwin-arm64": { file: "5672.index.js", before: "7784c8b16d4e639814c13b12be2a687f5dc2cd98cbf29ed0a10820778ab1bf62", after: "c01657c111f65153d7a40a923f01a5a86d393d1cd6893eaa03f71b9604d2af0c" },
|
||||
"darwin-x64": { file: "9841.index.js", before: "3c0aaf6ecc4fceb0f95e1731deec75384984570d14d0871cecec11972b948ac4", after: "cf0b5dbf4ec67219ce4fc49616af4c0041c4a9774e4b0840be170b749b8bc683" },
|
||||
"linux-x64": { file: "1699.index.js", before: "2de420f1b31e70ca74b083a1ce5b519c2abffa5789d71cc06e84d2c38acb7c07", after: "d3d634fc2080efac084f00e5d403a44d0edf1c39b02b02a4ed7d5ce0e8c703ff" },
|
||||
"darwin-arm64": { file: "5672.index.js", before: "7784c8b16d4e639814c13b12be2a687f5dc2cd98cbf29ed0a10820778ab1bf62", after: "83fa4d462b3d8fd95b05420589cc80b8a6536dbff0cc3c5d36fec69d8a753970" },
|
||||
"darwin-x64": { file: "9841.index.js", before: "3c0aaf6ecc4fceb0f95e1731deec75384984570d14d0871cecec11972b948ac4", after: "060c8816fac3d110f5931ce626e5f2d841d8252fba7dafd52e37157ca915301d" },
|
||||
"linux-x64": { file: "1699.index.js", before: "2de420f1b31e70ca74b083a1ce5b519c2abffa5789d71cc06e84d2c38acb7c07", after: "3435f90f028eb5b08cf8cb7b289694993636f2868e6af217c167a5fd27ac03b2" },
|
||||
});
|
||||
const digest = value => createHash("sha256").update(value).digest("hex");
|
||||
|
||||
@@ -70,6 +71,22 @@ return paperclipCursorInstructionSnapshot=Object.freeze({rules,ack:Object.freeze
|
||||
replace(oldAction, newAction);
|
||||
replace('if(e instanceof g.T&&e.code===m.C.Unauthenticated)return void(yield this.sendAgentMessageChunk("\\n\\nError: [unauthenticated] Backend rejected authentication. Verify this is a User API Key for the same endpoint/environment, then rerun with --debug for request-level auth logs."));'.replace('m.C.Unauthenticated', `${platform === "darwin-x64" ? "f" : "m"}.C.Unauthenticated`),
|
||||
`if(e instanceof g.T&&e.code===${platform === "darwin-x64" ? "f" : "m"}.C.Unauthenticated)throw new (${errorType})(-32000,"Cursor provider authentication rejected",{schema:"paperclip.cursor.provider-error.v1",kind:"authentication_required"});`);
|
||||
// This diagnostic envelope never emits standard ACP usage or cost. Keep the
|
||||
// collector local to handlePrompt, including overlapping cancelled prompts.
|
||||
replace(`const ${errorType}=`, `const paperclipCreateCursorUsage=${createCursorNativeUsage.toString()};const ${errorType}=`);
|
||||
replace("handlePrompt(e){return V(this,void 0,void 0,(function*(){var t,n,o;", "handlePrompt(e){return V(this,void 0,void 0,(function*(){var t,n,o;const paperclipUsage=paperclipCreateCursorUsage(crypto.randomUUID());let paperclipUsagePublisher,paperclipUsageTurn;try{");
|
||||
replace("n.beginTurn(),d=()=>", "n.beginTurn();paperclipUsagePublisher=this.subagentPublisher;paperclipUsageTurn=l;if(paperclipUsagePublisher&&l!==undefined){paperclipUsagePublisher.paperclipUsageCollectors??=new Map;paperclipUsagePublisher.paperclipUsageCollectors.set(l,paperclipUsage)}const d=()=>");
|
||||
replace("yield this.processPrompt(e,s,l)", "yield this.processPrompt(e,s,l,paperclipUsage)");
|
||||
replace('{stopReason:"cancelled"}):{stopReason:"end_turn"}}))}claimTaskToolCall', '{stopReason:"cancelled",_meta:{paperclipCursorUsage:paperclipUsage.finish()}}):{stopReason:"end_turn",_meta:{paperclipCursorUsage:paperclipUsage.finish()}}}finally{paperclipUsage.close();paperclipUsagePublisher?.paperclipUsageCollectors?.delete(paperclipUsageTurn)}}))}claimTaskToolCall');
|
||||
replace("processPrompt(e,t,n){", "processPrompt(e,t,n,paperclipUsage){");
|
||||
// A fresh sendUpdate closure for EACH native invocation prevents a delayed
|
||||
// callback from an earlier follow-up from being attributed to the next one.
|
||||
const invocation = /D=e=>this\.sharedServices\.agentClient\.run\(t,this\.agentStore\.getConversationStateStructure\(\),e,b\.modelDetails,\$,this\.resources,this\.agentStore\.getBlobStore\(\),u,this\.agentStore,([A-Za-z]+),([A-Za-z]+)\)/g;
|
||||
const invocations = [...source.matchAll(invocation)];
|
||||
if (invocations.length !== 1) throw new Error("Cursor usage invocation anchor is ambiguous");
|
||||
const call = invocations[0];
|
||||
replace(call[0], `D=async e=>{const observation=paperclipUsage?.beginParent();try{return await this.sharedServices.agentClient.run(t,this.agentStore.getConversationStateStructure(),e,b.modelDetails,{...$,sendUpdate:async(context,update)=>{observation?.observe(update);return $.sendUpdate(context,update)}},this.resources,this.agentStore.getBlobStore(),u,this.agentStore,${call[1]},${call[2]})}finally{observation?.end()}}`);
|
||||
replace("onInteractionUpdate(e,t){if(!this.enabled)return;const n=this.children.get(e),o=null==n?void 0:n.presenter;", "onInteractionUpdate(e,t){if(!this.enabled)return;const n=this.children.get(e),o=null==n?void 0:n.presenter;if(n&&!n.terminal)this.paperclipUsageCollectors?.get(n.turn)?.observeChild(n,t);");
|
||||
return source;
|
||||
}
|
||||
|
||||
|
||||
@@ -68,7 +68,7 @@ test("the isolation patch refuses unsupported, drifted, missing or repeated vend
|
||||
});
|
||||
|
||||
test("retained executable vendor proof matches every checked-in patch identity", async () => {
|
||||
const proof = JSON.parse(await readFile(new URL("../test/fixtures/cursor-acp/runtime-patch-offline-proof.json", import.meta.url), "utf8"));
|
||||
const proof = JSON.parse(await readFile(new URL("../test/fixtures/cursor-acp/usage-v3-runtime-patch-offline-proof.json", import.meta.url), "utf8"));
|
||||
assert.equal(proof.patchVersion, CURSOR_RUNTIME_PATCH_VERSION);
|
||||
assert.equal(proof.providerCalls, 0);
|
||||
assert.equal(proof.qualification, "offline-only");
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { runInNewContext } from "node:vm";
|
||||
import { CURSOR_RUNTIME_PATCH_PINS, patchCursorRuntimeSource } from "./cursor-runtime-patch.mjs";
|
||||
|
||||
const hash = value => createHash("sha256").update(value).digest("hex");
|
||||
const plain = value => JSON.parse(JSON.stringify(value));
|
||||
const event = counters => ({ message: { case: "turnEnded", value: counters } });
|
||||
function segment(source, start, end) {
|
||||
const first = source.indexOf(start); assert.ok(first >= 0, `Missing ${start}`);
|
||||
const last = source.indexOf(end, first + start.length); assert.ok(last > first, `Missing ${end}`);
|
||||
return source.slice(first, last);
|
||||
}
|
||||
function awaiter(self, args, _promise, fn) {
|
||||
const generator = fn.apply(self, args ?? []);
|
||||
const advance = (method, value) => {
|
||||
let step; try { step = generator[method](value); } catch (error) { return Promise.reject(error); }
|
||||
return step.done ? Promise.resolve(step.value) : Promise.resolve(step.value).then(
|
||||
value => advance("next", value), error => advance("throw", error));
|
||||
};
|
||||
return advance("next");
|
||||
}
|
||||
const deferred = () => { let resolve; const promise = new Promise(done => { resolve = done; }); return { promise, resolve }; };
|
||||
|
||||
/** Actual pinned method/expression execution with transport and surrounding
|
||||
* session dependencies doubled. No provider entrypoint or inference is run. */
|
||||
export async function qualifyCursorNativeUsage(vendorRoot) {
|
||||
const platforms = [];
|
||||
for (const [platform, pin] of Object.entries(CURSOR_RUNTIME_PATCH_PINS)) {
|
||||
const original = await readFile(join(vendorRoot, platform, "dist-package", pin.file), "utf8");
|
||||
assert.equal(hash(original), pin.before);
|
||||
const source = patchCursorRuntimeSource(original, platform);
|
||||
assert.throws(() => patchCursorRuntimeSource(original + "\n", platform), /digest mismatch/);
|
||||
const exported = {}; runInNewContext(source, { exports: exported });
|
||||
assert.ok(exported.modules["./src/acp/agent-session.ts"]);
|
||||
const collector = runInNewContext(segment(source, "const paperclipCreateCursorUsage=", ";const paperclipCursorResponseError=") + ";paperclipCreateCursorUsage");
|
||||
let ids = 0;
|
||||
const scope = { V: awaiter, G: 10, paperclipCreateCursorUsage: collector,
|
||||
crypto: { randomUUID: () => `00000000-0000-0000-0000-${String(++ids).padStart(12, "0")}` } };
|
||||
const handlePrompt = runInNewContext(`({${segment(source, "handlePrompt(e){", "claimTaskToolCall(e,t){")}}).handlePrompt`, scope);
|
||||
const invocationSource = segment(source, "D=async e=>", ",yield D(y)");
|
||||
const childUpdate = runInNewContext(`({${segment(source, "onInteractionUpdate(e,t){", "onSessionCompleted(e,t){")}}).onInteractionUpdate`);
|
||||
assert.ok(source.includes("yield this.processPrompt(e,s,l,paperclipUsage)"));
|
||||
assert.ok(source.includes("processPrompt(e,t,n,paperclipUsage){"));
|
||||
const checks = [];
|
||||
const createHost = backend => {
|
||||
const publisher = { turn: 0, children: new Map(), enabled: true,
|
||||
beginTurn() { return ++this.turn; }, runIdsForTurn() { return []; }, whenAllTerminal: async () => true,
|
||||
enqueue(_child, fn) { return fn(); }, onInteractionUpdate: childUpdate };
|
||||
const host = {
|
||||
subagentPublisher: publisher, subagentsEnabled: true, backgroundWorkRegistry: { abortWork() {} },
|
||||
ctx: { withCancel() { const abort = new AbortController(); return [{ signal: abort.signal, get canceled() { return abort.signal.aborted; } }, () => abort.abort()]; } },
|
||||
agentStore: { getConversationStateStructure: () => ({}), getBlobStore: () => ({}) }, resources: {},
|
||||
sharedServices: { agentClient: { run: backend } },
|
||||
async processPrompt(request, context, turn, usage) {
|
||||
const invoke = runInNewContext(`(function(){let D;${invocationSource};return D;})`, {
|
||||
paperclipUsage: usage, t: context, b: { modelDetails: {} }, $: { sendUpdate: async () => {} }, u: {}, x: [], A: [], M: {}, k: {},
|
||||
}).call(this);
|
||||
await request.execute({ invoke, usage, context, turn, publisher });
|
||||
},
|
||||
};
|
||||
return { host, publisher, run: request => handlePrompt.call(host, request) };
|
||||
};
|
||||
const successful = createHost(async (_context, _state, action, _model, callbacks) => {
|
||||
await callbacks.sendUpdate({}, event(action));
|
||||
});
|
||||
const first = await successful.run({ execute: async ({ invoke }) => { await invoke({ inputTokens: 12n }); await invoke({ outputTokens: 3n }); } });
|
||||
const envelope = first._meta.paperclipCursorUsage;
|
||||
assert.equal(first.stopReason, "end_turn"); assert.equal(first.usage, undefined);
|
||||
assert.equal(envelope.completeness, "partial");
|
||||
assert.deepEqual(plain(envelope.observations.map(row => row.counters)), [{ inputTokens: 12 }, { outputTokens: 3 }]);
|
||||
assert.equal(successful.publisher.paperclipUsageCollectors.size, 0);
|
||||
checks.push("exact_parent_invocation_capture", "separate_followups", "no_standard_usage");
|
||||
|
||||
const second = await successful.run({ execute: async ({ invoke }) => { await invoke({ outputTokens: 1 }); } });
|
||||
assert.notEqual(second._meta.paperclipCursorUsage.promptId, envelope.promptId);
|
||||
assert.equal(second._meta.paperclipCursorUsage.observations.length, 1);
|
||||
checks.push("warm_prompt_isolation");
|
||||
|
||||
const childHost = createHost(async () => {});
|
||||
const childResult = await childHost.run({ execute: async ({ publisher, turn }) => {
|
||||
const child = { turn, runs: 1, terminal: false, presenter: { presentInteractionUpdate() {} } };
|
||||
publisher.children.set("native-child", child);
|
||||
publisher.onInteractionUpdate("native-child", event({ inputTokens: 7n }));
|
||||
child.runs = 2; publisher.onInteractionUpdate("native-child", event({ inputTokens: 8n }));
|
||||
publisher.onInteractionUpdate("unknown-history-child", event({ inputTokens: 999n }));
|
||||
child.terminal = true; publisher.onInteractionUpdate("native-child", event({ inputTokens: 999n }));
|
||||
} });
|
||||
const childEnvelope = childResult._meta.paperclipCursorUsage;
|
||||
assert.deepEqual(plain(childEnvelope.observations.map(row => [row.role, row.nativeRun, row.counters.inputTokens])), [["child", 1, 7]]);
|
||||
assert.ok(childEnvelope.reasons.includes("child_run_attribution_unverified"));
|
||||
childHost.publisher.children.get("native-child").terminal = false;
|
||||
childHost.publisher.onInteractionUpdate("native-child", event({ inputTokens: 999n }));
|
||||
assert.equal(childEnvelope.observations.length, 1);
|
||||
checks.push("child_run_attribution", "history_and_late_child_isolation");
|
||||
|
||||
const started = deferred(), release = deferred(); let oldCallback;
|
||||
const overlapping = createHost(async (_context, _state, action, _model, callbacks) => {
|
||||
if (action === "first") { oldCallback = callbacks; started.resolve(); await release.promise; }
|
||||
await callbacks.sendUpdate({}, event({ inputTokens: action === "first" ? 10 : 20 }));
|
||||
});
|
||||
const pending = overlapping.run({ execute: async ({ invoke }) => invoke("first") });
|
||||
await started.promise;
|
||||
const next = await overlapping.run({ execute: async ({ invoke }) => invoke("second") });
|
||||
release.resolve(); const cancelled = await pending;
|
||||
assert.equal(cancelled.stopReason, "cancelled");
|
||||
assert.deepEqual(plain(cancelled._meta.paperclipCursorUsage.observations.map(row => row.counters.inputTokens)), [10]);
|
||||
assert.deepEqual(plain(next._meta.paperclipCursorUsage.observations.map(row => row.counters.inputTokens)), [20]);
|
||||
await oldCallback.sendUpdate({}, event({ inputTokens: 999 }));
|
||||
assert.equal(cancelled._meta.paperclipCursorUsage.observations.length, 1);
|
||||
assert.equal(overlapping.publisher.paperclipUsageCollectors.size, 0);
|
||||
checks.push("overlapping_cancellation", "late_parent_callback_isolation");
|
||||
|
||||
const failure = createHost(async () => { throw new Error("fixture transport failure"); });
|
||||
await assert.rejects(failure.run({ execute: async ({ invoke }) => invoke({}) }), /fixture transport failure/);
|
||||
assert.equal(failure.publisher.paperclipUsageCollectors.size, 0);
|
||||
checks.push("exception_cleanup");
|
||||
platforms.push({ platform, vendorSha256: hash(original), candidateSha256: hash(source), checks });
|
||||
}
|
||||
return { schema: "paperclip.cursor.native-usage-offline-proof.v1", providerCalls: 0, standardUsageEmitted: false,
|
||||
boundary: "digest-pinned handlePrompt, native invocation and child update methods; surrounding session/transport doubled", platforms };
|
||||
}
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
assert.equal(process.argv.length, 3, "Usage: node qualify-cursor-native-usage.mjs VENDOR_RESEARCH_ROOT");
|
||||
console.log(JSON.stringify(await qualifyCursorNativeUsage(process.argv[2]), null, 2));
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { QUALIFIED_ACPX_PROFILES, resolveQualifiedAcpxProfile } from "../drivers/acpx/qualified-profiles.js";
|
||||
import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
|
||||
import { mkdtempSync, readFileSync, writeFileSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
@@ -557,6 +557,24 @@ describe("native backend factory", () => {
|
||||
})).toThrow("ACPX candidate direct execution requires completed qualification");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["cursor", "../../test/fixtures/cursor-acp/profile-v7-identity.json"],
|
||||
["copilot", "../../test/fixtures/copilot-profile-v7-identity.json"],
|
||||
["pi", "../../test-fixtures/pi-acp/profile-v9-identity.json"],
|
||||
] as const)("rejects the exact historical %s identity before runtime startup", (agent, path) => {
|
||||
const historical = JSON.parse(readFileSync(new URL(path, import.meta.url), "utf8"));
|
||||
const input = acpxExecution();
|
||||
if (input.provider.kind !== "acpx") throw new Error("invalid fixture");
|
||||
const current = resolveQualifiedAcpxProfile(agent, agent === "pi" ? QUALIFIED_ACPX_PROFILES.pi.qualificationModel : "explicit-fixture-model");
|
||||
Object.assign(input.provider, { agent, model: current.qualificationModel, profile: { ...current,
|
||||
agentProfileVersion: historical.declaration.agentProfileVersion, commandDigest: historical.commandDigest } });
|
||||
expect(() => createNativeSessionBackend(input, { acpxRuntimeDirectory: "/runtime" }))
|
||||
.toThrow("does not match the qualified agentProfileVersion");
|
||||
input.provider.profile.agentProfileVersion = current.agentProfileVersion;
|
||||
expect(() => createNativeSessionBackend(input, { acpxRuntimeDirectory: "/runtime" }))
|
||||
.toThrow("does not match the qualified commandDigest");
|
||||
});
|
||||
|
||||
it.each([1, 2] as const)("rejects a Pi version %s warm snapshot after the rich ACP upgrade", version => {
|
||||
const input = acpxExecution("pi");
|
||||
if (input.provider.kind !== "acpx") throw new Error("invalid fixture");
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { acpxUsageEstimateNotice, persistedAcpxTurnUsage, persistedCursorUsageNotice } from "../drivers/acpx/usage-accounting.js";
|
||||
import { stripTypeScriptTypes } from "node:module";
|
||||
import { cursorPlanToolIdentity } from "../drivers/acpx/cursor-plan-tool-identity.js";
|
||||
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
||||
@@ -41,6 +42,60 @@ afterEach(async () => {
|
||||
});
|
||||
|
||||
describe("qualified ACPX runtime sidecar", () => {
|
||||
it("projects the actual sidecar terminal diagnostic block without authoritative accounting", () => {
|
||||
const source = readFileSync(new URL("./acpx-runtime-sidecar.ts", import.meta.url), "utf8");
|
||||
const start = source.indexOf(" try {", source.indexOf(" const usageAfter = await readSidecarHostStatusWithin(activeHost);"));
|
||||
const end = source.indexOf(" const usage = persistedAcpxTurnUsage(", start);
|
||||
expect(start).toBeGreaterThan(0); expect(end).toBeGreaterThan(start);
|
||||
const emitted: unknown[] = [];
|
||||
const project = new Function("persistedCursorUsageNotice", "validateAcpxRichEvent", "emit", "usageBefore", "usageAfter", "agent", `
|
||||
const currentTurnId="turn", runtimeTurn={requestId:"request-1"}, openParams={agent};
|
||||
${stripTypeScriptTypes(source.slice(start, end))}
|
||||
`).bind(null, persistedCursorUsageNotice, validateAcpxRichEvent, (...args: unknown[]) => emitted.push(args));
|
||||
const before = { promptMessageIds: [], requestTokenUsage: {} };
|
||||
const after = { lastRequestId: "request-1", promptMessageIds: ["prompt-1"], requestTokenUsage: {}, cursorPromptUsage: {
|
||||
request_id: "request-1", prompt_message_id: "prompt-1", receipt: {
|
||||
schema: "paperclip.cursor.native-usage.v1", source: "native_turn_ended", promptId: "12345678-1234-1234-1234-123456789abc", completeness: "partial",
|
||||
reasons: ["native_counter_semantics_unverified"], observations: [], limits: { maxObservations: 64, maxInvocations: 64, maxBytes: 16384 }, truncated: false,
|
||||
},
|
||||
} };
|
||||
project(before, after, "cursor");
|
||||
expect(emitted).toEqual([["runtime.rich_event", expect.objectContaining({ eventType: "provider.notice.recorded", payload: expect.objectContaining({ category: "cursor_native_usage_observed" }) }), "turn"]]);
|
||||
emitted.length = 0;
|
||||
for (const agent of ["copilot", "pi", "codex"]) project(before, after, agent);
|
||||
project(after, after, "cursor");
|
||||
project(before, { ...after, lastRequestId: "stale" }, "cursor");
|
||||
expect(emitted).toEqual([]);
|
||||
});
|
||||
|
||||
it.each(["projection", "validation", "emission"])("preserves standard usage when optional Cursor notice %s fails", async failure => {
|
||||
const source = readFileSync(new URL("./acpx-runtime-sidecar.ts", import.meta.url), "utf8");
|
||||
const start = source.indexOf(" try {\n const usageAfter = await readSidecarHostStatusWithin(activeHost);");
|
||||
const end = source.indexOf(" terminal = boundedSidecarValue(result);", start);
|
||||
expect(start).toBeGreaterThan(0); expect(end).toBeGreaterThan(start);
|
||||
const emitted: unknown[] = [], diagnostics: unknown[] = [];
|
||||
const after = { lastRequestId: "request-1", requestTokenUsage: { "prompt-1": { input_tokens: 12, output_tokens: 3 } } };
|
||||
const project = new Function("readSidecarHostStatusWithin", "persistedCursorUsageNotice", "persistedAcpxTurnUsage", "acpxUsageEstimateNotice", "validateAcpxRichEvent", "emit", "diagnostic", `
|
||||
return (async () => {
|
||||
const activeHost={}, currentTurnId="turn", runtimeTurn={requestId:"request-1"}, openParams={agent:"cursor"};
|
||||
const usageBefore={requestTokenUsage:{}}, sanitizeRuntimeEvent=value=>value, safeMessage=()=>"fixture error";
|
||||
${stripTypeScriptTypes(source.slice(start, end))}
|
||||
})();
|
||||
`);
|
||||
await project(async () => after,
|
||||
() => { if (failure === "projection") throw new Error("optional projection failed"); return { eventType: "provider.notice.recorded" }; },
|
||||
persistedAcpxTurnUsage, acpxUsageEstimateNotice,
|
||||
() => { if (failure === "validation") throw new Error("optional validation failed"); },
|
||||
(type: string, payload: unknown, turn: string) => {
|
||||
if (type === "runtime.rich_event" && failure === "emission") throw new Error("optional emission failed");
|
||||
emitted.push([type, payload, turn]);
|
||||
},
|
||||
(...args: unknown[]) => diagnostics.push(args),
|
||||
);
|
||||
expect(emitted).toEqual([["runtime.event", expect.objectContaining({ tag: "usage_update", breakdown: expect.objectContaining({ inputTokens: 12, outputTokens: 3 }) }), "turn"]]);
|
||||
expect(diagnostics).toEqual([]);
|
||||
});
|
||||
|
||||
it("emits the native plan tool identity from the actual sidecar input boundary", async () => {
|
||||
const source = readFileSync(new URL("./acpx-runtime-sidecar.ts", import.meta.url), "utf8");
|
||||
const start = source.indexOf("async function waitForExtensionInput(");
|
||||
|
||||
@@ -67,6 +67,7 @@ import { createCopilotToolEvidence, type CopilotToolEvidence } from "../drivers/
|
||||
import { createCursorToolEvidence, type CursorToolEvidence } from "../drivers/acpx/cursor-tool-evidence.js";
|
||||
import {
|
||||
persistedAcpxTurnUsage,
|
||||
persistedCursorUsageNotice,
|
||||
acpxUsageEstimateNotice,
|
||||
qualifiedAcpxUsageBreakdown,
|
||||
} from "../drivers/acpx/usage-accounting.js";
|
||||
@@ -633,9 +634,16 @@ async function pumpTurn(
|
||||
if (result.status === "completed") piMessages?.settle();
|
||||
await drainExtensions();
|
||||
try {
|
||||
const usageAfter = await readSidecarHostStatusWithin(activeHost);
|
||||
try {
|
||||
const cursorNotice = persistedCursorUsageNotice(usageBefore, usageAfter, runtimeTurn.requestId, openParams?.agent ?? null, `${currentTurnId}:cursor-native-usage`);
|
||||
if (cursorNotice) { validateAcpxRichEvent(cursorNotice); emit("runtime.rich_event", { ...cursorNotice }, currentTurnId); }
|
||||
} catch {
|
||||
// Optional diagnostics must not suppress standard usage or terminal settlement.
|
||||
}
|
||||
const usage = persistedAcpxTurnUsage(
|
||||
usageBefore,
|
||||
await readSidecarHostStatusWithin(activeHost),
|
||||
usageAfter,
|
||||
runtimeTurn.requestId,
|
||||
openParams?.agent,
|
||||
);
|
||||
|
||||
@@ -296,7 +296,7 @@ describe("NativeExecutionInputV1", () => {
|
||||
})).toThrow("eventExpiryDays");
|
||||
});
|
||||
|
||||
it.each([1, 2, 3, 4, 5, 6, 7, 8, 9] 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, 9, 10] 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, 10, 1.5, "9", null]) {
|
||||
for (const unsupportedVersion of [0, 11, 1.5, "10", 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 | 8 | 9;
|
||||
agentProfileVersion: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10;
|
||||
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 !== 8 && profile.agentProfileVersion !== 9)
|
||||
|| (profile.agentProfileVersion !== 1 && profile.agentProfileVersion !== 2 && profile.agentProfileVersion !== 3 && profile.agentProfileVersion !== 4 && profile.agentProfileVersion !== 5 && profile.agentProfileVersion !== 6 && profile.agentProfileVersion !== 7 && profile.agentProfileVersion !== 8 && profile.agentProfileVersion !== 9 && profile.agentProfileVersion !== 10)
|
||||
) {
|
||||
throw new NativeExecutionInputError("input.provider.profile does not match the qualified ACPX v1 profile");
|
||||
}
|
||||
|
||||
@@ -24,6 +24,28 @@ import type {
|
||||
import type { AcpxRecoveryWorkspaceLease } from "./runtime-sandbox.js";
|
||||
|
||||
describe("Codex ACPX harness driver", () => {
|
||||
it.each(["cursor", "copilot"] as const)("emits partial Cursor metadata only for admitted Cursor, preserving %s settlement", async agent => {
|
||||
const fixture = driverFixture({ agent, model: "explicit-test-model", providerPolicy: { readOnly: true } }, { runtimeEvents: [] });
|
||||
const session = await fixture.driver.openSession({ runId: "run-native-usage", normalizedSessionId: "session-1", workingDirectory: "/workspace" });
|
||||
const before = { promptMessageIds: [], requestTokenUsage: {} };
|
||||
const after = { lastRequestId: "provider-turn-1", promptMessageIds: ["prompt-1"], requestTokenUsage: {}, cursorPromptUsage: {
|
||||
request_id: "provider-turn-1", prompt_message_id: "prompt-1", receipt: {
|
||||
schema: "paperclip.cursor.native-usage.v1", source: "native_turn_ended", promptId: "12345678-1234-1234-1234-123456789abc", completeness: "partial",
|
||||
reasons: ["native_counter_semantics_unverified"], observations: [{ invocationId: "invocation-1", role: "parent", nativeRun: 1, sequence: 1, counters: { inputTokens: 7 } }],
|
||||
limits: { maxObservations: 64, maxInvocations: 64, maxBytes: 16384 }, truncated: false,
|
||||
},
|
||||
} };
|
||||
fixture.host.status.mockResolvedValueOnce(before as never).mockResolvedValue(after as never);
|
||||
await session.startTurn({ message: { text: "fixture" } });
|
||||
fixture.finishTurn({ status: "completed", stopReason: "end_turn" });
|
||||
const events = await collectUntil(session.events(), "turn.completed");
|
||||
const notices = events.filter(e => e.eventType === "provider.notice.recorded" && e.payload.category === "cursor_native_usage_observed");
|
||||
expect(notices).toHaveLength(agent === "cursor" ? 1 : 0);
|
||||
expect(events.filter(e => e.eventType === "usage.updated")).toEqual([]);
|
||||
expect(events.at(-1)?.eventType).toBe("turn.completed");
|
||||
await session.close({ reason: "partial diagnostics verified" });
|
||||
});
|
||||
|
||||
it.each(["tool-1", "tool with spaces", "🧭/plan", "x".repeat(300)])("binds a native Cursor plan request to its actual projected tool identity: %s", async toolCallId => {
|
||||
const fixture = driverFixture({ agent: "cursor", model: "explicit-test-model", cursorMode: "plan", providerPolicy: { readOnly: false } }, {
|
||||
runtimeEvents: [{ type: "tool_call", tag: "tool_call", toolCallId, title: "arbitrary tool display", kind: "execute", status: "pending" }],
|
||||
|
||||
@@ -87,7 +87,7 @@ import {
|
||||
} from "./runtime-sandbox.js";
|
||||
|
||||
import { AcpxTurnControlLedger, parseAcpxTurnControl, type AcpxTurnControlMode } from "./turn-controls.js";
|
||||
import { acpxUsageEstimateNotice, persistedAcpxTurnUsage } from "./usage-accounting.js";
|
||||
import { acpxUsageEstimateNotice, persistedAcpxTurnUsage, persistedCursorUsageNotice } from "./usage-accounting.js";
|
||||
|
||||
const MAX_BUFFERED_EVENTS = 512;
|
||||
const TERMINAL_EVENT_RESERVE = 3;
|
||||
@@ -1459,7 +1459,13 @@ class CodexAcpxSession implements HarnessSession {
|
||||
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);
|
||||
const usageAfter = await readUsageStatus(this.#host);
|
||||
// Diagnostic projection failures cannot replace the provider's terminal result.
|
||||
try {
|
||||
const notice = persistedCursorUsageNotice(usageBefore, usageAfter, turn.requestId, this.#agent, `${turnId}:cursor-native-usage`);
|
||||
if (notice) { validateAcpxRichEvent(notice); this.#emit(notice.eventType, notice.payload, { turnId, itemId: notice.itemId }); }
|
||||
} catch { /* Partial native diagnostics are optional, never settlement authority. */ }
|
||||
const receipt = persistedAcpxTurnUsage(usageBefore, usageAfter, turn.requestId, this.#agent);
|
||||
if (receipt) {
|
||||
this.#mapRuntimeEvent(receipt as unknown as AcpRuntimeEvent, turnId, ++index);
|
||||
const estimate = acpxUsageEstimateNotice(receipt, `${turnId}:usage-estimate`);
|
||||
|
||||
@@ -544,6 +544,8 @@ describe("Codex ACPX runtime adapter", () => {
|
||||
lastRequestId: "run:turn-1",
|
||||
request_token_usage: { prompt: { input_tokens: 12, output_tokens: 30 } },
|
||||
cumulative_cost: { amount: 0.1, currency: "USD" },
|
||||
messages: [{ User: { id: "prompt", content: [] } }],
|
||||
cursor_prompt_usage: { request_id: "run:turn-1", prompt_message_id: "prompt", receipt: { diagnostic: "fixture" } },
|
||||
acpx: {
|
||||
current_model_id: "gpt-5.6-sol",
|
||||
available_models: ["gpt-5.6-sol"],
|
||||
@@ -566,6 +568,8 @@ describe("Codex ACPX runtime adapter", () => {
|
||||
lastRequestId: "run:turn-1",
|
||||
requestTokenUsage: { prompt: { input_tokens: 12, output_tokens: 30 } },
|
||||
usageCost: { amount: 0.1, currency: "USD" },
|
||||
promptMessageIds: ["prompt"],
|
||||
cursorPromptUsage: { request_id: "run:turn-1", prompt_message_id: "prompt", receipt: { diagnostic: "fixture" } },
|
||||
models: {
|
||||
currentModelId: "gpt-5.6-sol",
|
||||
availableModelIds: ["gpt-5.6-sol"],
|
||||
|
||||
@@ -1553,6 +1553,8 @@ async function persistedRuntimeStatus(
|
||||
agentSessionId: persistedAgentSessionId,
|
||||
lastRequestId: record.lastRequestId,
|
||||
requestTokenUsage: structuredClone(record.request_token_usage ?? {}),
|
||||
cursorPromptUsage: structuredClone(record.cursor_prompt_usage),
|
||||
promptMessageIds: (record.messages ?? []).flatMap(message => typeof message === "object" && message !== null && "User" in message ? [message.User.id] : []),
|
||||
usageCost: structuredClone(record.cumulative_cost),
|
||||
...(currentModelId === undefined && !availableModelIds?.length
|
||||
? {}
|
||||
|
||||
@@ -10,8 +10,8 @@ import { QUALIFIED_ACPX_PROFILES } from "./qualified-profiles.js";
|
||||
vi.mock("./installation-integrity.js", () => ({ verifyNativeAcpxInstallation: vi.fn() }));
|
||||
|
||||
describe("Copilot build-owned installation", () => {
|
||||
it("admits the current v7 declaration and binds its source policy/patch hashes", () => {
|
||||
const identity = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v7-identity.json", import.meta.url), "utf8"));
|
||||
it("admits the current v8 declaration and binds its source policy/patch hashes", () => {
|
||||
const identity = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v8-identity.json", import.meta.url), "utf8"));
|
||||
expect(identity.declaration.systemInstructionDelivery).toBe(COPILOT_SYSTEM_INSTRUCTION_DELIVERY);
|
||||
expect(identity.declaration.sharedRuntimeContract).toBe("paperclip.acpx-runtime-contract.v1");
|
||||
expect(identity.declaration.messageIdentityContract).toBe("copilot-native-message-id-v1");
|
||||
@@ -64,7 +64,7 @@ describe("Copilot build-owned installation", () => {
|
||||
});
|
||||
it("rejects changed profile identities before native file access", async () => {
|
||||
vi.mocked(verifyNativeAcpxInstallation).mockClear();
|
||||
for (const override of [{ agentServerVersion: "latest" }, { commandDigest: "sha256:untrusted" }, { agent: "cursor" }, { agentProfileVersion: 1 }, { agentProfileVersion: 2 }, { agentProfileVersion: 3 }, { agentProfileVersion: 4 }, { agentProfileVersion: 5 }, { agentProfileVersion: 6 }]) {
|
||||
for (const override of [{ agentServerVersion: "latest" }, { commandDigest: "sha256:untrusted" }, { agent: "cursor" }, { agentProfileVersion: 1 }, { agentProfileVersion: 2 }, { agentProfileVersion: 3 }, { agentProfileVersion: 4 }, { agentProfileVersion: 5 }, { agentProfileVersion: 6 }, { agentProfileVersion: 7 }]) {
|
||||
await expect(verifyCopilotInstallation({ ...QUALIFIED_ACPX_PROFILES.copilot, ...override } as never)).rejects.toThrow("exact pinned");
|
||||
}
|
||||
expect(verifyNativeAcpxInstallation).not.toHaveBeenCalled();
|
||||
|
||||
@@ -22,7 +22,7 @@ it("matches build materializer pins and launches only package-owned complete dis
|
||||
|
||||
it("rejects profile substitutions before reading any native distribution", async () => {
|
||||
const base = { ...QUALIFIED_ACPX_PROFILES.cursor, qualificationModel: "explicit", reportedModelId: "explicit" };
|
||||
for (const profile of [QUALIFIED_ACPX_PROFILES.cursor, { ...base, agentServerVersion: "latest" }, { ...base, commandDigest: "forged" }, { ...base, reportedModelId: "different" }]) {
|
||||
for (const profile of [QUALIFIED_ACPX_PROFILES.cursor, { ...base, agentServerVersion: "latest" }, { ...base, commandDigest: "forged" }, { ...base, reportedModelId: "different" }, { ...base, agentProfileVersion: 7 as const }]) {
|
||||
await expect(verifyCursorInstallation(profile)).rejects.toThrow("exact pinned profile");
|
||||
}
|
||||
});
|
||||
|
||||
@@ -8,9 +8,9 @@ import { resolveRunnerProviderAssetsRoot } from "./provider-assets-root.js";
|
||||
|
||||
export const CURSOR_PINNED_VERSION = "2026.09.26-dd393fe";
|
||||
const CLOSURE_PINS = Object.freeze({
|
||||
"darwin-arm64": "912a37edb67fd7737809c24049dce2db20b1330f98b15a6e43809f0e0943cb6c",
|
||||
"darwin-x64": "322f54c7bd533b202a311ce8e2c8916bfe0202790ef8a40fbb36bb89cbd506cb",
|
||||
"linux-x64": "a375fc771dfe86b9b24532f95956ec4b02c5f4f24d80f5db06565160eb3b672c",
|
||||
"darwin-arm64": "0681abb675b46054155e0b3f28850bf4668cf24fb1aab457dad1f24b61b3ee6b",
|
||||
"darwin-x64": "18caa80f90f70f6a7d815335062b0858327891ec854aebe90b585a42eac578e6",
|
||||
"linux-x64": "0ee4ceb24062e991650bfb4c0b4ec5fbd7f1a01487b8c70aef0cf257509701a4",
|
||||
});
|
||||
|
||||
/** Trusted package assets only: no workspace, PATH, executable override or env root. */
|
||||
|
||||
@@ -19,7 +19,7 @@ export function cursorPrivateEnvironment(paths: { agentHomeDirectory: string; da
|
||||
/**
|
||||
* The vendor's disable-project-configs flag covers cli.json only. Keep this
|
||||
* admission refusal as defense in depth without mutating the user's project.
|
||||
* The separately verified paperclip-cursor-instructions-v2 distribution removes
|
||||
* The separately verified paperclip-cursor-usage-v3 distribution removes
|
||||
* ambient MCP and hook discovery at its source for the entire session. This
|
||||
* check alone is not continuous filesystem protection or an OS sandbox.
|
||||
*/
|
||||
|
||||
@@ -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: 9 });
|
||||
const candidate = (): QualifiedAcpxProfile => ({ ...QUALIFIED_ACPX_PROFILES.pi, agentProfileVersion: 10 });
|
||||
|
||||
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-v9-identity.json", import.meta.url), "utf8"));
|
||||
const identity = JSON.parse(await readFile(new URL("../../../test-fixtures/pi-acp/profile-v10-identity.json", import.meta.url), "utf8"));
|
||||
const declaration = identity.declaration;
|
||||
expect(declaration.sharedRuntimeContract).toBe("paperclip.acpx-runtime-contract.v1");
|
||||
expect(identity.commandDigest).toBe(`sha256:${hash(canonical(declaration))}`);
|
||||
@@ -57,14 +57,15 @@ describe("Pi installation factory", () => {
|
||||
|
||||
it("rejects legacy profiles and caller-selected identities", () => {
|
||||
expect(() => assertPiInstallationProfile(candidate())).not.toThrow();
|
||||
expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 1 })).toThrow("version 9");
|
||||
expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 2 })).toThrow("version 9");
|
||||
expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 3 })).toThrow("version 9");
|
||||
expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 4 })).toThrow("version 9");
|
||||
expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 5 })).toThrow("version 9");
|
||||
expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 6 })).toThrow("version 9");
|
||||
expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 7 })).toThrow("version 9");
|
||||
expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 8 })).toThrow("version 9");
|
||||
expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 1 })).toThrow("version 10");
|
||||
expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 2 })).toThrow("version 10");
|
||||
expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 3 })).toThrow("version 10");
|
||||
expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 4 })).toThrow("version 10");
|
||||
expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 5 })).toThrow("version 10");
|
||||
expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 6 })).toThrow("version 10");
|
||||
expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 7 })).toThrow("version 10");
|
||||
expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 8 })).toThrow("version 10");
|
||||
expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 9 })).toThrow("version 10");
|
||||
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 !== 9) throw new Error("Pi rich ACP requires profile version 9; reopen the previous session");
|
||||
if (profile.agentProfileVersion !== 10) throw new Error("Pi rich ACP requires profile version 10; 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");
|
||||
}
|
||||
|
||||
|
||||
@@ -63,8 +63,8 @@ describe("qualified ACPX profiles", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("binds Cursor v7 to inspectable native instructions, closures and exact ACPX guard patch", () => {
|
||||
const identity = JSON.parse(readFileSync(new URL("../../../test/fixtures/cursor-acp/profile-v7-identity.json", import.meta.url), "utf8"));
|
||||
it("binds Cursor v8 to inspectable native instructions, closures and exact ACPX guard patch", () => {
|
||||
const identity = JSON.parse(readFileSync(new URL("../../../test/fixtures/cursor-acp/profile-v8-identity.json", import.meta.url), "utf8"));
|
||||
const distribution = JSON.parse(readFileSync(new URL("../../../cursor-distributions.json", import.meta.url), "utf8"));
|
||||
expect(identity.declaration.distribution).toEqual(distribution);
|
||||
expect(identity.declaration.sharedRuntimeContract).toBe("paperclip.acpx-runtime-contract.v1");
|
||||
|
||||
@@ -47,24 +47,24 @@ export const QUALIFIED_ACPX_PROFILES: Readonly<
|
||||
protocolVersion: ACPX_DRIVER_PROTOCOL_VERSION,
|
||||
acpxVersion: QUALIFIED_ACPX_VERSION,
|
||||
agent: "pi",
|
||||
agentProfileVersion: 9,
|
||||
agentProfileVersion: 10,
|
||||
qualificationStatus: "pending",
|
||||
agentServerPackage: "pi-acp",
|
||||
agentServerVersion: "0.0.33",
|
||||
agentRuntimePackage: "@earendil-works/pi-coding-agent",
|
||||
agentRuntimeVersion: "0.84.2",
|
||||
commandDigest:
|
||||
"sha256:edf058835ee84de3869c4a8e8bdb71a934ffdb9f34daa37ae6faeb92371d1cdf",
|
||||
"sha256:5e1a4357fc108fa79a66111413f85f6353b3dd3ab802ddfb80fc66719bc12571",
|
||||
qualificationModel: "openrouter/deepseek/deepseek-v4-flash-0731",
|
||||
reportedModelId: "openrouter/deepseek/deepseek-v4-flash-0731",
|
||||
permissionPolicy: "interactive",
|
||||
},
|
||||
cursor: {
|
||||
driverKind: ACPX_DRIVER_KIND, protocolVersion: ACPX_DRIVER_PROTOCOL_VERSION,
|
||||
acpxVersion: QUALIFIED_ACPX_VERSION, agent: "cursor", agentProfileVersion: 7,
|
||||
acpxVersion: QUALIFIED_ACPX_VERSION, agent: "cursor", agentProfileVersion: 8,
|
||||
agentServerPackage: "cursor-agent", agentServerVersion: "2026.09.26-dd393fe",
|
||||
agentRuntimePackage: null, agentRuntimeVersion: null,
|
||||
commandDigest: "sha256:f4c7af914738149cf868d071e53ac4917658fb055224715a2c89d3f59b335503",
|
||||
commandDigest: "sha256:b9e94cbcdce2783665612c85caf019c4defe37d3d245a694de590ebdf5b1f1a3",
|
||||
// Authenticated discovery has not established a qualification model. Never
|
||||
// turn this empty declaration into a default; callers must select an ID.
|
||||
qualificationModel: "", reportedModelId: "", permissionPolicy: "interactive",
|
||||
@@ -72,10 +72,10 @@ export const QUALIFIED_ACPX_PROFILES: Readonly<
|
||||
},
|
||||
copilot: {
|
||||
driverKind: ACPX_DRIVER_KIND, protocolVersion: ACPX_DRIVER_PROTOCOL_VERSION,
|
||||
acpxVersion: QUALIFIED_ACPX_VERSION, agent: "copilot", agentProfileVersion: 7,
|
||||
acpxVersion: QUALIFIED_ACPX_VERSION, agent: "copilot", agentProfileVersion: 8,
|
||||
agentServerPackage: "@github/copilot", agentServerVersion: "1.0.88",
|
||||
agentRuntimePackage: null, agentRuntimeVersion: null,
|
||||
commandDigest: "sha256:b11721382293b39c1d6dd363eae547b5eae0ccca2cd5dcafc9127cc060ee9526",
|
||||
commandDigest: "sha256:3b97dac7020396a84997040ee2864a47af615d69aaf1c951de391e850038157a",
|
||||
// Authenticated discovery has not established a qualification model. Never
|
||||
// turn this empty declaration into a default; callers must select an ID.
|
||||
qualificationModel: "", reportedModelId: "", permissionPolicy: "interactive",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdir, mkdtemp, realpath, rm, writeFile } from "node:fs/promises";
|
||||
import { mkdir, mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, parse } from "node:path";
|
||||
|
||||
@@ -26,6 +26,27 @@ afterEach(async () => {
|
||||
});
|
||||
|
||||
describe("ACPX recovery identity", () => {
|
||||
it.each([
|
||||
["cursor", "../../../test/fixtures/cursor-acp/profile-v7-identity.json"],
|
||||
["copilot", "../../../test/fixtures/copilot-profile-v7-identity.json"],
|
||||
["pi", "../../../test-fixtures/pi-acp/profile-v9-identity.json"],
|
||||
] as const)("rejects retained %s sessions after the shared patch identity changes", async (agent, path) => {
|
||||
const fixture = await recoveryFixture();
|
||||
const historical = JSON.parse(await readFile(new URL(path, import.meta.url), "utf8"));
|
||||
const current = resolveQualifiedAcpxProfile(agent, agent === "pi" ? "openrouter/deepseek/deepseek-v4-flash-0731" : fixture.input.requestedModel);
|
||||
const input = { ...fixture.input, requestedModel: current.qualificationModel, profile: current };
|
||||
const next = await createAcpxRecoveryBinding(input);
|
||||
const prior = await createAcpxRecoveryBinding({ ...input, profile: { ...current,
|
||||
agentProfileVersion: historical.declaration.agentProfileVersion, commandDigest: historical.commandDigest } });
|
||||
const priorExpected = { ...fixture.expected, profileDigest: prior.commandDigest,
|
||||
requestedModel: prior.requestedModel, effectiveModel: prior.effectiveModel,
|
||||
...(agent === "cursor" ? { cursorMode: "agent" as const } : {}) };
|
||||
const record = createAcpxIdentityRecord(priorExpected, prior);
|
||||
expect(next.profileDigest).not.toBe(prior.profileDigest);
|
||||
expect(next.profileSessionKey).not.toBe(prior.profileSessionKey);
|
||||
expect(() => verifyExpectedAcpxIdentity({ ...priorExpected, profileDigest: next.commandDigest }, next, record)).toThrow(/persisted runtime record/);
|
||||
});
|
||||
|
||||
it("binds Cursor mode on recovery and rejects missing or changed persisted mode", async () => {
|
||||
const fixture = await recoveryFixture();
|
||||
const input = { ...fixture.input, profile: resolveQualifiedAcpxProfile("cursor", fixture.input.requestedModel) };
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
persistedAcpxTurnUsage,
|
||||
parseCursorPromptUsage,
|
||||
persistedCursorUsageNotice,
|
||||
acpxUsageEstimateNotice,
|
||||
qualifiedAcpxUsageBreakdown,
|
||||
} from "./usage-accounting.js";
|
||||
@@ -170,3 +172,57 @@ describe("Pi prompt accounting authority", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe("partial native Cursor observations", () => {
|
||||
const receipt = () => ({ request_id: "request-1", prompt_message_id: "message-1", receipt: {
|
||||
schema: "paperclip.cursor.native-usage.v1", source: "native_turn_ended", promptId: "12345678-1234-1234-1234-123456789abc",
|
||||
completeness: "partial", reasons: ["native_counter_semantics_unverified", "child_run_attribution_unverified"],
|
||||
observations: [{ invocationId: "invocation-1", role: "parent", nativeRun: 1, sequence: 1, counters: { inputTokens: 12, outputTokens: 3 } }],
|
||||
limits: { maxObservations: 64, maxInvocations: 64, maxBytes: 16384 }, truncated: false,
|
||||
} });
|
||||
const before = { lastRequestId: "old-request", promptMessageIds: ["old-message"], requestTokenUsage: {} };
|
||||
const after = () => ({ lastRequestId: "request-1", promptMessageIds: ["old-message", "message-1"], cursorPromptUsage: receipt(), requestTokenUsage: {} });
|
||||
it("emits only a bounded unsummed partial notice, with no accounting side effects", () => {
|
||||
const current = after(), original = structuredClone(current);
|
||||
const notice = persistedCursorUsageNotice(before, current, "request-1", "cursor", "turn:cursor-native-usage");
|
||||
expect(notice).toMatchObject({ eventType: "provider.notice.recorded", payload: { category: "cursor_native_usage_observed" } });
|
||||
expect(JSON.stringify(notice)).toContain('native_counter_semantics_unverified');
|
||||
expect(JSON.stringify(notice)).not.toContain('request-1');
|
||||
expect(JSON.stringify(notice)).not.toContain('12345678');
|
||||
expect(current).toEqual(original);
|
||||
expect(persistedAcpxTurnUsage(before, current, "request-1", "cursor")).toBeNull();
|
||||
});
|
||||
it("rejects stale, replaced, foreign, missing, and duplicate message bindings", () => {
|
||||
for (const agent of [null, "codex", "copilot", "pi"] as const) expect(persistedCursorUsageNotice(before, after(), "request-1", agent, "notice")).toBeNull();
|
||||
for (const current of [
|
||||
{ ...after(), lastRequestId: "wrong" },
|
||||
{ ...after(), cursorPromptUsage: { ...receipt(), request_id: "wrong" } },
|
||||
{ ...after(), promptMessageIds: [] },
|
||||
{ ...after(), promptMessageIds: ["message-1", "message-1"] },
|
||||
]) expect(persistedCursorUsageNotice(before, current, "request-1", "cursor", "notice")).toBeNull();
|
||||
for (const prior of [after(), { ...before, promptMessageIds: ["message-1"] }, { ...before, cursorPromptUsage: { ...receipt(), request_id: "old-request", prompt_message_id: "old-message" } }])
|
||||
expect(persistedCursorUsageNotice(prior, after(), "request-1", "cursor", "notice")).toBeNull();
|
||||
});
|
||||
it("drops malformed or excessive metadata without retaining unknown/free-text fields", () => {
|
||||
for (const value of [null, {}, { ...receipt(), extra: "secret" }, { ...receipt(), request_id: "x".repeat(241) },
|
||||
{ ...receipt(), receipt: { ...receipt().receipt, extra: "secret" } },
|
||||
{ ...receipt(), receipt: { ...receipt().receipt, reasons: ["secret"] } },
|
||||
{ ...receipt(), receipt: { ...receipt().receipt, observations: [{ ...receipt().receipt.observations[0], counters: { inputTokens: -1 } }] } },
|
||||
]) expect(parseCursorPromptUsage(value)).toBeNull();
|
||||
const circular: Record<string, unknown> = {}; circular.self = circular;
|
||||
expect(parseCursorPromptUsage(circular)).toBeNull();
|
||||
});
|
||||
it("retains all legal observations within canonical per-detail bounds", () => {
|
||||
const value = receipt();
|
||||
value.receipt.observations = Array.from({ length: 64 }, (_, i) => ({ invocationId: `invocation-${i + 1}`, role: "parent", nativeRun: 1, sequence: 1, counters: { inputTokens: 12, outputTokens: 3 } }));
|
||||
const notice = persistedCursorUsageNotice(before, { ...after(), cursorPromptUsage: value }, "request-1", "cursor", "notice")!;
|
||||
const details = notice.payload.details as {name:string;value:string}[];
|
||||
expect(details.length).toBeLessThanOrEqual(64);
|
||||
expect(details.every(d => Buffer.byteLength(d.value) <= 4000)).toBe(true);
|
||||
expect(details.filter(d => d.name.startsWith("Native observations")).flatMap(d => JSON.parse(d.value))).toEqual(value.receipt.observations);
|
||||
const maxIds = { ...receipt(), request_id: "r".repeat(240), prompt_message_id: "m".repeat(240) };
|
||||
expect(parseCursorPromptUsage(maxIds)).not.toBeNull();
|
||||
expect(Buffer.byteLength(JSON.stringify(maxIds)) - Buffer.byteLength(JSON.stringify(maxIds.receipt))).toBeLessThanOrEqual(640);
|
||||
});
|
||||
});
|
||||
@@ -95,3 +95,70 @@ export function acpxUsageEstimateNotice(usage: Record<string, unknown>, itemId:
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
type CursorPromptUsage = {
|
||||
request_id: string; prompt_message_id: string;
|
||||
receipt: {
|
||||
schema: "paperclip.cursor.native-usage.v1"; source: "native_turn_ended"; promptId: string;
|
||||
completeness: "partial"; reasons: string[]; truncated: boolean;
|
||||
limits: { maxObservations: 64; maxInvocations: 64; maxBytes: 16384 };
|
||||
observations: Array<{ invocationId: string; role: "parent" | "child"; nativeRun: number; sequence: number; counters: Record<string, number> }>;
|
||||
};
|
||||
};
|
||||
/** Closed diagnostic receipt. Counter semantics and billing are always unverified. */
|
||||
export function parseCursorPromptUsage(raw: unknown): CursorPromptUsage | null {
|
||||
try {
|
||||
const text = JSON.stringify(raw);
|
||||
if (!text || Buffer.byteLength(text, "utf8") > 16384) return null;
|
||||
const v = JSON.parse(text) as CursorPromptUsage;
|
||||
const object = (x: unknown): x is object => x !== null && typeof x === "object" && !Array.isArray(x);
|
||||
const exact = (x: object, keys: readonly string[]) => Object.keys(x).length === keys.length && keys.every(k => Object.hasOwn(x, k));
|
||||
const id = (x: unknown) => typeof x === "string" && /^[A-Za-z0-9][A-Za-z0-9._:-]{0,239}$/.test(x);
|
||||
if (!object(v) || !exact(v, ["request_id", "prompt_message_id", "receipt"]) || !id(v.request_id) || !id(v.prompt_message_id)) return null;
|
||||
const r = v.receipt;
|
||||
if (!object(r) || !exact(r, ["schema", "source", "promptId", "completeness", "reasons", "observations", "limits", "truncated"])) return null;
|
||||
if (r.schema !== "paperclip.cursor.native-usage.v1" || r.source !== "native_turn_ended" || r.completeness !== "partial" || typeof r.promptId !== "string" || !/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(r.promptId) || typeof r.truncated !== "boolean") return null;
|
||||
const reasons = ["native_counter_semantics_unverified", "native_counters_missing", "invalid_native_counter", "multiple_terminal_observations", "native_terminal_not_observed", "observation_limit_reached", "child_run_attribution_unverified"];
|
||||
if (!Array.isArray(r.reasons) || r.reasons.length > reasons.length || !r.reasons.includes(reasons[0]!) || new Set(r.reasons).size !== r.reasons.length || !r.reasons.every(x => reasons.includes(x))) return null;
|
||||
if (!object(r.limits) || !exact(r.limits, ["maxObservations", "maxInvocations", "maxBytes"]) || r.limits.maxObservations !== 64 || r.limits.maxInvocations !== 64 || r.limits.maxBytes !== 16384 || !Array.isArray(r.observations) || r.observations.length > 64) return null;
|
||||
const fields = ["inputTokens", "outputTokens", "cacheReadTokens", "cacheWriteTokens", "reasoningTokens"];
|
||||
const invocations = new Map<string, { role: string; nativeRun: number; sequence: number }>();
|
||||
for (const o of r.observations) {
|
||||
if (!object(o) || !exact(o, ["invocationId", "role", "nativeRun", "sequence", "counters"]) || typeof o.invocationId !== "string" || !/^invocation-([1-9]|[1-5][0-9]|6[0-4])$/.test(o.invocationId) || (o.role !== "parent" && o.role !== "child") || !Number.isSafeInteger(o.nativeRun) || o.nativeRun < 1 || !Number.isSafeInteger(o.sequence) || o.sequence < 1 || !object(o.counters)) return null;
|
||||
if (!Object.entries(o.counters).every(([k, n]) => fields.includes(k) && Number.isSafeInteger(n) && n >= 0)) return null;
|
||||
const previous = invocations.get(o.invocationId);
|
||||
if (previous && (previous.role !== o.role || previous.nativeRun !== o.nativeRun || o.sequence <= previous.sequence)) return null;
|
||||
invocations.set(o.invocationId, { role: o.role, nativeRun: o.nativeRun, sequence: o.sequence });
|
||||
}
|
||||
return v;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
/** Partial native observations never enter usage_update, aggregate tokens, or USD. */
|
||||
export function persistedCursorUsageNotice(before: unknown, after: unknown, requestId: string, agent: QualifiedAcpxAgent | null, itemId: string): CanonicalProviderEvent | null {
|
||||
if (agent !== "cursor") return null;
|
||||
const current = record(after), prior = record(before);
|
||||
if (current.lastRequestId !== requestId || prior.lastRequestId === requestId) return null;
|
||||
const receipt = parseCursorPromptUsage(current.cursorPromptUsage);
|
||||
if (!receipt || receipt.request_id !== requestId) return null;
|
||||
const previous = parseCursorPromptUsage(prior.cursorPromptUsage);
|
||||
if (previous && (previous.request_id === receipt.request_id || previous.prompt_message_id === receipt.prompt_message_id || previous.receipt.promptId === receipt.receipt.promptId)) return null;
|
||||
// A message must have been created by this turn; replacing metadata on an old message is not fresh evidence.
|
||||
if (!Array.isArray(current.promptMessageIds) || current.promptMessageIds.filter(id => id === receipt.prompt_message_id).length !== 1
|
||||
|| !Array.isArray(prior.promptMessageIds) || prior.promptMessageIds.includes(receipt.prompt_message_id)) return null;
|
||||
const details = [
|
||||
{ name: "Provenance", value: "Cursor native turnEnded observations; partial, unsummed, unverified counter semantics" },
|
||||
{ name: "Partial reasons", value: receipt.receipt.reasons.join(", ") },
|
||||
{ name: "Collector truncated", value: String(receipt.receipt.truncated) },
|
||||
...Array.from({ length: Math.ceil(receipt.receipt.observations.length / 8) }, (_, group) => ({
|
||||
name: `Native observations ${group * 8 + 1}-${Math.min((group + 1) * 8, receipt.receipt.observations.length)}`,
|
||||
value: JSON.stringify(receipt.receipt.observations.slice(group * 8, (group + 1) * 8)),
|
||||
})),
|
||||
];
|
||||
// Eight closed observations fit within each 4,000-byte detail, without dropping any counters.
|
||||
return {
|
||||
eventType: "provider.notice.recorded", itemId,
|
||||
payload: { schema: "paperclip.provider.notice.v1", noticeId: itemId, severity: "info", category: "cursor_native_usage_observed", scope: "turn", recoverable: true, userActionable: false,
|
||||
summary: "Cursor reported partial native counters. These observations are not authoritative token usage or billing cost.", details },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"commandDigest": "sha256:5e1a4357fc108fa79a66111413f85f6353b3dd3ab802ddfb80fc66719bc12571",
|
||||
"declaration": {
|
||||
"schema": "paperclip.acpx_profile_declaration.v1",
|
||||
"agent": "pi",
|
||||
"agentProfileVersion": 10,
|
||||
"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": "bd5393058a218040d217fa85449d59a6f30507de54cd645bf0ef21422823f85e",
|
||||
"nativeAssistantMessages": "sdk-boundaries-v1",
|
||||
"nativeNotices": "session-extension-v1",
|
||||
"messageProjectionSha256": "17d1e91b948dfa6a56e1905645add52b502041ad843c8d50a13b07956f22aadd",
|
||||
"noticeProjectionSha256": "c619080ec72a577889c382d44ea9024b10efdb9d83e933c03bfdd8da79ede591",
|
||||
"sharedRuntimeContract": "paperclip.acpx-runtime-contract.v1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import assert from "node:assert/strict";
|
||||
import { createRequire } from "node:module";
|
||||
import { dirname, join } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import test from "node:test";
|
||||
import { createCursorNativeUsage } from "../scripts/cursor-native-usage.mjs";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const root = process.env.PAPERCLIP_TEST_ACPX_PACKAGE_ROOT ?? dirname(require.resolve("acpx/package.json"));
|
||||
const { _: createConversation, y: recordSubmission, n: runPromptTurn,
|
||||
dt: serialize, ut: parse, tt: assertKeyPolicy, g: clone, E: apply } =
|
||||
await import(pathToFileURL(join(root, "dist/live-checkpoint-BSIrfgVo.js")));
|
||||
const envelope = () => ({
|
||||
schema: "paperclip.cursor.native-usage.v1", source: "native_turn_ended",
|
||||
promptId: "12345678-1234-1234-1234-123456789abc", completeness: "partial",
|
||||
reasons: ["native_counter_semantics_unverified"],
|
||||
observations: [{ invocationId: "invocation-1", role: "parent", nativeRun: 1, sequence: 1,
|
||||
counters: { inputTokens: 12, outputTokens: 3, cacheReadTokens: 0 } }],
|
||||
limits: { maxObservations: 64, maxInvocations: 64, maxBytes: 16384 }, truncated: false,
|
||||
});
|
||||
const record = conversation => ({
|
||||
schema: "acpx.session.v1", acpxRecordId: "record-1", acpSessionId: "session-1", agentCommand: "verified-cursor",
|
||||
cwd: "/workspace", createdAt: "2026-09-30T00:00:00Z", lastUsedAt: "2026-09-30T00:00:00Z", lastSeq: 0,
|
||||
...conversation,
|
||||
});
|
||||
async function prompt(conversation, metadata, options = {}) {
|
||||
const promptMessageId = options.promptMessageId ?? recordSubmission(conversation, "Fixture prompt");
|
||||
const result = await runPromptTurn({
|
||||
client: { prompt: async () => ({ stopReason: options.stopReason ?? "end_turn", usage: options.usage, _meta: { paperclipCursorUsage: metadata } }) },
|
||||
sessionId: "session-1", prompt: "Fixture prompt", conversation, promptMessageId,
|
||||
requestId: options.requestId ?? "request-1",
|
||||
});
|
||||
return { promptMessageId, result };
|
||||
}
|
||||
|
||||
test("Cursor partial metadata persists separately through clone/apply/disk/reload without accounting", async () => {
|
||||
const conversation = createConversation(), metadata = envelope();
|
||||
const { promptMessageId } = await prompt(conversation, metadata);
|
||||
const expected = { request_id: "request-1", prompt_message_id: promptMessageId, receipt: metadata };
|
||||
assert.deepEqual(conversation.cursor_prompt_usage, expected);
|
||||
const cloned = clone(conversation);
|
||||
metadata.observations[0].counters.inputTokens = 999;
|
||||
assert.equal(cloned.cursor_prompt_usage.receipt.observations[0].counters.inputTokens, 12);
|
||||
const applied = record(createConversation()); apply(applied, cloned);
|
||||
const disk = serialize(applied); assertKeyPolicy(disk);
|
||||
const restored = parse(JSON.parse(JSON.stringify(disk)));
|
||||
assert.deepEqual(restored.cursor_prompt_usage, cloned.cursor_prompt_usage);
|
||||
assert.deepEqual(restored.request_token_usage, {});
|
||||
assert.deepEqual(restored.cumulative_token_usage, {});
|
||||
assert.equal(restored.cumulative_cost, undefined);
|
||||
});
|
||||
|
||||
test("malformed, oversized, unknown, and unbound receipts cannot poison successful settlement", async () => {
|
||||
const malformed = [undefined, null, {}, { ...envelope(), extra: "DROP_ME" },
|
||||
{ ...envelope(), reasons: ["DROP_ME"] }, { ...envelope(), promptId: "raw-native-id" },
|
||||
{ ...envelope(), observations: [{ ...envelope().observations[0], counters: { inputTokens: -1 } }] },
|
||||
{ ...envelope(), observations: Array(65).fill(envelope().observations[0]) },
|
||||
{ ...envelope(), extra: "X".repeat(16384) }];
|
||||
for (const metadata of malformed) {
|
||||
const conversation = createConversation();
|
||||
assert.equal((await prompt(conversation, metadata)).result.stopReason, "end_turn");
|
||||
assert.equal(conversation.cursor_prompt_usage, undefined);
|
||||
}
|
||||
for (const options of [{ requestId: "invalid request" }, { promptMessageId: "missing-message" }, { stopReason: "refusal" }]) {
|
||||
const conversation = createConversation(); await prompt(conversation, envelope(), options);
|
||||
assert.equal(conversation.cursor_prompt_usage, undefined);
|
||||
}
|
||||
});
|
||||
|
||||
test("duplicate request, prompt message, and native prompt receipts do not replace an accepted receipt", async () => {
|
||||
const conversation = createConversation(); const first = await prompt(conversation, envelope());
|
||||
const original = structuredClone(conversation.cursor_prompt_usage);
|
||||
const updated = { ...envelope(), promptId: "aaaaaaaa-1234-1234-1234-123456789abc" };
|
||||
await prompt(conversation, updated); // same request, new message
|
||||
assert.deepEqual(conversation.cursor_prompt_usage, original);
|
||||
await prompt(conversation, updated, { requestId: "request-2", promptMessageId: first.promptMessageId });
|
||||
assert.deepEqual(conversation.cursor_prompt_usage, original);
|
||||
await prompt(conversation, envelope(), { requestId: "request-2" });
|
||||
assert.deepEqual(conversation.cursor_prompt_usage, original);
|
||||
await prompt(conversation, updated, { requestId: "request-2", stopReason: "cancelled" });
|
||||
assert.equal(conversation.cursor_prompt_usage.request_id, "request-2");
|
||||
assert.equal(conversation.cursor_prompt_usage.receipt.promptId, updated.promptId);
|
||||
});
|
||||
|
||||
test("reload discards missing/duplicate conversation-message bindings and tampered metadata only", async () => {
|
||||
const conversation = createConversation(); await prompt(conversation, envelope());
|
||||
for (const mutate of [
|
||||
disk => { disk.cursor_prompt_usage.prompt_message_id = "missing"; },
|
||||
disk => { disk.messages.push(structuredClone(disk.messages[0])); },
|
||||
disk => { disk.cursor_prompt_usage.receipt.observations[0].counters.secret = "DROP_ME"; },
|
||||
]) {
|
||||
const disk = structuredClone(serialize(record(conversation))); mutate(disk);
|
||||
const restored = parse(disk);
|
||||
assert.ok(restored); assert.equal(restored.cursor_prompt_usage, undefined);
|
||||
assert.deepEqual(restored.request_token_usage, {});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
test("actual runtime manager threads request identity into persisted and reloaded Cursor diagnostics", { timeout: 10000 }, async () => {
|
||||
const { createAcpRuntime } = await import(pathToFileURL(join(root, "dist/runtime.js")));
|
||||
const cwd = await mkdtemp(join(tmpdir(), "acpx-cursor-usage-"));
|
||||
const records = new Map();
|
||||
const sessionStore = {
|
||||
async load(id) { const disk = records.get(id); return disk ? parse(structuredClone(disk)) : undefined; },
|
||||
async save(value) { const disk = serialize(value); assertKeyPolicy(disk); records.set(value.acpxRecordId, structuredClone(disk)); },
|
||||
};
|
||||
const wire = `
|
||||
const readline=require('node:readline');
|
||||
const send=value=>process.stdout.write(JSON.stringify({jsonrpc:'2.0',...value})+'\\n');
|
||||
readline.createInterface({input:process.stdin}).on('line',line=>{
|
||||
const m=JSON.parse(line);
|
||||
if(m.method==='initialize') send({id:m.id,result:{protocolVersion:1,agentCapabilities:{loadSession:true},authMethods:[]}});
|
||||
else if(m.method==='session/new'||m.method==='session/load') send({id:m.id,result:{sessionId:'session-1'}});
|
||||
else if(m.method==='session/prompt') send({id:m.id,result:{stopReason:'end_turn',_meta:{paperclipCursorUsage:${JSON.stringify(envelope())}}}});
|
||||
});
|
||||
`;
|
||||
const options = { cwd, sessionStore, agentRegistry: { resolve: () => "fixture", list: () => ["cursor"] }, permissionMode: "approve-all", timeoutMs: 3000,
|
||||
spawnAgent: () => spawn(process.execPath, ["-e", wire], { cwd, env: {}, stdio: ["pipe", "pipe", "pipe"] }),
|
||||
};
|
||||
const runtime = createAcpRuntime(options); let handle;
|
||||
try {
|
||||
handle = await runtime.ensureSession({ sessionKey: "cursor-usage", agent: "cursor", mode: "persistent", cwd });
|
||||
const turn = runtime.startTurn({ handle, text: "fixture", mode: "prompt", requestId: "exact-runtime-request" });
|
||||
const drained = (async () => { for await (const _event of turn.events) {} })();
|
||||
assert.equal((await turn.result).status, "completed"); await drained;
|
||||
const persisted = await sessionStore.load(handle.acpxRecordId);
|
||||
assert.equal(persisted.lastRequestId, "exact-runtime-request");
|
||||
assert.equal(persisted.cursor_prompt_usage.request_id, "exact-runtime-request");
|
||||
assert.equal(persisted.messages.filter(m => m?.User?.id === persisted.cursor_prompt_usage.prompt_message_id).length, 1);
|
||||
assert.deepEqual(persisted.request_token_usage, {}); assert.deepEqual(persisted.cumulative_token_usage, {});
|
||||
await runtime.close({ handle, reason: "fixture restart" });
|
||||
const reopenedRuntime = createAcpRuntime(options);
|
||||
const reopened = await reopenedRuntime.ensureSession({ sessionKey: "cursor-usage", agent: "cursor", mode: "persistent", cwd, resumeSessionId: handle.backendSessionId });
|
||||
try {
|
||||
const loaded = await sessionStore.load(reopened.acpxRecordId);
|
||||
assert.deepEqual(loaded.cursor_prompt_usage, persisted.cursor_prompt_usage);
|
||||
} finally { await reopenedRuntime.close({ handle: reopened, reason: "fixture complete" }); }
|
||||
} finally {
|
||||
if (handle) await runtime.close({ handle, reason: "fixture cleanup" });
|
||||
await rm(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
test("actual native collector near-cap receipt persists with maximum legal request and message IDs", async () => {
|
||||
const collector = createCursorNativeUsage(envelope().promptId);
|
||||
for (let i = 0; i < 64; i++) collector.beginParent()?.observe({ message: { case: "turnEnded", value: Object.fromEntries(
|
||||
["inputTokens", "outputTokens", "cacheReadTokens", "cacheWriteTokens", "reasoningTokens"].map(k => [k, Number.MAX_SAFE_INTEGER])) } });
|
||||
const receipt = collector.finish();
|
||||
assert.equal(receipt.truncated, true); assert.ok(JSON.stringify(receipt).length > 15000);
|
||||
const conversation = createConversation(); recordSubmission(conversation, "fixture");
|
||||
const messageId = "m".repeat(240); conversation.messages[0].User.id = messageId;
|
||||
await prompt(conversation, receipt, { requestId: "r".repeat(240), promptMessageId: messageId });
|
||||
assert.deepEqual(conversation.cursor_prompt_usage.receipt, receipt);
|
||||
assert.ok(Buffer.byteLength(JSON.stringify(conversation.cursor_prompt_usage)) <= 16384);
|
||||
const restored = parse(serialize(record(conversation)));
|
||||
assert.deepEqual(restored.cursor_prompt_usage.receipt.observations, receipt.observations);
|
||||
});
|
||||
|
||||
|
||||
test("standard prompt token accounting stays independent of native diagnostic counters", async () => {
|
||||
const conversation = createConversation(); const native = envelope();
|
||||
native.observations[0].counters.inputTokens = 999999;
|
||||
const { promptMessageId } = await prompt(conversation, native, { usage: { inputTokens: 12, outputTokens: 3 } });
|
||||
assert.equal(conversation.request_token_usage[promptMessageId].input_tokens, 12);
|
||||
assert.equal(conversation.cumulative_token_usage.input_tokens, 12);
|
||||
assert.equal(conversation.cursor_prompt_usage.receipt.observations[0].counters.inputTokens, 999999);
|
||||
assert.equal(conversation.cumulative_cost, undefined);
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"commandDigest": "sha256:3b97dac7020396a84997040ee2864a47af615d69aaf1c951de391e850038157a",
|
||||
"declaration": {
|
||||
"schema": "paperclip.acpx_profile_declaration.v1",
|
||||
"agent": "copilot",
|
||||
"agentProfileVersion": 8,
|
||||
"acpxVersion": "0.13.1",
|
||||
"agentServerVersion": "1.0.88",
|
||||
"protocolPolicyRevision": "copilot-agent-manual-v1",
|
||||
"acpxPatchSha256": "bd5393058a218040d217fa85449d59a6f30507de54cd645bf0ef21422823f85e",
|
||||
"policySha256": "a4be69be43db84052560420a3ca546a665c9165573c24d543eb33d144ba138a3",
|
||||
"distributionSourceSha256": "c0a65e247fc47b263bd369a3d5ab505c4b323d4e6493be8a9ecbeb0e9dbb9107",
|
||||
"agentFilesBinding": "registered-runtime-context-v1",
|
||||
"systemInstructionDelivery": "COPILOT_HOME/copilot-instructions.md:replace-under-lifetime-lease-before-launch:v1",
|
||||
"sharedRuntimeContract": "paperclip.acpx-runtime-contract.v1",
|
||||
"innerDistributionSourceSha256": "9bb08347eed01f1ab44e065c8b06ead9be3a25973d6e6329dc4c0a099096279a",
|
||||
"upstreamAppSha256": "7b48282a19b5b0814a0c96ad5e3a125173d792cc55f9525b2effea962f6063c0",
|
||||
"patchedAppSha256": "61f8eebe0c30cc03b311c6818b2acb158a6d16ca58c8bfac5ebd42db095c2679",
|
||||
"messageIdentityContract": "copilot-native-message-id-v1",
|
||||
"ownedDistributionDelivery": "COPILOT_CLI_DIST_DIR:lease-owned-guarded-inner-distribution:v1"
|
||||
}
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"schema": "paperclip.cursor.native-usage-offline-proof.v1",
|
||||
"providerCalls": 0,
|
||||
"standardUsageEmitted": false,
|
||||
"boundary": "digest-pinned handlePrompt, native invocation and child update methods; surrounding session/transport doubled",
|
||||
"platforms": [
|
||||
{
|
||||
"platform": "darwin-arm64",
|
||||
"vendorSha256": "7784c8b16d4e639814c13b12be2a687f5dc2cd98cbf29ed0a10820778ab1bf62",
|
||||
"candidateSha256": "83fa4d462b3d8fd95b05420589cc80b8a6536dbff0cc3c5d36fec69d8a753970",
|
||||
"checks": [
|
||||
"exact_parent_invocation_capture",
|
||||
"separate_followups",
|
||||
"no_standard_usage",
|
||||
"warm_prompt_isolation",
|
||||
"child_run_attribution",
|
||||
"history_and_late_child_isolation",
|
||||
"overlapping_cancellation",
|
||||
"late_parent_callback_isolation",
|
||||
"exception_cleanup"
|
||||
]
|
||||
},
|
||||
{
|
||||
"platform": "darwin-x64",
|
||||
"vendorSha256": "3c0aaf6ecc4fceb0f95e1731deec75384984570d14d0871cecec11972b948ac4",
|
||||
"candidateSha256": "060c8816fac3d110f5931ce626e5f2d841d8252fba7dafd52e37157ca915301d",
|
||||
"checks": [
|
||||
"exact_parent_invocation_capture",
|
||||
"separate_followups",
|
||||
"no_standard_usage",
|
||||
"warm_prompt_isolation",
|
||||
"child_run_attribution",
|
||||
"history_and_late_child_isolation",
|
||||
"overlapping_cancellation",
|
||||
"late_parent_callback_isolation",
|
||||
"exception_cleanup"
|
||||
]
|
||||
},
|
||||
{
|
||||
"platform": "linux-x64",
|
||||
"vendorSha256": "2de420f1b31e70ca74b083a1ce5b519c2abffa5789d71cc06e84d2c38acb7c07",
|
||||
"candidateSha256": "3435f90f028eb5b08cf8cb7b289694993636f2868e6af217c167a5fd27ac03b2",
|
||||
"checks": [
|
||||
"exact_parent_invocation_capture",
|
||||
"separate_followups",
|
||||
"no_standard_usage",
|
||||
"warm_prompt_isolation",
|
||||
"child_run_attribution",
|
||||
"history_and_late_child_isolation",
|
||||
"overlapping_cancellation",
|
||||
"late_parent_callback_isolation",
|
||||
"exception_cleanup"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"commandDigest": "sha256:b9e94cbcdce2783665612c85caf019c4defe37d3d245a694de590ebdf5b1f1a3",
|
||||
"declaration": {
|
||||
"schema": "paperclip.acpx_profile_declaration.v1",
|
||||
"agent": "cursor",
|
||||
"agentProfileVersion": 8,
|
||||
"acpxVersion": "0.13.1",
|
||||
"agentServerVersion": "2026.09.26-dd393fe",
|
||||
"patchVersion": "paperclip-cursor-usage-v3",
|
||||
"distribution": {
|
||||
"schema": "paperclip.cursor_distribution.v1",
|
||||
"version": "2026.09.26-dd393fe",
|
||||
"platforms": {
|
||||
"darwin-arm64": {
|
||||
"url": "https://downloads.cursor.com/lab/2026.09.26-dd393fe/darwin/arm64/agent-cli-package.tar.gz",
|
||||
"archiveSha256": "538827d96a779bab854a865c8e42859e8e87db34b5f69770261b90fc8cfff191",
|
||||
"closureSha256": "0681abb675b46054155e0b3f28850bf4668cf24fb1aab457dad1f24b61b3ee6b",
|
||||
"executable": "node",
|
||||
"entrypoint": "index.js",
|
||||
"vendorClosureSha256": "77394184a89b0e7384971181da19c3c82d83a399b04e7944b3f94fe3d7e62b25"
|
||||
},
|
||||
"darwin-x64": {
|
||||
"url": "https://downloads.cursor.com/lab/2026.09.26-dd393fe/darwin/x64/agent-cli-package.tar.gz",
|
||||
"archiveSha256": "ed1771c44cbf0f8059c67cac0d939e6a60eb730bd066d7dc4cbb14de314cfb6e",
|
||||
"closureSha256": "18caa80f90f70f6a7d815335062b0858327891ec854aebe90b585a42eac578e6",
|
||||
"executable": "node",
|
||||
"entrypoint": "index.js",
|
||||
"vendorClosureSha256": "7c8775160af74e5fb8d25a30e8412c1a6476b0661529078a686e5118fd1c5e5f"
|
||||
},
|
||||
"linux-x64": {
|
||||
"url": "https://downloads.cursor.com/lab/2026.09.26-dd393fe/linux/x64/agent-cli-package.tar.gz",
|
||||
"archiveSha256": "8085fd120f5c71f4eae7fea26a043718e5644e3071e4fab3220a0e58c51f9593",
|
||||
"closureSha256": "0ee4ceb24062e991650bfb4c0b4ec5fbd7f1a01487b8c70aef0cf257509701a4",
|
||||
"executable": "node",
|
||||
"entrypoint": "index.js",
|
||||
"vendorClosureSha256": "bf04ef8ea6a63191067a6b3e15139aa2c793d8419daab246aa3f0a012e1bbcd9"
|
||||
}
|
||||
},
|
||||
"patchVersion": "paperclip-cursor-usage-v3"
|
||||
},
|
||||
"agentFilesBinding": "registered-runtime-context-v1",
|
||||
"instructionBinding": "native-global-rules-v1",
|
||||
"instructionAdmission": "synchronous-protocol-guard-v1",
|
||||
"acpxPatchSha256": "bd5393058a218040d217fa85449d59a6f30507de54cd645bf0ef21422823f85e",
|
||||
"sessionModeAdmission": "native-config-ack-recovery-bound-v1",
|
||||
"sessionModes": [
|
||||
"agent",
|
||||
"plan",
|
||||
"ask"
|
||||
],
|
||||
"defaultSessionMode": "agent",
|
||||
"sharedRuntimeContract": "paperclip.acpx-runtime-contract.v1",
|
||||
"nativePlanToolIdentity": "request-item-id-bound-lifecycle-v1"
|
||||
}
|
||||
}
|
||||
Vendored
+80
@@ -0,0 +1,80 @@
|
||||
{
|
||||
"schema": "paperclip.cursor.runtime-patch-proof.v1",
|
||||
"patchVersion": "paperclip-cursor-usage-v3",
|
||||
"providerCalls": 0,
|
||||
"qualification": "offline-only",
|
||||
"platforms": [
|
||||
{
|
||||
"instructions": {
|
||||
"boundedPayloadRejected": true,
|
||||
"explicitEmptyInstructions": true,
|
||||
"nativeGlobalRule": true,
|
||||
"nativeResourceAdditionalRules": true,
|
||||
"newSession": {
|
||||
"acknowledgementMatches": true,
|
||||
"exactComposedBytesAtRequestContext": true,
|
||||
"nativeRulePrecedencePreserved": true
|
||||
},
|
||||
"loadSession": {
|
||||
"acknowledgementMatches": true,
|
||||
"exactComposedBytesAtRequestContext": true,
|
||||
"nativeRulePrecedencePreserved": true
|
||||
},
|
||||
"mainSourceSha256": "f8bd1c549f844859f8aeb9f06c01420f299bee22fe136695fe891eaf18674b12",
|
||||
"boundary": "native RequestContext constructor; dependencies doubled; no inference"
|
||||
},
|
||||
"platform": "darwin-arm64",
|
||||
"file": "5672.index.js",
|
||||
"sourceSha256": "7784c8b16d4e639814c13b12be2a687f5dc2cd98cbf29ed0a10820778ab1bf62",
|
||||
"patchedSha256": "83fa4d462b3d8fd95b05420589cc80b8a6536dbff0cc3c5d36fec69d8a753970",
|
||||
"driftRejected": true,
|
||||
"fullChunkCompiles": true,
|
||||
"ambientMcpAccesses": 0,
|
||||
"ownedMcpPreserved": true,
|
||||
"hookConfigReads": 0,
|
||||
"remoteTeamHookFetches": 0,
|
||||
"typedActionErrors": true,
|
||||
"assistantProseIgnored": true
|
||||
},
|
||||
{
|
||||
"instructions": {
|
||||
"boundedPayloadRejected": true,
|
||||
"explicitEmptyInstructions": true,
|
||||
"nativeGlobalRule": true,
|
||||
"nativeResourceAdditionalRules": true
|
||||
},
|
||||
"platform": "darwin-x64",
|
||||
"file": "9841.index.js",
|
||||
"sourceSha256": "3c0aaf6ecc4fceb0f95e1731deec75384984570d14d0871cecec11972b948ac4",
|
||||
"patchedSha256": "060c8816fac3d110f5931ce626e5f2d841d8252fba7dafd52e37157ca915301d",
|
||||
"driftRejected": true,
|
||||
"fullChunkCompiles": true,
|
||||
"ambientMcpAccesses": 0,
|
||||
"ownedMcpPreserved": true,
|
||||
"hookConfigReads": 0,
|
||||
"remoteTeamHookFetches": 0,
|
||||
"typedActionErrors": true,
|
||||
"assistantProseIgnored": true
|
||||
},
|
||||
{
|
||||
"instructions": {
|
||||
"boundedPayloadRejected": true,
|
||||
"explicitEmptyInstructions": true,
|
||||
"nativeGlobalRule": true,
|
||||
"nativeResourceAdditionalRules": true
|
||||
},
|
||||
"platform": "linux-x64",
|
||||
"file": "1699.index.js",
|
||||
"sourceSha256": "2de420f1b31e70ca74b083a1ce5b519c2abffa5789d71cc06e84d2c38acb7c07",
|
||||
"patchedSha256": "3435f90f028eb5b08cf8cb7b289694993636f2868e6af217c167a5fd27ac03b2",
|
||||
"driftRejected": true,
|
||||
"fullChunkCompiles": true,
|
||||
"ambientMcpAccesses": 0,
|
||||
"ownedMcpPreserved": true,
|
||||
"hookConfigReads": 0,
|
||||
"remoteTeamHookFetches": 0,
|
||||
"typedActionErrors": true,
|
||||
"assistantProseIgnored": true
|
||||
}
|
||||
]
|
||||
}
|
||||
+192
-48
@@ -1,5 +1,5 @@
|
||||
diff --git a/dist/client-CxNllqui.d.ts b/dist/client-CxNllqui.d.ts
|
||||
index 5e2113ac0a1a92c99322cf01e5c106760a0b0dbf..45aff03c37427071de49b45eb0361a9a808786e0 100644
|
||||
index 5e2113ac0..45aff03c3 100644
|
||||
--- a/dist/client-CxNllqui.d.ts
|
||||
+++ b/dist/client-CxNllqui.d.ts
|
||||
@@ -120,6 +120,8 @@ declare class AcpClient {
|
||||
@@ -20,10 +20,10 @@ index 5e2113ac0a1a92c99322cf01e5c106760a0b0dbf..45aff03c37427071de49b45eb0361a9a
|
||||
private setSessionModelThroughConfig;
|
||||
private setSessionModelThroughLegacyMethod;
|
||||
diff --git a/dist/live-checkpoint-BSIrfgVo.js b/dist/live-checkpoint-BSIrfgVo.js
|
||||
index d454fd7c5bf742b469be75eb8c3988b694a0ffaa..bafb3b8af8a5478681ff5d73858e0d2f72f51d51 100644
|
||||
index d454fd7c5..6eba74345 100644
|
||||
--- a/dist/live-checkpoint-BSIrfgVo.js
|
||||
+++ b/dist/live-checkpoint-BSIrfgVo.js
|
||||
@@ -1068,6 +1068,7 @@ function serializeSessionRecordForDisk(record) {
|
||||
@@ -1068,12 +1068,14 @@ function serializeSessionRecordForDisk(record) {
|
||||
last_agent_disconnect_reason: canonical.lastAgentDisconnectReason,
|
||||
protocol_version: canonical.protocolVersion,
|
||||
agent_capabilities: canonical.agentCapabilities,
|
||||
@@ -31,7 +31,14 @@ index d454fd7c5bf742b469be75eb8c3988b694a0ffaa..bafb3b8af8a5478681ff5d73858e0d2f
|
||||
title: canonical.title,
|
||||
messages: canonical.messages,
|
||||
updated_at: canonical.updated_at,
|
||||
@@ -1239,7 +1240,8 @@ function parseRequestTokenUsage(raw) {
|
||||
cumulative_token_usage: canonical.cumulative_token_usage,
|
||||
cumulative_cost: canonical.cumulative_cost,
|
||||
request_token_usage: canonical.request_token_usage,
|
||||
+ cursor_prompt_usage: boundCursorPromptUsage(canonical.cursor_prompt_usage, canonical.messages),
|
||||
acpx: canonical.acpx,
|
||||
imported_from: canonical.importedFrom ? {
|
||||
record_id: canonical.importedFrom.recordId,
|
||||
@@ -1239,7 +1241,8 @@ function parseRequestTokenUsage(raw) {
|
||||
for (const [key, value] of Object.entries(record)) {
|
||||
const parsed = parseTokenUsage(value);
|
||||
if (parsed == null) return null;
|
||||
@@ -41,7 +48,111 @@ index d454fd7c5bf742b469be75eb8c3988b694a0ffaa..bafb3b8af8a5478681ff5d73858e0d2f
|
||||
}
|
||||
return usage;
|
||||
}
|
||||
@@ -1541,6 +1543,7 @@ function parseSessionRecord(raw) {
|
||||
@@ -1325,6 +1328,93 @@ function isAgentMessage$1(raw) {
|
||||
function isConversationMessage(raw) {
|
||||
return raw === "Resume" || isUserMessage$1(raw) || isAgentMessage$1(raw);
|
||||
}
|
||||
+function parseCursorPromptUsage(raw) {
|
||||
+ try {
|
||||
+ const text = JSON.stringify(raw);
|
||||
+ if (!text || Buffer.byteLength(text, "utf8") > 16384) return null;
|
||||
+ const v = JSON.parse(text);
|
||||
+ const object = (x)=>x !== null && typeof x === "object" && !Array.isArray(x);
|
||||
+ const exact = (x, keys)=>Object.keys(x).length === keys.length && keys.every((k)=>Object.hasOwn(x, k));
|
||||
+ const id = (x)=>typeof x === "string" && /^[A-Za-z0-9][A-Za-z0-9._:-]{0,239}$/.test(x);
|
||||
+ if (!object(v) || !exact(v, [
|
||||
+ "request_id",
|
||||
+ "prompt_message_id",
|
||||
+ "receipt"
|
||||
+ ]) || !id(v.request_id) || !id(v.prompt_message_id)) return null;
|
||||
+ const r = v.receipt;
|
||||
+ if (!object(r) || !exact(r, [
|
||||
+ "schema",
|
||||
+ "source",
|
||||
+ "promptId",
|
||||
+ "completeness",
|
||||
+ "reasons",
|
||||
+ "observations",
|
||||
+ "limits",
|
||||
+ "truncated"
|
||||
+ ])) return null;
|
||||
+ if (r.schema !== "paperclip.cursor.native-usage.v1" || r.source !== "native_turn_ended" || r.completeness !== "partial" || typeof r.promptId !== "string" || !/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(r.promptId) || typeof r.truncated !== "boolean") return null;
|
||||
+ const reasons = [
|
||||
+ "native_counter_semantics_unverified",
|
||||
+ "native_counters_missing",
|
||||
+ "invalid_native_counter",
|
||||
+ "multiple_terminal_observations",
|
||||
+ "native_terminal_not_observed",
|
||||
+ "observation_limit_reached",
|
||||
+ "child_run_attribution_unverified"
|
||||
+ ];
|
||||
+ if (!Array.isArray(r.reasons) || r.reasons.length > reasons.length || !r.reasons.includes(reasons[0]) || new Set(r.reasons).size !== r.reasons.length || !r.reasons.every((x)=>reasons.includes(x))) return null;
|
||||
+ if (!object(r.limits) || !exact(r.limits, [
|
||||
+ "maxObservations",
|
||||
+ "maxInvocations",
|
||||
+ "maxBytes"
|
||||
+ ]) || r.limits.maxObservations !== 64 || r.limits.maxInvocations !== 64 || r.limits.maxBytes !== 16384 || !Array.isArray(r.observations) || r.observations.length > 64) return null;
|
||||
+ const fields = [
|
||||
+ "inputTokens",
|
||||
+ "outputTokens",
|
||||
+ "cacheReadTokens",
|
||||
+ "cacheWriteTokens",
|
||||
+ "reasoningTokens"
|
||||
+ ];
|
||||
+ const invocations = new Map();
|
||||
+ for (const o of r.observations){
|
||||
+ if (!object(o) || !exact(o, [
|
||||
+ "invocationId",
|
||||
+ "role",
|
||||
+ "nativeRun",
|
||||
+ "sequence",
|
||||
+ "counters"
|
||||
+ ]) || typeof o.invocationId !== "string" || !/^invocation-([1-9]|[1-5][0-9]|6[0-4])$/.test(o.invocationId) || o.role !== "parent" && o.role !== "child" || !Number.isSafeInteger(o.nativeRun) || o.nativeRun < 1 || !Number.isSafeInteger(o.sequence) || o.sequence < 1 || !object(o.counters)) return null;
|
||||
+ if (!Object.entries(o.counters).every(([k, n])=>fields.includes(k) && Number.isSafeInteger(n) && n >= 0)) return null;
|
||||
+ const previous = invocations.get(o.invocationId);
|
||||
+ if (previous && (previous.role !== o.role || previous.nativeRun !== o.nativeRun || o.sequence <= previous.sequence)) return null;
|
||||
+ invocations.set(o.invocationId, {
|
||||
+ role: o.role,
|
||||
+ nativeRun: o.nativeRun,
|
||||
+ sequence: o.sequence
|
||||
+ });
|
||||
+ }
|
||||
+ return v;
|
||||
+ } catch {
|
||||
+ return null;
|
||||
+ }
|
||||
+}
|
||||
+function boundCursorPromptUsage(raw, messages) {
|
||||
+ const receipt = parseCursorPromptUsage(raw);
|
||||
+ if (!receipt || !Array.isArray(messages) || messages.filter(message => message?.User?.id === receipt.prompt_message_id).length !== 1) return undefined;
|
||||
+ return receipt;
|
||||
+}
|
||||
+function recordCursorPromptUsage(conversation, response, requestId, promptMessageId) {
|
||||
+ // Optional diagnostics cannot affect settlement or standard token/cost accounting.
|
||||
+ try {
|
||||
+ if (response.stopReason !== "end_turn" && response.stopReason !== "cancelled") return;
|
||||
+ const next = boundCursorPromptUsage({request_id: requestId, prompt_message_id: promptMessageId, receipt: response._meta?.paperclipCursorUsage}, conversation.messages);
|
||||
+ if (!next) return;
|
||||
+ const previous = parseCursorPromptUsage(conversation.cursor_prompt_usage);
|
||||
+ if (previous && (previous.request_id === next.request_id || previous.prompt_message_id === next.prompt_message_id || previous.receipt.promptId === next.receipt.promptId)) return;
|
||||
+ conversation.cursor_prompt_usage = next;
|
||||
+ } catch {}
|
||||
+}
|
||||
+
|
||||
function parseConversationRecord(record) {
|
||||
if (!hasValidConversationCore(record)) return;
|
||||
const title = parseConversationTitle(record.title);
|
||||
@@ -1339,7 +1429,8 @@ function parseConversationRecord(record) {
|
||||
updated_at: record.updated_at,
|
||||
cumulative_token_usage: cumulativeTokenUsage ?? {},
|
||||
cumulative_cost: cumulativeCost,
|
||||
- request_token_usage: requestTokenUsage ?? {}
|
||||
+ request_token_usage: requestTokenUsage ?? {},
|
||||
+ cursor_prompt_usage: boundCursorPromptUsage(record.cursor_prompt_usage, record.messages)
|
||||
};
|
||||
}
|
||||
const INVALID_VALUE = Symbol("invalid");
|
||||
@@ -1541,12 +1632,14 @@ function parseSessionRecord(raw) {
|
||||
lastAgentDisconnectReason: optionals.lastAgentDisconnectReason,
|
||||
protocolVersion: typeof record.protocol_version === "number" ? record.protocol_version : void 0,
|
||||
agentCapabilities: asRecord$4(record.agent_capabilities),
|
||||
@@ -49,16 +160,25 @@ index d454fd7c5bf742b469be75eb8c3988b694a0ffaa..bafb3b8af8a5478681ff5d73858e0d2f
|
||||
title: conversation.title,
|
||||
messages: conversation.messages,
|
||||
updated_at: conversation.updated_at,
|
||||
@@ -1661,7 +1664,7 @@ const ZED_TAG_KEYS = /* @__PURE__ */ new Set([
|
||||
cumulative_token_usage: conversation.cumulative_token_usage,
|
||||
cumulative_cost: conversation.cumulative_cost,
|
||||
request_token_usage: conversation.request_token_usage,
|
||||
+ cursor_prompt_usage: conversation.cursor_prompt_usage,
|
||||
acpx: parseAcpxState(record.acpx),
|
||||
importedFrom: metadata.importedFrom
|
||||
};
|
||||
@@ -1661,8 +1754,9 @@ const ZED_TAG_KEYS = /* @__PURE__ */ new Set([
|
||||
"RedactedThinking",
|
||||
"ToolUse"
|
||||
]);
|
||||
-const MAP_OBJECT_PATHS = /* @__PURE__ */ new Set(["request_token_usage", "messages.Agent.tool_results"]);
|
||||
+const MAP_OBJECT_PATHS = /* @__PURE__ */ new Set(["request_token_usage", "messages.Agent.tool_results", "acpx.session_options.env"]);
|
||||
const OPAQUE_VALUE_PATHS = /* @__PURE__ */ new Set([
|
||||
+ "cursor_prompt_usage.receipt",
|
||||
"agent_capabilities",
|
||||
"messages.Agent.content.ToolUse.input",
|
||||
@@ -3104,10 +3107,10 @@ function authEnvKey(methodId) {
|
||||
"acpx.desired_config_options",
|
||||
@@ -3104,10 +3198,10 @@ function authEnvKey(methodId) {
|
||||
authEnvKeyCache.set(methodId, key);
|
||||
return key;
|
||||
}
|
||||
@@ -71,7 +191,7 @@ index d454fd7c5bf742b469be75eb8c3988b694a0ffaa..bafb3b8af8a5478681ff5d73858e0d2f
|
||||
if (typeof value === "string" && value.trim().length > 0) return value;
|
||||
}
|
||||
function protectedEnvKey(key) {
|
||||
@@ -3135,8 +3138,21 @@ function promotePrefixedAuthEnvironment(env) {
|
||||
@@ -3135,8 +3229,21 @@ function promotePrefixedAuthEnvironment(env) {
|
||||
}
|
||||
return protectedKeys;
|
||||
}
|
||||
@@ -95,7 +215,7 @@ index d454fd7c5bf742b469be75eb8c3988b694a0ffaa..bafb3b8af8a5478681ff5d73858e0d2f
|
||||
const protectedAuthEnvKeys = promotePrefixedAuthEnvironment(env);
|
||||
if (authCredentials) for (const [methodId, credential] of Object.entries(authCredentials)) {
|
||||
addAuthCredentialEnvKeys(protectedAuthEnvKeys, methodId, credential);
|
||||
@@ -3178,10 +3194,10 @@ function resolveConfiguredAuthCredential(methodId, authCredentials) {
|
||||
@@ -3178,10 +3285,10 @@ function resolveConfiguredAuthCredential(methodId, authCredentials) {
|
||||
const configCredentials = authCredentials ?? {};
|
||||
return configCredentials[methodId] ?? configCredentials[toEnvToken(methodId)];
|
||||
}
|
||||
@@ -108,7 +228,7 @@ index d454fd7c5bf742b469be75eb8c3988b694a0ffaa..bafb3b8af8a5478681ff5d73858e0d2f
|
||||
stdio: [
|
||||
"pipe",
|
||||
"pipe",
|
||||
@@ -3961,10 +3977,13 @@ function resolveClientCapabilities(params) {
|
||||
@@ -3961,10 +4068,13 @@ function resolveClientCapabilities(params) {
|
||||
...params.elicitationModes.includes("url") ? { url: {} } : {}
|
||||
} } : {}
|
||||
};
|
||||
@@ -124,7 +244,7 @@ index d454fd7c5bf742b469be75eb8c3988b694a0ffaa..bafb3b8af8a5478681ff5d73858e0d2f
|
||||
};
|
||||
}
|
||||
function hasResponseField(response, field) {
|
||||
@@ -4067,6 +4086,27 @@ function createNdJsonMessageStream(agentCommand, output, input) {
|
||||
@@ -4067,6 +4177,27 @@ function createNdJsonMessageStream(agentCommand, output, input) {
|
||||
} })
|
||||
};
|
||||
}
|
||||
@@ -152,7 +272,7 @@ index d454fd7c5bf742b469be75eb8c3988b694a0ffaa..bafb3b8af8a5478681ff5d73858e0d2f
|
||||
var AcpClient = class {
|
||||
options;
|
||||
connection;
|
||||
@@ -4105,7 +4145,9 @@ var AcpClient = class {
|
||||
@@ -4105,7 +4236,9 @@ var AcpClient = class {
|
||||
cwd: asAbsoluteCwd(options.cwd),
|
||||
authPolicy: options.authPolicy ?? "skip",
|
||||
permissionPolicy: snapshotPermissionPolicy(options.permissionPolicy),
|
||||
@@ -163,7 +283,7 @@ index d454fd7c5bf742b469be75eb8c3988b694a0ffaa..bafb3b8af8a5478681ff5d73858e0d2f
|
||||
};
|
||||
this.eventHandlers = {
|
||||
onAcpMessage: this.options.onAcpMessage,
|
||||
@@ -4216,9 +4258,21 @@ var AcpClient = class {
|
||||
@@ -4216,9 +4349,21 @@ var AcpClient = class {
|
||||
this.lastAgentExit = void 0;
|
||||
this.lastKnownPid = child.pid ?? void 0;
|
||||
this.attachAgentLifecycleObservers(child);
|
||||
@@ -185,7 +305,7 @@ index d454fd7c5bf742b469be75eb8c3988b694a0ffaa..bafb3b8af8a5478681ff5d73858e0d2f
|
||||
if (!this.options.verbose) return;
|
||||
process.stderr.write(chunk);
|
||||
});
|
||||
@@ -4226,6 +4280,7 @@ var AcpClient = class {
|
||||
@@ -4226,6 +4371,7 @@ var AcpClient = class {
|
||||
const output = Readable.toWeb(child.stdout);
|
||||
const stream = this.createTappedStream(createNdJsonMessageStream(this.options.agentCommand, input, output));
|
||||
const connection = this.createConnection(stream, launch);
|
||||
@@ -193,7 +313,7 @@ index d454fd7c5bf742b469be75eb8c3988b694a0ffaa..bafb3b8af8a5478681ff5d73858e0d2f
|
||||
connection.signal.addEventListener("abort", () => {
|
||||
this.recordAgentExit("connection_close", child.exitCode ?? null, child.signalCode ?? null);
|
||||
}, { once: true });
|
||||
@@ -4253,7 +4308,12 @@ var AcpClient = class {
|
||||
@@ -4253,7 +4399,12 @@ var AcpClient = class {
|
||||
geminiAcp: isGeminiAcpCommand(spawnCommand, args),
|
||||
copilotAcp: isCopilotAcpCommand(spawnCommand, args),
|
||||
claudeAcp: isClaudeAcpCommand(spawnCommand, args),
|
||||
@@ -207,7 +327,7 @@ index d454fd7c5bf742b469be75eb8c3988b694a0ffaa..bafb3b8af8a5478681ff5d73858e0d2f
|
||||
};
|
||||
}
|
||||
logAgentLaunch(plan) {
|
||||
@@ -4279,10 +4339,17 @@ var AcpClient = class {
|
||||
@@ -4279,10 +4430,17 @@ var AcpClient = class {
|
||||
}
|
||||
async spawnAgentProcess(plan) {
|
||||
const spawnCommand = buildAgentSpawnCommand(plan.spawnCommand, plan.args, process.platform, plan.spawnOptions.env);
|
||||
@@ -227,7 +347,7 @@ index d454fd7c5bf742b469be75eb8c3988b694a0ffaa..bafb3b8af8a5478681ff5d73858e0d2f
|
||||
try {
|
||||
await waitForSpawn$1(spawnedChild);
|
||||
} catch (error) {
|
||||
@@ -4293,10 +4360,10 @@ var AcpClient = class {
|
||||
@@ -4293,10 +4451,10 @@ var AcpClient = class {
|
||||
createConnection(stream, launch) {
|
||||
const app = client({ name: "acpx" }).onNotification(methods.client.session.update, async ({ params }) => {
|
||||
await this.handleSessionUpdate(params);
|
||||
@@ -241,7 +361,7 @@ index d454fd7c5bf742b469be75eb8c3988b694a0ffaa..bafb3b8af8a5478681ff5d73858e0d2f
|
||||
}).onRequest(methods.client.fs.readTextFile, async ({ params }) => {
|
||||
return await this.handleReadTextFile(params);
|
||||
}).onRequest(methods.client.fs.writeTextFile, async ({ params }) => {
|
||||
@@ -4312,6 +4379,11 @@ var AcpClient = class {
|
||||
@@ -4312,6 +4470,11 @@ var AcpClient = class {
|
||||
}).onRequest(methods.client.terminal.release, async ({ params }) => {
|
||||
return await this.handleReleaseTerminal(params);
|
||||
});
|
||||
@@ -253,7 +373,7 @@ index d454fd7c5bf742b469be75eb8c3988b694a0ffaa..bafb3b8af8a5478681ff5d73858e0d2f
|
||||
if (launch.devinAcp) app.onRequest("_cognition.ai/request_diagnostics", (params) => {
|
||||
return params && typeof params === "object" && !Array.isArray(params) ? params : {};
|
||||
}, async () => ({}));
|
||||
@@ -4333,12 +4405,12 @@ var AcpClient = class {
|
||||
@@ -4333,12 +4496,12 @@ var AcpClient = class {
|
||||
async initializeProtocolConnection(connection, launch) {
|
||||
const initializePromise = connection.initialize({
|
||||
protocolVersion: PROTOCOL_VERSION,
|
||||
@@ -268,7 +388,7 @@ index d454fd7c5bf742b469be75eb8c3988b694a0ffaa..bafb3b8af8a5478681ff5d73858e0d2f
|
||||
clientInfo: resolveClientInfo(launch.devinAcp)
|
||||
});
|
||||
const initialized = launch.geminiAcp ? await withTimeout(initializePromise, resolveGeminiAcpStartupTimeoutMs()) : await initializePromise;
|
||||
@@ -4358,6 +4430,49 @@ var AcpClient = class {
|
||||
@@ -4358,6 +4521,49 @@ var AcpClient = class {
|
||||
throw normalizedError;
|
||||
}
|
||||
createTappedStream(base) {
|
||||
@@ -318,7 +438,7 @@ index d454fd7c5bf742b469be75eb8c3988b694a0ffaa..bafb3b8af8a5478681ff5d73858e0d2f
|
||||
const onAcpMessage = () => this.eventHandlers.onAcpMessage;
|
||||
const onAcpOutputMessage = () => this.eventHandlers.onAcpOutputMessage;
|
||||
const elicitationRequestIds = /* @__PURE__ */ new Set();
|
||||
@@ -4367,8 +4482,12 @@ var AcpClient = class {
|
||||
@@ -4367,8 +4573,12 @@ var AcpClient = class {
|
||||
const shouldSuppressInboundReplaySessionUpdate = (message) => {
|
||||
return this.suppressReplaySessionUpdateMessages && isSessionUpdateNotification(message);
|
||||
};
|
||||
@@ -332,7 +452,7 @@ index d454fd7c5bf742b469be75eb8c3988b694a0ffaa..bafb3b8af8a5478681ff5d73858e0d2f
|
||||
if (requestId !== void 0) {
|
||||
elicitationRequestIds.add(requestId);
|
||||
return;
|
||||
@@ -4379,6 +4498,7 @@ var AcpClient = class {
|
||||
@@ -4379,6 +4589,7 @@ var AcpClient = class {
|
||||
}
|
||||
};
|
||||
return {
|
||||
@@ -340,7 +460,7 @@ index d454fd7c5bf742b469be75eb8c3988b694a0ffaa..bafb3b8af8a5478681ff5d73858e0d2f
|
||||
readable: new ReadableStream({ async start(controller) {
|
||||
const reader = base.readable.getReader();
|
||||
try {
|
||||
@@ -4386,16 +4506,27 @@ var AcpClient = class {
|
||||
@@ -4386,16 +4597,27 @@ var AcpClient = class {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
if (!value) continue;
|
||||
@@ -370,7 +490,7 @@ index d454fd7c5bf742b469be75eb8c3988b694a0ffaa..bafb3b8af8a5478681ff5d73858e0d2f
|
||||
const promptOwner = promptRequestOwner(message);
|
||||
if (promptOwner) bindPromptOwner(promptOwner);
|
||||
const id = responseId(message);
|
||||
@@ -4403,10 +4534,20 @@ var AcpClient = class {
|
||||
@@ -4403,10 +4625,20 @@ var AcpClient = class {
|
||||
onAcpOutputMessage()?.("outbound", message);
|
||||
onAcpMessage()?.("outbound", message);
|
||||
}
|
||||
@@ -391,7 +511,7 @@ index d454fd7c5bf742b469be75eb8c3988b694a0ffaa..bafb3b8af8a5478681ff5d73858e0d2f
|
||||
writer.releaseLock();
|
||||
}
|
||||
} })
|
||||
@@ -4422,7 +4563,10 @@ var AcpClient = class {
|
||||
@@ -4422,7 +4654,10 @@ var AcpClient = class {
|
||||
const createPromise = this.runConnectionRequest(() => connection.newSession({
|
||||
cwd: sessionCwd,
|
||||
mcpServers: this.options.mcpServers ?? [],
|
||||
@@ -403,7 +523,7 @@ index d454fd7c5bf742b469be75eb8c3988b694a0ffaa..bafb3b8af8a5478681ff5d73858e0d2f
|
||||
}));
|
||||
result = claudeAcp ? await withTimeout(createPromise, resolveClaudeAcpSessionCreateTimeoutMs()) : await createPromise;
|
||||
} catch (error) {
|
||||
@@ -4864,7 +5008,7 @@ var AcpClient = class {
|
||||
@@ -4864,7 +5099,7 @@ var AcpClient = class {
|
||||
}
|
||||
selectAuthMethod(methods) {
|
||||
for (const method of methods) {
|
||||
@@ -412,7 +532,7 @@ index d454fd7c5bf742b469be75eb8c3988b694a0ffaa..bafb3b8af8a5478681ff5d73858e0d2f
|
||||
if (envCredential) return {
|
||||
methodId: method.id,
|
||||
credential: envCredential,
|
||||
@@ -4890,7 +5034,7 @@ var AcpClient = class {
|
||||
@@ -4890,7 +5125,7 @@ var AcpClient = class {
|
||||
}
|
||||
readAgentSpecificEnvCredential(methodId) {
|
||||
if (!this.isGrokBuildAcpCommand() || methodId !== "xai.api_key") return;
|
||||
@@ -421,7 +541,7 @@ index d454fd7c5bf742b469be75eb8c3988b694a0ffaa..bafb3b8af8a5478681ff5d73858e0d2f
|
||||
return typeof value === "string" && value.trim().length > 0 ? value : void 0;
|
||||
}
|
||||
selectAgentManagedAuthMethod(methodId) {
|
||||
@@ -4915,9 +5059,9 @@ var AcpClient = class {
|
||||
@@ -4915,9 +5150,9 @@ var AcpClient = class {
|
||||
await connection.authenticate({ methodId: selected.methodId });
|
||||
this.log(`authenticated with method ${selected.methodId} (${selected.source})`);
|
||||
}
|
||||
@@ -433,7 +553,7 @@ index d454fd7c5bf742b469be75eb8c3988b694a0ffaa..bafb3b8af8a5478681ff5d73858e0d2f
|
||||
if (hostResponse) return hostResponse;
|
||||
const { response, recorded } = await this.resolvePermissionRequestFromMode(params);
|
||||
if (!recorded) {
|
||||
@@ -4926,14 +5070,81 @@ var AcpClient = class {
|
||||
@@ -4926,14 +5161,81 @@ var AcpClient = class {
|
||||
}
|
||||
return response;
|
||||
}
|
||||
@@ -517,7 +637,7 @@ index d454fd7c5bf742b469be75eb8c3988b694a0ffaa..bafb3b8af8a5478681ff5d73858e0d2f
|
||||
})).then((response) => ({
|
||||
kind: "response",
|
||||
response
|
||||
@@ -4970,7 +5181,7 @@ var AcpClient = class {
|
||||
@@ -4970,7 +5272,7 @@ var AcpClient = class {
|
||||
isElicitationSessionCancelling(sessionId) {
|
||||
return this.closing || this.cancellingSessionIds.has(sessionId);
|
||||
}
|
||||
@@ -526,7 +646,7 @@ index d454fd7c5bf742b469be75eb8c3988b694a0ffaa..bafb3b8af8a5478681ff5d73858e0d2f
|
||||
if (!this.options.onPermissionRequest) return;
|
||||
const signal = this.cancellationSignalForSession(params.sessionId);
|
||||
try {
|
||||
@@ -4978,7 +5189,7 @@ var AcpClient = class {
|
||||
@@ -4978,7 +5280,7 @@ var AcpClient = class {
|
||||
sessionId: params.sessionId,
|
||||
raw: params,
|
||||
inferredKind: inferToolKind(params)
|
||||
@@ -535,7 +655,7 @@ index d454fd7c5bf742b469be75eb8c3988b694a0ffaa..bafb3b8af8a5478681ff5d73858e0d2f
|
||||
return this.hostPermissionDecisionResponse(params, signal, decision);
|
||||
} catch (error) {
|
||||
return this.hostPermissionErrorResponse(params, signal, error);
|
||||
@@ -5028,6 +5239,12 @@ var AcpClient = class {
|
||||
@@ -5028,6 +5330,12 @@ var AcpClient = class {
|
||||
attachAgentLifecycleObservers(child) {
|
||||
child.once("exit", (exitCode, signal) => {
|
||||
this.recordAgentExit("process_exit", exitCode, signal);
|
||||
@@ -548,7 +668,25 @@ index d454fd7c5bf742b469be75eb8c3988b694a0ffaa..bafb3b8af8a5478681ff5d73858e0d2f
|
||||
});
|
||||
child.once("close", (exitCode, signal) => {
|
||||
this.recordAgentExit("process_close", exitCode, signal);
|
||||
@@ -5771,10 +5988,20 @@ function recordSessionUpdate(conversation, state, notification, timestamp = isoN
|
||||
@@ -5239,6 +5547,7 @@ function applyConversation(record, conversation) {
|
||||
record.cumulative_token_usage = conversation.cumulative_token_usage;
|
||||
record.cumulative_cost = conversation.cumulative_cost;
|
||||
record.request_token_usage = conversation.request_token_usage;
|
||||
+ record.cursor_prompt_usage = boundCursorPromptUsage(conversation.cursor_prompt_usage, conversation.messages);
|
||||
}
|
||||
//#endregion
|
||||
//#region src/runtime/engine/session-options.ts
|
||||
@@ -5701,7 +6010,8 @@ function cloneSessionConversation(conversation) {
|
||||
updated_at: conversation.updated_at,
|
||||
cumulative_token_usage: deepClone(conversation.cumulative_token_usage ?? {}),
|
||||
cumulative_cost: cloneUsageCost(conversation.cumulative_cost),
|
||||
- request_token_usage: deepClone(conversation.request_token_usage ?? {})
|
||||
+ request_token_usage: deepClone(conversation.request_token_usage ?? {}),
|
||||
+ cursor_prompt_usage: boundCursorPromptUsage(conversation.cursor_prompt_usage, conversation.messages)
|
||||
};
|
||||
}
|
||||
function cloneUsageCost(cost) {
|
||||
@@ -5771,10 +6081,20 @@ function recordSessionUpdate(conversation, state, notification, timestamp = isoN
|
||||
trimConversationForRuntime(conversation);
|
||||
return acpx;
|
||||
}
|
||||
@@ -571,7 +709,7 @@ index d454fd7c5bf742b469be75eb8c3988b694a0ffaa..bafb3b8af8a5478681ff5d73858e0d2f
|
||||
updateConversationTimestamp(conversation, timestamp);
|
||||
trimConversationForRuntime(conversation);
|
||||
return true;
|
||||
@@ -6435,6 +6662,27 @@ async function withConnectedSession(options) {
|
||||
@@ -6435,6 +6755,27 @@ async function withConnectedSession(options) {
|
||||
//#region src/runtime/engine/prompt-turn.ts
|
||||
const SESSION_REPLY_IDLE_MS = 1e3;
|
||||
const SESSION_REPLY_DRAIN_TIMEOUT_MS = 5e3;
|
||||
@@ -599,12 +737,13 @@ index d454fd7c5bf742b469be75eb8c3988b694a0ffaa..bafb3b8af8a5478681ff5d73858e0d2f
|
||||
async function runPromptTurn(params) {
|
||||
try {
|
||||
const promptPromise = params.client.prompt(params.sessionId, params.prompt, params.onPromptRequestStarted, params.onElicitation);
|
||||
@@ -6444,7 +6692,22 @@ async function runPromptTurn(params) {
|
||||
@@ -6444,7 +6785,23 @@ async function runPromptTurn(params) {
|
||||
idleMs: SESSION_REPLY_IDLE_MS,
|
||||
timeoutMs: SESSION_REPLY_DRAIN_TIMEOUT_MS
|
||||
}).catch(() => {});
|
||||
+ // A failed terminal result may still include authoritative billable usage.
|
||||
recordPromptResponseUsage(params.conversation, response.usage, params.promptMessageId);
|
||||
+ recordCursorPromptUsage(params.conversation, response, params.requestId, params.promptMessageId);
|
||||
+ const terminalFailureCategory = typedTerminalSessionFailureCategory(response);
|
||||
+ if (terminalFailureCategory !== null) {
|
||||
+ // Pass complete provider text to the diagnostic callback. Consumers redact
|
||||
@@ -623,7 +762,7 @@ index d454fd7c5bf742b469be75eb8c3988b694a0ffaa..bafb3b8af8a5478681ff5d73858e0d2f
|
||||
stopReason: response.stopReason,
|
||||
source: "rpc"
|
||||
diff --git a/dist/runtime.d.ts b/dist/runtime.d.ts
|
||||
index e8102acb03c4c38830ad5ec22f356125eb0423b7..377c5d83830640cde0119d77bafbb46931301aa8 100644
|
||||
index e8102acb0..377c5d838 100644
|
||||
--- a/dist/runtime.d.ts
|
||||
+++ b/dist/runtime.d.ts
|
||||
@@ -1,7 +1,8 @@
|
||||
@@ -736,10 +875,10 @@ index e8102acb03c4c38830ad5ec22f356125eb0423b7..377c5d83830640cde0119d77bafbb469
|
||||
text: string;
|
||||
attachments?: AcpRuntimeTurnAttachment[];
|
||||
diff --git a/dist/runtime.js b/dist/runtime.js
|
||||
index a1f4a70a003792c6eacf68b6b038f37bfec1db53..896617544f6467c88da0dedf5c4867fc63e1fffe 100644
|
||||
index a1f4a70a0..a17fce3b3 100644
|
||||
--- a/dist/runtime.js
|
||||
+++ b/dist/runtime.js
|
||||
@@ -154,7 +154,7 @@ function resolveTextChunk(params) {
|
||||
@@ -153,7 +153,7 @@ function resolveTextChunk(params) {
|
||||
const contentType = asTrimmedString(contentRaw.type);
|
||||
if (contentType && contentType !== "text") return null;
|
||||
const text = asString(contentRaw.text);
|
||||
@@ -748,7 +887,7 @@ index a1f4a70a003792c6eacf68b6b038f37bfec1db53..896617544f6467c88da0dedf5c4867fc
|
||||
type: "text_delta",
|
||||
text,
|
||||
stream: params.stream,
|
||||
@@ -165,7 +165,7 @@ function resolveTextChunk(params) {
|
||||
@@ -162,7 +162,7 @@ function resolveTextChunk(params) {
|
||||
};
|
||||
}
|
||||
const text = asString(params.payload.text);
|
||||
@@ -882,7 +1021,7 @@ index a1f4a70a003792c6eacf68b6b038f37bfec1db53..896617544f6467c88da0dedf5c4867fc
|
||||
applyConfigOptionsToRecord(record, session.sessionResult);
|
||||
const modelApplication = await applyRequestedModelIfAdvertised({
|
||||
client,
|
||||
@@ -1301,6 +1360,7 @@ var AcpRuntimeManager = class {
|
||||
@@ -1301,9 +1360,11 @@ var AcpRuntimeManager = class {
|
||||
client: turn.client,
|
||||
sessionId,
|
||||
prompt: task.promptInput,
|
||||
@@ -890,7 +1029,11 @@ index a1f4a70a003792c6eacf68b6b038f37bfec1db53..896617544f6467c88da0dedf5c4867fc
|
||||
timeoutMs: task.input.timeoutMs ?? this.options.timeoutMs,
|
||||
conversation: turn.conversation,
|
||||
promptMessageId: turn.promptMessageId,
|
||||
@@ -1469,6 +1529,10 @@ var AcpRuntimeManager = class {
|
||||
+ requestId: task.input.requestId,
|
||||
onPromptRequestStarted: () => task.promptStarted.resolve(),
|
||||
onElicitation: task.input.onElicitation
|
||||
});
|
||||
@@ -1469,6 +1530,10 @@ var AcpRuntimeManager = class {
|
||||
setSessionConfigOption: async (configId, value) => {
|
||||
return (await task.state.activeController.setResolvedSessionConfigOption(configId, value)).response;
|
||||
},
|
||||
@@ -901,7 +1044,7 @@ index a1f4a70a003792c6eacf68b6b038f37bfec1db53..896617544f6467c88da0dedf5c4867fc
|
||||
setResolvedSessionConfigOption: async (configId, value) => await this.setRuntimeResolvedSessionConfigOption(task, turn, configId, value)
|
||||
};
|
||||
}
|
||||
@@ -1575,6 +1639,8 @@ var AcpRuntimeManager = class {
|
||||
@@ -1575,6 +1640,8 @@ var AcpRuntimeManager = class {
|
||||
reconcileAgentSessionId(turn.record, turn.record.agentSessionId);
|
||||
turn.record.protocolVersion = turn.client.initializeResult?.protocolVersion;
|
||||
turn.record.agentCapabilities = turn.client.initializeResult?.agentCapabilities;
|
||||
@@ -910,7 +1053,7 @@ index a1f4a70a003792c6eacf68b6b038f37bfec1db53..896617544f6467c88da0dedf5c4867fc
|
||||
turn.record.acpx = turn.acpxState;
|
||||
applyConversation(turn.record, turn.conversation);
|
||||
applyLifecycleSnapshotToRecord(turn.record, turn.client.getAgentLifecycleSnapshot());
|
||||
@@ -1707,6 +1773,17 @@ var AcpRuntimeManager = class {
|
||||
@@ -1707,6 +1774,17 @@ var AcpRuntimeManager = class {
|
||||
});
|
||||
await this.options.sessionStore.save(result.record);
|
||||
}
|
||||
@@ -928,7 +1071,7 @@ index a1f4a70a003792c6eacf68b6b038f37bfec1db53..896617544f6467c88da0dedf5c4867fc
|
||||
async cancel(handle) {
|
||||
await this.activeControllers.get(handle.acpxRecordId ?? handle.sessionKey)?.requestCancelActivePrompt();
|
||||
}
|
||||
@@ -2055,6 +2132,7 @@ var AcpxRuntime = class {
|
||||
@@ -2055,6 +2133,7 @@ var AcpxRuntime = class {
|
||||
const turnPromise = this.getManager().then((manager) => manager.startTurn({
|
||||
handle,
|
||||
text: input.text,
|
||||
@@ -936,7 +1079,7 @@ index a1f4a70a003792c6eacf68b6b038f37bfec1db53..896617544f6467c88da0dedf5c4867fc
|
||||
attachments: input.attachments,
|
||||
mode: input.mode,
|
||||
sessionMode: state.mode,
|
||||
@@ -2087,6 +2165,7 @@ var AcpxRuntime = class {
|
||||
@@ -2087,6 +2166,7 @@ var AcpxRuntime = class {
|
||||
yield* (await this.getManager()).runTurn({
|
||||
handle,
|
||||
text: input.text,
|
||||
@@ -944,7 +1087,7 @@ index a1f4a70a003792c6eacf68b6b038f37bfec1db53..896617544f6467c88da0dedf5c4867fc
|
||||
attachments: input.attachments,
|
||||
mode: input.mode,
|
||||
sessionMode: state.mode,
|
||||
@@ -2119,6 +2198,14 @@ var AcpxRuntime = class {
|
||||
@@ -2119,6 +2199,14 @@ var AcpxRuntime = class {
|
||||
const { handle, state } = this.resolveManagerHandle(input.handle);
|
||||
await (await this.getManager()).setConfigOption(handle, input.key, input.value, state.mode);
|
||||
}
|
||||
@@ -960,7 +1103,7 @@ index a1f4a70a003792c6eacf68b6b038f37bfec1db53..896617544f6467c88da0dedf5c4867fc
|
||||
const { handle } = this.resolveManagerHandle(input.handle);
|
||||
await (await this.getManager()).cancel(handle);
|
||||
diff --git a/dist/session-options-DwRDODlr.d.ts b/dist/session-options-DwRDODlr.d.ts
|
||||
index c3da1645235bbea22de3f8484149051cd7dca56b..3c886f8e52147adc8e45effa2a075a2df63b6454 100644
|
||||
index c3da16452..40ae05653 100644
|
||||
--- a/dist/session-options-DwRDODlr.d.ts
|
||||
+++ b/dist/session-options-DwRDODlr.d.ts
|
||||
@@ -1,4 +1,5 @@
|
||||
@@ -1025,7 +1168,7 @@ index c3da1645235bbea22de3f8484149051cd7dca56b..3c886f8e52147adc8e45effa2a075a2d
|
||||
}) => Promise<AcpPermissionDecision | undefined>;
|
||||
};
|
||||
declare const SESSION_RECORD_SCHEMA: "acpx.session.v1";
|
||||
@@ -263,12 +299,15 @@ type SessionRecord = {
|
||||
@@ -263,12 +299,16 @@ type SessionRecord = {
|
||||
lastAgentDisconnectReason?: string;
|
||||
protocolVersion?: number;
|
||||
agentCapabilities?: AgentCapabilities;
|
||||
@@ -1039,6 +1182,7 @@ index c3da1645235bbea22de3f8484149051cd7dca56b..3c886f8e52147adc8e45effa2a075a2d
|
||||
+ request_token_usage: Record<string, SessionTokenUsage & {
|
||||
+ paperclip_pi?: { provenance: "assistant_message_receipts" | "assistant_message_and_compaction_receipts"; cost_usd?: number };
|
||||
+ }>;
|
||||
+ cursor_prompt_usage?: { request_id: string; prompt_message_id: string; receipt: Record<string, unknown> };
|
||||
acpx?: SessionAcpxState;
|
||||
importedFrom?: SessionImportedFrom;
|
||||
};
|
||||
@@ -94,7 +94,7 @@ describe("accepted Cursor plan passive-wait authority", () => {
|
||||
const facts = fixture();
|
||||
expect(nativeCursorPlanWaitFromFacts(facts)).not.toBeNull();
|
||||
const current = resolveQualifiedAcpxProfile("cursor", "gpt-5.6-luna[context=272k,reasoning=medium,fast=false]");
|
||||
const resolver = vi.spyOn(runner, "resolveQualifiedAcpxProfile").mockReturnValue({ ...current, agentProfileVersion: 8, commandDigest: `sha256:${"b".repeat(64)}` });
|
||||
const resolver = vi.spyOn(runner, "resolveQualifiedAcpxProfile").mockReturnValue({ ...current, agentProfileVersion: current.agentProfileVersion + 1, commandDigest: `sha256:${"b".repeat(64)}` });
|
||||
try { expect(nativeCursorPlanWaitFromFacts(facts)).toBeNull(); }
|
||||
finally { resolver.mockRestore(); }
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user