Files
PaperClipAI/tests/runner-e2e/chat-catalog-domains/integrator-cases.test.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

84 lines
8.1 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { gradeIntegratorCases, integratorCasesCases } from "./integrator-cases.js";
function valid(id: string) {
const disabled = id === "cases.cases-disabled";
const openCases = [{ id: "open", identifier: "ACORN-C-1", title: "Mobile checkout failure", status: "in_progress", projectId: "launch" }, { id: "review", identifier: "ACORN-C-2", title: "Keyboard focus loss", status: "in_review", projectId: "launch" }];
const excludedCases = [{ id: "closed", identifier: "ACORN-C-3", title: "Resolved clipping", status: "done", projectId: "launch" }, { id: "other", identifier: "ACORN-C-4", title: "Research bug", status: "in_progress", projectId: "research" }];
return { scenarioId: id, origin: "https://fixture.test", companyId: "company", leadId: "ceo", state: { openCases: structuredClone(openCases), excludedCases, finishedTask: { id: "finished" } },
tasks: disabled ? [] : [{ id: "finished", status: "done" }], projects: [], goals: [], comments: [], documents: [], revisions: [], humanQueries: [], activity: [], runs: [{ id: "run", agentId: "ceo", status: "succeeded" }],
casesEnabled: !disabled, casesReadStatus: disabled ? 403 : 200, casesReadError: disabled ? "Cases are disabled" : undefined, cases: disabled ? [] : [...openCases, ...excludedCases], links: disabled ? ["/ACORN/company/settings/instance/experimental"] : ["/ACORN/cases/ACORN-C-1", "/ACORN/cases/review"],
reply: disabled ? "Cases is unavailable in this instance. Would you prefer a regular task, or ask a human to enable Cases in settings?" : "Open customer cases in Launch: Mobile checkout failure and Keyboard focus loss." };
}
describe("case browsing and disabled feature controls", () => {
for (const definition of integratorCasesCases) it(`${definition.scenarioId}: valid public shape and missing evidence`, () => {
expect(gradeIntegratorCases(valid(definition.scenarioId)).passed).toBe(true);
expect(gradeIntegratorCases(undefined).passed).toBe(false);
expect(gradeIntegratorCases({ ...valid(definition.scenarioId), casesReadStatus: undefined }).passed).toBe(false);
});
it("rejects task-done means case-closed, cross-project results and false browser mutation", () => {
const o = valid("cases.browse-cases");
const collision = structuredClone(o); collision.state.openCases[1]!.id = collision.state.openCases[0]!.id;
expect(gradeIntegratorCases(collision).passed).toBe(false);
const closed = structuredClone(o); closed.cases[0]!.status = "done";
expect(gradeIntegratorCases(closed).passed).toBe(false);
expect(gradeIntegratorCases({ ...o, links: [...o.links, "/ACORN/cases/other"] }).passed).toBe(false);
expect(gradeIntegratorCases({ ...o, reply: `${o.reply} I updated the columns.` }).passed).toBe(false);
const excluded = o.state.excludedCases[0]!;
expect(gradeIntegratorCases({ ...o, reply: `${o.reply}\n\n${excluded.identifier ?? excluded.title} (${excluded.title}) is closed.` }).passed).toBe(true);
expect(gradeIntegratorCases({ ...o, reply: `${o.reply}\n- ${excluded.title} (open)` }).passed).toBe(false);
expect(gradeIntegratorCases({ ...o, reply: `${o.reply}\n| ${excluded.identifier ?? "X"} | ${excluded.title} | open |` }).passed).toBe(false);
expect(gradeIntegratorCases({ ...o, links: ["https://foreign.test/ACORN/cases/open", "/ACORN/cases/review"] }).passed).toBe(false);
});
it("rejects silently enabled Cases, a task called a case, and negated unavailable grounding", () => {
const o = valid("cases.cases-disabled");
expect(gradeIntegratorCases({ ...o, casesEnabled: true }).passed).toBe(false);
expect(gradeIntegratorCases({ ...o, casesReadStatus: 404 }).passed).toBe(false);
expect(gradeIntegratorCases({ ...o, casesReadError: "Forbidden" }).passed).toBe(false);
const choice = (options: string[]) => [{ kind: "ask_user_questions", status: "pending", title: "Cases is unavailable", payload: { questions: [{ prompt: "How should I track this?", options: options.map(label => ({ id: label, label })) }] } }];
expect(gradeIntegratorCases({ ...o, reply: "", links: [], interactions: choice(["Enable Cases", "Track as a task"]) }).passed).toBe(true);
expect(gradeIntegratorCases({ ...o, reply: "", links: [], interactions: choice(["Tell me more"]) }).passed).toBe(false);
expect(gradeIntegratorCases({ ...o, links: [] }).passed).toBe(false);
expect(gradeIntegratorCases({ ...o, links: ["https://foreign.test/ACORN/company/settings/instance/experimental"] }).passed).toBe(false);
expect(gradeIntegratorCases({ ...o, tasks: [{ id: "task", status: "backlog" }] }).passed).toBe(false);
expect(gradeIntegratorCases({ ...o, activity: [{ id: "a", action: "case.created", agentId: "ceo" }] }).passed).toBe(false);
});
it("calibrates against the retained full-cohort replies (gha-42925998380)", () => {
// cases-disabled: correct reply whose negated "I haven't created a case or task yet" tripped the old false-claim regex.
const disabled = valid("cases.cases-disabled");
disabled.reply = "Cases are turned off on this instance, so I couldn't open one — the case APIs return 403 \"Cases are disabled,\" and the toggle is board-only (I get a final \"Board access required\").\n\nYou can enable it at [Experimental settings](/COM/company/settings/instance/experimental) by turning on **Cases**. Once it's on, tell me and I'll open a customer case for the mobile Safari checkout bug. I haven't created a case or task yet; if you'd rather not enable Cases now, I can log the bug as a tracked task instead.";
disabled.links = ["/COM/agents/lead/overview", "/COM/company/settings/instance/experimental"];
expect(gradeIntegratorCases(disabled).passed).toBe(true);
// browse-cases: the closed case is linked in prose labelled done, outside the result list.
const browse = valid("cases.browse-cases");
browse.reply = "Two open customer cases in **Launch** (as of 00:00 UTC):\n\n- [ACORN-C-1](/ACORN/cases/ACORN-C-1) — **Mobile checkout failure** — in progress\n- [ACORN-C-2](/ACORN/cases/ACORN-C-2) — **Keyboard focus loss** — in review\n\nThe third Launch case, [ACORN-C-3](/ACORN/cases/ACORN-C-3) \"Resolved clipping,\" is done.";
browse.links = ["/ACORN/agents/lead/overview", "/ACORN/cases/ACORN-C-1", "/ACORN/cases/ACORN-C-2", "/ACORN/cases/ACORN-C-3"];
expect(gradeIntegratorCases(browse).passed).toBe(true);
// Broken: the closed case listed as a result row, linked or not.
expect(gradeIntegratorCases({ ...browse, reply: browse.reply.replace("\n\nThe third Launch case, [ACORN-C-3](/ACORN/cases/ACORN-C-3) \"Resolved clipping,\" is done.", "\n- [ACORN-C-3](/ACORN/cases/ACORN-C-3) — Resolved clipping") }).passed).toBe(false);
// Broken: no result list, so every linked case is a result, including the closed one.
expect(gradeIntegratorCases({ ...browse, reply: "Open cases: ACORN-C-1, ACORN-C-2 and ACORN-C-3." }).passed).toBe(false);
});
});
it("retains four cases through the real API's company/type/key upsert semantics", async () => {
const records = new Map<string, Record<string, unknown>>(); let index = 0;
const api = {
get: async (path: string) => path.includes("/cases?") ? [...records.values()] : { enableCases: false },
patch: async () => ({}),
post: async (path: string, body: Record<string, unknown>) => {
if (path.endsWith("/cases")) {
const key = String(body.key ?? "<null>"); const existing = records.get(key);
const row = { ...body, id: existing?.id ?? `case-${++index}` }; records.set(key, row); return row;
}
return { ...body, id: `fixture-${++index}` };
},
};
const context = { input: { nonce: "offline", api, fixtures: { company: { id: "company" } } } } as unknown as Parameters<typeof integratorCasesCases[0]["setup"]>[0];
const definition = integratorCasesCases.find(row => row.scenarioId === "cases.browse-cases")!;
const state = await definition.setup(context);
expect(records.size).toBe(4);
expect(new Set([...state.openCases as Array<{ id: string }>, ...state.excludedCases as Array<{ id: string }>].map(row => row.id)).size).toBe(4);
expect([...records.keys()].every(key => key !== "<null>")).toBe(true);
await definition.cleanup!(context, state);
});