Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
174 changes: 174 additions & 0 deletions packages/opencode/src/acp-next/tool.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
import type { ToolCallContent, ToolCallLocation, ToolKind } from "@agentclientprotocol/sdk"

export type ToolInput = Record<string, unknown>

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<ToolAttachment>
}

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<ToolAttachment>): ToolCallContent[] {
return extractImageAttachments(attachments).map((attachment): ToolCallContent => {
return {
type: "content",
content: {
type: "image",
mimeType: attachment.mimeType,
data: attachment.data,
},
}
})
}

export function extractImageAttachments(attachments: ReadonlyArray<ToolAttachment>): 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<string, unknown>).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
}
169 changes: 169 additions & 0 deletions packages/opencode/test/acp-next/tool.test.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})
Loading