From 50ff3d5eb3a06a9fed23ad1b1338746fbb198417 Mon Sep 17 00:00:00 2001 From: adityachaudhary99 Date: Tue, 16 Jun 2026 20:19:11 +0530 Subject: [PATCH] fix(acp): preserve file source when converting text resource blocks (#32558) When an editor (e.g. Zed) sends a code selection over ACP, it uses a `type: "resource"` ContentBlock where `resource.uri` carries the file path (and optional `#L` line range) and `resource.text` carries the selected text. `contentBlockToParts()` extracted only `resource.text` and dropped the uri, so the LLM received bare text with no source context and replays could not trace the snippet back to its file. The text branch now prefixes the content with the resolved file location (`[/path/file.ts:12-15]`) when the resource carries a `file://` uri, mirroring how the `resource_link` and blob branches already use the uri. Non-`file://` uris (and unparseable ones) are left untouched. Co-Authored-By: Claude Opus 4.8 --- packages/opencode/src/acp/content.ts | 23 +++++++++++++-- packages/opencode/test/acp/content.test.ts | 33 ++++++++++++++++++++-- 2 files changed, 52 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/acp/content.ts b/packages/opencode/src/acp/content.ts index 5f149d85f0f2..45c61aa03ca9 100644 --- a/packages/opencode/src/acp/content.ts +++ b/packages/opencode/src/acp/content.ts @@ -1,6 +1,6 @@ import type { ContentBlock, ContentChunk, ResourceLink, Role } from "@agentclientprotocol/sdk" import path from "node:path" -import { pathToFileURL } from "node:url" +import { fileURLToPath, pathToFileURL } from "node:url" import { SessionV1 } from "@opencode-ai/core/v1/session" export type PromptPart = SessionV1.TextPartInput | SessionV1.FilePartInput @@ -76,7 +76,11 @@ export function contentBlockToParts(block: ContentBlock): PromptPart[] { case "resource": if ("text" in block.resource) { - return [{ type: "text", text: block.resource.text }] + // Prefix the selected text with its source location when the editor + // (e.g. Zed) supplies a `file://` uri, so the LLM and session replays + // can trace the snippet back to its file (and line range). See #32558. + const source = fileSourcePrefix(block.resource.uri) + return [{ type: "text", text: source ? `${source}\n${block.resource.text}` : block.resource.text }] } if (block.resource.mimeType) { return [ @@ -237,6 +241,21 @@ function partAudience(part: Extract) { return { annotations: { audience } } } +function fileSourcePrefix(uri: string | undefined) { + if (!uri || !uri.startsWith("file://")) return undefined + try { + const hashIndex = uri.indexOf("#") + const base = hashIndex === -1 ? uri : uri.slice(0, hashIndex) + const fragment = hashIndex === -1 ? "" : uri.slice(hashIndex + 1) + const filePath = fileURLToPath(base) + // ACP encodes line ranges as a `#L12` or `#L12-15` fragment. + const lines = /^L?(\d+(?:-\d+)?)/.exec(fragment) + return `[${filePath}${lines ? `:${lines[1]}` : ""}]` + } catch { + return undefined + } +} + function filenameFromUri(uri: string | undefined) { if (!uri) return if (uri.startsWith("data:")) return diff --git a/packages/opencode/test/acp/content.test.ts b/packages/opencode/test/acp/content.test.ts index 90f62f9d1892..22f5ad2269ad 100644 --- a/packages/opencode/test/acp/content.test.ts +++ b/packages/opencode/test/acp/content.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test" import type { ContentBlock } from "@agentclientprotocol/sdk" +import path from "node:path" import { pathToFileURL } from "node:url" import { contentBlockToParts, partsToContentChunks, promptContentToParts } from "../../src/acp/content" @@ -99,12 +100,40 @@ describe("acp content conversion", () => { ]) }) - test("resource with text becomes a text part", () => { + test("resource with text prefixes the file source location", () => { + const filePath = path.resolve("tmp", "context.txt") expect( contentBlockToParts({ type: "resource", resource: { - uri: "file:///tmp/context.txt", + uri: pathToFileURL(filePath).href, + mimeType: "text/plain", + text: "context", + }, + }), + ).toEqual([{ type: "text", text: `[${filePath}]\ncontext` }]) + }) + + test("resource with text includes the line range from the uri fragment", () => { + const filePath = path.resolve("tmp", "app.ts") + expect( + contentBlockToParts({ + type: "resource", + resource: { + uri: `${pathToFileURL(filePath).href}#L12-15`, + mimeType: "text/typescript", + text: "const x = 1", + }, + }), + ).toEqual([{ type: "text", text: `[${filePath}:12-15]\nconst x = 1` }]) + }) + + test("resource with text and a non-file uri is left untouched", () => { + expect( + contentBlockToParts({ + type: "resource", + resource: { + uri: "https://example.com/notes", mimeType: "text/plain", text: "context", },