diff --git a/tests/runner-e2e/PRODUCT-JOURNEYS.md b/tests/runner-e2e/PRODUCT-JOURNEYS.md
index daee209ef45..e95cd38c208 100644
--- a/tests/runner-e2e/PRODUCT-JOURNEYS.md
+++ b/tests/runner-e2e/PRODUCT-JOURNEYS.md
@@ -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
diff --git a/tests/runner-e2e/native-task-reply.spec.ts b/tests/runner-e2e/native-task-reply.spec.ts
index 558df0385d5..14372a944a5 100644
--- a/tests/runner-e2e/native-task-reply.spec.ts
+++ b/tests/runner-e2e/native-task-reply.spec.ts
@@ -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 QA-123.", false],
["See https://example.com/", "See https://example.com/", false],
+ ["- [x] Finished\n- [ ] Next", "
", true],
+ ["- [x] Finished\n- [ ] Next", "", 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:
diff --git a/tests/runner-e2e/native-task-reply.ts b/tests/runner-e2e/native-task-reply.ts
index c9c77ead298..59d11f1a178 100644
--- a/tests/runner-e2e/native-task-reply.ts
+++ b/tests/runner-e2e/native-task-reply.ts
@@ -54,7 +54,7 @@ export function uniqueBoundTaskReply(all: TaskReplyObservation[], run: Row, agen
export function observeTaskReplyElements(elements: Array): 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 = [element];
while (nodes.length) {
const node = nodes.pop()!;
@@ -62,6 +62,10 @@ export function observeTaskReplyElements(elements: Array {
+ 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 " },
diff --git a/tests/runner-e2e/task-reply-content.ts b/tests/runner-e2e/task-reply-content.ts
index 6b35116c0f3..d3d6ce9f5e0 100644
--- a/tests/runner-e2e/task-reply-content.ts
+++ b/tests/runner-e2e/task-reply-content.ts
@@ -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;
});
}
diff --git a/ui/src/lib/markdown-reply-content.ts b/ui/src/lib/markdown-reply-content.ts
index 1855b58272a..67c70f1b338 100644
--- a/ui/src/lib/markdown-reply-content.ts
+++ b/ui/src/lib/markdown-reply-content.ts
@@ -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;