mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-11 14:10:50 +02:00
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 <noreply@paperclip.ing>
This commit is contained in:
1 parent
933e6fcc18
commit
a04f8538cc
4 files changed
+69
-4
No files matched your search
@@ -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\n\n\n\n',
|
||||
[], { mediaMode: "reference", resolveImageSrc },
|
||||
);
|
||||
|
||||
expect(html).toContain("<h1>Plan</h1>");
|
||||
expect(html).toContain("<strong>Keep this step.</strong>");
|
||||
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(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
|
||||
@@ -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 <MermaidDiagramBlock source={mermaidSource} darkMode={theme === "dark"} />;
|
||||
}
|
||||
return <CodeBlock preProps={preProps}>{preChildren}</CodeBlock>;
|
||||
@@ -914,7 +917,13 @@ function MarkdownBodyImpl({
|
||||
);
|
||||
},
|
||||
};
|
||||
if (resolveImageSrc || onImageClick) {
|
||||
if (mediaMode === "reference") {
|
||||
map.img = ({ src, alt, title }) => (
|
||||
<span data-markdown-image-reference title={title}>
|
||||
Image: {alt || "Untitled image"}{src ? ` (${src})` : ""}
|
||||
</span>
|
||||
);
|
||||
} 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 (
|
||||
<div
|
||||
|
||||
@@ -522,7 +522,7 @@ export function QuestionForm({
|
||||
tabIndex={0}
|
||||
className="mb-3 max-h-96 overflow-auto text-sm text-muted-foreground"
|
||||
>
|
||||
<MarkdownBody>{questionSet.description}</MarkdownBody>
|
||||
<MarkdownBody mediaMode="reference">{questionSet.description}</MarkdownBody>
|
||||
</div>
|
||||
) : null}
|
||||
{question.answerMode === "text" ? (
|
||||
|
||||
@@ -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\n\n\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(
|
||||
|
||||
Reference in new issue
Block a user