mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-14 08:53:19 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The chat-feature eval catalog checks that a chat agent does the work a person asks for > - In the latest full-cohort run on master, 16 cases failed although the saved work was correct > - Their graders matched words, one literal value, or one API route in free-form model output > - A grader that fails correct work hides real failures in the noise, and it can also pass wrong work that uses the expected word > - This pull request grades those cases by saved state, audit records, setup literals and rendered page structure, and keeps only narrow checks for false claims > - The benefit is that these cases fail only when the product or agent does the wrong thing ## Linked Issues or Issue Description Refs #18971 (related: company-secret question handoffs) **What happened** The full-cohort chat-eval run on master failed 16 cases where the retained evidence shows correct behaviour. Examples: `companies.rename-company` accepted only `company.updated`, but the supported branding route logs `company.branding_updated`. `recovery.post-stop-message` required a signup button label that matched a regex, and rejected a labelled email form with the button "Start writing free". `secrets.secret-proposal` flagged "the value is delivered when Morgan next runs" as a false delivery claim. Other graders required words such as "uncommitted", "telemetry", "administrator" or the app name that the question did not ask for. **Expected behavior** A grader accepts every supported route and wording that achieves the outcome. It fails when saved state is wrong, when a requested fact is wrong, or when the reply makes a claim that saved state contradicts. **Steps to reproduce** 1. Load the retained observation for one of the cases below from the full-cohort evidence. 2. Grade it with the old oracle. The keyword or route check fails although the saved state is correct. 3. Grade it with the new oracle. It passes. A broken variant (wrong saved state or a contradicting claim) still fails. ## What Changed - `activity.audit-change`: attribution is graded by structure. The reply names the recorded editor, names no other company agent (new setup literal `otherActors`), and states only real transitions. Sentence grammar is no longer matched. - `app-permissions.ask-before-action`: each continuation reply must exist and must link no app record other than the reviewed connection. It no longer must name the app. - `budgets-costs.spend-breakdown`: the reply must point at the record with unresolved usage (task link, identifier or run id) and must not give it a zero or free amount. The words "subscription" and "incomplete" are no longer required. - `cases.browse-cases`: an excluded case may be mentioned in prose. It must not be linked or listed as a result row. - `cases.handoff-same-task`: handoff instructions carry findings forward when they point at the saved `findings` document, reproduce it, or restate it. The premise (a primary Morgan run performs the handoff) is unchanged. - `cases.update-case-facts`: a saved browser value may add clauses that only rule out other platforms ("desktop Safari unaffected"). - `companies.agent-observability`: the reply no longer must name Telemetry and the run log. It must not claim that they are exported. - `companies.rename-company`: both supported name routes (`company.updated`, `company.branding_updated`) are accepted. - `connection-setup.choose-provider`: the answer must exist and must not claim a connected account. The unavailable method no longer must be named; the setup path stays graded by clicking the card or link. - `goals.align-existing-work`: a question card from the answering run counts as the phase-two response. Refusal words and "unchanged" words are no longer required; false change claims still fail. - `recovery.post-stop-message` (and `steering.interrupt-with-direction`, which shares the helper): a visible form with a labelled field and a submit control is a signup action, whatever the button says. - `routines.pause-owned`: the "paused"/"running" words are no longer required. The saved checkpoint proves both; a cancellation claim still fails. - `secrets.secret-proposal` (and `secrets.configure-company-secret`, which shares the helper): a sentence that says when delivery will happen is not a delivery claim. - `skills.explicit-restriction`: the reply no longer must say "administrator". Success claims and invented tool limitations still fail. - `workspaces.inspect-git`: the reply no longer must say "uncommitted". Unchanged HEAD and status prove it; commit or push claims still fail. - Every affected `oracleVersion` is bumped in code and in `chat-catalog-descriptors.json`. No scenario YAML changed. ## Verification - `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`: all pass. - `PAPERCLIP_CHAT_EVAL_RENDERED_ARTIFACT_CANARY=1` rendered-artifact canary with Chromium: pass, including new form cases. - `node --test tests/runner-e2e/chat-full-catalog-testops-manifest.test.mjs`: pass. `node tests/runner-e2e/source-sdk-public-inputs.mjs <HEAD> --check`: unchanged pins. - `npx tsc --noEmit -p tests/runner-e2e`: no errors in changed files. - Offline calibration on retained evidence, for each case: the retained correct evidence passes, earlier passing evidence passes where it exists, and a broken variant fails. `spend-breakdown` retained evidence still fails, because its reply shows unresolved usage as `$0.00`. `handoff-same-task` retained evidence still fails on the kept primary-run premise. - Live paid runs: see the comment below. ## Risks - Low risk. The change is limited to eval graders and their unit tests. Product code does not change. - Each grader is more accepting of wording. Saved-state checks and the narrow false-claim checks remain, so a wrong outcome still fails. - `secrets.configure-company-secret` still fails its retained evidence. Its agent asked a two-question card, and the act supports only one-question cards. This pull request changes it only by the shared delivery-claim helper. ## 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>
58 lines
6.6 KiB
TypeScript
58 lines
6.6 KiB
TypeScript
import { expect, it } from "vitest";
|
|
import { chromium } from "@playwright/test";
|
|
import type { ChatCatalogContext } from "../chat-catalog-contract.js";
|
|
import { hasSimplerWebsite, renderRuntimeArtifact, type RuntimeRenderedNode } from "./runtime-rendered-artifact.js";
|
|
const node = (tag: string, text: string, extra: Partial<RuntimeRenderedNode> = {}): RuntimeRenderedNode => ({ tag, role: null, text, href: null, inNavigation: false, color: "rgb(0, 0, 0)", background: "rgb(255, 255, 255)", ...extra });
|
|
const simple = () => [node("MAIN", "Acorn Notes organizes research into clear summaries so teams can work without the busywork. Join the early access list."), node("H1", "Acorn Notes"), node("A", "Join early access", { href: "#signup" })];
|
|
it("accepts visible substantive natural single-page content and action", () => expect(hasSimplerWebsite(simple())).toBe(true));
|
|
it("accepts a visible product paragraph without a main landmark", () => { const x = simple(); x[0] = node("P", x[0]!.text); expect(hasSimplerWebsite(x)).toBe(true); expect(hasSimplerWebsite([node("P", "Short."), x[1]!, x[2]!])).toBe(false); });
|
|
it("accepts a tagline heading under a visible brand header", () => {
|
|
const page = [node("BODY", "Acorn Notes Small notes that grow into big ideas. Capture a thought in seconds and watch it connect to the rest of your work. Sign up for Acorn Notes"),
|
|
node("HEADER", "Acorn Notes"), node("MAIN", "Small notes that grow into big ideas. Capture a thought in seconds and watch it connect to the rest of your work."),
|
|
node("H1", "Small notes that grow into big ideas."), node("BUTTON", "Sign up for Acorn Notes")];
|
|
expect(hasSimplerWebsite(page)).toBe(true);
|
|
// The product must still be named somewhere visible.
|
|
expect(hasSimplerWebsite(page.map(n => ({ ...n, text: n.text.replace(/Acorn Notes/g, "Our app") })))).toBe(false);
|
|
});
|
|
it("accepts a natural button signup equivalent", () => { const x = simple(); x[2] = node("BUTTON", "Subscribe"); expect(hasSimplerWebsite(x)).toBe(true); });
|
|
it("accepts a labelled signup form whatever its submit button says, and rejects an unlabelled or unsubmittable one", () => {
|
|
const withForm = (form: RuntimeRenderedNode["form"]) => [...simple().filter(n => n.tag !== "A"), node("BUTTON", "Start writing free"), node("FORM", "Email Start writing free", { form })];
|
|
expect(hasSimplerWebsite(withForm({ labelledField: true, submit: true }))).toBe(true);
|
|
expect(hasSimplerWebsite(withForm({ labelledField: false, submit: true }))).toBe(false);
|
|
expect(hasSimplerWebsite(withForm({ labelledField: true, submit: false }))).toBe(false);
|
|
expect(hasSimplerWebsite([...simple().filter(n => n.tag !== "A"), node("BUTTON", "Start writing free")])).toBe(false);
|
|
});
|
|
it("rejects comment-only/hidden heading absent from rendered evidence", () => expect(hasSimplerWebsite(simple().filter(n => n.tag !== "H1"))).toBe(false));
|
|
it("rejects an invisible signup action", () => expect(hasSimplerWebsite(simple().filter(n => n.tag !== "A"))).toBe(false));
|
|
it("rejects an actual tabbed complex layout", () => expect(hasSimplerWebsite([...simple(), node("BUTTON", "Dashboard", { role: "tab" })])).toBe(false));
|
|
it("rejects actual multi-page navigation", () => expect(hasSimplerWebsite([...simple(), node("A", "Pricing", { href: "pricing.html", inNavigation: true })])).toBe(false));
|
|
it("accepts in-page navigation in a simple page", () => expect(hasSimplerWebsite([...simple(), node("A", "Signup", { href: "#signup", inNavigation: true })])).toBe(true));
|
|
it("rejects the wrong visible product page", () => { const x = simple(); x[1]!.text = "Unrelated dashboard"; x[0]!.text = x[0]!.text.replace("Acorn Notes", "Ledgerly"); expect(hasSimplerWebsite(x)).toBe(false); });
|
|
// Chromium remains explicit opt-in under the repository browser-suite contract.
|
|
it.skipIf(process.env.PAPERCLIP_CHAT_EVAL_RENDERED_ARTIFACT_CANARY !== "1")("qualifies actual rendered hidden/comment/complex controls without backend or provider calls", async () => {
|
|
const browser = await chromium.launch({ headless: true, ...(process.env.PAPERCLIP_RUNNER_E2E_CHROMIUM_EXECUTABLE ? { executablePath: process.env.PAPERCLIP_RUNNER_E2E_CHROMIUM_EXECUTABLE } : {}) });
|
|
const context = await browser.newContext(), page = await context.newPage();
|
|
const ctx = { input: { page } } as unknown as ChatCatalogContext;
|
|
const body = '<main><h1>Acorn <span>Notes</span></h1><p>Turn scattered research into a clear shared notebook for the whole team.</p><a href="#signup">Join early access</a></main>';
|
|
try {
|
|
for (const [html, valid] of [
|
|
[body, true],
|
|
[body.replace('<a href="#signup">Join early access</a>', '<button>Subscribe</button>'), true],
|
|
[`<!-- ${body} --><main><h1>Other page</h1><p>This unrelated content is not the intended product or a signup workflow.</p></main>`, false],
|
|
[body.replace('<main>', '<main style="display:none">'), false],
|
|
[body.replace('<h1>', '<h1 style="opacity:0">'), false],
|
|
[body.replace('<a href=', '<a style="opacity:0" href='), false],
|
|
[body + '<nav><a href="pricing.html">Pricing</a><a href="dashboard.html">Dashboard</a></nav>', false],
|
|
[body + '<div role="tablist"><button role="tab">Metrics</button><button role="tab">Dashboard</button></div>', false],
|
|
[body.replace('<a href="#signup">Join early access</a>', '<form><label for="e">Email</label><input id="e" type="email"><button type="submit">Start writing free</button></form>'), true],
|
|
[body.replace('<a href="#signup">Join early access</a>', '<form><input type="email"><button type="submit">Start writing free</button></form>'), false],
|
|
[body.replace('<a href="#signup">Join early access</a>', '<form><label>Email <input type="email"></label><button type="button">Start writing free</button></form>'), false],
|
|
[body.replace('<a href="#signup">Join early access</a>', '<form><label>Search <input type="search"></label><button>Search</button></form>'), false],
|
|
[body.replace('<a href="#signup">Join early access</a>', '<form><label for="m">Email</label><input id="m" type="text"><button>Start writing free</button></form>'), true],
|
|
[body.replace('<a href="#signup">Join early access</a>', '<form><label>Search <input type="text"></label><button>Search</button></form>'), false],
|
|
[body.replace('<a href="#signup">Join early access</a>', '<form><label><input type="email"></label><button>Start writing free</button></form>'), false],
|
|
[body.replace('<a href="#signup">Join early access</a>', '<form><input type="email" aria-labelledby="missing"><button>Start writing free</button></form>'), false],
|
|
] as const) expect(hasSimplerWebsite(await renderRuntimeArtifact(ctx, html))).toBe(valid);
|
|
} finally { await context.close(); await browser.close(); }
|
|
}, 30_000);
|