fix(runner): align direct eval provider setup with qualified runtimes (#12945)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Runner direct live evals test semantic tools against a mock control
plane.
> - The first complete AWS campaign exercised 358 cells.
> - It exposed setup differences from the working full-stack harness.
> - This pull request corrects those direct-harness differences.
> - It preserves production permission defaults and the full-stack
workflow.

## Linked Issues or Issue Description

**What happened?**

Native Codex cells could not find a global codex executable. ACPX denied
unattended tool requests and lost valid provider usage receipts.
AgentCore hit a 30-second facade timeout while its worker allows 120
seconds for delivery. Several models reported a native run result
without updating the separate mock task state.

**What did you expect?**

The direct harness should use the pinned executable, explicit test
permissions, and a timeout compatible with the provider delivery
contract. Its instructions should explain which operation changes mock
task state.

**Steps to reproduce**

Run the full Runner Direct Live Protocol Evals workflow. Baseline
campaign:
https://github.com/paperclipai/paperclip/actions/runs/34059009921.

**Paperclip version**

Master at b55ce03e86. Related public
change: #12932. No duplicate fix was found.

## What Changed

- Resolve native Codex from the pinned Codex ACP dependency, as the
full-stack launcher does.
- Select explicit unattended ACPX permissions only in the isolated
direct eval harness.
- Add a configurable bounded turn-admission wait. AgentCore direct evals
use up to 125 seconds, capped by their turn budget. Other callers retain
the current default waits.
- Explain mock task-state operations separately from native run-result
reporting. Keep all scoring assertions unchanged.
- Bind the direct turn's scope to the current request, rather than stale
shared fixture notes. A provider's end-of-turn result does not authorize
an unrequested mock task completion or extra completion comment.
- Normalize the qualified Claude/Codex usage semantics without
double-counting reasoning or inventing unknown billable categories.
Forward ACPX's persisted terminal prompt-response usage for the exact
current turn; reject stale or ambiguous receipts.
- Preserve the known numeric ACPX token-counter aliases through durable
redaction. Continue redacting strings and credential-shaped values. A
regression test reproduces the previously redacted usage before
normalization.
- Add regression tests and operating documentation.

## Verification

- Passed Runner TypeScript build.
- Passed 88 focused tests across the eval request contract, provider
setup, and Runner transport.
- Passed 93 focused ACPX adapter, sidecar lifecycle, and
usage-accounting tests, including persisted receipt identity and
missing-field regressions.
- Passed 97 additional runtime-host, in-process ACPX driver, OpenCode
proxy, and MCP bridge tests.
- Reproduced the ACPX counter-redaction failure, then passed 36 Rust
decoder/normalizer and durable-redaction tests after the fix.
Credential-shaped strings and objects remain redacted.
- Native Codex get-task-context passed a real provider probe with no
global CLI dependency.
- Local ACPX probes reached a platform startup rejection on macOS;
qualification therefore used the intended AWS Linux fleet. Complete
campaign 34060573948 reached 341/358 passing with zero infrastructure
failures (up from 250/358 and 100 infrastructure failures). ACPX Claude
reached 34/35, ACPX Codex 7/8, native Codex 34/35, and AgentCore 35/35.
Remaining failures were retained in the canonical report:
https://d1p6rlowie26tp.cloudfront.net/runner-protocol-evals/campaigns/gha-34060573948-1/.
- Final complete campaign 34062394019 exercises the current-request
scope clarification with all 358 cells; results pending.
- Passed git diff --check.
- Repo-wide typecheck, build, and tests are delegated to PR CI. They
were not repeated on this machine.

## Risks

- ACPX approval is scoped to the operator-requested isolated fixture
harness. Production defaults do not change.
- AgentCore admission can wait longer, but remains within the whole-turn
budget.
- The mock fixture has no production task lifecycle service. Explicit
task-state instructions describe that boundary; they do not relax
scoring.
- This change does not modify the browser full-stack workflow or its
test fixtures.
- ACPX receipt corrections apply to all native ACPX sessions. They rely
on the pinned qualified server contracts; unknown or ambiguous receipts
stay unknown, and a final accounting-read error never overwrites the
provider's terminal result.

## Model Used

- OpenAI GPT-6 (`gpt-6-astra`) through Codex, with reasoning, tool use,
and code execution. The session does not expose an exact context-window
limit.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-09-06 17:21:12 -05:00 committed by GitHub
parent d293dd3d14
commit 83987210d6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 423 additions and 4 deletions

View File

@ -62,6 +62,43 @@ same `paperclip.native-execution-input.v3` contract as production, including
the AgentCore HarnessSkill upload path, without borrowing any browser E2E
setup.
The direct CLI resolves native Codex from the pinned Codex ACP dependency, as
the browser E2E launcher does; no globally installed `codex` is required.
Its isolated fixture sessions explicitly use unattended ACPX `approve-all`
permissions, matching the full-stack ACPX test profiles. OpenCode retains its
existing semantic-only permission boundary.
The seeded mock control plane still enforces tool grants, company boundaries,
and governance checks. These test-only choices do not change production
permission defaults and are not a qualification of interactive approval UI.
AgentCore eval admission waits up to 125 seconds, capped by the configured
whole-turn timeout. Its network worker already allows 120 seconds for cold
invocation delivery; the generic 30-second facade wait must not cut that
delivery short during parallel fleet startup. Other providers retain their
existing admission timeout.
The runtime instructions distinguish the fixture's `finish_task`/`block_task`
state changes from native `paperclip_finish`/`paperclip_block` run-result
reporting. This layer has no production server to apply a reported run result
to a task. Its completion cases still require the actual semantic operation
and durable mock-state change; reporting success alone does not pass them.
The current turn request defines the requested work. Shared seed descriptions,
old eval notes, or past accepted interactions are background state, not a
replacement objective or proof that a newly requested action is already done.
Finishing the provider turn does not authorize an unrequested mock task-state
change or completion comment. These harness instructions keep single-operation
cases bounded while leaving their operation and state-effect assertions intact.
ACPX accounting uses the qualified server's billable token semantics: Claude
and Codex already include reasoning in output, and Codex has no cache-write
billing category. Other missing categories remain unknown. ACPX stores Codex's
terminal prompt-response usage without streaming it, so the sidecar forwards
the single newly persisted receipt bound to that exact turn before its terminal
event. It never substitutes a previous turn's receipt or context occupancy for
billable usage. These receipt corrections also apply to non-eval ACPX sessions.
The durable redactor preserves these known numeric token-counter fields;
string/object values under the same names remain redacted as sensitive data.
`all` means the maintained enabled campaign, not every matching file in the
roster directory. A missing campaign file fails closed. Within an enabled
lane, a missing remote profile or unavailable provider is retained as an

View File

@ -1135,6 +1135,12 @@ fn sensitive_key(key: &str, value: &Value) -> bool {
| "outputtokens"
| "cachereadtokens"
| "cachewritetokens"
// The qualified ACPX sidecar uses these numeric billing aliases.
// Strings under the same names remain credentials, not counters.
| "cachedreadtokens"
| "cachedwritetokens"
| "thoughttokens"
| "totaltokens"
| "pretokens"
| "posttokens"
) {
@ -1825,6 +1831,24 @@ mod tests {
sanitize_value(&json!({"inputTokens": "plain-provider-secret"})),
json!({"inputTokens": "[REDACTED]"})
);
for key in [
"cachedReadTokens",
"cachedWriteTokens",
"thoughtTokens",
"totalTokens",
] {
assert_eq!(sanitize_value(&json!({key: 12})), json!({key: 12}));
for value in [
json!("plain-provider-secret"),
json!({"value":"secret"}),
json!(["secret"]),
] {
assert_eq!(
sanitize_value(&json!({key: value})),
json!({key: "[REDACTED]"})
);
}
}
}
#[test]

View File

@ -201,6 +201,49 @@ fn maps_bounded_plan_and_completion_state() {
assert_eq!(events[0].priority, EventPriority::P1);
}
#[test]
fn preserves_usage_counters_across_sidecar_payload_redaction() {
let mut scope = AcpxEventScope::new("run-1").unwrap();
scope.bind_turn("turn-1").unwrap();
let decoded = decode_acpx_event(
&scope,
&AcpxSidecarEvent {
sequence: 1,
event_type: GeneratedAcpxSidecarEventType::RuntimeEvent,
run_id: Some("run-1".to_owned()),
turn_id: Some("turn-1".to_owned()),
payload: json!({
"type":"status", "tag":"usage_update",
"breakdown":{
"inputTokens":12, "outputTokens":7, "thoughtTokens":0,
"cachedReadTokens":2, "cachedWriteTokens":0, "totalTokens":21
},
"accessToken":"provider-secret",
"cost":{"amount":0.25,"currency":"USD"}
}),
},
)
.unwrap();
let AcpxEventPayload::Runtime {
kind,
tool_operation,
payload,
..
} = decoded
else {
panic!("runtime usage must decode as a runtime payload");
};
assert_eq!(payload["accessToken"], "[REDACTED]");
assert_eq!(payload["breakdown"]["totalTokens"], 21);
let events =
normalize_acpx_runtime_event(kind, &payload, tool_operation, "event-7", "turn-1", 3);
assert_eq!(events[0].payload["runDeltaAvailable"], true);
assert_eq!(events[0].payload["runDelta"]["inputTokens"], 12);
assert_eq!(events[0].payload["runDelta"]["outputTokens"], 7);
assert_eq!(events[0].payload["runDelta"]["cacheReadTokens"], 2);
assert_eq!(events[0].payload["runDelta"]["cacheWriteTokens"], 0);
}
#[test]
fn maps_usage_and_review_status_but_ignores_inventory_updates() {
let usage = normalize(

View File

@ -51,6 +51,10 @@ import {
type AcpxSidecarResponse,
} from "../drivers/acpx/sidecar-protocol.js";
import { safeAcpxLocations } from "./acpx-sidecar-locations.js";
import {
persistedAcpxTurnUsage,
qualifiedAcpxUsageBreakdown,
} from "../drivers/acpx/usage-accounting.js";
import { validatePrpStructuredRunResult } from "../protocol/replay-contract.js";
import type { RunnerToolCall } from "../drivers/runner-tool-bridge.js";
import {
@ -314,7 +318,9 @@ async function dispatch(
const currentTurnId = boundedIdentity(request.params.turnId, "turnId");
turnId = currentTurnId;
let runtimeTurn: AcpxRuntimeTurn;
let usageBefore: unknown;
try {
usageBefore = await readSidecarHostStatusWithin(activeHost);
runtimeTurn = activeHost.startTurn({
requestId: `${runId}:${currentTurnId}`,
text: boundedText(request.params.message, "message", 1024 * 1024),
@ -325,7 +331,7 @@ async function dispatch(
turnId = null;
throw error;
}
void pumpTurn(currentTurnId, runtimeTurn);
void pumpTurn(currentTurnId, runtimeTurn, activeHost, usageBefore);
return { turnId: currentTurnId };
}
if (request.command === "turn.cancel") {
@ -479,6 +485,8 @@ async function dispatch(
async function pumpTurn(
currentTurnId: string,
runtimeTurn: AcpxRuntimeTurn,
activeHost: AcpxRuntimeHost,
usageBefore: unknown,
): Promise<void> {
let terminal: Record<string, unknown>;
try {
@ -496,6 +504,24 @@ async function pumpTurn(
);
}
const result = await runtimeTurn.result;
try {
const usage = persistedAcpxTurnUsage(
usageBefore,
await readSidecarHostStatusWithin(activeHost),
runtimeTurn.requestId,
);
if (usage) {
emit(
"runtime.event",
sanitizeRuntimeEvent(usage as unknown as AcpRuntimeEvent),
currentTurnId,
);
}
} catch (error) {
// Missing usage must stay unknown, but an accounting read failure must
// not replace the provider's authoritative completed/cancelled result.
diagnostic("acpx_terminal_usage_unavailable", safeMessage(error));
}
terminal = boundedSidecarValue(result);
} catch (error) {
terminal = {
@ -868,7 +894,9 @@ function sanitizeRuntimeStatus(value: unknown): Record<string, unknown> {
function safeUsage(cost: unknown, breakdown: unknown): Record<string, unknown> {
const nativeCost = record(cost);
const nativeBreakdown = record(breakdown);
const nativeBreakdown = record(
qualifiedAcpxUsageBreakdown(initializedAgent, breakdown),
);
return {
cost:
cost === undefined || cost === null

View File

@ -0,0 +1,29 @@
import { existsSync } from "node:fs";
import { isAbsolute } from "node:path";
import { describe, expect, it } from "vitest";
import { evalProviderTransportOptions } from "./eval-provider-runtime.js";
describe("direct eval provider runtime", () => {
it("uses the installed pinned Codex dependency without requiring a global CLI", () => {
const options = evalProviderTransportOptions("codex");
expect(isAbsolute(options.codexCommand!)).toBe(true);
expect(existsSync(options.codexCommand!)).toBe(true);
expect(options.codexCommand).toMatch(/@openai\/codex\/bin\/codex\.js$/);
expect(options.acpxPermissionMode).toBeUndefined();
});
it("uses explicit unattended permissions only for isolated local eval providers", () => {
expect(evalProviderTransportOptions("acpx")).toEqual({
acpxPermissionMode: "approve-all",
acpxPermissionModePinned: true,
});
expect(evalProviderTransportOptions("opencode")).toEqual({});
expect(evalProviderTransportOptions("claude_managed")).toEqual({});
});
it("bounds AgentCore admission by both the turn budget and delivery timeout", () => {
expect(evalProviderTransportOptions("aws_agentcore", 180_000)).toEqual({ turnStartTimeoutMs: 125_000 });
expect(evalProviderTransportOptions("aws_agentcore", 10_000)).toEqual({ turnStartTimeoutMs: 10_000 });
});
});

View File

@ -0,0 +1,36 @@
import { readFileSync } from "node:fs";
import { createRequire } from "node:module";
import { dirname, resolve } from "node:path";
import type { CapabilityRunnerdCodexTransportOptions } from "../live/runnerd-codex-transport.js";
/** The direct fixture harness uses the same unattended policy as Runner E2E. */
export function evalProviderTransportOptions(
provider: "codex" | "opencode" | "acpx" | "claude_managed" | "aws_agentcore",
turnTimeoutMs = 120_000,
): Pick<CapabilityRunnerdCodexTransportOptions,
"codexCommand" | "acpxPermissionMode" | "acpxPermissionModePinned" | "turnStartTimeoutMs"
> {
if (provider === "aws_agentcore") {
// AgentCore's worker permits 120 seconds for invocation delivery. Cold
// starts under fleet load must not hit the facade's shorter 30s default.
return { turnStartTimeoutMs: Math.min(turnTimeoutMs, 125_000) };
}
if (provider === "acpx") {
// The operator requested this isolated mock-control-plane campaign. This
// is an eval policy, not a change to production permission defaults.
return { acpxPermissionMode: "approve-all", acpxPermissionModePinned: true };
}
if (provider !== "codex") return {};
const runnerRequire = createRequire(import.meta.url);
const codexRequire = createRequire(
runnerRequire.resolve("@agentclientprotocol/codex-acp/package.json"),
);
const manifestPath = codexRequire.resolve("@openai/codex/package.json");
const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as {
bin?: string | Record<string, string>;
};
const executable = typeof manifest.bin === "string" ? manifest.bin : manifest.bin?.codex;
if (!executable) throw new Error("Pinned Codex dependency does not expose its executable");
return { codexCommand: resolve(dirname(manifestPath), executable) };
}

View File

@ -78,6 +78,9 @@ describe("eval-session request contract", () => {
)).toContain("Paperclip direct live evaluation");
const systemInstructions = evalRuntimeSystemInstructions(context);
expect(systemInstructions).toContain("Paperclip direct live evaluation");
expect(systemInstructions).toContain("Task-state changes in this mock control plane use finish_task and block_task");
expect(systemInstructions).toContain("The current user request defines the work for this turn");
expect(systemInstructions).toContain("Do not finish or block the mock task unless the current request asks for that state change");
expect(systemInstructions).toContain(
`Read-only instruction sibling root: ${context.instructions.bundle.rootPath}`,
);

View File

@ -32,6 +32,7 @@ import {
type EvalSessionRequest,
type EvalSessionUsage,
} from "./eval-session-contract.js";
import { evalProviderTransportOptions } from "./eval-provider-runtime.js";
interface EvalSessionCliOptions {
requestPath: string;
@ -68,6 +69,9 @@ const EVAL_RUNTIME_INSTRUCTIONS = [
"",
"Use the provided Paperclip semantic tools to inspect and act on the assigned task.",
"Treat the seeded control-plane state as authoritative and keep every action within the requested scope.",
"The current user request defines the work for this turn. Seeded task descriptions, notes, and past interaction results are background context; they do not supersede that request or establish that a newly requested action has already been performed.",
"Task-state changes in this mock control plane use finish_task and block_task. Native paperclip_finish and paperclip_block report the provider run result but do not update the mock task. When asked to finish or block the assigned task, use its task-state semantic operation before reporting the run result.",
"Do not finish or block the mock task unless the current request asks for that state change. Ending the provider turn after another requested action does not authorize additional task-state changes or completion comments.",
"",
].join("\n");
@ -293,6 +297,7 @@ export async function runEvalSessionCli(
const service = options.serviceFactory?.(runnerdPath) ??
new CapabilityLiveSessionService({
transportOptions: {
...evalProviderTransportOptions(requestedProvider, request.limits.turnTimeoutMs),
runnerBinary: runnerdPath,
runtimeContext,
baseInstructions: evalRuntimeSystemInstructions(runtimeContext),

View File

@ -206,6 +206,9 @@ describe("Codex ACPX runtime adapter", () => {
acpxRecordId: "record-1",
acpSessionId: "backend-1",
agentSessionId: "agent-1",
lastRequestId: "run:turn-1",
request_token_usage: { prompt: { input_tokens: 12, output_tokens: 30 } },
cumulative_cost: { amount: 0.1, currency: "USD" },
acpx: {
current_model_id: "gpt-5.6-sol",
available_models: ["gpt-5.6-sol"],
@ -225,6 +228,9 @@ describe("Codex ACPX runtime adapter", () => {
acpxRecordId: "record-1",
backendSessionId: "backend-1",
agentSessionId: "agent-1",
lastRequestId: "run:turn-1",
requestTokenUsage: { prompt: { input_tokens: 12, output_tokens: 30 } },
usageCost: { amount: 0.1, currency: "USD" },
models: {
currentModelId: "gpt-5.6-sol",
availableModelIds: ["gpt-5.6-sol"],

View File

@ -1148,6 +1148,9 @@ async function persistedRuntimeStatus(
acpxRecordId: record.acpxRecordId,
backendSessionId: record.acpSessionId,
agentSessionId: persistedAgentSessionId,
lastRequestId: record.lastRequestId,
requestTokenUsage: structuredClone(record.request_token_usage ?? {}),
usageCost: structuredClone(record.cumulative_cost),
...(currentModelId === undefined && !availableModelIds?.length
? {}
: {

View File

@ -0,0 +1,132 @@
import { describe, expect, it } from "vitest";
import {
persistedAcpxTurnUsage,
qualifiedAcpxUsageBreakdown,
} from "./usage-accounting.js";
describe("qualified ACPX usage", () => {
it("accepts Claude's four-field aggregate without inventing extra reasoning", () => {
expect(
qualifiedAcpxUsageBreakdown("claude", {
inputTokens: 12,
outputTokens: 30,
cachedReadTokens: 40,
cachedWriteTokens: 50,
}),
).toEqual({
inputTokens: 12,
outputTokens: 30,
cachedReadTokens: 40,
cachedWriteTokens: 50,
thoughtTokens: 0,
});
});
it("does not count Codex reasoning twice and knows cache writes are inapplicable", () => {
expect(
qualifiedAcpxUsageBreakdown("codex", {
inputTokens: 12,
outputTokens: 30,
cachedReadTokens: 40,
thoughtTokens: 20,
}),
).toEqual({
inputTokens: 12,
outputTokens: 30,
cachedReadTokens: 40,
cachedWriteTokens: 0,
thoughtTokens: 0,
});
});
it("preserves unknown billable fields and explicit invalid values", () => {
expect(qualifiedAcpxUsageBreakdown("claude", { inputTokens: 12 })).toEqual({
inputTokens: 12,
thoughtTokens: 0,
});
expect(
qualifiedAcpxUsageBreakdown("codex", {
inputTokens: null,
cachedWriteTokens: null,
}),
).toEqual({ inputTokens: null, cachedWriteTokens: null, thoughtTokens: 0 });
expect(qualifiedAcpxUsageBreakdown("codex", undefined)).toBeUndefined();
expect(qualifiedAcpxUsageBreakdown("claude", null)).toBeNull();
});
it("does not infer accounting semantics for Pi or an uninitialized agent", () => {
const usage = { inputTokens: 12, outputTokens: 30, thoughtTokens: 20 };
expect(qualifiedAcpxUsageBreakdown("pi", usage)).toEqual(usage);
expect(qualifiedAcpxUsageBreakdown(null, usage)).toEqual(usage);
});
});
describe("persisted terminal ACPX usage", () => {
const before = { requestTokenUsage: { previous: { input_tokens: 999 } } };
const receipt = {
input_tokens: 10,
output_tokens: 20,
cache_read_input_tokens: 30,
thought_tokens: 5,
total_tokens: 60,
};
const after = {
lastRequestId: "run:turn-2",
usageCost: { amount: 0.5, currency: "USD" },
requestTokenUsage: { ...before.requestTokenUsage, current: receipt },
};
it("recovers only the newly persisted prompt receipt, including prompt-response-only usage", () => {
expect(persistedAcpxTurnUsage(before, after, "run:turn-2")).toEqual({
type: "status",
tag: "usage_update",
text: "terminal prompt usage",
cost: { amount: 0.5, currency: "USD" },
breakdown: {
inputTokens: 10,
outputTokens: 20,
cachedReadTokens: 30,
cachedWriteTokens: undefined,
thoughtTokens: 5,
totalTokens: 60,
},
});
});
it("rejects old, ambiguous, or differently bound receipts", () => {
expect(persistedAcpxTurnUsage(before, after, "run:other")).toBeNull();
expect(persistedAcpxTurnUsage(after, after, "run:turn-2")).toBeNull();
expect(
persistedAcpxTurnUsage(
before,
{
...after,
requestTokenUsage: { current: receipt, extra: receipt },
},
"run:turn-2",
),
).toBeNull();
expect(
persistedAcpxTurnUsage(undefined, undefined, "run:turn-2"),
).toBeNull();
});
it("works for the first turn without prior usage and retains missing fields", () => {
expect(
persistedAcpxTurnUsage(
{},
{
lastRequestId: "first",
requestTokenUsage: { current: { input_tokens: 10 } },
},
"first",
),
).toMatchObject({
breakdown: {
inputTokens: 10,
outputTokens: undefined,
cachedReadTokens: undefined,
},
});
});
});

View File

@ -0,0 +1,64 @@
import type { QualifiedAcpxAgent } from "./qualified-profiles.js";
function record(value: unknown): Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
/** Normalize only semantics established by the pinned, qualified ACP servers. */
export function qualifiedAcpxUsageBreakdown(
agent: QualifiedAcpxAgent | null,
value: unknown,
): unknown {
if (value === null || value === undefined) return value;
const breakdown = record(value);
if (agent !== "claude" && agent !== "codex") return breakdown;
// Claude SDK aggregate output and Codex ACP toPromptUsage.outputTokens both
// INCLUDE reasoning. PRP folds thought into output, so its additive component
// is zero here, not the provider's diagnostic reasoning-token subset.
return {
...breakdown,
thoughtTokens: 0,
// Codex ACP 1.6.2 has no cache-write billing category. Do not apply this
// provider-specific zero to Claude/Pi or to an explicitly invalid value.
...(agent === "codex" && breakdown.cachedWriteTokens === undefined
? { cachedWriteTokens: 0 }
: {}),
};
}
/**
* ACPX persists terminal prompt-response usage but does not stream it. Recover
* exactly the new prompt receipt belonging to this turn, never a prior receipt
* or the misleadingly named cumulative_token_usage (which is last-write-wins).
*/
export function persistedAcpxTurnUsage(
before: unknown,
after: unknown,
requestId: string,
): Record<string, unknown> | null {
const current = record(after);
if (current.lastRequestId !== requestId) return null;
const previousReceipts = record(record(before).requestTokenUsage);
const receipts = record(current.requestTokenUsage);
const added = Object.keys(receipts).filter(
(key) => !Object.hasOwn(previousReceipts, key),
);
if (added.length !== 1) return null;
const usage = record(receipts[added[0]!]);
return {
type: "status",
tag: "usage_update",
text: "terminal prompt usage",
cost: current.usageCost,
breakdown: {
inputTokens: usage.input_tokens,
outputTokens: usage.output_tokens,
cachedReadTokens: usage.cache_read_input_tokens,
cachedWriteTokens: usage.cache_creation_input_tokens,
thoughtTokens: usage.thought_tokens,
totalTokens: usage.total_tokens,
},
};
}

View File

@ -940,6 +940,8 @@ export interface CapabilityRunnerdCodexTransportOptions {
/** Provider system instructions supplied by a native execution caller. */
baseInstructions?: string;
closeGraceMs?: number;
/** Bounded provider turn-admission wait; defaults to 30 seconds. */
turnStartTimeoutMs?: number;
onDiagnostic?: (message: string) => void;
onEvidence?: (evidence: Readonly<CapabilityRunnerdProcessEvidence>) => void;
stateDirectory?: string;
@ -3737,6 +3739,11 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
async #startTurn(
params: Record<string, unknown>,
): Promise<Record<string, unknown>> {
const turnStartTimeoutMs = this.options.turnStartTimeoutMs ?? 30_000;
if (!Number.isSafeInteger(turnStartTimeoutMs) || turnStartTimeoutMs <= 0) {
throw new Error("turnStartTimeoutMs must be a positive safe integer");
}
const commandDeadline = Date.now() + turnStartTimeoutMs;
const input = Array.isArray(params.input) ? params.input.map(record) : [];
const message = input
.map((item) => (typeof item.text === "string" ? item.text : ""))
@ -3755,7 +3762,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
const startResult = await this.#commandResult("turn.start", {
text: message,
turnId: pendingTurnId,
});
}, commandDeadline);
const expectedProviderTurnId =
typeof startResult.providerTurnId === "string" &&
startResult.providerTurnId.length > 0
@ -3788,7 +3795,9 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
// command's durable result so a delayed prior-turn event cannot satisfy
// the new response fence. ACPX echoes the requested identity, while
// Codex and OpenCode return their provider-assigned identity.
const deadline = Date.now() + 30_000;
const deadline = this.options.turnStartTimeoutMs === undefined
? Date.now() + 30_000
: commandDeadline;
const providerTurnStarted = () =>
turnStartResponseReady({
responseEpoch,