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-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>
122 lines
13 KiB
TypeScript
122 lines
13 KiB
TypeScript
/** Read-only closed-metadata reporting. No result, diagnosis, or campaign mutation. */
|
|
import { createHash } from "node:crypto";
|
|
import { readFileSync, realpathSync } from "node:fs";
|
|
import path from "node:path";
|
|
import { pathToFileURL } from "node:url";
|
|
import { campaignSummary, campaignCleanupResolved, type CampaignAttempt, type CampaignScenario, type ChatCampaign } from "./chat-campaign.js";
|
|
import { chatQueryWithAttachments, type ChatQueryAttachmentReceipt } from "./chat-query-attachments.js";
|
|
|
|
type Json = Record<string, unknown>;
|
|
const object = (v: unknown): Json => v && typeof v === "object" && !Array.isArray(v) ? v as Json : {};
|
|
const array = (v: unknown): unknown[] => Array.isArray(v) ? v : [];
|
|
const sha = (v: string) => createHash("sha256").update(v).digest("hex");
|
|
export interface ReportProof { path: string; sha256: string }
|
|
export interface ReportStage { state: "proven" | "unknown" | "not-applicable"; proofs: ReportProof[] }
|
|
export interface RetainedReportDocument { path: string; sha256: string; value: unknown }
|
|
/** Documents are supplied only from a result-bound attempt directory by the reader. */
|
|
export function extractCampaignStages(scenario: CampaignScenario, attempt: CampaignAttempt, documents: Record<string, RetainedReportDocument>) {
|
|
const stage = (): ReportStage => ({ state: "unknown", proofs: [] });
|
|
const stages = { setupBlockedBeforeCanonical: stage(), canonicalDispatch: stage(), canonicalPersisted: stage(), canonicalNativeAdmission: stage(), featureGradingReturned: stage(), observationGradeRecorded: stage(), intentionalNoChatBoundary: stage() };
|
|
const use = (target: ReportStage, ...names: string[]) => { target.state = "proven"; target.proofs = [...new Set(names)].map(name => ({ path: documents[name]!.path, sha256: documents[name]!.sha256 })); };
|
|
const value = (name: string) => object(documents[name]?.value);
|
|
const result = value("result.json");
|
|
if (!["paperclip.runner-e2e.result/v1", "paperclip.runner-e2e.result/v2"].includes(String(result.schema)) || result.executionId !== attempt.executionId || object(result.source).sha !== attempt.sourceRevision) return stages;
|
|
const definition = value("chat-catalog-definition.json"), boundaryDefinition = value("chat-boundary-definition.json");
|
|
const bound = (d: Json) => d.scenarioId === scenario.id && d.definitionDigest === attempt.definitionDigest && d.graderDigest === attempt.graderDigest;
|
|
const boundaryIntent = value("chat-boundary-intent.json");
|
|
const boundary = bound(boundaryDefinition) && boundaryDefinition.journeyKind === "browser-boundary" && boundaryIntent.scenarioId === scenario.id && boundaryIntent.originalQuery === scenario.query && boundaryIntent.querySent === false;
|
|
if (boundary) {
|
|
use(stages.intentionalNoChatBoundary, "result.json", "chat-boundary-definition.json", "chat-boundary-intent.json");
|
|
for (const name of ["canonicalDispatch", "canonicalPersisted", "canonicalNativeAdmission"] as const) stages[name] = { state: "not-applicable", proofs: stages.intentionalNoChatBoundary.proofs };
|
|
}
|
|
const prerequisite = value("chat-catalog-input-prerequisite.json");
|
|
if (prerequisite.scenarioId === scenario.id && prerequisite.phase === "reserved-setup" && prerequisite.querySent === false && prerequisite.modelAdmissions === 0 && array(prerequisite.blockers).length)
|
|
use(stages.setupBlockedBeforeCanonical, "result.json", "chat-catalog-input-prerequisite.json");
|
|
if (!bound(definition) && !boundary) return stages;
|
|
const observationName = boundary ? "chat-boundary-observation.json" : documents["chat-feature.json"] ? "chat-feature.json" : "chat-catalog-observation.json";
|
|
const observation = value(observationName), grade = object(observation.grade);
|
|
const actualChecks = array(grade.checks).map(object).filter(check => typeof check.id === "string" && typeof check.passed === "boolean");
|
|
if (observation.scenarioId === scenario.id && actualChecks.length) use(stages.observationGradeRecorded, "result.json", boundary ? "chat-boundary-definition.json" : "chat-catalog-definition.json", observationName);
|
|
// Finally also writes observations. Only matching returned check receipts establish
|
|
// normal feature grading/verifyResult completion; never infer it from an exception.
|
|
const returned = array(result.matcherResults).map(object);
|
|
const returnedGrade = stages.observationGradeRecorded.state === "proven" && actualChecks.every(check => returned.some(row => object(row.matcher).path === `chat.${check.id}` && row.passed === check.passed));
|
|
if (boundary) { if (returnedGrade) use(stages.featureGradingReturned, "result.json", observationName); return stages; }
|
|
const legacy = scenario.id === "tasks.create-backlog" || scenario.id === "tasks.committed-send-retry";
|
|
const conversationName = legacy ? "chat-feature.json" : "chat-catalog-conversation.json";
|
|
const conversation = legacy ? object(value(conversationName).observation) : value(conversationName);
|
|
const source = object(conversation[legacy ? "source" : "issue"]);
|
|
const binding = value("chat-catalog-query-binding.json"), attachments = value("chat-catalog-query-attachments-before-send.json");
|
|
let query: string | undefined;
|
|
if (legacy && conversation.query === scenario.query) query = scenario.query;
|
|
if (!legacy && binding.scenarioId === scenario.id && binding.originalQuery === scenario.query && typeof binding.renderedQuery === "string") query = binding.renderedQuery;
|
|
if (query && documents["chat-catalog-query-attachments-before-send.json"]) {
|
|
const receipts = array(attachments.receipts).map(object);
|
|
if (attachments.originalQuery !== scenario.query || attachments.renderedQuery !== query || receipts.length !== 1 || typeof receipts[0]?.originalFilename !== "string" || typeof receipts[0]?.contentPath !== "string") query = undefined;
|
|
else {
|
|
const formatted = chatQueryWithAttachments(query, receipts as unknown as ChatQueryAttachmentReceipt[]);
|
|
query = attachments.persistedQuery === formatted ? formatted : undefined;
|
|
}
|
|
}
|
|
const setup = value("chat-catalog-setup-boundary.json");
|
|
if (!query || typeof source.id !== "string" || typeof source.companyId !== "string" || (!legacy && !Array.isArray(setup.humanCommentIds))) return stages;
|
|
const comments = array(conversation.comments).map(object).filter(comment => typeof comment.id === "string" && comment.issueId === source.id && comment.companyId === source.companyId && !comment.authorAgentId && !!comment.authorUserId && (!source.conversationUserId || comment.authorUserId === source.conversationUserId) && comment.body === query && !array(setup.humanCommentIds).includes(comment.id));
|
|
if (comments.length !== 1) return stages;
|
|
const proofNames = ["result.json", "chat-catalog-definition.json", conversationName, ...(!legacy ? ["chat-catalog-query-binding.json", "chat-catalog-setup-boundary.json"] : []), ...(documents["chat-catalog-query-attachments-before-send.json"] ? ["chat-catalog-query-attachments-before-send.json"] : [])];
|
|
use(stages.canonicalPersisted, ...proofNames); use(stages.canonicalDispatch, ...proofNames);
|
|
// Contradictory retained receipts cannot establish pre-query setup blockage.
|
|
stages.setupBlockedBeforeCanonical = stage();
|
|
if (returnedGrade) use(stages.featureGradingReturned, ...proofNames, observationName);
|
|
// A saved comment can precede a failed wake/admission (actual HTTP409 case).
|
|
// A native run must bind this exact source and new human comment.
|
|
if (array(conversation.runs).map(object).some(run => run.companyId === source.companyId && (!source.conversationAgentId || run.agentId === source.conversationAgentId) && run.runtimeMode === "native" && object(run.contextSnapshot).issueId === source.id && object(run.contextSnapshot).commentId === comments[0]!.id && !array(setup.runIds).includes(run.id))) use(stages.canonicalNativeAdmission, ...proofNames);
|
|
return stages;
|
|
}
|
|
|
|
const snapshotNames = ["chat-catalog-definition.json", "chat-boundary-definition.json", "chat-boundary-intent.json", "chat-boundary-observation.json", "chat-catalog-input-prerequisite.json", "chat-catalog-conversation.json", "chat-catalog-query-binding.json", "chat-catalog-query-attachments-before-send.json", "chat-catalog-setup-boundary.json", "chat-catalog-observation.json", "chat-feature.json"];
|
|
export function readCampaignReportEvidence(root: string, attempt: CampaignAttempt) {
|
|
const documents: Record<string, RetainedReportDocument> = {};
|
|
const evidenceRoot = realpathSync(path.join(root, "tests/runner-e2e/results"));
|
|
function read(filename: string, name: string) {
|
|
try {
|
|
const resolved = realpathSync(filename);
|
|
if (!resolved.startsWith(`${evidenceRoot}${path.sep}`)) return;
|
|
const bytes = readFileSync(resolved, "utf8");
|
|
documents[name] = { path: path.relative(root, resolved), sha256: sha(bytes), value: JSON.parse(bytes) };
|
|
} catch { /* Missing/corrupt/escaped evidence stays unknown. */ }
|
|
}
|
|
const results = attempt.evidencePaths.filter(p => p.endsWith("/result.json"));
|
|
if (results.length !== 1) return documents;
|
|
const filename = path.resolve(root, results[0]!); read(filename, "result.json");
|
|
if (!documents["result.json"]) return documents;
|
|
for (const name of snapshotNames) read(path.join(path.dirname(filename), "snapshots", name), name);
|
|
return documents;
|
|
}
|
|
export function buildChatCampaignReport(campaign: ChatCampaign, root: string) {
|
|
if (campaign.denominator !== 244 || campaign.areas !== 35 || campaign.capabilities !== 191 || campaign.requiredPasses !== 196 || campaign.scenarios.length !== 244 || new Set(campaign.scenarios.map(s => s.id)).size !== 244 || new Set(campaign.scenarios.map(s => s.feature)).size !== 35 || new Set(campaign.scenarios.flatMap(s => s.capabilities)).size !== 191) throw new Error("Report requires the complete frozen 244/35/191 inventory and 196 gate");
|
|
const summary = campaignSummary(campaign, root);
|
|
const scenarios = campaign.scenarios.map(scenario => {
|
|
const eligibility = campaignSummary({ ...campaign, scenarios: [scenario] }, root);
|
|
return { id: scenario.id, area: scenario.feature, capabilities: scenario.capabilities, readiness: scenario.readiness, authoringStatus: scenario.authoringStatus ?? "unknown",
|
|
attempted: scenario.attempts.length > 0, initialEligiblePass: eligibility.initialPassed === 1, finalEligiblePass: eligibility.finalPassed === 1,
|
|
attempts: scenario.attempts.map(attempt => ({ ordinal: attempt.ordinal, recordedOutcome: attempt.outcome, classification: attempt.classification, diagnosisHistoryCount: attempt.diagnosisHistory?.length ?? 0,
|
|
sourceRevision: attempt.sourceRevision, workingTreeDigest: attempt.workingTreeDigest, definitionDigest: attempt.definitionDigest, graderDigest: attempt.graderDigest, suiteDefinitionDigest: attempt.suiteDefinitionDigest,
|
|
model: attempt.model, profile: attempt.profile, environment: attempt.environment, databaseLane: attempt.databaseLane ?? { lane: "embedded-postgres" }, productIdentity: attempt.productIdentity ?? { kind: "unknown" },
|
|
startedAt: attempt.startedAt, finishedAt: attempt.finishedAt, usage: attempt.usage, dollars: attempt.usage.costUsd === null ? "unpriced-or-unknown" : "recorded",
|
|
cleanup: attempt.cleanup, cleanupResolved: campaignCleanupResolved(attempt, root), corePrDependencies: attempt.corePrDependencies,
|
|
evidencePaths: attempt.evidencePaths, stages: extractCampaignStages(scenario, attempt, readCampaignReportEvidence(root, attempt)) })) };
|
|
});
|
|
const attempts = scenarios.flatMap(s => s.attempts);
|
|
return { schema: "paperclip.chat-campaign-report/v1", disposition: "incomplete-read-only-proof-summary", campaignId: campaign.campaignId, campaignSnapshotSha256: sha(JSON.stringify(campaign)), reportGeneratorSha256: sha(readFileSync(import.meta.filename, "utf8")), baselineRevision: campaign.baselineRevision, inventoryDigest: campaign.inventoryDigest,
|
|
frozen: { denominator: 244, areas: 35, capabilities: 191, requiredPasses: 196 }, eligibility: summary,
|
|
stageSemantics: { canonicalDispatch: "Exact persisted source human proves dispatch; query intent or unbound HTTP traffic does not.", canonicalPersisted: "Exact original/audited-rendered human body on independently bound source, excluding setup comments.", canonicalNativeAdmission: "Native run on that source bound to the exact new canonical comment; saved comment alone is insufficient.", featureGradingReturned: "Exact independent checks returned after verification, plus canonical or explicit boundary proof.", observationGradeRecorded: "Snapshot grade may originate in finally; does not establish completed grading.", setupBlockedBeforeCanonical: "Explicit reserved-setup blocker receipt only; missing outputs/zero runs remain unknown.", intentionalNoChatBoundary: "Selected fingerprinted boundary definition and actual exact unsent-query intent; not feature success." },
|
|
proofCounts: Object.fromEntries(Object.keys(attempts[0]?.stages ?? {}).map(key => [key, { proven: attempts.filter(a => a.stages[key as keyof typeof a.stages].state === "proven").length, unknown: attempts.filter(a => a.stages[key as keyof typeof a.stages].state === "unknown").length, notApplicable: attempts.filter(a => a.stages[key as keyof typeof a.stages].state === "not-applicable").length }])),
|
|
scenarios };
|
|
}
|
|
// Explicit read-only CLI: caller chooses an ignored output destination via stdout.
|
|
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
|
|
const root = path.resolve(import.meta.dirname, "../..");
|
|
const manifest = JSON.parse(readFileSync(path.join(root, "tests/runner-e2e/chat-campaign-manifest.json"), "utf8")) as ChatCampaign;
|
|
process.stdout.write(`${JSON.stringify(buildChatCampaignReport(manifest, root), null, 2)}\n`);
|
|
}
|