Preserve generated GFM checkbox states in reply evidence

Recognize read-only task-list controls from the existing UI renderer and match their visible checked state. Keep raw HTML rejected and add positive and changed-state source/browser regressions.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-30 00:39:40 -05:00
1 parent 023f78c8b9
commit b3d2d08305
6 files changed
+28 -8

No files matched your search

+1 -1
View File
@@ -115,7 +115,7 @@ satisfy the check. A pending or suppressed comment can use the same proven nativ
reply path. A finish summary by itself, an earlier turn, a progress
bubble, or a cancelled run cannot satisfy it. Private receipts record the run,
thread anchor and observed text; classic protocol comment matchers stay strict.
Markdown replies compare complete text and exact link destinations after parsing
Markdown replies compare complete text, GFM checkbox states and exact link destinations after parsing
through the UI-owned projection of its existing Markdown renderer and GFM/issue-reference plugins. The observer receives the
same visible-company prefix list used by the UI, so bare issue keys and URLs
match their rendered links. Whitespace and inline formatting can change during rendering. A
@@ -26,6 +26,8 @@ for (const [source, html, passes] of [
["Depends on TREE-604.", "Depends on TREE-604.", true],
["Depends on QA-123.", "Depends on <a data-mention-kind='issue' href='/QA/issues/QA-999'>QA-123</a>.", false],
["See https://example.com/", "See <a href='https://other.test/'>https://example.com/</a>", false],
["- [x] Finished\n- [ ] Next", "<ul><li><input type='checkbox' checked disabled> Finished</li><li><input type='checkbox' disabled> Next</li></ul>", true],
["- [x] Finished\n- [ ] Next", "<ul><li><input type='checkbox' disabled> Finished</li><li><input type='checkbox' disabled> Next</li></ul>", false],
] as const) test(`reloaded auto-linked content: ${source} (${passes})`, async ({ page }) => {
const url = "https://fixture.test/QA/issues/QA-1";
await page.route(url, route => route.fulfill({ contentType: "text/html", body:
+5 -1
View File
@@ -54,7 +54,7 @@ export function uniqueBoundTaskReply(all: TaskReplyObservation[], run: Row, agen
export function observeTaskReplyElements(elements: Array<HTMLElement | SVGElement>): TaskReplyObservation[] {
return elements.map(element => {
const content: Array<{ text: string } | { href: string; label: string; decorated: boolean }> = [];
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()!;
@@ -62,6 +62,10 @@ export function observeTaskReplyElements(elements: Array<HTMLElement | SVGElemen
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]') });
@@ -14,6 +14,12 @@ it("rejects unsupported Markdown rather than stripping unknown or invisible cont
expect(taskReplyContentMatches({ body: "Visible" }, source)).toBe(false);
});
it("matches generated GFM checkboxes and rejects changed check states", () => {
const observation = { body: "Finished Next", content: [{ checked: true }, { text: " Finished " }, { checked: false }, { text: " Next" }] };
expect(taskReplyContentMatches(observation, "- [x] Finished\n- [ ] Next")).toBe(true);
expect(taskReplyContentMatches(observation, "- [ ] Finished\n- [ ] Next")).toBe(false);
});
it("uses production GFM autolinks and issue linking with the viewer's known companies", () => {
const context = { pageUrl: "https://fixture.test/QA/issues/QA-1", knownCompanyPrefixes: ["QA"] };
expect(taskReplyContentMatches({ ...context, body: "Depends on QA-123.", content: [{ text: "Depends on " },
+8 -4
View File
@@ -2,12 +2,15 @@ import { markdownReplyContent } from "../../ui/src/lib/markdown-reply-content.js
import { applyCompanyPrefix, extractCompanyPrefixFromPath } from "../../ui/src/lib/company-routes.js";
import { parseIssueReferenceFromHref } from "../../ui/src/lib/issue-reference.js";
export type ReplyContentPart = { text: string } | { href: string; label?: string; decorated?: boolean };
export type ReplyContentPart = { text: string } | { href: string; label?: string; decorated?: boolean } | { checked: boolean };
function normalized(parts: ReplyContentPart[], pageUrl: string, source = false) {
const result: ReplyContentPart[] = [];
for (const part of parts) {
if ("href" in part) {
if ("checked" in part) {
if (typeof part.checked !== "boolean") return null;
result.push({ checked: part.checked });
} else if ("href" in part) {
try {
const issueRef = source ? parseIssueReferenceFromHref(part.href) : null;
const href = issueRef ? applyCompanyPrefix(issueRef.href, extractCompanyPrefixFromPath(new URL(pageUrl).pathname)) : part.href;
@@ -37,7 +40,7 @@ export function taskReplyContentMatches(
// Bind the structured projection to the independently observed innerText.
// A destination alone cannot attest to arbitrary visible wording.
if (observation.content.some(part => "href" in part && (typeof part.label !== "string" || !part.label.trim()))) return false;
const displayed = observation.content.map(part => "text" in part ? part.text : part.label!).join("").replace(/\s+/g, " ").trim();
const displayed = observation.content.map(part => "text" in part ? part.text : "href" in part ? part.label! : "").join("").replace(/\s+/g, " ").trim();
if (displayed !== observation.body.replace(/\s+/g, " ").trim()) return false;
}
const parts = markdownReplyContent(markdown, observation.knownCompanyPrefixes);
@@ -48,6 +51,7 @@ export function taskReplyContentMatches(
return expected !== null && actual !== null && expected.length === actual.length && expected.every((part, index) => {
const displayed = actual[index]!;
return "text" in part ? "text" in displayed && part.text === displayed.text
: "href" in displayed && part.href === displayed.href && (part.label === displayed.label || displayed.decorated === true);
: "href" in part ? "href" in displayed && part.href === displayed.href && (part.label === displayed.label || displayed.decorated === true)
: "checked" in displayed && part.checked === displayed.checked;
});
}
+6 -2
View File
@@ -3,7 +3,7 @@ import Markdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { remarkLinkIssueReferences } from "./issue-reference.js";
export type MarkdownReplyPart = { text: string } | { href: string; label: string };
export type MarkdownReplyPart = { text: string } | { href: string; label: string } | { checked: boolean };
type AstNode = { type: string; children?: AstNode[] };
/** The reply observer consumes the UI's existing parser without adding a
@@ -31,8 +31,12 @@ export function markdownReplyContent(markdown: string, knownPrefixes?: string[])
return true;
}
if (Array.isArray(node)) return node.every(child => visit(child, label));
if (!isValidElement<{ children?: ReactNode; href?: string }>(node)) return false;
if (!isValidElement<{ children?: ReactNode; href?: string; type?: string; checked?: boolean; disabled?: boolean }>(node)) return false;
if (node.type === Fragment) return visit(node.props.children, label);
if (!label && node.type === "input" && node.props.type === "checkbox" && node.props.disabled === true) {
parts.push({ checked: node.props.checked === true });
return true;
}
if (node.type === "a" && !label && node.props.href) {
const words: string[] = [];
if (!visit(node.props.children, words)) return false;