Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 55 additions & 38 deletions apps/code/src/renderer/features/editor/utils/cloud-prompt.test.ts
Original file line number Diff line number Diff line change
@@ -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(() => ({
Expand Down Expand Up @@ -31,14 +33,19 @@ vi.mock("@renderer/trpc/client", () => ({
},
}));

import { parseAttachmentUri } from "@utils/promptContent";
import {
buildCloudPromptBlocks,
buildCloudTaskDescription,
serializeCloudPrompt,
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();
Expand All @@ -63,24 +70,17 @@ describe("cloud-prompt", () => {
});

it("excludes folder paths from absolute attachment list", async () => {
mockFs.readAbsoluteFile.query.mockResolvedValue("hi");

const prompt =
'scan <folder path="/abs/dir" /> and <file path="/tmp/test.txt" />';
const blocks = await buildCloudPromptBlocks(prompt, [
"/abs/dir",
"/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", () => {
Expand All @@ -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 <file path="/tmp/test.txt" />',
);

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 <file path="C:\\\\tmp\\\\100%\\\\a#b?.txt" />',
);

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 <file path="\\\\\\\\server\\\\share\\\\My Folder\\\\file.txt" />',
);

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 () => {
Expand Down Expand Up @@ -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 <file path="/tmp/missing.txt" />'),
).rejects.toThrow(/Unable to read/);
const blocks = await buildCloudPromptBlocks(
'read <file path="/tmp/maybe-missing-on-disk.txt" />',
);
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 () => {
Expand All @@ -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"');
});
});
58 changes: 22 additions & 36 deletions apps/code/src/renderer/features/editor/utils/cloud-prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = /<file\s+path="([^"]+)"\s*\/>/g;
Expand Down Expand Up @@ -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<string, string> = {
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 {
Expand All @@ -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));
}
Expand All @@ -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[] = [];

Expand Down Expand Up @@ -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(
Expand All @@ -173,7 +165,7 @@ export function buildCloudTaskDescription(

async function buildAttachmentBlock(filePath: string): Promise<ContentBlock> {
const fileName = getFileName(filePath);
const uri = makeAttachmentUri(filePath);
const uri = pathToFileUri(filePath);

if (isSupportedCloudImageAttachment(fileName)) {
const base64 = await trpcClient.fs.readFileAsBase64.query({ filePath });
Expand Down Expand Up @@ -207,21 +199,15 @@ async function buildAttachmentBlock(filePath: string): Promise<ContentBlock> {
);
}

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[] = [],
Expand Down
55 changes: 54 additions & 1 deletion apps/code/src/renderer/utils/path.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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("//")
);
}

/**
Expand Down Expand Up @@ -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}`;
}
Loading