mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-13 07:12: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>
292 lines
16 KiB
TypeScript
292 lines
16 KiB
TypeScript
import type { APIRequestContext, APIResponse } from "@playwright/test";
|
|
|
|
export interface JsonRecord {
|
|
[key: string]: unknown;
|
|
}
|
|
|
|
async function failureMessage(response: APIResponse, method: string) {
|
|
const text = await response.text().catch(() => "");
|
|
return `${method} ${response.url()} returned ${response.status()}${text ? `: ${text}` : ""}`;
|
|
}
|
|
|
|
/** Preserve HTTP status independently of diagnostic response text. */
|
|
export class RunnerApiHttpError extends Error {
|
|
constructor(readonly status: number, message: string) { super(message); this.name = "RunnerApiHttpError"; }
|
|
}
|
|
|
|
export type SkillSourceUpdateCode = "skill_source_ref_unavailable" | "skill_source_github_rate_limited" | "skill_source_github_read_failed" | "unclassified";
|
|
export class SkillSourceUpdateReadError extends RunnerApiHttpError {
|
|
constructor(status: number, readonly reason: SkillSourceUpdateCode, readonly providerStatus: number | null) {
|
|
super(status, `Skill source update prerequisite failed (HTTP ${status}; reason=${reason}; providerStatus=${providerStatus ?? "unknown"}); body withheld`);
|
|
this.name = "SkillSourceUpdateReadError";
|
|
}
|
|
}
|
|
async function closedSkillUpdateFailure(response: Response): Promise<{ reason: SkillSourceUpdateCode; providerStatus: number | null }> {
|
|
const unknown = { reason: "unclassified" as const, providerStatus: null };
|
|
const reader = response.body?.getReader(); if (!reader) return unknown;
|
|
const chunks: Uint8Array[] = []; let bytes = 0, done = false;
|
|
try {
|
|
for (;;) {
|
|
const next = await reader.read(); if (next.done) { done = true; break; }
|
|
bytes += next.value.byteLength; if (bytes > 16_384) return unknown;
|
|
chunks.push(next.value);
|
|
}
|
|
const body = JSON.parse(Buffer.concat(chunks).toString("utf8")) as { code?: unknown; details?: { code?: unknown; status?: unknown } };
|
|
if (!body || typeof body !== "object" || Array.isArray(body)) return unknown;
|
|
const codes: readonly string[] = ["skill_source_ref_unavailable", "skill_source_github_rate_limited", "skill_source_github_read_failed"];
|
|
if (typeof body.code !== "string" || !codes.includes(body.code) || body.details?.code !== undefined && body.details.code !== body.code) return unknown;
|
|
const status = body.details?.status;
|
|
return { reason: body.code as SkillSourceUpdateCode, providerStatus: typeof status === "number" && [401, 403, 404, 422, 429, 500, 502, 503, 504].includes(status) ? status : null };
|
|
} catch { return unknown; }
|
|
finally { if (!done) await reader.cancel().catch(() => {}); reader.releaseLock(); }
|
|
}
|
|
|
|
/** Admission-only diagnostics. Never retain the rejected value or its cause. */
|
|
export class RemoteAdmissionReadError extends Error {
|
|
constructor(
|
|
readonly endpoint: "issue" | "run" | "leases",
|
|
readonly failureClass: "candidate_failure" | "transient_infrastructure" | "permanent_infrastructure",
|
|
) {
|
|
super(`Remote admission ${endpoint} read failed (${failureClass}); diagnostics withheld`);
|
|
this.name = "RemoteAdmissionReadError";
|
|
}
|
|
}
|
|
|
|
export class RunnerApi {
|
|
readonly baseURL: string;
|
|
private browserCookie?: string;
|
|
|
|
setBrowserSession(cookie: string) { this.browserCookie = cookie; }
|
|
private sessionOptions() {
|
|
return this.browserCookie ? { headers: { Cookie: this.browserCookie, Origin: this.baseURL } } : {};
|
|
}
|
|
|
|
constructor(readonly request: APIRequestContext) {
|
|
const port = process.env.PAPERCLIP_RUNNER_E2E_PORT?.trim();
|
|
if (!port) throw new Error("PAPERCLIP_RUNNER_E2E_PORT is required");
|
|
this.baseURL = `http://127.0.0.1:${port}`;
|
|
}
|
|
|
|
async get<T>(path: string, options?: { timeout: number }): Promise<T> {
|
|
if (this.browserCookie) return this.authenticatedJson<T>("GET", path, undefined, options);
|
|
const response = await this.request.get(path, { ...this.sessionOptions(), ...options });
|
|
if (!response.ok()) throw new RunnerApiHttpError(response.status(), await failureMessage(response, "GET"));
|
|
return response.json() as Promise<T>;
|
|
}
|
|
|
|
async getSkillSourceUpdateStatus<T>(companyId: string, skillId: string, options?: { timeout: number }): Promise<T> {
|
|
const uuid = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i;
|
|
if (!this.browserCookie || !uuid.test(companyId) || !uuid.test(skillId)) throw new Error("Skill source update prerequisite requires the exact owned human-session endpoint");
|
|
const response = await this.authenticatedResponse("GET", `/api/companies/${companyId}/skills/${skillId}/update-status`, undefined, options);
|
|
if (!response.ok) {
|
|
const closed = await closedSkillUpdateFailure(response);
|
|
throw new SkillSourceUpdateReadError(response.status, closed.reason, closed.providerStatus);
|
|
}
|
|
try { return await response.json() as T; } catch { throw new Error("Skill source update prerequisite returned invalid JSON; body withheld"); }
|
|
}
|
|
|
|
async bytes(path: string): Promise<Buffer> {
|
|
const response = await this.request.get(path, this.sessionOptions());
|
|
if (!response.ok()) throw new RunnerApiHttpError(response.status(), await failureMessage(response, "GET"));
|
|
return response.body();
|
|
}
|
|
|
|
|
|
/** Repeated native observation reads retain explicit snapshots, without duplicating full DTO bodies in the browser trace. */
|
|
async getRunObservation<T>(path: string, options?: { timeout: number }): Promise<T> {
|
|
const url = new URL(path, this.baseURL);
|
|
const uuid = "[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}";
|
|
const companyRuns = new RegExp(`^/api/companies/${uuid}/heartbeat-runs$`, "i").test(url.pathname)
|
|
&& [...url.searchParams.keys()].every(key => key === "limit") && url.searchParams.get("limit") === "100";
|
|
const run = new RegExp(`^/api/heartbeat-runs/${uuid}$`, "i").test(url.pathname) && !url.search;
|
|
const events = new RegExp(`^/api/heartbeat-runs/${uuid}/events$`, "i").test(url.pathname)
|
|
&& [...url.searchParams.keys()].every(key => ["afterSeq", "limit"].includes(key))
|
|
&& /^\d+$/.test(url.searchParams.get("afterSeq") ?? "") && /^[1-9]\d*$/.test(url.searchParams.get("limit") ?? "");
|
|
// Catalog observers re-read the full company inventory and activity on every poll; tracing those bodies
|
|
// made large-fixture failure evidence exceed the private archive allowance, so they share this untraced path.
|
|
const exactQuery = (expected: Record<string, string>) => [...url.searchParams.keys()].length === Object.keys(expected).length
|
|
&& Object.entries(expected).every(([key, value]) => url.searchParams.getAll(key).length === 1 && url.searchParams.get(key) === value);
|
|
const companyIssues = new RegExp(`^/api/companies/${uuid}/issues$`, "i").test(url.pathname) && exactQuery({ includeAgentCreatedTasks: "true", limit: "1000" });
|
|
const companyActivity = new RegExp(`^/api/companies/${uuid}/activity$`, "i").test(url.pathname) && exactQuery({ limit: "1000" });
|
|
if (url.origin !== this.baseURL || url.username || url.password || url.hash || !(companyRuns || run || events || companyIssues || companyActivity)) throw new Error("Run observation requires an allowlisted same-origin GET endpoint");
|
|
return this.authenticatedJson<T>("GET", path, undefined, options);
|
|
}
|
|
|
|
/** Fetch owned attachment bytes with the same authenticated actor as JSON reads. */
|
|
async getAttachmentContent(attachmentId: string): Promise<Buffer> {
|
|
if (!/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test(attachmentId)) throw new Error("Invalid fixture attachment identity");
|
|
if (this.browserCookie) {
|
|
const path = `/api/attachments/${attachmentId}/content`; const response = await this.authenticatedResponse("GET", path);
|
|
if (!response.ok) { await response.body?.cancel(); throw new RunnerApiHttpError(response.status, `GET ${path} returned ${response.status}; body withheld`); }
|
|
try { return Buffer.from(await response.arrayBuffer()); } catch { throw new Error(`GET ${path} byte response failed; details withheld`); }
|
|
}
|
|
const response = await this.request.get(`/api/attachments/${attachmentId}/content`, this.sessionOptions());
|
|
if (!response.ok()) throw new RunnerApiHttpError(response.status(), await failureMessage(response, "GET"));
|
|
return response.body();
|
|
}
|
|
|
|
async post<T>(path: string, data?: unknown, options?: { timeout: number }): Promise<T> {
|
|
if (this.browserCookie) return this.authenticatedJson<T>("POST", path, data, options);
|
|
const response = await this.request.post(path, { data, ...this.sessionOptions(), ...options });
|
|
if (!response.ok()) throw new Error(await failureMessage(response, "POST"));
|
|
return response.json() as Promise<T>;
|
|
}
|
|
|
|
/**
|
|
* Playwright traces APIRequestContext request bodies. Secret creation must
|
|
* still use the public API, but it goes through Node fetch so plaintext is
|
|
* never serialized into trace/blob evidence before Paperclip encrypts it.
|
|
*/
|
|
async postSensitive<T>(path: string, data: unknown): Promise<T> {
|
|
return this.sensitive<T>("POST", path, data);
|
|
}
|
|
|
|
async putSensitive<T>(path: string, data: unknown): Promise<T> {
|
|
return this.sensitive<T>("PUT", path, data);
|
|
}
|
|
|
|
async patchSensitive<T>(path: string, data: unknown): Promise<T> {
|
|
return this.sensitive<T>("PATCH", path, data);
|
|
}
|
|
|
|
private async sensitive<T>(method: "POST" | "PUT" | "PATCH", path: string, data: unknown): Promise<T> {
|
|
const url = new URL(path, this.baseURL);
|
|
if (url.origin !== this.baseURL || !url.pathname.startsWith("/api/")) throw new Error("Sensitive request must use the fixture API");
|
|
let response: Response;
|
|
try {
|
|
response = await fetch(url, {
|
|
method, redirect: "error", signal: AbortSignal.timeout(15_000),
|
|
headers: { "content-type": "application/json", ...this.sessionOptions().headers },
|
|
body: JSON.stringify(data),
|
|
});
|
|
} catch { throw new Error(`Sensitive ${method} request failed; details withheld`); }
|
|
if (!response.ok) {
|
|
await response.body?.cancel();
|
|
throw new Error(`Sensitive ${method} ${url.pathname} returned ${response.status}; response body withheld`);
|
|
}
|
|
try { return await response.json() as T; } catch { throw new Error(`Sensitive ${method} ${url.pathname} returned invalid JSON; response body withheld`); }
|
|
}
|
|
|
|
async patch<T>(path: string, data: unknown): Promise<T> {
|
|
if (this.browserCookie) return this.authenticatedJson<T>("PATCH", path, data);
|
|
const response = await this.request.patch(path, { data, ...this.sessionOptions() });
|
|
if (!response.ok())
|
|
throw new Error(await failureMessage(response, "PATCH"));
|
|
return response.json() as Promise<T>;
|
|
}
|
|
|
|
/** Use the public revision fence when configuring a fixture's instruction entry. */
|
|
async saveAgentInstructions(agentId: string, content: string): Promise<void> {
|
|
const path = `/api/agents/${agentId}/instructions-bundle/file`;
|
|
if (this.browserCookie) {
|
|
let baseHash: string | null = null;
|
|
try {
|
|
const detail = await this.authenticatedJson<{ contentHash: string }>("GET", `${path}?path=AGENTS.md`);
|
|
if (typeof detail.contentHash !== "string" || !detail.contentHash) throw new Error("Existing fixture instructions have no revision hash");
|
|
baseHash = detail.contentHash;
|
|
} catch (error) { if (!(error instanceof RunnerApiHttpError) || error.status !== 404) throw error; }
|
|
await this.authenticatedJson("PUT", path, { path: "AGENTS.md", content, baseHash }); return;
|
|
}
|
|
const current = await this.request.get(`${path}?path=AGENTS.md`);
|
|
let baseHash: string | null = null;
|
|
if (current.ok()) {
|
|
const detail = await current.json();
|
|
if (typeof detail.contentHash !== "string" || !detail.contentHash) {
|
|
throw new Error("Existing fixture instructions have no revision hash");
|
|
}
|
|
baseHash = detail.contentHash;
|
|
} else if (current.status() !== 404) {
|
|
throw new Error(await failureMessage(current, "GET"));
|
|
}
|
|
const saved = await this.request.put(path, { data: { path: "AGENTS.md", content, baseHash } });
|
|
if (!saved.ok()) throw new Error(await failureMessage(saved, "PUT"));
|
|
}
|
|
|
|
async delete(
|
|
path: string,
|
|
options?: { allowNotFound?: boolean; timeout?: number },
|
|
): Promise<void> {
|
|
if (this.browserCookie) {
|
|
const response = await this.authenticatedResponse("DELETE", path, undefined, options?.timeout === undefined ? undefined : { timeout: options.timeout }); await response.body?.cancel();
|
|
if (response.ok || options?.allowNotFound && response.status === 404) return;
|
|
throw new RunnerApiHttpError(response.status, `DELETE ${new URL(path, this.baseURL).pathname} returned ${response.status}; body withheld`);
|
|
}
|
|
const response = await this.request.delete(path, { ...this.sessionOptions(), timeout: options?.timeout });
|
|
if (response.ok() || (options?.allowNotFound && response.status() === 404))
|
|
return;
|
|
throw new Error(await failureMessage(response, "DELETE"));
|
|
}
|
|
|
|
/** Explicit session cookies never become Playwright API-step parameters.
|
|
* Public responses remain untouched for independent product/leak oracles. */
|
|
private async authenticatedResponse(method: string, path: string, data?: unknown, options?: { timeout: number }): Promise<Response> {
|
|
const url = new URL(path, this.baseURL);
|
|
if (url.origin !== this.baseURL || !url.pathname.startsWith("/api/")) throw new Error("Authenticated request must use the fixture API");
|
|
const timeout = options?.timeout ?? 30_000;
|
|
try {
|
|
return await fetch(url, { method, redirect: "error", ...(timeout === 0 ? {} : { signal: AbortSignal.timeout(timeout) }),
|
|
headers: { "content-type": "application/json", ...this.sessionOptions().headers }, ...(data === undefined ? {} : { body: JSON.stringify(data) }) });
|
|
} catch { throw new Error(`Authenticated ${method} ${url.pathname} connection request failed; details withheld`); }
|
|
}
|
|
|
|
private async authenticatedJson<T>(method: string, path: string, data?: unknown, options?: { timeout: number }): Promise<T> {
|
|
const response = await this.authenticatedResponse(method, path, data, options);
|
|
const endpoint = new URL(path, this.baseURL).pathname;
|
|
if (!response.ok) { await response.body?.cancel(); throw new RunnerApiHttpError(response.status, `${method} ${endpoint} returned ${response.status}; body withheld`); }
|
|
try { return await response.json() as T; } catch { throw new Error(`${method} ${endpoint} returned ${response.status} with invalid JSON; body withheld`); }
|
|
}
|
|
}
|
|
|
|
export class ObservedStateTimeout extends Error {
|
|
constructor(label: string, readonly failureClass: "candidate_failure" | "transient_infrastructure", detail?: string) {
|
|
super(`Timed out waiting for ${label}; ${detail ?? "the observed state did not satisfy the condition"}. See the saved state evidence.`);
|
|
this.name = "ObservedStateTimeout";
|
|
}
|
|
}
|
|
|
|
export async function pollUntil<T>(input: {
|
|
label: string;
|
|
deadlineAt: number;
|
|
load: () => Promise<T>;
|
|
accept: (value: T) => boolean;
|
|
reject?: (value: T) => string | undefined;
|
|
intervalMs?: number;
|
|
retryOnError?: (error: unknown) => boolean;
|
|
timeoutFailureClass?: "candidate_failure" | "transient_infrastructure";
|
|
timeoutDetail?: (value: T | undefined) => string | undefined;
|
|
}): Promise<T> {
|
|
let last: T | undefined;
|
|
let lastError: unknown;
|
|
while (Date.now() < input.deadlineAt) {
|
|
try {
|
|
last = await input.load();
|
|
if (input.accept(last)) return last;
|
|
const rejection = input.reject?.(last);
|
|
if (rejection) {
|
|
throw new Error(`Stopped waiting for ${input.label}: ${rejection}`);
|
|
}
|
|
lastError = undefined;
|
|
} catch (error) {
|
|
if (input.retryOnError && !input.retryOnError(error)) throw error;
|
|
if (
|
|
error instanceof Error &&
|
|
error.message.startsWith(`Stopped waiting for ${input.label}:`)
|
|
) {
|
|
throw error;
|
|
}
|
|
lastError = error;
|
|
}
|
|
await new Promise((resolve) =>
|
|
setTimeout(resolve, input.intervalMs ?? 2_000),
|
|
);
|
|
}
|
|
if (lastError instanceof Error) {
|
|
throw new Error(`Timed out waiting for ${input.label}: ${lastError.message}`, { cause: lastError });
|
|
}
|
|
throw new ObservedStateTimeout(
|
|
input.label,
|
|
input.timeoutFailureClass ?? "candidate_failure",
|
|
input.timeoutDetail?.(last),
|
|
);
|
|
}
|