feat(acp-next): add pure tool conversion helpers (#29232)

This commit is contained in:
Shoubhit Dash 2026-05-25 20:55:22 +05:30 committed by GitHub
parent fe482fe3dd
commit d200da121b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 343 additions and 0 deletions

View file

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

View file

@ -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()
})
})