Files
PaperClipAI/tests/runner-e2e/dashboard-regenerate.ts
T
f4337bb5fb test(evals): land the chat catalog eval harness on master (#19614)
## 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>
2026-11-10 08:02:11 -06:00

307 lines
13 KiB
TypeScript

import path from "node:path";
import { mkdir, open, readFile, readdir, writeFile } from "node:fs/promises";
import { constants } from "node:fs";
import { isSeq, parseDocument } from "yaml";
import { parseCatalogOverview, type CatalogScenarioDescription } from "./catalog-overview.js";
import { stageDashboardBrandAssets } from "./report-assets.js";
import { renderRunnerE2EDashboard, type RunnerDashboardInput } from "./dashboard.js";
import { campaignFeedbackReviewText } from "./feedback-dispositions.js";
import type {
RunnerE2ECampaign,
RunnerE2EHistoryIndex,
RunnerE2EResult,
} from "./types.js";
interface PublishedResult extends RunnerE2EResult {
evidenceValid?: boolean;
evidenceErrors?: string[];
}
interface PublishedCampaign {
schema?: string;
campaignId?: string;
source?: RunnerE2ECampaign["source"];
generatedAt: string;
expected: string[];
results: PublishedResult[];
accounting?: RunnerE2ECampaign["accounting"];
operations?: RunnerE2ECampaign["operations"];
feedbackReview?: RunnerE2ECampaign["feedbackReview"];
braintrustExperiment?: RunnerE2ECampaign["braintrustExperiment"];
presentation?: RunnerDashboardInput["presentation"];
}
function validatePresentation(value: unknown) {
if (value === undefined) return;
const invalid = () => {
throw new Error("Published bundle has invalid presentation metadata in normalized-results.json");
};
if (!value || typeof value !== "object" || Array.isArray(value)) invalid();
const presentation = value as Record<string, unknown>;
for (const field of ["title", "description"]) {
if (presentation[field] !== undefined && typeof presentation[field] !== "string") invalid();
}
if (presentation.notes !== undefined &&
(!Array.isArray(presentation.notes) || presentation.notes.some(note => typeof note !== "string"))) invalid();
if (presentation.links !== undefined &&
(!Array.isArray(presentation.links) || presentation.links.some(link =>
!link || typeof link !== "object" || Array.isArray(link) ||
typeof link.label !== "string" || typeof link.href !== "string"))) invalid();
}
async function relativeFiles(root: string, current = root): Promise<string[]> {
const entries = await readdir(current, { withFileTypes: true }).catch(
() => [],
);
const files: string[] = [];
for (const entry of entries) {
const absolute = path.join(current, entry.name);
if (entry.isDirectory())
files.push(...(await relativeFiles(root, absolute)));
if (entry.isFile()) {
files.push(path.relative(root, absolute).split(path.sep).join("/"));
}
}
return files;
}
async function readOptionalHistory(file: string) {
return readFile(file, "utf8")
.then((value) => JSON.parse(value) as RunnerE2EHistoryIndex)
.then((value) =>
value.schema === "paperclip.runner-e2e.history/v1" ? value : undefined,
)
.catch(() => undefined);
}
export async function regenerateCatalogOverview(input: { metadataFile: string; outputDirectory: string }) {
const file = await open(input.metadataFile, constants.O_RDONLY | constants.O_NOFOLLOW);
let metadata: unknown;
try {
const stat = await file.stat();
if (!stat.isFile() || stat.size > 256 * 1024) throw new Error("Catalog overview input must be a bounded regular JSON file");
metadata = JSON.parse(await file.readFile("utf8"));
} finally {
await file.close();
}
const overview = parseCatalogOverview(metadata);
const catalogDirectory = new URL("./chat-scenarios/", import.meta.url);
const catalogIds = new Set<string>();
const descriptions = new Map<string, CatalogScenarioDescription>();
for (const name of (await readdir(catalogDirectory)).filter(name => name.endsWith(".yaml") && name !== "defaults.yaml")) {
const document = parseDocument(await readFile(new URL(name, catalogDirectory), "utf8"), { uniqueKeys: true });
if (document.errors.length) throw new Error("Invalid static chat catalog");
const sequence = document.get("scenarios", true);
if (!isSeq(sequence)) throw new Error("Invalid static chat catalog");
const scenarios: unknown = sequence.toJSON();
if (!Array.isArray(scenarios)) throw new Error("Invalid static chat catalog");
for (const row of scenarios) {
if (!row || typeof row.id !== "string" || catalogIds.has(row.id)) throw new Error("Invalid static chat catalog identity");
catalogIds.add(row.id);
if (typeof row.query !== "string" || typeof row.expected_outcome !== "string") throw new Error("Missing public catalog description");
descriptions.set(row.id, { query: row.query, expectedOutcome: row.expected_outcome });
}
}
if (catalogIds.size !== 244 || overview.rows.some(row => !catalogIds.has(row.scenarioId))) throw new Error("Catalog overview must match the frozen scenario IDs");
const dashboard = renderRunnerE2EDashboard({ title: "Chat feature catalog", generatedAt: overview.asOf, expected: [], catalog: [], entries: [], catalogOverview: overview, catalogOverviewDescriptions: descriptions });
await mkdir(input.outputDirectory, { mode: 0o700 });
await writeFile(path.join(input.outputDirectory, "index.html"), dashboard, { mode: 0o600, flag: "wx" });
await stageDashboardBrandAssets(input.outputDirectory);
}
export async function regenerateRunnerDashboard(input: {
bundle: string;
historyFile?: string | null;
outputDirectory?: string;
evidenceHrefPrefix?: string;
feedbackReviewFile?: string;
}) {
const { runnerMatrix } = await import("./catalog.js");
const { buildRunnerCampaign, canonicalExecutionId, upgradeRunnerResult } = await import("./history.js");
const bundle = path.resolve(input.bundle);
const outputDirectory = path.resolve(input.outputDirectory ?? bundle);
const evidenceHrefPrefix = input.evidenceHrefPrefix?.replace(
/^\/+|\/+$/g,
"",
);
if (
evidenceHrefPrefix &&
(evidenceHrefPrefix.includes("\\") ||
evidenceHrefPrefix
.split("/")
.some((segment) => !segment || segment === "." || segment === ".."))
) {
throw new Error("evidenceHrefPrefix must be a safe relative URL path");
}
const normalized = JSON.parse(
await readFile(path.join(bundle, "normalized-results.json"), "utf8"),
) as PublishedCampaign;
if (
!Array.isArray(normalized.expected) ||
!Array.isArray(normalized.results) ||
typeof normalized.generatedAt !== "string"
) {
throw new Error("Published bundle has an invalid normalized-results.json");
}
validatePresentation(normalized.presentation);
const results = normalized.results.map(upgradeRunnerResult);
const expected = normalized.expected.map(canonicalExecutionId);
const campaign: RunnerE2ECampaign = buildRunnerCampaign({
campaignId:
normalized.campaignId ??
`legacy-${normalized.generatedAt.replace(/[:.]/g, "-")}`,
generatedAt: normalized.generatedAt,
expected,
results,
accounting: normalized.accounting,
operations: normalized.operations,
braintrustExperiment: normalized.braintrustExperiment,
feedbackReview: input.feedbackReviewFile
? JSON.parse(await readFile(input.feedbackReviewFile, "utf8"))
: normalized.feedbackReview,
});
// A rendering refresh must not inherit the renderer's checkout or CI event.
const retainedSource = results.find((result) =>
result.source?.sha || result.source?.ref || result.source?.workflowRunUrl,
)?.source;
campaign.source = normalized.source ?? {
sha: retainedSource?.sha ?? null,
ref: retainedSource?.ref ?? null,
workflowRunUrl: retainedSource?.workflowRunUrl ?? null,
eventName: null,
};
const history =
input.historyFile === null
? undefined
: await readOptionalHistory(
input.historyFile ?? path.join(bundle, "history.json"),
);
const entries = await Promise.all(
normalized.results.map(async (publishedResult, index) => {
const result = results[index]!;
const originalExecutionId = publishedResult.executionId;
const evidenceIds = [
originalExecutionId,
...(originalExecutionId.startsWith("core-compatibility.")
? [originalExecutionId.slice("core-compatibility.".length)]
: []),
];
let evidenceBaseHref = "";
let evidenceFiles: string[] = [];
for (const evidenceId of evidenceIds) {
const candidate = [
"evidence",
evidenceId,
`attempt-${result.attempt}`,
].join("/");
const files = await relativeFiles(
path.join(bundle, ...candidate.split("/")),
);
if (files.length === 0) continue;
evidenceBaseHref = [evidenceHrefPrefix, candidate]
.filter(Boolean)
.join("/");
evidenceFiles = files;
break;
}
return {
result,
valid:
result.status === "passed" && result.cleanup === "passed" &&
publishedResult.evidenceValid !== false,
errors: publishedResult.evidenceErrors ?? [],
evidenceBaseHref,
evidenceFiles,
};
}),
);
const summary = await readFile(path.join(bundle, "summary.md"), "utf8").then(text => text.replace(
/^(Feedback across retained attempts:.*?); [0-9]+ distinct items awaiting review;/m, "$1;",
)).catch((error: NodeJS.ErrnoException) => {
if (error.code === "ENOENT") return undefined;
throw error;
});
const dashboard = renderRunnerE2EDashboard({
title: normalized.presentation?.title ?? "E2E tests",
generatedAt: normalized.generatedAt,
expected,
catalog: runnerMatrix,
entries,
campaign,
summaryAvailable: summary !== undefined,
summaryText: summary,
history,
presentation: normalized.presentation,
});
const upgraded = {
...campaign,
...(normalized.presentation ? { presentation: normalized.presentation } : {}),
results: campaign.results.map((result, index) => ({
...result,
evidenceValid:
normalized.results[index]?.evidenceValid ??
(result.status === "passed" && result.cleanup === "passed"),
evidenceErrors: normalized.results[index]?.evidenceErrors ?? [],
})),
};
const reviewStatus = /^(?:[0-9]+ distinct feedback items await review\.|Feedback reviewed by (?:human|agent) at ).*$/m;
const refreshedSummary = summary === undefined ? undefined : reviewStatus.test(summary)
? summary.replace(reviewStatus, () => campaignFeedbackReviewText(campaign))
: `${summary.trimEnd()}\n\n## Campaign feedback review\n\n${campaignFeedbackReviewText(campaign)}\n`;
await mkdir(outputDirectory, { recursive: true });
await Promise.all([
writeFile(path.join(outputDirectory, "index.html"), dashboard, "utf8"),
writeFile(path.join(outputDirectory, "dashboard.html"), dashboard, "utf8"),
...(refreshedSummary === undefined ? [] : [writeFile(path.join(outputDirectory, "summary.md"), refreshedSummary, "utf8")]),
writeFile(
path.join(outputDirectory, "normalized-results.json"),
`${JSON.stringify(upgraded, null, 2)}\n`,
"utf8",
),
]);
console.log(
`Regenerated dashboard from ${normalized.results.length} retained result(s) in ${outputDirectory}`,
);
}
async function main() {
const arguments_ = process.argv
.slice(2)
.filter((argument) => argument !== "--");
if (arguments_.includes("--catalog-overview")) {
const index = arguments_.indexOf("--catalog-overview");
const output = arguments_.indexOf("--output");
if (index !== 0 || output !== 2 || arguments_.length !== 4 || !arguments_[1] || !arguments_[3]) throw new Error("Usage: dashboard --catalog-overview <display-metadata.json> --output <new-private-directory>");
await regenerateCatalogOverview({ metadataFile: arguments_[1], outputDirectory: arguments_[3] });
return;
}
const bundleArgument = arguments_.find(
(argument) => !argument.startsWith("--"),
);
if (!bundleArgument) {
throw new Error(
"Usage: pnpm test:e2e:runner:dashboard -- <published-bundle-directory> [--history <history.json>] [--feedback-review <review.json>]",
);
}
const historyIndex = arguments_.indexOf("--history");
const historyFile =
historyIndex >= 0 ? arguments_[historyIndex + 1] : undefined;
if (historyIndex >= 0 && !historyFile)
throw new Error("--history requires a path");
const reviewIndex = arguments_.indexOf("--feedback-review");
const feedbackReviewFile = reviewIndex >= 0 ? arguments_[reviewIndex + 1] : undefined;
if (reviewIndex >= 0 && !feedbackReviewFile) throw new Error("--feedback-review requires a path");
await regenerateRunnerDashboard({ bundle: bundleArgument, historyFile, feedbackReviewFile });
}
if (
process.argv[1] &&
path.resolve(process.argv[1]) === path.resolve(import.meta.filename)
) {
await main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
});
}