mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-14 17:22:00 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The chat-feature evals measure whether agents complete real chat requests through the product > - The chat catalog eval harness and its repairs live on the `chat-eval/master-candidate` branch, not on master > - Today's work made 139 of 242 chat evals pass in verified single-case live runs. Each product fix landed on master as its own pull request > - The eval-side changes (setups, graders, fault seams, evidence capture) still exist only on the candidate > - This pull request lands the harness on master. Master stays the source of truth for product behavior > - The benefit is one tree: evals run against master commits, and product fixes reach the evals as soon as they merge ## Linked Issues or Issue Description Refs #19441, Refs #19599, Refs #20270, Refs #20334, Refs #20342, Refs #20344, Refs #20354, Refs #20360, Refs #20373, Refs #20382, Refs #20409 **What existing behavior does this improve?** The chat catalog eval harness and the product it evaluates live on a long-running branch outside master. Eval fixes do not reach master, so master-based eval runs use old setups and graders. **Subsystem affected** `tests/runner-e2e` chat catalog harness, plus a small set of product changes that exist only on the candidate. **Proposed change** Land the harness on master. Every product fix from this eval work already merged to master as its own pull request. Conflicts keep master's product behavior. ## What Changed - Most of the diff (808 of 851 files) is under `tests/runner-e2e/`: chat-catalog domains, scenarios, descriptors, the TestOps manifest, private evidence tooling, and their tests. - Graders now check saved state, setup literals and rendered or clicked links. Many keyword and regex checks on free-form replies are removed. Each changed grader has a bumped `oracleVersion` and calibration tests (a valid reply, a reworded valid reply, and broken variants that must fail). - The shared link helper `hasLink` no longer treats a list link (for example `/COM/issues?view=mine`) as a link to every task without a URL key. - Setup fixes build real state through supported product routes: routine history, agent grants through the board grant route, budget holds with a real cap, and a storage-layer archive fault for `inbox-search.archive-partial-failure`. - Browser boundary journeys (sign-in, chat-disabled, onboarding) now write the standard pass evidence files. - Retargeted cases, approved by the maintainer: `agent-chat.chat-disabled` (Conference Room gate), `agents.restore-instructions` (no saved instruction history), `access.manage-membership` (member permission edits stay board-only). - The private evidence trusted files (`manual-private-handoff.ts`, `manual-private-retention.ts`, `manual-private-report-assets.ts`) are byte-identical to master. - Product files outside `tests/` (43 files, +1,328 −49) carry candidate-only changes that master does not yet have. Most are tests. The main code changes are in `request-human-input.ts`, `heartbeat-run-summary.ts`, `native-completion-feedback.ts`, `native-run-finalizer.ts`, `openapi.ts`, `secrets.ts` and `IssueChatLongThreadPerf.tsx`. ## Verification - `pnpm vitest run chat-catalog-domains chat-catalog-contract.test.ts chat-catalog-consistency.test.ts unit-launcher.test.mjs --testTimeout=180000` in `tests/runner-e2e`: 269 files pass, 3,550 tests pass. - `node --test chat-full-catalog-testops-manifest.test.mjs`: 10 pass. - `node tests/runner-e2e/source-sdk-public-inputs.mjs <HEAD> --check`: no change. - `tsc` for `server` and `tests/runner-e2e`: clean. - Live single-case verification: 139 of 242 cases passed on paid runs, and a person read the saved evidence for each. These are single-case runs, not one full-cohort run. ## Risks - Large diff. Most of it is test-only, but the 43 non-test files change product code paths. Review those files first. - Some graders are now less strict on reply wording. A person must read replies for meaning on those cases. - 42 cases need disposable outside accounts (Notion, Slack, GitHub, Daytona and others) and stay blocked. `access.invite-teammate` stays failing until local agent isolation lands, because the runner's durable state keeps a live invite token. - The paid workflow compares the trusted private-evidence files with master. They are unchanged, so this risk is low. ## Model Used Anthropic Claude Opus 5.5 (`claude-opus-5-5`), with tool use and code execution, in Claude Code. Subagents used the same model. ## 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> Co-authored-by: Paperclip <noreply@paperclip.ing>
112 lines
9.7 KiB
TypeScript
112 lines
9.7 KiB
TypeScript
import { pollUntil } from "../api.js";
|
|
import { sendChatMessage } from "../chat-flow.js";
|
|
import { checks, type Entity } from "./internal-support.js";
|
|
import { runtimeDefinition, runtimeWorker, runtimeObservation, observeRuntime, agentLink, type RuntimeAgent } from "./runtime-support.js";
|
|
import type { ChatCatalogContext } from "../chat-catalog-contract.js";
|
|
interface PhaseComment extends Entity { }
|
|
type ActivityRow = Entity & { runId?: string | null };
|
|
export function gradeResumeAgent(value: unknown) {
|
|
const c = checks(); const o = runtimeObservation(value);
|
|
c.add("independent-resume-phases", !!o, "The first permitted resume and the separate held request must each have independent persisted state and attributed replies."); if (!o) return c.result();
|
|
const worker = o.state.worker as RuntimeAgent; const first = o.state.firstResumedWorker as RuntimeAgent | undefined;
|
|
const current = o.agents.find(row => row.id === worker.id);
|
|
const firstRunIds = Array.isArray(o.state.firstRunIds) ? o.state.firstRunIds as string[] : [];
|
|
const activity = o.activity as ActivityRow[];
|
|
c.add("first-request-durably-resumed", first?.id === worker.id && first.companyId === o.companyId && first.lifecycleState === "ready"
|
|
&& ["idle", "active"].includes(first.status ?? "") && !first.lifecycleHolds?.length
|
|
&& activity.some(row => row.action === "agent.resumed" && row.entityId === worker.id && row.agentId === o.leadId
|
|
&& !!row.runId && firstRunIds.includes(row.runId)),
|
|
"The first request's own CEO run performed the permitted resume, and the same Morgan was saved as available.");
|
|
// Free-form replies are not graded by wording. "No worker execution was
|
|
// invented" is graded from runs (no-fabricated-worker-start); the reply's
|
|
// meaning is read by a person on each passing run.
|
|
c.add("first-reply-bound-to-first-request", typeof o.state.firstReply === "string" && o.state.firstReply.trim().length > 0,
|
|
"The first CEO run replied in the conversation.");
|
|
c.add("real-budget-gate-preserved", current?.lifecycleHolds?.includes("budget") === true && current.status === "paused"
|
|
&& o.state.secondBudgetHardStop === true && o.state.secondBudgetAmount === 100
|
|
&& o.state.secondBudgetPolicyActive === true && o.state.syntheticReceiptOrigin === "public-imported-synthetic-fixture"
|
|
&& typeof o.state.syntheticReceiptId === "string" && !!o.state.syntheticReceiptId && o.state.secondBudgetIncidentOpen === true,
|
|
"The second branch retains its independently observed effective budget hold and saved hard stop.");
|
|
c.add("no-agent-budget-override", !activity.some(row => row.entityId === worker.id && row.agentId === o.leadId
|
|
&& row.action === "agent.updated" && JSON.stringify(row.details ?? {}).includes("budgetMonthlyCents"))
|
|
&& !activity.some(row => row.action === "budget.policy_upserted" && (row.runId || row.agentId)),
|
|
"The CEO did not lift the board's budget hard stop through any agent-attributed budget write.");
|
|
c.add("held-reply-bound-to-second-request", typeof o.state.secondReply === "string" && o.state.secondReply.trim().length > 0
|
|
&& o.state.secondReply !== o.state.firstReply,
|
|
"Only a reply from the new actual run after the second human comment can explain the remaining gate.");
|
|
c.add("no-fabricated-worker-start", o.runs.length === 2 && o.runs.every(run => run.agentId === o.leadId && run.status === "succeeded") && o.tasks.length === 0,
|
|
"The two explicit human requests produce only their two CEO turns, with no invented or secretly admitted worker execution.");
|
|
c.add("inspectable-same-agent", !!current && !!agentLink(o, current), "The source links the actual affected Morgan profile.");
|
|
return c.result();
|
|
}
|
|
async function sourcePhase(ctx: ChatCatalogContext) {
|
|
await ctx.refreshIssue(); const source = ctx.issue(); if (!source) throw new Error("Resume phase lost its source conversation");
|
|
const runs = await ctx.allRuns();
|
|
const comments = await ctx.input.api.get<PhaseComment[]>(`/api/issues/${source.id}/comments?order=asc`);
|
|
return { runs, comments, source };
|
|
}
|
|
export const runtimeResumeCases = [runtimeDefinition("agents.resume-agent", async ctx => {
|
|
const worker = await runtimeWorker(ctx);
|
|
await ctx.input.api.post(`/api/agents/${worker.id}/pause`, {});
|
|
const paused = await pollUntil({ label: "Genuine manual Morgan pause", deadlineAt: Date.now() + 45_000,
|
|
load: () => ctx.input.api.get<RuntimeAgent>(`/api/agents/${worker.id}`),
|
|
accept: row => row.lifecycleState === "paused" && row.lifecycleHolds?.includes("manual") === true });
|
|
return { worker: paused };
|
|
}, gradeResumeAgent, {
|
|
oracleVersion: "paperclip.chat-catalog.runtime-resume.v3",
|
|
sourceFiles: ["chat-catalog-domains/runtime-resume.ts", "chat-catalog-domains/runtime-support.ts", "chat-scenarios/agents.yaml"],
|
|
setupPolicy: { readiness: "preserve-scenario", invariantCheckIds: ["lead-ready", "manual-pause-only"] },
|
|
expectedRunCount: 2, expectedHumanMessageCount: 2, attemptTimeoutMs: 540_000,
|
|
async verifySetup(ctx, state) {
|
|
const c = checks(); const lead = await ctx.input.api.get<RuntimeAgent>(`/api/agents/${ctx.input.fixtures.agent.id}`);
|
|
const worker = await ctx.input.api.get<RuntimeAgent>(`/api/agents/${(state.worker as RuntimeAgent).id}`);
|
|
c.add("lead-ready", lead.lifecycleState === "ready" && ["idle", "active"].includes(lead.status ?? ""), "The real source agent can receive the query.");
|
|
c.add("manual-pause-only", worker.lifecycleState === "paused" && worker.status === "paused"
|
|
&& worker.lifecycleHolds?.length === 1 && worker.lifecycleHolds[0] === "manual", "No budget, approval, setup or company hold replaces the catalog's first manual-only pause.");
|
|
return c.result();
|
|
},
|
|
async act(ctx, state) {
|
|
const worker = state.worker as RuntimeAgent;
|
|
const first = await pollUntil({ label: "Permitted resume phase settled and saved", deadlineAt: Date.now() + 150_000,
|
|
load: async () => ({ ...await sourcePhase(ctx), worker: await ctx.input.api.get<RuntimeAgent>(`/api/agents/${worker.id}`) }),
|
|
accept: value => value.source.conversationState === "waiting" && value.runs.length === 1 && value.runs[0]?.status === "succeeded"
|
|
&& value.worker.lifecycleState === "ready" && ["idle", "active"].includes(value.worker.status ?? "") });
|
|
state.firstResumedWorker = first.worker;
|
|
state.firstReply = first.comments.filter(row => row.authorAgentId === ctx.input.fixtures.agent.id && row.createdByRunId === first.runs[0]!.id).map(row => row.body ?? "").join("\n");
|
|
state.firstRunIds = first.runs.map(run => run.id); state.firstCommentIds = first.comments.map(row => row.id);
|
|
await ctx.input.api.post(`/api/agents/${worker.id}/pause`, {});
|
|
await ctx.input.api.post(`/api/companies/${ctx.input.fixtures.company.id}/budgets/policies`, { scopeType: "agent", scopeId: worker.id,
|
|
amount: 100, isActive: true, hardStopEnabled: true, unpricedUsagePolicy: "allow" });
|
|
// Zero disables enforcement. Import a truthful accounting fixture through
|
|
// the supported public path; this is not native/model-provider spending.
|
|
const receipt = await ctx.input.api.post<{ id: string }>(`/api/companies/${ctx.input.fixtures.company.id}/cost-events`, {
|
|
agentId: worker.id, provider: "synthetic-eval-import", biller: "synthetic-eval-import", model: "imported-fixture-receipt",
|
|
billingType: "metered_api", costStatus: "reported", costCents: 125, inputTokens: 0, outputTokens: 0,
|
|
occurredAt: new Date().toISOString(), idempotencyKey: `resume-held-prerequisite:${ctx.input.nonce}` });
|
|
state.syntheticReceiptId = receipt.id; state.syntheticReceiptOrigin = "public-imported-synthetic-fixture";
|
|
await ctx.input.evidence("runtime-resume-imported-budget-context.json", { agentId: worker.id, receiptId: receipt.id,
|
|
origin: state.syntheticReceiptOrigin, importedFixtureCostCents: 125, hardCapCents: 100,
|
|
modelProviderUsage: "Not model/provider usage; explicit public synthetic accounting fixture" });
|
|
await pollUntil({ label: "Independent effective budget hold before the second explicit request", deadlineAt: Date.now() + 45_000,
|
|
load: () => ctx.input.api.get<RuntimeAgent>(`/api/agents/${worker.id}`), accept: row => row.lifecycleHolds?.includes("budget") === true });
|
|
await sendChatMessage(ctx.input.page, ctx.scenario.query);
|
|
},
|
|
async observe(ctx, state) {
|
|
const phase = await sourcePhase(ctx);
|
|
if (Array.isArray(state.firstRunIds)) {
|
|
const oldComments = state.firstCommentIds as string[];
|
|
const secondHuman = phase.comments.find(row => !row.authorAgentId && row.body === ctx.scenario.query && !oldComments.includes(row.id));
|
|
state.secondReply = secondHuman ? phase.comments.filter(row => row.authorAgentId === ctx.input.fixtures.agent.id
|
|
&& !oldComments.includes(row.id) && phase.runs.some(run => run.id === row.createdByRunId && !(state.firstRunIds as string[]).includes(run.id))
|
|
&& Date.parse(row.createdAt ?? "") >= Date.parse(secondHuman.createdAt ?? "")).map(row => row.body ?? "").join("\n") : "";
|
|
}
|
|
const budget = await ctx.input.api.get<{ policies: Array<{ scopeId: string; scopeType: string; amount: number; hardStopEnabled: boolean; isActive: boolean }>; activeIncidents: Array<{ scopeId: string; scopeType: string; thresholdType: string; status: string }> }>(`/api/companies/${ctx.input.fixtures.company.id}/budgets/overview`);
|
|
const policy = budget.policies.find(row => row.scopeId === (state.worker as RuntimeAgent).id && row.scopeType === "agent");
|
|
state.secondBudgetAmount = policy?.amount; state.secondBudgetHardStop = policy?.hardStopEnabled;
|
|
state.secondBudgetPolicyActive = policy?.isActive;
|
|
state.secondBudgetIncidentOpen = budget.activeIncidents.some(row => row.scopeId === (state.worker as RuntimeAgent).id
|
|
&& row.scopeType === "agent" && row.thresholdType === "hard" && row.status === "open");
|
|
return observeRuntime(ctx, state);
|
|
},
|
|
})];
|