diff --git a/packages/opencode/src/acp-next/tool.ts b/packages/opencode/src/acp-next/tool.ts new file mode 100644 index 0000000000..128c4c9c85 --- /dev/null +++ b/packages/opencode/src/acp-next/tool.ts @@ -0,0 +1,174 @@ +import type { ToolCallContent, ToolCallLocation, ToolKind } from "@agentclientprotocol/sdk" + +export type ToolInput = Record + +export type ToolAttachment = { + readonly mime?: string + readonly url?: string + readonly [key: string]: unknown +} + +export type CompletedToolState = { + readonly status: "completed" + readonly input: ToolInput + readonly output: string + readonly metadata?: unknown + readonly attachments?: ReadonlyArray +} + +export type ImageAttachment = { + readonly mimeType: string + readonly data: string +} + +export function toToolKind(toolName: string): ToolKind { + const tool = toolName.toLocaleLowerCase() + + switch (tool) { + case "bash": + case "shell": + return "execute" + + case "webfetch": + return "fetch" + + case "edit": + case "patch": + case "write": + return "edit" + + case "grep": + case "glob": + case "repo_clone": + case "repo_overview": + case "context": + case "context7_resolve_library_id": + case "context7_get_library_docs": + return "search" + + case "read": + return "read" + + default: + return "other" + } +} + +export function toLocations(toolName: string, input: ToolInput): ToolCallLocation[] { + const tool = toolName.toLocaleLowerCase() + + switch (tool) { + case "read": + case "edit": + case "write": + return locationFrom(input.filePath) + + case "grep": + case "glob": + case "repo_clone": + case "repo_overview": + case "context": + case "context7_resolve_library_id": + case "context7_get_library_docs": + return locationFrom(input.path) + + case "bash": + case "shell": + return [] + + default: + return [] + } +} + +export function completedToolContent(toolName: string, state: CompletedToolState): ToolCallContent[] { + const content: ToolCallContent[] = [ + { + type: "content", + content: { + type: "text", + text: state.output, + }, + }, + ] + + if (toToolKind(toolName) === "edit") { + content.push(...diffContent(state.input)) + } + + content.push(...imageContents(state.attachments ?? [])) + return content +} + +export function completedToolRawOutput(state: CompletedToolState) { + return { + output: state.output, + ...(state.metadata !== undefined ? { metadata: state.metadata } : {}), + ...(state.attachments?.length ? { attachments: state.attachments } : {}), + } +} + +export function imageContents(attachments: ReadonlyArray): ToolCallContent[] { + return extractImageAttachments(attachments).map((attachment): ToolCallContent => { + return { + type: "content", + content: { + type: "image", + mimeType: attachment.mimeType, + data: attachment.data, + }, + } + }) +} + +export function extractImageAttachments(attachments: ReadonlyArray): ImageAttachment[] { + return attachments.flatMap((attachment): ImageAttachment[] => { + const data = dataUrlImage(attachment) + return data ? [data] : [] + }) +} + +export function shellOutputSnapshot(state: { readonly metadata?: unknown }) { + if (!state.metadata || typeof state.metadata !== "object") return undefined + return stringValue((state.metadata as Record).output) +} + +export const mapToolKind = toToolKind +export const extractLocations = toLocations +export const buildCompletedToolContent = completedToolContent +export const buildCompletedRawOutput = completedToolRawOutput +export const extractShellOutputSnapshot = shellOutputSnapshot + +function locationFrom(value: unknown): ToolCallLocation[] { + const path = stringValue(value) + return path ? [{ path }] : [] +} + +function diffContent(input: ToolInput): ToolCallContent[] { + const oldText = stringValue(input.oldString) + const newText = stringValue(input.newString) ?? stringValue(input.content) + if (oldText === undefined || newText === undefined) return [] + + return [ + { + type: "diff", + path: stringValue(input.filePath) ?? "", + oldText, + newText, + }, + ] +} + +function dataUrlImage(attachment: ToolAttachment) { + const match = stringValue(attachment.url)?.match(/^data:([^;,]+)(?:;[^,]*)*;base64,(.*)$/) + const mime = match?.[1] ?? stringValue(attachment.mime) + if (!mime?.startsWith("image/")) return undefined + + const data = match?.[2] + if (data === undefined) return undefined + return { mimeType: mime, data } +} + +function stringValue(value: unknown) { + return typeof value === "string" ? value : undefined +} diff --git a/packages/opencode/test/acp-next/tool.test.ts b/packages/opencode/test/acp-next/tool.test.ts new file mode 100644 index 0000000000..0e0cc1e3ff --- /dev/null +++ b/packages/opencode/test/acp-next/tool.test.ts @@ -0,0 +1,169 @@ +import { describe, expect, test } from "bun:test" +import { + completedToolContent, + completedToolRawOutput, + extractImageAttachments, + imageContents, + shellOutputSnapshot, + toLocations, + toToolKind, +} from "../../src/acp-next/tool" + +describe("acp-next tool conversion", () => { + test("maps OpenCode tool ids to ACP tool kinds", () => { + expect(toToolKind("bash")).toBe("execute") + expect(toToolKind("shell")).toBe("execute") + expect(toToolKind("webfetch")).toBe("fetch") + expect(toToolKind("edit")).toBe("edit") + expect(toToolKind("patch")).toBe("edit") + expect(toToolKind("write")).toBe("edit") + expect(toToolKind("grep")).toBe("search") + expect(toToolKind("glob")).toBe("search") + expect(toToolKind("repo_clone")).toBe("search") + expect(toToolKind("repo_overview")).toBe("search") + expect(toToolKind("context7_resolve_library_id")).toBe("search") + expect(toToolKind("context7_get_library_docs")).toBe("search") + expect(toToolKind("read")).toBe("read") + expect(toToolKind("custom_tool")).toBe("other") + }) + + test("extracts file locations from tool input", () => { + expect(toLocations("read", { filePath: "/tmp/a.ts" })).toEqual([{ path: "/tmp/a.ts" }]) + expect(toLocations("edit", { filePath: "/tmp/b.ts" })).toEqual([{ path: "/tmp/b.ts" }]) + expect(toLocations("write", { filePath: "/tmp/c.ts" })).toEqual([{ path: "/tmp/c.ts" }]) + expect(toLocations("grep", { path: "/repo/src" })).toEqual([{ path: "/repo/src" }]) + expect(toLocations("glob", { path: "/repo/test" })).toEqual([{ path: "/repo/test" }]) + expect(toLocations("repo_clone", { path: "/repo" })).toEqual([{ path: "/repo" }]) + expect(toLocations("repo_overview", { path: "/repo" })).toEqual([{ path: "/repo" }]) + expect(toLocations("context7_get_library_docs", { path: "/docs" })).toEqual([{ path: "/docs" }]) + expect(toLocations("bash", { filePath: "/tmp/nope.ts", path: "/tmp" })).toEqual([]) + expect(toLocations("read", { path: "/tmp/missing-file-path.ts" })).toEqual([]) + }) + + test("builds completed content with text, edit diffs, and image attachments", () => { + const image = Buffer.from("image-data").toString("base64") + + expect( + completedToolContent("edit", { + status: "completed", + input: { + filePath: "/tmp/file.ts", + oldString: "before", + newString: "after", + }, + output: "edited /tmp/file.ts", + attachments: [ + { + type: "file", + mime: "image/png", + filename: "image.png", + url: `data:image/png;base64,${image}`, + }, + { + type: "file", + mime: "text/plain", + filename: "note.txt", + url: "data:text/plain;base64,bm90ZQ==", + }, + ], + }), + ).toEqual([ + { + type: "content", + content: { type: "text", text: "edited /tmp/file.ts" }, + }, + { + type: "diff", + path: "/tmp/file.ts", + oldText: "before", + newText: "after", + }, + { + type: "content", + content: { type: "image", mimeType: "image/png", data: image }, + }, + ]) + }) + + test("omits edit diffs until old and new text fields exist", () => { + expect( + completedToolContent("write", { + status: "completed", + input: { + filePath: "/tmp/file.ts", + content: "created", + }, + output: "wrote /tmp/file.ts", + }), + ).toEqual([ + { + type: "content", + content: { type: "text", text: "wrote /tmp/file.ts" }, + }, + ]) + }) + + test("builds completed raw output with optional metadata and attachments", () => { + const attachments = [ + { + type: "file", + mime: "image/jpeg", + filename: "photo.jpg", + url: "data:image/jpeg;base64,AAAA", + }, + ] + + expect( + completedToolRawOutput({ + status: "completed", + input: {}, + output: "done", + metadata: { exit: 0 }, + attachments, + }), + ).toEqual({ + output: "done", + metadata: { exit: 0 }, + attachments, + }) + + expect( + completedToolRawOutput({ + status: "completed", + input: {}, + output: "done", + }), + ).toEqual({ output: "done" }) + }) + + test("extracts image attachments only from data URLs", () => { + const attachments = [ + { + mime: "image/webp", + url: "data:image/webp;charset=utf-8;base64,AAAA", + }, + { + mime: "image/png", + url: "https://example.com/image.png", + }, + { + mime: "text/plain", + url: "data:text/plain;base64,BBBB", + }, + ] + + expect(extractImageAttachments(attachments)).toEqual([{ mimeType: "image/webp", data: "AAAA" }]) + expect(imageContents(attachments)).toEqual([ + { + type: "content", + content: { type: "image", mimeType: "image/webp", data: "AAAA" }, + }, + ]) + }) + + test("reads shell output snapshot from string metadata output", () => { + expect(shellOutputSnapshot({ metadata: { output: "line 1\nline 2" } })).toBe("line 1\nline 2") + expect(shellOutputSnapshot({ metadata: { output: 42 } })).toBeUndefined() + expect(shellOutputSnapshot({ metadata: undefined })).toBeUndefined() + }) +})