mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 19:35:13 +01:00
## Thinking Path > - Paperclip's Product E2E journeys verify user outcomes in the real app and GitHub. > - The review journey must show the score and failed check for its exact reviewed commit, then the visible task status before and after reload. > - GitHub includes a hidden “Re-run failed checks” menu item before the visible check result. > - The journey selected that hidden item and failed even after native work and publication completed. A subsequent attempt exposed the same first-text-match problem with Done in the task UI. > - This change reads the selected check's status header and binds its URL, score, and commit. > - Browser fixtures retain this failure and reject plausible wrong projections. ## Linked Issues or Issue Description Refs: #17457, #17513, #17551. **What happened?** The bot completed its native run and task, then published its summary, inline precision finding, and failed 2/5 check. The browser assertion selected a hidden “Re-run failed checks” menu item before the visible result, so the journey still failed. After that was corrected, the next live attempt passed the check projection but selected hidden Done text before the visible task status. **Expected behavior** Verify the actual selected GitHub check. A hidden menu item or a word in the model's narrative cannot prove its conclusion. **Steps to reproduce** 1. Render a hidden “Re-run failed checks” item before a visible failed check. 2. Use the old first text match for “failed”. 3. Observe the false visibility failure. **Paperclip version or commit** The base includes the journey changes from #17457 and #17513. ## What Changed - Validate the check URL against the configured repository and select its exact check-run ID. - Require the expected score and full reviewed commit inside that check. - Read failure status only from that check's header. - Give each projection step its own phase so failures identify the correct boundary. - Stage the verified persisted screenshot as the standard report final-state artifact, rejecting a passing report if it is absent. - Preserve measured billing summaries during regeneration when raw run usage is absent; newer raw usage takes precedence. - Extend the catalog-wide history fixture to include the new journey evidence contract. - Read task completion from the visible Status property picker before and after reload, with separate phase labels. - Add eighteen isolated browser fixtures to the existing browser-support suite and document both boundaries. ## Verification - Reproduced the old selector failure in the live GitHub page and a credential-free browser fixture before fixing it. - All 60 browser-support cases pass, including eighteen focused projection cases. They reject the wrong check status, score, commit, page, and repository reference, plus hidden or unrelated Done text. - All 163 focused billing, history, accounting, report, and journey tests pass, as does runner E2E typechecking. - A regression reproduced discarded saved token totals. Retained totals now survive per-cell and campaign aggregation while missing dollar prices stay unknown; fresh raw usage still overrides stale summaries. - Diff and credential-value checks pass. - The earlier failed attempts remain failed. A fresh complete QA journey passed all 18 checkpoints and cleanup with a successful native run, a below-threshold review and inline precision finding, the correctly failed check, visible Done status, and reload persistence. The later reference-validation commit changes only browser fixtures; the live driver bytes are unchanged. - A report regression reproduced the missing standard final-state artifact. The packager now aliases only the hash-verified persisted screenshot for passing results; missing final-state proof is rejected. Presentation regeneration preserves the original live grade and dispatches no provider work. ## Risks This depends on GitHub's check-run header structure. A markup change fails the journey instead of accepting narrative text as a result. The product runtime and behavioral oracle stay unchanged. Usage-price coverage remains a separate evidence dimension. A saved billing summary is reused only when raw usage is absent, preserving the recorded coverage and unknown prices. The separate advisory Paperclip Review remains queued after its prior assigned attempt timed out. Its normal Retry control rejected the obsolete failed chat request as unsafe to retry. Required repository gates `ci / verify` and `ci / e2e` pass, all 55 completed CI checks are green, and final-head Greptile is 5/5 with no unresolved findings. The advisory queue is not represented as a completed assessment. ## Model Used OpenAI Codex, based on GPT-6. The exact runtime model ID and context-window size are not exposed in this session. Used for code, browser fixtures, and live evidence analysis. ## 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 <paperclip@paperclip.ing> Co-authored-by: Paperclip <bot@paperclip.ing>
268 lines
12 KiB
TypeScript
268 lines
12 KiB
TypeScript
import { reserveCompletionQuality } from "./completion-quality.js";
|
|
import { describe, expect, it } from "vitest";
|
|
import {
|
|
aggregateCampaignBilling,
|
|
buildRuntimeUsage,
|
|
summarizeExecutionBilling,
|
|
} from "./billing.js";
|
|
import type { RunnerE2EResult } from "./types.js";
|
|
import { durationLabel, llmCostLabel } from "./report-format.js";
|
|
import { validateRetainedRunnerResult } from "./result-validation.js";
|
|
|
|
function result(overrides: Partial<RunnerE2EResult> = {}): RunnerE2EResult {
|
|
return {
|
|
schema: "paperclip.runner-e2e.result/v1",
|
|
executionId: "legacy-claude.local.message-marker",
|
|
attempt: 1,
|
|
status: "passed",
|
|
profileId: "legacy-claude",
|
|
environmentId: "local",
|
|
caseId: "message-marker",
|
|
provider: "anthropic",
|
|
model: "fixture-model",
|
|
runtimeMode: "legacy",
|
|
runIds: ["run-1"],
|
|
startedAt: "2026-08-27T00:00:00.000Z",
|
|
finishedAt: "2026-08-27T00:00:02.000Z",
|
|
durationMs: 2_000,
|
|
cleanup: "passed",
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe("runner E2E billing summaries", () => {
|
|
it("preserves captured token totals when a retained result has only a billing summary", () => {
|
|
const billing = summarizeExecutionBilling(result({ usage: {
|
|
inputTokens: 59_989, outputTokens: 4_974, cachedInputTokens: 488_798, costStatus: "unpriced",
|
|
} }));
|
|
const retained = result({ billing });
|
|
expect(summarizeExecutionBilling(retained)).toEqual(billing);
|
|
expect(aggregateCampaignBilling([retained])).toMatchObject({ llm: {
|
|
inputTokens: 59_989, outputTokens: 4_974, cachedInputTokens: 488_798,
|
|
runsWithTokenUsage: 1, runsWithReportedCost: 0,
|
|
}, testsWithCompleteBilling: 0 });
|
|
});
|
|
|
|
it("uses newly captured usage instead of a stale retained billing summary", () => {
|
|
const billing = summarizeExecutionBilling(result({ usage: { inputTokens: 100, outputTokens: 10, costStatus: "unpriced" } }));
|
|
const fresh = summarizeExecutionBilling(result({ billing, usage: { inputTokens: 200, outputTokens: 20, costStatus: "unpriced" } }));
|
|
expect(fresh.llm).toMatchObject({ inputTokens: 200, outputTokens: 20, runsWithReportedCost: 0 });
|
|
expect(fresh.complete).toBe(false);
|
|
});
|
|
|
|
it("keeps missing agent time separate from attempt duration and measured zero", () => {
|
|
const interrupted = result({ status: "failed", durationMs: 108_850 });
|
|
expect(summarizeExecutionBilling(interrupted).runtime.agentRunDurationMs).toBeNull();
|
|
const noCapturedRuns = result({ ...interrupted, runIds: [],
|
|
runtimeUsage: buildRuntimeUsage({ environmentId: "local", runs: [] }) });
|
|
expect(noCapturedRuns.runtimeUsage?.agentRunDurationMs).toBeNull();
|
|
expect(() => validateRetainedRunnerResult(noCapturedRuns)).not.toThrow();
|
|
const measured = result({ runtimeUsage: buildRuntimeUsage({ environmentId: "local", runs: [
|
|
{ startedAt: "2026-10-21T00:00:00Z", finishedAt: "2026-10-21T00:00:04Z" },
|
|
{ startedAt: "2026-10-21T00:00:01Z", finishedAt: "2026-10-21T00:00:05Z" },
|
|
] }) });
|
|
expect(aggregateCampaignBilling([measured]).agentRunDurationMs).toBe(8000);
|
|
expect(aggregateCampaignBilling([measured, interrupted]).agentRunDurationMs).toBeNull();
|
|
expect(aggregateCampaignBilling([measured, noCapturedRuns]).agentRunDurationMs).toBeNull();
|
|
expect(durationLabel(aggregateCampaignBilling([measured, interrupted]).agentRunDurationMs)).toBe("Unknown");
|
|
const zero = buildRuntimeUsage({ environmentId: "local", runs: [
|
|
{ startedAt: "2026-10-21T00:00:00Z", finishedAt: "2026-10-21T00:00:00Z" },
|
|
] });
|
|
expect(zero.agentRunDurationMs).toBe(0);
|
|
expect(durationLabel(zero.agentRunDurationMs)).toBe("0ms");
|
|
});
|
|
|
|
it.each([undefined, "not-a-date", "2026-10-20T23:59:59Z"])("retains unknown time for a missing or invalid end timestamp: %s", finishedAt => {
|
|
const runtimeUsage = buildRuntimeUsage({ environmentId: "daytona", runs: [
|
|
{ startedAt: "2026-10-21T00:00:00Z", finishedAt: "2026-10-21T00:00:04Z" },
|
|
{ startedAt: "2026-10-21T00:00:00Z", finishedAt },
|
|
], leases: [{ acquiredAt: "2026-10-21T00:00:00Z", releasedAt: finishedAt,
|
|
metadata: { cpu: 4, memory: 4, disk: 10 } }] });
|
|
expect(runtimeUsage).toMatchObject({ agentRunDurationMs: null, leaseDurationMs: null, costStatus: "unavailable" });
|
|
expect(runtimeUsage.estimatedListCostUsd).toBeUndefined();
|
|
const retained = result({ environmentId: "daytona", runtimeUsage });
|
|
expect(() => validateRetainedRunnerResult(retained)).not.toThrow();
|
|
expect(aggregateCampaignBilling([retained, result()]).leaseDurationMs).toBeNull();
|
|
expect(aggregateCampaignBilling([result()]).leaseDurationMs).toBe(0);
|
|
});
|
|
|
|
it("reads precise provider amounts without relabeling estimates or unpriced usage", () => {
|
|
const precise = summarizeExecutionBilling(result({ usage: {
|
|
inputTokens: 0, outputTokens: 0, cachedInputTokens: 5,
|
|
costUsdExact: "0.000000123", costStatus: "reported", accountingReceiptReady: true,
|
|
} }));
|
|
expect(precise.llm).toMatchObject({ reportedCostUsd: 0.000000123, runsWithReportedCost: 1, runsWithTokenUsage: 1 });
|
|
const estimated = summarizeExecutionBilling(result({ usage: {
|
|
inputTokens: 100, outputTokens: 10, costUsdExact: "0.012345678", costStatus: "estimated",
|
|
} }));
|
|
expect(estimated.llm).toMatchObject({ reportedCostUsd: 0, estimatedCostUsd: 0.012345678,
|
|
runsWithReportedCost: 0, runsWithEstimatedCost: 1, costStatus: "estimated" });
|
|
expect(estimated.observedAndEstimatedCostUsd).toBe(0.012345678);
|
|
const unpriced = summarizeExecutionBilling(result({ usage: {
|
|
inputTokens: 100, outputTokens: 10, costUsd: 0, costStatus: "unpriced",
|
|
} }));
|
|
expect(unpriced.llm).toMatchObject({ runsWithReportedCost: 0, costStatus: "unpriced" });
|
|
});
|
|
|
|
it("distinguishes explicit zero usage from a partial or missing receipt", () => {
|
|
const zero = summarizeExecutionBilling(result({ usage: { inputTokens: 0, outputTokens: 0, costUsdExact: "0", accountingReceiptReady: true } }));
|
|
expect(zero.complete).toBe(true);
|
|
const partial = summarizeExecutionBilling(result({ usage: { inputTokens: 100, outputTokens: 10, costUsd: 0.01, accountingReceiptReady: false } }));
|
|
expect(partial.reportedCostUsd).toBe(0.01);
|
|
expect(partial.complete).toBe(false);
|
|
expect(summarizeExecutionBilling(result({ usage: null })).complete).toBe(false);
|
|
});
|
|
it("retains a known OpenCode retry subtotal while the missing turn price stays incomplete", () => {
|
|
const partial = result({ usage: { inputTokens: 30, outputTokens: 6, costUsd: 0.5, costStatus: "unpriced", accountingReceiptReady: true } });
|
|
const billing = summarizeExecutionBilling(partial);
|
|
expect(billing.llm).toMatchObject({ reportedCostUsd: 0.5, runsWithReportedCost: 1, runsWithPartialReportedCost: 1, costStatus: "partial" });
|
|
expect(billing.observedAndEstimatedCostUsd).toBe(0.5);
|
|
expect(billing.complete).toBe(false);
|
|
expect(llmCostLabel(billing.reportedCostUsd, billing.llm)).toBe("$0.5000 (partial amount)");
|
|
const campaign = aggregateCampaignBilling([partial, result({ executionId: "other", usage: { inputTokens: 1, outputTokens: 1, costUsd: 0.2 } })]);
|
|
expect(campaign).toMatchObject({ reportedLlmCostUsd: 0.7, observedAndEstimatedCostUsd: 0.7, testsWithCompleteBilling: 1,
|
|
llm: { runsWithReportedCost: 2, runsWithPartialReportedCost: 1, costStatus: "partial" } });
|
|
expect(llmCostLabel(campaign.reportedLlmCostUsd, campaign.llm)).toBe("$0.7000 (partial amount)");
|
|
});
|
|
it("counts completion judge reservations and preserves unknown spend after interruption", () => {
|
|
const pending = { ...reserveCompletionQuality({ sourceId: "chat", marker: "x", worker: { id: "task", status: "done", completedAt: "2026-09-01" }, documents: [{ id: "doc", issueId: "task", body: "result" }], comments: [{ id: "reply", issueId: "chat", authorAgentId: "agent", createdAt: "2026-09-02", body: "ready" }], runs: [] }, 0.5), name: "completion", expectedPass: true };
|
|
const unknown = summarizeExecutionBilling(result({ completionQuality: [pending] }));
|
|
expect(unknown.judge?.reservedCostUsd).toBe(pending.reservedCostUsd);
|
|
expect(unknown.observedAndEstimatedCostUsd).toBeNull();
|
|
expect(unknown.complete).toBe(false);
|
|
const known = summarizeExecutionBilling(result({ completionQuality: [{ ...pending, status: "completed", inputTokens: 100, outputTokens: 50, estimatedCostUsd: 0.001 }] }));
|
|
expect(known.judge).toMatchObject({ inputTokens: 100, outputTokens: 50, estimatedCostUsd: 0.001 });
|
|
});
|
|
it("summarizes provider-reported token usage and cost", () => {
|
|
const billing = summarizeExecutionBilling(
|
|
result({
|
|
usage: {
|
|
inputTokens: 12_000,
|
|
outputTokens: 420,
|
|
cachedInputTokens: 5_000,
|
|
cacheAdjustedCostUsd: 0.08125,
|
|
costStatus: "reported",
|
|
},
|
|
}),
|
|
);
|
|
expect(billing.llm).toMatchObject({
|
|
runCount: 1,
|
|
runsWithTokenUsage: 1,
|
|
runsWithReportedCost: 1,
|
|
inputTokens: 12_000,
|
|
outputTokens: 420,
|
|
cachedInputTokens: 5_000,
|
|
totalTokens: 17_420,
|
|
reportedCostUsd: 0.08125,
|
|
costStatus: "reported",
|
|
});
|
|
expect(billing.runtime.costStatus).toBe("not_metered");
|
|
expect(billing.complete).toBe(true);
|
|
});
|
|
|
|
it("labels missing and unpriced runs instead of treating them as free", () => {
|
|
const billing = summarizeExecutionBilling(
|
|
result({
|
|
runIds: ["run-1", "run-2", "run-3"],
|
|
usage: {
|
|
runs: [
|
|
{
|
|
runId: "run-1",
|
|
usage: {
|
|
inputTokens: 1_000,
|
|
outputTokens: 100,
|
|
costUsd: 0.01,
|
|
},
|
|
},
|
|
{
|
|
runId: "run-2",
|
|
usage: {
|
|
inputTokens: 2_000,
|
|
outputTokens: 200,
|
|
costStatus: "unpriced",
|
|
},
|
|
},
|
|
{ runId: "run-3", usage: null },
|
|
],
|
|
},
|
|
}),
|
|
);
|
|
expect(billing.llm).toMatchObject({
|
|
runCount: 3,
|
|
runsWithTokenUsage: 2,
|
|
runsWithReportedCost: 1,
|
|
reportedCostUsd: 0.01,
|
|
costStatus: "partial",
|
|
});
|
|
expect(billing.complete).toBe(false);
|
|
});
|
|
|
|
it("estimates Daytona list price from captured lease seconds and resources", () => {
|
|
const runtime = buildRuntimeUsage({
|
|
environmentId: "daytona",
|
|
runs: [
|
|
{
|
|
startedAt: "2026-08-27T00:00:05.000Z",
|
|
finishedAt: "2026-08-27T00:30:05.000Z",
|
|
},
|
|
],
|
|
leases: [
|
|
{
|
|
acquiredAt: "2026-08-27T00:00:00.000Z",
|
|
releasedAt: "2026-08-27T01:00:00.000Z",
|
|
metadata: { cpu: 4, memory: 4, disk: 10 },
|
|
},
|
|
],
|
|
});
|
|
expect(runtime).toMatchObject({
|
|
provider: "daytona",
|
|
agentRunDurationMs: 1_800_000,
|
|
leaseDurationMs: 3_600_000,
|
|
leaseCount: 1,
|
|
cpuCores: 4,
|
|
memoryGiB: 4,
|
|
diskGiB: 10,
|
|
costStatus: "estimated",
|
|
estimatedListCostUsd: 0.26748,
|
|
});
|
|
});
|
|
|
|
it("aggregates tokens, reported spend, runtime estimates, and coverage", () => {
|
|
const local = result({
|
|
usage: { inputTokens: 100, outputTokens: 20, costUsd: 0.004 },
|
|
});
|
|
const daytona = result({
|
|
executionId: "runner-acpx-claude.daytona.message-marker",
|
|
profileId: "runner-acpx-claude",
|
|
environmentId: "daytona",
|
|
runtimeMode: "native",
|
|
usage: { inputTokens: 200, outputTokens: 30 },
|
|
runtimeUsage: {
|
|
provider: "daytona",
|
|
agentRunDurationMs: 30_000,
|
|
leaseDurationMs: 40_000,
|
|
leaseCount: 1,
|
|
cpuCores: 4,
|
|
memoryGiB: 4,
|
|
diskGiB: 10,
|
|
estimatedListCostUsd: 0.002972,
|
|
costStatus: "estimated",
|
|
costSource: "daytona_public_list_price",
|
|
},
|
|
});
|
|
expect(aggregateCampaignBilling([local, daytona])).toMatchObject({
|
|
testCount: 2,
|
|
reportedLlmCostUsd: 0.004,
|
|
estimatedRuntimeCostUsd: 0.002972,
|
|
llm: {
|
|
runCount: 2,
|
|
runsWithTokenUsage: 2,
|
|
runsWithReportedCost: 1,
|
|
inputTokens: 300,
|
|
outputTokens: 50,
|
|
costStatus: "partial",
|
|
},
|
|
});
|
|
});
|
|
});
|