Files
PaperClipAI/tests/runner-e2e/chat-catalog-domains/integrator-routine-pause.ts
T
DottaandClaude Opus 5.5 5f34f777ec fix(evals): grade 16 chat cases by saved state instead of reply keywords (#20600)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The chat-feature eval catalog checks that a chat agent does the work
a person asks for
> - In the latest full-cohort run on master, 16 cases failed although
the saved work was correct
> - Their graders matched words, one literal value, or one API route in
free-form model output
> - A grader that fails correct work hides real failures in the noise,
and it can also pass wrong work that uses the expected word
> - This pull request grades those cases by saved state, audit records,
setup literals and rendered page structure, and keeps only narrow checks
for false claims
> - The benefit is that these cases fail only when the product or agent
does the wrong thing

## Linked Issues or Issue Description

Refs #18971 (related: company-secret question handoffs)

**What happened**

The full-cohort chat-eval run on master failed 16 cases where the
retained evidence shows correct behaviour. Examples:
`companies.rename-company` accepted only `company.updated`, but the
supported branding route logs `company.branding_updated`.
`recovery.post-stop-message` required a signup button label that matched
a regex, and rejected a labelled email form with the button "Start
writing free". `secrets.secret-proposal` flagged "the value is delivered
when Morgan next runs" as a false delivery claim. Other graders required
words such as "uncommitted", "telemetry", "administrator" or the app
name that the question did not ask for.

**Expected behavior**

A grader accepts every supported route and wording that achieves the
outcome. It fails when saved state is wrong, when a requested fact is
wrong, or when the reply makes a claim that saved state contradicts.

**Steps to reproduce**

1. Load the retained observation for one of the cases below from the
full-cohort evidence.
2. Grade it with the old oracle. The keyword or route check fails
although the saved state is correct.
3. Grade it with the new oracle. It passes. A broken variant (wrong
saved state or a contradicting claim) still fails.

## What Changed

- `activity.audit-change`: attribution is graded by structure. The reply
names the recorded editor, names no other company agent (new setup
literal `otherActors`), and states only real transitions. Sentence
grammar is no longer matched.
- `app-permissions.ask-before-action`: each continuation reply must
exist and must link no app record other than the reviewed connection. It
no longer must name the app.
- `budgets-costs.spend-breakdown`: the reply must point at the record
with unresolved usage (task link, identifier or run id) and must not
give it a zero or free amount. The words "subscription" and "incomplete"
are no longer required.
- `cases.browse-cases`: an excluded case may be mentioned in prose. It
must not be linked or listed as a result row.
- `cases.handoff-same-task`: handoff instructions carry findings forward
when they point at the saved `findings` document, reproduce it, or
restate it. The premise (a primary Morgan run performs the handoff) is
unchanged.
- `cases.update-case-facts`: a saved browser value may add clauses that
only rule out other platforms ("desktop Safari unaffected").
- `companies.agent-observability`: the reply no longer must name
Telemetry and the run log. It must not claim that they are exported.
- `companies.rename-company`: both supported name routes
(`company.updated`, `company.branding_updated`) are accepted.
- `connection-setup.choose-provider`: the answer must exist and must not
claim a connected account. The unavailable method no longer must be
named; the setup path stays graded by clicking the card or link.
- `goals.align-existing-work`: a question card from the answering run
counts as the phase-two response. Refusal words and "unchanged" words
are no longer required; false change claims still fail.
- `recovery.post-stop-message` (and `steering.interrupt-with-direction`,
which shares the helper): a visible form with a labelled field and a
submit control is a signup action, whatever the button says.
- `routines.pause-owned`: the "paused"/"running" words are no longer
required. The saved checkpoint proves both; a cancellation claim still
fails.
- `secrets.secret-proposal` (and `secrets.configure-company-secret`,
which shares the helper): a sentence that says when delivery will happen
is not a delivery claim.
- `skills.explicit-restriction`: the reply no longer must say
"administrator". Success claims and invented tool limitations still
fail.
- `workspaces.inspect-git`: the reply no longer must say "uncommitted".
Unchanged HEAD and status prove it; commit or push claims still fail.
- Every affected `oracleVersion` is bumped in code and in
`chat-catalog-descriptors.json`. No scenario YAML changed.

## Verification

- `pnpm exec vitest run --config tests/runner-e2e/vitest.config.ts
--testTimeout=180000 tests/runner-e2e/chat-catalog-domains
tests/runner-e2e/chat-catalog-contract.test.ts
tests/runner-e2e/chat-catalog-consistency.test.ts`: all pass.
- `PAPERCLIP_CHAT_EVAL_RENDERED_ARTIFACT_CANARY=1` rendered-artifact
canary with Chromium: pass, including new form cases.
- `node --test
tests/runner-e2e/chat-full-catalog-testops-manifest.test.mjs`: pass.
`node tests/runner-e2e/source-sdk-public-inputs.mjs <HEAD> --check`:
unchanged pins.
- `npx tsc --noEmit -p tests/runner-e2e`: no errors in changed files.
- Offline calibration on retained evidence, for each case: the retained
correct evidence passes, earlier passing evidence passes where it
exists, and a broken variant fails. `spend-breakdown` retained evidence
still fails, because its reply shows unresolved usage as `$0.00`.
`handoff-same-task` retained evidence still fails on the kept
primary-run premise.
- Live paid runs: see the comment below.

## Risks

- Low risk. The change is limited to eval graders and their unit tests.
Product code does not change.
- Each grader is more accepting of wording. Saved-state checks and the
narrow false-claim checks remain, so a wrong outcome still fails.
- `secrets.configure-company-secret` still fails its retained evidence.
Its agent asked a two-question card, and the act supports only
one-question cards. This pull request changes it only by the shared
delivery-claim helper.

## Model Used

- Anthropic Claude Opus 5.5 (`claude-opus-5-5`), tool use and code
execution 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 13:37:16 -06:00

89 lines
10 KiB
TypeScript

import { chatCatalogNativePhaseDeadline, chatCatalogNativePhaseIsTimely } from "../chat-catalog-contract.js";
import type { RoutineDetail, RoutineRunSummary } from "../../../packages/shared/src/types/routine.js";
import type { ChatCatalogContext } from "../chat-catalog-contract.js";
import { pollUntil } from "../api.js";
import { checks, observation, project, type Entity, type InternalObservation } from "./internal-support.js";
import { fixtureWorker } from "./internal-definition.js";
import { startRunBriefGate } from "./internal-run-brief.js";
import { integratorDefinition, observeInternal, inspectResult } from "./integrator-support.js";
import type { RuntimeAgent } from "./runtime-support.js";
const gates = new WeakMap<ChatCatalogContext, Awaited<ReturnType<typeof startRunBriefGate>>>();
interface PauseObservation extends InternalObservation { routine: RoutineDetail; invocations: RoutineRunSummary[] }
async function observePause(ctx: ChatCatalogContext, state: Record<string, unknown>): Promise<PauseObservation> {
const base = await observeInternal(ctx, state); const id = (state.routine as RoutineDetail).id;
return { ...base, routine: await ctx.input.api.get<RoutineDetail>(`/api/routines/${id}`), invocations: await ctx.input.api.get<RoutineRunSummary[]>(`/api/routines/${id}/runs`) };
}
function pauseObservation(value: unknown): PauseObservation | undefined {
const o = value as PauseObservation; return observation(value) && !!o.routine && Array.isArray(o.invocations) ? o : undefined;
}
export function gradeIntegratorRoutinePause(value: unknown) {
const c = checks(); const o = pauseObservation(value);
c.add("actual-history-and-active-invocation", !!o, "Requires saved routine, genuine prior execution and the actual in-flight task checkpoint.");
if (!o) return c.result();
const before = o.state.routine as RoutineDetail; const first = pauseObservation(o.state.pauseCheckpoint);
const prior = o.state.priorInvocation as RoutineRunSummary; const active = o.state.activeInvocation as RoutineRunSummary;
c.add("future-schedule-paused-without-definition-loss", o.routine.id === before?.id && o.routine.status === "paused" && first?.routine.status === "paused"
&& o.routine.assigneeAgentId === o.leadId && o.routine.description === before.description && o.routine.projectId === before.projectId
&& o.routine.triggers.length === before.triggers.length && before.triggers.some(trigger => trigger.kind === "schedule" && trigger.enabled)
&& o.routine.triggers.every(trigger => before.triggers.some(old => old.id === trigger.id && old.cronExpression === trigger.cronExpression && old.timezone === trigger.timezone)),
"The supported persisted pause excludes future scheduled dispatch while retaining the same owned routine and its saved trigger definition.");
const checkpointRun = first?.runs.find(run => run.id === o.state.activeNativeRunId);
c.add("pause-does-not-cancel-inflight", !!first && checkpointRun?.status === "running" && checkpointRun.agentId === (o.state.worker as Entity)?.id
&& first.tasks.some(task => task.id === active?.linkedIssueId && task.status === "in_progress")
// The pause and the still-running task are proven by saved state at the response; the reply must
// exist and must not claim the in-flight work was cancelled. Its wording is left to a human reader.
&& !!first.reply.trim()
&& !/\b(?:cancelled|canceled|terminated|stopped)\b[^.!?\n]{0,35}\b(?:running|in.flight|task|execution)\b|\b(?:task|execution|work)\b[^.!?\n]{0,25}\b(?:cancelled|canceled|terminated)\b/i.test(first.reply),
"At the actual response the separately assigned execution is still genuinely running and the reply does not claim it was cancelled.");
const savedPrior = o.invocations.find(run => run.id === prior?.id); const savedActive = o.invocations.find(run => run.id === active?.id);
c.add("history-retained-and-inflight-completes-normally", o.invocations.length === 2 && savedPrior?.status === "completed" && savedPrior.linkedIssueId === prior.linkedIssueId
&& savedActive?.status === "completed" && savedActive.linkedIssueId === active.linkedIssueId
&& o.runs.some(run => run.id === o.state.activeNativeRunId && run.status === "succeeded") && o.tasks.some(task => task.id === active.linkedIssueId && task.status === "done"),
"Both real invocation histories remain accessible and the preexisting in-flight task completes normally after its actual dependency is released.");
return c.result();
}
export const integratorRoutinePauseCases = [integratorDefinition("routines", "routines.pause-owned", async ctx => {
const p = await project(ctx, "Launch"); const worker = await fixtureWorker(ctx);
for (const scope of [{ scopeType: "company", scopeId: ctx.input.fixtures.company.id }, { scopeType: "agent", scopeId: worker.id }])
await ctx.input.api.post(`/api/companies/${ctx.input.fixtures.company.id}/budgets/policies`, { ...scope, amount: 500, hardStopEnabled: true, isActive: true, unpricedUsagePolicy: "allow" });
const routine = await ctx.input.api.post<RoutineDetail>(`/api/companies/${ctx.input.fixtures.company.id}/routines`, { title: "Daily launch summaries", projectId: p.id, assigneeAgentId: ctx.input.fixtures.agent.id,
description: "Save one short factual Launch summary in a task comment, then complete this summary task. Do not create other work.", concurrencyPolicy: "always_enqueue" });
// Public manual execution supports a one-invocation assignee override. The
// routine remains owned by the CEO while this ordinary task runs as Morgan.
const priorInvocation = await ctx.input.api.post<RoutineRunSummary>(`/api/routines/${routine.id}/run`, { source: "manual", assigneeAgentId: worker.id, idempotencyKey: `prior-pause-${ctx.input.nonce}` });
await pollUntil({ label: "real completed routine history before pause", deadlineAt: Date.now() + 180_000, intervalMs: 1000,
load: () => ctx.input.api.get<RoutineRunSummary[]>(`/api/routines/${routine.id}/runs`), accept: rows => rows.length === 1 && rows[0]?.status === "completed" });
const gate = await startRunBriefGate(ctx, worker.id); gates.set(ctx, gate);
try {
await ctx.input.api.patch(`/api/routines/${routine.id}`, { description: "This Launch summary is waiting for its current website brief. First call the connected Website Brief app's read_website_brief tool and wait for its real response; do not substitute earlier context. Then save a short summary of the received brief in a task comment and mark this summary task done. Do not create tasks or change routine settings." });
const activeInvocation = await ctx.input.api.post<RoutineRunSummary>(`/api/routines/${routine.id}/run`, { source: "manual", assigneeAgentId: worker.id, idempotencyKey: `active-pause-${ctx.input.nonce}` });
const phase = await pollUntil({ label: "actual routine task waiting for source brief", deadlineAt: Date.now() + 150_000, intervalMs: 1000,
load: async () => ({ runs: await ctx.allRuns(), task: await ctx.input.api.get<Entity>(`/api/issues/${activeInvocation.linkedIssueId}`) }),
accept: value => value.runs.length === 2 && value.task.status === "in_progress" && gate.calls.some(call => call.authorized) && value.runs.some(run => run.contextSnapshot?.issueId === activeInvocation.linkedIssueId && run.status === "running") });
await ctx.input.api.post(`/api/routines/${routine.id}/triggers`, { kind: "schedule", cronExpression: "0 9 * * *", timezone: "America/Chicago", enabled: true });
return { project: p, worker: { id: worker.id }, routine: await ctx.input.api.get<RoutineDetail>(`/api/routines/${routine.id}`), priorInvocation, activeInvocation,
activeNativeRunId: phase.runs.find(run => run.contextSnapshot?.issueId === activeInvocation.linkedIssueId)?.id, gateObserved: gate.calls.some(call => call.authorized) };
} catch (error) { await gate.close(); gates.delete(ctx); throw error; }
}, observePause, gradeIntegratorRoutinePause, async (ctx, state) => {
const routine = state.routine as RoutineDetail; await inspectResult(ctx, `/${ctx.input.fixtures.company.issuePrefix}/routines/${routine.id}/history`, routine.title);
}, { sourceFiles: ["chat-catalog-domains/integrator-routine-pause.ts", "chat-catalog-domains/internal-run-brief.ts", "chat-catalog-domains/internal-definition.ts", "chat-catalog-domains/internal-support.ts", "chat-catalog-domains/integrator-support.ts", "chat-scenarios/routines.yaml"],
oracleVersion: "paperclip.chat-catalog.routine-pause.structural.v2", expectedRunCount: 3, attemptTimeoutMs: 720_000,
setupPolicy: { readiness: "preserve-scenario", invariantCheckIds: ["lead-ready", "owned-routine-real-active-task"] },
async verifySetup(ctx, state) {
const c = checks(); const lead = await ctx.input.api.get<RuntimeAgent>(`/api/agents/${ctx.input.fixtures.agent.id}`);
const active = (await ctx.allRuns()).find(run => run.id === state.activeNativeRunId);
c.add("lead-ready", lead.lifecycleState === "ready" && ["idle", "active"].includes(lead.status ?? ""), "The real routine owner is ready for the canonical chat.");
c.add("owned-routine-real-active-task", active?.status === "running" && active.agentId === (state.worker as Entity).id && active.contextSnapshot?.issueId === (state.activeInvocation as RoutineRunSummary).linkedIssueId
&& state.gateObserved === true && (state.routine as RoutineDetail).assigneeAgentId === lead.id, "The saved CEO-owned routine has a genuinely busy separately assigned ordinary invocation.");
return c.result();
},
async act(ctx, state) {
const checkpoint = await pollUntil({ label: "actual pause reply while invocation remains running", deadlineAt: chatCatalogNativePhaseDeadline(ctx, Date.now() + 150_000), intervalMs: 1000,
load: () => observePause(ctx, state), accept: o => chatCatalogNativePhaseIsTimely(ctx) && (o.runs.length === 3 && o.runs.some(run => run.agentId === o.leadId && run.status === "succeeded") && ctx.issue()?.conversationState === "waiting") });
state.pauseCheckpoint = { ...checkpoint, state: structuredClone(state) };
await ctx.input.evidence("chat-routine-pause-running-checkpoint.json", state.pauseCheckpoint);
gates.get(ctx)?.release();
},
async cleanup(ctx) { const gate = gates.get(ctx); if (gate) { await gate.close(); gates.delete(ctx); } },
})];