mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-30 13:20:54 +01:00
Preserve ACP usage receipts on terminal provider failure
Record terminal usage before classifying or throwing a provider-reported failure. Failed Pi turns retain their exact request token counts and source-proven USD receipt, including across canonical disk serialization, while the failure remains terminal. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
84955c9321
commit
503c08a62c
2 files changed
+38
-3
No files matched your search
@@ -40,6 +40,39 @@ test("missing cost stays missing and untrusted provenance or invalid costs never
|
||||
}
|
||||
});
|
||||
|
||||
test("terminal failures preserve billable Pi receipts for the failing request before reporting failure", async () => {
|
||||
for (const provenance of ["assistant_message_receipts", "assistant_message_and_compaction_receipts"]) {
|
||||
const { conversation, promptMessageId: previousId } = await recordReceipt({ provenance, costUsd: 0.001 });
|
||||
const promptMessageId = recordSubmission(conversation, "Fail after paid work");
|
||||
let failureReceipt;
|
||||
await assert.rejects(runPromptTurn({
|
||||
client: { prompt: async () => ({
|
||||
stopReason: "end_turn",
|
||||
usage: { inputTokens: 21, outputTokens: 8, _meta: { paperclipPi: { provenance, costUsd: 0.003 }, secret: "DROP_ME" } },
|
||||
_meta: { jetbrains: { air: { version: 1, sessionFailure: { severity: "error", category: "limit" } } } },
|
||||
}) },
|
||||
sessionId: "session-1", prompt: "Fail after paid work", conversation, promptMessageId,
|
||||
onTerminalSessionFailure() { failureReceipt = structuredClone(conversation.request_token_usage[promptMessageId]); },
|
||||
}), /ACP agent reported a terminal limit failure/);
|
||||
assert.equal(failureReceipt?.input_tokens, 21);
|
||||
assert.equal(failureReceipt?.output_tokens, 8);
|
||||
assert.deepEqual(failureReceipt?.paperclip_pi, { provenance, cost_usd: 0.003 });
|
||||
assert.equal(conversation.request_token_usage[previousId].paperclip_pi.cost_usd, 0.001);
|
||||
const disk = serialize({
|
||||
schema: "acpx.session.v1", acpxRecordId: "record-1", acpSessionId: "session-1", agentCommand: "verified-pi",
|
||||
cwd: "/workspace", createdAt: "2026-09-28T00:00:00Z", lastUsedAt: "2026-09-28T00:00:00Z", lastSeq: 0,
|
||||
...conversation,
|
||||
});
|
||||
assertKeyPolicy(disk);
|
||||
const restored = parse(JSON.parse(JSON.stringify(disk)));
|
||||
assert.ok(restored);
|
||||
assert.deepEqual(restored.request_token_usage[promptMessageId].paperclip_pi, { provenance, cost_usd: 0.003 });
|
||||
assert.equal(restored.request_token_usage[promptMessageId].input_tokens, 21);
|
||||
assert.equal(restored.request_token_usage[promptMessageId].output_tokens, 8);
|
||||
assert.equal(JSON.stringify(restored).includes("DROP_ME"), false);
|
||||
}
|
||||
});
|
||||
|
||||
for (const provenance of ["assistant_message_receipts", "assistant_message_and_compaction_receipts"]) test(`${provenance} survives canonical disk serialization/reload with a closed metadata shape`, async () => {
|
||||
const { conversation, promptMessageId } = await recordReceipt({ provenance, costUsd: 0.01 });
|
||||
const record = {
|
||||
|
||||
@@ -482,10 +482,12 @@ diff --git a/dist/live-checkpoint-BSIrfgVo.js b/dist/live-checkpoint-BSIrfgVo.js
|
||||
async function runPromptTurn(params) {
|
||||
try {
|
||||
const promptPromise = params.client.prompt(params.sessionId, params.prompt, params.onPromptRequestStarted, params.onElicitation);
|
||||
@@ -6444,6 +6633,19 @@
|
||||
@@ -6444,7 +6633,21 @@
|
||||
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);
|
||||
+ const terminalFailureCategory = typedTerminalSessionFailureCategory(response);
|
||||
+ if (terminalFailureCategory !== null) {
|
||||
+ // Inspect provider text only in memory, before emitting the safe category.
|
||||
@@ -499,10 +501,10 @@ diff --git a/dist/live-checkpoint-BSIrfgVo.js b/dist/live-checkpoint-BSIrfgVo.js
|
||||
+ } catch {}
|
||||
+ throw new Error(`ACP agent reported a terminal ${terminalFailureCategory} failure.`);
|
||||
+ }
|
||||
recordPromptResponseUsage(params.conversation, response.usage, params.promptMessageId);
|
||||
return {
|
||||
stopReason: response.stopReason,
|
||||
@@ -6518,4 +6720,4 @@
|
||||
source: "rpc"
|
||||
@@ -6518,4 +6721,4 @@
|
||||
//#endregion
|
||||
export { writeSessionRecord as $, PERMISSION_POLICY_ACTIONS as $t, REQUESTED_MODEL_UNSUPPORTED_ERROR_CODE as A, TimeoutError as At, getAcpxVersion as B, formatErrorMessage as Bt, mergeSessionOptions as C, PromptInputValidationError as Ct, applyLifecycleSnapshotToRecord as D, promptToDisplayText as Dt, applyConversation as E, parsePromptSource as Et, modelStateFromConfigOptions as F, normalizeAgentName$1 as Ft, findSession as G, toAcpErrorPayload as Gt, DEFAULT_HISTORY_LIMIT as H, normalizeOutputError as Ht, normalizeAgentCommandInput as I, resolveAgentArgv as It, listSessions as J, NON_INTERACTIVE_PERMISSION_POLICIES as Jt, findSessionByDirectoryWalk as K, AUTH_POLICIES as Kt, renderArgvIdentity as L, resolveAgentCommand as Lt, RequestedModelUnsupportedError as M, withTimeout as Mt, assertRequestedModelSupported as N, DEFAULT_AGENT_NAME as Nt, reconcileAgentSessionId as O, textPrompt as Ot, isRequestedModelUnsupportedError as P, listBuiltInAgents as Pt, resolveSessionRecord as Q, PERMISSION_MODES as Qt, runTimedExecFile as R, resolveCanonicalAgentName as Rt, advertisedModelState as S, parsePromptStopReason as St, sessionOptionsFromRecord as T, mergePromptSourceWithText as Tt, absolutePath as U, extractAcpError as Ut, permissionModeSatisfies as V, isRetryablePromptError as Vt, findGitRepositoryRoot as W, isAcpResourceNotFoundError as Wt, normalizeName as X, OUTPUT_ERROR_ORIGINS as Xt, listSessionsForAgent as Y, OUTPUT_ERROR_CODES as Yt, pruneSessions as Z, OUTPUT_FORMATS as Zt, createSessionConversation as _, sessionEventLockPath as _t, applyRequestedModelIfAdvertised as a, measurePerf as at, recordSessionUpdate as b, isAcpJsonRpcMessage as bt, setCurrentModelId as c, setPerfGauge as ct, setDesiredModelId as d, serializeSessionRecordForDisk as dt, SESSION_RECORD_SCHEMA as en, createAtomicWriteTempPath as et, syncAdvertisedModelState as f, normalizeRuntimeSessionId as ft, cloneSessionConversation as g, sessionEventActivePath as gt, cloneSessionAcpxState as h, sessionBaseDir$1 as ht, connectAndLoadSession as i, QueueProtocolError as in, incrementPerfCounter as it, REQUESTED_MODEL_UNSUPPORTED_REASONS as j, withInterrupt as jt, AcpClient as k, InterruptedError as kt, setDesiredConfigOption as l, startPerfTimer as lt, applyConfigOptionsToState as m, defaultSessionEventLog as mt, runPromptTurn as n, AgentSpawnError as nn, formatPerfMetric as nt, currentModelIdFromSetModelResponse as o, recordPerfDuration as ot, applyConfigOptionsToRecord as p, DEFAULT_EVENT_SEGMENT_MAX_BYTES as pt, isoNow$2 as q, EXIT_CODES as qt, withConnectedSession as r, QueueConnectionError as rn, getPerfMetricsSnapshot as rt, clearDesiredConfigOption as s, resetPerfMetrics as st, LiveSessionCheckpoint as t, AcpxOperationalError as tn, assertPersistedKeyPolicy as tt, setDesiredModeId as u, parseSessionRecord as ut, recordClientOperation as v, sessionEventSegmentPath as vt, persistSessionOptions as w, isPromptInput as wt, trimConversationForRuntime as x, parseJsonRpcErrorMessage as xt, recordPromptSubmission as y, extractSessionUpdateNotification as yt, splitCommandLine as z, exitCodeForOutputErrorCode as zt };
|
||||
|
||||
|
||||
Reference in new issue
Block a user