Files
PaperClipAI/tests/runner-e2e/chat-slack-ingress.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

134 lines
17 KiB
TypeScript

import { pollUntil } from "./api.js";
import { validateChatCatalogGrade } from "./chat-catalog-contract.js";
import type { ChatConversation, ChatEndpoint, ExternalChannelBindingSummary } from "../../packages/shared/src/types/chat-channels.js";
import type { IssueComment } from "../../packages/shared/src/types/issue.js";
import type { ChatCatalogCase, ChatCatalogContext, ChatSlackIngressReceipt } from "./chat-catalog-contract.js";
import type { ChatRun } from "./chat-flow.js";
export const nativeSlackScenarioIds = ["chat-channels.reply-in-thread", "chat-channels.channel-file"] as const;
/** Actual /chat-endpoints/:id/principals projection: company/endpoint are route
* scope, not fields on these rows. Raw provider identity is checked separately. */
interface SlackPrincipalLink { principalId:string;externalDetail?:string|null;paperclipUserId?:string|null;status:string }
interface SlackSenderIdentity {userId:string;workspaceId:string;handle:string}
export async function verifySelectedSlackSender(ctx:ChatCatalogContext,receipt:ChatSlackIngressReceipt,signal:AbortSignal,fetcher:typeof fetch=fetch):Promise<SlackSenderIdentity>{
const config=ctx.input.privateInputs?.configuration("external-provider") as {providers?:{slack?:{disposable?:boolean;resourceIds?:string[];prerequisites?:Record<string,string>}}}|undefined,binding=config?.providers?.slack,p=binding?.prerequisites;
if(binding?.disposable!==true||!p||![receipt.provider.workspaceId,receipt.provider.channelId,receipt.provider.senderUserId].every(id=>binding.resourceIds?.includes(id))||p.workspaceId!==receipt.provider.workspaceId||p.channelId!==receipt.provider.channelId||p.senderUserId!==receipt.provider.senderUserId||!/^PAPERCLIP_CHAT_EVAL_[A-Z0-9_]+$/.test(p.senderTokenEnv??""))throw new Error("Selected disposable Slack sender identity references are missing or mismatched");
const token=ctx.input.privateInputs?.read(p.senderTokenEnv!);if(!token||!ctx.input.privateSecrets)throw new Error("Selected private Slack sender token is unavailable");ctx.input.privateSecrets.push(token);
async function call(method:"auth.test"|"users.info",body:Record<string,string>){
try{const response=await fetcher(`https://slack.com/api/${method}`,{method:"POST",redirect:"error",signal:AbortSignal.any([signal,AbortSignal.timeout(20000)]),headers:{authorization:`Bearer ${token}`,"content-type":"application/json; charset=utf-8"},body:JSON.stringify(body)});if(!response.ok){await response.body?.cancel();throw new Error();}const bytes=Buffer.from(await response.arrayBuffer());if(bytes.length>262144)throw new Error();const result=JSON.parse(bytes.toString("utf8"));if(result.ok!==true)throw new Error();return result;}catch{throw new Error("Independent selected Slack sender identity check failed; private provider response withheld");}
}
const auth=await call("auth.test",{});if(auth.team_id!==receipt.provider.workspaceId||auth.user_id!==receipt.provider.senderUserId||auth.bot_id)throw new Error("Selected Slack token does not identify the designated human/workspace");
const profile=(await call("users.info",{user:receipt.provider.senderUserId})).user;
if(!profile||profile.id!==auth.user_id||profile.team_id!==auth.team_id||profile.is_bot||profile.deleted||typeof profile.name!=="string"||!profile.name||profile.name.length>80||/[\r\n]/.test(profile.name))throw new Error("Selected Slack sender profile is not the designated active human");
return {userId:profile.id,workspaceId:profile.team_id,handle:profile.name};
}
export function validSlackIngressDeclaration(c: Pick<ChatCatalogCase, "scenarioId" | "slackIngress" | "expectedRunCount" | "minimumRunCount" | "expectedHumanMessageCount" | "setupHumanMessageCount" | "queryBindings" | "queryAttachments" | "queryOutcome" | "companionSurface" | "runPolicy" | "turnTimeoutMs" | "attemptTimeoutMs"> & { journeyKind?: string }) {
return (nativeSlackScenarioIds as readonly string[]).includes(c.scenarioId) && c.journeyKind === "native-slack-thread"
&& c.slackIngress?.kind === "native-slack-thread" && c.slackIngress.maximumProviderCalls === 20 && c.slackIngress.ingressTimeoutMs === 120000
&& Object.keys(c.slackIngress).sort().join(",") === (c.scenarioId === "chat-channels.reply-in-thread" ? "ingressTimeoutMs,kind,maximumProviderCalls,setupThread" : "ingressTimeoutMs,kind,maximumProviderCalls")
&& c.turnTimeoutMs === 120000
&& (c.scenarioId === "chat-channels.channel-file" ? !c.slackIngress.setupThread && c.attemptTimeoutMs === 900000 && c.expectedRunCount === 6 && c.minimumRunCount === 2 && c.expectedHumanMessageCount === 2
: c.attemptTimeoutMs === 600000 && c.expectedRunCount === 4 && c.minimumRunCount === 2 && c.expectedHumanMessageCount === 1 && c.slackIngress.setupThread === "same-agent-distractor") && (c.setupHumanMessageCount ?? 0) === 0
&& !c.queryBindings?.length && !c.queryAttachments?.length && !c.queryOutcome && !c.companionSurface && !c.runPolicy;
}
export function slackIngressReceiptMatches(input: { receipt: ChatSlackIngressReceipt; query: string; companyId: string; agentId: string;
endpoint: ChatEndpoint; conversation: ChatConversation | undefined; binding: ExternalChannelBindingSummary; comment: IssueComment | undefined; links: SlackPrincipalLink[];linksEndpointId:string;senderIdentity:SlackSenderIdentity }) {
const { receipt: r, endpoint: e, conversation: c, binding: b, comment } = input;
return !!r && r.query === input.query && Number.isSafeInteger(r.providerCalls) && r.providerCalls >= 1 && r.providerCalls <= 20
&& /^T[A-Z0-9]+$/.test(r.provider.workspaceId) && /^[CG][A-Z0-9]+$/.test(r.provider.channelId) && /^[UW][A-Z0-9]+$/.test(r.provider.senderUserId)
&& /^\d+\.\d{6}$/.test(r.provider.threadId) && /^\d+\.\d{6}$/.test(r.provider.canonicalMessageId)
&& e.id === r.endpointId && e.companyId === input.companyId && e.assignedAgentId === input.agentId && e.provider === "slack" && e.providerAccountId === r.provider.workspaceId
&& !!c && c.id === r.conversationId && c.endpointId === e.id && c.companyId === input.companyId && c.issueId === r.issueId
&& c.externalThreadId === `slack:${r.provider.channelId}:${r.provider.threadId}` && c.externalConversationId === `slack:${r.provider.channelId}`
&& b.provider === "slack" && b.endpointId === e.id && b.conversationId === c.id && b.assignedAgentLocked === true
&& !!comment && comment.id === r.humanCommentId && comment.issueId === r.issueId && comment.companyId === input.companyId
&& comment.body === input.query && !comment.authorAgentId && comment.authorUserId === r.linkedPaperclipUserId && comment.metadata?.sourceChannel === "slack"
&& Array.isArray(comment.metadata.sections) && comment.metadata.sections.some(section => section.rows.some(row => row.type === "key_value" && row.label === "Provider ID" && row.value === r.provider.senderUserId))
&& input.linksEndpointId===e.id&&input.senderIdentity.userId===r.provider.senderUserId&&input.senderIdentity.workspaceId===r.provider.workspaceId
&& input.links.some(link => typeof link.principalId==="string"&&!!link.principalId&&link.externalDetail===`@${input.senderIdentity.handle}`&&link.status === "linked" && link.paperclipUserId === r.linkedPaperclipUserId);
}
export function sameSlackThreadNewMessage(first: ChatSlackIngressReceipt, next: ChatSlackIngressReceipt) {
return next.query === first.query && next.endpointId === first.endpointId && next.conversationId === first.conversationId && next.issueId === first.issueId
&& next.linkedPaperclipUserId === first.linkedPaperclipUserId && next.humanCommentId !== first.humanCommentId
&& next.provider.workspaceId === first.provider.workspaceId && next.provider.channelId === first.provider.channelId && next.provider.threadId === first.provider.threadId
&& next.provider.senderUserId === first.provider.senderUserId && next.provider.canonicalMessageId !== first.provider.canonicalMessageId;
}
export const slackOtherThreadSetupQuery = "Please summarize the current company project status in this separate discussion.";
async function independentlyBoundSlackReceipt(ctx:ChatCatalogContext,receipt:ChatSlackIngressReceipt,query:string,signal:AbortSignal){
for(const id of [receipt?.endpointId,receipt?.conversationId,receipt?.issueId,receipt?.humanCommentId])if(typeof id!=="string"||!/^[a-zA-Z0-9-]{1,100}$/.test(id))throw new Error("Invalid Slack product receipt identity");
const api = ctx.input.api;
const endpoint = await api.get<ChatEndpoint>(`/api/chat-endpoints/${receipt.endpointId}`);
const conversations = await api.get<ChatConversation[]>(`/api/chat-endpoints/${receipt.endpointId}/conversations`);
const binding = await api.get<ExternalChannelBindingSummary>(`/api/issues/${receipt.issueId}/chat-binding`);
const comments = await api.get<IssueComment[]>(`/api/issues/${receipt.issueId}/comments?order=asc`);
const links = await api.get<SlackPrincipalLink[]>(`/api/chat-endpoints/${receipt.endpointId}/principals`);
const senderIdentity=await verifySelectedSlackSender(ctx,receipt,signal);
const matched = slackIngressReceiptMatches({ receipt, query: query, companyId: ctx.input.fixtures.company.id, agentId: ctx.input.fixtures.agent.id,
endpoint, conversation: conversations.find(row => row.id === receipt.conversationId), binding, comment: comments.find(row => row.id === receipt.humanCommentId), links,linksEndpointId:receipt.endpointId,senderIdentity })&&receipt.providerCalls+2<=20;
return matched;
}
export function slackOtherThreadBoundaryMatches(input:{receipt:ChatSlackIngressReceipt & {runId:string};runs:ChatRun[];companyId:string;agentId:string;sourceIssueId?:string}){
const {receipt:r,runs}=input;
return r.query===slackOtherThreadSetupQuery&&runs.length===1&&runs[0]?.id===r.runId&&runs[0].companyId===input.companyId&&runs[0].agentId===input.agentId&&runs[0].runtimeMode==="native"&&runs[0].status==="succeeded"&&runs[0].contextSnapshot?.issueId===r.issueId&&(!input.sourceIssueId||input.sourceIssueId!==r.issueId);
}
/** One exact precondition exchange on another actual Slack source. Its human
* comment never enters the canonical source's setup-human history. */
export async function prepareNativeSlackOtherThread(ctx:ChatCatalogContext,c:ChatCatalogCase,state:Record<string,unknown>,deadlineAt:number){
if(c.scenarioId!=="chat-channels.reply-in-thread"||c.slackIngress?.setupThread!=="same-agent-distractor"||!validSlackIngressDeclaration(c)||!c.dispatchSlackOtherThread||ctx.issue()||(await ctx.allRuns()).length)throw new Error("Invalid or nonempty declared Slack other-thread setup");
const signal=AbortSignal.timeout(Math.min(120000,deadlineAt-Date.now()));
let listener:(()=>void)|undefined;
const timeout=new Promise<never>((_resolve,reject)=>{listener=()=>reject(new Error("Slack other-thread setup exceeded its declared deadline"));signal.addEventListener("abort",listener,{once:true});});
let receipt:ChatSlackIngressReceipt & {runId:string};
try{receipt=await Promise.race([c.dispatchSlackOtherThread(ctx,state,{query:slackOtherThreadSetupQuery,signal}),timeout]);}finally{if(listener)signal.removeEventListener("abort",listener);}
const matched=await independentlyBoundSlackReceipt(ctx,receipt,slackOtherThreadSetupQuery,signal)&&slackOtherThreadBoundaryMatches({receipt,runs:await ctx.allRuns(),companyId:ctx.input.fixtures.company.id,agentId:ctx.input.fixtures.agent.id});
await ctx.input.evidence("chat-slack-other-thread-setup.json",{receipt,matched,canonicalSourceHumanCount:0,includedInTotalNativeBound:true});
if(!matched||signal.aborted)throw new Error("Actual Slack other-thread setup did not prove its sole linked-human/company/source/native execution");
state.slackOtherThreadSetupReceipt=receipt;
}
/** Provider transport belongs to the selected domain; public product identity
* and exact canonical persistence are independently checked here. */
export async function dispatchNativeSlackIngress(ctx: ChatCatalogContext, c: ChatCatalogCase, state: Record<string, unknown>, deadlineAt: number, phase: "canonical" | "unsupported-file" = "canonical") {
if (!validSlackIngressDeclaration(c) || !c.dispatchSlackQuery || c.act || !ctx.selectSlackSource) throw new Error("Invalid actual native Slack ingress contract");
const driver = phase === "canonical" ? c.dispatchSlackQuery : c.scenarioId === "chat-channels.channel-file" ? c.dispatchSlackUnsupportedFile : undefined;
if (!driver) throw new Error("Undeclared Slack ingress phase");
const remaining = Math.min(c.slackIngress!.ingressTimeoutMs, deadlineAt - Date.now());
if (remaining <= 0) throw new Error("Slack setup exhausted the actual attempt deadline");
const signal = AbortSignal.timeout(remaining); let listener: (() => void) | undefined;
const interrupted = new Promise<never>((_resolve, reject) => { listener = () => reject(new Error("Actual Slack ingress exceeded its declared deadline")); signal.addEventListener("abort", listener, { once: true }); });
let receipt: ChatSlackIngressReceipt;
try { receipt = await Promise.race([driver(ctx, state, { query: ctx.scenario.query, signal }), interrupted]); }
finally { if (listener) signal.removeEventListener("abort", listener); }
// Fail before interpreting identifiers as API routes.
for (const id of [receipt?.endpointId, receipt?.conversationId, receipt?.issueId, receipt?.humanCommentId]) if (typeof id !== "string" || !/^[a-zA-Z0-9-]{1,100}$/.test(id)) throw new Error("Invalid Slack product receipt identity");
const matched = await independentlyBoundSlackReceipt(ctx,receipt,ctx.scenario.query,signal);
const first = state.slackIngressReceipt as ChatSlackIngressReceipt | undefined;
const sameThreadNewMessage = phase === "canonical" || !!first && sameSlackThreadNewMessage(first, receipt);
await ctx.input.evidence(`chat-slack-ingress-${phase}-binding.json`, { matched, sameThreadNewMessage, phase, canonicalQuery: ctx.scenario.query, receipt, assignedAgentId: ctx.input.fixtures.agent.id,
linkedIdentityConfirmed: matched, independentProviderIdentityCalls:2,privateProviderConfiguration: "withheld", providerSenderMapping: "private official human token/profile plus scoped public principal and actual comment" });
const other=state.slackOtherThreadSetupReceipt as ChatSlackIngressReceipt|undefined;
const otherThreadDistinct=ctx.scenario.id!=="chat-channels.reply-in-thread"||!!other&&other.endpointId===receipt.endpointId&&other.issueId!==receipt.issueId&&other.conversationId!==receipt.conversationId&&other.provider.threadId!==receipt.provider.threadId&&other.provider.workspaceId===receipt.provider.workspaceId&&other.provider.channelId===receipt.provider.channelId&&other.provider.senderUserId===receipt.provider.senderUserId&&other.linkedPaperclipUserId===receipt.linkedPaperclipUserId;
if (signal.aborted || !matched || !sameThreadNewMessage || !otherThreadDistinct) throw new Error("Slack ingress did not independently bind exact canonical human, linked identity, company and provider thread");
await ctx.selectSlackSource(receipt.issueId);
return receipt;
}
export async function completeNativeSlackFileConditional(ctx: ChatCatalogContext, c: ChatCatalogCase, state: Record<string, unknown>, deadlineAt: number) {
if (c.scenarioId !== "chat-channels.channel-file" || !c.verifySlackSupportedTransfer || !c.dispatchSlackUnsupportedFile) throw new Error("Missing actual Slack file conditional");
const first = await pollUntil({ label: "real supported Slack transfer before unsupported upload", deadlineAt, intervalMs: 1000,
load: async () => { const grade = validateChatCatalogGrade(await c.verifySlackSupportedTransfer!(ctx, state)); const runs = await ctx.allRuns(); return { grade, runs }; },
accept: value => value.grade.passed && value.runs.length >= 1 && value.runs.length <= 3 && value.runs.every(run => run.runtimeMode === "native" && run.companyId === ctx.input.fixtures.company.id && run.status === "succeeded") && slackNativeRunCheck(ctx, value.runs).passed });
state.slackSupportedTransferRunIds = first.runs.map(run => run.id);
await ctx.input.evidence("chat-slack-supported-transfer-checkpoint.json", { grade: first.grade, runIds: state.slackSupportedTransferRunIds, beforeUnsupportedUpload: true });
state.slackUnsupportedIngressReceipt = await dispatchNativeSlackIngress(ctx, c, state, deadlineAt, "unsupported-file");
}
export function slackNativeRunCheck(ctx: ChatCatalogContext, runs: ChatRun[], state?: Record<string, unknown>) {
const source = ctx.issue();
const sourceRuns = runs.filter(run => run.contextSnapshot?.issueId === source?.id);
const other=state?.slackOtherThreadSetupReceipt as (ChatSlackIngressReceipt & {runId:string})|undefined;
const otherStillBound=!other||slackOtherThreadBoundaryMatches({receipt:other,runs:runs.filter(run=>run.contextSnapshot?.issueId===other.issueId),companyId:ctx.input.fixtures.company.id,agentId:ctx.input.fixtures.agent.id,sourceIssueId:source?.id});
return { id: "catalog-real-slack-source-native", passed: otherStillBound && !!source && source.companyId === ctx.input.fixtures.company.id && source.assigneeAgentId === ctx.input.fixtures.agent.id
&& sourceRuns.length >= 1 && sourceRuns.every(run => run.agentId === ctx.input.fixtures.agent.id && run.runtimeMode === "native" && run.status === "succeeded")
&& runs.every(run => typeof run.contextSnapshot?.issueId === "string")
&& (!state?.slackUnsupportedIngressReceipt || Array.isArray(state.slackSupportedTransferRunIds) && sourceRuns.some(run => !(state.slackSupportedTransferRunIds as unknown[]).includes(run.id))),
detail: "The actual inbound source has a genuine assigned-agent native admission; fast responses cannot substitute. The independent case oracle binds every downstream task and automatic source follow-up to this provider exchange." };
}