mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-13 15:55:46 +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>
92 lines
8.1 KiB
TypeScript
92 lines
8.1 KiB
TypeScript
import { expect } from "@playwright/test";
|
|
import { execFile } from "node:child_process";
|
|
import { promisify } from "node:util";
|
|
import { realpath, readFile, writeFile } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { checks, sameTask, type Entity } from "./internal-support.js";
|
|
import { savedTaskWorkspace } from "./runtime-workspaces.js";
|
|
import { runtimeDefinition, runtimeObservation, observeRuntime, type RuntimeAgent } from "./runtime-support.js";
|
|
const exec = promisify(execFile);
|
|
async function ownedGit(cwd: string, args: string[]) {
|
|
const root = process.env.PAPERCLIP_RUNNER_E2E_TEMP_ROOT;
|
|
if (!root || !(await realpath(cwd)).startsWith(`${await realpath(root)}${path.sep}`)) throw new Error("Git fixture escaped the isolated task workspace");
|
|
return (await exec("git", args, { cwd, timeout: 15_000, maxBuffer: 262_144 })).stdout;
|
|
}
|
|
export function gradeInspectGit(value: unknown) {
|
|
const c = checks(); const o = runtimeObservation(value);
|
|
c.add("real-authorized-repository-evidence", !!o, "Actual task binding, owned repository baseline and independent before/after bytes are required."); if (!o) return c.result();
|
|
const before = o.state.workspaceBefore as { workspace: { id: string; cwd: string } }; const after = o.state.workspaceAfter as typeof before;
|
|
c.add("exact-task-repository-scope", !!before && !!after && before.workspace.id === after.workspace.id && before.workspace.cwd === after.workspace.cwd
|
|
&& o.state.nativeInspectionCwd === after.workspace.cwd && sameTask(o.state.task as Entity, o.tasks.find(row => row.id === (o.state.task as Entity).id))
|
|
&& o.tasks.length === 1 && o.state.noRemote === true,
|
|
"The actual chat native workspace is the exact authorized bound task checkout, without an unrelated checkout or configured remote.");
|
|
c.add("real-uncommitted-header-and-unrelated-change", typeof o.state.diffBefore === "string" && /diff --git a\/header.html b\/header.html/.test(o.state.diffBefore)
|
|
&& /Launch later/.test(o.state.diffBefore) && /Launch sooner/.test(o.state.diffBefore)
|
|
&& /diff --git a\/existing-notes.txt b\/existing-notes.txt/.test(o.state.diffBefore)
|
|
&& o.state.diffAfter === o.state.diffBefore && o.state.statusAfter === o.state.statusBefore && o.state.headAfter === o.state.headBefore
|
|
&& o.state.headerAfter === o.state.headerBefore && o.state.notesAfter === o.state.notesBefore,
|
|
"Both real synthetic edits and the committed baseline remain byte-for-byte unchanged by this inspection.");
|
|
c.add("actual-diff-summary-with-file-references", /header\.html/.test(o.reply) && /Launch later/i.test(o.reply) && /Launch sooner/i.test(o.reply)
|
|
// The changed files and values are the setup's own literals, so the reply must report them. That the work
|
|
// is uncommitted is proven by the unchanged HEAD/status above; the reply need not use the word, only
|
|
// make no commit/push claim.
|
|
&& /existing-notes\.txt/.test(o.reply)
|
|
&& !/(?:I(?:'ve| have)? |successfully )(?:pushed|committed|published|deployed)|remote.*(?:has been|was) updated/i.test(o.reply),
|
|
"The answer describes the observed header and unrelated diff and makes no commit, push or remote-update claim.");
|
|
c.add("real-native-inspection-only", o.runs.length === 2 && o.runs.every(run => run.status === "succeeded")
|
|
&& o.runs.filter(run => run.agentId === o.leadId).length === 1,
|
|
"The real prior workspace setup and one native inspection are the only provider executions.");
|
|
return c.result();
|
|
}
|
|
export const runtimeGitCases = [runtimeDefinition("workspaces.inspect-git", async ctx => {
|
|
if (ctx.input.execution.environment.id !== "local") throw new Error("Owned repository fixture requires the actual local runtime");
|
|
const state = await savedTaskWorkspace(ctx); const cwd = state.workspaceBefore.workspace.cwd;
|
|
await ownedGit(cwd, ["init", "--initial-branch=eval-fixture"]);
|
|
if ((await ownedGit(cwd, ["remote"])).trim()) throw new Error("Disposable synthetic task repository unexpectedly has a remote");
|
|
const header = path.join(cwd, "header.html"), notes = path.join(cwd, "existing-notes.txt");
|
|
await writeFile(header, `<h1>Launch later</h1>\n<!-- ${ctx.input.nonce} -->\n`, "utf8");
|
|
await writeFile(notes, "Existing unrelated notes before the user's edit.\n", "utf8");
|
|
await ownedGit(cwd, ["add", "--", "header.html", "existing-notes.txt"]);
|
|
await ownedGit(cwd, ["-c", "core.hooksPath=/dev/null", "-c", "user.name=Paperclip Eval Fixture", "-c", "user.email=fixture@invalid.example", "commit", "-m", "Synthetic baseline for the owned diff fixture"]);
|
|
await writeFile(header, `<h1>Launch sooner</h1>\n<!-- ${ctx.input.nonce} -->\n`, "utf8");
|
|
await writeFile(notes, `Existing unrelated notes preserved with human amendment ${ctx.input.nonce}.\n`, "utf8");
|
|
const lead = await ctx.input.api.get<RuntimeAgent>(`/api/agents/${ctx.input.fixtures.agent.id}`);
|
|
await ctx.input.api.patch(`/api/agents/${lead.id}`, { adapterConfig: { ...lead.adapterConfig, cwd } });
|
|
await ctx.input.api.patch("/api/instance/settings/experimental", { enableExperimentalFileViewer: true });
|
|
return { ...state, nativeInspectionCwd: cwd, noRemote: true, headBefore: await ownedGit(cwd, ["rev-parse", "HEAD"]),
|
|
diffBefore: await ownedGit(cwd, ["diff", "--", "header.html", "existing-notes.txt"]), statusBefore: await ownedGit(cwd, ["status", "--porcelain"]),
|
|
headerBefore: await readFile(header, "utf8"), notesBefore: await readFile(notes, "utf8") };
|
|
}, gradeInspectGit, {
|
|
sourceFiles: ["chat-catalog-domains/runtime-git.ts", "chat-catalog-domains/runtime-workspaces.ts", "chat-catalog-domains/runtime-support.ts", "chat-scenarios/workspaces.yaml"],
|
|
oracleVersion: "paperclip.chat-catalog.inspect-git.structural.v4", expectedRunCount: 2, attemptTimeoutMs: 540_000,
|
|
async observe(ctx, state) {
|
|
const cwd = String(state.nativeInspectionCwd);
|
|
state.workspaceAfter = await ctx.input.api.get(`/api/issues/${(state.task as Entity).id}/workspace`);
|
|
state.headAfter = await ownedGit(cwd, ["rev-parse", "HEAD"]); state.diffAfter = await ownedGit(cwd, ["diff", "--", "header.html", "existing-notes.txt"]);
|
|
state.statusAfter = await ownedGit(cwd, ["status", "--porcelain"]);
|
|
state.headerAfter = await readFile(path.join(cwd, "header.html"), "utf8"); state.notesAfter = await readFile(path.join(cwd, "existing-notes.txt"), "utf8");
|
|
return observeRuntime(ctx, state);
|
|
},
|
|
async verifyResult(ctx, state) {
|
|
const source = ctx.input.page.url(), 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();
|
|
// The task side panel can start closed; open it through the control the page renders ("Show properties"
|
|
// in the task header), as a person would, then choose the Files tab from the panel's tab launcher.
|
|
const page = ctx.input.page;
|
|
const newTab = page.getByRole("button", { name: "Open a new tab", exact: true }).filter({ visible: true });
|
|
const toggle = page.getByRole("button", { name: /^(Show properties|Toggle side panel)$/ }).filter({ visible: true }).first();
|
|
const files = page.getByRole("option", { name: /^Files/ }).filter({ visible: true }).first();
|
|
await expect.poll(async () => await newTab.isVisible() || await files.isVisible() || await toggle.isVisible()).toBe(true);
|
|
if (!await newTab.isVisible() && !await files.isVisible()) await toggle.click();
|
|
await expect.poll(async () => await files.isVisible() || await newTab.isVisible()).toBe(true);
|
|
if (!await files.isVisible()) await newTab.click();
|
|
await files.click();
|
|
await expect(ctx.input.page.getByText("header.html", { exact: true }).first()).toBeVisible();
|
|
await expect(ctx.input.page.getByText("existing-notes.txt", { exact: true }).first()).toBeVisible();
|
|
await ctx.input.evidence("runtime-authorized-git-files-ui.json", { taskId: task.id, workspaceId: (state.workspaceBefore as { workspace: { id: string } }).workspace.id, url: ctx.input.page.url() });
|
|
await ctx.input.page.goto(source, { waitUntil: "domcontentloaded" });
|
|
},
|
|
})];
|