mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-11 21:59:38 +01:00
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:
1 parent
b3c2172d41
commit
6677515cf3
11 files changed
+177
-18
No files matched your search
@@ -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 };
|
||||
}
|
||||
Reference in new issue
Block a user