From a04f8538cc15da0125c7be4d018bcec4eb03b159 Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 28 Sep 2026 12:50:19 -0500 Subject: [PATCH] Keep provider decision media inert without truncating plans Question and plan descriptions render images as text references and Mermaid diagrams as source. Other Markdown surfaces retain their existing behavior. Cover remote and relative images, resolver bypass, and complete 100,000-character context through the production question card. Co-Authored-By: Paperclip --- ui/src/components/MarkdownBody.test.tsx | 27 +++++++++++++++++ ui/src/components/MarkdownBody.tsx | 15 ++++++++-- ui/src/components/task-chat/QuestionForm.tsx | 2 +- .../task-chat/TaskChatProtocolCard.test.tsx | 29 +++++++++++++++++++ 4 files changed, 69 insertions(+), 4 deletions(-) diff --git a/ui/src/components/MarkdownBody.test.tsx b/ui/src/components/MarkdownBody.test.tsx index 3615c501a4..0e8572fbdc 100644 --- a/ui/src/components/MarkdownBody.test.tsx +++ b/ui/src/components/MarkdownBody.test.tsx @@ -120,6 +120,33 @@ describe("MarkdownBody", () => { expect(html).toContain('alt="Org chart"'); }); + it("renders decision images as inert references without resolving or preloading them", () => { + const resolveImageSrc = vi.fn((src: string) => `https://resolver.invalid/${src}`); + const html = renderMarkdown( + '# Plan\n\n**Keep this step.**\n\n![Evidence](https://provider.invalid/track.png "Provider image")\n\n![Relative](images/proof.png)\n\n![Protocol relative](//provider.invalid/other.png)', + [], { mediaMode: "reference", resolveImageSrc }, + ); + + expect(html).toContain("

Plan

"); + expect(html).toContain("Keep this step."); + expect(html).toContain("Image: Evidence (https://provider.invalid/track.png)"); + expect(html).toContain('title="Provider image"'); + expect(html).toContain("Image: Relative (images/proof.png)"); + expect(html).toContain("Image: Protocol relative (//provider.invalid/other.png)"); + expect(html).not.toMatch(/<(?:img|link|iframe|video|audio)\b/); + expect(resolveImageSrc).not.toHaveBeenCalled(); + }); + + it("keeps diagram source inert in decision media mode", () => { + const source = 'flowchart LR\n A@{ img: "https://provider.invalid/diagram.png" }'; + const html = renderMarkdown(`\`\`\`mermaid\n${source}\n\`\`\``, [], { mediaMode: "reference" }); + + expect(html).toContain('class="language-mermaid"'); + expect(html).toContain("https://provider.invalid/diagram.png"); + expect(html).not.toContain('class="paperclip-mermaid"'); + expect(html).not.toContain("Rendering Mermaid diagram"); + }); + it("renders user, agent, project, skill, and routine mentions as chips", () => { const html = renderToStaticMarkup( diff --git a/ui/src/components/MarkdownBody.tsx b/ui/src/components/MarkdownBody.tsx index 98c06bf01f..24b9772854 100644 --- a/ui/src/components/MarkdownBody.tsx +++ b/ui/src/components/MarkdownBody.tsx @@ -90,6 +90,8 @@ interface MarkdownBodyProps { resolveImageSrc?: (src: string) => string | null; /** Called when a user clicks an inline image */ onImageClick?: (src: string) => void; + /** Keep untrusted decision media inert: image references and diagram source only. */ + mediaMode?: "render" | "reference"; /** * Resolver that decides which inline-code workspace file paths may be linked * to the issue file viewer. Omitting it (or returning null) leaves every @@ -719,6 +721,7 @@ function MarkdownBodyImpl({ externalReferences, resolveImageSrc, onImageClick, + mediaMode = "render", resolveWorkspaceFileRef, }: MarkdownBodyProps) { const { theme } = useTheme(); @@ -803,7 +806,7 @@ function MarkdownBodyImpl({ ), pre: ({ node: _node, children: preChildren, ...preProps }) => { const mermaidSource = extractMermaidSource(preChildren); - if (mermaidSource) { + if (mermaidSource && mediaMode === "render") { return ; } return {preChildren}; @@ -914,7 +917,13 @@ function MarkdownBodyImpl({ ); }, }; - if (resolveImageSrc || onImageClick) { + if (mediaMode === "reference") { + map.img = ({ src, alt, title }) => ( + + Image: {alt || "Untitled image"}{src ? ` (${src})` : ""} + + ); + } else if (resolveImageSrc || onImageClick) { map.img = ({ node: _node, src, alt, ...imgProps }) => { const resolved = resolveImageSrc && src ? resolveImageSrc(src) : null; const finalSrc = resolved ?? src; @@ -930,7 +939,7 @@ function MarkdownBodyImpl({ }; } return map; - }, [theme, linkIssueReferences, linkCaseReferences, externalReferenceLookup, resolveImageSrc, onImageClick]); + }, [theme, linkIssueReferences, linkCaseReferences, externalReferenceLookup, resolveImageSrc, onImageClick, mediaMode]); return (
- {questionSet.description} + {questionSet.description}
) : null} {question.answerMode === "text" ? ( diff --git a/ui/src/components/task-chat/TaskChatProtocolCard.test.tsx b/ui/src/components/task-chat/TaskChatProtocolCard.test.tsx index 9af1b59b91..00961fdbaa 100644 --- a/ui/src/components/task-chat/TaskChatProtocolCard.test.tsx +++ b/ui/src/components/task-chat/TaskChatProtocolCard.test.tsx @@ -632,6 +632,35 @@ describe("TaskChatProtocolCard", () => { expect(container.textContent).toContain("Submitting…"); }); + it("keeps complete provider plan Markdown while making image references inert", () => { + const prefix = '# Release plan\n\n**Preserve every instruction.**\n\n![Evidence](https://provider.invalid/track.png "Provider image")\n\n![Local image](/api/attachments/untrusted/content)\n\n'; + const suffix = '\n\n```mermaid\nflowchart LR\n A@{ img: "https://provider.invalid/diagram.png" }\n```\n\nFINAL_PLAN_BOUNDARY'; + const fullInstructions = "x".repeat(100_000 - prefix.length - suffix.length); + const description = prefix + fullInstructions + suffix; + expect(description).toHaveLength(100_000); + renderCard(root, { + id: "provider-plan", kind: "protocol", surface: "runtime_request", runId: "run-1", + requestId: "plan-revision-7", requestKind: "runtime", turnId: "turn-1", + requestType: "input", status: "pending", prompt: "Review the complete plan.", choices: [], fields: [], + questionSet: { + schema: "paperclip.question_set.v1", description, + questions: [{ id: "decision", prompt: "Accept this plan?", required: true, answerMode: "single_select", + options: [{ id: "accept", label: "Accept" }, { id: "reject", label: "Reject" }] }], + }, + }); + + const context = container.querySelector('[role="region"][aria-label="Question context"]'); + expect(context?.querySelector("h1")?.textContent).toBe("Release plan"); + expect(context?.querySelector("strong")?.textContent).toBe("Preserve every instruction."); + expect(context?.textContent).toContain(fullInstructions); + expect(context?.textContent).toContain("FINAL_PLAN_BOUNDARY"); + expect(context?.textContent).toContain("Evidence"); + expect(context?.textContent).toContain("https://provider.invalid/track.png"); + expect(context?.querySelector("img, video, audio, iframe, object, embed, image, link")).toBeNull(); + expect(context?.querySelector('.language-mermaid')?.textContent).toContain('https://provider.invalid/diagram.png'); + expect(context?.querySelector('.paperclip-mermaid')).toBeNull(); + }); + it("submits the canonical response from a v2 harness question set", async () => { const onDecision = vi.fn().mockResolvedValue(undefined); renderCard(