mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-14 00:26:28 +01:00
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work
> - The chat eval catalog runs real chat journeys and grades saved
product state
> - Three cases failed in runs 43088288923 and 43101125481. In two of
them the product did the right thing
> - `cases.update-case-facts` used a word allow-list on a saved field.
`cases.create-case` read a field the product never fills for a new task
> - `task-coordination.block-on-design` timed out in the browser on a
missing row, which hid the real cause
> - This pull request grades what the user asked for, binds records by
structure, and stops with the real cause
> - The benefit is that each case fails only when the product fails, and
says why
## Linked Issues or Issue Description
**What happened**
- `cases.update-case-facts`: the CEO saved `browser: "Mobile Safari only
(bug reproduces only on mobile Safari)"` and kept the other fields. The
grader allowed only the words "safari" and mobile qualifiers, so "bug
reproduces" failed the first phase. The earlier rewording of the
scripted follow-up was not the cause: it is sent only after this phase.
- `cases.create-case`: the CEO created the case, the `workflow` and
`customer_brief` documents and Morgan's kickoff in one run, before
Morgan started. The grader required the kickoff's `sourceRunId` to be a
CEO run. The product sets `sourceRunId` to the run that holds the task
being handed off, so it is always null when the kickoff creates the
task. The grade never passed, so the harness kept waiting. Morgan then
found no app to test, handed the case back to the CEO, and the extra CEO
run broke the run bound.
- `task-coordination.block-on-design`: the CEO saved no Design blocker.
The scripted human then waited 30 s for the "Blocked by" row and failed
with a locator timeout.
**Expected behavior**
Each case accepts every valid product behavior and rejects the failures
that matter. A failure names its cause.
**Steps to reproduce**
1. Regrade the retained observation of run 43101125481 for
`cases.create-case` with the old grader: `real-first-morgan-kickoff` and
`required-documents-persist-before-investigation` fail although the
records are correct.
2. Call `corrected({ browser: "Mobile Safari only (bug reproduces only
on mobile Safari)" })` with the old grader: it returns false.
3. Run `task-coordination.block-on-design` when the CEO saves no
blocker: it fails on a UI locator instead of on the missing blocker.
## What Changed
- `cases.update-case-facts` (oracle v8): grade the saved correction
structurally, with no word lists. The browser fact differs from the
case's own setup value, and no field keeps the setup's stale literal
"not yet confirmed", in the saved case and in the reviewer's handoff
context. A person reads whether the wording says mobile Safari.
- `cases.create-case` (oracle case-create.v2): bind the kickoff and its
documents to the CEO chat run that was active when the kickoff was
saved, not to `sourceRunId`. Admit one CEO run on the primary task woken
by Morgan's own case handoff back to the CEO, and add a check that binds
it to that handoff. Run bound is now 2 to 3.
- Shared run policy (`chat-catalog-run-policy.ts`,
`chat-catalog-contract.ts`): new optional `maximumLeadCaseHandbackRuns`
for `source-settled-with-active-work`. The default is 0, so other cases
grade exactly as before. No other oracle version changes.
- `task-coordination.block-on-design` (oracle v9): after the first turn,
stop with "The CEO did not save Design as a blocker on Website" when the
edge is missing.
- Descriptors updated for the three cases.
## Verification
- `pnpm exec vitest run --config tests/runner-e2e/vitest.config.ts` on
`integrator-case-create`, `integrator-case-facts`, `runtime-dependency`,
`chat-catalog-run-policy`, `chat-catalog-readiness`,
`chat-catalog-failure-retirement`, `chat-catalog-consistency` and
`chat-ordinary-phase-callers` tests: all pass.
- Calibration in both directions:
- case facts: the retained values of runs 43088288923 and 43107042937
and four rewordings pass. The unchanged setup value, a kept stale
literal (in any field), empty facts and a first turn that left the value
unchanged fail.
- create-case: the retained run 43101125481 regrades to pass. Documents
from another run, a kickoff outside the CEO run, a kickoff by Morgan, a
handback with no handoff, a handback woken another way and two handbacks
fail.
- run policy: an undeclared handback, a lead run woken another way and a
second handback fail.
- `npx tsc --noEmit -p tests/runner-e2e`: no errors in changed files.
- `node tests/runner-e2e/source-sdk-public-inputs.mjs <HEAD> --check`:
unchanged.
- End-to-end: chat eval run linked in a comment below.
## Risks
- Low risk. Only eval code changes. The shared run-policy option is
opt-in, and its default keeps the old result.
## 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>
116 lines
13 KiB
TypeScript
116 lines
13 KiB
TypeScript
import type { cases } from "../../../packages/db/src/schema/cases.js";
|
|
import type { CaseHandoff } from "../../../packages/shared/src/case-handoffs.js";
|
|
import type { IssueDocument, DocumentRevision } from "../../../packages/shared/src/types/issue.js";
|
|
import type { ChatCatalogContext } from "../chat-catalog-contract.js";
|
|
import { uploadFactualQueryAttachment, factualContextQueryAttachment } from "../chat-query-attachments.js";
|
|
import { checks, hasLink, observation, project, type Entity, type InternalObservation } from "./internal-support.js";
|
|
import { fixtureWorker } from "./internal-definition.js";
|
|
import { integratorDefinition, inspectResult, resultHref, observeInternal } from "./integrator-support.js";
|
|
type CaseRecord = typeof cases.$inferSelect;
|
|
interface CaseCreationObservation extends InternalObservation { cases: CaseRecord[]; handoffs: Record<string, CaseHandoff[]>; taskDocuments: Record<string, IssueDocument[]>; documentVersions: Record<string, DocumentRevision[]> }
|
|
export const customerBugFacts = { customer: "Acorn Test Shop", symptom: "Checkout button becomes unresponsive", browser: "Safari 18", device: "iPhone 15", reproduction: "Add the synthetic trial item, enter the test address, then press Pay", expected: "Order confirmation appears", actual: "No confirmation and no charge" };
|
|
export function preservesCustomerBugFacts(value: unknown): boolean {
|
|
if (!value || typeof value !== "object") return false;
|
|
const text = JSON.stringify(value); const entries: Array<{ path: string; text: string }> = [];
|
|
const walk = (node: unknown, path: string) => {
|
|
if (typeof node === "string") entries.push({ path, text: node });
|
|
else if (node && typeof node === "object") for (const [key, nested] of Object.entries(node)) walk(nested, `${path}.${key}`);
|
|
}; walk(value, "");
|
|
const expected = entries.filter(entry => /expect|should/i.test(entry.path)).map(entry => entry.text).join(" ");
|
|
const actual = entries.filter(entry => /actual|observ|result/i.test(entry.path)).map(entry => entry.text).join(" ");
|
|
return /Acorn Test Shop/i.test(text) && /checkout/i.test(text) && /unresponsive|non.responsive|does not respond|stops responding|no response/i.test(text)
|
|
&& /Safari\s*18/i.test(text) && /iPhone\s*15/i.test(text) && /trial item/i.test(text) && /test address/i.test(text) && /\bPay\b/i.test(text)
|
|
&& /order confirmation/i.test(expected) && !/\b(?:no|not|without|never)\b/i.test(expected)
|
|
&& /(?:no|without) (?:order )?confirmation|confirmation (?:does not|doesn't|never) appear/i.test(actual)
|
|
&& /no charge|not charged|without (?:a )?charge/i.test(actual);
|
|
}
|
|
const briefHasFacts = (body: string) => /Acorn Test Shop/i.test(body) && /checkout/i.test(body) && /unresponsive|non.responsive|does not respond|stops responding|no response/i.test(body)
|
|
&& /Safari\s*18/i.test(body) && /iPhone\s*15/i.test(body) && /trial item/i.test(body) && /test address/i.test(body) && /\bPay\b/i.test(body)
|
|
&& /order confirmation/i.test(body) && /(?:no|without) (?:order )?confirmation|confirmation (?:does not|doesn't|never) appear/i.test(body) && /no charge|not charged|without (?:a )?charge/i.test(body);
|
|
async function observeCreation(ctx: ChatCatalogContext, state: Record<string, unknown>): Promise<CaseCreationObservation> {
|
|
const o = await observeInternal(ctx, state); const api = ctx.input.api;
|
|
const cases = await api.get<CaseRecord[]>(`/api/companies/${o.companyId}/cases`);
|
|
const handoffs: Record<string, CaseHandoff[]> = {}; const taskDocuments: Record<string, IssueDocument[]> = {}; const documentVersions: Record<string, DocumentRevision[]> = {};
|
|
for (const row of cases) {
|
|
handoffs[row.id] = (await api.get<{ items: CaseHandoff[] }>(`/api/cases/${row.id}/handoffs?limit=100`)).items;
|
|
if (!row.workIssueId) continue;
|
|
const summaries = await api.get<IssueDocument[]>(`/api/issues/${row.workIssueId}/documents`);
|
|
taskDocuments[row.workIssueId] = await Promise.all(summaries.map(doc => api.get<IssueDocument>(`/api/issues/${row.workIssueId}/documents/${encodeURIComponent(doc.key)}`)));
|
|
for (const doc of summaries) documentVersions[`${row.workIssueId}:${doc.key}`] = await api.get<DocumentRevision[]>(`/api/issues/${row.workIssueId}/documents/${encodeURIComponent(doc.key)}/revisions`);
|
|
}
|
|
return { ...o, cases, handoffs, taskDocuments, documentVersions };
|
|
}
|
|
/** The CEO's own chat run on the standing conversation that was running when the kickoff handoff was saved. */
|
|
function kickoffSourceRun(o: CaseCreationObservation, kickoff: CaseHandoff | undefined) {
|
|
const at = Date.parse(String(kickoff?.createdAt)), sourceId = (o.state.task as Entity | undefined)?.id;
|
|
if (!kickoff || !Number.isFinite(at) || !sourceId) return undefined;
|
|
return o.runs.find(run => run.agentId === o.leadId && run.contextSnapshot?.issueId === sourceId && Date.parse(String(run.startedAt)) <= at
|
|
&& (!(run as { finishedAt?: string | null }).finishedAt || at <= Date.parse(String((run as { finishedAt?: string | null }).finishedAt))));
|
|
}
|
|
function creationObservation(value: unknown): CaseCreationObservation | undefined {
|
|
const o = value as CaseCreationObservation;
|
|
return observation(value) && Array.isArray(o.cases) && !!o.handoffs && !!o.taskDocuments && !!o.documentVersions ? o : undefined;
|
|
}
|
|
export function gradeIntegratorCaseCreation(value: unknown) {
|
|
const c = checks(); const o = creationObservation(value);
|
|
c.add("public-case-kickoff-and-document-evidence", !!o, "Requires actual public case, handoff, primary task and saved document revision evidence.");
|
|
if (!o) return c.result();
|
|
const row = o.cases[0]; const worker = o.state.worker as Entity | undefined;
|
|
const primary = row?.workIssueId ? o.tasks.find(task => task.id === row.workIssueId) : undefined;
|
|
c.add("one-case-with-one-primary-task", o.cases.length === 1 && !!row && row.companyId === o.companyId && row.projectId === (o.state.project as Entity | undefined)?.id
|
|
&& !!primary && o.tasks.filter(task => task.id !== (o.state.task as Entity | undefined)?.id).length === 1, "One actual company case has one primary task; the prerequisite customer brief remains a separate original record.");
|
|
c.add("structured-customer-facts", preservesCustomerBugFacts(row?.fields), "Structured case fields preserve the actual customer, device/browser, reproduction and expected versus observed behavior; prose may be paraphrased.");
|
|
const kickoff = row && o.handoffs[row.id]?.filter(handoff => handoff.version === 1);
|
|
// A handoff's sourceRunId names the run that held the task being handed off, so a kickoff that creates its
|
|
// primary task has none (gha-43101125481). Bind the kickoff to the measured CEO chat run active when it was saved.
|
|
const kickoffRun = kickoffSourceRun(o, kickoff?.[0]);
|
|
c.add("real-first-morgan-kickoff", !!worker && kickoff?.length === 1 && kickoff[0]?.caseId === row?.id && kickoff[0]?.issueId === primary?.id && kickoff[0]?.toAgentId === worker.id
|
|
&& kickoff[0]?.actorId === o.leadId && !!kickoffRun
|
|
&& ["delivered", "completed"].includes(kickoff[0]?.state ?? "")
|
|
&& o.runs.some(run => run.agentId === worker.id && run.contextSnapshot?.issueId === primary?.id && ["queued", "running", "succeeded"].includes(run.status)), "The real measured CEO starts the primary task with Morgan as its first actual investigation recipient.");
|
|
const docs = primary ? o.taskDocuments[primary.id] ?? [] : [];
|
|
const workflow = docs.find(doc => doc.key === "workflow"); const brief = docs.find(doc => doc.key !== "workflow" && briefHasFacts(doc.body));
|
|
const workerRun = o.runs.find(run => run.agentId === worker?.id && run.contextSnapshot?.issueId === primary?.id);
|
|
const savedBeforeStart = (doc: IssueDocument | undefined, content: (body: string) => boolean) => !!doc && !!primary && doc.issueId === primary.id && doc.companyId === o.companyId
|
|
&& !!workerRun && o.documentVersions[`${primary.id}:${doc.key}`]?.some(revision => revision.issueId === primary.id && revision.documentId === doc.id && revision.createdByAgentId === o.leadId
|
|
&& content(revision.body) && !!kickoffRun && revision.createdByRunId === kickoffRun.id && Date.parse(String(revision.createdAt)) <= Date.parse(String(workerRun.startedAt)));
|
|
c.add("required-documents-persist-before-investigation", savedBeforeStart(workflow, body => /Morgan/i.test(body) && /\b(?:review|handoff|hand.off|owner)\b/i.test(body)) && savedBeforeStart(brief, briefHasFacts), "The primary task owns an attributed saved brief and workflow describing Morgan, owners and later handoffs before Morgan's run begins; a later repaired document cannot rescue a missing initial brief.");
|
|
// Morgan may legitimately hand the case back to the CEO (e.g. blocked without app access); that wakes the CEO on the
|
|
// primary task once. Any other CEO run outside this conversation is undeclared work.
|
|
const handbacks = o.runs.filter(run => run.agentId === o.leadId && run.contextSnapshot?.issueId !== (o.state.task as Entity | undefined)?.id);
|
|
c.add("only-declared-case-handback-to-ceo", handbacks.length <= 1 && handbacks.every(run => run.contextSnapshot?.issueId === primary?.id && run.contextSnapshot?.wakeReason === "case_handoff"
|
|
&& !!row && (o.handoffs[row.id] ?? []).some(handoff => handoff.version > 1 && handoff.issueId === primary?.id && handoff.toAgentId === o.leadId && handoff.actorId === worker?.id)),
|
|
"Beyond its chat turn, the CEO runs at most once, on the primary task, woken by Morgan's own case handoff back to it.");
|
|
c.add("case-and-primary-task-links", !!row && !!primary && !!(resultHref(o, row.id, "cases") ?? resultHref(o, row.identifier, "cases")) && hasLink(o, "issues", primary), "Chat links the actual case and primary task instead of a passive record with a fictional investigation claim.");
|
|
return c.result();
|
|
}
|
|
const previous = new WeakMap<ChatCatalogContext, boolean>();
|
|
export const integratorCaseCreationCases = [integratorDefinition("cases", "cases.create-case", async ctx => {
|
|
const settings = await ctx.input.api.get<{ enableCases: boolean }>("/api/instance/settings/experimental"); previous.set(ctx, settings.enableCases);
|
|
try {
|
|
await ctx.input.api.patch("/api/instance/settings/experimental", { enableCases: true });
|
|
const p = await project(ctx, "Launch"); const worker = await fixtureWorker(ctx);
|
|
await ctx.input.api.post(`/api/companies/${ctx.input.fixtures.company.id}/chats/${ctx.input.fixtures.agent.id}`);
|
|
await ctx.refreshIssue(); const t = ctx.issue();
|
|
if (!t) throw new Error("Customer brief requires its actual standing conversation");
|
|
await ctx.input.api.patch(`/api/issues/${t.id}`, { projectId: p.id });
|
|
const body = `# Customer bug brief\n\n${Object.entries(customerBugFacts).map(([key, value]) => `${key}: ${value}`).join("\n")}`;
|
|
await ctx.input.api.putSensitive(`/api/issues/${t.id}/documents/brief`, { title: "Customer bug brief", format: "markdown", body, baseRevisionId: null });
|
|
const brief = await ctx.input.api.get<IssueDocument>(`/api/issues/${t.id}/documents/brief`);
|
|
if (brief.body !== body || brief.issueId !== t.id) throw new Error("Customer brief did not persist on the actual canonical conversation");
|
|
const factualContextAttachment = await uploadFactualQueryAttachment(ctx, { filename: "customer-bug-brief.md", body: brief.body });
|
|
return { project: p, task: t, worker, factualContextAttachment };
|
|
} catch (error) { await ctx.input.api.patch("/api/instance/settings/experimental", { enableCases: settings.enableCases }); previous.delete(ctx); throw error; }
|
|
}, observeCreation, gradeIntegratorCaseCreation, async (ctx, _state, value) => {
|
|
const o = creationObservation(value); const row = o?.cases[0]; if (!o || !row) throw new Error("Missing created case result");
|
|
const href = resultHref(o, row.id, "cases") ?? resultHref(o, row.identifier, "cases"); if (!href) throw new Error("Missing real case link");
|
|
await inspectResult(ctx, new URL(href, o.origin).pathname, row.title, href);
|
|
const primary = o.tasks.find(task => task.id === row.workIssueId); if (!primary) throw new Error("Missing primary case task");
|
|
const taskHref = o.links.find(link => hasLink({ ...o, links: [link] }, "issues", primary)); if (!taskHref) throw new Error("Missing primary task link");
|
|
await inspectResult(ctx, new URL(taskHref, o.origin).pathname, primary.title!, taskHref);
|
|
}, { authentication: "human-session", queryAttachments: factualContextQueryAttachment("customer-bug-brief.md"), sourceFiles: ["chat-catalog-domains/integrator-case-create.ts", "chat-catalog-domains/integrator-support.ts", "chat-catalog-domains/internal-support.ts", "chat-catalog-domains/internal-definition.ts", "chat-scenarios/cases.yaml"],
|
|
oracleVersion: "paperclip.chat-catalog.integrator.case-create.v2",
|
|
expectedRunCount: 3, minimumRunCount: 2, runPolicy: { kind: "source-settled-with-active-work", maximumActiveWorkerRuns: 1, maximumLeadCaseHandbackRuns: 1 }, attemptTimeoutMs: 420_000,
|
|
async cleanup(ctx) { const value = previous.get(ctx); if (value !== undefined) { await ctx.input.api.patch("/api/instance/settings/experimental", { enableCases: value }); previous.delete(ctx); } },
|
|
})];
|