Files
PaperClipAI/tests/runner-e2e/completion-update-flow.ts
T
DottaandPaperclip cea8dda472 test: evaluate completion updates after native task handoffs (#13969)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Users can delegate work through onboarding and Agent Chat.
> - A completed task does not prove that its result reached the original
conversation.
> - Existing tests do not isolate completion after the source chat
becomes idle.
> - This pull request adds four explicit native-runner probes across
Claude and Codex.
> - The probes preserve the result and reply so we can separate delivery
failures from inaccurate answers.

## Linked Issues or Issue Description

Refs #13775. Refs #13813.

These evals extend native-runner qualification. They measure completion
updates before we choose a product change.

## What Changed

- Add the opt-in `completion-updates` suite with two stories for each
native provider.
- Test completion in the existing onboarding task flow and after an
Agent Chat handoff becomes idle.
- Gate the chat worker on a brief inside its managed project workspace.
Prove the source is idle before releasing the worker.
- Check durable task completion, saved output, a subsequent source
reply, and rendered access to the result.
- Preserve replies, task state, screenshots, run events, and a separate
semantic review rubric.
- Add grader regression tests and update the documented eval contract.
- Preserve the suites added on master and include four completion cases
in the 306-cell catalog.

Production behavior and prompts are unchanged.

## Verification

- Passed all 565 eval support tests across 45 files after merging
current master: `node node_modules/vitest/vitest.mjs run --config
tests/runner-e2e/vitest.config.ts`.
- Passed eval TypeScript: `node node_modules/typescript/bin/tsc -p
tests/runner-e2e/tsconfig.json`.
- Confirmed four selected cells: `node cli/node_modules/tsx/dist/cli.mjs
tests/runner-e2e/launch.ts --list --suite completion-updates`.
- Four-cell behavior campaign on source
`ad47cf1da2b1e36f19f4227cfeb53998720b0b5b`:
https://github.com/paperclipai/paperclip/actions/runs/36072337485.
- A screenshot-only follow-up waits for the restored source reply to
render after result-link navigation. Its one-cell Claude onboarding
verification passed on final head:
https://github.com/paperclipai/paperclip/actions/runs/36075716141.
Corrected report:
https://d1p6rlowie26tp.cloudfront.net/runner-e2e/campaigns/gha-36075716141-1/.
The original four-cell onboarding screenshots caught navigation loading;
its saved reply evidence remains valid. The follow-up again found stale
wording: "That work will run next" was posted 38 seconds after the child
was Done. The four-cell campaign keeps its original source and
measurements.
- Published evidence:
https://d1p6rlowie26tp.cloudfront.net/runner-e2e/campaigns/gha-36072337485-1/.
- Suite definition:
`afba4d85d6c53d9f64c08b37a2e9cc20481b78f5bd7e2fa012045e2c69444d9d`,
version 6. Models: native `gpt-5.6-sol` and `claude-sonnet-5`, local
execution, one attempt per cell. All four cleanup checks passed.
Onboarding billing coverage is partial; reported zero cost must not be
read as a free run.

| Story | Automated delivery/access | Separate semantic review |
| --- | --- | --- |
| Codex onboarding | Pass | Pass: accurate completion reply with an
accessible result |
| Claude onboarding | Pass | Fail: reply says it will save the note once
the task runs, after the note is already saved and the task is Done |
| Codex idle chat handoff | Fail | Worker completed and saved the note;
no completion reply during the full observation window |
| Claude idle chat handoff | Fail | Worker completed and saved the note;
no completion reply during the full observation window |

Both chat cases positively recorded the source waiting and the worker at
the brief gate before release. Both saved outputs include the brief-only
start time. The opt-in campaign is red because it exposes current
behavior. It is not a required merge gate. The PR does not fix that
product behavior. Semantic review is a recorded human/agent assessment
of retained evidence; it is not an automated prose-quality judge.
- Second campaign:
https://github.com/paperclipai/paperclip/actions/runs/36071065098. Codex
chat reached the idle boundary and completed its task, then received no
completion reply during the full window. Claude onboarding again
returned a stale handoff answer. Claude chat exceeded the prior
110-second handoff setup budget; this revision raises that bounded setup
window to 180 seconds.
- Retained baseline:
https://github.com/paperclipai/paperclip/actions/runs/36069427676.
Onboarding passed delivery/access for both providers, but Claude gave a
stale handoff answer. Chat cases stopped at fixture problems; they do
not establish a completion-delivery failure. This revision fixes the
workspace path and competing reference requirements.
- On the previous head `4023a2a3c28d45c9eb2c42d452ce99ffba5c7b73`, 54 PR
checks passed and two were skipped, including typecheck, tests, and
build. Broad checks ran in CI, not locally. That head received Greptile
5/5 with no unresolved findings. The unchanged mobile
repository-settings browser test passed on one targeted retry after a
detached/disabled Save-button timeout.

- Merged current master in `9b4491e1f` and resolved the catalog-count
conflict. Eval support tests and eval TypeScript pass locally. All
individual CI jobs passed on this merge commit, including build,
typecheck, server tests, runner checks, and browser shards. The final
aggregate check also passed: 54 checks passed and two were skipped.
Greptile reviewed this exact commit at 5/5 with no unresolved findings.

## Risks

- These explicit probes can expose current product failures. They do not
change the default paid test selection.
- Mechanical delivery and result access do not establish answer
accuracy. The preserved reply still requires semantic review.
- A fixture failure before the idle boundary or worker completion cannot
establish a completion-update failure.
- The handoff setup window lasts three minutes. The worker brief wait is
bounded at four minutes. The observation window lasts two minutes after
worker completion. It retains later replies without erasing earlier
accessible delivery.

## Model Used

OpenAI Codex, GPT-6 (`gpt-6-astra`), with reasoning, repository
inspection, code execution, and GitHub tool use. The runtime does not
expose the 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-09-28 09:58:14 -05:00

167 lines
12 KiB
TypeScript

import { expect, type Page } from "@playwright/test";
import { readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { resolveManagedProjectWorkspaceDir } from "../../server/src/home-paths.js";
import { pollUntil, type RunnerApi } from "./api.js";
import { sendChatMessage, readChatOutputDocument, collectChatRunEvidence, type ChatFlowInput, type ChatRun } from "./chat-flow.js";
import { prepareChatBrief } from "./chat-stories.js";
import { completionDelivery, completionOutputUsesReleasedBrief, type CompletionObservation } from "./completion-updates.js";
type Row = Record<string, any>;
export async function observeCompletionUpdate(input: {
page: Page; api: RunnerApi; sourceId: string; workerId: string; marker: string;
allRuns(): Promise<Row[]>;
evidence(name: string, data: unknown): Promise<void>;
capture(id: string, label: string, file: string): Promise<void>;
}) {
const startedAt = new Date().toISOString();
const observationWindowEndsAt = Date.now() + 120_000;
let observation: CompletionObservation | undefined;
let failure: unknown;
try {
await pollUntil({
label: "unsolicited source-thread completion reply and result access",
// Keep observing even after an early reply, so a later correction is retained.
deadlineAt: observationWindowEndsAt + 5_000, intervalMs: 1000,
load: async () => {
const worker = await input.api.get<Row>(`/api/issues/${input.workerId}`);
const documents = await input.api.get<Row[]>(`/api/issues/${input.workerId}/documents`);
observation = {
sourceId: input.sourceId, worker, marker: input.marker,
documents: await Promise.all(documents.map(d => input.api.get<Row>(`/api/issues/${worker.id}/documents/${encodeURIComponent(d.key)}`))),
comments: await input.api.get<Row[]>(`/api/issues/${input.sourceId}/comments?order=asc`),
runs: await input.allRuns(),
};
observation.renderedLinks = [];
for (const response of completionDelivery(observation).responses) {
const reply = input.page.locator(`[id=${JSON.stringify(`comment-${response.id}`)}]`);
observation.renderedLinks.push(...(await reply.locator("a[href]").evaluateAll(elements =>
elements.map(element => element.getAttribute("href")!))).map(href => ({ commentId: response.id, href })));
}
return completionDelivery(observation);
},
accept: result => Date.now() >= observationWindowEndsAt && result.checks.every(c => c.passed),
reject: () => observation!.runs.length > 12 ? "completion probe exceeded 12 runs" : undefined,
timeoutDetail: result => result?.checks.filter(c => !c.passed).map(c => c.id).join(", "),
});
const delivery = completionDelivery(observation!);
if (!delivery.response) throw new Error("Completion observation lost its source reply");
// Verify browser persistence, not just an API comment. No new user input.
await input.page.reload({ waitUntil: "domcontentloaded" });
const reply = input.page.locator(`[id=${JSON.stringify(`comment-${delivery.response.id}`)}]`);
await expect(reply).toBeVisible();
if (delivery.resultLinks.length) {
const link = delivery.resultLinks[0]!;
const url = new URL(link, input.api.baseURL);
expect(url.origin).toBe(new URL(input.api.baseURL).origin);
await expect(reply.locator(`a[href=${JSON.stringify(link)}]`).first()).toBeVisible();
const sourceUrl = input.page.url();
try {
// A client-side route can return HTTP 200 even when the task is missing.
// Open the actual rendered target and prove that its task loaded.
await input.page.goto(url.href, { waitUntil: "domcontentloaded" });
await expect(input.page.getByRole("heading", { name: String(observation!.worker.title), exact: true })).toBeVisible();
const accessibleWorker = await input.api.get<Row>(`/api/issues/${encodeURIComponent(observation!.worker.identifier ?? input.workerId)}`);
expect(accessibleWorker.id).toBe(input.workerId);
const accessibleOutput = await readChatOutputDocument(input.api, accessibleWorker.id, input.marker);
expect(observation!.documents.some(d => d.id === accessibleOutput.id && d.body === accessibleOutput.body)).toBe(true);
} finally {
await input.page.goto(sourceUrl, { waitUntil: "domcontentloaded" });
await expect(reply).toBeVisible();
}
} else {
await expect(reply).toContainText(input.marker);
}
return observation!;
} catch (error) {
failure = error;
throw error;
} finally {
const evidenceErrors: string[] = [];
const preserve = async (label: string, collect: () => Promise<void>) => {
try { await collect(); }
catch (error) { evidenceErrors.push(`${label}: ${error instanceof Error ? error.message : String(error)}`); }
};
await preserve("observation", () => input.evidence("completion-update.json", {
schema: "paperclip.completion-update-probe.v6", startedAt, finishedAt: new Date().toISOString(),
observation, delivery: observation ? completionDelivery(observation) : null,
observedFailure: failure instanceof Error ? failure.message : null,
}));
await preserve("screenshot", () => input.capture("completion-update", "Originating thread after delegated completion", "completion-update.png"));
if (observation) await preserve("run evidence", async () => {
const results = await Promise.allSettled(observation!.runs.map(run => collectChatRunEvidence(input.api, run as ChatRun)));
await input.evidence("completion-update-run-evidence.json", results.map((result, index) => {
if (result.status === "fulfilled") return result.value;
const error = result.reason instanceof Error ? result.reason.message : String(result.reason);
evidenceErrors.push(`run ${observation!.runs[index]!.id}: ${error}`);
return { runId: observation!.runs[index]!.id, evidenceError: error };
}));
});
if (evidenceErrors.length) {
await input.evidence("completion-update-evidence-errors.json", { errors: evidenceErrors }).catch(() => {});
// Preserve the behavior failure. A successful probe still needs its evidence.
if (!failure) throw new Error(`Completion evidence collection failed: ${evidenceErrors.join("; ")}`);
}
}
}
export async function runChatCompletionUpdate(context: {
input: ChatFlowInput; marker: string; allRuns(): Promise<ChatRun[]>; refreshIssue(): Promise<void>; issue(): { id: string };
}) {
const { input, marker } = context;
const { api, fixtures: f, execution, page } = input;
const company = `/api/companies/${f.company.id}`;
const config = execution.profile.buildAgent({ environmentId: f.environment.id, environmentFixtureId: "local", workspacePath: input.workspacePath, secretRefs: f.secretRefs, executionId: input.nonce });
const worker = await api.post<Row>(`${company}/agents`, { ...config, name: "Riley Writer", role: "engineer", reportsTo: f.agent.id });
const project = await api.post<Row>(`${company}/projects`, { name: "Garden welcome", description: "A non-code neighborhood garden meetup. No repository needed." });
// A project task runs in its managed project workspace. The agent-home path
// used by projectless chats is outside the native Codex workspace projection.
const workspace = resolveManagedProjectWorkspaceDir({ companyId: f.company.id, projectId: project.id });
const relative = path.relative(path.dirname(input.workspacePath), workspace);
if (relative.startsWith("..") || path.isAbsolute(relative)) throw new Error("Completion fixture escaped isolated instance");
const wait = await prepareChatBrief(workspace, input.nonce, 240_000);
const reference = marker;
const instructions = `For the welcome-note assignment, run node ${wait.scriptPath} to read the organizer's brief before writing the final note. Save a two-sentence welcome note as a Paperclip document on your assigned task using the brief's details and reference. Then complete your task. Do not edit or comment on another task.`;
const saved = await api.request.put(`/api/agents/${worker.id}/instructions-bundle/file`, { data: { path: "AGENTS.md", content: instructions } });
expect(saved.ok()).toBe(true);
expect(await api.get(`/api/agents/${worker.id}/instructions-bundle/file?path=AGENTS.md`)).toMatchObject({ content: instructions });
const prompt = `Create one task in the Garden welcome project (${project.id}) assigned to Riley Writer to write a two-sentence welcome note for our free Friday garden meetup. Riley has the organizer's brief. Save the finished note on that task and include ${marker}. Please tell me here when the work is finished and give me access to the result. You may start the handoff now; no further approval is needed. Let Riley write the note.`;
let task: Row | undefined;
try {
await sendChatMessage(page, prompt);
await pollUntil({ label: "worker waiting while originating chat is idle", deadlineAt: Date.now() + 180_000, intervalMs: 1000,
load: async () => {
await context.refreshIssue();
const source = await api.get<Row>(`/api/issues/${context.issue().id}`);
const tasks = await api.get<Row[]>(`${company}/issues`);
task = tasks.find(t => t.assigneeAgentId === worker.id);
const runs = await context.allRuns();
return { source, tasks, runs, ready: await readFile(wait.ready, "utf8").catch(() => "") };
},
accept: state => Boolean(task) && state.ready === "waiting" && state.source.conversationState === "waiting" &&
state.runs.some(r => r.contextSnapshot?.issueId === task!.id && r.status === "running") &&
state.runs.some(r => r.contextSnapshot?.issueId === state.source.id && r.status === "succeeded") &&
!state.runs.some(r => r.contextSnapshot?.issueId === state.source.id && ["queued", "running"].includes(r.status)),
});
expect(task!.parentId).toBeNull();
expect(task!.projectId).toBe(project.id);
await input.evidence("completion-update-boundary.json", { task, source: await api.get(`/api/issues/${context.issue().id}`), runs: await context.allRuns(), gateReady: true, prompt, reference });
await input.capture("completion-idle", "Chat is idle while Riley waits for the brief", "completion-idle.png");
await writeFile(wait.gate, `The free Friday meetup starts at 10:30 in the community garden. Reference: ${reference}.`);
await pollUntil({ label: "delegated welcome note completed", deadlineAt: Date.now() + 180_000, intervalMs: 1000,
load: () => api.get<Row>(`/api/issues/${task!.id}`), accept: t => t.status === "done" });
const output = await readChatOutputDocument(api, task!.id, marker);
await input.evidence("completion-update-worker-output.json", { task: await api.get(`/api/issues/${task!.id}`), output });
await observeCompletionUpdate({ ...input, sourceId: context.issue().id, workerId: task!.id, marker, allRuns: context.allRuns });
// Always capture completion delivery before grading how the worker phrased the brief.
expect(completionOutputUsesReleasedBrief(output.body), "worker output must use the start time supplied only in the released brief").toBe(true);
expect((await api.get<Row[]>(`${company}/issues`)).map(t => t.id)).toEqual([task!.id]);
expect(await api.get(`/api/issues/${task!.id}/documents/${encodeURIComponent(output.key)}`)).toEqual(output);
const comments = await api.get<Row[]>(`/api/issues/${context.issue().id}/comments?order=asc`);
expect(comments.filter(c => c.authorUserId).map(c => c.body)).toEqual([prompt]);
} finally {
await writeFile(wait.gate, `Reference: ${reference}`);
await context.refreshIssue();
}
}