Jelajahi Sumber

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

Shoubhit Dash 3 bulan lalu
induk
melakukan
d200da121b

+ 174 - 0
packages/opencode/src/acp-next/tool.ts

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