From 1f6632aeb1f114267f972f5ad839068e9c2bf2bb Mon Sep 17 00:00:00 2001 From: Alessandro Pogliaghi Date: Tue, 7 Apr 2026 15:06:45 +0100 Subject: [PATCH] chore(cloud-agent): extract shared utilities for cloud attachments --- .../code-review/hooks/useReviewComment.ts | 10 +- .../features/message-editor/utils/content.ts | 10 +- 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 +++ 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 + 12 files changed, 357 insertions(+), 27 deletions(-) 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/shared/src/cloud-prompt.test.ts create mode 100644 packages/shared/src/cloud-prompt.ts 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/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/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/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 c7821d297..7a53f3dc8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -767,6 +767,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)