Files
PaperClipAI/tests/runner-e2e/native-task-reply.ts
T
DottaandPaperclip 81ebe479e8 fix(evals): bind rendered Markdown replies to saved run evidence (#18264)
## Thinking Path

> - Paperclip lets people assign work to AI agents and read saved
results.
> - Product journeys must prove that a result is visible and belongs to
the completed run.
> - The shared observer compares stored Markdown with browser text.
> - A document link can render as an issue chip, so those strings
differ.
> - This PR keeps run and comment attribution, then compares full text
and exact link destinations.
> - The benefit is a valid browser result without a false timeout or a
weaker provenance check.

## Linked Issues or Issue Description

Refs: #18248, #18255.

**What happened?**

The protected-action story completed both decisions and all four native
turns. Its final explanation contained a document link. The browser
rendered that link as an issue chip. The observer waited for raw
Markdown to equal rendered text, so it could not accept the valid
result.

**Expected behavior**

Accept complete matching content from the final run after reload. Keep
the exact link destination, task anchor, author, run and durable
final-message checks. Reject missing or changed content.

**Steps to reproduce**

1. Save an attributed reply with bold text and a Markdown document link.
2. Render the link as an issue chip in the task bubble.
3. Run `readBoundTaskFinalReply`. The original observer times out. The
new browser regression preserves that failure.

## What Changed

- Add a UI-owned content projection using its existing Markdown renderer
and GFM/issue-reference plugins. Keep DOM observation, attribution and
destination checks in the task-reply observer. Keep source Markdown and
observed text separately.
- Read visible DOM text, link labels and destinations. Require the
structured projection to match the actual visible text. Allow only
recognized product chips to replace a link label. Ordinary links keep
their source label. Reuse the production issue-reference and
company-route helpers, plus the current task URL. Ordinary root-relative
anchors keep their original path. Preserve exact marker checks and all
run/comment/native-event attribution checks.
- Check visible decline explanations and unread data in preserved native
source text.
- Add browser regressions and source/content tests. Reuse the UI’s
existing `react-markdown` and `remark-gfm` dependencies. The final diff
changes no package manifest or lockfile.
- Match generated GFM checkbox states and reject changed states.
Document supported content and fail-closed behavior.

## Verification

- Original observer: the decorated-link browser regression fails at the
final-reply predicate.
- Fixed observer: all 21 browser regressions pass, without credentials
or model calls.
- Focused observer, decline, content and journey contract suites: 64
tests pass.
- E2E and UI TypeScript checks pass. All UI token gates pass. The
unchanged frozen install passes.
- Full CI passed on the preceding 6eba7ff head (41003112166). Full CI
passed on the preceding b9a2571 head (41006205475), with both reviewers
at 5/5. Current-head full CI passed at
b3d2d08305 (41009669966), including
typecheck, build and all eight E2E shards. Both reviewers scored this
commit 5/5, with no unresolved threads. The initial lockfile policy
failure was corrected. The final diff has no manifest or lock changes,
and its unchanged frozen install passed.
- The optional positive GFM strikethrough assertion also passes.
- The Node 24/tsx end-to-end observer probe passes. The callback has no
external compiler helper; a browser regression covers actual tsx
serialization.
- The Paperclip review finding is also fixed: bare URLs and issue keys
use the production plugins and the UI’s `scope=accessible`
company-prefix context, with RED/GREEN browser coverage. Source Markdown
is required in the receipt.
- All Greptile findings are fixed, including matching the UI’s
accessible-company scope: ordinary link labels, issue-prefix/fragment
resolution and ordinary root-relative paths have positive and negative
browser coverage.
- The deployed attempt remains a failed original measurement. Fresh
qualification is required after this repair.

## Risks

The comparison normalizes whitespace and inline formatting. It verifies
full text and exact link targets, while link labels may become product
chips. Images, raw HTML, tables and fenced code fail closed. Those
constructs may require a future explicit extension if a journey uses
them. No application behavior, model, budget, approval or connection
permission changes.

## Model Used

OpenAI Codex, GPT-6 family, with reasoning, code execution and browser
tools. The host does not expose an exact underlying model version or
context-window size.

## 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 described the issue in-PR following the relevant issue
template
- [x] I have not referenced internal/instance-local Paperclip issues or
links
- [x] My branch name describes the change and contains no internal issue
identifier
- [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
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-10-30 00:57:59 -05:00

117 lines
8.2 KiB
TypeScript

import { expect, type Page } from "@playwright/test";
import { isValidNativePrpEnvelope } from "./native-event-envelope.js";
import { taskReplyContentMatches, type ReplyContentPart } from "./task-reply-content.js";
type Row = Record<string, any>;
export interface TaskReplyObservation {
body: string; anchor: string | null; settled: boolean; nativeFinal: boolean; visible: boolean;
content?: ReplyContentPart[]; origin?: string; pageUrl?: string; knownCompanyPrefixes?: string[];
}
export type BoundTaskFinalReply = Awaited<ReturnType<typeof readBoundTaskFinalReply>>;
/** Native task replies can render from a saved turn before a completion comment
* is projected. Respect an explicit presentation decision when one exists. */
export function nativeFinalReplyEvents(events: Row[], run: Row) {
return events.flatMap(event => {
const envelope = event.payload?.prpEvent, payload = envelope?.payload, item = payload?.item;
if (!envelope || !Number.isSafeInteger(event.seq) || event.seq < 1 || !isValidNativePrpEnvelope(envelope, event.protocolSchemaVersion)
|| event.runId !== run.id || event.companyId !== run.companyId || event.agentId !== run.agentId
|| envelope.runId !== run.id || envelope.sourceKind !== "runner" || event.eventType !== "item.completed" || envelope.eventType !== event.eventType
|| typeof envelope.sourceEventId !== "string" || !envelope.sourceEventId || event.sourceEventId !== envelope.sourceEventId
|| typeof envelope.sourceInstanceId !== "string" || !envelope.sourceInstanceId || event.sourceInstanceId !== envelope.sourceInstanceId
|| !Number.isSafeInteger(envelope.sourceSeq) || envelope.sourceSeq < 1 || event.sourceSeq !== envelope.sourceSeq
|| !["agentMessage", "assistant_message"].includes(payload?.kind ?? item?.type) || (payload.channel ?? item?.channel) !== "final"
|| payload.kind != null && item?.type != null && payload.kind !== item.type
|| payload.channel != null && item?.channel != null && payload.channel !== item.channel) return [];
const text = typeof payload.text === "string" ? payload.text : item?.text;
if (typeof text !== "string" || !text.trim() || typeof payload.text === "string" && typeof item?.text === "string" && payload.text !== item.text) return [];
return [{ seq: event.seq, sourceEventId: envelope.sourceEventId, sourceInstanceId: envelope.sourceInstanceId,
sourceSeq: envelope.sourceSeq, protocolSchemaVersion: event.protocolSchemaVersion, body: text.trim() }];
});
}
export function taskReplyBelongsToRun(observation: TaskReplyObservation, run: Row, agentId: string, comments: Row[], events: Row[] = []) {
if (typeof run.id !== "string" || !run.id || !agentId || run.agentId !== agentId || run.status !== "succeeded" || run.runtimeMode !== "native" || !observation.body || !observation.visible) return false;
const decision = run.resultJson?.presentationDecision;
if (decision != null && (typeof decision !== "object" || Array.isArray(decision))) return false;
if (decision?.commentId != null && typeof decision.commentId !== "string") return false;
const projected = typeof decision?.commentId === "string" ? comments.find(comment => comment.id === decision.commentId) : null;
if (projected) {
return observation.anchor === projected.id && projected.authorAgentId === agentId
&& projected.createdByRunId === run.id && typeof projected.body === "string" && taskReplyContentMatches(observation, projected.body);
}
const finals = nativeFinalReplyEvents(events, run);
return finals.length === 1 && taskReplyContentMatches(observation, finals[0]!.body) && observation.settled && observation.nativeFinal
&& (observation.anchor === `${run.id}:turn` || observation.anchor?.startsWith(`${run.id}:turn:`) === true);
}
export function uniqueBoundTaskReply(all: TaskReplyObservation[], run: Row, agentId: string, comments: Row[], expectedText?: string, events: Row[] = []) {
const matches = all.filter(row => taskReplyBelongsToRun(row, run, agentId, comments, events)
&& (expectedText === undefined || row.body === expectedText));
return matches.length === 1 && (expectedText === undefined || all.filter(row => row.visible && row.body === expectedText).length === 1)
? matches[0]! : null;
}
export function observeTaskReplyElements(elements: Array<HTMLElement | SVGElement>): TaskReplyObservation[] {
return elements.map(element => {
const content: Array<{ text: string } | { href: string; label: string; decorated: boolean } | { checked: boolean }> = [];
const nodes: Array<Node | string> = [element];
while (nodes.length) {
const node = nodes.pop()!;
if (typeof node === "string") { content.push({ text: node }); continue; }
if (node.nodeType === Node.TEXT_NODE) { content.push({ text: node.textContent ?? "" }); continue; }
if (!(node instanceof HTMLElement) || node.getAttribute("aria-hidden") === "true"
|| getComputedStyle(node).display === "none" || getComputedStyle(node).visibility === "hidden") continue;
if (node instanceof HTMLInputElement && node.type === "checkbox") {
if (node.getClientRects().length) content.push({ checked: node.checked });
continue;
}
if (node instanceof HTMLAnchorElement) {
if (node.getClientRects().length && node.innerText.trim()) content.push({ href: node.href, label: node.innerText,
decorated: node.matches('[data-mention-kind="issue"], [data-sidebar-document-link]') });
continue;
}
const block = ["P", "DIV", "BR", "LI", "PRE", "BLOCKQUOTE", "H1", "H2", "H3", "H4", "H5", "H6"].includes(node.tagName);
if (block) { content.push({ text: " " }); nodes.push(" "); }
for (let index = node.childNodes.length - 1; index >= 0; index--) nodes.push(node.childNodes[index]!);
}
return {
body: (element as HTMLElement).innerText.trim(),
content, origin: location.origin, pageUrl: location.href,
anchor: element.closest("[data-thread-anchor]")?.getAttribute("data-thread-anchor") ?? null,
settled: element.closest('[data-testid="task-chat-turn"]')?.getAttribute("data-settled") === "true",
nativeFinal: element.closest('[data-testid="task-chat-final-response"]') !== null,
visible: element.getClientRects().length > 0 && getComputedStyle(element).visibility !== "hidden" && getComputedStyle(element).display !== "none",
};
});
}
/** Observe the real task thread after a fresh load, rather than a finish summary
* or a provider's claimed result. The caller separately validates task/run scope. */
export async function readBoundTaskFinalReply(input: {
page: Page; runId: string; agentId: string; expectedText?: string; deadlineAt: number; knownCompanyPrefixes: string[];
load(): Promise<{ run: Row; comments: Row[]; events: Row[] }>;
}) {
await input.page.reload();
const replies = input.page.getByTestId("task-chat-thread").getByTestId("task-chat-agent-bubble");
const observe = () => replies.evaluateAll(observeTaskReplyElements);
const read = async () => {
const state = await input.load(), all = (await observe()).map(row => ({ ...row, knownCompanyPrefixes: input.knownCompanyPrefixes }));
const match = state.run.id === input.runId ? uniqueBoundTaskReply(all, state.run, input.agentId, state.comments, input.expectedText, state.events) : null;
return { ...state, match };
};
const timeout = input.deadlineAt - Date.now();
if (timeout <= 0) throw new Error("native_reply_deadline");
await expect.poll(async () => {
return Boolean((await read()).match);
}, { timeout, intervals: [1000, 2000, 5000] }).toBe(true);
const { match, run, events, comments } = await read();
if (!match) throw new Error("native_reply_changed");
const source = match.anchor === run.resultJson?.presentationDecision?.commentId ? "posted-comment" : "native-turn";
const finalEvent = source === "native-turn" ? nativeFinalReplyEvents(events, run)[0] : null;
const sourceBody = source === "posted-comment" ? comments.find(comment => comment.id === match.anchor)?.body : finalEvent?.body;
if (typeof sourceBody !== "string" || !taskReplyContentMatches(match, sourceBody)) throw new Error("native_reply_source_changed");
return { runId: input.runId, agentId: input.agentId, observedAfterReload: true,
source, sourceBody, finalEvent, ...match };
}