Skip to content

Commit 7f42dc5

Browse files
authored
chore(cloud-agent): extract shared utilities for cloud attachments (#1514)
[ph_code_cloud_attachments.mp4 <span class="graphite__hidden">(uploaded via Graphite)</span> <img class="graphite__hidden" src="https://app.graphite.com/user-attachments/thumbnails/727aaffd-ea15-4c0d-b46a-4f328d7c2e11.mp4" />](https://app.graphite.com/user-attachments/video/727aaffd-ea15-4c0d-b46a-4f328d7c2e11.mp4) ## Problem Cloud attachments feature needs shared utilities across multiple modules ## Changes - Extract `escapeXmlAttr`/`unescapeXmlAttr` into `xml.ts` (was duplicated in 3 files) - Extract `getFileName`/`getFileExtension` - Extract `extractPromptDisplayContent`, `makeAttachmentUri`, `parseAttachmentUri` - Move cloud prompt codec (`serializeCloudPrompt`, `deserializeCloudPrompt`) into `@posthog/shared` - Fix mid-file import in `content.ts` ## How did you test this? Unit tests for cloud prompt codec and prompt content extraction
1 parent 416ccef commit 7f42dc5

File tree

12 files changed

+357
-27
lines changed

12 files changed

+357
-27
lines changed

apps/code/src/renderer/features/code-review/hooks/useReviewComment.ts

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,11 @@ import { DEFAULT_TAB_IDS } from "@features/panels/constants/panelConstants";
22
import { usePanelLayoutStore } from "@features/panels/store/panelLayoutStore";
33
import { findTabInTree } from "@features/panels/store/panelTree";
44
import { getSessionService } from "@features/sessions/service/service";
5+
import { escapeXmlAttr } from "@utils/xml";
56
import { useCallback } from "react";
67
import { useReviewNavigationStore } from "../stores/reviewNavigationStore";
78
import type { OnCommentCallback } from "../types";
89

9-
function escapeXmlAttr(value: string): string {
10-
return value
11-
.replace(/&/g, "&amp;")
12-
.replace(/</g, "&lt;")
13-
.replace(/>/g, "&gt;")
14-
.replace(/"/g, "&quot;")
15-
.replace(/'/g, "&apos;");
16-
}
17-
1810
export function useReviewComment(taskId: string): OnCommentCallback {
1911
return useCallback(
2012
(filePath, startLine, endLine, side, comment) => {

apps/code/src/renderer/features/message-editor/utils/content.ts

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { escapeXmlAttr } from "@utils/xml";
2+
13
export interface MentionChip {
24
type:
35
| "file"
@@ -35,14 +37,6 @@ export function contentToPlainText(content: EditorContent): string {
3537
.join("");
3638
}
3739

38-
function escapeXmlAttr(value: string): string {
39-
return value
40-
.replace(/&/g, "&amp;")
41-
.replace(/"/g, "&quot;")
42-
.replace(/</g, "&lt;")
43-
.replace(/>/g, "&gt;");
44-
}
45-
4640
export function contentToXml(content: EditorContent): string {
4741
const inlineFilePaths = new Set<string>();
4842
const parts = content.segments.map((seg) => {

apps/code/src/renderer/utils/path.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,13 @@ export function compactHomePath(text: string): string {
3636
.replace(/\/home\/[^/\s]+/g, "~");
3737
}
3838

39+
export function getFileName(filePath: string): string {
40+
const parts = filePath.split(/[\\/]/);
41+
return parts[parts.length - 1] || filePath;
42+
}
43+
3944
export function getFileExtension(filePath: string): string {
40-
const parts = filePath.split(".");
41-
return parts.length > 1 ? parts[parts.length - 1] : "";
45+
const name = getFileName(filePath);
46+
const lastDot = name.lastIndexOf(".");
47+
return lastDot >= 0 ? name.slice(lastDot + 1).toLowerCase() : "";
4248
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { describe, expect, it } from "vitest";
2+
import {
3+
extractPromptDisplayContent,
4+
makeAttachmentUri,
5+
parseAttachmentUri,
6+
} from "./promptContent";
7+
8+
describe("promptContent", () => {
9+
it("builds unique attachment URIs for same-name files", () => {
10+
const firstUri = makeAttachmentUri("/tmp/one/README.md");
11+
const secondUri = makeAttachmentUri("/tmp/two/README.md");
12+
13+
expect(firstUri).not.toBe(secondUri);
14+
expect(parseAttachmentUri(firstUri)).toEqual({
15+
id: firstUri,
16+
label: "README.md",
17+
});
18+
expect(parseAttachmentUri(secondUri)).toEqual({
19+
id: secondUri,
20+
label: "README.md",
21+
});
22+
});
23+
24+
it("keeps duplicate file labels visible when attachment ids differ", () => {
25+
const firstUri = makeAttachmentUri("/tmp/one/README.md");
26+
const secondUri = makeAttachmentUri("/tmp/two/README.md");
27+
28+
const result = extractPromptDisplayContent([
29+
{ type: "text", text: "compare both" },
30+
{
31+
type: "resource",
32+
resource: { uri: firstUri, text: "first", mimeType: "text/markdown" },
33+
},
34+
{
35+
type: "resource",
36+
resource: {
37+
uri: secondUri,
38+
text: "second",
39+
mimeType: "text/markdown",
40+
},
41+
},
42+
]);
43+
44+
expect(result.text).toBe("compare both");
45+
expect(result.attachments).toEqual([
46+
{ id: firstUri, label: "README.md" },
47+
{ id: secondUri, label: "README.md" },
48+
]);
49+
});
50+
});
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import type { ContentBlock } from "@agentclientprotocol/sdk";
2+
import { getFileName } from "@utils/path";
3+
4+
export const ATTACHMENT_URI_PREFIX = "attachment://";
5+
6+
function hashAttachmentPath(filePath: string): string {
7+
let hash = 2166136261;
8+
9+
for (let i = 0; i < filePath.length; i++) {
10+
hash ^= filePath.charCodeAt(i);
11+
hash = Math.imul(hash, 16777619);
12+
}
13+
14+
return (hash >>> 0).toString(36);
15+
}
16+
17+
export function makeAttachmentUri(filePath: string): string {
18+
const label = encodeURIComponent(getFileName(filePath));
19+
const id = hashAttachmentPath(filePath);
20+
return `${ATTACHMENT_URI_PREFIX}${id}?label=${label}`;
21+
}
22+
23+
export interface AttachmentRef {
24+
id: string;
25+
label: string;
26+
}
27+
28+
export function parseAttachmentUri(uri: string): AttachmentRef | null {
29+
if (!uri.startsWith(ATTACHMENT_URI_PREFIX)) {
30+
return null;
31+
}
32+
33+
const rawValue = uri.slice(ATTACHMENT_URI_PREFIX.length);
34+
const queryStart = rawValue.indexOf("?");
35+
if (queryStart < 0) {
36+
return null;
37+
}
38+
39+
const label =
40+
decodeURIComponent(
41+
new URLSearchParams(rawValue.slice(queryStart + 1)).get("label") ?? "",
42+
) || "attachment";
43+
44+
return { id: uri, label };
45+
}
46+
47+
function getBlockAttachmentUri(block: ContentBlock): string | null {
48+
if (block.type === "resource") {
49+
return block.resource.uri ?? null;
50+
}
51+
52+
if (block.type === "image") {
53+
return block.uri ?? null;
54+
}
55+
56+
return null;
57+
}
58+
59+
export interface PromptDisplayContent {
60+
text: string;
61+
attachments: AttachmentRef[];
62+
}
63+
64+
export function extractPromptDisplayContent(
65+
blocks: ContentBlock[],
66+
options?: { filterHidden?: boolean },
67+
): PromptDisplayContent {
68+
const filterHidden = options?.filterHidden ?? false;
69+
70+
const textParts: string[] = [];
71+
for (const block of blocks) {
72+
if (block.type !== "text") continue;
73+
if (filterHidden) {
74+
const meta = (block as { _meta?: { ui?: { hidden?: boolean } } })._meta;
75+
if (meta?.ui?.hidden) continue;
76+
}
77+
textParts.push(block.text);
78+
}
79+
80+
const seen = new Set<string>();
81+
const attachments: AttachmentRef[] = [];
82+
for (const block of blocks) {
83+
const uri = getBlockAttachmentUri(block);
84+
if (!uri || seen.has(uri)) continue;
85+
const ref = parseAttachmentUri(uri);
86+
if (!ref) continue;
87+
seen.add(uri);
88+
attachments.push(ref);
89+
}
90+
91+
return { text: textParts.join(""), attachments };
92+
}

apps/code/src/renderer/utils/session.ts

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
isJsonRpcNotification,
1919
isJsonRpcRequest,
2020
} from "@shared/types/session-events";
21+
import { extractPromptDisplayContent } from "@utils/promptContent";
2122

2223
/**
2324
* Convert a stored log entry to an ACP message.
@@ -197,16 +198,9 @@ export function extractUserPromptsFromEvents(events: AcpMessage[]): string[] {
197198
return prompts;
198199
}
199200

200-
/**
201-
* Extract prompt text from ContentBlocks, filtering out hidden blocks.
202-
*/
203201
export function extractPromptText(prompt: string | ContentBlock[]): string {
204202
if (typeof prompt === "string") return prompt;
205-
206-
return (prompt as ContentBlock[])
207-
.filter((b) => b.type === "text")
208-
.map((b) => (b as { text: string }).text)
209-
.join("");
203+
return extractPromptDisplayContent(prompt).text;
210204
}
211205

212206
/**
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
export function escapeXmlAttr(value: string): string {
2+
return value
3+
.replace(/&/g, "&amp;")
4+
.replace(/</g, "&lt;")
5+
.replace(/>/g, "&gt;")
6+
.replace(/"/g, "&quot;")
7+
.replace(/'/g, "&apos;");
8+
}
9+
10+
export function unescapeXmlAttr(value: string): string {
11+
return value
12+
.replace(/&quot;/g, '"')
13+
.replace(/&apos;/g, "'")
14+
.replace(/&lt;/g, "<")
15+
.replace(/&gt;/g, ">")
16+
.replace(/&amp;/g, "&");
17+
}

packages/shared/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
"clean": "node ../../scripts/rimraf.mjs dist .turbo"
1717
},
1818
"devDependencies": {
19+
"@agentclientprotocol/sdk": "0.16.1",
1920
"tsup": "^8.5.1",
2021
"typescript": "^5.5.0"
2122
},
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import { describe, expect, it } from "vitest";
2+
import {
3+
CLOUD_PROMPT_PREFIX,
4+
deserializeCloudPrompt,
5+
promptBlocksToText,
6+
serializeCloudPrompt,
7+
} from "./cloud-prompt";
8+
9+
describe("cloud-prompt", () => {
10+
describe("serializeCloudPrompt", () => {
11+
it("returns plain text for a single text block", () => {
12+
const result = serializeCloudPrompt([
13+
{ type: "text", text: " hello world " },
14+
]);
15+
expect(result).toBe("hello world");
16+
expect(result).not.toContain(CLOUD_PROMPT_PREFIX);
17+
});
18+
19+
it("returns prefixed JSON for multi-block content", () => {
20+
const blocks = [
21+
{ type: "text" as const, text: "read this" },
22+
{
23+
type: "resource" as const,
24+
resource: {
25+
uri: "attachment://test.txt",
26+
text: "file contents",
27+
mimeType: "text/plain",
28+
},
29+
},
30+
];
31+
const result = serializeCloudPrompt(blocks);
32+
expect(result).toMatch(
33+
new RegExp(
34+
`^${CLOUD_PROMPT_PREFIX.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`,
35+
),
36+
);
37+
const payload = JSON.parse(result.slice(CLOUD_PROMPT_PREFIX.length));
38+
expect(payload.blocks).toEqual(blocks);
39+
});
40+
});
41+
42+
describe("deserializeCloudPrompt", () => {
43+
it("round-trips with serializeCloudPrompt (text-only)", () => {
44+
const original = [{ type: "text" as const, text: "hello" }];
45+
const serialized = serializeCloudPrompt(original);
46+
const deserialized = deserializeCloudPrompt(serialized);
47+
expect(deserialized).toEqual(original);
48+
});
49+
50+
it("round-trips with serializeCloudPrompt (multi-block)", () => {
51+
const original = [
52+
{ type: "text" as const, text: "read this" },
53+
{
54+
type: "resource" as const,
55+
resource: {
56+
uri: "attachment://test.txt",
57+
text: "contents",
58+
mimeType: "text/plain",
59+
},
60+
},
61+
];
62+
const serialized = serializeCloudPrompt(original);
63+
const deserialized = deserializeCloudPrompt(serialized);
64+
expect(deserialized).toEqual(original);
65+
});
66+
67+
it("wraps plain string (no prefix) as a text block", () => {
68+
const result = deserializeCloudPrompt("just a plain message");
69+
expect(result).toEqual([{ type: "text", text: "just a plain message" }]);
70+
});
71+
72+
it("returns empty array for empty string", () => {
73+
expect(deserializeCloudPrompt("")).toEqual([]);
74+
expect(deserializeCloudPrompt(" ")).toEqual([]);
75+
});
76+
77+
it("falls back to text block for malformed JSON after prefix", () => {
78+
const malformed = `${CLOUD_PROMPT_PREFIX}{not valid json`;
79+
const result = deserializeCloudPrompt(malformed);
80+
expect(result).toEqual([{ type: "text", text: malformed }]);
81+
});
82+
83+
it("falls back to text block for empty blocks array", () => {
84+
const payload = `${CLOUD_PROMPT_PREFIX}${JSON.stringify({ blocks: [] })}`;
85+
const result = deserializeCloudPrompt(payload);
86+
expect(result).toEqual([{ type: "text", text: payload }]);
87+
});
88+
});
89+
90+
describe("promptBlocksToText", () => {
91+
it("extracts and joins text blocks", () => {
92+
const result = promptBlocksToText([
93+
{ type: "text", text: "hello " },
94+
{
95+
type: "resource",
96+
resource: {
97+
uri: "attachment://f.txt",
98+
text: "ignored",
99+
mimeType: "text/plain",
100+
},
101+
},
102+
{ type: "text", text: "world" },
103+
]);
104+
expect(result).toBe("hello world");
105+
});
106+
107+
it("returns empty string for non-text blocks only", () => {
108+
expect(
109+
promptBlocksToText([
110+
{
111+
type: "resource",
112+
resource: {
113+
uri: "attachment://f.txt",
114+
text: "content",
115+
mimeType: "text/plain",
116+
},
117+
},
118+
]),
119+
).toBe("");
120+
});
121+
122+
it("returns empty string for empty array", () => {
123+
expect(promptBlocksToText([])).toBe("");
124+
});
125+
});
126+
});

0 commit comments

Comments
 (0)