mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-14 08:53:19 +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>
284 lines
13 KiB
TypeScript
284 lines
13 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
import { isDeepStrictEqual } from "node:util";
|
|
import type { Agent } from "../../../packages/shared/src/types/agent.js";
|
|
import type {
|
|
CatalogSkill,
|
|
CompanySkill,
|
|
CompanySkillListItem,
|
|
CompanySkillDetail,
|
|
CompanySkillVersion,
|
|
} from "../../../packages/shared/src/types/company-skill.js";
|
|
import type {
|
|
CatalogTeam,
|
|
CatalogTeamImportPreviewResult,
|
|
} from "../../../packages/shared/src/types/teams-catalog.js";
|
|
import type { ChatCatalogContext } from "../chat-catalog-contract.js";
|
|
import { RunnerApiHttpError } from "../api.js";
|
|
import { identityWorker } from "./identity-support.js";
|
|
import { checks, root, type ExternalObservation } from "./external-support.js";
|
|
import type { Entity } from "./internal-support.js";
|
|
const agentAuthority = (a: Agent) => ({
|
|
id: a.id,
|
|
companyId: a.companyId,
|
|
name: a.name,
|
|
urlKey: a.urlKey,
|
|
adapterType: a.adapterType,
|
|
adapterConfig: a.adapterConfig,
|
|
runtimeConfig: a.runtimeConfig,
|
|
permissions: a.permissions,
|
|
metadata: a.metadata,
|
|
});
|
|
const skillIdentity = (s: CompanySkill) => ({
|
|
id: s.id,
|
|
name: s.name,
|
|
companyId: s.companyId,
|
|
key: s.key,
|
|
slug: s.slug,
|
|
markdown: s.markdown,
|
|
currentVersionId: s.currentVersionId,
|
|
sourceType: s.sourceType,
|
|
});
|
|
/** Genuine post-import failure: the catalog installer imports the team graph,
|
|
* then installFromCatalog rejects this separately owned local skill's occupied
|
|
* slug. No test flag, permission denial, missing account or catalog mutation. */
|
|
export async function prepareTeamConditions(
|
|
ctx: ChatCatalogContext,
|
|
team: CatalogTeam,
|
|
) {
|
|
const preview = await ctx.input.api.post<CatalogTeamImportPreviewResult>(
|
|
`${root(ctx)}/teams/catalog/${encodeURIComponent(team.id)}/preview`,
|
|
{ targetManagerAgentId: ctx.input.fixtures.agent.id },
|
|
);
|
|
const rootAgent = preview.portabilityPreview.manifest.agents.find(
|
|
(a) => a.slug === team.rootAgentSlugs[0],
|
|
);
|
|
const skills = await ctx.input.api.get<CatalogSkill[]>("/api/skills/catalog");
|
|
const required = skills.find((s) =>
|
|
team.requiredSkills.some(
|
|
(r) => r.type === "catalog" && r.resolved && r.catalogSkillId === s.id,
|
|
),
|
|
);
|
|
if (!rootAgent || !required)
|
|
throw new Error(
|
|
"Setup blocker: actual team lacks its inspectable root role/required catalog skill for finite installer conditions",
|
|
);
|
|
const inventory = await ctx.input.api.get<CompanySkillListItem[]>(
|
|
`${root(ctx)}/skills`,
|
|
);
|
|
const occupied = inventory.filter((s) => s.slug === required.slug);
|
|
// A default installed catalog skill in this fresh owned company can be
|
|
// replaced with a real local customization through ordinary public controls.
|
|
if (occupied.some((s) => s.key !== required.key))
|
|
throw new Error(
|
|
"Setup blocker: unexpected preexisting local skill already occupies selected conditional fixture",
|
|
);
|
|
for (const s of occupied)
|
|
await ctx.input.api.delete(`${root(ctx)}/skills/${s.id}`);
|
|
const local = await ctx.input.api.post<CompanySkill>(`${root(ctx)}/skills`, {
|
|
name: "Existing company launch checklist",
|
|
slug: required.slug,
|
|
description: "Company-owned local launch review checklist",
|
|
markdown:
|
|
"---\nname: Existing company launch checklist\ndescription: Company-owned local launch review checklist\n---\n\nCheck the launch owner and release checklist before publishing.\n",
|
|
});
|
|
if (local.key === required.key || local.slug !== required.slug)
|
|
throw new Error(
|
|
"Real local skill did not create the intended separate-key slug conflict",
|
|
);
|
|
let denied409 = false;
|
|
try {
|
|
await ctx.input.api.post(`${root(ctx)}/skills/install-catalog`, {
|
|
catalogSkillId: required.id,
|
|
});
|
|
} catch (error) {
|
|
if (error instanceof RunnerApiHttpError && error.status === 409)
|
|
denied409 = true;
|
|
else throw error;
|
|
}
|
|
if (!denied409)
|
|
throw new Error(
|
|
"Setup blocker: actual required-skill installer did not reject the real occupied local slug",
|
|
);
|
|
const created = await identityWorker(ctx, rootAgent.name);
|
|
const existing = await ctx.input.api.get<Agent>(`/api/agents/${created.id}`);
|
|
const conflict = await ctx.input.api.post<CatalogTeamImportPreviewResult>(
|
|
`${root(ctx)}/teams/catalog/${encodeURIComponent(team.id)}/preview`,
|
|
{ targetManagerAgentId: ctx.input.fixtures.agent.id },
|
|
);
|
|
const plan = conflict.portabilityPreview.plan.agentPlans.find(
|
|
(p) => p.slug === rootAgent.slug,
|
|
);
|
|
if (
|
|
!plan ||
|
|
plan.existingAgentId !== existing.id ||
|
|
plan.action !== "create" ||
|
|
plan.plannedName === existing.name ||
|
|
!/rename/i.test(plan.reason ?? "")
|
|
)
|
|
throw new Error(
|
|
"Setup blocker: actual package preview did not prove the unrelated agent-name collision and rename plan",
|
|
);
|
|
return {
|
|
conflictAgentId: existing.id,
|
|
conflictAgentBefore: agentAuthority(existing),
|
|
blockedSkill: required,
|
|
localSkillId: local.id,
|
|
localSkillBefore: skillIdentity(local),
|
|
actualSkillCollision409: true,
|
|
actualPreviewCollision: true,
|
|
renamePlan: plan,
|
|
conditionalPreview: conflict,
|
|
};
|
|
}
|
|
interface TeamSkillRecovery {
|
|
skill: CompanySkillDetail;
|
|
version: CompanySkillVersion;
|
|
agents: Agent[];
|
|
}
|
|
export function verifiedTeamSkillRecovery(state: Record<string, unknown>, companyId: string, value: unknown): boolean {
|
|
const recovery = value as TeamSkillRecovery | undefined, target = state.blockedSkill as CatalogSkill;
|
|
const team = state.team as CatalogTeam, preview = state.conditionalPreview as CatalogTeamImportPreviewResult;
|
|
const first = state.first as { agents: Agent[] } | undefined;
|
|
if (!recovery?.skill || !recovery.version || !Array.isArray(recovery.agents) || !target?.key || !target.contentHash
|
|
|| !team?.id || !preview?.portabilityPreview?.plan || !first?.agents) return false;
|
|
const { skill, version, agents } = recovery;
|
|
if (!skill.id || skill.companyId !== companyId || skill.key !== target.key || skill.sourceType !== "catalog" || skill.sourceRef !== target.contentHash
|
|
|| !skill.currentVersionId || version.id !== skill.currentVersionId || version.companyId !== companyId || version.companySkillId !== skill.id
|
|
|| !Array.isArray(version.fileInventory) || !target.files?.length || version.fileInventory.length !== target.files.length
|
|
|| new Set(version.fileInventory.map(file => file.path)).size !== target.files.length
|
|
|| version.fileInventory.find(file => file.path === "SKILL.md")?.content !== skill.markdown
|
|
|| !target.files.every(expected => {
|
|
const file = version.fileInventory.find(file => file.path === expected.path);
|
|
return file && file.kind === expected.kind && typeof file.content === "string"
|
|
&& createHash("sha256").update(Buffer.from(file.content, file.encoding === "base64" ? "base64" : "utf8")).digest("hex") === expected.sha256;
|
|
})) return false;
|
|
const required = team.requiredSkills.find(row => row.type === "catalog" && row.resolved && row.catalogSkillId === target.id && row.catalogSkillKey === target.key);
|
|
if (!required?.agentSlugs.length) return false;
|
|
const plans = preview.portabilityPreview.plan.agentPlans;
|
|
const members = (rows: Agent[]) => rows.filter(agent => {
|
|
const provenance = (agent.metadata as { paperclip?: { catalogTeam?: { catalogId?: string; originHash?: string } } } | null)?.paperclip?.catalogTeam;
|
|
return agent.companyId === companyId && provenance?.catalogId === team.id && provenance.originHash === team.contentHash;
|
|
});
|
|
const initialMembers = members(first.agents), currentMembers = members(agents);
|
|
if (initialMembers.length !== team.agentSlugs.length || currentMembers.length !== initialMembers.length
|
|
|| new Set(currentMembers.map(agent => agent.id)).size !== currentMembers.length) return false;
|
|
return required.agentSlugs.every(slug => {
|
|
const planned = plans.filter(plan => plan.slug === slug);
|
|
if (planned.length !== 1) return false;
|
|
const initial = initialMembers.filter(agent => agent.name === planned[0]!.plannedName);
|
|
if (initial.length !== 1) return false;
|
|
const current = currentMembers.find(agent => agent.id === initial[0]!.id);
|
|
const sync = current?.adapterConfig?.paperclipSkillSync as { desiredSkills?: unknown[] } | undefined;
|
|
const selection = sync?.desiredSkills?.find(value => typeof value === "string" ? value.trim() === target.key
|
|
: !!value && typeof value === "object" && "key" in value && value.key === target.key);
|
|
return typeof selection === "string" || !!selection && typeof selection === "object" && "versionId" in selection
|
|
&& (selection.versionId === null || selection.versionId === version.id);
|
|
});
|
|
}
|
|
export async function observeTeamConditions(
|
|
ctx: ChatCatalogContext,
|
|
state: Record<string, unknown>,
|
|
) {
|
|
const [agent, skill, skills] = await Promise.all([
|
|
ctx.input.api.get<Agent>(`/api/agents/${state.conflictAgentId}`),
|
|
ctx.input.api.get<CompanySkill>(
|
|
`${root(ctx)}/skills/${state.localSkillId}`,
|
|
),
|
|
ctx.input.api.get<CompanySkillListItem[]>(`${root(ctx)}/skills`),
|
|
]);
|
|
const target = state.blockedSkill as CatalogSkill;
|
|
const installed = skills.filter(row => row.key === target.key && row.sourceType === "catalog");
|
|
let recovery: TeamSkillRecovery | undefined;
|
|
if (installed.length === 1) {
|
|
const detail = await ctx.input.api.get<CompanySkillDetail>(`${root(ctx)}/skills/${encodeURIComponent(installed[0]!.id)}`);
|
|
if (detail.id === installed[0]!.id && detail.currentVersionId) {
|
|
const [version, agents] = await Promise.all([
|
|
ctx.input.api.get<CompanySkillVersion>(`${root(ctx)}/skills/${encodeURIComponent(detail.id)}/versions/${encodeURIComponent(detail.currentVersionId)}`),
|
|
ctx.input.api.get<Agent[]>(`${root(ctx)}/agents`),
|
|
]);
|
|
recovery = { skill: detail, version, agents };
|
|
}
|
|
}
|
|
return {
|
|
existingAgentPreserved: isDeepStrictEqual(
|
|
agentAuthority(agent),
|
|
state.conflictAgentBefore,
|
|
),
|
|
existingLocalSkillPreserved: isDeepStrictEqual(
|
|
skillIdentity(skill),
|
|
state.localSkillBefore,
|
|
),
|
|
recovery,
|
|
requiredCatalogStillMissing: !skills.some(
|
|
(s) => s.key === target.key && s.sourceType === "catalog",
|
|
),
|
|
localSkillStillOccupiesSlug:
|
|
skill.slug === target.slug && skill.key !== target.key,
|
|
};
|
|
}
|
|
/** The reply names the unresolved required skill by an identity the product returned (slug, name or catalog key). */
|
|
export function namesTeamSkill(reply: string, skill: Pick<CatalogSkill, "slug" | "name"> & { key?: string }): boolean {
|
|
const text = reply.replace(/[*_`]/g, "").toLowerCase();
|
|
return [skill.slug, skill.slug.replaceAll("-", " "), skill.name, skill.key].some(name => !!name && text.includes(name.toLowerCase()));
|
|
}
|
|
export function gradeTeamConditions(
|
|
o: ExternalObservation,
|
|
phase: "initial" | "complete" = "complete",
|
|
) {
|
|
const c = checks(),
|
|
target = o.state.blockedSkill as CatalogSkill | undefined,
|
|
plan = o.state.renamePlan as { plannedName: string } | undefined;
|
|
const first = o.phaseReplies
|
|
.filter((r) => r.runId === o.state.initialRunId)
|
|
.map((r) => r.body)
|
|
.join("\n");
|
|
const earlier = o.state.firstConditional as
|
|
Record<string, unknown> | undefined,
|
|
later = o.state.finalConditional as Record<string, unknown> | undefined;
|
|
c.add(
|
|
"real-preview-conflict-and-owned-originals",
|
|
o.state.actualPreviewCollision === true &&
|
|
o.state.actualSkillCollision409 === true &&
|
|
!!plan &&
|
|
!!target &&
|
|
earlier?.existingAgentPreserved === true &&
|
|
earlier.existingLocalSkillPreserved === true &&
|
|
(phase === "initial" ||
|
|
(later?.existingAgentPreserved === true &&
|
|
later.existingLocalSkillPreserved === true)),
|
|
"Actual accepted preview conflicts with a public-created unrelated agent, and the separately owned local skill really rejects catalog installation; both existing originals remain immutable across install and repeat.",
|
|
);
|
|
const resolved = (condition: Record<string, unknown> | undefined) => condition?.requiredCatalogStillMissing === false
|
|
&& verifiedTeamSkillRecovery(o.state, o.companyId, condition.recovery);
|
|
const accounted = (condition: Record<string, unknown> | undefined) => condition?.localSkillStillOccupiesSlug === true
|
|
&& (condition.requiredCatalogStillMissing === true || resolved(condition));
|
|
const events = o.activity as Array<Entity & { runId?: string | null }>;
|
|
c.add(
|
|
"actual-post-import-partial-warning",
|
|
!!target &&
|
|
accounted(earlier) &&
|
|
(phase === "initial" || accounted(later)) &&
|
|
events.some(
|
|
(e) =>
|
|
e.action === "company.team_catalog_installed" &&
|
|
e.actorType === "agent" &&
|
|
e.agentId === o.leadId &&
|
|
e.runId === o.state.initialRunId &&
|
|
e.details?.catalogId === (o.state.team as CatalogTeam)?.id &&
|
|
Number(e.details?.warningCount) > 0 &&
|
|
Number(e.details?.skillPreparationCount) > 0,
|
|
),
|
|
"The actual initial model's installer creates the team graph and reports a genuine post-import warning with the required skill either still blocked or independently recovered through its exact catalog version and intended agent bindings.",
|
|
);
|
|
// Reply meaning is read by a person; graded here only by the identity the
|
|
// product returned. The renamed member and project links are graded by the
|
|
// install case, and saved state above proves the partial outcome.
|
|
c.add(
|
|
"initial-reply-names-unresolved-skill",
|
|
!!target && first.trim().length > 0 &&
|
|
(resolved(earlier) || namesTeamSkill(first, target)),
|
|
"While the required catalog skill is still missing, the first reply names it by its returned slug, name or key; a verified recovery needs no particular wording.",
|
|
);
|
|
return c.result();
|
|
}
|