Files
PaperClipAI/tests/runner-e2e/chat-catalog-domains/runtime-handoffs.ts
T
DottaandClaude Opus 5.5 8e332ccb5e fix(evals): name stale SDK pins and fix four chat-eval harness defects (#20579)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The chat feature eval catalog (`tests/runner-e2e`) runs real chat
journeys against a real server and grades the saved state, the run's
tool events and the rendered reply
> - The full-cohort run on master `f4337bb5fb` had 12 failures that come
from the harness, not from the product or the model
> - Six plugin and secrets cells stopped in 7–9 s, before the server
started, because the SDK public source pins were stale
> - Four more cells failed on harness timing, a first-visit prompt, or a
read receipt that ignored a dedicated tool
> - This pull request fixes those four harness defects and adds a cheap
guard that names stale SDK pins
> - The benefit is that these cells now report on the product and the
model again

## Linked Issues or Issue Description

Refs #19614, #20550, #20453, #20417, #20483

**What happened**

On master `f4337bb5fb`, these chat eval cells failed for harness
reasons:

- `plugins.configure-plugin`, `plugins.disable-plugin`,
`plugins.install-plugin`, `plugins.use-plugin-tool`,
`routines.plugin-managed-routine` and `secrets.least-scope-delivery`
stopped before the server started. The source SDK privacy scan refused,
because `tests/runner-e2e/source-sdk-public-inputs.json` did not match
`packages/shared/src`. #20550 and #20362 already resynced the pins on
master. The pins are current at `ce1ee9e89f`.
- `agent-chat.track-delegated-work` and
`navigation-preferences.edit-profile`: the reply was correct and had the
right link. The case read the rendered links immediately after the runs
settled, before the reply rendered, so it recorded no links.
- `inbox-search.archive-done` and `inbox-search.restore-archive`: setup
completes five tasks, so the GitHub star prompt from #20453 opened over
the chat and blocked the composer.
- `status-cards.explain-card`: the follow-up run read the deleted source
with `get_task` and received a 404. The reply said so. `apiReadStatuses`
counted only `call_api` reads, so the grader recorded no unavailable
read.

Why the existing pin check did not stop the drift: `ci / Runner support
suite` runs the source SDK tests and is part of the required `ci /
verify`. On #19614 it passed at 03:43Z on a merge ref from before #20417
(06:12Z) and #20483 (08:14Z). #19614 merged at 14:02Z without a new CI
run on current master. After that, the suite failed on every PR until
#20550. So the check exists and fires; the gap was a stale green result,
and the failure message was opaque (`SDK public source proof
unavailable`).

**Expected behavior**

- A change to a pinned SDK input without a pin refresh fails with the
changed files and the command that fixes it.
- Cases read rendered links only after the saved reply renders.
- The eval browser is a returning user's browser that has already seen
the star prompt. The product does not change.
- A dedicated task read tool (`get_task`, `list_task_documents`,
`read_task_document`, also when namespaced) counts as a read of the
record it names, with the status its receipt reports.

**Steps to reproduce**

1. Run `chat-features.runner-opencode.local.<case>` for one of the cases
above on master `f4337bb5fb`.
2. Read `result.json` and `snapshots/chat-catalog-observation.json` in
the evidence bundle.
3. See the failures described above.

## What Changed

- `tests/runner-e2e/source-sdk-public-inputs.mjs`: add
`staleSdkPublicInputs(root)`. It compares the registry with `git ls-tree
HEAD` for every pinned input (the roots and build inputs that the pins
file lists), and returns each changed, unpinned or removed file.
- `tests/runner-e2e/source-sdk-public-inputs.test.mjs`: a test fails
when the committed pins do not match the committed tree. The message
names the files and gives the `--write` command. The Runner support
suite runs it for every `packages/**` change. A synthetic test covers
changed, unpinned and removed inputs.
- `tests/runner-e2e/chat-catalog-domains/runtime-handoffs.ts` and
`identity-profile.ts`: call the existing `waitForRenderedReply` before
they read rendered links.
- `tests/runner-e2e/chat-returning-browser.ts` (new) and
`chat-catalog-flow.ts`: inside the flow's failure boundary, before
setup, mark `paperclip.github-star-prompt.seen` in the eval browser. An
init script covers later documents of the app origin, and the open page
is marked directly. A test checks that the key equals
`STAR_PROMPT_SEEN_KEY` in `ui/src/components/GitHubStarPrompt.tsx`.
- `tests/runner-e2e/chat-catalog-domains/api-read-receipts.ts`: bind
dedicated task read tools by the digest of the `call_api` input that the
server builds for them (`taskBridgeApiRequest`). The result must carry
the same `apiOperationId`. Other tools, other records, mismatched
receipts and unpaired results do not count.
- Bump `oracleVersion` in code and descriptor for every case that uses
`api-read-receipts.ts` (shared grader change) and for
`navigation-preferences.edit-profile`. Add
`internal-comment-reference.ts` to the source files of the two cases
that now import it.

## Verification

- `pnpm exec vitest run --config tests/runner-e2e/vitest.config.ts`
(whole config): 11301 passed; 1 local-only failure in
`controller-replacement-smoke.test.ts` (server spawn, no changed
imports).
- `pnpm exec vitest run --config tests/runner-e2e/vitest.config.ts
--testTimeout=180000 tests/runner-e2e/chat-catalog-domains
tests/runner-e2e/chat-catalog-contract.test.ts
tests/runner-e2e/chat-catalog-consistency.test.ts
tests/runner-e2e/chat-returning-browser.test.ts`
- `pnpm exec vitest run --config tests/runner-e2e/vitest.config.ts
source-sdk`
- `node tests/runner-e2e/source-sdk-public-inputs.mjs $(git rev-parse
HEAD) --check` reports `changed: false`.
- Red checks: with the `f4337bb5fb` registry, the new pin test fails and
names 15 inputs. Without the new waits, the two rendered-reply tests
fail. A regrade of the retained `explain-card` run events now gives the
follow-up run `unavailable: true, live: false` and the first run `live:
true`.
- Paid runs of the affected cells
(`chat-features.runner-opencode.local.*`):
- Run 42852698879 (head `d54c04d5f0`): `track-delegated-work`,
`restore-archive`, `explain-card`, `configure-plugin` and
`plugin-managed-routine` pass. I read each reply and saved state.
`track-delegated-work` now records the rendered `/COM/issues/COM-2`
link. `explain-card` records `laterUnavailable: true, laterLive: false`
from the follow-up `get_task` 404.
- `plugins.install-plugin` now starts its server (the pin refusal is
gone). It fails a later grading check, `truthful-installation-handoff`:
the reply sends the person to the plugin settings page but does not name
the package. This is outside this PR.
- `inbox-search.archive-done`: the star prompt no longer blocks the
composer, and the query is sent. The agent then archives the 501 Done
tasks one at a time (about 2 s each, 57 in the 240 s window), because
only the per-task archive route exists. This is outside this PR.
- `navigation-preferences.edit-profile` did not start in that run (the
AWS credentials step timed out). Run 42857020103 (head `2f0b80aa84`):
pass. The agent write gets 401, the reply links
`/COM/company/settings/instance/profile`, and the person follows the
rendered link and saves the name.

## Risks

- Low risk. Test harness only; no product code changes.
- The receipt change counts more reads as record reads. It only adds
dedicated reads bound to the exact record id and operation, so a grader
that needs a read can pass on a genuine `get_task` read. The affected
cases have new oracle versions.
- The star prompt is not tested by any chat catalog case. Product e2e
tests for the prompt do not use this flow.

## Model Used

- Anthropic Claude Opus 5.5 (`claude-opus-5-5`), via Claude Code with
tool use (shell, file editing, test 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-10 11:01:44 -06:00

154 lines
15 KiB
TypeScript

import { chatCatalogNativePhaseDeadline, chatCatalogNativePhaseIsTimely, chatCatalogOriginalPhaseDeadline } from "../chat-catalog-contract.js";
import { expect } from "@playwright/test";
import { writeFile } from "node:fs/promises";
import { pollUntil } from "../api.js";
import { sendChatMessage } from "../chat-flow.js";
import { observeCompletionUpdate } from "../completion-update-flow.js";
import { completionDelivery } from "../completion-updates.js";
import { collectRunEvents } from "../run-observations.js";
import { apiReadDigests, apiReadStatuses, recordParams, type ApiRead } from "./api-read-receipts.js";
import { checks, hasLink, type Entity } from "./internal-support.js";
import { waitForRenderedReply } from "./internal-comment-reference.js";
import { openStandingChat } from "./identity-support.js";
import { prepareRuntimeWorkerGate, readRuntimeTaskGate, releaseRuntimeWorker } from "./runtime-busy-worker.js";
import { runtimeDefinition, runtimeObservation, observeRuntime, type RuntimeAgent } from "./runtime-support.js";
/** A completed query-run read; `mentionsTask` is true only when it is tied to the delegated task or Morgan's run. */
export interface QueryToolRead { runId: string; name: string; mentionsTask: boolean }
/** call_api reads of the delegated task or Morgan's run, bound by their saved input digest (bodies are redacted). */
export function delegatedWorkReads(task: Entity, workerRunId: string | undefined): ApiRead[] {
const params = recordParams("id", task.id, task.identifier);
return [
...["GET /api/issues/{id}", "GET /api/issues/{id}/runs", "GET /api/issues/{id}/comments", "GET /api/issues/{id}/activity"].map(operationId => ({ operationId, pathParams: params, paged: true })),
{ operationId: "GET /api/heartbeat-runs/{runId}", pathParams: recordParams("runId", workerRunId) },
];
}
export function gradeTrackDelegatedWork(value: unknown) {
const c = checks(); const o = runtimeObservation(value);
c.add("native-conversation-handoff-evidence", !!o, "The prior genuine handoff, real busy worker, canonical reply and eventual source update must all be observed."); if (!o) return c.result();
const task = o.state.task as Entity; const worker = o.state.worker as Entity;
c.add("original-conversation-created-one-work-task", o.state.createdFromSource === true && o.state.workerWasReallyWaiting === true
&& o.tasks.length === 1 && o.tasks[0]?.id === task.id && o.tasks[0]?.assigneeAgentId === worker.id
&& o.state.originalSourceRetained === true,
"A real prior source-chat native turn created exactly this task, with Morgan as its sole assignee; the standing chat remains the same conversation.");
// Wording is not graded: the answer's accuracy against the running state is semantic review.
const reads = Array.isArray(o.state.queryToolReads) ? o.state.queryToolReads as QueryToolRead[] : [];
c.add("canonical-status-uses-current-execution", o.state.workerStatusAtQueryReply === "running"
&& String(o.state.queryReply ?? "").trim().length > 0
&& reads.some(read => read.mentionsTask)
&& hasLink({ ...o, links: o.state.queryLinks as string[] }, "issues", task),
"While Morgan's run was still active, the canonical query's attributed answer was produced after the run read the task or its runs, and links the existing task.");
const delivery = o.state.delivery as ReturnType<typeof completionDelivery> | undefined;
c.add("completion-returns-to-original-request", o.state.completionDelivered === true && !!delivery && delivery.checks.every(check => check.passed)
&& o.tasks[0]?.status === "done" && o.runs.find(run => run.id === o.state.workerRunId)?.status === "succeeded"
&& o.documents.some(doc => (doc.body ?? "").includes(String(o.state.marker))),
"Independent completed output and the unsolicited source-scoped completion delivery agree after the genuine worker finishes.");
c.add("no-duplicate-or-parent-execution", o.runs.length === 4 && o.runs.filter(run => run.agentId === worker.id).length === 1
&& o.runs.every(run => run.status === "succeeded") && o.state.sourceAssigneePreserved === true,
"The only native work is the prior handoff, one Morgan execution, the canonical status reply and one automatic source update.");
return c.result();
}
async function queryToolReads(ctx: Parameters<typeof prepareRuntimeWorkerGate>[0], runIds: string[], task: Entity, workerRunId: string | undefined): Promise<QueryToolRead[]> {
const reads: QueryToolRead[] = [];
const digests = apiReadDigests(delegatedWorkReads(task, workerRunId));
for (const runId of runIds) {
const events = await collectRunEvents<{ seq?: number; payload?: Record<string, unknown> | null }>((afterSeq, limit) =>
ctx.input.api.getRunObservation(`/api/heartbeat-runs/${runId}/events?afterSeq=${afterSeq}&limit=${limit}`));
for (const read of apiReadStatuses(events as Record<string, unknown>[], digests)) if (read.ok && read.status >= 200 && read.status < 300) reads.push({ runId, name: "call_api", mentionsTask: true });
for (const event of events) {
const prp = event.payload?.prpEvent as { eventType?: string; payload?: { name?: unknown; status?: unknown; output?: unknown } } | undefined;
if (prp?.eventType !== "tool.execution.completed" || prp.payload?.status !== "completed" || typeof prp.payload.name !== "string") continue;
const output = typeof prp.payload.output === "string" ? prp.payload.output : JSON.stringify(prp.payload.output ?? "");
reads.push({ runId, name: prp.payload.name, mentionsTask: [task.id, task.identifier].some(id => !!id && output.includes(String(id))) });
}
}
return reads;
}
export const runtimeHandoffCases = [runtimeDefinition("agent-chat.track-delegated-work", async ctx => {
const prepared = await prepareRuntimeWorkerGate(ctx, { taskBound: true }); const source = await openStandingChat(ctx);
const marker = `Tracked website delivery ${ctx.input.nonce}`;
const instructions = `For the website assignment, first run ${prepared.gate.command} once and wait for the real human brief. Do not invent the brief or change the script. Once it returns, save a short website deliverable document with key website-delivery on the assigned task containing the supplied reference and complete that same task normally. Do not create other tasks or perform external actions.`;
await ctx.input.api.saveAgentInstructions(prepared.worker.id, instructions);
const prompt = `Create one task titled Website, assigned to Morgan, to draft our launch page, and have Morgan start on it right away (ready to work, not parked in backlog). I am giving Morgan the brief directly, outside this chat, and Morgan already knows how to receive it; it will include the reference ${marker}. Let Morgan implement it on that separate work task. Track the result here and tell me here when the work is complete, with access to the actual result. No additional task or project is needed.`;
await sendChatMessage(ctx.input.page, prompt);
const phase = await pollUntil({ label: "Actual conversation-created Morgan handoff", deadlineAt: chatCatalogNativePhaseDeadline(ctx, Date.now() + 180_000),
load: async () => {
await ctx.refreshIssue();
const tasks = await ctx.input.api.get<Entity[]>(`${prepared.company}/issues?includeAgentCreatedTasks=true&limit=1000`);
const candidates = tasks.filter(task => task.id !== source.id && task.assigneeAgentId === prepared.worker.id);
const runs = await ctx.allRuns();
// The task has no execution workspace until Morgan's run starts, so only then can the gate be bound.
const workerStarted = runs.some(run => run.agentId === prepared.worker.id);
return { source: ctx.issue(), tasks, runs, candidates,
waiting: candidates.length === 1 && workerStarted ? await readRuntimeTaskGate(ctx, prepared.gate, candidates[0]!.id, prepared.worker.id) : "" };
},
accept: value => chatCatalogNativePhaseIsTimely(ctx) && value.source?.conversationState === "waiting" && value.waiting === "waiting" && value.runs.length === 2
&& value.runs.some(run => run.agentId === prepared.worker.id && run.status === "running")
&& value.runs.some(run => run.agentId === ctx.input.fixtures.agent.id && run.status === "succeeded")
&& value.tasks.filter(task => task.id !== source.id).length === 1,
reject: value => value.runs.some(run => ["failed", "cancelled", "timed_out"].includes(run.status)) ? "Prior native handoff failed"
: value.runs.every(run => run.agentId !== prepared.worker.id && run.status === "succeeded") && value.candidates.length === 1
&& ["backlog", "blocked", "cancelled", "done"].includes(String(value.candidates[0]!.status)) ? `Prior native handoff left the Website task ${value.candidates[0]!.status}; Morgan never started` : undefined });
const task = phase.tasks.find(task => task.id !== source.id)!;
const created = await ctx.input.api.get<Entity[]>(`${prepared.company}/issues?createdFromIssueId=${source.id}&includeAgentCreatedTasks=true&limit=1000`);
if (task.assigneeAgentId !== prepared.worker.id || created.length !== 1 || created[0]?.id !== task.id) throw new Error("The saved work task is not the actual source conversation handoff");
const workerRun = phase.runs.find(run => run.agentId === prepared.worker.id)!;
if (![workerRun.contextSnapshot?.issueId, (workerRun as typeof workerRun & { nativeIssueId?: string }).nativeIssueId].includes(task.id)) throw new Error("Native worker attribution does not match the actual handoff task");
return { worker: prepared.worker, gate: prepared.gate, task, marker, prompt, sourceBefore: source,
sourceId: source.id, setupRunIds: phase.runs.map(run => run.id), workerRunId: workerRun.id, workerWasReallyWaiting: true, createdFromSource: true };
}, gradeTrackDelegatedWork, {
oracleVersion: "paperclip.chat-catalog.track-delegated-work.v3",
sourceFiles: ["chat-catalog-domains/runtime-handoffs.ts", "chat-catalog-domains/api-read-receipts.ts", "chat-catalog-domains/internal-comment-reference.ts", "chat-catalog-domains/runtime-busy-worker.ts", "chat-catalog-domains/runtime-support.ts", "completion-update-flow.ts", "completion-updates.ts", "run-observations.ts", "chat-scenarios/agent-chat.yaml"],
setupHumanMessageCount: 1, expectedHumanMessageCount: 2, expectedRunCount: 4, attemptTimeoutMs: 900_000,
setupPolicy: { readiness: "preserve-scenario", invariantCheckIds: ["lead-ready", "native-handoff-owner", "real-source-provenance"] },
async verifySetup(ctx, state) {
const c = checks(); const lead = await ctx.input.api.get<RuntimeAgent>(`/api/agents/${ctx.input.fixtures.agent.id}`);
const worker = await ctx.input.api.get<RuntimeAgent>(`/api/agents/${(state.worker as Entity).id}`);
const run = (await ctx.allRuns()).find(row => row.id === state.workerRunId);
c.add("lead-ready", lead.lifecycleState === "ready" && ["active", "idle"].includes(lead.status ?? ""), "The speaking agent is genuinely ready after its prior handoff.");
c.add("native-handoff-owner", worker.lifecycleState === "ready" && ["active", "running"].includes(worker.status ?? "")
&& run?.status === "running" && run.agentId === worker.id && run.companyId === ctx.input.fixtures.company.id,
"Morgan remains the genuine active native owner; no shared setup forces the worker idle.");
const created = await ctx.input.api.get<Entity[]>(`/api/companies/${ctx.input.fixtures.company.id}/issues?createdFromIssueId=${state.sourceId}&includeAgentCreatedTasks=true&limit=1000`);
c.add("real-source-provenance", created.length === 1 && created[0]?.id === (state.task as Entity).id,
"The public source provenance filter independently confirms this conversation created the actual work task.");
return c.result();
},
async act(ctx, state) {
const phase = await pollUntil({ label: "Canonical handoff status response", deadlineAt: chatCatalogNativePhaseDeadline(ctx, Date.now() + 150_000),
load: async () => { await ctx.refreshIssue(); return { source: ctx.issue(), runs: await ctx.allRuns() }; },
accept: value => chatCatalogNativePhaseIsTimely(ctx) && value.source?.conversationState === "waiting" && value.runs.length === 3
&& value.runs.find(run => run.id === state.workerRunId)?.status === "running"
&& value.runs.filter(run => run.agentId === ctx.input.fixtures.agent.id).every(run => run.status === "succeeded") });
const queryIds = phase.runs.filter(run => !(state.setupRunIds as string[]).includes(run.id)).map(run => run.id);
const comments = await ctx.input.api.get<Entity[]>(`/api/issues/${state.sourceId}/comments?order=asc`);
const replies = comments.filter(row => row.authorAgentId === ctx.input.fixtures.agent.id && queryIds.includes(row.createdByRunId ?? ""));
state.queryReply = replies.map(row => row.body ?? "").join("\n");
// Run settle precedes the reply's render in the open conversation; read
// rendered links only after each saved reply itself is on the page.
await waitForRenderedReply(ctx, { leadId: ctx.input.fixtures.agent.id, runs: phase.runs.filter(run => queryIds.includes(run.id)) });
const links: string[] = [];
for (const reply of replies) links.push(...await ctx.input.page.locator(`[id=${JSON.stringify(`comment-${reply.id}`)}] a[href]`).evaluateAll(nodes => nodes.map(node => node.getAttribute("href")!)));
state.queryLinks = links; state.workerStatusAtQueryReply = phase.runs.find(run => run.id === state.workerRunId)!.status;
state.queryToolReads = await queryToolReads(ctx, queryIds, state.task as Entity, typeof state.workerRunId === "string" ? state.workerRunId : undefined);
await writeFile((state.gate as { gate: string }).gate, `The real website brief: use a blue page with concise copy. Save a short task delivery document that includes exactly ${state.marker}, then complete this existing Website task.`, "utf8");
const delivered = await observeCompletionUpdate({ page: ctx.input.page, api: ctx.input.api, sourceId: String(state.sourceId), workerId: (state.task as Entity).id,
marker: String(state.marker), fixtureRequest: String(state.prompt), originalObservationDeadlineAt: chatCatalogOriginalPhaseDeadline(ctx), allRuns: () => ctx.allRuns(), evidence: ctx.input.evidence, capture: ctx.input.capture });
const delivery = completionDelivery(delivered); state.delivery = delivery; state.completionDelivered = !!delivery.response;
},
async observe(ctx, state) {
await ctx.refreshIssue(); const before = state.sourceBefore as { id: string; assigneeAgentId?: string | null };
state.originalSourceRetained = ctx.issue()?.id === before.id;
state.sourceAssigneePreserved = ctx.issue()?.assigneeAgentId === before.assigneeAgentId;
return observeRuntime(ctx, state);
},
async verifyResult(ctx, state) {
const source = ctx.input.page.url(); const task = state.task as Entity;
await ctx.input.page.goto(`/${ctx.input.fixtures.company.issuePrefix}/issues/${task.identifier ?? task.id}`, { waitUntil: "domcontentloaded" });
await ctx.input.page.reload({ waitUntil: "domcontentloaded" });
await expect(ctx.input.page.getByRole("heading", { name: task.title!, exact: true }).first()).toBeVisible();
await ctx.input.page.goto(source, { waitUntil: "domcontentloaded" });
},
cleanup: async (_ctx, state) => releaseRuntimeWorker(state),
})];