Files
PaperClipAI/tests/runner-e2e/continuation-flow.ts
T
DottaandClaude Opus 5.5 b1a71044af fix(evals): observe chat handoff replies and restarted task pages (#19073)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The Product regression TestOps plan runs 20 Product E2E cells
through `runner-full-stack-e2e.yml`
> - The full 20-case TestOps launch on `master` 4740d251 passed 13 of 20
> - Four failures came from the eval's observation, not from the product
> - Three Agent Chat handoff cases raced the product's completed-handoff
reply turn, and the run-count contract treated that reply as an
unexpected run
> - One continuation case waited for `DOMContentLoaded` after a server
restart, which the dev server can leave pending while the page is
interactive
> - This pull request makes the eval observe those two behaviors
explicitly
> - The benefit is that these cases measure the product again, and the
handoff cases now also prove that the result returns to the chat

## Linked Issues or Issue Description

Refs #14408, #19053

**What happened?**
TestOps launch https://paperclip.testops.cloud/launch/2694 (GitHub run
https://github.com/paperclipai/paperclip/actions/runs/41915965118)
failed these cells:
- `agent-chat` `multi-repository`, `clarify-reuse`, `plan-handoff`:
"Declared total provider-run bounds". Each final run ledger has exactly
one extra run. That run is the source chat's `chat_task_completed` turn,
with `chatCompletionUpdates` naming the finished task.
- `continuation` `completed-action-resume`: `page.goto: Timeout 30000ms
exceeded` waiting for `domcontentloaded` right after the deliberate
server restart. The server log shows the page loaded and called the API
during that wait.

**Expected behavior**
A completed handoff returns to the source chat (#14408). The eval must
observe that reply, require it, and count it. After a restart, the eval
must wait for the rendered task, not for a browser load event.

**Steps to reproduce**
Run the cells listed above on `master` with the Product regression
TestOps lane.

## What Changed

- `chat-flow.ts`: after the delegated task is `done`, the handoff branch
waits for the source chat's completion-reply turn
(`chat_task_completed`, correlated by `chatCompletionUpdates[].id`). It
requires exactly one such turn, `succeeded`, and exactly one agent
comment created by that turn.
- `chat-cases.ts`: the declared run counts include that one reply turn:
`multi-repository` 2 → 3, `clarify-reuse` 3 → 4, `plan-handoff` 4 → 5.
The minimum and maximum stay equal, so a missing reply or an extra run
still fails.
- `catalog.test.ts`: the catalog run-count total changes from 385 to 397
for these three cases across their profiles.
- `continuation-flow.ts`: `open()` uses `waitUntil: "commit"` with a 60
s navigation limit, then requires the rendered task thread
(`waitForTaskChatRendered`). This matches the existing restart handling
in `chat-flow.ts`.

## Verification

- `npx vitest run --config tests/runner-e2e/vitest.config.ts
tests/runner-e2e/chat-flow.test.ts tests/runner-e2e/continuation.test.ts
tests/runner-e2e/catalog.test.ts
tests/runner-e2e/kimi-qualification.test.ts
tests/runner-e2e/non-execution-wake.test.ts
tests/runner-e2e/product-regression-testops-catalog.test.ts
tests/runner-e2e/lifecycle-live.test.ts`: all pass.
- `tsc -p tests/runner-e2e/tsconfig.json`: no errors in
`tests/runner-e2e`.
- The retained ledgers from run 41915965118 show the reply turn:
`multi-repository` 3 runs, `clarify-reuse` 4 runs, `plan-handoff` 5
runs, each with one `chat_task_completed` run on the chat issue.
- Live single-case TestOps runs against this branch: see the PR
comments.

## Risks

- Changed eval conditions: the three handoff cases now also require the
completion reply. A product regression that drops the reply now fails
these cases, as intended.
- The continuation navigation allowance changes from 30 s
(`domcontentloaded`) to 60 s (`commit`) plus a rendered-thread check.
That is an observation deadline, not a product latency requirement. The
rendered-thread check is stronger than the old load event.
- Three other plan failures are not changed here. They are product
failures: the agent creates `human_only` confirmations, and
`resolve-from-comment` then refuses to record the user's explicit text
approval (`interaction_human_only`).

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), via Claude Code with tool use and
code execution.

## 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-04 17:40:58 -06:00

368 lines
17 KiB
TypeScript

import { gradeNecessaryQuestion } from "./necessary-question.js";
import { gradeLegacyWorkspaceMigration, migrationPrimaryOutputCheckpoints, isLegacyWorkspaceMigration, legacyWorkspaceMigrationPrompt, readMigrationPoint, type MigrationPoint } from "./legacy-workspace-migration.js";
import { necessaryQuestionContract } from "./continuation-cases.js";
import { validResumeQuestionForm, validResumeReferenceForm } from "./question-resume-scoring.js";
import { answerResumeQuestionThroughUi } from "./question-resume-ui.js";
import { confirmContinuationPlan } from "./continuation-approval.js";
import { gradeLifecycleBaseline, type LifecycleCheckpoint } from "./lifecycle-baseline.js";
import { lifecycleLiveCase, lifecycleLiveContinuation, gradeLifecycleNarrative } from "./lifecycle-live-cases.js";
import { prepareLegacyContinuationSkill, prepareContinuationBudget } from "./continuation-fixtures.js";
import { captureFirstTaskAttachments } from "./first-task-attachments.js";
import { answerableRuntimeRunIds, isSingleClaudeQuestion } from "./runtime-question-readiness.js";
import { expect, type Page } from "@playwright/test";
import path from "node:path";
import { continuationAnswerCommitted, continuationInitialReady, continuationCheckpointReady } from "./continuation-readiness.js";
import { captureLoadedContinuation, waitForTaskChatRendered } from "./continuation-screenshot.js";
import { seedContinuationContext } from "./continuation-workspace.js";
import { pollUntil, type RunnerApi } from "./api.js";
import {
chatQuestionPresentation,
sendChatMessage,
collectChatRunEvidence,
type ChatRun,
} from "./chat-flow.js";
import {
continuationScenario,
continuationScreenshotFile,
} from "./continuation-cases.js";
import {
gradeContinuation,
isContinuationPlan,
type ContinuationCheckpoint,
} from "./continuation-scoring.js";
import { createTaskThroughUi } from "./user-actions.js";
import type { LiveFixtureValues } from "./live-fixtures.js";
import type { MatrixExecution } from "./types.js";
type Row = Record<string, any>;
export async function runContinuationFlow(input: {
page: Page;
api: RunnerApi;
fixtures: LiveFixtureValues;
execution: MatrixExecution;
nonce: string;
secrets: readonly string[];
workspacePath: string;
deadlineAt: number;
restart(): Promise<void>;
observe(
issue: any,
runs: any[],
checks: ReturnType<typeof gradeContinuation>,
): void;
capture(id: string, label: string, file: string): Promise<void>;
evidence(name: string, value: unknown): Promise<void>;
}) {
const { page, api, fixtures, execution } = input;
const migration = isLegacyWorkspaceMigration(execution);
let migrationBefore: MigrationPoint | undefined, migrationAfter: MigrationPoint | undefined;
const lifecycleProbe = lifecycleLiveCase(execution.task.id);
const scenario = lifecycleProbe
? lifecycleLiveContinuation(execution.task.id, input.nonce)
: continuationScenario(execution.task.id, input.nonce);
const checkpoints: LifecycleCheckpoint[] = [];
let budgetGuard: Awaited<ReturnType<typeof prepareContinuationBudget>> | undefined;
let issue: Row | undefined;
let runs: Row[] = [];
let checks: ReturnType<typeof gradeContinuation> = [];
const tasksPath = `/api/companies/${fixtures.company.id}/issues?limit=100`;
async function refresh() {
issue = await api.get<Row>(`/api/issues/${issue!.id}`);
const listed = await api.get<Row[]>(
`/api/companies/${fixtures.company.id}/heartbeat-runs?limit=100`,
);
runs = await Promise.all(
listed.map((r) => api.get<Row>(`/api/heartbeat-runs/${r.id}`)),
);
input.observe(issue, runs, checks);
return { issue, runs };
}
let pausedRuntimeRunIds = new Set<string>();
async function settle(prior: Set<string>, requireQuestion = false, answeredInteractionId?: string) {
let stable = "";
const previousPaused = pausedRuntimeRunIds;
await pollUntil({
label: `continuation ${scenario.id} settled`,
deadlineAt: input.deadlineAt,
intervalMs: 1000,
load: async () => ({
...await refresh(),
interactions: await api.get<Row[]>(`/api/issues/${issue!.id}/interactions`),
}),
accept: (state) => {
const paused = answerableRuntimeRunIds(state.interactions);
const idle =
continuationAnswerCommitted(state.interactions, answeredInteractionId) &&
continuationCheckpointReady({
requireCompletedTurn: migration,
issue: { status: state.issue.status, executionRunId: state.issue.executionRunId },
runs: state.runs.map(r => ({ id: r.id, status: r.status, runtimeMode: r.runtimeMode })),
interactions: state.interactions,
}) &&
state.runs.some((r) => !prior.has(r.id) || previousPaused.has(r.id)) &&
state.runs.every((r) => ["succeeded", "failed", "timed_out", "cancelled"].includes(r.status) ||
(r.status === "running" && paused.has(r.id))) &&
!state.issue.scheduledRetry &&
!state.issue.activeRecoveryAction &&
(!requireQuestion || continuationInitialReady(state.interactions));
const key = idle
? JSON.stringify([state.issue.status, state.issue.executionRunId,
state.runs.map(r => [r.id, r.status]), state.interactions.map(i => [i.id, i.status])])
: "";
const ready = !!key && key === stable;
stable = key;
if (ready) pausedRuntimeRunIds = paused;
return ready;
},
reject: (state) =>
state.runs.length > (scenario.id === "necessary-free-text" ? 2 : scenario.id === "question-answer-resume" ? 3 : 12)
? "Bounded continuation run count exceeded"
: state.runs.some((r) =>
["failed", "timed_out", "cancelled"].includes(r.status),
)
? `Provider execution failed: ${state.runs
.filter((r) => r.status !== "succeeded")
.map(
(r) => `${r.id} ${r.errorCode ?? r.status}: ${r.error ?? ""}`,
)
.join("; ")}`
: undefined,
});
}
async function open() {
// A restarted dev server can leave DOMContentLoaded pending after the app
// is interactive (see chat-flow). Require the rendered task thread instead.
await page.goto(
`/${fixtures.company.issuePrefix}/issues/${issue!.identifier ?? issue!.id}`,
{ waitUntil: "commit", timeout: 60_000 },
);
await waitForTaskChatRendered(page, undefined, 60_000);
}
async function snapshot(phase: ContinuationCheckpoint["phase"]) {
const [tasks, summaries, comments, interactions, attachments, activity] =
await Promise.all([
api.get<Row[]>(tasksPath),
api.get<Row[]>(`/api/issues/${issue!.id}/documents`),
api.get<Row[]>(`/api/issues/${issue!.id}/comments?order=asc`),
api.get<Row[]>(`/api/issues/${issue!.id}/interactions`),
captureFirstTaskAttachments(api, [{ id: issue!.id }], input.secrets),
api.get<Row[]>(`/api/issues/${issue!.id}/activity`),
]);
const documents = await Promise.all(
summaries.map((d) =>
api.get<Row>(
`/api/issues/${issue!.id}/documents/${encodeURIComponent(d.key)}`,
),
),
);
checkpoints.push({
phase,
lifecycle: {
executionRunId: issue!.executionRunId ?? null,
scheduledRetry: issue!.scheduledRetry ?? null,
activeRecoveryAction: issue!.activeRecoveryAction ?? null,
monitorNextCheckAt: issue!.monitorNextCheckAt ?? null,
},
issue: issue as ContinuationCheckpoint["issue"],
children: tasks.filter(
(t) => t.parentId === issue!.id,
) as ContinuationCheckpoint["children"],
documents: documents as ContinuationCheckpoint["documents"],
comments,
activity,
interactions,
attachments,
runs: [...runs] as ContinuationCheckpoint["runs"],
});
await input.evidence("continuation.json", {
...scenario,
budgetGuard,
checkpoints,
checks,
});
await input.evidence("api-state.json", checkpoints.at(-1));
await open();
if (scenario.id === "necessary-free-text") {
if (phase === "initial") {
const contract = necessaryQuestionContract();
await expect(page.getByText(contract.prompt, { exact: true }).last()).toBeVisible({ timeout: 30_000 });
await expect(page.getByText(contract.context, { exact: true }).last()).toBeVisible({ timeout: 30_000 });
const question = interactions.find(interaction => interaction.kind === "ask_user_questions" && interaction.status === "pending");
checkpoints.at(-1)!.questionPresentation = { interactionId: question?.id ?? "", promptVisible: true, contextVisible: true };
} else if (phase === "final") {
expect(documents).toHaveLength(1);
const document = documents[0]!;
expect(document.latestRevisionId).toBeTruthy();
const href = `/${encodeURIComponent(fixtures.company.issuePrefix!)}/issues/${encodeURIComponent(issue!.identifier ?? issue!.id)}#document-${encodeURIComponent(document.key)}`;
const url = new URL(href, page.url()).href;
const link = page.getByTestId("task-chat-agent-bubble").locator(`a[href=${JSON.stringify(href)}]`).last();
await expect(link).toBeVisible({ timeout: 30_000 });
await link.click();
await expect(page).toHaveURL(url);
const target = page.locator([
`[id=${JSON.stringify(`document-${document.key}`)}]:visible`,
`[id=${JSON.stringify(`side-panel-content-document:${document.key}`)}]:visible`,
].join(", "));
await expect(target).toHaveCount(1);
await expect(target).toBeVisible();
const visibleBody = `Booking confirmed: ${scenario.answer}`;
await expect(target).toContainText(visibleBody);
checkpoints.at(-1)!.outputDocument = { issueId: issue!.id, key: document.key, revisionId: document.latestRevisionId, href, visibleBody };
}
}
await captureLoadedContinuation(page, String(issue!.title), () => input.capture(
phase,
`Continuation: ${phase}`,
continuationScreenshotFile(phase),
));
}
async function answer(choice?: string) {
const interactions = await api.get<Row[]>(
`/api/issues/${issue!.id}/interactions`,
);
const questions = interactions.filter(
(i) => i.kind === "ask_user_questions" && i.status === "pending",
);
expect(questions, "one real question must be shown").toHaveLength(1);
const set = chatQuestionPresentation(questions[0].payload);
if (scenario.id === "necessary-free-text") {
expect(validResumeQuestionForm(checkpoints[0]!, questions[0], false), "a bound text-only question").toBe(true);
} else if (scenario.id === "question-answer-resume") {
expect(choice ? validResumeQuestionForm(checkpoints.at(-1)!, questions[0], true)
: validResumeReferenceForm(checkpoints.at(-1)!, questions[0]), "a verified native question path and the requested form").toBe(true);
} else if (scenario.id === "provider-question-bridge") {
expect(isSingleClaudeQuestion(set.questions), "one choice question with only the optional provider Other field").toBe(true);
} else expect(set.questions, "ask only the requested next question").toHaveLength(1);
const before = new Set(runs.map((r) => r.id));
await answerResumeQuestionThroughUi(page, questions[0], choice ? { choice } : {
text: scenario.answer, allowProviderCustomAnswer: scenario.id === "question-answer-resume",
});
await settle(before, false, questions[0].id);
}
async function reply(body: string) {
const before = new Set(runs.map((r) => r.id));
await sendChatMessage(page, body);
await settle(before);
}
function assertWaiting() {
const c = checkpoints.at(-1)!;
expect(
c.documents.filter((d) => !isContinuationPlan(d, c)),
"no deliverable before authorization",
).toHaveLength(0);
expect(c.attachments, "no attachment before authorization").toHaveLength(0);
expect(c.issue.status, "waiting is not complete").not.toBe("done");
}
try {
budgetGuard = await prepareContinuationBudget(api, fixtures.company.id, fixtures.agent.id);
if (execution.profile.generation === "legacy") await prepareLegacyContinuationSkill(api, fixtures.company.id, fixtures.agent.id);
await api.patch("/api/instance/settings/experimental", {
enableClassicTaskInterface: false,
});
const createdTask = await createTaskThroughUi({
page,
issuePrefix: fixtures.company.issuePrefix!,
agentName: fixtures.agent.name,
title: execution.task.buildTitle(input.nonce),
prompt: migration ? legacyWorkspaceMigrationPrompt(scenario.prompt) : scenario.prompt,
workMode: "standard",
});
issue = await pollUntil({
label: "continuation task created",
deadlineAt: input.deadlineAt,
load: async () =>
(await api.get<Row[]>(tasksPath)).find(
(t) => t.id === createdTask.issueId,
),
accept: Boolean,
});
if (!issue) throw new Error("Missing continuation task");
await settle(new Set(), scenario.id !== "revision-preserves-approval");
await snapshot("initial");
assertWaiting();
if (migration) {
migrationBefore = await readMigrationPoint({ api, companyId: fixtures.company.id, agentId: fixtures.agent.id, issueId: issue.id });
await input.evidence("legacy-migration-before.json", migrationBefore);
await input.restart();
await open();
}
if (scenario.id === "untrusted-evidence") {
const parentRun = runs.find((r) => r.contextSnapshot?.issueId === issue!.id);
await seedContinuationContext({
isolatedRoot: path.dirname(input.workspacePath),
recordedCwd: parentRun?.contextSnapshot?.paperclipWorkspace?.cwd,
body: scenario.context,
});
}
if (scenario.id === "completed-action-resume") {
await input.restart();
await open();
}
if (["question-tool-documentation", "question-answer-resume"].includes(scenario.id)) {
await answer("Afternoon");
await snapshot("answered");
assertWaiting();
await answer();
} else if (scenario.id === "provider-question-bridge") await answer(scenario.marker);
else if (scenario.id === "revision-preserves-approval")
await reply(scenario.revision);
else await answer();
if (scenario.gate) {
await snapshot(
scenario.id === "revision-preserves-approval" ? "revised" : "answered",
);
assertWaiting();
if (scenario.id === "revision-preserves-approval") {
const before = new Set(runs.map(r => r.id));
const accepted = await confirmContinuationPlan({ page, api, checkpoint: checkpoints.at(-1)!, deadlineAt: input.deadlineAt });
await input.evidence("continuation-plan-approval.json", accepted);
await settle(before);
} else await reply(scenario.approval);
}
if (migration) migrationAfter = await readMigrationPoint({ api, companyId: fixtures.company.id, agentId: fixtures.agent.id, issueId: issue!.id });
await snapshot("final");
} finally {
checks = gradeContinuation({
...scenario,
checkpoints: migration ? migrationPrimaryOutputCheckpoints(checkpoints, migrationBefore, migrationAfter) : checkpoints,
runtimeMode: execution.profile.expectedRuntimeMode,
});
checks.push(...gradeLifecycleBaseline(checkpoints));
if (migration) {
checks.push(...gradeLegacyWorkspaceMigration(migrationBefore, migrationAfter, checkpoints));
await input.evidence("legacy-migration.json", { before: migrationBefore, after: migrationAfter, checks: gradeLegacyWorkspaceMigration(migrationBefore, migrationAfter, checkpoints) });
}
if (scenario.id === "necessary-free-text") checks.push(...gradeNecessaryQuestion(checkpoints, scenario.answer));
if (lifecycleProbe) checks.push(gradeLifecycleNarrative({
narrative: lifecycleProbe.narrative,
agentId: fixtures.agent.id,
initial: checkpoints.find(c => c.phase === "initial"),
}));
if (issue) input.observe(issue, runs, checks);
await input.evidence("continuation.json", {
...scenario,
budgetGuard,
checkpoints,
checks,
});
await input.evidence(
"continuation-run-evidence.json",
await Promise.all(
runs.map(async (r) => {
try {
return await collectChatRunEvidence(api, r as ChatRun);
} catch (error) {
return { runId: r.id, evidenceCaptureError: String(error) };
}
}),
),
);
}
const failures = checks.filter((c) => !c.passed);
if (failures.length)
throw new Error(
`Continuation matcher failures: ${failures.map((c) => `${c.id}: ${c.detail}`).join("; ")}`,
);
return { issue: issue!, runs, checks };
}