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>
119 lines
11 KiB
TypeScript
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" },
|
|
];
|