Fix rendered Markdown reply evidence in product journeys

Match full visible content and exact link destinations while retaining source Markdown and native run attribution. Preserve the original browser regression and strengthen decline source checks.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-29 23:07:30 -05:00
1 parent b3c2172d41
commit 6677515cf3
11 files changed
+177 -18

No files matched your search

+39 -12
View File
@@ -1,11 +1,13 @@
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;
}
export type BoundTaskFinalReply = Awaited<ReturnType<typeof readBoundTaskFinalReply>>;
export type BoundTaskFinalReply = Omit<Awaited<ReturnType<typeof readBoundTaskFinalReply>>, "sourceBody"> & { sourceBody?: string };
/** Native task replies can render from a saved turn before a completion comment
* is projected. Respect an explicit presentation decision when one exists. */
@@ -36,10 +38,10 @@ export function taskReplyBelongsToRun(observation: TaskReplyObservation, run: Ro
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" && projected.body.trim() === observation.body;
&& projected.createdByRunId === run.id && typeof projected.body === "string" && taskReplyContentMatches(observation, projected.body);
}
const finals = nativeFinalReplyEvents(events, run);
return finals.length === 1 && finals[0]!.body === observation.body && observation.settled && observation.nativeFinal
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);
}
@@ -50,6 +52,34 @@ export function uniqueBoundTaskReply(all: TaskReplyObservation[], run: Row, agen
? matches[0]! : null;
}
export function observeTaskReplyElements(elements: Array<HTMLElement | SVGElement>): TaskReplyObservation[] {
return elements.map(element => {
const content: Array<{ text: string } | { href: string }> = [];
const walk = (node: Node) => {
if (node.nodeType === Node.TEXT_NODE) { content.push({ text: node.textContent ?? "" }); return; }
if (!(node instanceof HTMLElement) || node.getAttribute("aria-hidden") === "true"
|| getComputedStyle(node).display === "none" || getComputedStyle(node).visibility === "hidden") return;
if (node instanceof HTMLAnchorElement) {
if (node.getClientRects().length && (node.innerText.trim() || node.getAttribute("aria-label"))) content.push({ href: node.href });
return;
}
const block = ["P", "DIV", "BR", "LI", "PRE", "BLOCKQUOTE", "H1", "H2", "H3", "H4", "H5", "H6"].includes(node.tagName);
if (block) content.push({ text: " " });
node.childNodes.forEach(walk);
if (block) content.push({ text: " " });
};
walk(element);
return {
body: (element as HTMLElement).innerText.trim(),
content, origin: location.origin,
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: {
@@ -58,13 +88,7 @@ export async function readBoundTaskFinalReply(input: {
}) {
await input.page.reload();
const replies = input.page.getByTestId("task-chat-thread").getByTestId("task-chat-agent-bubble");
const observe = () => replies.evaluateAll(elements => elements.map(element => ({
body: (element as HTMLElement).innerText.trim(),
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",
})));
const observe = () => replies.evaluateAll(observeTaskReplyElements);
const read = async () => {
const state = await input.load(), all = await observe();
const match = state.run.id === input.runId ? uniqueBoundTaskReply(all, state.run, input.agentId, state.comments, input.expectedText, state.events) : null;
@@ -75,9 +99,12 @@ export async function readBoundTaskFinalReply(input: {
await expect.poll(async () => {
return Boolean((await read()).match);
}, { timeout, intervals: [1000, 2000, 5000] }).toBe(true);
const { match, run, events } = await read();
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, finalEvent: source === "native-turn" ? nativeFinalReplyEvents(events, run)[0] : null, ...match };
source, sourceBody, finalEvent, ...match };
}