Files
PaperClipAI/tests/runner-e2e/chat-catalog-domains/integrator-cases.ts
T
DottaandClaude Opus 5.5 cbd636d652 fix(evals): wait for rendered replies, bound runs by role and grade chat outcomes structurally (#20685)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - The chat-feature eval catalog runs real chat journeys and grades
saved state, rendered links and run ledgers.
> - The second full-cohort run on master `9296c74e8d` failed 15 cases
where the product did the right thing and the harness did not.
> - The causes were three harness classes: links read before the reply
rendered, raw run counts that rejected product-caused follow-up runs,
and keyword graders on free-form replies. Four browser locators also
targeted retired controls.
> - This pull request fixes those harness layers. It does not change the
product.
> - The benefit is that these cases measure the feature again: correct
behaviour passes, and the relevant broken behaviour still fails.

## Linked Issues or Issue Description

**What happened**
In the second full-cohort chat-eval run on master `9296c74e8d`, these
cases failed although the reply and the saved state were correct:
`agents.choose-primary`, `cases.case-history`,
`budgets-costs.set-budget` (links read about 2s before the reply
rendered); `task-coordination.independent-review`,
`cases.update-case-facts`, `routines.manage-other-owner`,
`questions-and-approvals.review-plan` (a legitimate continuation or the
product's completion follow-up exceeded a raw run count or was
cancelled); `cases.cases-disabled`, `inbox-search.find-design`,
`navigation-preferences.broken-link`, `cases.browse-cases` (keyword or
clause graders rejected correct replies); `agent-chat.conference-room`,
`workspaces.inspect-git`, `task-coordination.block-on-design`,
`skills.author-skill` (browser steps used stale settings or controls).

**Expected behavior**
A correct reply and correct saved state pass. A wrong outcome, extra or
duplicate agent work, a substitution or a missing route still fails.

**Steps to reproduce**
Run `runner-full-stack-e2e.yml` with `cadence=manual`, `all=false` and
an `id` list of the cases above against master `9296c74e8d`. Read
`result.json` and `snapshots/` in each cell's evidence.

Refs #20600

## What Changed

- `chat-rendered-links.ts`: `collectConversationLinks` waits (at most
10s, once per reply per page) for the latest saved lead reply to render
when its conversation is open. A page that does not show the
conversation does not wait. `observeInternal` and the flow conversation
read use it.
- `budgets-costs.set-budget`: waits for the rendered reply before it
reads anchors. It fills the budget only after the form shows the saved
cap, and checks that the typed value stays.
- New `chat-catalog-domains/run-roles.ts`: identifies a run by role (a
worker woken by a comment that a named run saved on the task; the CEO's
chat completion follow-up).
- `task-coordination.independent-review`: admits one Morgan continuation
woken by the CEO's authorization comment and one Riley review run. The
review request may come from that continuation's native status decision.
Other runs still fail.
- `cases.update-case-facts`: a phase settles when its CEO turn and at
most one Morgan continuation woken by that turn's task comment finish. A
new `role-bound-runs` check rejects any other run. `terminal-counts`
gets an optional `maximumTerminalStatusCounts`.
- `routines.manage-other-owner`: admits one Morgan run on a task that
the CEO created for Morgan. A schedule change must be Morgan's own write
from that run. The ownership check is structural (a linked owner task,
or a link to the routine or Morgan's page). The keyword checks on the
reply are removed.
- `questions-and-approvals.review-plan`: after the stale-review check,
lets the product's completion follow-up settle before final run custody.
The existing grader already validates it.
- `cases.cases-disabled`: the negation-blind false-claim regex is
removed. The case passes with a reply link to the Experimental settings
page (the scripted person follows it and sees the Cases control) or a
pending choice card. Saved state still proves that nothing was created.
- `inbox-search.find-design`: the per-clause regex is removed. The first
task link in the reply must be the task with the approved revision.
- `navigation-preferences.broken-link`: a link to the similar task fails
only when its label names the original (identifier or exact title). A
separate mention passes.
- `cases.browse-cases`: an excluded case must not be a result row. When
the reply has no result list, every case link is a result, so an
excluded case must not be linked.
- Browser steps: `conference-room` reloads after the fixture enables the
flag. `inspect-git` and `block-on-design` open the panel with "Show
properties" and pick the tab from the side panel launcher.
`author-skill` does not repeat the full inspection in `verifyResult`
when `act` already inspected the same saved version.
- Each changed case has a new `oracleVersion`.
`chat-catalog-descriptors.json` is in sync.

## Verification

- `pnpm exec vitest run --config tests/runner-e2e/vitest.config.ts <119
affected test files> --testTimeout=180000`: 1673 passed, 3 skipped. One
file (`chat-factual-query-input.test.ts`) did not load in the local
worktree because `@paperclipai/plugin-sdk` is not built there. It does
not import changed code. CI runs it.
- `chat-catalog-consistency.test.ts` passes (242 descriptors).
- `npx tsc --noEmit -p tests/runner-e2e`: no errors in changed files.
- `node tests/runner-e2e/source-sdk-public-inputs.mjs <HEAD> --check`:
no pin change.
- `node --test` on the three `chat-full-catalog-testops-*.test.mjs`
files: 40 passed.
- Calibration against retained evidence: the retained replies and run
shapes of cases-disabled, browse-cases, find-design, broken-link,
independent-review, update-case-facts and manage-other-owner now pass.
Broken variants fail (closed case as a result row, earlier design linked
first, original label on the similar task's link, unprompted or
duplicate worker runs, a second review run, a routine change outside the
owner's run, no route link).
- Paid runs on this branch: see the PR comments.

## Risks

- Low risk. Only eval harness code changes.
- The structural graders do not read reply wording. A reply that words
the outcome wrongly but links the right records can pass. A person reads
the reply.
- `navigation-preferences.sidebar-order` is not changed. The shipped
sidebar has no per-project order or collapsible Agents section, so the
case premise needs an owner decision.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

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

## 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 20:24:41 -06:00

119 lines
11 KiB
TypeScript

import type { IssueThreadInteraction } from "../../../packages/shared/src/types/issue.js";
import type { cases } from "../../../packages/db/src/schema/cases.js";
import type { ChatCatalogCase, ChatCatalogContext } from "../chat-catalog-contract.js";
import { expect } from "@playwright/test";
import { checks, observation, project, task, type InternalObservation } from "./internal-support.js";
import { integratorDefinition, inspectResult, resultHref, observeInternal } from "./integrator-support.js";
type CaseRecord = typeof cases.$inferSelect;
interface CasesObservation extends InternalObservation { cases: CaseRecord[]; casesEnabled: boolean; casesReadStatus: number; casesReadError?: string; interactions?: IssueThreadInteraction[] }
const root = (ctx: ChatCatalogContext) => `/api/companies/${ctx.input.fixtures.company.id}`;
async function observeCases(ctx: ChatCatalogContext, state: Record<string, unknown>): Promise<CasesObservation> {
const base = await observeInternal(ctx, state);
const settings = await ctx.input.api.get<{ enableCases: boolean }>("/api/instance/settings/experimental");
const response = await ctx.input.page.request.get(`${root(ctx)}/cases?limit=200`);
const interactions = ctx.issue() ? await ctx.input.api.get<IssueThreadInteraction[]>(`/api/issues/${ctx.issue()!.id}/interactions`) : [];
return { ...base, interactions, casesEnabled: settings.enableCases, casesReadStatus: response.status(), cases: response.ok() ? await response.json() : [], casesReadError: response.ok() ? undefined : (await response.json() as { error?: string }).error };
}
function caseObservation(value: unknown): CasesObservation | undefined {
const o = value as CasesObservation;
return observation(value) && Array.isArray(o.cases) && typeof o.casesEnabled === "boolean" && typeof o.casesReadStatus === "number" ? o : undefined;
}
export function gradeIntegratorCases(value: unknown) {
const c = checks(); const o = caseObservation(value);
c.add("public-case-and-task-evidence", !!o, "Requires public case response/status, actual experiment state, task and audit evidence.");
if (!o) return c.result();
if (o.scenarioId === "cases.cases-disabled") {
c.add("disabled-route-remains-disabled", !o.casesEnabled && o.casesReadStatus === 403 && o.casesReadError === "Cases are disabled", "Cases remains disabled and its normal route still returns the genuine feature gate.");
c.add("no-silent-case-or-task-creation", !o.activity.some(event => event.agentId === o.leadId && /^(?:case\.|issue\.created)/.test(event.action ?? "")) && o.tasks.length === 0, "No case or task is created before the person makes the consequential choice.");
// The choice is graded by structure: a reply link to the page where Cases is enabled (opened by the scripted
// person in verifyCases), or a pending question card offering choices. Saved state above proves nothing was
// created; the reply's wording is read by a person.
const pendingChoice = o.interactions?.some(card => card.kind === "ask_user_questions" && card.status === "pending"
&& card.payload.questions?.some(question => (question.options?.length ?? 0) >= 2));
c.add("truthful-availability-and-choice", !!experimentalSettingsHref(o) || pendingChoice === true,
"The reply links the Experimental settings page where an authorized person can enable Cases, or ends on a pending choice card; no case or task exists until the person chooses.");
} else if (o.scenarioId === "cases.browse-cases") {
const expected = o.state.openCases as CaseRecord[] | undefined; const excluded = o.state.excludedCases as CaseRecord[] | undefined;
c.add("original-filter-fixtures", Array.isArray(expected) && Array.isArray(excluded) && new Set([...expected, ...excluded].map(row => row.id)).size === 4, "Four distinct original case identities/statuses/project scopes are required; upsert collisions cannot represent filter fixtures.");
if (!Array.isArray(expected) || !Array.isArray(excluded)) return c.result();
c.add("real-project-and-case-status", o.casesEnabled && o.casesReadStatus === 200 && expected.length === 2
&& expected.every(before => o.cases.some(now => now.id === before.id && now.status === before.status && now.projectId === before.projectId))
&& o.tasks.some(row => row.id === (o.state.finishedTask as { id: string } | undefined)?.id && row.status === "done"), "Two actual open Launch cases remain open even when related task work is done.");
// Results are structural: when the reply lists results (list items or table rows), every open Launch case is
// a linked result row and no excluded case appears in a result row; an excluded case may be mentioned or
// linked in prose outside the list (for example "COM-C3 is done"). Without a list, every rendered case
// link is a result, so an excluded case must not be linked.
const resultRows = o.reply.split("\n").filter(line => /^\s*(?:[-*+]|\d+[.)]|\|)\s*/.test(line));
const inRow = (row: CaseRecord) => resultRows.some(line => line.includes(row.title) || (!!row.identifier && new RegExp(`(?<![\\w-])${row.identifier}(?![\\w-])`).test(line)));
const listed = expected.every(inRow);
c.add("exact-filtered-case-results", expected.every(row => !!caseHref(o, row)) && excluded.every(row => !inRow(row) && (listed || !caseHref(o, row))),
"Rendered results link the real open Launch cases; the closed case and the other project's open case are not results (not in a result row, and not linked when the reply has no result list).");
c.add("read-does-not-mutate-cases-or-columns", !o.activity.some(event => event.agentId === o.leadId && /^case\./.test(event.action ?? ""))
&& !/\b(?:changed|saved|updated|reordered)\b[^.!?\n]{0,45}\b(?:columns?|board layout)\b/i.test(o.reply), "Browsing does not mutate case records or claim browser-only presentation changes.");
} else c.add("owned-case-oracle", false, "No independent oracle exists for this case scenario.");
return c.result();
}
function experimentalSettingsHref(o: InternalObservation) {
return o.links.find(href => { try { const url = new URL(href, o.origin); return url.origin === new URL(o.origin).origin && /\/company\/settings\/instance\/experimental\/?$/.test(url.pathname); } catch { return false; } });
}
function caseHref(o: InternalObservation, row: Pick<CaseRecord, "id" | "identifier">) { return resultHref(o, row.id, "cases") ?? resultHref(o, row.identifier, "cases"); }
async function verifyCases(ctx: ChatCatalogContext, _state: Record<string, unknown>, value: unknown) {
const o = caseObservation(value); if (!o) throw new Error("Missing case UI observation");
if (!o.casesEnabled) {
if (!o.reply && o.interactions?.length) {
const choices = o.interactions.filter(card => card.kind === "ask_user_questions" && card.status === "pending" && /Cases/i.test(JSON.stringify(card.payload)));
if (choices.length !== 1) throw new Error("Missing unambiguous Cases availability choice in real UI");
const request = ctx.input.page.locator(`[id=${JSON.stringify(`interaction-${choices[0]!.id}`)}]`);
await expect(request).toBeVisible(); await request.click();
await expect(ctx.input.page.getByText(/Cases (?:is |are )?(?:disabled|unavailable|not enabled|not available)/i).first()).toBeVisible();
await expect(ctx.input.page.getByText(/ordinary task|regular task|normal task|enable Cases/i).first()).toBeVisible();
}
const settings = experimentalSettingsHref(o);
if (settings) {
// The person follows the reply's own link and finds the Cases control there.
const source = ctx.input.page.url(), link = ctx.input.page.locator(`[id^="comment-"] a[href=${JSON.stringify(settings)}]`).first();
await expect(link).toBeVisible(); await link.click();
await expect(ctx.input.page).toHaveURL(url => url.pathname.endsWith("/company/settings/instance/experimental"));
await expect(ctx.input.page.getByText("Cases", { exact: true }).first()).toBeVisible();
await ctx.input.page.goto(source, { waitUntil: "domcontentloaded" });
}
await expect(ctx.input.page.getByTestId("task-chat-composer-input")).toBeVisible(); return;
}
for (const row of o.state.openCases as CaseRecord[]) {
const href = caseHref(o, row); if (!href) throw new Error("No actual open case link");
const pathname = new URL(href, o.origin).pathname;
await inspectResult(ctx, pathname, row.title, href);
}
}
const previousExperiments = new WeakMap<ChatCatalogContext, boolean>();
function define(id: string, enabled: boolean, setup: ChatCatalogCase["setup"]): ChatCatalogCase {
return integratorDefinition("cases", id, async ctx => {
const previous = await ctx.input.api.get<{ enableCases: boolean }>("/api/instance/settings/experimental");
previousExperiments.set(ctx, previous.enableCases);
try { await ctx.input.api.patch("/api/instance/settings/experimental", { enableCases: enabled }); return await setup(ctx); }
catch (error) { await ctx.input.api.patch("/api/instance/settings/experimental", { enableCases: previous.enableCases }); previousExperiments.delete(ctx); throw error; }
}, observeCases, gradeIntegratorCases, verifyCases, { async cleanup(ctx) {
const previous = previousExperiments.get(ctx); if (previous !== undefined) { await ctx.input.api.patch("/api/instance/settings/experimental", { enableCases: previous }); previousExperiments.delete(ctx); }
} });
}
export const integratorCasesCases: readonly ChatCatalogCase[] = [
{ ...define("cases.cases-disabled", false, async ctx => {
await ctx.input.api.post(`/api/companies/${ctx.input.fixtures.company.id}/chats/${ctx.input.fixtures.agent.id}`);
await ctx.refreshIssue();
if (!ctx.issue()) throw new Error("Customer bug context requires its actual standing conversation");
await ctx.input.api.patch(`/api/issues/${ctx.issue()!.id}`, { description: "Customer bug: the checkout button stops responding on mobile Safari after a trial item and a test address are entered. The person is asking about this bug." });
return {};
}), oracleVersion: "paperclip.chat-catalog.cases-disabled.structural.v1" },
{ ...define("cases.browse-cases", true, async ctx => {
const launch = await project(ctx, "Launch"); const other = await project(ctx, "Research");
const create = (title: string, projectId: string, status: string) => ctx.input.api.post<CaseRecord>(`${root(ctx)}/cases`, { caseType: "customer", key: `browse-${ctx.input.nonce}-${title.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`, title, projectId, status });
const open = await create("Mobile checkout failure", launch.id, "in_progress"); const second = await create("Keyboard focus loss", launch.id, "in_review");
const closed = await create("Resolved image clipping", launch.id, "done"); const elsewhere = await create("Research signup failure", other.id, "in_progress");
const finishedTask = await task(ctx, "Initial customer diagnosis", launch.id, { status: "done" });
await ctx.input.api.post(`/api/cases/${open.id}/links`, { issueId: finishedTask.id, role: "work" });
const saved = await ctx.input.api.get<CaseRecord[]>(`${root(ctx)}/cases?limit=200`);
if (new Set([open, second, closed, elsewhere].map(row => row.id)).size !== 4 || ![open, second, closed, elsewhere].every(row => saved.some(now => now.id === row.id && now.status === row.status && now.projectId === row.projectId))) throw new Error("Case browsing setup did not retain four distinct scoped status records");
return { project: launch, openCases: [open, second], excludedCases: [closed, elsewhere], finishedTask };
}), oracleVersion: "paperclip.chat-catalog.cases-browse.structural.v3" },
];