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..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
@@ -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,49 @@ 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("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 () => {
@@ -156,12 +171,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 +200,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..5138a56c6 100644
--- a/apps/code/src/renderer/features/editor/utils/cloud-prompt.ts
+++ b/apps/code/src/renderer/features/editor/utils/cloud-prompt.ts
@@ -2,8 +2,12 @@ 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 { makeAttachmentUri } from "@utils/promptContent";
+import {
+ getFileExtension,
+ getFileName,
+ isAbsolutePath,
+ pathToFileUri,
+} from "@utils/path";
import { unescapeXmlAttr } from "@utils/xml";
const ABSOLUTE_FILE_TAG_REGEX = //g;
@@ -58,14 +62,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 +71,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,14 +84,6 @@ 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 collectAbsoluteFileTagPaths(prompt: string): string[] {
const filePaths: string[] = [];
@@ -149,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(
@@ -173,7 +165,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 +199,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/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}`;
+}
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..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
@@ -1,8 +1,34 @@
+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.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);
+ }
+ });
+});
+
+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 +40,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..323e4bc43 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,42 @@ 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 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) {
@@ -60,7 +106,7 @@ function processPromptChunk(
content.push(
sdkText(
chunk.uri.startsWith("file://")
- ? formatFileAttachment(chunk.uri)
+ ? workspacePromptFromFileUri(chunk.uri)
: formatUriAsLink(chunk.uri),
),
);
@@ -68,10 +114,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 +144,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;