Files
PaperClipAI/tests/runner-e2e/product-journeys/agent-evidence.test.ts
T
DottaandClaude Opus 5.5 b4cc2d30d2 fix(e2e): grade only started runs and bound the clarification readback (#19335)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Product E2E journeys grade agent work in the QA staging smoke plan
> - On the current QA deployment, two journeys failed after the product
had already done the requested work
> - protected-actions counted a wake that the control plane retired
before it started as a bound run, and failed its runtime binding check
> - question-answer-resume waited for its saved document in a view that
no longer exists. The wait lasted 27 minutes, until the case deadline,
and starved the run queue
> - This pull request grades only runs that started provider work, and
reads the document where the UI now shows it, with a bounded wait
> - The benefit is that both journeys measure the product, and a UI
readback failure costs about two minutes instead of a whole worker

## Linked Issues or Issue Description

**What happened?**
- protected-actions: the task succeeded with two runs. A third
automation wake for the same agent and task was cancelled before it
started, with the reason "Agent cannot start work while completed runs
await accounting". Its `startedAt` was null, its
`resultJson.cancellation` had `source: "control_plane"` and `expected:
true`, and its `executionRecovery.providerWorkStarted` was false. That
run has no runtime binding, so `validateAgentTaskState` failed with
`agent_run_binding_mismatch`.
- question-answer-resume: the agent saved the requested document and the
task was done by 19:32. The journey then navigated to `#document-<key>`
and waited until 19:59 for an element that never appeared. With the task
chat shell and Artifacts V1, documents render in the properties pane's
Artifacts tab. The run reported `journey_operation_failed` and
`evidence_collection_timeout`, and the 60-minute job ended before four
other cases could run.

**Expected behavior**
- A wake retired before admission is kept as evidence but is not graded
as provider work.
- The saved document is read in its current view, and a UI readback
failure ends quickly.

**Steps to reproduce**
QA staging smoke launch 3074 on app cb8df178c8. The stack-side run
records are described above.

**Paperclip version or commit**
master at 03eb49053f.

## What Changed

- `agent-flows.ts`: add `retiredBeforeProviderStart(run)`. The run must
have status `cancelled` and `startedAt === null`, a control-plane
cancellation with `expected: true`, and
`executionRecovery.providerWorkStarted === false`.
`validateAgentTaskState` still checks such a run's company, agent and
task binding and counts it toward the run limit, then skips the runtime
checks.
- `clarification-flow.ts`: after reload, open the Artifacts tab with the
existing `openDocumentAuthoring` helper before reading the document.
task-dependency-resume uses the same path and passes on this deployment.
The UI readback is capped at 2 minutes after the API returned the saved
document. Agent work time is not changed.
- `agent-evidence.test.ts`: the retired wake is accepted. Nine variants
still fail: a started run, a missing `startedAt`, a failed status, an
unexpected cancellation, an operator source, `providerWorkStarted:
true`, missing recovery evidence, another agent, and another task. The
run limit still applies.

## Verification

- `npx vitest run --config tests/runner-e2e/vitest.config.ts
tests/runner-e2e/product-journeys`: 46 files passed.
- Negative control: without the skip, the new test fails.
- `clarification-readback.spec.ts` (browser support): 6 tests passed.
- `tsc -p tests/runner-e2e/tsconfig.json` shows no errors in the changed
files.
- Not verified live yet. QA uses the pinned harness, so the pin must
move to a commit that contains this change.

## Risks

- The exclusion is narrow, and every field it requires is positive
evidence that the wake did no work. Any started or unexpected extra run
still fails.
- The readback cap is stricter, not looser. It applies only after the
API has confirmed the document. Agent work and answer timing still use
the existing deadlines.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, via Claude Code with
tool use (shell, browser, code editing). Extended reasoning was used.

## 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
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 14:39:18 -06:00

163 lines
13 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { agentTaskSettled, fixtureReplyStoredOnce, startedTaskRuns, validateAgentTaskState } from "./agent-flows.js";
import { SLUGIFY_REVISION } from "../everyday-cases.js";
import { activeNativeTurnStarted, nativeCancellationHasNoLaterWork, nativeStopAcknowledged, nativeWorkEvents } from "../stop-redirect-flow.js";
import { parseTargetConfig } from "./catalog.js";
import type { JourneyContext } from "./types.js";
import { validateRetainedRunnerResult } from "../result-validation.js";
import { agentJourneyRuns, journeyRequiredChecks } from "./success-contract.js";
const context = { company: { id: "qa-company" }, environmentId: "qa-daytona",
agent: { id: "owned-agent", provider: "opencode", model: "qa-model", runtimeMode: "native" } } as JourneyContext;
it("accepts the QA editor's escaped underscore while rejecting lost, duplicate, foreign or changed replies", () => {
const before = [{ id: "old", authorUserId: "user", body: SLUGIFY_REVISION }];
const reply = { id: "new", authorUserId: "user", body: SLUGIFY_REVISION.replaceAll("_", "\\_") };
expect(fixtureReplyStoredOnce(before, [...before, reply], "user", SLUGIFY_REVISION)).toBe(true);
for (const after of [before, [...before, reply, { ...reply, id: "duplicate" }], [...before, { ...reply, authorUserId: "other" }],
[...before, { ...reply, body: reply.body.replace("optional", "required") }], [...before, { ...reply, body: reply.body + " extra" }]]) {
expect(fixtureReplyStoredOnce(before, after, "user", SLUGIFY_REVISION)).toBe(false);
}
});
function state() {
return { currentIssue: { id: "owned-task", companyId: "qa-company", assigneeAgentId: "owned-agent", status: "done" },
taskRuns: [{ id: "run", issueId: "owned-task", companyId: "qa-company", agentId: "owned-agent", status: "succeeded", runtimeMode: "native",
contextSnapshot: { paperclipEnvironment: { id: "qa-daytona" } }, runnerProfileJson: { nativeExecutionInput: { provider: { kind: "opencode", model: "qa-model" } } } }],
comments: [], interactions: [], attachments: [] };
}
describe("deployed agent journey evidence", () => {
it.each(["tool.execution.started", "turn.started", "message.delta", "item.delta", "item.completed"])("rejects %s after cancellation even when cleanup's first snapshot already contains it", eventType => {
const event = (type: string) => ({ id: type, payload: { prpEvent: { eventType: type } } });
const stopped = [event("turn.started"), event("item.delta"), event("turn.cancelled")];
expect(nativeCancellationHasNoLaterWork(stopped)).toBe(true);
expect(nativeCancellationHasNoLaterWork([...stopped, event("usage.reported")])).toBe(true);
expect(nativeCancellationHasNoLaterWork([...stopped, event(eventType)])).toBe(false);
expect(nativeCancellationHasNoLaterWork(stopped.slice(0, -1))).toBe(false);
});
it("requires an active native turn before the Stop boundary, including the native completion race", () => {
const run = { runtimeMode: "native", status: "running" };
const event = (eventType: string) => ({ payload: { prpEvent: { eventType } } });
expect(activeNativeTurnStarted(run, [])).toBe(false);
expect(activeNativeTurnStarted(run, [event("session.opened")])).toBe(false);
expect(activeNativeTurnStarted(run, [event("turn.started")])).toBe(true);
for (const terminal of ["turn.completed", "turn.failed", "turn.cancelled", "turn.interrupted"]) {
expect(activeNativeTurnStarted(run, [event("turn.started"), event(terminal)])).toBe(false);
}
expect(activeNativeTurnStarted({ ...run, status: "succeeded" }, [event("turn.started")])).toBe(false);
expect(activeNativeTurnStarted({ ...run, runtimeMode: "legacy" }, [event("turn.started")])).toBe(false);
});
it.each(["tool.execution.started", "turn.started", "message.delta", "item.delta", "item.completed"])("rejects a quiet-Stop claim when the retired turn emits %s", eventType => {
const event = (type: string) => ({ id: type, payload: { prpEvent: { eventType: type, payload: { text: "old turn kept writing" } } } });
const before = [event("turn.cancelled")];
expect(nativeWorkEvents([...before, event(eventType)])).not.toEqual(nativeWorkEvents(before));
expect(nativeWorkEvents([...before, event("usage.reported")])).toEqual(nativeWorkEvents(before));
});
it("accepts bound native work and fails plausible passes from a different target, agent, model or task", () => {
expect(() => validateAgentTaskState(context, "owned-task", state(), 3)).not.toThrow();
const substitutions = [
(s: ReturnType<typeof state>) => { s.currentIssue.companyId = "other"; },
(s: ReturnType<typeof state>) => { s.currentIssue.assigneeAgentId = "other"; },
(s: ReturnType<typeof state>) => { s.taskRuns[0]!.issueId = "other"; },
(s: ReturnType<typeof state>) => { s.taskRuns[0]!.agentId = "other"; },
(s: ReturnType<typeof state>) => { s.taskRuns[0]!.contextSnapshot.paperclipEnvironment.id = "local"; },
(s: ReturnType<typeof state>) => { s.taskRuns[0]!.runnerProfileJson.nativeExecutionInput.provider.model = "other"; },
(s: ReturnType<typeof state>) => { s.taskRuns[0]!.runtimeMode = "legacy"; },
(s: ReturnType<typeof state>) => { s.taskRuns[0]!.status = "failed"; },
(s: ReturnType<typeof state>) => { s.taskRuns[0]!.status = "cancelled"; },
];
for (const substitute of substitutions) { const s = state(); substitute(s); expect(() => validateAgentTaskState(context, "owned-task", s, 3)).toThrow(); }
const s = state(); s.taskRuns.push(s.taskRuns[0]!);
expect(() => validateAgentTaskState(context, "owned-task", s, 1)).toThrow();
});
it("permits only the explicitly stopped run, while still checking its deployment binding", () => {
const s = state(); s.taskRuns[0]!.status = "cancelled";
expect(() => validateAgentTaskState(context, "owned-task", s, 3, ["run"])).not.toThrow();
s.taskRuns[0]!.companyId = "other";
expect(() => validateAgentTaskState(context, "owned-task", s, 3, ["run"])).toThrow();
});
it("ignores only a bound wake the control plane retired before provider work started", () => {
// Observed on QA: an automation wake cancelled pre-start while completed runs awaited accounting.
const retired = (s: ReturnType<typeof state>) => {
(s.taskRuns as Array<Record<string, any>>).push({ id: "retired", companyId: "qa-company", agentId: s.taskRuns[0]!.agentId, issueId: "owned-task", status: "cancelled",
startedAt: null, contextSnapshot: {}, resultJson: {
executionRecovery: { kind: "accounting_wait", providerWorkStarted: false },
cancellation: { source: "control_plane", expected: true, initiator: { type: "system" } } } });
return s.taskRuns.at(-1)! as Record<string, any>;
};
const s = state(); retired(s);
expect(() => validateAgentTaskState(context, "owned-task", s, 3)).not.toThrow();
expect(s.taskRuns).toHaveLength(2);
expect(() => validateAgentTaskState(context, "owned-task", s, 1)).toThrow("agent_task_binding_mismatch");
const variants: Array<(run: Record<string, any>) => void> = [
run => { run.startedAt = "2026-11-05T19:17:00Z"; },
run => { delete run.startedAt; },
run => { run.status = "failed"; },
run => { run.resultJson.cancellation.expected = false; },
run => { run.resultJson.cancellation.source = "operator"; },
run => { run.resultJson.executionRecovery.providerWorkStarted = true; },
run => { delete run.resultJson.executionRecovery; },
run => { run.agentId = "other"; },
run => { run.issueId = "other"; },
];
for (const change of variants) { const v = state(); change(retired(v)); expect(() => validateAgentTaskState(context, "owned-task", v, 3)).toThrow(); }
});
it("settles a completed task without counting a wake retired before provider start", () => {
const run = (id: string, extra: Record<string, any> = {}): Record<string, any> => ({ id, status: "succeeded", startedAt: "2026-11-05T19:15:17Z", ...extra });
const retired = run("retired", { status: "cancelled", startedAt: null, resultJson: {
executionRecovery: { kind: "accounting_wait", providerWorkStarted: false },
cancellation: { source: "control_plane", expected: true, initiator: { type: "system" } } } });
const task = (taskRuns: Array<Record<string, any>>, status = "done") => ({ currentIssue: { id: "owned-task", status },
taskRuns, comments: [], interactions: [], attachments: [] }) as unknown as Parameters<typeof agentTaskSettled>[0];
expect(agentTaskSettled(task([run("first"), run("second"), retired]), 2)).toBe(true);
expect(startedTaskRuns(task([run("first"), run("second"), retired]))).toHaveLength(2);
// Negative controls: a started or unexpected extra run, missing work, open work or an unfinished task.
expect(agentTaskSettled(task([run("first"), run("second"), { ...retired, startedAt: "2026-11-05T19:17:19Z" }]), 2)).toBe(false);
expect(agentTaskSettled(task([run("first"), run("second"), { ...retired, resultJson: { ...retired.resultJson, cancellation: { ...retired.resultJson.cancellation, expected: false } } }]), 2)).toBe(false);
expect(agentTaskSettled(task([run("first"), retired]), 2)).toBe(false);
expect(agentTaskSettled(task([run("first"), run("second", { status: "running" })]), 2)).toBe(false);
expect(agentTaskSettled(task([run("first"), run("second"), retired], "in_progress"), 2)).toBe(false);
});
it("requires explicit paid run limits for a mixed deployment campaign", () => {
const config = { version: 1, id: "qa", mode: "existing-cloud", adapterModule: "/private/provider.mjs",
executionEnvironment: "daytona", journeys: ["task-create", "ask-question", "plan-revise-accept", "build-revise", "protected-actions", "create-skill-studio", "stop-redirect"],
baseline: { features: {}, connections: [] }, limits: { durationMs: 1_800_000, modelBudgetCents: 500, maxRuns: 14 } };
expect(parseTargetConfig(config).journeys).toEqual(config.journeys);
expect(() => parseTargetConfig({ ...config, limits: { ...config.limits, maxRuns: 1 } })).toThrow("agent_journeys_require_bounded_runs");
});
it("does not mistake a cancelled status or pending native intent for an acknowledged Stop", () => {
const run = { id: "run", companyId: "qa-company", issueId: "owned-task", status: "cancelled", runtimeMode: "native", resultJson: { cancelledByActorType: "user", cancelledByUserId: "qa-user",
cancellation: { source: "operator", expected: true, initiator: { type: "user", id: "qa-user" } },
startupCancellation: { requestedAt: "2026-10-29T00:00:00Z", beforeNativeSelection: false }, nativeCancellation: {
schema: "paperclip.native-cancellation.v1", intentId: "native-cancellation:00000000-0000-4000-8000-000000000001", companyId: "qa-company", runId: "run", issueId: "owned-task", scope: "run",
dispatched: true, dispatchState: "acknowledged", reasonCode: "cancellation_run_only", effects: ["release_run_resources"], intentAuditId: "intent", acknowledgementAuditId: "ack" } } };
expect(nativeStopAcknowledged(run, "qa-company", "owned-task", "qa-user")).toBe(true);
for (const change of [{ dispatchState: "pending" }, { runId: "other" }, { scope: "task" }, { effects: [] }, { acknowledgementAuditId: "intent" }]) {
const changed = structuredClone(run); Object.assign(changed.resultJson.nativeCancellation, change);
expect(nativeStopAcknowledged(changed, "qa-company", "owned-task", "qa-user")).toBe(false);
}
expect(nativeStopAcknowledged(run, "qa-company", "owned-task", "other-user")).toBe(false);
expect(nativeStopAcknowledged({ ...run, resultJson: {} }, "qa-company", "owned-task", "qa-user")).toBe(false);
});
it.each(Object.keys(agentJourneyRuns))("retained %s successes require every story check and the exact run receipts", id => {
const count = agentJourneyRuns[id as keyof typeof agentJourneyRuns];
const result = { schema: "paperclip.runner-e2e.result/v2", executionId: `product-journeys.qa.configured-agent.daytona.${id}`,
suiteId: "product-journeys", suiteDefinitionHash: "test", profileId: "configured-agent", environmentId: "daytona", caseId: id,
provider: "opencode", model: "qa-model", runtimeMode: "native", status: "passed", cleanup: "passed", attempt: 1,
startedAt: "2026-10-29T00:00:00Z", finishedAt: "2026-10-29T00:00:01Z", durationMs: 1000,
runIds: Array.from({ length: count }, (_, index) => `owned-run-${index}`),
productJourney: { outcome: "passed", startingState: "recipe", privacy: "private", resourceDisposition: "retained",
phases: [{ phase: "journey", outcome: "passed" }], checks: journeyRequiredChecks[id]!.map(id => ({ id, passed: true })),
deployment: { kind: "cloud", targetId: "qa", origin: "https://qa.test.paperclip.app", commit: "a".repeat(40),
cloudRelease: "b".repeat(40), runtimeImage: "pinned-image", configurationHash: "test" } } };
expect(() => validateRetainedRunnerResult(result)).not.toThrow();
for (let index = 0; index < result.productJourney.checks.length; index++) {
const missing = structuredClone(result); missing.productJourney.checks.splice(index, 1);
expect(() => validateRetainedRunnerResult(missing)).toThrow("result.productJourney");
}
const extraRun = structuredClone(result); extraRun.runIds.push("foreign-run");
expect(() => validateRetainedRunnerResult(extraRun)).toThrow("result.agentJourneyRun");
const falseCheck = structuredClone(result); falseCheck.productJourney.checks.push({ id: "independent-extra-check", passed: false });
expect(() => validateRetainedRunnerResult(falseCheck)).toThrow("result.productJourney");
});
});