Files
PaperClipAI/tests/runner-e2e/chat-catalog-domains/privileged-support.ts
T
DottaandClaude Opus 5.5 bce79f186f fix(evals): count a pending question card as the turn's response where the case allows it (#20597)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The chat catalog evals check that a chat agent answers the person
and saves the right records
> - An agent can end its turn on a question card; the product then
records `interaction_owns_conversation_response` and posts no reply
comment
> - The privileged settle step and some graders still waited for a reply
comment, so valid card-ended turns timed out or failed
> - This pull request adds one shared rule for when a pending card
counts as the turn's response, and uses it where the case allows a card
> - The benefit is that card-ended turns are graded on what the person
saw, while cases that need a written reply stay strict

## Linked Issues or Issue Description

Refs #20588 (request_human_input version fix found by the same runs).
Refs #20590 (chat review cards link their document).

**What happened?**
In the full chat eval run on master `f4337bb5fb`, agents ended turns on
pending question or review cards. `developer-labs.component-reference`
timed out in the privileged settle step, which required a reply comment.
`goals.align-existing-work` refused the foreign goal in a question card,
but the grader read only reply comments.
`questions-and-approvals.reject-and-revise` returned its draft in a
review card bound to the draft revision, but the grader and the result
check required a reply link.

**Expected behavior**
A pending card counts as the turn's response only when the product
decided that the card owns the run's response, the lead's own run
created it, a person can answer it, and it is not a governed approval.
Each case still grades the content it requires.

**Steps to reproduce**
Regrade the retained evidence of the cases above with the old graders:
they fail on the missing reply comment although the card is on the chat.

## What Changed

- New `chat-catalog-domains/turn-response-card.ts`:
`turnResponseCards()` returns pending cards from runs whose
`presentationDecision.reasonCodes` contains
`interaction_owns_conversation_response`. A card must be created by the
lead, be human-answerable (a text question, two or more choices, or an
ordinary confirmation) and not carry `toolAction`, `secretProposal` or
`connectionAuthorization`.
- `settlePrivilegedChat` treats such a card as a settled turn. The
privileged graders still require their written guidance, so
`developer-labs.component-reference` now fails at grading instead of
timing out.
- `goals.align-existing-work` reads the card from the run woken by the
foreign-goal request as that phase's answer. A leaked foreign text, or a
card that does not refuse, still fails. Oracle
`paperclip.chat-catalog.internal.goals-align.v2`.
- `questions-and-approvals.reject-and-revise` accepts a review card
bound to the draft's exact saved revision. The result check opens the
draft from the card's document link (needs #20590). Oracle
`paperclip.chat-catalog.plan-review.v6` for both plan-review cases.
- Descriptors updated for oracle versions and `sourceFiles`.

## Verification

- `turn-response-card.test.ts`: the retained cards pass; other run,
other agent, answered, other company, user-created, non-human resolver,
single non-text choice, governed confirmation, and a run that replied
all fail.
- Offline regrade of retained evidence (master `f4337bb5fb` full-cohort
run): `goals.align-existing-work` cross-company check passes with the
card and fails without it; `reject-and-revise` `real-local-review-draft`
passes with the bound card and fails with no card or a card bound to
another revision.
- `npx vitest run --config tests/runner-e2e/vitest.config.ts
tests/runner-e2e/chat-catalog-domains ...clock tests
--testTimeout=180000`: 269 files passed (one file needed the plugin SDK
build locally, then passed).
- Consistency and contract tests pass. `npx tsc --noEmit -p
tests/runner-e2e` is clean. SDK public input pins are unchanged.
- Paid run
[42855262851](https://github.com/paperclipai/paperclip/actions/runs/42855262851)
on a temporary branch with this PR, #20588 and #20590 (OpenCode, local):
`reject-and-revise` and `revoke-personal-grant` passed;
`goals.align-existing-work` failed only
`phase-bound-strategic-context-explanation` (keyword check, unchanged
here); `developer-labs.component-reference` ended on a question card
again and now failed at grading (`existing-question-reference`,
`reference-only`) instead of timing out in settle. This run's agents
replied in text in the two passing cases, so the card branch of
`reject-and-revise` is covered by the offline regrade only.

## Risks

- `settlePrivilegedChat` now stops waiting on a card-ended turn.
Privileged cases whose graders need a reply fail sooner and with the
real failed check; none pass more easily.
- Changed oracle versions start new history for
`goals.align-existing-work` and both plan-review cases.
- The remaining keyword check
`phase-bound-strategic-context-explanation` in
`goals.align-existing-work` is unchanged and still fails the retained
reply; it needs its own fix.

## Model Used

- Anthropic Claude Opus 5.5 (`claude-opus-5-5`), agentic coding with
tool use in Claude Code.

## 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>
2026-11-10 12:33:52 -06:00

53 lines
4.5 KiB
TypeScript

import { chatCatalogNativePhaseDeadline, chatCatalogNativePhaseIsTimely } from "../chat-catalog-contract.js";
import type { ChatCatalogCase, ChatCatalogContext } from "../chat-catalog-contract.js";
import { pollUntil } from "../api.js";
import { observeInternal, observation, type Entity, type InternalObservation } from "./internal-support.js";
import { turnResponseCards } from "./turn-response-card.js";
/** These cases exercise only the launcher-owned instance or a rendered developer fixture. */
export function privilegedDefinition(scenarioId: string, module: string, extra: Pick<ChatCatalogCase, "setup" | "grade" | "act" | "verifyResult">): ChatCatalogCase {
return { scenarioId, oracleVersion: "paperclip.chat-catalog.privileged.v1", coverageGaps: [],
authentication: "human-session", expectedRunCount: 1, turnTimeoutMs: 120_000, attemptTimeoutMs: 420_000,
sourceFiles: ["chat-catalog-domains/privileged-support.ts", "chat-catalog-domains/turn-response-card.ts", `chat-catalog-domains/${module}.ts`, `chat-scenarios/${scenarioId.split(".")[0]}.yaml`],
observe: observeInternal, ...extra };
}
export function remainingPrivilegedObservationBudget(ctx: ChatCatalogContext): number {
const deadline = ctx.observationDeadlineAt;
const now = Date.now();
if (!Number.isFinite(deadline) || deadline! <= now) throw new Error("Original privileged observation budget exhausted or missing");
return deadline! - now;
}
/**
* The turn has settled once the one run succeeded and answered the person, either with a reply
* or with a pending question card the product chose as the turn's response. A card settles the
* turn only; each case still grades whether the reply (or card) is the guidance it requires.
*/
export async function settlePrivilegedChat(ctx: ChatCatalogContext, observationDeadlineAt?: number): Promise<InternalObservation> {
const settled = await pollUntil({ label: "Canonical privileged guidance settled", deadlineAt: chatCatalogNativePhaseDeadline(ctx, observationDeadlineAt ?? Date.now() + 150_000),
load: async () => {
const o = await observeInternal(ctx, {}), source = ctx.issue();
const interactions = source && !o.reply ? await ctx.input.api.get<unknown[]>(`/api/issues/${source.id}/interactions`) : [];
return { o, cards: turnResponseCards({ companyId: o.companyId, leadId: o.leadId, runs: o.runs, interactions }) };
},
accept: ({ o, cards }) => chatCatalogNativePhaseIsTimely(ctx) && ((observationDeadlineAt === undefined || Date.now() < observationDeadlineAt) && o.runs.length === 1 && o.runs[0]?.status === "succeeded" && (!!o.reply || cards.length > 0) && ctx.issue()?.conversationState === "waiting"),
reject: ({ o }) => o.runs.some(run => ["failed", "timed_out", "cancelled"].includes(run.status)) ? "Canonical guidance run failed" : undefined });
return settled.o;
}
export const privilegedObservation = observation;
/** Bind a declared factual operator handoff to its actual persisted human message and native reply. */
export async function companionPhase(ctx: ChatCatalogContext, priorRunIds: string[], message: string) {
const final = await pollUntil({ label: "Native reply to the actual operator receipt", deadlineAt: chatCatalogNativePhaseDeadline(ctx, Date.now() + 150_000),
load: async () => { await ctx.refreshIssue(); return { issue: ctx.issue(), runs: await ctx.allRuns() }; },
accept: o => chatCatalogNativePhaseIsTimely(ctx) && (o.issue?.conversationState === "waiting" && o.runs.length === priorRunIds.length + 1 && o.runs.every(run => run.status === "succeeded")) });
const run = final.runs.find(run => !priorRunIds.includes(run.id) && run.agentId === ctx.input.fixtures.agent.id);
if (!run) throw new Error("Operator receipt did not produce its own native reply");
const comments = await ctx.input.api.get<Entity[]>(`/api/issues/${ctx.issue()!.id}/comments?order=asc`);
const human = comments.filter(comment => !comment.authorAgentId && comment.body === message);
const replies = comments.filter(comment => comment.authorAgentId === ctx.input.fixtures.agent.id && comment.createdByRunId === run.id);
const profile = await ctx.input.api.get<{ runnerProfileJson?: { nativeExecutionInput?: { task?: { prompt?: string } } } }>(`/api/heartbeat-runs/${run.id}`);
return { humanCommentId: human[0]?.id, runId: run.id, reply: replies.map(row => row.body).join("\n"),
attributed: human.length === 1 && replies.length > 0 && profile.runnerProfileJson?.nativeExecutionInput?.task?.prompt?.includes(human[0]!.id) === true
&& replies.every(reply => Date.parse(reply.createdAt!) >= Date.parse(human[0]!.createdAt!)) };
}