Files
PaperClipAI/tests/runner-e2e/chat-catalog-domains/runtime-primary.ts
T
DottaandClaude Opus 5.5 bf18b348c5 fix(evals): wait for saved, rendered replies and settled forms, and step through multi-question cards (#21009)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The chat eval catalog drives real chat journeys in a browser and
grades saved product state
> - In full cohort 3, four cases acted on the page before the page was
ready, or did not step through a card
> - The product was correct in each case, so these failures hide real
product failures
> - This pull request makes the harness wait for the saved, rendered
reply and for a settled form, and step through multi-question cards
> - The benefit is fewer false failures, with the same product
requirements

## Linked Issues or Issue Description

Refs #21000
Refs #21008

**What happened?**
- `companies.fast-responses`, `agents.choose-primary`,
`budgets-costs.set-budget`: the server recorded the chat run as finished
0.2 to 1.3 seconds before it saved the reply comment. The harness read
the reply links right after the run finished. It found no reply, so it
found no links.
- `budgets-costs.set-budget`: the budget card remounted after the
harness typed the new amount. The remount reset the draft, so the Update
budget button stayed disabled until the click timed out.
- `questions-and-approvals.necessary-question`: the agent asked the
required product-name question and one optional question. The card
showed Next on its first page. The harness only clicked Submit answers.
The grade also required exactly one question on the card.

**Expected behavior**
The harness reads a turn's links after its reply is saved and rendered.
It saves the budget only on a settled form. It pages through a card,
answers its one required question and skips optional ones.

**Steps to reproduce**
Run the three link cases when the reply is saved after the run finishes.
Run set-budget when the budget card reloads after typing. Run
necessary-question when the card has an optional second question.

## What Changed

- `chat-rendered-links.ts`: new shared `waitForSavedTurnReplyRender`. It
waits (bounded, 15 s) until each finished lead run's reply is saved,
then uses the existing render wait for the latest reply. It returns
`unsaved` when no reply arrives. The caller then fails with its own
error.
- `identity-support.ts`: new `waitForTurnReplyLinks` uses that wait for
the source conversation. `companies.fast-responses` (v3),
`agents.choose-primary` (v4) and `budgets-costs.set-budget` (v3) call it
before they read reply links.
- `budgets-costs.set-budget`: `settledInput` resolves when the same
input node stays on the page for one second. The harness types and saves
on that node. If the card still remounts and drops the amount, it types
it again, at most three times.
- `questions-and-approvals.necessary-question` (v2): the harness pages
through a multi-question card. It answers the one required question and
clicks Skip on each optional question. The grade now requires exactly
one required question, and that question must ask for the product name
as free text. Other questions must be optional. An optional necessary
question, a second required question, or a required question about
something else still fails.
- Descriptors are updated. No scenario yaml changed, so the TestOps
manifest does not change.

## Verification

- `npx vitest run --config tests/runner-e2e/vitest.config.ts
tests/runner-e2e/chat-rendered-links.test.ts
tests/runner-e2e/chat-catalog-consistency.test.ts
tests/runner-e2e/chat-catalog-fingerprint.test.ts
tests/runner-e2e/chat-catalog-domains/identity.test.ts`: 100 tests pass.
- New tests: a reply saved after the run finished is waited for and then
rendered; a run that saves no reply returns `unsaved` within its bound.
The necessary-question grade accepts the recorded card (required name
plus optional follow-up) and rejects the broken cards above.
- `npx tsc -p tests/runner-e2e/tsconfig.json --noEmit` passes.
- `node tests/runner-e2e/source-sdk-public-inputs.mjs <HEAD> --check`
reports no change.
- One paid run on a combined branch with #21008. The result is in a
comment on this pull request.

## Risks

- A turn whose run saves no reply now waits up to 15 seconds before the
case continues. Only the three named cases call the wait.
- `settledInput` marks the input node with a `data-e2e-settle-mark`
attribute to detect a remount. It does not change product behavior.
- #21000 changes the render-wait expiry in the same file. Whichever
merges second rebases.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, with 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-11 21:33:32 -06:00

145 lines
12 KiB
TypeScript

import { expect } from "@playwright/test";
import { checks, type Entity } from "./internal-support.js";
import { createAuthenticatedPerson, enrollPrerequisitePerson } from "./identity-auth.js";
import { probeAgentPermission, waitForReply, waitForTurnReplyLinks, remainingIdentityObservationBudget } from "./identity-support.js";
import { runtimeDefinition, runtimeWorker, runtimeObservation, observeRuntime, openRuntimeAgentProfile, verifyRuntimeResult, agentLink, type RuntimeAgent } from "./runtime-support.js";
import type { ChatCatalogContext } from "../chat-catalog-contract.js";
interface Primary { companyId: string; userId: string; primaryAgentId: string | null }
type Person = Awaited<ReturnType<typeof createAuthenticatedPerson>>;
const people = new WeakMap<ChatCatalogContext, Person>();
const endpoint = (companyId: string) => `/api/companies/${companyId}/primary-agent/me`;
async function personPrimary(person: Person, companyId: string): Promise<Primary> {
const response = await person.request(endpoint(companyId));
if (!response.ok) { await response.body?.cancel(); throw new Error(`Secondary primary read returned ${response.status}`); }
return response.json() as Promise<Primary>;
}
export function gradePrimary(value: unknown) {
const c = checks(); const o = runtimeObservation(value);
c.add("complete-human-primary-evidence", !!o, "Real user-scoped saved preferences and human replacement control are required."); if (!o) return c.result();
const worker = o.state.worker as RuntimeAgent; const before = o.state.primaryBefore as Primary;
const after = o.state.primaryAfter as Primary | undefined; const otherBefore = o.state.otherPersonBefore as Primary;
const otherAfter = o.state.otherPersonAfter as Primary | undefined;
c.add("only-human-replaces-existing-primary", !!before && before.primaryAgentId === o.leadId
&& o.state.beforeHumanPrimary === o.leadId && o.state.replacementConfirmed === true
&& after?.primaryAgentId === worker.id && after.companyId === o.companyId && after.userId === before.userId,
"The original primary survives the chat response and is replaced only after the visible human confirmation.");
c.add("agent-preference-write-denied", o.state.agentWriteStatus === 403, "The preference endpoint rejects the real CEO agent bearer.");
c.add("other-person-company-unaffected", !!otherAfter && !!otherBefore && otherAfter.userId !== before.userId
&& JSON.stringify(otherAfter) === JSON.stringify(otherBefore)
&& JSON.stringify(o.state.otherCompanyAfter) === JSON.stringify(o.state.otherCompanyBefore),
"Independent authenticated person and company preference readbacks do not change.");
c.add("recent-chat-precedence-without-work", o.state.reopenedRecentAgent === o.leadId
&& o.state.runsAfterNavigation === 1 && o.state.chatCountBeforeNavigation === o.state.chatCountAfterNavigation,
"Opening Chat may reopen this valid recent source conversation and creates no duplicate chat or execution.");
// The reply's wording is not graded: the person must reach the control
// through the exact profile link the reply rendered, and saved state proves
// the agent wrote nothing before that human confirmation.
const href = agentLink(o, worker);
c.add("reply-links-chosen-profile", !!href && o.state.profileLinkFollowed === href,
"The reply links Morgan's profile, and the person reaches the primary control by clicking that rendered link.");
return c.result();
}
export async function verifyReadyPrimaryChat(ctx: ChatCatalogContext, state: Record<string, unknown>, value: unknown) {
const o = runtimeObservation(value); const before = state.primaryBefore as Primary; const worker = state.worker as RuntimeAgent;
const sourceId = state.sourceBeforeNavigation; const nativeIds = state.nativeIdsBeforeNavigation;
if (!o || o.companyId !== ctx.input.fixtures.company.id || o.leadId !== ctx.input.fixtures.agent.id
|| typeof sourceId !== "string" || !sourceId || !Array.isArray(nativeIds) || nativeIds.length !== 1 || typeof nativeIds[0] !== "string")
throw new Error("Primary Chat readiness requires its exact original conversation and native run");
const budget = () => remainingIdentityObservationBudget(ctx);
const lead = o.agents.find(row => row.id === o.leadId); if (!lead) throw new Error("Primary Chat lead is missing");
await expect(ctx.input.page).toHaveURL(url => {
const parts = url.pathname.split("/").filter(Boolean).map(decodeURIComponent);
return parts.length === 3 && parts[0] === ctx.input.fixtures.company.issuePrefix && parts[1] === "chats" && [lead.id, lead.urlKey].includes(parts[2]);
}, { timeout: budget() }); budget();
const composer = ctx.input.page.getByTestId("task-chat-composer-input");
await expect(composer).toBeVisible({ timeout: budget() }); budget();
await expect(composer.locator('[contenteditable="true"], textarea').first()).toBeEditable({ timeout: budget() }); budget();
const chat = await ctx.input.api.get<Entity & { conversationAgentId?: string; conversationUserId?: string }>(`/api/companies/${o.companyId}/chats/${o.leadId}`, { timeout: budget() }); budget();
if (chat.id !== sourceId || chat.companyId !== o.companyId || chat.conversationAgentId !== o.leadId || chat.conversationUserId !== before.userId)
throw new Error("Ready Chat reopened a different conversation or person");
const comments = await ctx.input.api.get<Entity[]>(`/api/issues/${sourceId}/comments?order=asc`, { timeout: budget() }); budget();
const humans = comments.filter(row => !row.authorAgentId);
const reply = comments.find(row => row.authorAgentId === o.leadId && row.createdByRunId === nativeIds[0] && !!row.body?.trim());
if (humans.length !== 1 || humans[0]!.authorUserId !== before.userId || humans[0]!.body?.trim() !== ctx.scenario.query.trim() || !reply)
throw new Error("Ready Chat lacks its original human request and native response");
for (const comment of [humans[0]!, reply]) {
await expect(ctx.input.page.locator(`[id=${JSON.stringify(`comment-${comment.id}`)}]`)).toBeVisible({ timeout: budget() }); budget();
}
await ctx.refreshIssue(); budget();
const primary = await ctx.input.api.get<Primary>(endpoint(o.companyId), { timeout: budget() }); budget();
const chats = await ctx.input.api.get<Entity[]>(`/api/companies/${o.companyId}/chats`, { timeout: budget() }); budget();
const runs = await ctx.allRuns(); budget();
if (ctx.issue()?.id !== sourceId || primary.companyId !== o.companyId || primary.userId !== before.userId || primary.primaryAgentId !== worker.id
|| chats.length !== state.chatCountBeforeNavigation || chats.filter(row => row.id === sourceId).length !== 1
|| runs.length !== 1 || runs[0]?.id !== nativeIds[0] || runs[0].agentId !== o.leadId || runs[0].status !== "succeeded")
throw new Error("Settled Chat changed the primary preference, conversation or native work");
state.reopenedRecentAgent = o.leadId; state.runsAfterNavigation = runs.length; state.chatCountAfterNavigation = chats.length;
await ctx.input.evidence("chat-primary-ready-conversation.json", { companyId: o.companyId, agentId: o.leadId, sourceId, userId: before.userId,
primaryAgentId: primary.primaryAgentId, originalNativeIds: nativeIds, chatCount: chats.length, composerReady: true, originalConversationVisible: true, noExtraWork: true }); budget();
}
export const runtimePrimaryCases = [runtimeDefinition("agents.choose-primary", async ctx => {
const secrets = ctx.input.privateSecrets; if (!secrets) throw new Error("Primary fixture requires private credential sink");
const browser = ctx.input.page.context().browser(); if (!browser) throw new Error("Real browser required");
const primaryBefore = await ctx.input.api.get<Primary>(endpoint(ctx.input.fixtures.company.id));
if (primaryBefore.primaryAgentId !== ctx.input.fixtures.agent.id) throw new Error("Catalog requires an existing primary agent");
const worker = await runtimeWorker(ctx);
const person = await createAuthenticatedPerson({ api: ctx.input.api, browser, secrets, name: "Second primary reader" }); people.set(ctx, person);
await enrollPrerequisitePerson(ctx, person, "operator");
const otherCompany = await ctx.input.api.post<Entity>("/api/companies", { name: `Other primary company ${ctx.input.nonce}` });
return { worker, primaryBefore, otherCompany, otherPersonBefore: await personPrimary(person, ctx.input.fixtures.company.id),
otherCompanyBefore: await ctx.input.api.get<Primary>(endpoint(otherCompany.id)) };
}, gradePrimary, {
authentication: "human-session", oracleVersion: "paperclip.chat-catalog.runtime-primary.v4",
sourceFiles: ["chat-catalog-domains/runtime-primary.ts", "chat-catalog-domains/runtime-support.ts", "chat-catalog-domains/identity-support.ts", "chat-scenarios/agents.yaml"],
async act(ctx, state) {
remainingIdentityObservationBudget(ctx);
await waitForReply(ctx, ctx.observationDeadlineAt);
remainingIdentityObservationBudget(ctx);
const worker = state.worker as RuntimeAgent;
state.beforeHumanPrimary = (await ctx.input.api.get<Primary>(endpoint(ctx.input.fixtures.company.id))).primaryAgentId;
state.agentWriteStatus = await probeAgentPermission(ctx, endpoint(ctx.input.fixtures.company.id), "PUT", { primaryAgentId: worker.id });
// The run is recorded finished before its reply is saved and rendered; read the reply's links only once it is on the page.
await waitForTurnReplyLinks(ctx);
const o = await observeRuntime(ctx, state);
const source = ctx.input.page.url();
const href = agentLink(o, worker);
if (!href) throw new Error("The reply rendered no link to Morgan's profile for the person to follow");
await openRuntimeAgentProfile(ctx, worker, o);
state.profileLinkFollowed = href;
await ctx.input.page.getByRole("button", { name: "Set as my primary", exact: true }).click();
const dialog = ctx.input.page.getByRole("alertdialog");
await expect(dialog).toContainText("Change your primary agent?"); await expect(dialog).toContainText("Morgan");
await dialog.getByRole("button", { name: "Set as my primary", exact: true }).click();
await expect(ctx.input.page.getByRole("img", { name: "My primary agent", exact: true }).first()).toBeVisible();
state.replacementConfirmed = true;
// Revisit the valid recent source before opening the normal Chat landing.
await ctx.input.page.goto(source, { waitUntil: "domcontentloaded" });
await expect(ctx.input.page.getByTestId("task-chat-composer-input")).toBeVisible();
const chats = `/api/companies/${ctx.input.fixtures.company.id}/chats`;
state.chatCountBeforeNavigation = (await ctx.input.api.get<Entity[]>(chats)).length;
state.sourceBeforeNavigation = ctx.issue()?.id;
state.nativeIdsBeforeNavigation = (await ctx.allRuns()).map(run => run.id);
await ctx.input.page.goto(`/${ctx.input.fixtures.company.issuePrefix}/chats`, { waitUntil: "domcontentloaded" });
const lead = o.agents.find(row => row.id === ctx.input.fixtures.agent.id)!;
await ctx.input.page.waitForURL(url => [lead.id, lead.urlKey].includes(decodeURIComponent(url.pathname.split("/").at(-1)!)));
await verifyReadyPrimaryChat(ctx, state, o);
},
async observe(ctx, state) {
const person = people.get(ctx); if (!person) throw new Error("Secondary authenticated person missing");
state.primaryAfter = await ctx.input.api.get<Primary>(endpoint(ctx.input.fixtures.company.id));
state.otherPersonAfter = await personPrimary(person, ctx.input.fixtures.company.id);
state.otherCompanyAfter = await ctx.input.api.get<Primary>(endpoint((state.otherCompany as Entity).id));
return observeRuntime(ctx, state);
},
async verifyResult(ctx, state, value) {
remainingIdentityObservationBudget(ctx);
await verifyRuntimeResult(ctx, state, value);
remainingIdentityObservationBudget(ctx);
await verifyReadyPrimaryChat(ctx, state, value);
},
async cleanup(ctx, state) {
const person = people.get(ctx); people.delete(ctx); await person?.dispose();
await ctx.input.api.delete(`/api/companies/${(state.otherCompany as Entity).id}`, { allowNotFound: true });
},
})];