Files
PaperClipAI/tests/runner-e2e/product-journeys/agent-flows.ts
T
DottaandPaperclip d9354bbfc3 Add a portable agent question and answer resumption journey (#18346)
## Thinking Path

> - Paperclip helps people manage agent work and keep human decisions
attached to tasks.
> - Product E2E journeys verify the browser, native agent, saved state,
and cleanup.
> - The classic suite already checks a choice question followed by a
text question.
> - Deployment-targeted journeys need the same behavior on an attached
instance.
> - Production questions can use provider input, the dedicated semantic
tool, or the authenticated native API fallback.
> - The basic journey must verify the original card and native
continuation for each supported path.
> - This pull request shares the scenario and browser actions, adds
strict API receipt evidence, and verifies the saved document after
reload.
> - Separate tool-documentation qualification and the original failed
measurements keep their existing scope and outcomes.

## Linked Issues or Issue Description

**What existing behavior does this improve?**

It adds portable coverage for answering agent questions and resuming the
same task on an attached deployment.

**Current behavior**

The classic suite has the two-question scenario. The portable catalog
does not expose it. Its shared native-path grader recognizes provider
and dedicated semantic receipts, but rejects a valid completed native
API creation receipt.

**Proposed behavior**

Expose `question-answer-resume` as a deployment-targeted journey. Answer
one choice question and one text question in the browser. Require
server-owned creation evidence, unchanged forms, exact board answers,
bound continuation, an agent-authored task document, and readback after
reload.

**Reason and benefit**

The same story can qualify local and hosted targets through their normal
provider setup and cleanup. It catches lost answers, duplicate or
foreign work, premature output, and stale documents.

## What Changed

- Register the journey with a three-run maximum and positive model
allowance. Accept one to three attributed work runs at both collection
and retained-result boundaries.
- Share the existing two-question scenario, browser answer actions, and
behavioral grader. Keep separate tool-documentation grading unchanged.
- Recognize a completed native API creation receipt only when its HTTP
201 result retains the original pending card, exact
task/company/agent/source run, idempotency key, and full normalized
form. Require the receipt to remain unchanged after the answer and
through the final checkpoint. Missing, pending, foreign, or altered
proof still fails.
- Check signed-in board ownership, each native continuation, no
premature or extra output, document authorship, and the saved revision
after reload.
- Wait for the authenticated product region before screenshots. Recheck
origin and path after readiness. Bound readiness and capture by the
campaign deadline.
- Add closed stage-specific errors, catalog coverage, sanitized receipt
calibration, and documentation. Include the provider-form and
browser-question presentation helpers in the definition fingerprint.

## Verification

- Fresh QA staging qualification passed at
`7f7de8be286a436010a48af89aba1727b4451493`: all 11 required checks
passed, cleanup passed, and all three native runs used the configured QA
target and model. The [sanitized run
report](https://pages.paperclip.ing/evals-qa-smoke-41121966520-1/)
records the measured pass. This is one successful qualification, not a
reliability claim.
- Final integration head `ae0d9d289831c770a413658d3520fb1dc8ad60e3`
contains the one main merge through
`fd008979f5794fda3d5f54151d36acd31d0de140` and an exact cherry-pick of
the four-file qualified search fix
`4edd10804e2b42941aa2038e90c362c13a0b98dc` from #18370. Patch identity
matches. It preserves the project and search registrations and atomic
search readback. The question live qualification ran at the earlier SHA
above; the final integration head has separate local and CI
verification.
- Eighteen question journey, helper, and test file blobs are identical
between the qualified and integration SHAs. This includes clarification
flow and evidence, question grader and browser helper, the sanitized
receipt fixture, screenshot helper, provider-form helper, browser
question presentation, continuation scenario/readiness/flow, shared
agent validation, and user actions. The question run-count contract and
dispatch branch are also unchanged. Reviewers can compare those paths
directly between the two commits.
- All 622 selected Product E2E, catalog, question-resumption,
necessary-question, report, and readiness tests pass after the main
merge. After the exact search delta, all 145 narrow
question/search/project/contract tests and all nine real Chromium search
regressions pass. Map checks, combined credential-free preview, source
fingerprints, and the earlier workspace-link preflight pass. The local
TypeScript attempt reports stale Runner declaration errors for
`onAccountingEvent` in unchanged native-session code; fresh official CI
provides the clean-build type gate. The three search source/test blobs
match the qualified search merge, and the three project source blobs are
unchanged.
- An in-memory probe executes the actual launcher fingerprint
expression. Each project, search, and question implementation input
changes the digest. Separate red probes first reproduced the omitted
provider-form and browser presentation helpers; both pass after their
source inputs are added.
- The sanitized retained native API response-shape regression fails
before the grading correction and passes afterward. Negative controls
reject missing, pending, foreign, changed, spoofed, or wrongly bound
receipts, answers, continuation, model, environment, and document
evidence. All nine provider/semantic/API path combinations pass
calibration.
- The real local Chromium screenshot probe fails before the readiness
repair and passes afterward. Login redirects and sign-in pages at task
URLs remain excluded with no screenshot files.
- The two original attached failures remain retained: the first stopped
at early screenshot capture; the second stopped before answering at the
former API provenance gate. An earlier sandbox run also retained one
report subprocess failure; the full selected tests then passed with
local subprocess IPC access.
- The prior integration CI had an unchanged canary mock `EADDRINUSE`
failure. That history is retained; its documented retry is owned by the
coordinator. No application API, schema, production UI, model, runtime
policy, credential, or deployment configuration change is included.
Final integration head ae0d9d2898 passed
all 58 CI checks, the external Snyk status, and fresh Greptile 5/5 with
no unresolved findings. This clean CI includes the official TypeScript
and build gates. Recurring selection follows merge and a separate normal
TestOps job verification.

## Risks

An API result must prove native creation; form shape and run count
cannot establish it. The grader checks the server receipt, exact scope,
unchanged card and receipt, real board answers, and bound native
continuation. The shared answer helper still serves classic journeys.
The screenshot wait affects attached journeys and remains limited to the
authenticated product region and campaign deadline. One live QA pass
establishes the measured journey outcome at the qualified SHA. It does
not establish reliability across models or deployments. The integration
SHA preserves its journey and helper blobs, while shared registry
changes receive fresh CI and review.

## Model Used

OpenAI Codex, GPT-6 family, with reasoning, source inspection, and code
execution. The host does not expose the exact underlying model version
or context-window size.

## 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-30 16:18:21 -05:00

277 lines
20 KiB
TypeScript

import { createHash } from "node:crypto";
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { expect } from "@playwright/test";
import { activeNativeTurnStarted, nativeCancellationHasNoLaterWork, nativeStopAcknowledged, nativeWorkEvents, stopAndRedirectThroughUi } from "../stop-redirect-flow.js";
import { runnerTasks } from "../catalog.js";
import { everydayTasks, SLUGIFY_REQUIREMENTS, SLUGIFY_REVISION } from "../everyday-cases.js";
import { latestStoryDelivery, type StoryDelivery } from "../everyday-delivery.js";
import { gradeConnectionGuidanceDecline } from "../connection-guidance-evidence.js";
import { runPlanRevisionAcceptance } from "../plan-revision-flow.js";
import { decideProtectedActionThroughUi, readServiceBriefing, serviceBriefingChecks } from "../protected-action-flow.js";
import { editCreatedSkillThroughStudio } from "../skill-studio-flow.js";
import { downloadStoryArtifact, runArtifactOracle } from "../story-artifact.js";
import { collectRunEvents } from "../run-observations.js";
import { readBoundTaskFinalReply } from "../native-task-reply.js";
import { createTaskThroughUi, submitTaskReply } from "../user-actions.js";
import { agentJourneyRuns } from "./catalog.js";
import { JourneyError, type JourneyContext, type JourneyEvidence, type TargetSession } from "./types.js";
type Row = Record<string, any>;
type AgentJourney = Exclude<keyof typeof agentJourneyRuns, "ask-question" | "question-answer-resume">;
interface TaskState { currentIssue: Row & { id: string; status: string }; taskRuns: Array<Row & { status: string }>; comments: Row[]; interactions: Array<Row & { id: string; status: string }>; attachments: StoryDelivery[] }
interface Options { deadlineAt: number; directory: string; capture(id: string, label: string, file: string): Promise<void> }
const active = (run: Row) => ["queued", "running"].includes(run.status);
/** These fixture replies are plain text. The rich editor escapes underscores in
* its persisted Markdown; compare the intended text while retaining exact count
* and author checks. Do not strip arbitrary markup or accept partial messages. */
export function fixtureReplyStoredOnce(before: Row[], after: Row[], userId: string, message: string) {
const added = after.filter(row => row.authorUserId === userId && !before.some(old => old.id === row.id));
return added.length === 1 && typeof added[0].body === "string" && added[0].body.replaceAll("\\_", "_") === message;
}
/** Owned-task observations only: other work in a persistent QA company cannot affect this grade. */
export function validateAgentTaskState(context: JourneyContext, issueId: string, state: TaskState, maxRuns: number, allowedCancelled: string[] = []) {
const agent = context.agent!;
if (state.currentIssue.id !== issueId || state.currentIssue.companyId !== context.company.id
|| state.currentIssue.assigneeAgentId !== agent.id || state.taskRuns.length > maxRuns) throw new JourneyError("failed", "agent_task_binding_mismatch");
for (const run of state.taskRuns) {
const provider = run.runnerProfileJson?.nativeExecutionInput?.provider;
const boundIssueId = run.issueId ?? run.nativeIssueId ?? run.contextSnapshot?.issueId ?? run.contextSnapshot?.taskId;
if (run.companyId !== context.company.id || run.agentId !== agent.id || boundIssueId !== issueId) throw new JourneyError("failed", "agent_run_binding_mismatch");
if (!active(run) && (run.runtimeMode !== "native" || run.contextSnapshot?.paperclipEnvironment?.id !== context.environmentId
|| provider?.kind !== agent.provider || provider?.model !== agent.model)) throw new JourneyError("failed", "agent_run_binding_mismatch");
if (!["queued", "running", "succeeded"].includes(run.status) && !(run.status === "cancelled" && allowedCancelled.includes(run.id))) throw new JourneyError("failed", "agent_run_failed");
}
}
async function waitFor<T>(deadlineAt: number, load: () => Promise<T>, accept: (value: T) => boolean, code: string): Promise<T> {
while (Date.now() < deadlineAt) {
const value = await load();
if (accept(value)) return value;
await new Promise(resolve => setTimeout(resolve, 1000));
}
throw new JourneyError("failed", code);
}
export async function runAgentJourney(id: AgentJourney, context: JourneyContext, session: TargetSession,
campaignId: string, evidence: JourneyEvidence, options: Options) {
const agent = context.agent;
if (!agent || agent.runtimeMode !== "native" || !context.environmentId) throw new JourneyError("unsupported", "native_agent_and_environment_required");
const nonce = campaignId.replaceAll("-", "_");
const maxRuns = agentJourneyRuns[id];
const cancelled: string[] = [];
await mkdir(options.directory, { recursive: true, mode: 0o700 });
const check = (id: string, passed = true) => {
evidence.checks.push({ id, passed });
if (!passed) throw new JourneyError("failed", id.replaceAll("-", "_"));
};
const retain = async (name: string, value: unknown) => writeFile(path.join(options.directory, name), JSON.stringify(value, null, 2), { mode: 0o600 });
const open = async (issue: Row) => context.page.goto(`${context.api.origin}/${context.company.issuePrefix}/issues/${issue.identifier ?? issue.id}`, { waitUntil: "domcontentloaded" });
const create = async (suffix: string, prompt: string, workMode: "standard" | "planning" = "standard") => {
const title = `Journey QA ${id}${suffix} ${campaignId}`;
const created = await createTaskThroughUi({ page: context.page, origin: context.api.origin, issuePrefix: context.company.issuePrefix,
agentName: agent.name, title, prompt, workMode, requireExplicitTitle: true,
beforeDispatch: () => session.own({ kind: "issue", name: title, journeyId: id }) });
await session.own({ kind: "issue", name: title, id: created.issueId, journeyId: id });
return context.api.get<Row>(`/api/issues/${created.issueId}`);
};
const read = async (issue: Row): Promise<TaskState> => {
const route = `/api/issues/${issue.id}`;
const [currentIssue, summaries, comments, interactions, attachments] = await Promise.all([
context.api.get<TaskState["currentIssue"]>(route), context.api.get<Row[]>(`${route}/runs`), context.api.get<Row[]>(`${route}/comments?order=asc`),
context.api.get<TaskState["interactions"]>(`${route}/interactions`), context.api.get<TaskState["attachments"]>(`${route}/attachments`),
]);
if (!Array.isArray(summaries) || summaries.length > maxRuns) throw new JourneyError("failed", "agent_run_limit_exceeded");
const taskRuns = await Promise.all(summaries.map(summary => context.api.get<TaskState["taskRuns"][number]>(`/api/heartbeat-runs/${summary.runId}`)));
const state = { currentIssue, taskRuns, comments, interactions, attachments };
await retain(`${issue.id}-latest.json`, state);
validateAgentTaskState(context, issue.id, state, maxRuns, cancelled);
return state;
};
const settled = (issue: Row, count: number) => waitFor(options.deadlineAt, () => read(issue), state =>
state.currentIssue.status === "done" && state.taskRuns.length === count && state.taskRuns.every(run => !active(run))
&& state.interactions.every(row => row.status !== "pending"), "agent_task_not_completed");
const finalReply = async (issue: Row, state: TaskState, marker?: string) => {
const runs = state.taskRuns.filter(run => run.status === "succeeded");
if (!runs.length || runs.some(run => !Number.isFinite(Date.parse(run.finishedAt)))) throw new JourneyError("failed", "final_run_time_missing");
const run = [...runs].sort((a, b) => Date.parse(a.finishedAt) - Date.parse(b.finishedAt)).at(-1)!;
const companies = await context.api.get<Row[]>("/api/companies?scope=accessible");
if (!Array.isArray(companies) || !companies.some(row => row.id === context.company.id)
|| companies.some(row => typeof row.issuePrefix !== "string" || !row.issuePrefix)) throw new JourneyError("failed", "reply_company_context_missing");
await open(issue);
const receipt = await readBoundTaskFinalReply({ page: context.page, runId: run.id, agentId: agent.id,
load: async () => {
const [latest, events] = await Promise.all([read(issue), collectRunEvents<Row>((afterSeq, limit) =>
context.api.get(`/api/heartbeat-runs/${run.id}/events?afterSeq=${afterSeq}&limit=${limit}`))]);
return { run: latest.taskRuns.find(row => row.id === run.id)!, comments: latest.comments, events };
},
expectedText: marker, deadlineAt: options.deadlineAt, knownCompanyPrefixes: companies.map(row => row.issuePrefix) });
await retain(`${issue.id}-final-reply.json`, receipt);
return receipt;
};
const reply = async (issue: Row, message: string) => {
const before = (await read(issue)).comments;
await open(issue);
await submitTaskReply(context.page, message);
const after = await waitFor(Math.min(options.deadlineAt, Date.now() + 30_000), () => read(issue), state =>
state.comments.some(row => row.authorUserId === context.user.id && !before.some(old => old.id === row.id)), "reply_not_persisted");
const added = after.comments.filter(row => row.authorUserId === context.user.id && !before.some(old => old.id === row.id));
check("user-reply-stored-once", fixtureReplyStoredOnce(before, after.comments, context.user.id, message));
return added[0];
};
const download = async (issue: Row, state: TaskState, phase: string, mode: "base" | "separator", preserved: string[] = []) => {
const delivery = latestStoryDelivery(state.attachments, [issue.id], undefined, preserved);
if (!delivery) throw new JourneyError("failed", "artifact_delivery_missing");
await open(issue);
const receipt = await downloadStoryArtifact({ page: context.page, attachmentId: delivery.id, directory: options.directory,
phase, mode, deadlineAt: options.deadlineAt });
await retain(`${phase}-download.json`, receipt);
check(`${phase}-artifact-verified`, receipt.checks.length > 0 && receipt.checks.every(row => row.passed));
return receipt;
};
if (id === "plan-revise-accept") {
const fixture = runnerTasks.find(task => task.id === id)!;
const issue = await create("", fixture.buildPrompt(nonce), "planning");
const receipt = await runPlanRevisionAcceptance({ page: context.page, api: context.api, origin: context.api.origin,
issue: { id: issue.id, identifier: issue.identifier }, issuePrefix: context.company.issuePrefix, native: true,
planMarkers: fixture.buildPlanMarkers!(nonce), revisionRequest: fixture.buildRevisionRequest!(nonce), deadlineAt: options.deadlineAt,
load: () => read(issue), retryOnError: error => !(error instanceof JourneyError), reject: () => undefined, capture: options.capture });
await retain("plan-revision.json", receipt);
const final = await settled(issue, 3);
const marker = fixture.buildVisibleMarker(nonce);
check("plan-revision-and-approval", receipt.draftInteraction.id !== receipt.revisedInteraction.id);
await finalReply(issue, final, marker);
check("plan-final-answer");
} else if (id === "build-revise") {
await runArtifactOracle(null, "base", options.deadlineAt);
const issue = await create("", SLUGIFY_REQUIREMENTS);
const initial = await download(issue, await settled(issue, 1), "initial", "base");
await reply(issue, SLUGIFY_REVISION);
const revised = await download(issue, await settled(issue, 2), "revised", "separator", [initial.sha256]);
check("artifact-versions-differ", revised.attachmentId !== initial.attachmentId && revised.sha256 !== initial.sha256);
check("original-artifact-preserved", createHash("sha256").update(await context.api.bytes(`/api/attachments/${initial.attachmentId}/content`)).digest("hex") === initial.sha256);
} else if (id === "create-skill-studio") {
const skillName = `qa-skill-${campaignId}`;
const fixture = everydayTasks.find(task => task.id === id)!;
const prompt = fixture.buildPrompt(nonce).replaceAll("release-readiness-checklist", skillName).replaceAll("create-skill-e2e-001", campaignId);
await session.own({ kind: "skill", name: skillName });
const issue = await create("", prompt);
const completed = await settled(issue, 1);
const skills = await context.api.get<Row[]>(`/api/companies/${context.company.id}/skills`);
const owned = skills.filter(skill => skill.slug === skillName && skill.name === skillName);
check("created-skill-persisted", owned.length === 1);
const skill = owned[0];
await session.own({ kind: "skill", name: skillName, id: skill.id });
const activity = await context.api.get<Row[]>(`/api/issues/${issue.id}/activity`);
check("skill-creation-attributed", activity.filter(row => row.action === "company.skill_created" && row.entityId === skill.id
&& row.runId === completed.taskRuns[0]!.id && row.agentId === agent.id && row.details?.sourceIssueId === issue.id).length === 1);
await open(issue);
await editCreatedSkillThroughStudio({ page: context.page, skillId: skill.id, skillName,
marker: `Studio edit marker: ${campaignId}`, deadlineAt: options.deadlineAt,
read: () => context.api.get(`/api/companies/${context.company.id}/skills/${skill.id}`) });
check("skill-studio-edit-persisted");
} else if (id === "protected-actions") {
const service = context.fixtures?.reviewService;
if (!service) throw new JourneyError("unsupported", "review_service_required");
for (const decline of [false, true]) {
const phase = decline ? "decline" : "approve";
const callsBefore = service.invocationCount();
const connectionBefore = await context.api.get<Row>(`/api/tool-connections/${service.connectionId}`);
const fixture = everydayTasks.find(task => task.id === `service-${phase}`)!;
const issue = await create(`-${phase}`, `${fixture.buildPrompt(nonce)}\nUse only the connected service named "${service.name}". Do not use any other service.`);
const pending = await waitFor(options.deadlineAt, () => read(issue), state =>
state.taskRuns.length === 1 && !state.taskRuns.some(active) && state.interactions.some(row => row.status === "pending"), "protected_action_not_requested");
check(`${phase}-waited-for-decision`, pending.currentIssue.status !== "done" && service.invocationCount() === callsBefore);
await open(issue); await context.page.reload();
const decision = await decideProtectedActionThroughUi({ page: context.page, rows: pending.interactions as any,
connectionId: service.connectionId, decline, invocationCount: service.invocationCount, expectedCallsBefore: callsBefore });
const final = await settled(issue, 2);
check(`${phase}-decision-persisted`, final.interactions.some(row => row.id === decision.id && row.status === (decline ? "rejected" : "accepted")));
check(`${phase}-service-call-count`, service.invocationCount() === callsBefore + (decline ? 0 : 1));
if (decline) {
const nativeFinalReply = await finalReply(issue, final);
const refreshed = await read(issue);
const connectionAfter = await context.api.get<Row>(`/api/tool-connections/${service.connectionId}`);
const unchanged = (row: Row) => JSON.stringify({ id: row.id, companyId: row.companyId, status: row.status, enabled: row.enabled,
installs: row.installs?.filter((install: Row) => install.targetType === "agent" && install.targetId === agent.id) });
const checks = gradeConnectionGuidanceDecline({ caseId: "service-decline", decisionId: decision.id,
decisions: refreshed.interactions as any, leadAgentId: agent.id, issueId: issue.id, replies: refreshed.comments, nativeFinalReply,
runs: refreshed.taskRuns as any, marker: service.marker, calls: service.invocationCount() - callsBefore, sameConnections: unchanged(connectionBefore) === unchanged(connectionAfter) });
await retain("service-decline-grade.json", checks);
check("decline-truthful-explanation", checks.every(row => row.passed));
} else {
await open(issue);
const bodies = await readServiceBriefing({ page: context.page, api: context.api, issueId: issue.id });
await retain("service-briefing.json", bodies);
check("approved-service-data-used", serviceBriefingChecks(bodies, service.marker).every(row => row.passed));
}
await open(issue);
}
} else {
await runArtifactOracle(null, "base", options.deadlineAt);
const issue = await create("", SLUGIFY_REQUIREMENTS);
const initial = await download(issue, await settled(issue, 1), "checkpoint", "base");
await reply(issue, SLUGIFY_REVISION);
const state = await waitFor(options.deadlineAt, () => read(issue), value => value.taskRuns.some(run => run.status === "running"), "stop_boundary_not_active");
const original = state.taskRuns.find(run => run.status === "running")!;
await open(issue);
const originalEvents = () => collectRunEvents<Row>((afterSeq, limit) =>
context.api.get(`/api/heartbeat-runs/${original.id}/events?afterSeq=${afterSeq}&limit=${limit}`));
const boundary = await waitFor(options.deadlineAt, async () => {
const events = await originalEvents();
await retain("stop-startup-events.json", events);
const current = await read(issue);
const run = current.taskRuns.find(row => row.id === original.id);
if (!run || !active(run)) throw new JourneyError("failed", "stop_boundary_already_settled");
return { state: current, run, events };
}, value => activeNativeTurnStarted(value.run, value.events), "stop_native_turn_not_active");
await retain("stop-before.json", boundary.state);
cancelled.push(original.id);
const runActivity = async () => nativeWorkEvents(await originalEvents());
let beforeEvents: Row[];
const redirected = await stopAndRedirectThroughUi({ page: context.page, nonce,
awaitRetirement: async () => {
const stopped = await waitFor(options.deadlineAt, () => read(issue), value => {
const run = value.taskRuns.find(row => row.id === original.id);
return run?.status === "cancelled" && nativeStopAcknowledged(run, context.company.id, issue.id, context.user.id);
}, "stop_not_acknowledged");
await retain("stop-after.json", stopped);
beforeEvents = await waitFor(options.deadlineAt, async () => {
const events = await runActivity();
await retain("stop-cancellation-events.json", events);
return events;
}, events => events.some(row => row.payload?.prpEvent?.eventType === "turn.cancelled"), "stop_native_terminal_not_retained");
check("stop-native-terminal-retained", beforeEvents.some(row => row.payload?.prpEvent?.eventType === "turn.cancelled"));
if (!nativeCancellationHasNoLaterWork(beforeEvents)) check("stopped-run-no-later-events", false);
},
beforeRedirect: async () => {
const leases = await waitFor(options.deadlineAt, () => context.api.get<Row[]>(`/api/environments/${context.environmentId}/leases`), rows => {
const owned = rows.filter(row => row.companyId === context.company.id && row.issueId === issue.id && row.heartbeatRunId === original.id);
return owned.length > 0 && owned.every(row => ["released", "retained", "expired"].includes(row.status) && row.cleanupStatus === "success");
}, "stopped_run_resources_not_released");
await retain("stop-leases.json", leases.filter(row => row.issueId === issue.id && row.heartbeatRunId === original.id));
check("active-work-stopped");
},
reply: message => reply(issue, message),
});
const marker = redirected.marker;
const userReply = redirected.reply as Row;
const final = await settled(issue, 3);
const completion = await finalReply(issue, final, marker);
check("new-direction-completed", completion.runId !== original.id);
check("stopped-run-remained-terminal", final.taskRuns.find(run => run.id === original.id)?.status === "cancelled");
const afterEvents = await runActivity();
check("stopped-run-no-later-events", nativeCancellationHasNoLaterWork(afterEvents)
&& JSON.stringify(beforeEvents!) === JSON.stringify(afterEvents));
check("stop-preserved-download", createHash("sha256").update(await context.api.bytes(`/api/attachments/${initial.attachmentId}/content`)).digest("hex") === initial.sha256);
const queue = await context.api.get<Row>(`/api/issues/${issue.id}/queued-comments`);
check("redirect-consumed-once", queue.entries.length === 0 && final.comments.filter(row => row.id === userReply.id).length === 1);
await open(issue); await context.page.reload();
await expect(context.page.getByText(marker, { exact: true }).last()).toBeVisible();
}
check("configured-model-and-environment");
}