Files
PaperClipAI/tests/runner-e2e/product-journeys/agent-evidence.test.ts
T
DottaandPaperclip 8941db9ce6 fix(evals): accept editor escapes and cancel sensitive QA requests (#18244)
## Thinking Path

> - Paperclip manages AI agents that do work for users.
> - Product E2E journeys check real browser actions and saved results.
> - A rich editor can store plain underscores as escaped Markdown.
> - The QA reply check treated that valid saved message as a failure.
> - A private target adapter also needs to cancel credential-safe HTTP
requests before cleanup.
> - This PR fixes reply comparison and adds abort and timeout options to
the existing sensitive transport.

## Linked Issues or Issue Description

Refs #18222.

**What happened?**

The artifact revision and Stop journeys rejected the correct single user
reply because its underscore was stored as `\_`. Sensitive connection
setup also lacked a way to pass its lifecycle signal to the actual HTTP
request.

**Expected behavior**

The check accepts the editor's underscore escape and still rejects
changed, duplicate, missing, or foreign replies. A cancelled sensitive
request closes before setup cleanup proceeds.

**Steps to reproduce**

Run an artifact revision with an underscore in the reply. For the
transport case, abort a pending sensitive request or let its deadline
expire.

## What Changed

- Compare the fixture reply after undoing only underscore escaping. Keep
exact text, count, and author checks.
- Let `ConnectionApi.postSensitive` accept a bounded timeout and abort
signal. Check cancellation again after cookie loading and before
dispatch.
- Test the retained reply failure and both cancellation paths with an
actual loopback HTTP server. Document the adapter contract.

## Verification

- Focused Product E2E tests: 30 passed across two files.
- Product E2E TypeScript check passed. After rebase, a final check first
found four diagnostics from stale runner/plugin SDK declarations;
rebuilding those two workspace packages cleared all four, and the
unchanged E2E check passed.
- The first sandbox run failed because loopback `listen` returned
`EPERM`. The unchanged HTTP tests passed with loopback permission. The
test now reports listener errors directly.
- Both independent reviewers found a possible pre-arrival deadline-test
hang. The follow-up races arrival against request settlement and adds
one second of deadline headroom. All 30 affected tests passed after that
fix.
- Full current-head CI passed in run 40982914525. Greptile and Paperclip
Review both scored 5/5 at `f0526f5fbbf13de5ea43e868eb012069ca3d8907`;
all review threads are resolved. Live QA requalification follows the
merged public and private fixes; no live pass is claimed here.

## Risks

The text normalization applies only to plain fixture replies and only
removes underscore escapes. It does not remove arbitrary Markdown.
Sensitive calls retain their existing 30-second default and closed error
messages. API wrappers must forward the optional signal and await the
actual request.

## Model Used

OpenAI Codex, GPT-6 family. The serving model ID and context window are
not exposed by this session. Used reasoning, code editing, shell
execution, and test inspection.

## 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>
2026-10-29 20:14:56 -05:00

121 lines
10 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { fixtureReplyStoredOnce, 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("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");
});
});