From 910fa7741051860bf650460584d24208a11af303 Mon Sep 17 00:00:00 2001 From: Subham Kumar Das <35267544+lost-particles@users.noreply.github.com> Date: Wed, 6 May 2026 21:57:43 -0400 Subject: [PATCH 1/3] fix(agent): Request-limit-unrecoverable-thread fix(agent): Request-limit-unrecoverable-thread Stop inlining workspace file bodies from ACP resources into Claude SDK user messages. For file:// on resource/resource_link, steer the model toward Read (offset/limit, PDF pages) and preserve attachment:// and other schemes. Update buildCloudPromptBlocks harness to use resource_link for text files. Add/adjust Vitest coverage for stripping inlined file text vs attachment parity. --- .../editor/utils/cloud-prompt.test.ts | 72 +++++------ .../features/editor/utils/cloud-prompt.ts | 45 ++----- .../claude/conversion/acp-to-sdk.test.ts | 121 ++++++++++++++++-- .../adapters/claude/conversion/acp-to-sdk.ts | 74 +++++++++-- 4 files changed, 221 insertions(+), 91 deletions(-) diff --git a/apps/code/src/renderer/features/editor/utils/cloud-prompt.test.ts b/apps/code/src/renderer/features/editor/utils/cloud-prompt.test.ts index b152dbeca..f0d53df2c 100644 --- a/apps/code/src/renderer/features/editor/utils/cloud-prompt.test.ts +++ b/apps/code/src/renderer/features/editor/utils/cloud-prompt.test.ts @@ -1,3 +1,5 @@ +import { fileURLToPath } from "node:url"; +import type { ContentBlock } from "@agentclientprotocol/sdk"; import { beforeEach, describe, expect, it, vi } from "vitest"; const mockFs = vi.hoisted(() => ({ @@ -31,7 +33,6 @@ vi.mock("@renderer/trpc/client", () => ({ }, })); -import { parseAttachmentUri } from "@utils/promptContent"; import { buildCloudPromptBlocks, buildCloudTaskDescription, @@ -39,6 +40,12 @@ import { stripAbsoluteFileTags, } from "./cloud-prompt"; +function resourceLinksFrom(blocks: ContentBlock[]): string[] { + return blocks.flatMap((b) => + b.type === "resource_link" && typeof b.uri === "string" ? [b.uri] : [], + ); +} + describe("cloud-prompt", () => { beforeEach(() => { vi.clearAllMocks(); @@ -63,8 +70,6 @@ describe("cloud-prompt", () => { }); it("excludes folder paths from absolute attachment list", async () => { - mockFs.readAbsoluteFile.query.mockResolvedValue("hi"); - const prompt = 'scan and '; const blocks = await buildCloudPromptBlocks(prompt, [ @@ -72,15 +77,10 @@ describe("cloud-prompt", () => { "/tmp/test.txt", ]); - const uris = blocks.flatMap((b) => - b.type === "resource" ? [b.resource.uri] : [], - ); + const uris = resourceLinksFrom(blocks); expect(uris).toHaveLength(1); expect(uris[0]).toContain("test.txt"); - expect(mockFs.readAbsoluteFile.query).toHaveBeenCalledTimes(1); - expect(mockFs.readAbsoluteFile.query).toHaveBeenCalledWith({ - filePath: "/tmp/test.txt", - }); + expect(mockFs.readAbsoluteFile.query).not.toHaveBeenCalled(); }); it("builds a safe cloud task description for local attachments", () => { @@ -93,34 +93,28 @@ describe("cloud-prompt", () => { ); }); - it("embeds text attachments as ACP resources", async () => { - mockFs.readAbsoluteFile.query.mockResolvedValue("hello from file"); - + it("uses resource_link path references for text attachments", async () => { const blocks = await buildCloudPromptBlocks( 'read this ', ); expect(blocks).toEqual([ { type: "text", text: "read this" }, - expect.objectContaining({ - type: "resource", - resource: expect.objectContaining({ - text: "hello from file", - mimeType: "text/plain", - }), - }), + { + type: "resource_link", + uri: expect.stringMatching(/^file:\/\/.+/), + name: "test.txt", + }, ]); const attachmentBlock = blocks[1]; - expect(attachmentBlock.type).toBe("resource"); - if (attachmentBlock.type !== "resource") { - throw new Error("Expected a resource attachment block"); + expect(attachmentBlock.type).toBe("resource_link"); + if (attachmentBlock.type !== "resource_link") { + throw new Error("Expected a resource_link attachment block"); } - expect(parseAttachmentUri(attachmentBlock.resource.uri)).toEqual({ - id: attachmentBlock.resource.uri, - label: "test.txt", - }); + expect(fileURLToPath(attachmentBlock.uri)).toBe("/tmp/test.txt"); + expect(mockFs.readAbsoluteFile.query).not.toHaveBeenCalled(); }); it("embeds image attachments as ACP image blocks", async () => { @@ -156,12 +150,17 @@ describe("cloud-prompt", () => { ).rejects.toThrow(/Unsupported image/); }); - it("throws when readAbsoluteFile returns null", async () => { + it("does not rely on readAbsoluteFile for txt attachments", async () => { mockFs.readAbsoluteFile.query.mockResolvedValue(null); - await expect( - buildCloudPromptBlocks('read '), - ).rejects.toThrow(/Unable to read/); + const blocks = await buildCloudPromptBlocks( + 'read ', + ); + expect(blocks[1]).toMatchObject({ + type: "resource_link", + name: "maybe-missing-on-disk.txt", + }); + expect(mockFs.readAbsoluteFile.query).not.toHaveBeenCalled(); }); it("throws when readFileAsBase64 returns falsy for images", async () => { @@ -180,16 +179,13 @@ describe("cloud-prompt", () => { const serialized = serializeCloudPrompt([ { type: "text", text: "read this" }, { - type: "resource", - resource: { - uri: "attachment://test.txt", - text: "hello from file", - mimeType: "text/plain", - }, + type: "resource_link", + uri: "file:///tmp/test.txt", + name: "test.txt", }, ]); expect(serialized).toContain("__twig_cloud_prompt_v1__:"); - expect(serialized).toContain('"type":"resource"'); + expect(serialized).toContain('"type":"resource_link"'); }); }); diff --git a/apps/code/src/renderer/features/editor/utils/cloud-prompt.ts b/apps/code/src/renderer/features/editor/utils/cloud-prompt.ts index 98dfa457c..93119ccde 100644 --- a/apps/code/src/renderer/features/editor/utils/cloud-prompt.ts +++ b/apps/code/src/renderer/features/editor/utils/cloud-prompt.ts @@ -3,7 +3,6 @@ import { CLOUD_PROMPT_PREFIX, serializeCloudPrompt } from "@posthog/shared"; import { trpcClient } from "@renderer/trpc/client"; import { getImageMimeType, isImageFile } from "@shared/constants/image"; import { getFileExtension, getFileName, isAbsolutePath } from "@utils/path"; -import { makeAttachmentUri } from "@utils/promptContent"; import { unescapeXmlAttr } from "@utils/xml"; const ABSOLUTE_FILE_TAG_REGEX = //g; @@ -58,14 +57,7 @@ const TEXT_FILENAMES = new Set([ "README.md", ]); const CLOUD_IMAGE_EXTENSIONS = new Set(["png", "jpg", "jpeg", "gif", "webp"]); -const TEXT_MIME_TYPES: Record = { - json: "application/json", - md: "text/markdown", - svg: "image/svg+xml", - xml: "application/xml", -}; - -const MAX_EMBEDDED_TEXT_CHARS = 100_000; + const MAX_EMBEDDED_IMAGE_BYTES = 5 * 1024 * 1024; function isTextAttachment(filePath: string): boolean { @@ -74,11 +66,6 @@ function isTextAttachment(filePath: string): boolean { return TEXT_FILENAMES.has(fileName) || TEXT_EXTENSIONS.has(ext); } -function getTextMimeType(filePath: string): string { - const ext = getFileExtension(filePath); - return TEXT_MIME_TYPES[ext] ?? "text/plain"; -} - export function isSupportedCloudImageAttachment(filePath: string): boolean { return CLOUD_IMAGE_EXTENSIONS.has(getFileExtension(filePath)); } @@ -92,12 +79,12 @@ function estimateBase64Bytes(base64: string): number { return Math.floor((base64.length * 3) / 4) - padding; } -function truncateText(text: string): string { - if (text.length <= MAX_EMBEDDED_TEXT_CHARS) { - return text; - } - - return `${text.slice(0, MAX_EMBEDDED_TEXT_CHARS)}\n\n[Attachment truncated to ${MAX_EMBEDDED_TEXT_CHARS.toLocaleString()} characters for this cloud prompt.]`; +function pathToFileUri(filePath: string): string { + const encoded = filePath + .split("/") + .map((segment) => encodeURIComponent(segment)) + .join("/"); + return `file://${encoded}`; } function collectAbsoluteFileTagPaths(prompt: string): string[] { @@ -173,7 +160,7 @@ export function buildCloudTaskDescription( async function buildAttachmentBlock(filePath: string): Promise { const fileName = getFileName(filePath); - const uri = makeAttachmentUri(filePath); + const uri = pathToFileUri(filePath); if (isSupportedCloudImageAttachment(fileName)) { const base64 = await trpcClient.fs.readFileAsBase64.query({ filePath }); @@ -207,21 +194,15 @@ async function buildAttachmentBlock(filePath: string): Promise { ); } - const text = await trpcClient.fs.readAbsoluteFile.query({ filePath }); - if (text === null) { - throw new Error(`Unable to read attached file ${fileName}`); - } - + // Path-only: workspace text via `resource_link`; images above still embed base64. return { - type: "resource", - resource: { - uri, - text: truncateText(text), - mimeType: getTextMimeType(fileName), - }, + type: "resource_link", + uri, + name: fileName, }; } +/** Test/harness prompts: text → `resource_link` only (production cloud uses uploads + artifact_ids). */ export async function buildCloudPromptBlocks( prompt: string, filePaths: string[] = [], diff --git a/packages/agent/src/adapters/claude/conversion/acp-to-sdk.test.ts b/packages/agent/src/adapters/claude/conversion/acp-to-sdk.test.ts index 8d50b6162..01221fa70 100644 --- a/packages/agent/src/adapters/claude/conversion/acp-to-sdk.test.ts +++ b/packages/agent/src/adapters/claude/conversion/acp-to-sdk.test.ts @@ -1,8 +1,33 @@ +import type { PromptRequest } from "@agentclientprotocol/sdk"; import { describe, expect, it } from "vitest"; -import { promptToClaude } from "./acp-to-sdk"; +import { + promptToClaude, + readToolGuidanceForPath, + workspacePromptFromFileUri, +} from "./acp-to-sdk"; + +describe("readToolGuidanceForPath", () => { + it("guides PDF reads with optional pages ranges", () => { + expect(readToolGuidanceForPath("/docs/x.pdf")).toContain("pages"); + }); + + it("guides arbitrary text extensions with offset and limit", () => { + const g = readToolGuidanceForPath("/proj/app.ts"); + expect(g).toContain("offset"); + expect(g).toContain("limit"); + }); +}); + +describe("workspacePromptFromFileUri", () => { + it("includes file_path and Read-oriented chunking hints", () => { + const s = workspacePromptFromFileUri("file:///tmp/x.pdf"); + expect(s).toContain("Read"); + expect(s).toContain("file_path: /tmp/x.pdf"); + }); +}); describe("promptToClaude", () => { - it("renders file resource links as explicit workspace attachments", () => { + it("maps file resource_link to workspace path + Read guidance", () => { const result = promptToClaude({ sessionId: "session-1", prompt: [ @@ -14,17 +39,87 @@ describe("promptToClaude", () => { ], }); - expect(result.message.content).toEqual([ - { - type: "text", - text: [ - "Attached file available in the workspace:", - "- name: report.pdf", - "- path: /tmp/workspace/.posthog/attachments/run-1/report.pdf", - "Use the available tools to inspect this file if needed.", - ].join("\n"), - }, - ]); + expect(result.message.content.length).toBe(1); + expect(result.message.content[0]).toMatchObject({ + type: "text", + text: expect.any(String), + }); + expect( + (result.message.content[0] as { type: string; text: string }).text, + ).toContain("file_path:"); + expect( + (result.message.content[0] as { type: string; text: string }).text, + ).toContain("/tmp/workspace/.posthog/attachments/run-1/report.pdf"); + const text = (result.message.content[0] as { text: string }).text; + expect(text.toLowerCase()).toContain("read"); + expect(text).toContain("pages"); + }); + + it("drops embedded body for file:// resource but keeps attachment:// payload", () => { + const hugeInline = `${"y".repeat(30_000)}KEEP_ATTACH${"y".repeat(30_000)}`; + const fileRes = promptToClaude({ + sessionId: "x", + prompt: [ + { + type: "resource", + resource: { + uri: "file:///tmp/note.txt", + text: `${"x".repeat(50_000)}DROP_THIS${"x".repeat(50_000)}`, + mimeType: "text/plain", + }, + }, + ], + }); + expect(fileRes.message.content.length).toBe(1); + expect(JSON.stringify(fileRes)).not.toContain("DROP_THIS"); + expect(fileRes.message.content[0]).toMatchObject({ + type: "text", + text: expect.stringContaining("file_path: /tmp/note.txt"), + }); + + const attachRes = promptToClaude({ + sessionId: "y", + prompt: [ + { + type: "resource", + resource: { + uri: "attachment://z?label=f.txt", + text: hugeInline, + mimeType: "text/plain", + }, + }, + ], + }); + expect(attachRes.message.content.length).toBe(2); + expect(JSON.stringify(attachRes)).toContain("KEEP_ATTACH"); + }); + + it("maps file URI-only image blocks to workspace Read prompt text", () => { + const req: PromptRequest = { + sessionId: "session-1", + prompt: [ + { + type: "image", + uri: "file:///tmp/ui/screenshot.png", + mimeType: "image/png", + } as PromptRequest["prompt"][number], + ], + }; + const result = promptToClaude(req); + + expect(result.message.content).toHaveLength(1); + expect(result.message.content[0]).toMatchObject({ + type: "text", + text: expect.any(String), + }); + expect( + (result.message.content[0] as { type: string; text: string }).text, + ).toContain("/tmp/ui/screenshot.png"); + expect( + ( + result.message.content[0] as { type: string; text: string } + ).text.toLowerCase(), + ).toContain("read"); }); it("preserves non-file resource links as links", () => { diff --git a/packages/agent/src/adapters/claude/conversion/acp-to-sdk.ts b/packages/agent/src/adapters/claude/conversion/acp-to-sdk.ts index 1166c922c..81c40b78f 100644 --- a/packages/agent/src/adapters/claude/conversion/acp-to-sdk.ts +++ b/packages/agent/src/adapters/claude/conversion/acp-to-sdk.ts @@ -6,6 +6,31 @@ import type { ContentBlockParam } from "@anthropic-ai/sdk/resources"; type ImageMimeType = "image/jpeg" | "image/png" | "image/gif" | "image/webp"; +const PDF_EXTENSIONS = new Set(["pdf"]); + +const COMMON_IMAGE_EXTENSIONS = new Set([ + "png", + "jpg", + "jpeg", + "gif", + "webp", + "bmp", + "svg", + "heic", + "tif", + "tiff", +]); + +const VIDEO_EXTENSIONS = new Set([ + "mp4", + "mov", + "webm", + "mkv", + "avi", + "mpeg", + "mpg", +]); + function sdkText(value: string): ContentBlockParam { return { type: "text", text: value }; } @@ -22,21 +47,46 @@ function formatUriAsLink(uri: string): string { } } -function formatFileAttachment(uri: string): string { +/** Chunking hints for Claude Code `Read` (`file_path`, optional `pages` / `offset` / `limit`). */ +export function readToolGuidanceForPath(filePath: string): string { + const ext = path.extname(filePath).slice(1).toLowerCase(); + if (PDF_EXTENSIONS.has(ext)) { + return 'Optional `pages` string (e.g. "1-5") per Read call instead of loading the entire PDF.'; + } + if (COMMON_IMAGE_EXTENSIONS.has(ext) || VIDEO_EXTENSIONS.has(ext)) { + return "Binary file — use Read with `file_path`; prefer bounded reads where supported."; + } + return "Large text — use multiple Read calls with optional `offset` and `limit`."; +} + +/** Path-only workspace attach text (never embed `resource.text` from disk). */ +export function workspacePromptFromFileUri(uri: string): string { try { const filePath = fileURLToPath(uri); const name = path.basename(filePath) || filePath; return [ - "Attached file available in the workspace:", - `- name: ${name}`, - `- path: ${filePath}`, - "Use the available tools to inspect this file if needed.", + "Attached workspace file — use Read with required `file_path`:", + `- file_path: ${filePath}`, + `- name (context): ${name}`, + readToolGuidanceForPath(filePath), ].join("\n"); } catch { - return `Attached file available at ${uri}`; + return [ + "Attached file — decode path from URI, call Read with that path as `file_path`:", + uri, + 'Chunk PDFs with `pages` (e.g. "1-5"); long text with `offset`/`limit`.', + ].join("\n"); } } +function formatFileAttachment(uri: string): string { + return workspacePromptFromFileUri(uri); +} + +function isFileSchemeUri(uri: string | undefined | null): boolean { + return Boolean(uri?.startsWith("file://")); +} + function transformMcpCommand(text: string): string { const mcpMatch = text.match(/^\/mcp:([^:\s]+):(\S+)(\s+.*)?$/); if (mcpMatch) { @@ -68,10 +118,16 @@ function processPromptChunk( case "resource": if ("text" in chunk.resource) { - content.push(sdkText(formatUriAsLink(chunk.resource.uri))); + const uri = chunk.resource.uri; + if (uri != null && isFileSchemeUri(uri)) { + content.push(sdkText(workspacePromptFromFileUri(uri))); + break; + } + + content.push(sdkText(formatUriAsLink(uri ?? ""))); context.push( sdkText( - `\n\n${chunk.resource.text}\n`, + `\n\n${chunk.resource.text}\n`, ), ); } @@ -92,6 +148,8 @@ function processPromptChunk( type: "image", source: { type: "url", url: chunk.uri }, }); + } else if (chunk.uri != null && isFileSchemeUri(chunk.uri)) { + content.push(sdkText(workspacePromptFromFileUri(chunk.uri))); } break; From ae643d819799b1585d9d9aa09181402a836da60f Mon Sep 17 00:00:00 2001 From: Subham Kumar Das <35267544+lost-particles@users.noreply.github.com> Date: Wed, 6 May 2026 22:38:38 -0400 Subject: [PATCH 2/3] minor-fixes --- .../features/editor/utils/cloud-prompt.ts | 7 ++----- .../claude/conversion/acp-to-sdk.test.ts | 17 +++++++++-------- .../adapters/claude/conversion/acp-to-sdk.ts | 6 +----- 3 files changed, 12 insertions(+), 18 deletions(-) diff --git a/apps/code/src/renderer/features/editor/utils/cloud-prompt.ts b/apps/code/src/renderer/features/editor/utils/cloud-prompt.ts index 93119ccde..53c0e3b06 100644 --- a/apps/code/src/renderer/features/editor/utils/cloud-prompt.ts +++ b/apps/code/src/renderer/features/editor/utils/cloud-prompt.ts @@ -1,3 +1,4 @@ +import { pathToFileURL } from "node:url"; import type { ContentBlock } from "@agentclientprotocol/sdk"; import { CLOUD_PROMPT_PREFIX, serializeCloudPrompt } from "@posthog/shared"; import { trpcClient } from "@renderer/trpc/client"; @@ -80,11 +81,7 @@ function estimateBase64Bytes(base64: string): number { } function pathToFileUri(filePath: string): string { - const encoded = filePath - .split("/") - .map((segment) => encodeURIComponent(segment)) - .join("/"); - return `file://${encoded}`; + return pathToFileURL(filePath).href; } function collectAbsoluteFileTagPaths(prompt: string): string[] { diff --git a/packages/agent/src/adapters/claude/conversion/acp-to-sdk.test.ts b/packages/agent/src/adapters/claude/conversion/acp-to-sdk.test.ts index 01221fa70..24d65db92 100644 --- a/packages/agent/src/adapters/claude/conversion/acp-to-sdk.test.ts +++ b/packages/agent/src/adapters/claude/conversion/acp-to-sdk.test.ts @@ -7,14 +7,15 @@ import { } from "./acp-to-sdk"; describe("readToolGuidanceForPath", () => { - it("guides PDF reads with optional pages ranges", () => { - expect(readToolGuidanceForPath("/docs/x.pdf")).toContain("pages"); - }); - - it("guides arbitrary text extensions with offset and limit", () => { - const g = readToolGuidanceForPath("/proj/app.ts"); - expect(g).toContain("offset"); - expect(g).toContain("limit"); + it.each([ + ["/docs/x.pdf", ["pages"]], + ["/proj/app.ts", ["offset", "limit"]], + ["/assets/logo.png", ["Binary", "file_path"]], + ])("guides reads for %s", (filePath, keywords) => { + const guidance = readToolGuidanceForPath(filePath); + for (const keyword of keywords) { + expect(guidance).toContain(keyword); + } }); }); diff --git a/packages/agent/src/adapters/claude/conversion/acp-to-sdk.ts b/packages/agent/src/adapters/claude/conversion/acp-to-sdk.ts index 81c40b78f..323e4bc43 100644 --- a/packages/agent/src/adapters/claude/conversion/acp-to-sdk.ts +++ b/packages/agent/src/adapters/claude/conversion/acp-to-sdk.ts @@ -79,10 +79,6 @@ export function workspacePromptFromFileUri(uri: string): string { } } -function formatFileAttachment(uri: string): string { - return workspacePromptFromFileUri(uri); -} - function isFileSchemeUri(uri: string | undefined | null): boolean { return Boolean(uri?.startsWith("file://")); } @@ -110,7 +106,7 @@ function processPromptChunk( content.push( sdkText( chunk.uri.startsWith("file://") - ? formatFileAttachment(chunk.uri) + ? workspacePromptFromFileUri(chunk.uri) : formatUriAsLink(chunk.uri), ), ); From 76c2cda7f52dc0f59d7a5a31baf468585d12a552 Mon Sep 17 00:00:00 2001 From: Subham Kumar Das <35267544+lost-particles@users.noreply.github.com> Date: Thu, 7 May 2026 00:08:50 -0400 Subject: [PATCH 3/3] Path-Parsing-Support-for-POSIX-&-Windows --- .../editor/utils/cloud-prompt.test.ts | 21 +++++++ .../features/editor/utils/cloud-prompt.ts | 22 +++++--- apps/code/src/renderer/utils/path.ts | 55 ++++++++++++++++++- 3 files changed, 90 insertions(+), 8 deletions(-) diff --git a/apps/code/src/renderer/features/editor/utils/cloud-prompt.test.ts b/apps/code/src/renderer/features/editor/utils/cloud-prompt.test.ts index f0d53df2c..e09464c10 100644 --- a/apps/code/src/renderer/features/editor/utils/cloud-prompt.test.ts +++ b/apps/code/src/renderer/features/editor/utils/cloud-prompt.test.ts @@ -117,6 +117,27 @@ describe("cloud-prompt", () => { expect(mockFs.readAbsoluteFile.query).not.toHaveBeenCalled(); }); + it("encodes Windows drive paths as file URIs", async () => { + const blocks = await buildCloudPromptBlocks( + 'read ', + ); + + const uris = resourceLinksFrom(blocks); + expect(uris).toHaveLength(1); + // C:\tmp\100%\a#b?.txt → file:///C:/tmp/100%25/a%23b%3F.txt + expect(uris[0]).toBe("file:///C:/tmp/100%25/a%23b%3F.txt"); + }); + + it("encodes Windows UNC paths as file URIs", async () => { + const blocks = await buildCloudPromptBlocks( + 'read ', + ); + + const uris = resourceLinksFrom(blocks); + expect(uris).toHaveLength(1); + expect(uris[0]).toBe("file://server/share/My%20Folder/file.txt"); + }); + it("embeds image attachments as ACP image blocks", async () => { const fakeBase64 = btoa("tiny-image-data"); mockFs.readFileAsBase64.query.mockResolvedValue(fakeBase64); diff --git a/apps/code/src/renderer/features/editor/utils/cloud-prompt.ts b/apps/code/src/renderer/features/editor/utils/cloud-prompt.ts index 53c0e3b06..5138a56c6 100644 --- a/apps/code/src/renderer/features/editor/utils/cloud-prompt.ts +++ b/apps/code/src/renderer/features/editor/utils/cloud-prompt.ts @@ -1,9 +1,13 @@ -import { pathToFileURL } from "node:url"; import type { ContentBlock } from "@agentclientprotocol/sdk"; import { CLOUD_PROMPT_PREFIX, serializeCloudPrompt } from "@posthog/shared"; import { trpcClient } from "@renderer/trpc/client"; import { getImageMimeType, isImageFile } from "@shared/constants/image"; -import { getFileExtension, getFileName, isAbsolutePath } from "@utils/path"; +import { + getFileExtension, + getFileName, + isAbsolutePath, + pathToFileUri, +} from "@utils/path"; import { unescapeXmlAttr } from "@utils/xml"; const ABSOLUTE_FILE_TAG_REGEX = //g; @@ -80,10 +84,6 @@ function estimateBase64Bytes(base64: string): number { return Math.floor((base64.length * 3) / 4) - padding; } -function pathToFileUri(filePath: string): string { - return pathToFileURL(filePath).href; -} - function collectAbsoluteFileTagPaths(prompt: string): string[] { const filePaths: string[] = []; @@ -133,7 +133,15 @@ export function getAbsoluteAttachmentPaths( ...collectAbsoluteFileTagPaths(prompt), ...filePaths.filter(isAbsolutePath), ]; - return unique(absolutePaths).filter((p) => !folderPaths.has(p)); + const normalizedFolderPaths = new Set( + Array.from(folderPaths, (p) => p.replaceAll("\\", "/")), + ); + const normalizedAbsolutePaths = absolutePaths.map((p) => + p.replaceAll("\\", "/"), + ); + return unique(normalizedAbsolutePaths).filter( + (p) => !normalizedFolderPaths.has(p), + ); } export function buildCloudTaskDescription( diff --git a/apps/code/src/renderer/utils/path.ts b/apps/code/src/renderer/utils/path.ts index ec9585f18..875ae2a12 100644 --- a/apps/code/src/renderer/utils/path.ts +++ b/apps/code/src/renderer/utils/path.ts @@ -3,7 +3,15 @@ * Handles both Unix (/path) and Windows (C:\path) formats. */ export function isAbsolutePath(filePath: string): boolean { - return filePath.startsWith("/") || /^[a-zA-Z]:/.test(filePath); + return ( + filePath.startsWith("/") || + // Windows drive, e.g. C:\path or C:/path + /^[a-zA-Z]:/.test(filePath) || + // Windows UNC, e.g. \\server\share\path + filePath.startsWith("\\\\") || + // UNC normalized to forward slashes, e.g. //server/share/path + filePath.startsWith("//") + ); } /** @@ -46,3 +54,48 @@ export function getFileExtension(filePath: string): string { const lastDot = name.lastIndexOf("."); return lastDot >= 0 ? name.slice(lastDot + 1).toLowerCase() : ""; } + +/** + * Convert a local file path to a `file://` URI. + * Renderer-safe (no `node:*` imports) and supports Windows drive and UNC paths. + */ +export function pathToFileUri(filePath: string): string { + if (filePath.startsWith("file://")) { + return filePath; + } + + // Normalize Windows separators for string processing. + const normalized = filePath.replaceAll("\\", "/"); + + // UNC path: \\server\share\dir\file.txt → file://server/share/dir/file.txt + if (normalized.startsWith("//")) { + const withoutPrefix = normalized.slice(2); + const parts = withoutPrefix.split("/").filter(Boolean); + const host = parts.shift() ?? ""; + const encodedPath = parts.map(encodeURIComponent).join("/"); + return `file://${host}/${encodedPath}`; + } + + // Drive path: C:\dir\file.txt or C:/dir/file.txt → file:///C:/dir/file.txt + const drive = normalized.match(/^([A-Za-z]):\/(.*)$/); + if (drive) { + const letter = drive[1].toUpperCase(); + const rest = drive[2]; + const encoded = rest + .split("/") + .filter((segment) => segment.length > 0) + .map(encodeURIComponent) + .join("/"); + return `file:///${letter}:/${encoded}`; + } + + // POSIX absolute path: /tmp/test.txt → file:///tmp/test.txt + if (normalized.startsWith("/")) { + const encoded = normalized.split("/").map(encodeURIComponent).join("/"); + return `file://${encoded}`; + } + + // Fallback. + const encoded = normalized.split("/").map(encodeURIComponent).join("/"); + return `file://${encoded}`; +}