mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-10 12:12:25 +01:00
## 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 appcb8df178c8. The stack-side run records are described above. **Paperclip version or commit** master at03eb49053f. ## 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>
163 lines
13 KiB
TypeScript
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");
|
|
});
|
|
});
|