Skip to content
Merged
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
96 changes: 56 additions & 40 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,50 @@ 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 () => {
// Actual UNC path: \\server\share\My Folder\file.txt
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 +172,15 @@ describe("cloud-prompt", () => {
).rejects.toThrow(/Unsupported image/);
});

it("throws when readAbsoluteFile returns null", async () => {
mockFs.readAbsoluteFile.query.mockResolvedValue(null);

await expect(
buildCloudPromptBlocks('read <file path="/tmp/missing.txt" />'),
).rejects.toThrow(/Unable to read/);
it("does not rely on readAbsoluteFile for txt attachments", async () => {
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 +199,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"');
});
});
54 changes: 18 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 @@ -144,12 +128,16 @@ export function getAbsoluteAttachmentPaths(
prompt: string,
filePaths: string[] = [],
): string[] {
const normalize = (p: string) => p.replaceAll("\\", "/");
const folderPaths = collectFolderTagPaths(prompt);
const normalizedFolderPaths = new Set(Array.from(folderPaths, normalize));
const absolutePaths = [
...collectAbsoluteFileTagPaths(prompt),
...filePaths.filter(isAbsolutePath),
];
return unique(absolutePaths).filter((p) => !folderPaths.has(p));
return unique(absolutePaths).filter(
(p) => !normalizedFolderPaths.has(normalize(p)),
);
}

export function buildCloudTaskDescription(
Expand All @@ -173,7 +161,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 +195,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
10 changes: 1 addition & 9 deletions apps/code/src/renderer/features/editor/utils/prompt-builder.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,5 @@
import type { ContentBlock } from "@agentclientprotocol/sdk";
import { isAbsolutePath } from "@utils/path";

function pathToFileUri(filePath: string): string {
const encoded = filePath
.split("/")
.map((segment) => encodeURIComponent(segment))
.join("/");
return `file://${encoded}`;
}
import { isAbsolutePath, pathToFileUri } from "@utils/path";

export async function buildPromptBlocks(
textContent: string,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import type {
TaskArtifactUploadRequest,
} from "@renderer/api/posthogClient";
import { trpcClient } from "@renderer/trpc/client";
import { getFileName } from "@utils/path";
import { getFileName, pathToFileUri } from "@utils/path";
import type { EditorContent } from "../../message-editor/utils/content";

const FILE_URI_PREFIX = "file://";
Expand Down Expand Up @@ -65,14 +65,6 @@ interface LoadedCloudAttachment {
upload: TaskArtifactUploadRequest;
}

function pathToFileUri(filePath: string): string {
const encoded = filePath
.split("/")
.map((segment) => encodeURIComponent(segment))
.join("/");
return `file://${encoded}`;
}

export interface CloudPromptTransport {
filePaths: string[];
messageText?: string;
Expand Down
73 changes: 73 additions & 0 deletions apps/code/src/renderer/utils/path.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { describe, expect, it } from "vitest";
import { isAbsolutePath, pathToFileUri } from "./path";

describe("isAbsolutePath", () => {
it.each([
["/tmp/file.txt", true],
["C:\\Users\\me\\file.txt", true],
["C:/Users/me/file.txt", true],
["d:\\downloads\\file.txt", true],
["\\\\server\\share\\file.txt", true],
["//server/share/file.txt", true],
["relative/path.txt", false],
["./file.txt", false],
["../file.txt", false],
["file.txt", false],
["", false],
])("isAbsolutePath(%j) === %s", (input, expected) => {
expect(isAbsolutePath(input)).toBe(expected);
});
});

describe("pathToFileUri", () => {
it("encodes a POSIX absolute path", () => {
expect(pathToFileUri("/tmp/test.txt")).toBe("file:///tmp/test.txt");
});

it("percent-encodes POSIX path segments with spaces and reserved chars", () => {
expect(pathToFileUri("/tmp/My Folder/a#b?.txt")).toBe(
"file:///tmp/My%20Folder/a%23b%3F.txt",
);
});

it("encodes a Windows drive path with single backslashes", () => {
expect(pathToFileUri("C:\\tmp\\file.txt")).toBe("file:///C:/tmp/file.txt");
});

it("encodes a Windows drive path that already uses forward slashes", () => {
expect(pathToFileUri("C:/tmp/file.txt")).toBe("file:///C:/tmp/file.txt");
});

it("uppercases the drive letter", () => {
expect(pathToFileUri("c:\\tmp\\file.txt")).toBe("file:///C:/tmp/file.txt");
});

it("percent-encodes Windows drive path segments with special chars", () => {
expect(pathToFileUri("C:\\tmp\\100%\\a#b?.txt")).toBe(
"file:///C:/tmp/100%25/a%23b%3F.txt",
);
});

it("encodes a UNC path with the host as the URI authority", () => {
expect(pathToFileUri("\\\\server\\share\\My Folder\\file.txt")).toBe(
"file://server/share/My%20Folder/file.txt",
);
});

it("encodes a UNC path already normalized to forward slashes", () => {
expect(pathToFileUri("//server/share/file.txt")).toBe(
"file://server/share/file.txt",
);
});

it("returns existing file:// URIs unchanged", () => {
const uri = "file:///tmp/file.txt";
expect(pathToFileUri(uri)).toBe(uri);
});

it("round-trips literal percent signs via re-encoding", () => {
// Already-encoded segments are re-encoded so the original characters round-trip
// through decodeURIComponent.
expect(pathToFileUri("/tmp/100%25")).toBe("file:///tmp/100%2525");
});
});
Loading
Loading