From 90c8cdac8b61eac393109a92ca9f5541f32d750e Mon Sep 17 00:00:00 2001 From: Alessandro Pogliaghi Date: Tue, 7 Apr 2026 11:19:57 +0100 Subject: [PATCH] feat(cloud-agent): cloud file attachments chore: cleaner chore: cleaner chore: cleaner --- apps/code/src/main/trpc/routers/os.ts | 50 +++- apps/code/src/renderer/api/posthogClient.ts | 19 +- .../code-review/hooks/useReviewComment.ts | 10 +- .../editor/utils/cloud-prompt.test.ts | 164 ++++++++++++ .../features/editor/utils/cloud-prompt.ts | 237 ++++++++++++++++++ .../components/AttachmentMenu.test.tsx | 66 +++++ .../components/AttachmentMenu.tsx | 92 ++++--- .../tiptap/useDraftSync.test.tsx | 63 +++++ .../message-editor/tiptap/useDraftSync.ts | 9 +- .../message-editor/tiptap/useTiptapEditor.ts | 22 +- .../features/message-editor/utils/content.ts | 10 +- .../message-editor/utils/persistFile.test.ts | 126 ++++++++++ .../message-editor/utils/persistFile.ts | 66 +++++ .../sessions/components/ConversationView.tsx | 1 + .../components/buildConversationItems.test.ts | 97 +++++++ .../components/buildConversationItems.ts | 42 ++-- .../session-update/UserMessage.test.tsx | 24 ++ .../components/session-update/UserMessage.tsx | 28 ++- .../session-update/parseFileMentions.tsx | 2 +- .../features/sessions/service/service.test.ts | 50 ++++ .../features/sessions/service/service.ts | 66 +++-- .../components/TaskInputEditor.tsx | 49 ++-- .../task-detail/hooks/useTaskCreation.ts | 13 +- .../renderer/sagas/task/task-creation.test.ts | 79 +++++- .../src/renderer/sagas/task/task-creation.ts | 32 ++- apps/code/src/renderer/utils/path.ts | 10 +- .../src/renderer/utils/promptContent.test.ts | 50 ++++ apps/code/src/renderer/utils/promptContent.ts | 92 +++++++ apps/code/src/renderer/utils/session.ts | 10 +- apps/code/src/renderer/utils/xml.ts | 17 ++ .../agent/src/server/agent-server.test.ts | 24 ++ packages/agent/src/server/agent-server.ts | 91 ++++--- packages/agent/src/server/cloud-prompt.ts | 13 + .../agent/src/server/question-relay.test.ts | 47 ++++ packages/agent/src/server/schemas.test.ts | 20 +- packages/agent/src/server/schemas.ts | 5 +- packages/shared/package.json | 1 + packages/shared/src/cloud-prompt.test.ts | 126 ++++++++++ packages/shared/src/cloud-prompt.ts | 49 ++++ packages/shared/src/index.ts | 6 + pnpm-lock.yaml | 3 + 41 files changed, 1779 insertions(+), 202 deletions(-) create mode 100644 apps/code/src/renderer/features/editor/utils/cloud-prompt.test.ts create mode 100644 apps/code/src/renderer/features/editor/utils/cloud-prompt.ts create mode 100644 apps/code/src/renderer/features/message-editor/components/AttachmentMenu.test.tsx create mode 100644 apps/code/src/renderer/features/message-editor/tiptap/useDraftSync.test.tsx create mode 100644 apps/code/src/renderer/features/message-editor/utils/persistFile.test.ts create mode 100644 apps/code/src/renderer/features/message-editor/utils/persistFile.ts create mode 100644 apps/code/src/renderer/features/sessions/components/buildConversationItems.test.ts create mode 100644 apps/code/src/renderer/features/sessions/components/session-update/UserMessage.test.tsx create mode 100644 apps/code/src/renderer/utils/promptContent.test.ts create mode 100644 apps/code/src/renderer/utils/promptContent.ts create mode 100644 apps/code/src/renderer/utils/xml.ts create mode 100644 packages/agent/src/server/cloud-prompt.ts create mode 100644 packages/shared/src/cloud-prompt.test.ts create mode 100644 packages/shared/src/cloud-prompt.ts diff --git a/apps/code/src/main/trpc/routers/os.ts b/apps/code/src/main/trpc/routers/os.ts index 24f9dd781..effee8014 100644 --- a/apps/code/src/main/trpc/routers/os.ts +++ b/apps/code/src/main/trpc/routers/os.ts @@ -39,6 +39,7 @@ const expandHomePath = (searchPath: string): string => const MAX_IMAGE_DIMENSION = 1568; const JPEG_QUALITY = 85; +const CLIPBOARD_TEMP_DIR = path.join(os.tmpdir(), "posthog-code-clipboard"); interface DownscaledImage { buffer: Buffer; @@ -88,6 +89,17 @@ function downscaleImage(raw: Buffer, mimeType: string): DownscaledImage { }; } +async function createClipboardTempFilePath( + displayName: string, +): Promise { + const safeName = path.basename(displayName) || "attachment"; + await fsPromises.mkdir(CLIPBOARD_TEMP_DIR, { recursive: true }); + const tempDir = await fsPromises.mkdtemp( + path.join(CLIPBOARD_TEMP_DIR, "attachment-"), + ); + return path.join(tempDir, safeName); +} + const claudeSettingsPath = path.join(os.homedir(), ".claude", "settings.json"); export const osRouter = router({ @@ -136,6 +148,25 @@ export const osRouter = router({ return result.filePaths[0]; }), + /** + * Show file picker dialog + */ + selectFiles: publicProcedure.output(z.array(z.string())).query(async () => { + const win = getMainWindow(); + if (!win) return []; + + const result = await dialog.showOpenDialog(win, { + title: "Select files", + properties: ["openFile", "multiSelections", "treatPackageAsDirectory"], + }); + + if (result.canceled || !result.filePaths?.length) { + return []; + } + + return result.filePaths; + }), + /** * Check if a directory has write access */ @@ -277,18 +308,18 @@ export const osRouter = router({ .input( z.object({ text: z.string(), + originalName: z.string().optional(), }), ) .mutation(async ({ input }) => { - const filename = `pasted-text-${Date.now()}.txt`; - const tempDir = path.join(os.tmpdir(), "posthog-code-clipboard"); - - await fsPromises.mkdir(tempDir, { recursive: true }); - const filePath = path.join(tempDir, filename); + const displayName = path.basename( + input.originalName ?? "pasted-text.txt", + ); + const filePath = await createClipboardTempFilePath(displayName); await fsPromises.writeFile(filePath, input.text, "utf-8"); - return { path: filePath, name: "pasted-text.txt" }; + return { path: filePath, name: displayName }; }), /** @@ -321,12 +352,7 @@ export const osRouter = router({ /\.[^.]+$/, `.${extension}`, ); - const baseName = displayName.replace(/\.[^.]+$/, ""); - const filename = `${baseName}-${Date.now()}.${extension}`; - const tempDir = path.join(os.tmpdir(), "posthog-code-clipboard"); - - await fsPromises.mkdir(tempDir, { recursive: true }); - const filePath = path.join(tempDir, filename); + const filePath = await createClipboardTempFilePath(displayName); await fsPromises.writeFile(filePath, buffer); diff --git a/apps/code/src/renderer/api/posthogClient.ts b/apps/code/src/renderer/api/posthogClient.ts index 49ff66b9f..d514e4b7d 100644 --- a/apps/code/src/renderer/api/posthogClient.ts +++ b/apps/code/src/renderer/api/posthogClient.ts @@ -557,20 +557,25 @@ export class PostHogAPIClient { async runTaskInCloud( taskId: string, branch?: string | null, - resumeOptions?: { resumeFromRunId: string; pendingUserMessage: string }, - sandboxEnvironmentId?: string, + options?: { + resumeFromRunId?: string; + pendingUserMessage?: string; + sandboxEnvironmentId?: string; + }, ): Promise { const teamId = await this.getTeamId(); const body: Record = { mode: "interactive" }; if (branch) { body.branch = branch; } - if (resumeOptions) { - body.resume_from_run_id = resumeOptions.resumeFromRunId; - body.pending_user_message = resumeOptions.pendingUserMessage; + if (options?.resumeFromRunId) { + body.resume_from_run_id = options.resumeFromRunId; + } + if (options?.pendingUserMessage) { + body.pending_user_message = options.pendingUserMessage; } - if (sandboxEnvironmentId) { - body.sandbox_environment_id = sandboxEnvironmentId; + if (options?.sandboxEnvironmentId) { + body.sandbox_environment_id = options.sandboxEnvironmentId; } const data = await this.api.post( diff --git a/apps/code/src/renderer/features/code-review/hooks/useReviewComment.ts b/apps/code/src/renderer/features/code-review/hooks/useReviewComment.ts index b0dbca403..3ec0a792f 100644 --- a/apps/code/src/renderer/features/code-review/hooks/useReviewComment.ts +++ b/apps/code/src/renderer/features/code-review/hooks/useReviewComment.ts @@ -2,18 +2,10 @@ import { DEFAULT_TAB_IDS } from "@features/panels/constants/panelConstants"; import { usePanelLayoutStore } from "@features/panels/store/panelLayoutStore"; import { findTabInTree } from "@features/panels/store/panelTree"; import { getSessionService } from "@features/sessions/service/service"; +import { escapeXmlAttr } from "@utils/xml"; import { useCallback } from "react"; import type { OnCommentCallback } from "../types"; -function escapeXmlAttr(value: string): string { - return value - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """) - .replace(/'/g, "'"); -} - export function useReviewComment(taskId: string): OnCommentCallback { return useCallback( (filePath, startLine, endLine, side, comment) => { 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 new file mode 100644 index 000000000..e33895f5e --- /dev/null +++ b/apps/code/src/renderer/features/editor/utils/cloud-prompt.test.ts @@ -0,0 +1,164 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockFs = vi.hoisted(() => ({ + readAbsoluteFile: { query: vi.fn() }, + readFileAsBase64: { query: vi.fn() }, +})); + +vi.mock("@features/message-editor/utils/imageUtils", () => ({ + isImageFile: (name: string) => + /\.(png|jpe?g|gif|webp|bmp|svg|ico|tiff?)$/i.test(name), +})); + +vi.mock("@features/code-editor/utils/imageUtils", () => ({ + getImageMimeType: (name: string) => { + const ext = name.split(".").pop()?.toLowerCase(); + const map: Record = { + png: "image/png", + jpg: "image/jpeg", + jpeg: "image/jpeg", + gif: "image/gif", + webp: "image/webp", + }; + return map[ext ?? ""] ?? "image/png"; + }, +})); + +vi.mock("@renderer/trpc/client", () => ({ + trpcClient: { + fs: mockFs, + }, +})); + +import { parseAttachmentUri } from "@utils/promptContent"; +import { + buildCloudPromptBlocks, + buildCloudTaskDescription, + serializeCloudPrompt, + stripAbsoluteFileTags, +} from "./cloud-prompt"; + +describe("cloud-prompt", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("strips absolute file tags but keeps repo file tags", () => { + const prompt = + 'review and '; + + expect(stripAbsoluteFileTags(prompt)).toBe( + 'review and', + ); + }); + + it("builds a safe cloud task description for local attachments", () => { + const description = buildCloudTaskDescription( + 'review and ', + ); + + expect(description).toBe( + 'review and\n\nAttached files: test.txt', + ); + }); + + it("embeds text attachments as ACP resources", async () => { + mockFs.readAbsoluteFile.query.mockResolvedValue("hello from file"); + + 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", + }), + }), + ]); + + const attachmentBlock = blocks[1]; + expect(attachmentBlock.type).toBe("resource"); + if (attachmentBlock.type !== "resource") { + throw new Error("Expected a resource attachment block"); + } + + expect(parseAttachmentUri(attachmentBlock.resource.uri)).toEqual({ + id: attachmentBlock.resource.uri, + label: "test.txt", + }); + }); + + it("embeds image attachments as ACP image blocks", async () => { + const fakeBase64 = btoa("tiny-image-data"); + mockFs.readFileAsBase64.query.mockResolvedValue(fakeBase64); + + const blocks = await buildCloudPromptBlocks( + 'check ', + ); + + expect(blocks).toHaveLength(2); + expect(blocks[0]).toEqual({ type: "text", text: "check" }); + expect(blocks[1]).toMatchObject({ + type: "image", + data: fakeBase64, + mimeType: "image/png", + }); + }); + + it("rejects images over 5 MB", async () => { + // 5 MB in base64 is ~6.67M chars; generate slightly over + const oversize = "A".repeat(7_000_000); + mockFs.readFileAsBase64.query.mockResolvedValue(oversize); + + await expect( + buildCloudPromptBlocks('see '), + ).rejects.toThrow(/too large/); + }); + + it("rejects unsupported image formats", async () => { + await expect( + buildCloudPromptBlocks('see '), + ).rejects.toThrow(/Unsupported image/); + }); + + it("throws when readAbsoluteFile returns null", async () => { + mockFs.readAbsoluteFile.query.mockResolvedValue(null); + + await expect( + buildCloudPromptBlocks('read '), + ).rejects.toThrow(/Unable to read/); + }); + + it("throws when readFileAsBase64 returns falsy for images", async () => { + mockFs.readFileAsBase64.query.mockResolvedValue(null); + + await expect( + buildCloudPromptBlocks('see '), + ).rejects.toThrow(/Unable to read/); + }); + + it("throws on empty prompt with no attachments", async () => { + await expect(buildCloudPromptBlocks("")).rejects.toThrow(/cannot be empty/); + }); + + it("serializes structured prompts for pending cloud messages", () => { + const serialized = serializeCloudPrompt([ + { type: "text", text: "read this" }, + { + type: "resource", + resource: { + uri: "attachment://test.txt", + text: "hello from file", + mimeType: "text/plain", + }, + }, + ]); + + expect(serialized).toContain("__twig_cloud_prompt_v1__:"); + expect(serialized).toContain('"type":"resource"'); + }); +}); diff --git a/apps/code/src/renderer/features/editor/utils/cloud-prompt.ts b/apps/code/src/renderer/features/editor/utils/cloud-prompt.ts new file mode 100644 index 000000000..92245c696 --- /dev/null +++ b/apps/code/src/renderer/features/editor/utils/cloud-prompt.ts @@ -0,0 +1,237 @@ +import type { ContentBlock } from "@agentclientprotocol/sdk"; +import { getImageMimeType } from "@features/code-editor/utils/imageUtils"; +import { isImageFile } from "@features/message-editor/utils/imageUtils"; +import { CLOUD_PROMPT_PREFIX, serializeCloudPrompt } from "@posthog/shared"; +import { trpcClient } from "@renderer/trpc/client"; +import { getFileExtension, getFileName, isAbsolutePath } from "@utils/path"; +import { makeAttachmentUri } from "@utils/promptContent"; +import { unescapeXmlAttr } from "@utils/xml"; + +const ABSOLUTE_FILE_TAG_REGEX = //g; +const TEXT_EXTENSIONS = new Set([ + "c", + "cc", + "cfg", + "conf", + "cpp", + "cs", + "css", + "csv", + "env", + "gitignore", + "go", + "h", + "hpp", + "html", + "ini", + "java", + "js", + "json", + "jsx", + "log", + "md", + "mjs", + "py", + "rb", + "rs", + "scss", + "sh", + "sql", + "svg", + "toml", + "ts", + "tsx", + "txt", + "xml", + "yaml", + "yml", + "zsh", +]); +const TEXT_FILENAMES = new Set([ + ".env", + ".gitignore", + "Dockerfile", + "LICENSE", + "Makefile", + "README", + "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 { + const fileName = getFileName(filePath); + const ext = getFileExtension(filePath); + 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)); +} + +export function isSupportedCloudTextAttachment(filePath: string): boolean { + return isTextAttachment(filePath); +} + +function estimateBase64Bytes(base64: string): number { + const padding = base64.endsWith("==") ? 2 : base64.endsWith("=") ? 1 : 0; + 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[] = []; + + for (const match of prompt.matchAll(ABSOLUTE_FILE_TAG_REGEX)) { + const decodedPath = unescapeXmlAttr(match[1]); + if (isAbsolutePath(decodedPath)) { + filePaths.push(decodedPath); + } + } + + return filePaths; +} + +function unique(values: T[]): T[] { + return Array.from(new Set(values)); +} + +function normalizePromptText(prompt: string): string { + return prompt.replace(/\n{3,}/g, "\n\n").trim(); +} + +export function stripAbsoluteFileTags(prompt: string): string { + return normalizePromptText( + prompt.replaceAll(ABSOLUTE_FILE_TAG_REGEX, (match, rawPath: string) => { + const decodedPath = unescapeXmlAttr(rawPath); + return isAbsolutePath(decodedPath) ? "" : match; + }), + ); +} + +export function getAbsoluteAttachmentPaths( + prompt: string, + filePaths: string[] = [], +): string[] { + const absolutePaths = [ + ...collectAbsoluteFileTagPaths(prompt), + ...filePaths.filter(isAbsolutePath), + ]; + return unique(absolutePaths); +} + +export function buildCloudTaskDescription( + prompt: string, + filePaths: string[] = [], +): string { + const strippedPrompt = stripAbsoluteFileTags(prompt); + const attachmentNames = getAbsoluteAttachmentPaths(prompt, filePaths).map( + getFileName, + ); + + if (attachmentNames.length === 0) { + return strippedPrompt; + } + + const attachmentSummary = `Attached files: ${attachmentNames.join(", ")}`; + return strippedPrompt + ? `${strippedPrompt}\n\n${attachmentSummary}` + : attachmentSummary; +} + +async function buildAttachmentBlock(filePath: string): Promise { + const fileName = getFileName(filePath); + const uri = makeAttachmentUri(filePath); + + if (isSupportedCloudImageAttachment(fileName)) { + const base64 = await trpcClient.fs.readFileAsBase64.query({ filePath }); + if (!base64) { + throw new Error(`Unable to read attached image ${fileName}`); + } + + if (estimateBase64Bytes(base64) > MAX_EMBEDDED_IMAGE_BYTES) { + throw new Error( + `${fileName} is too large for a cloud image attachment (max 5 MB)`, + ); + } + + return { + type: "image", + data: base64, + mimeType: getImageMimeType(fileName), + uri, + }; + } + + if (isImageFile(fileName)) { + throw new Error( + `Cloud image attachments currently support PNG, JPG, GIF, and WebP. Unsupported image: ${fileName}`, + ); + } + + if (!isTextAttachment(fileName)) { + throw new Error( + `Cloud attachments currently support text and image files. Unsupported attachment: ${fileName}`, + ); + } + + const text = await trpcClient.fs.readAbsoluteFile.query({ filePath }); + if (text === null) { + throw new Error(`Unable to read attached file ${fileName}`); + } + + return { + type: "resource", + resource: { + uri, + text: truncateText(text), + mimeType: getTextMimeType(fileName), + }, + }; +} + +export async function buildCloudPromptBlocks( + prompt: string, + filePaths: string[] = [], +): Promise { + const promptText = stripAbsoluteFileTags(prompt); + const attachmentPaths = getAbsoluteAttachmentPaths(prompt, filePaths); + + const attachmentBlocks = await Promise.all( + attachmentPaths.map(buildAttachmentBlock), + ); + + const blocks: ContentBlock[] = []; + if (promptText) { + blocks.push({ type: "text", text: promptText }); + } + blocks.push(...attachmentBlocks); + + if (blocks.length === 0) { + throw new Error("Cloud prompt cannot be empty"); + } + + return blocks; +} + +export { CLOUD_PROMPT_PREFIX, serializeCloudPrompt }; diff --git a/apps/code/src/renderer/features/message-editor/components/AttachmentMenu.test.tsx b/apps/code/src/renderer/features/message-editor/components/AttachmentMenu.test.tsx new file mode 100644 index 000000000..739c4b9d7 --- /dev/null +++ b/apps/code/src/renderer/features/message-editor/components/AttachmentMenu.test.tsx @@ -0,0 +1,66 @@ +import { Theme } from "@radix-ui/themes"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockSelectFiles = vi.hoisted(() => vi.fn()); + +vi.mock("@renderer/trpc/client", () => ({ + trpcClient: { + os: { + selectFiles: { + query: mockSelectFiles, + }, + }, + }, + useTRPC: () => ({ + git: { + getGhStatus: { + queryOptions: () => ({}), + }, + }, + }), +})); + +vi.mock("@tanstack/react-query", () => ({ + useQuery: () => ({ data: undefined }), +})); + +vi.mock("@renderer/utils/toast", () => ({ + toast: { + error: vi.fn(), + }, +})); + +import { AttachmentMenu } from "./AttachmentMenu"; + +describe("AttachmentMenu", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("adds attachments using absolute file paths from the OS picker", async () => { + const user = userEvent.setup(); + const onAddAttachment = vi.fn(); + + mockSelectFiles.mockResolvedValue(["/tmp/demo/test.txt"]); + + render( + + + , + ); + + await user.click(screen.getByRole("button")); + await user.click(await screen.findByText("Add file")); + + expect(mockSelectFiles).toHaveBeenCalledOnce(); + expect(onAddAttachment).toHaveBeenCalledWith({ + id: "/tmp/demo/test.txt", + label: "test.txt", + }); + }); +}); diff --git a/apps/code/src/renderer/features/message-editor/components/AttachmentMenu.tsx b/apps/code/src/renderer/features/message-editor/components/AttachmentMenu.tsx index b36539ff6..73704b692 100644 --- a/apps/code/src/renderer/features/message-editor/components/AttachmentMenu.tsx +++ b/apps/code/src/renderer/features/message-editor/components/AttachmentMenu.tsx @@ -1,11 +1,13 @@ import "./AttachmentMenu.css"; -import { Tooltip } from "@components/ui/Tooltip"; import { File, GithubLogo, Paperclip } from "@phosphor-icons/react"; import { IconButton, Popover } from "@radix-ui/themes"; -import { useTRPC } from "@renderer/trpc/client"; +import { trpcClient, useTRPC } from "@renderer/trpc/client"; +import { toast } from "@renderer/utils/toast"; import { useQuery } from "@tanstack/react-query"; +import { getFileName } from "@utils/path"; import { useRef, useState } from "react"; import type { FileAttachment, MentionChip } from "../utils/content"; +import { persistBrowserFile } from "../utils/persistFile"; import { IssuePicker } from "./IssuePicker"; type View = "menu" | "issues"; @@ -54,20 +56,42 @@ export function AttachmentMenu({ const issueDisabledReason = getIssueDisabledReason(ghStatus, repoPath); - const handleFileSelect = (e: React.ChangeEvent) => { - const files = e.target.files; - if (files && files.length > 0) { - const fileArray = Array.from(files); - for (const file of fileArray) { - const filePath = - (file as globalThis.File & { path?: string }).path || file.name; - onAddAttachment({ id: filePath, label: file.name }); - } - onAttachFiles?.(fileArray); - } + const handleFileSelect = async (e: React.ChangeEvent) => { + const files = e.target.files ? Array.from(e.target.files) : []; if (fileInputRef.current) { fileInputRef.current.value = ""; } + + if (files.length === 0) { + return; + } + + try { + const attachments = await Promise.all( + files.map(async (file) => { + const filePath = (file as globalThis.File & { path?: string }).path; + if (filePath) { + return { id: filePath, label: file.name } satisfies FileAttachment; + } + + return await persistBrowserFile(file); + }), + ); + + for (const attachment of attachments) { + if (attachment) { + onAddAttachment(attachment); + } + } + + onAttachFiles?.(files); + } catch (error) { + toast.error( + error instanceof Error + ? error.message + : "Unable to attach selected files from this picker", + ); + } }; const handleOpenChange = (isOpen: boolean) => { @@ -77,8 +101,21 @@ export function AttachmentMenu({ } }; - const handleAddFile = () => { + const handleAddFile = async () => { setOpen(false); + + try { + const filePaths = await trpcClient.os.selectFiles.query(); + if (filePaths.length > 0) { + for (const filePath of filePaths) { + onAddAttachment({ id: filePath, label: getFileName(filePath) }); + } + } + return; + } catch { + // Fall back to the input element for non-Electron environments. + } + fileInputRef.current?.click(); }; @@ -112,18 +149,17 @@ export function AttachmentMenu({ style={{ display: "none" }} /> - - - - - - - + + + + + {view === "menu" ? (
@@ -138,9 +174,7 @@ export function AttachmentMenu({ Add file {issueDisabledReason ? ( - - {issueButton} - + {issueButton} ) : ( issueButton )} diff --git a/apps/code/src/renderer/features/message-editor/tiptap/useDraftSync.test.tsx b/apps/code/src/renderer/features/message-editor/tiptap/useDraftSync.test.tsx new file mode 100644 index 000000000..133365e52 --- /dev/null +++ b/apps/code/src/renderer/features/message-editor/tiptap/useDraftSync.test.tsx @@ -0,0 +1,63 @@ +import { act, render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@utils/electronStorage", () => ({ + electronStorage: { + getItem: () => null, + setItem: () => {}, + removeItem: () => {}, + }, +})); + +import { useDraftStore } from "../stores/draftStore"; +import { useDraftSync } from "./useDraftSync"; + +function DraftAttachmentsProbe({ sessionId }: { sessionId: string }) { + const { restoredAttachments } = useDraftSync(null, sessionId); + return ( +
+ {restoredAttachments.map((att) => att.label).join(",") || "empty"} +
+ ); +} + +describe("useDraftSync", () => { + beforeEach(() => { + vi.clearAllMocks(); + useDraftStore.setState((state) => ({ + ...state, + drafts: {}, + contexts: {}, + commands: {}, + focusRequested: {}, + pendingContent: {}, + _hasHydrated: true, + })); + }); + + it("clears restored attachments when a draft no longer has attachments", () => { + const { rerender } = render( + , + ); + + act(() => { + useDraftStore.getState().actions.setDraft("session-1", { + segments: [{ type: "text", text: "hello" }], + attachments: [{ id: "/tmp/file.txt", label: "file.txt" }], + }); + }); + + expect(screen.getByText("file.txt")).toBeInTheDocument(); + + act(() => { + useDraftStore.getState().actions.setDraft("session-1", { + segments: [{ type: "text", text: "hello" }], + }); + }); + + expect(screen.getByText("empty")).toBeInTheDocument(); + + rerender(); + expect(screen.getByText("empty")).toBeInTheDocument(); + }); +}); diff --git a/apps/code/src/renderer/features/message-editor/tiptap/useDraftSync.ts b/apps/code/src/renderer/features/message-editor/tiptap/useDraftSync.ts index 340fb19d3..eefe4e26a 100644 --- a/apps/code/src/renderer/features/message-editor/tiptap/useDraftSync.ts +++ b/apps/code/src/renderer/features/message-editor/tiptap/useDraftSync.ts @@ -171,9 +171,12 @@ export function useDraftSync( >([]); useLayoutEffect(() => { if (!draft || typeof draft === "string") return; - if (draft.attachments && draft.attachments.length > 0) { - setRestoredAttachments(draft.attachments); - } + const incoming = draft.attachments ?? []; + // Short-circuit the common empty→empty case to avoid creating a new array + // reference that would trigger unnecessary re-renders. + setRestoredAttachments((prev) => + prev.length === 0 && incoming.length === 0 ? prev : incoming, + ); }, [draft]); const attachmentsRef = useRef([]); diff --git a/apps/code/src/renderer/features/message-editor/tiptap/useTiptapEditor.ts b/apps/code/src/renderer/features/message-editor/tiptap/useTiptapEditor.ts index 85165613e..0a4882677 100644 --- a/apps/code/src/renderer/features/message-editor/tiptap/useTiptapEditor.ts +++ b/apps/code/src/renderer/features/message-editor/tiptap/useTiptapEditor.ts @@ -1,6 +1,5 @@ import { sessionStoreSetters } from "@features/sessions/stores/sessionStore"; import { useSettingsStore as useFeatureSettingsStore } from "@features/settings/stores/settingsStore"; -import { trpcClient } from "@renderer/trpc/client"; import { toast } from "@renderer/utils/toast"; import { useSettingsStore } from "@stores/settingsStore"; import type { EditorView } from "@tiptap/pm/view"; @@ -10,6 +9,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { usePromptHistoryStore } from "../stores/promptHistoryStore"; import type { FileAttachment, MentionChip } from "../utils/content"; import { contentToXml, isContentEmpty } from "../utils/content"; +import { persistImageFile, persistTextContent } from "../utils/persistFile"; import { getEditorExtensions } from "./extensions"; import { type DraftContext, useDraftSync } from "./useDraftSync"; @@ -45,7 +45,7 @@ async function pasteTextAsFile( text: string, pasteCountRef: React.MutableRefObject, ): Promise { - const result = await trpcClient.os.saveClipboardText.mutate({ text }); + const result = await persistTextContent(text); pasteCountRef.current += 1; const lineCount = text.split("\n").length; const label = `Pasted text #${pasteCountRef.current} (${lineCount} lines)`; @@ -331,19 +331,7 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) { if (!file) continue; try { - const arrayBuffer = await file.arrayBuffer(); - const base64 = btoa( - new Uint8Array(arrayBuffer).reduce( - (data, byte) => data + String.fromCharCode(byte), - "", - ), - ); - - const result = await trpcClient.os.saveClipboardImage.mutate({ - base64Data: base64, - mimeType: file.type, - originalName: file.name, - }); + const result = await persistImageFile(file); setAttachments((prev) => { if (prev.some((a) => a.id === result.path)) return prev; @@ -448,9 +436,7 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) { // Restore attachments from draft on mount useEffect(() => { - if (draft.restoredAttachments.length > 0) { - setAttachments(draft.restoredAttachments); - } + setAttachments(draft.restoredAttachments); // Only run on mount / session change // eslint-disable-next-line react-hooks/exhaustive-deps }, [draft.restoredAttachments]); diff --git a/apps/code/src/renderer/features/message-editor/utils/content.ts b/apps/code/src/renderer/features/message-editor/utils/content.ts index 038c024aa..96d87b028 100644 --- a/apps/code/src/renderer/features/message-editor/utils/content.ts +++ b/apps/code/src/renderer/features/message-editor/utils/content.ts @@ -1,3 +1,5 @@ +import { escapeXmlAttr } from "@utils/xml"; + export interface MentionChip { type: | "file" @@ -35,14 +37,6 @@ export function contentToPlainText(content: EditorContent): string { .join(""); } -function escapeXmlAttr(value: string): string { - return value - .replace(/&/g, "&") - .replace(/"/g, """) - .replace(//g, ">"); -} - export function contentToXml(content: EditorContent): string { const inlineFilePaths = new Set(); const parts = content.segments.map((seg) => { diff --git a/apps/code/src/renderer/features/message-editor/utils/persistFile.test.ts b/apps/code/src/renderer/features/message-editor/utils/persistFile.test.ts new file mode 100644 index 000000000..74a121742 --- /dev/null +++ b/apps/code/src/renderer/features/message-editor/utils/persistFile.test.ts @@ -0,0 +1,126 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockSaveClipboardImage = vi.hoisted(() => vi.fn()); +const mockSaveClipboardText = vi.hoisted(() => vi.fn()); + +vi.mock("@renderer/trpc/client", () => ({ + trpcClient: { + os: { + saveClipboardImage: { + mutate: mockSaveClipboardImage, + }, + saveClipboardText: { + mutate: mockSaveClipboardText, + }, + }, + }, +})); + +vi.mock("@features/code-editor/utils/imageUtils", () => ({ + getImageMimeType: () => "image/png", +})); + +import { + persistBrowserFile, + persistImageFile, + persistTextContent, +} from "./persistFile"; + +describe("persistFile", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("passes original text filenames through clipboard persistence", async () => { + mockSaveClipboardText.mockResolvedValue({ + path: "/tmp/posthog-code-clipboard/attachment-123/notes.md", + name: "notes.md", + }); + + const result = await persistTextContent("# hello", "notes.md"); + + expect(mockSaveClipboardText).toHaveBeenCalledWith({ + text: "# hello", + originalName: "notes.md", + }); + expect(result).toEqual({ + path: "/tmp/posthog-code-clipboard/attachment-123/notes.md", + name: "notes.md", + }); + }); + + it("persists image files via saveClipboardImage", async () => { + mockSaveClipboardImage.mockResolvedValue({ + path: "/tmp/posthog-code-clipboard/attachment-789/photo.png", + name: "photo.png", + mimeType: "image/png", + }); + + const file = { + name: "photo.png", + type: "image/png", + arrayBuffer: vi.fn().mockResolvedValue(new ArrayBuffer(8)), + } as unknown as File; + + const result = await persistImageFile(file); + + expect(mockSaveClipboardImage).toHaveBeenCalledWith( + expect.objectContaining({ + mimeType: "image/png", + originalName: "photo.png", + }), + ); + expect(result).toEqual({ + path: "/tmp/posthog-code-clipboard/attachment-789/photo.png", + name: "photo.png", + mimeType: "image/png", + }); + }); + + it("routes image files through persistBrowserFile", async () => { + mockSaveClipboardImage.mockResolvedValue({ + path: "/tmp/posthog-code-clipboard/attachment-abc/img.png", + name: "img.png", + mimeType: "image/png", + }); + + const file = { + name: "img.png", + type: "image/png", + arrayBuffer: vi.fn().mockResolvedValue(new ArrayBuffer(8)), + } as unknown as File; + + const result = await persistBrowserFile(file); + + expect(result).toEqual({ + id: "/tmp/posthog-code-clipboard/attachment-abc/img.png", + label: "img.png", + }); + }); + + it("throws for unsupported file types", async () => { + const file = { name: "archive.zip" } as unknown as File; + await expect(persistBrowserFile(file)).rejects.toThrow(/Unsupported/); + }); + + it("returns the preserved filename for browser-selected text files", async () => { + mockSaveClipboardText.mockResolvedValue({ + path: "/tmp/posthog-code-clipboard/attachment-456/config.json", + name: "config.json", + }); + + const file = { + name: "config.json", + text: vi.fn().mockResolvedValue('{"ok":true}'), + } as unknown as File; + + await expect(persistBrowserFile(file)).resolves.toEqual({ + id: "/tmp/posthog-code-clipboard/attachment-456/config.json", + label: "config.json", + }); + expect(mockSaveClipboardText).toHaveBeenCalledWith({ + text: '{"ok":true}', + originalName: "config.json", + }); + }); +}); diff --git a/apps/code/src/renderer/features/message-editor/utils/persistFile.ts b/apps/code/src/renderer/features/message-editor/utils/persistFile.ts new file mode 100644 index 000000000..e13ee77a9 --- /dev/null +++ b/apps/code/src/renderer/features/message-editor/utils/persistFile.ts @@ -0,0 +1,66 @@ +import { getImageMimeType } from "@features/code-editor/utils/imageUtils"; +import { + isSupportedCloudImageAttachment, + isSupportedCloudTextAttachment, +} from "@features/editor/utils/cloud-prompt"; +import { trpcClient } from "@renderer/trpc/client"; + +const CHUNK_SIZE = 8192; + +function arrayBufferToBase64(buffer: ArrayBuffer): string { + const bytes = new Uint8Array(buffer); + const chunks: string[] = []; + for (let i = 0; i < bytes.length; i += CHUNK_SIZE) { + chunks.push(String.fromCharCode(...bytes.subarray(i, i + CHUNK_SIZE))); + } + return btoa(chunks.join("")); +} + +export interface PersistedFile { + path: string; + name: string; + mimeType?: string; +} + +export async function persistImageFile(file: File): Promise { + const arrayBuffer = await file.arrayBuffer(); + const base64Data = arrayBufferToBase64(arrayBuffer); + const mimeType = file.type || getImageMimeType(file.name); + + const result = await trpcClient.os.saveClipboardImage.mutate({ + base64Data, + mimeType, + originalName: file.name, + }); + return { path: result.path, name: result.name, mimeType: result.mimeType }; +} + +export async function persistTextContent( + text: string, + originalName?: string, +): Promise { + const result = await trpcClient.os.saveClipboardText.mutate({ + text, + originalName, + }); + return { path: result.path, name: result.name }; +} + +export async function persistBrowserFile( + file: File, +): Promise<{ id: string; label: string }> { + if (isSupportedCloudImageAttachment(file.name)) { + const result = await persistImageFile(file); + return { id: result.path, label: file.name }; + } + + if (isSupportedCloudTextAttachment(file.name)) { + const text = await file.text(); + const result = await persistTextContent(text, file.name); + return { id: result.path, label: result.name }; + } + + throw new Error( + `Unsupported attachment: ${file.name}. Cloud attachments currently support text files and PNG/JPG/GIF/WebP images.`, + ); +} diff --git a/apps/code/src/renderer/features/sessions/components/ConversationView.tsx b/apps/code/src/renderer/features/sessions/components/ConversationView.tsx index 91e6af776..b5bacb4dc 100644 --- a/apps/code/src/renderer/features/sessions/components/ConversationView.tsx +++ b/apps/code/src/renderer/features/sessions/components/ConversationView.tsx @@ -146,6 +146,7 @@ export function ConversationView({ return ( { + it("extracts cloud prompt attachments into user messages", () => { + const uri = makeAttachmentUri("/tmp/hello world.txt"); + + const events: AcpMessage[] = [ + { + type: "acp_message", + ts: 1, + message: { + jsonrpc: "2.0", + id: 1, + method: "session/prompt", + params: { + prompt: [ + { type: "text", text: "read this file" }, + { + type: "resource", + resource: { + uri, + text: "watup", + mimeType: "text/plain", + }, + }, + ], + }, + }, + }, + ]; + + const result = buildConversationItems(events, null); + + expect(result.items).toEqual([ + { + type: "user_message", + id: "turn-1-1-user", + content: "read this file", + timestamp: 1, + attachments: [ + { + id: uri, + label: "hello world.txt", + }, + ], + }, + ]); + }); + + it("keeps attachment-only prompts visible", () => { + const uri = makeAttachmentUri("/tmp/test.txt"); + + const events: AcpMessage[] = [ + { + type: "acp_message", + ts: 1, + message: { + jsonrpc: "2.0", + id: 1, + method: "session/prompt", + params: { + prompt: [ + { + type: "resource", + resource: { + uri, + text: "watup", + mimeType: "text/plain", + }, + }, + ], + }, + }, + }, + ]; + + const result = buildConversationItems(events, null); + + expect(result.items).toEqual([ + { + type: "user_message", + id: "turn-1-1-user", + content: "", + timestamp: 1, + attachments: [ + { + id: uri, + label: "test.txt", + }, + ], + }, + ]); + }); +}); diff --git a/apps/code/src/renderer/features/sessions/components/buildConversationItems.ts b/apps/code/src/renderer/features/sessions/components/buildConversationItems.ts index 7c0fa9a4b..b1f6f0867 100644 --- a/apps/code/src/renderer/features/sessions/components/buildConversationItems.ts +++ b/apps/code/src/renderer/features/sessions/components/buildConversationItems.ts @@ -11,8 +11,10 @@ import { isJsonRpcResponse, type UserShellExecuteParams, } from "@shared/types/session-events"; +import { extractPromptDisplayContent } from "@utils/promptContent"; import { type GitActionType, parseGitActionMessage } from "./GitActionMessage"; import type { RenderItem } from "./session-update/SessionUpdateView"; +import type { UserMessageAttachment } from "./session-update/UserMessage"; import type { UserShellExecute } from "./session-update/UserShellExecuteView"; export interface TurnContext { @@ -23,7 +25,13 @@ export interface TurnContext { } export type ConversationItem = - | { type: "user_message"; id: string; content: string; timestamp: number } + | { + type: "user_message"; + id: string; + content: string; + timestamp: number; + attachments?: UserMessageAttachment[]; + } | { type: "git_action"; id: string; actionType: GitActionType } | { type: "session_update"; @@ -196,9 +204,12 @@ function handlePromptRequest( b.currentTurn.context.turnComplete = true; } - const userContent = extractUserContent(msg.params); + const userPrompt = extractUserPrompt(msg.params); + const userContent = userPrompt.content; - if (userContent.trim().length === 0) return; + if (userContent.trim().length === 0 && userPrompt.attachments.length === 0) { + return; + } const turnId = `turn-${ts}-${msg.id}`; const toolCalls = new Map(); @@ -237,6 +248,7 @@ function handlePromptRequest( id: `${turnId}-user`, content: userContent, timestamp: ts, + attachments: userPrompt.attachments, }); } } @@ -411,23 +423,19 @@ function ensureImplicitTurn(b: ItemBuilder, ts: number) { }; } -interface TextBlockWithMeta { - type: "text"; - text: string; - _meta?: { ui?: { hidden?: boolean } }; -} - -function extractUserContent(params: unknown): string { +function extractUserPrompt(params: unknown): { + content: string; + attachments: UserMessageAttachment[]; +} { const p = params as { prompt?: ContentBlock[] }; - if (!p?.prompt?.length) return ""; + if (!p?.prompt?.length) { + return { content: "", attachments: [] }; + } - const visibleTextBlocks = p.prompt.filter((b): b is TextBlockWithMeta => { - if (b.type !== "text") return false; - const meta = (b as TextBlockWithMeta)._meta; - return !meta?.ui?.hidden; + const { text, attachments } = extractPromptDisplayContent(p.prompt, { + filterHidden: true, }); - - return visibleTextBlocks.map((b) => b.text).join(""); + return { content: text, attachments }; } function getParentToolCallId(update: SessionUpdate): string | undefined { diff --git a/apps/code/src/renderer/features/sessions/components/session-update/UserMessage.test.tsx b/apps/code/src/renderer/features/sessions/components/session-update/UserMessage.test.tsx new file mode 100644 index 000000000..a1ac9cd60 --- /dev/null +++ b/apps/code/src/renderer/features/sessions/components/session-update/UserMessage.test.tsx @@ -0,0 +1,24 @@ +import { Theme } from "@radix-ui/themes"; +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { UserMessage } from "./UserMessage"; + +describe("UserMessage", () => { + it("renders attachment chips for cloud prompts", () => { + render( + + + , + ); + + expect(screen.getByText("read this file")).toBeInTheDocument(); + expect(screen.getByText("test.txt")).toBeInTheDocument(); + expect(screen.getByText("notes.md")).toBeInTheDocument(); + }); +}); diff --git a/apps/code/src/renderer/features/sessions/components/session-update/UserMessage.tsx b/apps/code/src/renderer/features/sessions/components/session-update/UserMessage.tsx index 41df0951c..403cdbc8e 100644 --- a/apps/code/src/renderer/features/sessions/components/session-update/UserMessage.tsx +++ b/apps/code/src/renderer/features/sessions/components/session-update/UserMessage.tsx @@ -5,18 +5,29 @@ import { CaretUp, Check, Copy, + File, SlackLogo, } from "@phosphor-icons/react"; -import { Box, IconButton } from "@radix-ui/themes"; +import { Box, Flex, IconButton } from "@radix-ui/themes"; import { useCallback, useEffect, useRef, useState } from "react"; -import { hasFileMentions, parseFileMentions } from "./parseFileMentions"; +import { + hasFileMentions, + MentionChip, + parseFileMentions, +} from "./parseFileMentions"; const COLLAPSED_MAX_HEIGHT = 160; +export interface UserMessageAttachment { + id: string; + label: string; +} + interface UserMessageProps { content: string; timestamp?: number; sourceUrl?: string; + attachments?: UserMessageAttachment[]; } function formatTimestamp(ts: number): string { @@ -34,8 +45,10 @@ export function UserMessage({ content, timestamp, sourceUrl, + attachments = [], }: UserMessageProps) { const containsFileMentions = hasFileMentions(content); + const showAttachmentChips = attachments.length > 0 && !containsFileMentions; const [copied, setCopied] = useState(false); const [isExpanded, setIsExpanded] = useState(false); const [isOverflowing, setIsOverflowing] = useState(false); @@ -73,6 +86,17 @@ export function UserMessage({ ) : ( )} + {showAttachmentChips && ( + + {attachments.map((attachment) => ( + } + label={attachment.label} + /> + ))} + + )} {!isExpanded && isOverflowing && ( ({ writeLocalLogs: { mutate: vi.fn() }, })); +const mockTrpcCloudTask = vi.hoisted(() => ({ + sendCommand: { mutate: vi.fn() }, +})); + vi.mock("@renderer/trpc/client", () => ({ trpcClient: { agent: mockTrpcAgent, workspace: mockTrpcWorkspace, logs: mockTrpcLogs, + cloudTask: mockTrpcCloudTask, }, })); @@ -567,6 +573,50 @@ describe("SessionService", () => { }); }); + it("serializes structured prompts before sending cloud follow-ups", async () => { + const service = getSessionService(); + mockSessionStoreSetters.getSessionByTaskId.mockReturnValue( + createMockSession({ + isCloud: true, + cloudStatus: "in_progress", + }), + ); + mockTrpcCloudTask.sendCommand.mutate.mockResolvedValue({ + success: true, + result: { stopReason: "end_turn" }, + }); + + const prompt: ContentBlock[] = [ + { type: "text", text: "read this" }, + { + type: "resource", + resource: { + uri: "attachment://test.txt", + text: "hello from file", + mimeType: "text/plain", + }, + }, + ]; + + const result = await service.sendPrompt("task-123", prompt); + + expect(result.stopReason).toBe("end_turn"); + expect(mockTrpcCloudTask.sendCommand.mutate).toHaveBeenCalledTimes(1); + + const [args] = mockTrpcCloudTask.sendCommand.mutate.mock.calls[0] as [ + { + params?: { content?: unknown }; + }, + ]; + + expect(args.params?.content).toEqual( + expect.stringContaining("__twig_cloud_prompt_v1__:"), + ); + expect(args.params?.content).toEqual( + expect.stringContaining('"type":"resource"'), + ); + }); + it("sets session to error state on fatal error", async () => { const service = getSessionService(); const mockSession = createMockSession(); diff --git a/apps/code/src/renderer/features/sessions/service/service.ts b/apps/code/src/renderer/features/sessions/service/service.ts index 4d97a7868..f11f1dd81 100644 --- a/apps/code/src/renderer/features/sessions/service/service.ts +++ b/apps/code/src/renderer/features/sessions/service/service.ts @@ -8,6 +8,11 @@ import { getAuthenticatedClient, } from "@features/auth/hooks/authClient"; import { fetchAuthState } from "@features/auth/hooks/authQueries"; +import { + buildCloudPromptBlocks, + buildCloudTaskDescription, + serializeCloudPrompt, +} from "@features/editor/utils/cloud-prompt"; import { useSessionAdapterStore } from "@features/sessions/stores/sessionAdapterStore"; import { getPersistedConfigOptions, @@ -60,6 +65,7 @@ import { } from "@utils/session"; const log = logger.scope("session-service"); +const TERMINAL_CLOUD_STATUSES = new Set(["completed", "failed", "cancelled"]); interface AuthCredentials { apiHost: string; @@ -1109,23 +1115,42 @@ export class SessionService { // --- Cloud Commands --- + private async prepareCloudPrompt( + prompt: string | ContentBlock[], + ): Promise<{ blocks: ContentBlock[]; promptText: string }> { + const blocks = + typeof prompt === "string" + ? await buildCloudPromptBlocks(prompt) + : prompt; + + if (blocks.length === 0) { + throw new Error("Cloud prompt cannot be empty"); + } + + const promptText = + extractPromptText(blocks).trim() || + (typeof prompt === "string" ? buildCloudTaskDescription(prompt) : ""); + + return { blocks, promptText }; + } + private async sendCloudPrompt( session: AgentSession, prompt: string | ContentBlock[], options?: { skipQueueGuard?: boolean }, ): Promise<{ stopReason: string }> { - const promptText = extractPromptText(prompt); - if (!promptText.trim()) { - return { stopReason: "empty" }; - } - - const terminalStatuses = new Set(["completed", "failed", "cancelled"]); - if (session.cloudStatus && terminalStatuses.has(session.cloudStatus)) { - return this.resumeCloudRun(session, promptText); + if ( + session.cloudStatus && + TERMINAL_CLOUD_STATUSES.has(session.cloudStatus) + ) { + return this.resumeCloudRun(session, prompt); } if (!options?.skipQueueGuard && session.isPromptPending) { - sessionStoreSetters.enqueueMessage(session.taskId, promptText); + sessionStoreSetters.enqueueMessage( + session.taskId, + typeof prompt === "string" ? prompt : extractPromptText(prompt), + ); log.info("Cloud message queued", { taskId: session.taskId, queueLength: session.messageQueue.length + 1, @@ -1138,6 +1163,8 @@ export class SessionService { throw new Error("Authentication required for cloud commands"); } + const { blocks, promptText } = await this.prepareCloudPrompt(prompt); + sessionStoreSetters.updateSession(session.taskRunId, { isPromptPending: true, }); @@ -1156,7 +1183,11 @@ export class SessionService { apiHost: auth.apiHost, teamId: auth.teamId, method: "user_message", - params: { content: promptText }, + params: { + // The live /command API still validates user_message content as a + // string, so structured prompts must go through the serialized form. + content: serializeCloudPrompt(blocks), + }, }); sessionStoreSetters.updateSession(session.taskRunId, { @@ -1262,13 +1293,15 @@ export class SessionService { private async resumeCloudRun( session: AgentSession, - promptText: string, + prompt: string | ContentBlock[], ): Promise<{ stopReason: string }> { const client = await getAuthenticatedClient(); if (!client) { throw new Error("Authentication required for cloud commands"); } + const { blocks, promptText } = await this.prepareCloudPrompt(prompt); + log.info("Creating resume run for terminal cloud task", { taskId: session.taskId, previousRunId: session.taskRunId, @@ -1283,7 +1316,7 @@ export class SessionService { session.cloudBranch, { resumeFromRunId: session.taskRunId, - pendingUserMessage: promptText, + pendingUserMessage: serializeCloudPrompt(blocks), }, ); const newRun = updatedTask.latest_run; @@ -1332,8 +1365,10 @@ export class SessionService { } private async cancelCloudPrompt(session: AgentSession): Promise { - const terminalStatuses = new Set(["completed", "failed", "cancelled"]); - if (session.cloudStatus && terminalStatuses.has(session.cloudStatus)) { + if ( + session.cloudStatus && + TERMINAL_CLOUD_STATUSES.has(session.cloudStatus) + ) { log.info("Skipping cancel for terminal cloud run", { taskId: session.taskId, status: session.cloudStatus, @@ -1989,8 +2024,7 @@ export class SessionService { } } - const terminalStatuses = new Set(["completed", "failed", "cancelled"]); - if (update.status && terminalStatuses.has(update.status)) { + if (update.status && TERMINAL_CLOUD_STATUSES.has(update.status)) { // Clean up any pending resume messages that couldn't be sent const session = sessionStoreSetters.getSessions()[taskRunId]; if ( diff --git a/apps/code/src/renderer/features/task-detail/components/TaskInputEditor.tsx b/apps/code/src/renderer/features/task-detail/components/TaskInputEditor.tsx index a38049949..2b3dcc7e6 100644 --- a/apps/code/src/renderer/features/task-detail/components/TaskInputEditor.tsx +++ b/apps/code/src/renderer/features/task-detail/components/TaskInputEditor.tsx @@ -12,7 +12,7 @@ import { UnifiedModelSelector } from "@features/sessions/components/UnifiedModel import type { AgentAdapter } from "@features/settings/stores/settingsStore"; import { useConnectivity } from "@hooks/useConnectivity"; import { ArrowUp } from "@phosphor-icons/react"; -import { Box, Flex, IconButton, Text, Tooltip } from "@radix-ui/themes"; +import { Box, Flex, IconButton, Text } from "@radix-ui/themes"; import { trpcClient } from "@renderer/trpc/client"; import { EditorContent } from "@tiptap/react"; import { forwardRef, useCallback, useEffect, useImperativeHandle } from "react"; @@ -270,30 +270,29 @@ export const TaskInputEditor = forwardRef< - - { - e.stopPropagation(); - onSubmit(); - }} - disabled={!canSubmit || isSubmitDisabled} - loading={isCreatingTask} - style={{ - backgroundColor: - !canSubmit || isSubmitDisabled - ? "var(--accent-a4)" - : undefined, - color: - !canSubmit || isSubmitDisabled - ? "var(--accent-8)" - : undefined, - }} - > - - - + { + e.stopPropagation(); + onSubmit(); + }} + disabled={!canSubmit || isSubmitDisabled} + loading={isCreatingTask} + style={{ + backgroundColor: + !canSubmit || isSubmitDisabled + ? "var(--accent-a4)" + : undefined, + color: + !canSubmit || isSubmitDisabled + ? "var(--accent-8)" + : undefined, + }} + > + + diff --git a/apps/code/src/renderer/features/task-detail/hooks/useTaskCreation.ts b/apps/code/src/renderer/features/task-detail/hooks/useTaskCreation.ts index cf8fba1aa..f508afef6 100644 --- a/apps/code/src/renderer/features/task-detail/hooks/useTaskCreation.ts +++ b/apps/code/src/renderer/features/task-detail/hooks/useTaskCreation.ts @@ -1,4 +1,5 @@ import { useAuthStateValue } from "@features/auth/hooks/authQueries"; +import { buildCloudTaskDescription } from "@features/editor/utils/cloud-prompt"; import type { MessageEditorHandle } from "@features/message-editor/components/MessageEditor"; import { useTaskInputHistoryStore } from "@features/message-editor/stores/taskInputHistoryStore"; import { @@ -59,9 +60,16 @@ function prepareTaskInput( sandboxEnvironmentId?: string; }, ): TaskCreationInput { + const serializedContent = contentToXml(content).trim(); + const filePaths = extractFilePaths(content); + return { - content: contentToXml(content).trim(), - filePaths: extractFilePaths(content), + content: serializedContent, + taskDescription: + options.workspaceMode === "cloud" + ? buildCloudTaskDescription(serializedContent, filePaths) + : undefined, + filePaths, repoPath: options.selectedDirectory, repository: options.selectedRepository, githubIntegrationId: options.githubIntegrationId, @@ -81,6 +89,7 @@ function getErrorTitle(failedStep: string): string { repo_detection: "Failed to detect repository", task_creation: "Failed to create task", workspace_creation: "Failed to create workspace", + cloud_prompt_preparation: "Failed to prepare cloud attachments", cloud_run: "Failed to start cloud execution", agent_session: "Failed to start agent session", }; diff --git a/apps/code/src/renderer/sagas/task/task-creation.test.ts b/apps/code/src/renderer/sagas/task/task-creation.test.ts index 878677d08..8479dbdee 100644 --- a/apps/code/src/renderer/sagas/task/task-creation.test.ts +++ b/apps/code/src/renderer/sagas/task/task-creation.test.ts @@ -4,6 +4,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const mockWorkspaceCreate = vi.hoisted(() => vi.fn()); const mockWorkspaceDelete = vi.hoisted(() => vi.fn()); const mockGetTaskDirectory = vi.hoisted(() => vi.fn()); +const mockReadAbsoluteFile = vi.hoisted(() => vi.fn()); +const mockReadFileAsBase64 = vi.hoisted(() => vi.fn()); vi.mock("@renderer/trpc", () => ({ trpcClient: { @@ -14,6 +16,15 @@ vi.mock("@renderer/trpc", () => ({ }, })); +vi.mock("@renderer/trpc/client", () => ({ + trpcClient: { + fs: { + readAbsoluteFile: { query: mockReadAbsoluteFile }, + readFileAsBase64: { query: mockReadFileAsBase64 }, + }, + }, +})); + vi.mock("@hooks/useRepositoryDirectory", () => ({ getTaskDirectory: mockGetTaskDirectory, })); @@ -100,6 +111,8 @@ describe("TaskCreationSaga", () => { mockWorkspaceCreate.mockResolvedValue(undefined); mockWorkspaceDelete.mockResolvedValue(undefined); mockGetTaskDirectory.mockResolvedValue(null); + mockReadAbsoluteFile.mockResolvedValue(null); + mockReadFileAsBase64.mockResolvedValue(null); }); it("waits for the cloud run response before surfacing the task", async () => { @@ -107,6 +120,7 @@ describe("TaskCreationSaga", () => { const startedTask = createTask({ latest_run: createRun() }); const createTaskMock = vi.fn().mockResolvedValue(createdTask); const runTaskInCloudMock = vi.fn().mockResolvedValue(startedTask); + const sendRunCommandMock = vi.fn(); const onTaskReady = vi.fn(); const saga = new TaskCreationSaga({ @@ -115,6 +129,7 @@ describe("TaskCreationSaga", () => { deleteTask: vi.fn(), getTask: vi.fn(), runTaskInCloud: runTaskInCloudMock, + sendRunCommand: sendRunCommandMock, updateTask: vi.fn(), } as never, onTaskReady, @@ -135,9 +150,12 @@ describe("TaskCreationSaga", () => { expect(runTaskInCloudMock).toHaveBeenCalledWith( "task-123", "release/remembered-branch", - undefined, - undefined, + { + pendingUserMessage: "Ship the fix", + sandboxEnvironmentId: undefined, + }, ); + expect(sendRunCommandMock).not.toHaveBeenCalled(); expect(onTaskReady).toHaveBeenCalledTimes(1); expect(onTaskReady.mock.calls[0][0].task.latest_run?.branch).toBe( "release/remembered-branch", @@ -149,4 +167,61 @@ describe("TaskCreationSaga", () => { onTaskReady.mock.invocationCallOrder[0], ); }); + + it("sends initial cloud prompts with attachments as pending user messages", async () => { + const createdTask = createTask(); + const startedTask = createTask({ latest_run: createRun() }); + const createTaskMock = vi.fn().mockResolvedValue(createdTask); + const runTaskInCloudMock = vi.fn().mockResolvedValue(startedTask); + const sendRunCommandMock = vi.fn(); + const onTaskReady = vi.fn(); + + mockReadAbsoluteFile.mockResolvedValue("hello from attachment"); + + const saga = new TaskCreationSaga({ + posthogClient: { + createTask: createTaskMock, + deleteTask: vi.fn(), + getTask: vi.fn(), + runTaskInCloud: runTaskInCloudMock, + sendRunCommand: sendRunCommandMock, + updateTask: vi.fn(), + } as never, + onTaskReady, + }); + + const result = await saga.run({ + content: 'read this file ', + taskDescription: "read this file\n\nAttached files: test.txt", + filePaths: ["/tmp/test.txt"], + repository: "posthog/posthog", + workspaceMode: "cloud", + branch: "release/remembered-branch", + }); + + expect(result.success).toBe(true); + if (!result.success) { + throw new Error("Expected task creation to succeed"); + } + + expect(createTaskMock).toHaveBeenCalledWith( + expect.objectContaining({ + description: "read this file\n\nAttached files: test.txt", + }), + ); + expect(runTaskInCloudMock).toHaveBeenCalledWith( + "task-123", + "release/remembered-branch", + { + pendingUserMessage: expect.stringContaining( + "__twig_cloud_prompt_v1__:", + ), + sandboxEnvironmentId: undefined, + }, + ); + expect(sendRunCommandMock).not.toHaveBeenCalled(); + expect(runTaskInCloudMock.mock.invocationCallOrder[0]).toBeLessThan( + onTaskReady.mock.invocationCallOrder[0], + ); + }); }); diff --git a/apps/code/src/renderer/sagas/task/task-creation.ts b/apps/code/src/renderer/sagas/task/task-creation.ts index 35b5c0427..d4835abdf 100644 --- a/apps/code/src/renderer/sagas/task/task-creation.ts +++ b/apps/code/src/renderer/sagas/task/task-creation.ts @@ -1,3 +1,7 @@ +import { + buildCloudPromptBlocks, + serializeCloudPrompt, +} from "@features/editor/utils/cloud-prompt"; import { buildPromptBlocks } from "@features/editor/utils/prompt-builder"; import { DEFAULT_PANEL_IDS } from "@features/panels/constants/panelConstants"; import { usePanelLayoutStore } from "@features/panels/store/panelLayoutStore"; @@ -61,6 +65,7 @@ export interface TaskCreationInput { taskId?: string; // For creating new task (required if no taskId) content?: string; + taskDescription?: string; filePaths?: string[]; repoPath?: string; repository?: string | null; @@ -99,6 +104,13 @@ export class TaskCreationSaga extends Saga< protected async execute( input: TaskCreationInput, ): Promise { + const initialCloudPrompt = + input.workspaceMode === "cloud" && !input.taskId && input.content + ? await this.readOnlyStep("cloud_prompt_preparation", () => + buildCloudPromptBlocks(input.content ?? "", input.filePaths), + ) + : null; + // Step 1: Get or create task // For new tasks, start folder registration in parallel with task creation // since folder_registration only needs repoPath (from input), not task.id @@ -116,7 +128,11 @@ export class TaskCreationSaga extends Saga< // Fire-and-forget: generate a proper LLM title for new tasks if (!taskId) { - generateTaskTitle(task.id, input.content ?? "", this.deps.posthogClient); + generateTaskTitle( + task.id, + input.taskDescription ?? input.content ?? "", + this.deps.posthogClient, + ); } const repoKey = getTaskRepository(task); @@ -260,12 +276,12 @@ export class TaskCreationSaga extends Saga< task = await this.step({ name: "cloud_run", execute: () => - this.deps.posthogClient.runTaskInCloud( - task.id, - branch, - undefined, - input.sandboxEnvironmentId, - ), + this.deps.posthogClient.runTaskInCloud(task.id, branch, { + pendingUserMessage: initialCloudPrompt + ? serializeCloudPrompt(initialCloudPrompt) + : undefined, + sandboxEnvironmentId: input.sandboxEnvironmentId, + }), rollback: async () => { log.info("Rolling back: cloud run (no-op)", { taskId: task.id }); }, @@ -390,7 +406,7 @@ export class TaskCreationSaga extends Saga< name: "task_creation", execute: async () => { const result = await this.deps.posthogClient.createTask({ - description: input.content ?? "", + description: input.taskDescription ?? input.content ?? "", repository: repository ?? undefined, github_integration: input.workspaceMode === "cloud" diff --git a/apps/code/src/renderer/utils/path.ts b/apps/code/src/renderer/utils/path.ts index a2e019886..ec9585f18 100644 --- a/apps/code/src/renderer/utils/path.ts +++ b/apps/code/src/renderer/utils/path.ts @@ -36,7 +36,13 @@ export function compactHomePath(text: string): string { .replace(/\/home\/[^/\s]+/g, "~"); } +export function getFileName(filePath: string): string { + const parts = filePath.split(/[\\/]/); + return parts[parts.length - 1] || filePath; +} + export function getFileExtension(filePath: string): string { - const parts = filePath.split("."); - return parts.length > 1 ? parts[parts.length - 1] : ""; + const name = getFileName(filePath); + const lastDot = name.lastIndexOf("."); + return lastDot >= 0 ? name.slice(lastDot + 1).toLowerCase() : ""; } diff --git a/apps/code/src/renderer/utils/promptContent.test.ts b/apps/code/src/renderer/utils/promptContent.test.ts new file mode 100644 index 000000000..1b8f3c5e5 --- /dev/null +++ b/apps/code/src/renderer/utils/promptContent.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import { + extractPromptDisplayContent, + makeAttachmentUri, + parseAttachmentUri, +} from "./promptContent"; + +describe("promptContent", () => { + it("builds unique attachment URIs for same-name files", () => { + const firstUri = makeAttachmentUri("/tmp/one/README.md"); + const secondUri = makeAttachmentUri("/tmp/two/README.md"); + + expect(firstUri).not.toBe(secondUri); + expect(parseAttachmentUri(firstUri)).toEqual({ + id: firstUri, + label: "README.md", + }); + expect(parseAttachmentUri(secondUri)).toEqual({ + id: secondUri, + label: "README.md", + }); + }); + + it("keeps duplicate file labels visible when attachment ids differ", () => { + const firstUri = makeAttachmentUri("/tmp/one/README.md"); + const secondUri = makeAttachmentUri("/tmp/two/README.md"); + + const result = extractPromptDisplayContent([ + { type: "text", text: "compare both" }, + { + type: "resource", + resource: { uri: firstUri, text: "first", mimeType: "text/markdown" }, + }, + { + type: "resource", + resource: { + uri: secondUri, + text: "second", + mimeType: "text/markdown", + }, + }, + ]); + + expect(result.text).toBe("compare both"); + expect(result.attachments).toEqual([ + { id: firstUri, label: "README.md" }, + { id: secondUri, label: "README.md" }, + ]); + }); +}); diff --git a/apps/code/src/renderer/utils/promptContent.ts b/apps/code/src/renderer/utils/promptContent.ts new file mode 100644 index 000000000..760df4a79 --- /dev/null +++ b/apps/code/src/renderer/utils/promptContent.ts @@ -0,0 +1,92 @@ +import type { ContentBlock } from "@agentclientprotocol/sdk"; +import { getFileName } from "@utils/path"; + +export const ATTACHMENT_URI_PREFIX = "attachment://"; + +function hashAttachmentPath(filePath: string): string { + let hash = 2166136261; + + for (let i = 0; i < filePath.length; i++) { + hash ^= filePath.charCodeAt(i); + hash = Math.imul(hash, 16777619); + } + + return (hash >>> 0).toString(36); +} + +export function makeAttachmentUri(filePath: string): string { + const label = encodeURIComponent(getFileName(filePath)); + const id = hashAttachmentPath(filePath); + return `${ATTACHMENT_URI_PREFIX}${id}?label=${label}`; +} + +export interface AttachmentRef { + id: string; + label: string; +} + +export function parseAttachmentUri(uri: string): AttachmentRef | null { + if (!uri.startsWith(ATTACHMENT_URI_PREFIX)) { + return null; + } + + const rawValue = uri.slice(ATTACHMENT_URI_PREFIX.length); + const queryStart = rawValue.indexOf("?"); + if (queryStart < 0) { + return null; + } + + const label = + decodeURIComponent( + new URLSearchParams(rawValue.slice(queryStart + 1)).get("label") ?? "", + ) || "attachment"; + + return { id: uri, label }; +} + +function getBlockAttachmentUri(block: ContentBlock): string | null { + if (block.type === "resource") { + return block.resource.uri ?? null; + } + + if (block.type === "image") { + return block.uri ?? null; + } + + return null; +} + +export interface PromptDisplayContent { + text: string; + attachments: AttachmentRef[]; +} + +export function extractPromptDisplayContent( + blocks: ContentBlock[], + options?: { filterHidden?: boolean }, +): PromptDisplayContent { + const filterHidden = options?.filterHidden ?? false; + + const textParts: string[] = []; + for (const block of blocks) { + if (block.type !== "text") continue; + if (filterHidden) { + const meta = (block as { _meta?: { ui?: { hidden?: boolean } } })._meta; + if (meta?.ui?.hidden) continue; + } + textParts.push(block.text); + } + + const seen = new Set(); + const attachments: AttachmentRef[] = []; + for (const block of blocks) { + const uri = getBlockAttachmentUri(block); + if (!uri || seen.has(uri)) continue; + const ref = parseAttachmentUri(uri); + if (!ref) continue; + seen.add(uri); + attachments.push(ref); + } + + return { text: textParts.join(""), attachments }; +} diff --git a/apps/code/src/renderer/utils/session.ts b/apps/code/src/renderer/utils/session.ts index ac88e390c..d1400dc75 100644 --- a/apps/code/src/renderer/utils/session.ts +++ b/apps/code/src/renderer/utils/session.ts @@ -18,6 +18,7 @@ import { isJsonRpcNotification, isJsonRpcRequest, } from "@shared/types/session-events"; +import { extractPromptDisplayContent } from "@utils/promptContent"; /** * Convert a stored log entry to an ACP message. @@ -197,16 +198,9 @@ export function extractUserPromptsFromEvents(events: AcpMessage[]): string[] { return prompts; } -/** - * Extract prompt text from ContentBlocks, filtering out hidden blocks. - */ export function extractPromptText(prompt: string | ContentBlock[]): string { if (typeof prompt === "string") return prompt; - - return (prompt as ContentBlock[]) - .filter((b) => b.type === "text") - .map((b) => (b as { text: string }).text) - .join(""); + return extractPromptDisplayContent(prompt).text; } /** diff --git a/apps/code/src/renderer/utils/xml.ts b/apps/code/src/renderer/utils/xml.ts new file mode 100644 index 000000000..7f54ebe41 --- /dev/null +++ b/apps/code/src/renderer/utils/xml.ts @@ -0,0 +1,17 @@ +export function escapeXmlAttr(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +export function unescapeXmlAttr(value: string): string { + return value + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/&/g, "&"); +} diff --git a/packages/agent/src/server/agent-server.test.ts b/packages/agent/src/server/agent-server.test.ts index 2b69f524c..3ba8943d7 100644 --- a/packages/agent/src/server/agent-server.test.ts +++ b/packages/agent/src/server/agent-server.test.ts @@ -252,6 +252,30 @@ describe("AgentServer HTTP Mode", () => { const body = await response.json(); expect(body.error).toBe("No active session for this run"); }); + + it("accepts structured user_message content", async () => { + await createServer().start(); + const token = createToken({ run_id: "different-run-id" }); + + const response = await fetch(`http://localhost:${port}/command`, { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + jsonrpc: "2.0", + method: "user_message", + params: { + content: [{ type: "text", text: "test" }], + }, + }), + }); + + expect(response.status).toBe(400); + const body = await response.json(); + expect(body.error).toBe("No active session for this run"); + }); }); describe("404 handling", () => { diff --git a/packages/agent/src/server/agent-server.ts b/packages/agent/src/server/agent-server.ts index 8f8fa25fe..2d14f84e0 100644 --- a/packages/agent/src/server/agent-server.ts +++ b/packages/agent/src/server/agent-server.ts @@ -1,3 +1,4 @@ +import type { ContentBlock } from "@agentclientprotocol/sdk"; import { ClientSideConnection, ndJsonStream, @@ -30,6 +31,11 @@ import type { import { AsyncMutex } from "../utils/async-mutex"; import { getLlmGatewayUrl } from "../utils/gateway"; import { Logger } from "../utils/logger"; +import { + deserializeCloudPrompt, + normalizeCloudPromptContent, + promptBlocksToText, +} from "./cloud-prompt"; import { type JwtPayload, JwtValidationError, validateJwt } from "./jwt"; import { jsonRpcRequestSchema, validateCommandParams } from "./schemas"; import type { AgentServerConfig } from "./types"; @@ -487,17 +493,20 @@ export class AgentServer { switch (method) { case POSTHOG_NOTIFICATIONS.USER_MESSAGE: case "user_message": { - const content = params.content as string; + const prompt = normalizeCloudPromptContent( + params.content as string | ContentBlock[], + ); + const promptPreview = promptBlocksToText(prompt); this.logger.info( - `Processing user message (detectedPrUrl=${this.detectedPrUrl ?? "none"}): ${content.substring(0, 100)}...`, + `Processing user message (detectedPrUrl=${this.detectedPrUrl ?? "none"}): ${promptPreview.substring(0, 100)}...`, ); this.session.logWriter.resetTurnMessages(this.session.payload.run_id); const result = await this.session.clientConnection.prompt({ sessionId: this.session.acpSessionId, - prompt: [{ type: "text", text: content }], + prompt, ...(this.detectedPrUrl && { _meta: { prContext: @@ -837,24 +846,33 @@ export class AgentServer { const initialPromptOverride = taskRun ? this.getInitialPromptOverride(taskRun) : null; - const initialPrompt = initialPromptOverride ?? task.description; + const pendingUserPrompt = this.getPendingUserPrompt(taskRun); + let initialPrompt: ContentBlock[] = []; + if (pendingUserPrompt?.length) { + initialPrompt = pendingUserPrompt; + } else if (initialPromptOverride) { + initialPrompt = [{ type: "text", text: initialPromptOverride }]; + } else if (task.description) { + initialPrompt = [{ type: "text", text: task.description }]; + } - if (!initialPrompt) { + if (initialPrompt.length === 0) { this.logger.warn("Task has no description, skipping initial message"); return; } this.logger.info("Sending initial task message", { taskId: payload.task_id, - descriptionLength: initialPrompt.length, + descriptionLength: promptBlocksToText(initialPrompt).length, usedInitialPromptOverride: !!initialPromptOverride, + usedPendingUserMessage: !!pendingUserPrompt?.length, }); this.session.logWriter.resetTurnMessages(payload.run_id); const result = await this.session.clientConnection.prompt({ sessionId: this.session.acpSessionId, - prompt: [{ type: "text", text: initialPrompt }], + prompt: initialPrompt, }); this.logger.info("Initial task message completed", { @@ -886,38 +904,49 @@ export class AgentServer { this.resumeState.conversation, ); - // Read the pending user message from TaskRun state (set by the workflow + // Read the pending user prompt from TaskRun state (set by the workflow // when the user sends a follow-up message that triggers a resume). - const pendingUserMessage = this.getPendingUserMessage(taskRun); + const pendingUserPrompt = this.getPendingUserPrompt(taskRun); const sandboxContext = this.resumeState.snapshotApplied ? `The workspace environment (all files, packages, and code changes) has been fully restored from where you left off.` : `The workspace files from the previous session were not restored (the file snapshot may have expired), so you are starting with a fresh environment. Your conversation history is fully preserved below.`; - let resumePrompt: string; - if (pendingUserMessage) { - // Include the pending message as the user's new question so the agent - // responds to it directly instead of the generic resume context. - resumePrompt = - `You are resuming a previous conversation. ${sandboxContext}\n\n` + - `Here is the conversation history from the previous session:\n\n` + - `${conversationSummary}\n\n` + - `The user has sent a new message:\n\n` + - `${pendingUserMessage}\n\n` + - `Respond to the user's new message above. You have full context from the previous session.`; + let resumePromptBlocks: ContentBlock[]; + if (pendingUserPrompt?.length) { + resumePromptBlocks = [ + { + type: "text", + text: + `You are resuming a previous conversation. ${sandboxContext}\n\n` + + `Here is the conversation history from the previous session:\n\n` + + `${conversationSummary}\n\n` + + `The user has sent a new message:\n\n`, + }, + ...pendingUserPrompt, + { + type: "text", + text: "\n\nRespond to the user's new message above. You have full context from the previous session.", + }, + ]; } else { - resumePrompt = - `You are resuming a previous conversation. ${sandboxContext}\n\n` + - `Here is the conversation history from the previous session:\n\n` + - `${conversationSummary}\n\n` + - `Continue from where you left off. The user is waiting for your response.`; + resumePromptBlocks = [ + { + type: "text", + text: + `You are resuming a previous conversation. ${sandboxContext}\n\n` + + `Here is the conversation history from the previous session:\n\n` + + `${conversationSummary}\n\n` + + `Continue from where you left off. The user is waiting for your response.`, + }, + ]; } this.logger.info("Sending resume message", { taskId: payload.task_id, conversationTurns: this.resumeState.conversation.length, - promptLength: resumePrompt.length, - hasPendingUserMessage: !!pendingUserMessage, + promptLength: promptBlocksToText(resumePromptBlocks).length, + hasPendingUserMessage: !!pendingUserPrompt?.length, snapshotApplied: this.resumeState.snapshotApplied, }); @@ -928,7 +957,7 @@ export class AgentServer { const result = await this.session.clientConnection.prompt({ sessionId: this.session.acpSessionId, - prompt: [{ type: "text", text: resumePrompt }], + prompt: resumePromptBlocks, }); this.logger.info("Resume message completed", { @@ -1013,7 +1042,7 @@ export class AgentServer { return trimmed.length > 0 ? trimmed : null; } - private getPendingUserMessage(taskRun: TaskRun | null): string | null { + private getPendingUserPrompt(taskRun: TaskRun | null): ContentBlock[] | null { if (!taskRun) return null; const state = taskRun.state as Record | undefined; const message = state?.pending_user_message; @@ -1021,8 +1050,8 @@ export class AgentServer { return null; } - const trimmed = message.trim(); - return trimmed.length > 0 ? trimmed : null; + const prompt = deserializeCloudPrompt(message); + return prompt.length > 0 ? prompt : null; } private getResumeRunId(taskRun: TaskRun | null): string | null { diff --git a/packages/agent/src/server/cloud-prompt.ts b/packages/agent/src/server/cloud-prompt.ts new file mode 100644 index 000000000..c85370dbc --- /dev/null +++ b/packages/agent/src/server/cloud-prompt.ts @@ -0,0 +1,13 @@ +import type { ContentBlock } from "@agentclientprotocol/sdk"; +import { deserializeCloudPrompt, promptBlocksToText } from "@posthog/shared"; + +export { deserializeCloudPrompt, promptBlocksToText }; + +export function normalizeCloudPromptContent( + content: string | ContentBlock[], +): ContentBlock[] { + if (typeof content === "string") { + return deserializeCloudPrompt(content); + } + return content; +} diff --git a/packages/agent/src/server/question-relay.test.ts b/packages/agent/src/server/question-relay.test.ts index bdac3891f..5f73abfd3 100644 --- a/packages/agent/src/server/question-relay.test.ts +++ b/packages/agent/src/server/question-relay.test.ts @@ -371,6 +371,53 @@ describe("Question relay", () => { }); describe("sendInitialTaskMessage prompt source", () => { + it("uses pending user prompt blocks when present", async () => { + vi.spyOn(server.posthogAPI, "getTask").mockResolvedValue({ + id: "test-task-id", + title: "t", + description: "original task description", + } as unknown as Task); + vi.spyOn(server.posthogAPI, "getTaskRun").mockResolvedValue({ + id: "test-run-id", + task: "test-task-id", + state: { + pending_user_message: + '__twig_cloud_prompt_v1__:{"blocks":[{"type":"text","text":"read this attachment"},{"type":"resource","resource":{"uri":"attachment://test.txt","text":"hello from file","mimeType":"text/plain"}}]}', + }, + } as unknown as TaskRun); + + const promptSpy = vi.fn().mockResolvedValue({ stopReason: "max_tokens" }); + server.session = { + payload: TEST_PAYLOAD, + acpSessionId: "acp-session", + clientConnection: { prompt: promptSpy }, + logWriter: { + flushAll: vi.fn().mockResolvedValue(undefined), + getFullAgentResponse: vi.fn().mockReturnValue(null), + resetTurnMessages: vi.fn(), + flush: vi.fn().mockResolvedValue(undefined), + isRegistered: vi.fn().mockReturnValue(true), + }, + }; + + await server.sendInitialTaskMessage(TEST_PAYLOAD); + + expect(promptSpy).toHaveBeenCalledWith({ + sessionId: "acp-session", + prompt: [ + { type: "text", text: "read this attachment" }, + { + type: "resource", + resource: { + uri: "attachment://test.txt", + text: "hello from file", + mimeType: "text/plain", + }, + }, + ], + }); + }); + it("uses run state initial_prompt_override when present", async () => { vi.spyOn(server.posthogAPI, "getTask").mockResolvedValue({ id: "test-task-id", diff --git a/packages/agent/src/server/schemas.test.ts b/packages/agent/src/server/schemas.test.ts index 87b977aed..30efddc9f 100644 --- a/packages/agent/src/server/schemas.test.ts +++ b/packages/agent/src/server/schemas.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { mcpServersSchema } from "./schemas"; +import { mcpServersSchema, validateCommandParams } from "./schemas"; describe("mcpServersSchema", () => { it("accepts a valid HTTP server", () => { @@ -115,3 +115,21 @@ describe("mcpServersSchema", () => { expect(result.success).toBe(false); }); }); + +describe("validateCommandParams", () => { + it("accepts structured user_message content arrays", () => { + const result = validateCommandParams("user_message", { + content: [{ type: "text", text: "hello" }], + }); + + expect(result.success).toBe(true); + }); + + it("rejects empty content array", () => { + const result = validateCommandParams("user_message", { + content: [], + }); + + expect(result.success).toBe(false); + }); +}); diff --git a/packages/agent/src/server/schemas.ts b/packages/agent/src/server/schemas.ts index 7eb4348a3..2f2528759 100644 --- a/packages/agent/src/server/schemas.ts +++ b/packages/agent/src/server/schemas.ts @@ -42,7 +42,10 @@ export const jsonRpcRequestSchema = z.object({ export type JsonRpcRequest = z.infer; export const userMessageParamsSchema = z.object({ - content: z.string().min(1, "Content is required"), + content: z.union([ + z.string().min(1, "Content is required"), + z.array(z.record(z.string(), z.unknown())).min(1, "Content is required"), + ]), }); export const commandParamsSchemas = { diff --git a/packages/shared/package.json b/packages/shared/package.json index df283e9c9..8a1ccfaca 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -16,6 +16,7 @@ "clean": "node ../../scripts/rimraf.mjs dist .turbo" }, "devDependencies": { + "@agentclientprotocol/sdk": "0.16.1", "tsup": "^8.5.1", "typescript": "^5.5.0" }, diff --git a/packages/shared/src/cloud-prompt.test.ts b/packages/shared/src/cloud-prompt.test.ts new file mode 100644 index 000000000..028e15666 --- /dev/null +++ b/packages/shared/src/cloud-prompt.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, it } from "vitest"; +import { + CLOUD_PROMPT_PREFIX, + deserializeCloudPrompt, + promptBlocksToText, + serializeCloudPrompt, +} from "./cloud-prompt"; + +describe("cloud-prompt", () => { + describe("serializeCloudPrompt", () => { + it("returns plain text for a single text block", () => { + const result = serializeCloudPrompt([ + { type: "text", text: " hello world " }, + ]); + expect(result).toBe("hello world"); + expect(result).not.toContain(CLOUD_PROMPT_PREFIX); + }); + + it("returns prefixed JSON for multi-block content", () => { + const blocks = [ + { type: "text" as const, text: "read this" }, + { + type: "resource" as const, + resource: { + uri: "attachment://test.txt", + text: "file contents", + mimeType: "text/plain", + }, + }, + ]; + const result = serializeCloudPrompt(blocks); + expect(result).toMatch( + new RegExp( + `^${CLOUD_PROMPT_PREFIX.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`, + ), + ); + const payload = JSON.parse(result.slice(CLOUD_PROMPT_PREFIX.length)); + expect(payload.blocks).toEqual(blocks); + }); + }); + + describe("deserializeCloudPrompt", () => { + it("round-trips with serializeCloudPrompt (text-only)", () => { + const original = [{ type: "text" as const, text: "hello" }]; + const serialized = serializeCloudPrompt(original); + const deserialized = deserializeCloudPrompt(serialized); + expect(deserialized).toEqual(original); + }); + + it("round-trips with serializeCloudPrompt (multi-block)", () => { + const original = [ + { type: "text" as const, text: "read this" }, + { + type: "resource" as const, + resource: { + uri: "attachment://test.txt", + text: "contents", + mimeType: "text/plain", + }, + }, + ]; + const serialized = serializeCloudPrompt(original); + const deserialized = deserializeCloudPrompt(serialized); + expect(deserialized).toEqual(original); + }); + + it("wraps plain string (no prefix) as a text block", () => { + const result = deserializeCloudPrompt("just a plain message"); + expect(result).toEqual([{ type: "text", text: "just a plain message" }]); + }); + + it("returns empty array for empty string", () => { + expect(deserializeCloudPrompt("")).toEqual([]); + expect(deserializeCloudPrompt(" ")).toEqual([]); + }); + + it("falls back to text block for malformed JSON after prefix", () => { + const malformed = `${CLOUD_PROMPT_PREFIX}{not valid json`; + const result = deserializeCloudPrompt(malformed); + expect(result).toEqual([{ type: "text", text: malformed }]); + }); + + it("falls back to text block for empty blocks array", () => { + const payload = `${CLOUD_PROMPT_PREFIX}${JSON.stringify({ blocks: [] })}`; + const result = deserializeCloudPrompt(payload); + expect(result).toEqual([{ type: "text", text: payload }]); + }); + }); + + describe("promptBlocksToText", () => { + it("extracts and joins text blocks", () => { + const result = promptBlocksToText([ + { type: "text", text: "hello " }, + { + type: "resource", + resource: { + uri: "attachment://f.txt", + text: "ignored", + mimeType: "text/plain", + }, + }, + { type: "text", text: "world" }, + ]); + expect(result).toBe("hello world"); + }); + + it("returns empty string for non-text blocks only", () => { + expect( + promptBlocksToText([ + { + type: "resource", + resource: { + uri: "attachment://f.txt", + text: "content", + mimeType: "text/plain", + }, + }, + ]), + ).toBe(""); + }); + + it("returns empty string for empty array", () => { + expect(promptBlocksToText([])).toBe(""); + }); + }); +}); diff --git a/packages/shared/src/cloud-prompt.ts b/packages/shared/src/cloud-prompt.ts new file mode 100644 index 000000000..c83d76a7b --- /dev/null +++ b/packages/shared/src/cloud-prompt.ts @@ -0,0 +1,49 @@ +import type { ContentBlock } from "@agentclientprotocol/sdk"; + +/** + * Wire format prefix for structured cloud prompts. + * Text-only prompts are sent as plain strings (no prefix) as an optimization. + * Multi-block prompts (text + attachments) are serialized as `PREFIX + JSON({ blocks })`. + */ +export const CLOUD_PROMPT_PREFIX = "__twig_cloud_prompt_v1__:"; + +export function serializeCloudPrompt(blocks: ContentBlock[]): string { + if (blocks.length === 1 && blocks[0].type === "text") { + return blocks[0].text.trim(); + } + + return `${CLOUD_PROMPT_PREFIX}${JSON.stringify({ blocks })}`; +} + +export function deserializeCloudPrompt(value: string): ContentBlock[] { + const trimmed = value.trim(); + if (!trimmed) { + return []; + } + + if (!trimmed.startsWith(CLOUD_PROMPT_PREFIX)) { + return [{ type: "text", text: trimmed }]; + } + + try { + const parsed = JSON.parse(trimmed.slice(CLOUD_PROMPT_PREFIX.length)) as { + blocks?: ContentBlock[]; + }; + + if (Array.isArray(parsed.blocks) && parsed.blocks.length > 0) { + return parsed.blocks; + } + } catch { + // Fall through to preserve the raw string if the payload is malformed. + } + + return [{ type: "text", text: trimmed }]; +} + +export function promptBlocksToText(blocks: ContentBlock[]): string { + return blocks + .filter((b): b is ContentBlock & { type: "text" } => b.type === "text") + .map((block) => block.text) + .join("") + .trim(); +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 8b278ac5a..c6206c314 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,3 +1,9 @@ +export { + CLOUD_PROMPT_PREFIX, + deserializeCloudPrompt, + promptBlocksToText, + serializeCloudPrompt, +} from "./cloud-prompt"; export { Saga, type SagaLogger, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 85471188f..d5af2ddbe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -731,6 +731,9 @@ importers: packages/shared: devDependencies: + '@agentclientprotocol/sdk': + specifier: 0.16.1 + version: 0.16.1(zod@4.3.6) tsup: specifier: ^8.5.1 version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2)