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", },