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

171 lines
12 KiB
TypeScript

import type { Locator, Page } from "@playwright/test";
import type { ChatRun } from "./chat-flow.js";
interface Row { id: string; companyId?: string; issueId?: string; authorAgentId?: string | null; createdByRunId?: string | null; name?: string; action?: string; entityType?: string; entityId?: string; agentId?: string | null; actorType?: string; actorId?: string; runId?: string | null }
export interface RenderedConversationLink {
threadTestId: "task-chat-thread" | "thread-root"; href: string; source: "reply" | "project-created-card"; conversationId: string; anchorCommentId: string;
commentId?: string; resourceId?: string; cardLabel?: string; createdByRunId?: string;
}
export interface ConversationLinkInput {
origin: string; companyId: string; companyPrefix?: string | null; sourceId: string; leadId: string;
comments: Row[]; runs: ChatRun[]; projects: Row[]; activity: Row[];
}
/** Markdown link targets written in saved text, e.g. a reply body: inline [label](target), reference
* definitions ([label]: target) and autolinks (<https://...>). */
export function markdownLinkTargets(text: string): string[] {
const unwrap = (target: string) => target.startsWith("<") && target.endsWith(">") ? target.slice(1, -1) : target;
return [
...[...text.matchAll(/\]\(\s*([^)\s]+)/g)].map(match => unwrap(match[1]!)),
...[...text.matchAll(/^[ \t]{0,3}\[[^\]\n]+\]:[ \t]*(\S+)/gm)].map(match => unwrap(match[1]!)),
...[...text.matchAll(/<((?:https?:\/\/|\/)[^>\s]+)>/g)].map(match => match[1]!),
];
}
/** A link target that still holds a template placeholder such as <prefix> or {companyId}; it opens no real page. */
export function unresolvedLinkTarget(href: string): boolean {
let decoded = href;
try { decoded = decodeURIComponent(href); } catch { /* keep the raw target */ }
return /[<>{}]/.test(decoded);
}
export function conversationLinkBindings(input: ConversationLinkInput) {
const comments = input.comments.filter(row => row.companyId === input.companyId && row.issueId === input.sourceId);
const replies = comments.filter(row => row.authorAgentId === input.leadId && input.runs.some(run =>
run.id === row.createdByRunId && run.companyId === input.companyId && run.agentId === input.leadId
&& ((run as ChatRun & { nativeIssueId?: string }).nativeIssueId ?? run.contextSnapshot?.issueId) === input.sourceId));
const projects = input.projects.flatMap(project => {
if (project.companyId !== input.companyId || !project.name) return [];
const creation = input.activity.find(row => row.companyId === input.companyId && row.action === "project.created"
&& row.entityType === "project" && row.entityId === project.id && row.actorType === "agent"
&& row.actorId === input.leadId && row.agentId === input.leadId
&& replies.some(reply => reply.createdByRunId === row.runId));
return creation?.runId ? [{ id: project.id, name: project.name, runId: creation.runId }] : [];
});
return { origin: input.origin, companyPrefix: input.companyPrefix, sourceId: input.sourceId,
anchors: comments.map(row => row.id), replies: replies.map(row => row.id), projects };
}
export function readConversationDomLinks(input: ReturnType<typeof conversationLinkBindings>): RenderedConversationLink[] {
const visible = (element: Element) => {
for (let node: Element | null = element; node; node = node.parentElement) {
if (node.hasAttribute("hidden") || node.hasAttribute("inert") || node.getAttribute("aria-hidden") === "true") return false;
const style = window.getComputedStyle(node);
if (style.display === "none" || ["hidden", "collapse"].includes(style.visibility) || style.opacity === "0") return false;
}
return element.getClientRects().length > 0;
};
const threadSelector = '[data-testid="task-chat-thread"], [data-testid="thread-root"]';
const roots = Array.from(document.querySelectorAll(threadSelector)).filter(root => visible(root)
&& input.anchors.some(id => Array.from(root.querySelectorAll("[id]")).some(node => node.id === `comment-${id}` && node.closest(threadSelector) === root)));
if (roots.length !== 1) return [];
const root = roots[0]!;
const threadTestId = root.getAttribute("data-testid") as RenderedConversationLink["threadTestId"];
const anchor = input.anchors.find(id => Array.from(root.querySelectorAll("[id]")).some(node => node.id === `comment-${id}`))!;
const rows: RenderedConversationLink[] = [];
const sameConversation = (node: Element) => node.closest(threadSelector) === root;
const companyResource = (href: string) => {
try {
const url = new URL(href, input.origin), parts = url.pathname.split("/").filter(Boolean).map(decodeURIComponent);
const resourceAt = parts.findIndex(part => ["projects", "issues", "goals"].includes(part));
if (url.origin !== new URL(input.origin).origin) return !(resourceAt === 1 && parts.length === 3);
return resourceAt < 0 || resourceAt === 1 && parts[0] === input.companyPrefix;
} catch { return false; }
};
for (const id of input.replies) {
const reply = Array.from(root.querySelectorAll("[id]")).find(node => node.id === `comment-${id}` && sameConversation(node));
for (const node of reply?.querySelectorAll("a[href]") ?? []) {
const href = node.getAttribute("href");
if (href && sameConversation(node) && visible(node) && companyResource(href)) rows.push({ threadTestId, href, source: "reply", conversationId: input.sourceId, anchorCommentId: id, commentId: id });
}
}
for (const project of threadTestId === "task-chat-thread" ? input.projects : []) {
const label = `Project created: ${project.name}`;
for (const card of root.querySelectorAll("article[aria-label]")) {
const item = card.closest('[data-thread-item-kind="project_created"]');
if (item?.getAttribute("data-thread-anchor") !== `project-created:${project.id}` || card.getAttribute("aria-label") !== label || card.closest('[id^="comment-"]') || !sameConversation(card) || !visible(card)) continue;
for (const node of card.querySelectorAll("a[href]")) {
const href = node.getAttribute("href");
if (!href || !visible(node) || !sameConversation(node)) continue;
try {
const url = new URL(href, input.origin), parts = url.pathname.split("/").filter(Boolean).map(decodeURIComponent);
if (url.origin !== new URL(input.origin).origin || parts.length !== 3 || parts[0] !== input.companyPrefix || parts[1] !== "projects" || parts[2] !== project.id) continue;
rows.push({ threadTestId, href, source: "project-created-card", conversationId: input.sourceId, anchorCommentId: anchor, resourceId: project.id, cardLabel: label, createdByRunId: project.runId });
} catch { continue; }
}
}
}
return rows;
}
/** The API reports a saved reply before the open conversation renders it.
* When this conversation is open in the page, wait (bounded) for its latest
* saved lead reply to render so rendered links are not read too early. A page
* that is not showing this conversation is left alone. */
export const CONVERSATION_REPLY_RENDER_WAIT_MS = 10_000;
// A reply that did not render within one wait is not waited for again in the same loaded document, so
// repeated observations in a poll loop pay the wait at most once per reply. The expiry belongs to the
// document, not the Playwright page: after a reload or navigation the thread renders again from scratch,
// and reading it instantly then would capture no links although the reply renders moments later.
const expiredReplyWaits = new WeakMap<object, Set<string>>();
export function readConversationDocumentIdentity(): string {
const view = window as typeof window & { __paperclipConversationLinkDocument?: string };
return view.__paperclipConversationLinkDocument ??= `${performance.timeOrigin}:${Math.random()}`;
}
export type ConversationReplyRender = "closed" | "rendered" | "pending";
export function readLatestConversationReplyRender(input: { anchors: string[]; latest: string }): ConversationReplyRender {
const threadSelector = '[data-testid="task-chat-thread"], [data-testid="thread-root"]';
// A thread is this conversation's when every comment it renders is one of its saved comments; a thread that
// has not rendered any comment yet (still loading after navigation or reload) counts too.
const roots = Array.from(document.querySelectorAll(threadSelector)).filter(root =>
Array.from(root.querySelectorAll('[id^="comment-"]')).every(node => input.anchors.some(id => node.id === `comment-${id}`)));
if (!roots.length) return "closed";
const rendered = roots.some(root => Array.from(root.querySelectorAll('[id^="comment-"]'))
.some(node => node.id === `comment-${input.latest}` && node.getClientRects().length > 0));
return rendered ? "rendered" : "pending";
}
export async function waitForLatestConversationReplyRender(page: Pick<Page, "evaluate">, bindings: Pick<ReturnType<typeof conversationLinkBindings>, "anchors" | "replies">,
timeoutMs = CONVERSATION_REPLY_RENDER_WAIT_MS, intervalMs = 250): Promise<ConversationReplyRender> {
const latest = bindings.replies.at(-1);
if (!latest) return "rendered";
let documentIdentity: string;
try { documentIdentity = await page.evaluate(readConversationDocumentIdentity); } catch { return "closed"; }
const waitKey = `${documentIdentity}:${latest}`;
const expired = expiredReplyWaits.get(page) ?? new Set<string>();
if (expired.has(waitKey)) timeoutMs = 0;
// Bounded by attempts as well as time so a frozen test clock cannot spin.
for (let attempt = 0, attempts = Math.max(1, Math.ceil(timeoutMs / intervalMs)), deadlineAt = Date.now() + timeoutMs; ; attempt++) {
let state: unknown;
try { state = await page.evaluate(readLatestConversationReplyRender, { anchors: bindings.anchors, latest }); } catch { return "closed"; }
if (state !== "pending") return state === "closed" ? "closed" : "rendered";
if (attempt + 1 >= attempts || Date.now() >= deadlineAt) { expired.add(waitKey); expiredReplyWaits.set(page, expired); return "pending"; }
await new Promise(resolve => setTimeout(resolve, intervalMs));
}
}
/** A finished run's reply comment can be saved shortly after the run itself is recorded as finished, and the open
* conversation renders it later still. Before acting on a turn's rendered links, wait (bounded) for each finished
* lead run's saved reply, then for the latest to render. "unsaved" means no run saved a reply in time. */
export const TURN_REPLY_SAVE_WAIT_MS = 15_000;
export async function waitForSavedTurnReplyRender(page: Pick<Page, "evaluate">, input: {
comments: () => Promise<Row[]>; leadId: string; runIds: readonly string[];
}, timeoutMs = TURN_REPLY_SAVE_WAIT_MS, intervalMs = 250): Promise<ConversationReplyRender | "unsaved"> {
const deadlineAt = Date.now() + timeoutMs;
for (let attempt = 0, attempts = Math.max(1, Math.ceil(timeoutMs / intervalMs)); ; attempt++) {
const rows = await input.comments();
const replies = rows.filter(row => row.authorAgentId === input.leadId && input.runIds.includes(String(row.createdByRunId)));
const saved = input.runIds.length > 0 && input.runIds.every(id => replies.some(row => row.createdByRunId === id));
if (saved || attempt + 1 >= attempts || Date.now() >= deadlineAt) {
if (!replies.length) return "unsaved";
return waitForLatestConversationReplyRender(page, { anchors: rows.map(row => String(row.id)), replies: replies.map(row => String(row.id)) },
Math.max(CONVERSATION_REPLY_RENDER_WAIT_MS, deadlineAt - Date.now()), intervalMs);
}
await new Promise(resolve => setTimeout(resolve, intervalMs));
}
}
export async function collectConversationLinks(page: Page, input: ConversationLinkInput) {
const bindings = conversationLinkBindings(input);
await waitForLatestConversationReplyRender(page, bindings);
return page.evaluate(readConversationDomLinks, bindings);
}
export function conversationLinkLocator(page: Page, link: RenderedConversationLink): Locator {
const thread = page.getByTestId(link.threadTestId).filter({ has: page.locator(`[id=${JSON.stringify(`comment-${link.anchorCommentId}`)}]`) });
return link.source === "reply"
? thread.locator(`[id=${JSON.stringify(`comment-${link.commentId}`)}] a[href=${JSON.stringify(link.href)}]`)
: thread.getByRole("article", { name: link.cardLabel!, exact: true }).locator(`a[href=${JSON.stringify(link.href)}]`);
}