Fix Windows image previews (#1811)

* fix(images): preserve Windows preview paths

Provider image markdown used escaped backslash paths on Windows, which markdown parsing could turn into encoded drive paths that the app treated as workspace-relative. Emit drive-letter paths as file URIs and normalize existing encoded local paths before file preview.

* test(server): accept file URI image sources

Windows provider image output now renders drive-letter paths as file URIs. Decode those markdown sources before filesystem assertions so the image rendering tests keep checking the materialized file.

* fix(images): keep plain local paths literal

Limit legacy URI decoding to markdown-encoded Windows drive paths so ordinary local paths with percent sequences remain unchanged. Document why provider image markdown matching still accepts old doubled-backslash history.
This commit is contained in:
Mohamed Boudra
2026-06-29 20:49:13 +00:00
committed by GitHub
parent df36a958f8
commit 811f52395e
8 changed files with 113 additions and 18 deletions
@@ -1,4 +1,5 @@
import { existsSync, rmSync } from "node:fs";
import { fileURLToPath } from "node:url";
import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk";
import { describe, expect, test } from "vitest";
@@ -146,7 +147,8 @@ function markdownImageSource(markdown: string): string {
throw new Error(`Expected markdown image, got: ${markdown}`);
}
// Reverse escapeMarkdownImageSource: "\\" -> "\" and "\)" -> ")" (Windows paths are escaped).
return match[1].replace(/\\(.)/g, "$1");
const source = match[1].replace(/\\(.)/g, "$1");
return source.startsWith("file://") ? fileURLToPath(source) : source;
}
describe("Claude tool_result image rendering", () => {
@@ -6,6 +6,7 @@ import { mkdtemp } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { PassThrough } from "node:stream";
import { fileURLToPath } from "node:url";
import type {
AgentLaunchContext,
@@ -111,7 +112,8 @@ function markdownImageSource(markdown: string): string {
if (!match) {
throw new Error(`Expected markdown image, got: ${markdown}`);
}
return match[1].replace(/\\\)/g, ")");
const source = match[1].replace(/\\\)/g, ")");
return source.startsWith("file://") ? fileURLToPath(source) : source;
}
function emitCodexUserMessage(
@@ -1,9 +1,20 @@
import { describe, expect, test } from "vitest";
import { isProviderImageMarkdown } from "./provider-image-output.js";
import {
isProviderImageMarkdown,
renderProviderImageOutputAsAssistantMarkdown,
} from "./provider-image-output.js";
const HASH = "a".repeat(64);
function renderImageMarkdown(path: string): string {
const item = renderProviderImageOutputAsAssistantMarkdown({ path });
if (!item || item.type !== "assistant_message") {
throw new Error("Expected provider image output to render as assistant markdown.");
}
return item.text;
}
describe("isProviderImageMarkdown", () => {
test("matches the markdown emitted for a materialized attachment", () => {
expect(isProviderImageMarkdown(`![Image](/tmp/paseo-attachments/${HASH}.png)`)).toBe(true);
@@ -18,6 +29,17 @@ describe("isProviderImageMarkdown", () => {
).toBe(true);
});
test("emits Windows file paths as file URIs", () => {
const markdown = renderImageMarkdown(
`C:\\Users\\me\\AppData\\Local\\Temp\\paseo-attachments\\${HASH}.png`,
);
expect(markdown).toBe(
`![Image](file:///C:/Users/me/AppData/Local/Temp/paseo-attachments/${HASH}.png)`,
);
expect(isProviderImageMarkdown(markdown)).toBe(true);
});
test("rejects user-authored markdown that is not a materialized attachment", () => {
// No content hash — a hand-written path, not something the writer produced.
expect(isProviderImageMarkdown("![diagram](./paseo-attachments/notes.png)")).toBe(false);
@@ -66,11 +66,10 @@ export function materializeProviderImage(image: {
return { path: filePath };
}
// Recognizes the markdown renderProviderImageOutputAsAssistantMarkdown emits for a materialized
// provider image: its source is a content-hashed file in the attachments dir. Matching the full
// <hash>.<ext> shape (not just a leading "![") keeps user-authored text from being mistaken for a
// provider image when it reaches the history-replay filter. The separator class allows one-or-more
// because on Windows the path uses "\\" and escapeMarkdownImageSource doubles each backslash.
// Recognizes markdown rendered for a materialized provider image: its source is a content-hashed
// file in the attachments dir. Matching the full <hash>.<ext> shape (not just a leading "![")
// keeps user-authored text from being mistaken for a provider image during history replay. The
// separator still accepts old doubled-backslash Windows history; new Windows output uses file URIs.
const PROVIDER_IMAGE_MARKDOWN = new RegExp(
`^!\\[[^\\]]*\\]\\([^)]*${PROVIDER_IMAGE_ATTACHMENT_DIR}[/\\\\]+[0-9a-f]{64}\\.[a-z0-9]+\\)`,
);
@@ -96,8 +95,15 @@ function escapeMarkdownImageAlt(value: string): string {
return value.replace(/\\/g, "\\\\").replace(/\]/g, "\\]");
}
function markdownImageSource(value: string): string {
if (/^[A-Za-z]:[\\/]/.test(value)) {
return `file:///${value.replace(/\\/g, "/")}`;
}
return value;
}
function escapeMarkdownImageSource(value: string): string {
return value.replace(/\\/g, "\\\\").replace(/\)/g, "\\)");
return markdownImageSource(value).replace(/\\/g, "\\\\").replace(/\)/g, "\\)");
}
export function renderProviderImageOutputAsAssistantMarkdown(