Skip to content

Commit f22bd50

Browse files
committed
feat(cloud-agent): add cloud prompt builder and file persistence
1 parent abee4ee commit f22bd50

5 files changed

Lines changed: 631 additions & 12 deletions

File tree

apps/code/src/main/trpc/routers/os.ts

Lines changed: 38 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ const expandHomePath = (searchPath: string): string =>
3939

4040
const MAX_IMAGE_DIMENSION = 1568;
4141
const JPEG_QUALITY = 85;
42+
const CLIPBOARD_TEMP_DIR = path.join(os.tmpdir(), "posthog-code-clipboard");
4243

4344
interface DownscaledImage {
4445
buffer: Buffer;
@@ -88,6 +89,17 @@ function downscaleImage(raw: Buffer, mimeType: string): DownscaledImage {
8889
};
8990
}
9091

92+
async function createClipboardTempFilePath(
93+
displayName: string,
94+
): Promise<string> {
95+
const safeName = path.basename(displayName) || "attachment";
96+
await fsPromises.mkdir(CLIPBOARD_TEMP_DIR, { recursive: true });
97+
const tempDir = await fsPromises.mkdtemp(
98+
path.join(CLIPBOARD_TEMP_DIR, "attachment-"),
99+
);
100+
return path.join(tempDir, safeName);
101+
}
102+
91103
const claudeSettingsPath = path.join(os.homedir(), ".claude", "settings.json");
92104

93105
export const osRouter = router({
@@ -136,6 +148,25 @@ export const osRouter = router({
136148
return result.filePaths[0];
137149
}),
138150

151+
/**
152+
* Show file picker dialog
153+
*/
154+
selectFiles: publicProcedure.output(z.array(z.string())).query(async () => {
155+
const win = getMainWindow();
156+
if (!win) return [];
157+
158+
const result = await dialog.showOpenDialog(win, {
159+
title: "Select files",
160+
properties: ["openFile", "multiSelections", "treatPackageAsDirectory"],
161+
});
162+
163+
if (result.canceled || !result.filePaths?.length) {
164+
return [];
165+
}
166+
167+
return result.filePaths;
168+
}),
169+
139170
/**
140171
* Check if a directory has write access
141172
*/
@@ -277,18 +308,18 @@ export const osRouter = router({
277308
.input(
278309
z.object({
279310
text: z.string(),
311+
originalName: z.string().optional(),
280312
}),
281313
)
282314
.mutation(async ({ input }) => {
283-
const filename = `pasted-text-${Date.now()}.txt`;
284-
const tempDir = path.join(os.tmpdir(), "posthog-code-clipboard");
285-
286-
await fsPromises.mkdir(tempDir, { recursive: true });
287-
const filePath = path.join(tempDir, filename);
315+
const displayName = path.basename(
316+
input.originalName ?? "pasted-text.txt",
317+
);
318+
const filePath = await createClipboardTempFilePath(displayName);
288319

289320
await fsPromises.writeFile(filePath, input.text, "utf-8");
290321

291-
return { path: filePath, name: "pasted-text.txt" };
322+
return { path: filePath, name: displayName };
292323
}),
293324

294325
/**
@@ -321,12 +352,7 @@ export const osRouter = router({
321352
/\.[^.]+$/,
322353
`.${extension}`,
323354
);
324-
const baseName = displayName.replace(/\.[^.]+$/, "");
325-
const filename = `${baseName}-${Date.now()}.${extension}`;
326-
const tempDir = path.join(os.tmpdir(), "posthog-code-clipboard");
327-
328-
await fsPromises.mkdir(tempDir, { recursive: true });
329-
const filePath = path.join(tempDir, filename);
355+
const filePath = await createClipboardTempFilePath(displayName);
330356

331357
await fsPromises.writeFile(filePath, buffer);
332358

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
import { beforeEach, describe, expect, it, vi } from "vitest";
2+
3+
const mockFs = vi.hoisted(() => ({
4+
readAbsoluteFile: { query: vi.fn() },
5+
readFileAsBase64: { query: vi.fn() },
6+
}));
7+
8+
vi.mock("@features/message-editor/utils/imageUtils", () => ({
9+
isImageFile: (name: string) =>
10+
/\.(png|jpe?g|gif|webp|bmp|svg|ico|tiff?)$/i.test(name),
11+
}));
12+
13+
vi.mock("@features/code-editor/utils/imageUtils", () => ({
14+
getImageMimeType: (name: string) => {
15+
const ext = name.split(".").pop()?.toLowerCase();
16+
const map: Record<string, string> = {
17+
png: "image/png",
18+
jpg: "image/jpeg",
19+
jpeg: "image/jpeg",
20+
gif: "image/gif",
21+
webp: "image/webp",
22+
};
23+
return map[ext ?? ""] ?? "image/png";
24+
},
25+
}));
26+
27+
vi.mock("@renderer/trpc/client", () => ({
28+
trpcClient: {
29+
fs: mockFs,
30+
},
31+
}));
32+
33+
import { parseAttachmentUri } from "@utils/promptContent";
34+
import {
35+
buildCloudPromptBlocks,
36+
buildCloudTaskDescription,
37+
serializeCloudPrompt,
38+
stripAbsoluteFileTags,
39+
} from "./cloud-prompt";
40+
41+
describe("cloud-prompt", () => {
42+
beforeEach(() => {
43+
vi.clearAllMocks();
44+
});
45+
46+
it("strips absolute file tags but keeps repo file tags", () => {
47+
const prompt =
48+
'review <file path="src/index.ts" /> and <file path="/tmp/test.txt" />';
49+
50+
expect(stripAbsoluteFileTags(prompt)).toBe(
51+
'review <file path="src/index.ts" /> and',
52+
);
53+
});
54+
55+
it("builds a safe cloud task description for local attachments", () => {
56+
const description = buildCloudTaskDescription(
57+
'review <file path="src/index.ts" /> and <file path="/tmp/test.txt" />',
58+
);
59+
60+
expect(description).toBe(
61+
'review <file path="src/index.ts" /> and\n\nAttached files: test.txt',
62+
);
63+
});
64+
65+
it("embeds text attachments as ACP resources", async () => {
66+
mockFs.readAbsoluteFile.query.mockResolvedValue("hello from file");
67+
68+
const blocks = await buildCloudPromptBlocks(
69+
'read this <file path="/tmp/test.txt" />',
70+
);
71+
72+
expect(blocks).toEqual([
73+
{ type: "text", text: "read this" },
74+
expect.objectContaining({
75+
type: "resource",
76+
resource: expect.objectContaining({
77+
text: "hello from file",
78+
mimeType: "text/plain",
79+
}),
80+
}),
81+
]);
82+
83+
const attachmentBlock = blocks[1];
84+
expect(attachmentBlock.type).toBe("resource");
85+
if (attachmentBlock.type !== "resource") {
86+
throw new Error("Expected a resource attachment block");
87+
}
88+
89+
expect(parseAttachmentUri(attachmentBlock.resource.uri)).toEqual({
90+
id: attachmentBlock.resource.uri,
91+
label: "test.txt",
92+
});
93+
});
94+
95+
it("embeds image attachments as ACP image blocks", async () => {
96+
const fakeBase64 = btoa("tiny-image-data");
97+
mockFs.readFileAsBase64.query.mockResolvedValue(fakeBase64);
98+
99+
const blocks = await buildCloudPromptBlocks(
100+
'check <file path="/tmp/screenshot.png" />',
101+
);
102+
103+
expect(blocks).toHaveLength(2);
104+
expect(blocks[0]).toEqual({ type: "text", text: "check" });
105+
expect(blocks[1]).toMatchObject({
106+
type: "image",
107+
data: fakeBase64,
108+
mimeType: "image/png",
109+
});
110+
});
111+
112+
it("rejects images over 5 MB", async () => {
113+
// 5 MB in base64 is ~6.67M chars; generate slightly over
114+
const oversize = "A".repeat(7_000_000);
115+
mockFs.readFileAsBase64.query.mockResolvedValue(oversize);
116+
117+
await expect(
118+
buildCloudPromptBlocks('see <file path="/tmp/huge.png" />'),
119+
).rejects.toThrow(/too large/);
120+
});
121+
122+
it("rejects unsupported image formats", async () => {
123+
await expect(
124+
buildCloudPromptBlocks('see <file path="/tmp/photo.bmp" />'),
125+
).rejects.toThrow(/Unsupported image/);
126+
});
127+
128+
it("throws when readAbsoluteFile returns null", async () => {
129+
mockFs.readAbsoluteFile.query.mockResolvedValue(null);
130+
131+
await expect(
132+
buildCloudPromptBlocks('read <file path="/tmp/missing.txt" />'),
133+
).rejects.toThrow(/Unable to read/);
134+
});
135+
136+
it("throws when readFileAsBase64 returns falsy for images", async () => {
137+
mockFs.readFileAsBase64.query.mockResolvedValue(null);
138+
139+
await expect(
140+
buildCloudPromptBlocks('see <file path="/tmp/broken.png" />'),
141+
).rejects.toThrow(/Unable to read/);
142+
});
143+
144+
it("throws on empty prompt with no attachments", async () => {
145+
await expect(buildCloudPromptBlocks("")).rejects.toThrow(/cannot be empty/);
146+
});
147+
148+
it("serializes structured prompts for pending cloud messages", () => {
149+
const serialized = serializeCloudPrompt([
150+
{ type: "text", text: "read this" },
151+
{
152+
type: "resource",
153+
resource: {
154+
uri: "attachment://test.txt",
155+
text: "hello from file",
156+
mimeType: "text/plain",
157+
},
158+
},
159+
]);
160+
161+
expect(serialized).toContain("__twig_cloud_prompt_v1__:");
162+
expect(serialized).toContain('"type":"resource"');
163+
});
164+
});

0 commit comments

Comments
 (0)