mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-15 18:56:05 +01:00
## 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>
84 lines
8.1 KiB
TypeScript
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);
|
|
});
|