mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-31 06:04:11 +01:00
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:
1 parent
023f78c8b9
commit
b3d2d08305
6 files changed
+28
-8
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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 " },
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user