diff --git a/packages/app/src/attachments/utils.test.ts b/packages/app/src/attachments/utils.test.ts index 6c50ce17c..319f247d2 100644 --- a/packages/app/src/attachments/utils.test.ts +++ b/packages/app/src/attachments/utils.test.ts @@ -34,6 +34,10 @@ describe("fileUriToPath", () => { it("converts Windows drive-letter file URIs back to paths", () => { expect(fileUriToPath("file:///C:/Users/file.txt")).toBe("C:/Users/file.txt"); }); + + it("converts host-based file URIs back to UNC paths", () => { + expect(fileUriToPath("file://server/share/shot%231.png")).toBe("\\\\server\\share\\shot#1.png"); + }); }); describe("localFileSourceToPath", () => { diff --git a/packages/app/src/attachments/utils.ts b/packages/app/src/attachments/utils.ts index e1422a7f4..bb1903f9f 100644 --- a/packages/app/src/attachments/utils.ts +++ b/packages/app/src/attachments/utils.ts @@ -162,7 +162,11 @@ export function fileUriToPath(uri: string): string { if (!uri.startsWith("file://")) { return uri; } - const decodedPath = decodeFilePathSource(uri.replace(/^file:\/\//, "")); + const fileSource = uri.slice("file://".length); + const decodedPath = decodeFilePathSource(fileSource); + if (!fileSource.startsWith("/")) { + return `\\\\${decodedPath.replace(/\//g, "\\")}`; + } return normalizeWindowsDrivePath(decodedPath.replace(/^\/([A-Za-z]:[\\/])/, "$1")); } diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts index 4db6c748c..228f67124 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts @@ -3668,7 +3668,7 @@ describe("Codex app-server provider", () => { }); }); - test("emits imageView thread items as assistant markdown images using the path", () => { + test("emits imageView paths with spaces as valid assistant markdown images", () => { const session = createSession(); const events: AgentStreamEvent[] = []; session.subscribe((event) => events.push(event)); @@ -3688,15 +3688,15 @@ describe("Codex app-server provider", () => { turnId: "test-turn", item: { type: "assistant_message", - text: "![Image](/tmp/paseo image.png)", + text: "![Image](file:///tmp/paseo%20image.png)", }, }, ]); }); test.each([ - ["savedPath", { savedPath: "/tmp/generated-camel.png" }, "/tmp/generated-camel.png"], - ["saved_path", { saved_path: "/tmp/generated-snake.png" }, "/tmp/generated-snake.png"], + ["savedPath", { savedPath: "/tmp/generated-camel.png" }, "file:///tmp/generated-camel.png"], + ["saved_path", { saved_path: "/tmp/generated-snake.png" }, "file:///tmp/generated-snake.png"], ])( "emits imageGeneration thread items with %s as assistant markdown images", (_fieldName, imageFields, expectedPath) => { diff --git a/packages/server/src/server/agent/providers/provider-image-output.test.ts b/packages/server/src/server/agent/providers/provider-image-output.test.ts index 49ffcf94b..0c32af04f 100644 --- a/packages/server/src/server/agent/providers/provider-image-output.test.ts +++ b/packages/server/src/server/agent/providers/provider-image-output.test.ts @@ -49,6 +49,37 @@ describe("isProviderImageMarkdown", () => { expect(isProviderImageMarkdown(markdown)).toBe(true); }); + test("emits POSIX file paths with spaces as valid file URI markdown", () => { + const markdown = renderImageMarkdown("/home/user/Projects/Project With Spaces/screenshot.png"); + + expect(markdown).toBe( + "![Image](file:///home/user/Projects/Project%20With%20Spaces/screenshot.png)", + ); + }); + + test("encodes URI-significant characters in POSIX file paths", () => { + const markdown = renderImageMarkdown("/tmp/screenshot#1?draft.png"); + + expect(markdown).toBe("![Image](file:///tmp/screenshot%231%3Fdraft.png)"); + }); + + test("preserves double-leading slashes in POSIX file paths", () => { + const markdown = renderImageMarkdown("//tmp/screenshot#1.png"); + + expect(markdown).toBe("![Image](file:////tmp/screenshot%231.png)"); + }); + + test.each([ + ["UNC", "\\\\server\\share\\shot#1.png", "file://server/share/shot%231.png"], + [ + "extended UNC", + "\\\\?\\UNC\\server\\share\\shot?draft.png", + "file://server/share/shot%3Fdraft.png", + ], + ])("encodes %s image paths as file URIs", (_label, imagePath, expectedSource) => { + expect(renderImageMarkdown(imagePath)).toBe(`![Image](${expectedSource})`); + }); + 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); diff --git a/packages/server/src/server/agent/providers/provider-image-output.ts b/packages/server/src/server/agent/providers/provider-image-output.ts index 649e5bdfe..387158b32 100644 --- a/packages/server/src/server/agent/providers/provider-image-output.ts +++ b/packages/server/src/server/agent/providers/provider-image-output.ts @@ -128,9 +128,39 @@ function escapeMarkdownImageAlt(value: string): string { return value.replace(/\\/g, "\\\\").replace(/\]/g, "\\]"); } +function encodeFilePath(value: string): string { + return value + .split("/") + .map((segment) => encodeURIComponent(segment)) + .join("/"); +} + +function windowsFileUri(value: string): string | null { + const isWindowsNetworkPath = value.startsWith("\\\\"); + let normalizedPath = value.replace(/\\/g, "/"); + if (/^\/\/\?\/UNC\//i.test(normalizedPath)) { + normalizedPath = `//${normalizedPath.slice(8)}`; + } else if (/^\/\/\?\/[A-Za-z]:\//.test(normalizedPath)) { + normalizedPath = normalizedPath.slice(4); + } + + if (/^[A-Za-z]:\//.test(normalizedPath)) { + const drive = normalizedPath.slice(0, 2); + return `file:///${drive}${encodeFilePath(normalizedPath.slice(2))}`; + } + if (isWindowsNetworkPath && normalizedPath.startsWith("//")) { + return `file:${encodeFilePath(normalizedPath)}`; + } + return null; +} + function markdownImageSource(value: string): string { - if (/^[A-Za-z]:[\\/]/.test(value)) { - return `file:///${value.replace(/\\/g, "/")}`; + const windowsUri = windowsFileUri(value); + if (windowsUri) { + return windowsUri; + } + if (value.startsWith("/")) { + return `file://${encodeFilePath(value)}`; } return value; }