mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-21 18:26:09 +00:00
feat(cli): add acp protocol mappings
This commit is contained in:
@@ -124,6 +124,7 @@
|
||||
"opencode2": "./bin/opencode2.cjs",
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "0.21.0",
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@opencode-ai/client": "workspace:*",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"typecheck": "tsgo --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "0.21.0",
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@opencode-ai/client": "workspace:*",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import type { SessionConfigOption } from "@agentclientprotocol/sdk"
|
||||
|
||||
export const DEFAULT_VARIANT_VALUE = "default"
|
||||
|
||||
export type ConfigOptionModel = {
|
||||
id: string
|
||||
name: string
|
||||
variants?: ReadonlyArray<string>
|
||||
}
|
||||
|
||||
export type ConfigOptionProvider = {
|
||||
id: string
|
||||
name: string
|
||||
models: ReadonlyArray<ConfigOptionModel>
|
||||
}
|
||||
|
||||
export type ConfigOptionMode = {
|
||||
id: string
|
||||
name: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export type ModelSelection = {
|
||||
model: { providerID: string; modelID: string }
|
||||
variant?: string
|
||||
}
|
||||
|
||||
export function buildConfigOptions(input: {
|
||||
providers: readonly ConfigOptionProvider[]
|
||||
currentModel: ModelSelection["model"]
|
||||
currentVariant?: string
|
||||
modes?: readonly ConfigOptionMode[]
|
||||
currentModeId?: string
|
||||
}): SessionConfigOption[] {
|
||||
const variants = variantsForModel(input.providers, input.currentModel)
|
||||
const effort = variants.length > 0 ? buildEffortSelectOption({ variants, currentVariant: input.currentVariant }) : undefined
|
||||
return [
|
||||
buildModelSelectOption({ providers: input.providers, currentModel: input.currentModel }),
|
||||
...(effort ? [effort] : []),
|
||||
...(input.modes && input.currentModeId
|
||||
? [buildModeSelectOption({ modes: input.modes, currentModeId: input.currentModeId })]
|
||||
: []),
|
||||
]
|
||||
}
|
||||
|
||||
export function buildModelSelectOption(input: {
|
||||
providers: readonly ConfigOptionProvider[]
|
||||
currentModel: ModelSelection["model"]
|
||||
}): SessionConfigOption {
|
||||
return {
|
||||
id: "model",
|
||||
name: "Model",
|
||||
category: "model",
|
||||
type: "select",
|
||||
currentValue: `${input.currentModel.providerID}/${input.currentModel.modelID}`,
|
||||
options: input.providers.flatMap((provider) =>
|
||||
provider.models
|
||||
.toSorted((a, b) => a.name.localeCompare(b.name))
|
||||
.map((model) => ({ value: `${provider.id}/${model.id}`, name: `${provider.name}/${model.name}` })),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
export function buildEffortSelectOption(input: {
|
||||
variants: readonly string[]
|
||||
currentVariant?: string
|
||||
}): SessionConfigOption {
|
||||
return {
|
||||
id: "effort",
|
||||
name: "Effort",
|
||||
description: "Available effort levels for this model",
|
||||
category: "thought_level",
|
||||
type: "select",
|
||||
currentValue: selectVariant(input.currentVariant, input.variants),
|
||||
options: input.variants.map((variant) => ({ value: variant, name: formatVariantName(variant) })),
|
||||
}
|
||||
}
|
||||
|
||||
export function buildModeSelectOption(input: {
|
||||
modes: readonly ConfigOptionMode[]
|
||||
currentModeId: string
|
||||
}): SessionConfigOption {
|
||||
return {
|
||||
id: "mode",
|
||||
name: "Session Mode",
|
||||
category: "mode",
|
||||
type: "select",
|
||||
currentValue: input.currentModeId,
|
||||
options: input.modes.map((mode) => ({
|
||||
value: mode.id,
|
||||
name: mode.name,
|
||||
...(mode.description ? { description: mode.description } : {}),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
export function parseModelSelection(modelId: string, providers: readonly ConfigOptionProvider[]): ModelSelection {
|
||||
const provider = providers.find((item) => modelId.startsWith(`${item.id}/`))
|
||||
if (!provider) {
|
||||
const separator = modelId.indexOf("/")
|
||||
return separator === -1
|
||||
? { model: { providerID: modelId, modelID: "" } }
|
||||
: { model: { providerID: modelId.slice(0, separator), modelID: modelId.slice(separator + 1) } }
|
||||
}
|
||||
const modelID = modelId.slice(provider.id.length + 1)
|
||||
if (provider.models.some((model) => model.id === modelID)) return { model: { providerID: provider.id, modelID } }
|
||||
const separator = modelID.lastIndexOf("/")
|
||||
const baseModelID = separator === -1 ? modelID : modelID.slice(0, separator)
|
||||
const variant = separator === -1 ? undefined : modelID.slice(separator + 1)
|
||||
const model = provider.models.find((item) => item.id === baseModelID)
|
||||
if (model && variant && model.variants?.includes(variant)) {
|
||||
return { model: { providerID: provider.id, modelID: baseModelID }, variant }
|
||||
}
|
||||
return { model: { providerID: provider.id, modelID } }
|
||||
}
|
||||
|
||||
export function formatVariantName(variant: string) {
|
||||
return variant
|
||||
.split(/[_-]/)
|
||||
.map((part) => (part ? part.charAt(0).toUpperCase() + part.slice(1) : part))
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
function variantsForModel(providers: readonly ConfigOptionProvider[], model: ModelSelection["model"]) {
|
||||
return providers.find((provider) => provider.id === model.providerID)?.models.find((item) => item.id === model.modelID)
|
||||
?.variants ?? []
|
||||
}
|
||||
|
||||
function selectVariant(variant: string | undefined, variants: readonly string[]) {
|
||||
if (variant && variants.includes(variant)) return variant
|
||||
if (variants.includes(DEFAULT_VARIANT_VALUE)) return DEFAULT_VARIANT_VALUE
|
||||
return variants[0] ?? DEFAULT_VARIANT_VALUE
|
||||
}
|
||||
|
||||
export * as ACPConfigOption from "./config-option"
|
||||
@@ -0,0 +1,188 @@
|
||||
import type { ContentBlock, ContentChunk, ResourceLink, Role } from "@agentclientprotocol/sdk"
|
||||
import path from "node:path"
|
||||
import { fileURLToPath, pathToFileURL } from "node:url"
|
||||
|
||||
export type PromptPart =
|
||||
| { readonly type: "text"; readonly text: string; readonly synthetic?: boolean; readonly ignored?: boolean }
|
||||
| { readonly type: "file"; readonly url: string; readonly filename?: string; readonly mime: string }
|
||||
|
||||
export type ReplayPart = PromptPart | { readonly type: "reasoning"; readonly text: string }
|
||||
|
||||
export function promptContentToParts(content: readonly ContentBlock[]): PromptPart[] {
|
||||
return content.flatMap(contentBlockToParts)
|
||||
}
|
||||
|
||||
export function contentBlockToParts(block: ContentBlock): PromptPart[] {
|
||||
switch (block.type) {
|
||||
case "text":
|
||||
return [{ type: "text", text: block.text, ...audienceFlags(block.annotations?.audience ?? undefined) }]
|
||||
case "image":
|
||||
if (block.data) {
|
||||
return [
|
||||
{
|
||||
type: "file",
|
||||
url: `data:${block.mimeType};base64,${block.data}`,
|
||||
filename: filenameFromUri(block.uri ?? undefined) ?? "image",
|
||||
mime: block.mimeType,
|
||||
},
|
||||
]
|
||||
}
|
||||
if (block.uri?.startsWith("data:") || block.uri?.startsWith("http://") || block.uri?.startsWith("https://")) {
|
||||
return [
|
||||
{
|
||||
type: "file",
|
||||
url: block.uri,
|
||||
filename: filenameFromUri(block.uri) ?? "image",
|
||||
mime: block.mimeType,
|
||||
},
|
||||
]
|
||||
}
|
||||
return []
|
||||
case "resource_link":
|
||||
return [resourceLinkToPart(block)]
|
||||
case "resource":
|
||||
if ("text" in block.resource) {
|
||||
try {
|
||||
const parsed = new URL(block.resource.uri)
|
||||
if (parsed.protocol === "file:") {
|
||||
const line = parsed.hash.match(/^#L(\d+)/)?.[1]
|
||||
const decoded = (() => {
|
||||
try {
|
||||
return fileURLToPath(parsed)
|
||||
} catch {
|
||||
return decodeURIComponent(parsed.pathname)
|
||||
}
|
||||
})()
|
||||
const filepath = path.sep === "\\" ? decoded.replace(/\\/g, "/") : decoded
|
||||
return [{ type: "text", text: `[${filepath}${line ? `:${line}` : ""}]\n${block.resource.text}` }]
|
||||
}
|
||||
} catch {}
|
||||
return [{ type: "text", text: `[${block.resource.uri}]\n${block.resource.text}` }]
|
||||
}
|
||||
if (!block.resource.mimeType) return []
|
||||
return [
|
||||
{
|
||||
type: "file",
|
||||
url: block.resource.uri.startsWith("data:")
|
||||
? block.resource.uri
|
||||
: `data:${block.resource.mimeType};base64,${block.resource.blob}`,
|
||||
filename: filenameFromUri(block.resource.uri) ?? "file",
|
||||
mime: block.resource.mimeType,
|
||||
},
|
||||
]
|
||||
default:
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export function partsToContentChunks(parts: readonly ReplayPart[]): ContentChunk[] {
|
||||
return parts.flatMap((part): ContentChunk[] => {
|
||||
if (part.type === "text") {
|
||||
if (!part.text) return []
|
||||
return [
|
||||
{
|
||||
content: {
|
||||
type: "text",
|
||||
text: part.text,
|
||||
...(part.synthetic
|
||||
? { annotations: { audience: ["assistant" as const] } }
|
||||
: part.ignored
|
||||
? { annotations: { audience: ["user" as const] } }
|
||||
: {}),
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
if (part.type === "reasoning") {
|
||||
return part.text ? [{ content: { type: "text", text: part.text } }] : []
|
||||
}
|
||||
if (part.url.startsWith("file://")) {
|
||||
return [
|
||||
{
|
||||
content: {
|
||||
type: "resource_link",
|
||||
uri: part.url,
|
||||
name: part.filename ?? "file",
|
||||
mimeType: part.mime,
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
if (!part.url.startsWith("data:")) return []
|
||||
const data = decodeDataUrl(part.url)
|
||||
if (!data) return []
|
||||
if (data.mime.startsWith("image/")) {
|
||||
return [
|
||||
{
|
||||
content: {
|
||||
type: "image",
|
||||
mimeType: data.mime,
|
||||
data: data.base64,
|
||||
uri: pathToFileURL(part.filename ?? "image").href,
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
return [
|
||||
{
|
||||
content: {
|
||||
type: "resource",
|
||||
resource:
|
||||
data.mime.startsWith("text/") || data.mime === "application/json"
|
||||
? {
|
||||
uri: pathToFileURL(part.filename ?? "file").href,
|
||||
mimeType: data.mime,
|
||||
text: Buffer.from(data.base64, "base64").toString("utf8"),
|
||||
}
|
||||
: {
|
||||
uri: pathToFileURL(part.filename ?? "file").href,
|
||||
mimeType: data.mime,
|
||||
blob: data.base64,
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
function resourceLinkToPart(link: ResourceLink): PromptPart {
|
||||
try {
|
||||
if (link.uri.startsWith("file://")) {
|
||||
return { type: "file", url: link.uri, filename: link.name || filenameFromUri(link.uri) || "file", mime: link.mimeType ?? "text/plain" }
|
||||
}
|
||||
if (link.uri.startsWith("zed://")) {
|
||||
const pathname = new URL(link.uri).searchParams.get("path")
|
||||
if (pathname)
|
||||
return {
|
||||
type: "file",
|
||||
url: pathToFileURL(pathname).href,
|
||||
filename: link.name || path.basename(pathname) || "file",
|
||||
mime: link.mimeType ?? "text/plain",
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
return { type: "text", text: link.uri }
|
||||
}
|
||||
|
||||
function decodeDataUrl(url: string) {
|
||||
const match = /^data:([^;]+);base64,(.*)$/.exec(url)
|
||||
if (!match?.[1] || match[2] === undefined) return
|
||||
return { mime: match[1], base64: match[2] }
|
||||
}
|
||||
|
||||
function audienceFlags(audience: readonly Role[] | null | undefined) {
|
||||
if (audience?.length === 1 && audience[0] === "assistant") return { synthetic: true as const }
|
||||
if (audience?.length === 1 && audience[0] === "user") return { ignored: true as const }
|
||||
return {}
|
||||
}
|
||||
|
||||
function filenameFromUri(uri: string | undefined) {
|
||||
if (!uri || uri.startsWith("data:")) return
|
||||
try {
|
||||
return path.basename(new URL(uri).pathname) || undefined
|
||||
} catch {
|
||||
return path.basename(uri) || undefined
|
||||
}
|
||||
}
|
||||
|
||||
export * as ACPContent from "./content"
|
||||
@@ -0,0 +1,79 @@
|
||||
import { RequestError } from "@agentclientprotocol/sdk"
|
||||
import { Schema } from "effect"
|
||||
|
||||
export class SessionNotFoundError extends Schema.TaggedErrorClass<SessionNotFoundError>()("ACPSessionNotFoundError", {
|
||||
sessionId: Schema.String,
|
||||
}) {}
|
||||
|
||||
export class InvalidConfigOptionError extends Schema.TaggedErrorClass<InvalidConfigOptionError>()(
|
||||
"ACPInvalidConfigOptionError",
|
||||
{ configId: Schema.String },
|
||||
) {}
|
||||
|
||||
export class InvalidModelError extends Schema.TaggedErrorClass<InvalidModelError>()("ACPInvalidModelError", {
|
||||
modelId: Schema.String,
|
||||
providerId: Schema.optional(Schema.String),
|
||||
}) {}
|
||||
|
||||
export class InvalidEffortError extends Schema.TaggedErrorClass<InvalidEffortError>()("ACPInvalidEffortError", {
|
||||
effort: Schema.String,
|
||||
}) {}
|
||||
|
||||
export class InvalidModeError extends Schema.TaggedErrorClass<InvalidModeError>()("ACPInvalidModeError", {
|
||||
mode: Schema.String,
|
||||
}) {}
|
||||
|
||||
export class UnknownAuthMethodError extends Schema.TaggedErrorClass<UnknownAuthMethodError>()(
|
||||
"ACPUnknownAuthMethodError",
|
||||
{ methodId: Schema.String },
|
||||
) {}
|
||||
|
||||
export class ServiceFailureError extends Schema.TaggedErrorClass<ServiceFailureError>()("ACPServiceFailureError", {
|
||||
safeMessage: Schema.String,
|
||||
service: Schema.optional(Schema.String),
|
||||
errorName: Schema.optional(Schema.String),
|
||||
}) {}
|
||||
|
||||
export type Error =
|
||||
| SessionNotFoundError
|
||||
| InvalidConfigOptionError
|
||||
| InvalidModelError
|
||||
| InvalidEffortError
|
||||
| InvalidModeError
|
||||
| UnknownAuthMethodError
|
||||
| ServiceFailureError
|
||||
|
||||
export function toRequestError(error: Error) {
|
||||
switch (error._tag) {
|
||||
case "ACPSessionNotFoundError":
|
||||
return RequestError.invalidParams({ sessionId: error.sessionId }, `session not found: ${error.sessionId}`)
|
||||
case "ACPInvalidConfigOptionError":
|
||||
return RequestError.invalidParams({ configId: error.configId }, `unknown config option: ${error.configId}`)
|
||||
case "ACPInvalidModelError":
|
||||
return RequestError.invalidParams(
|
||||
{ providerId: error.providerId, modelId: error.modelId },
|
||||
`model not found: ${error.modelId}`,
|
||||
)
|
||||
case "ACPInvalidEffortError":
|
||||
return RequestError.invalidParams({ effort: error.effort }, `effort not found: ${error.effort}`)
|
||||
case "ACPInvalidModeError":
|
||||
return RequestError.invalidParams({ mode: error.mode }, `mode not found: ${error.mode}`)
|
||||
case "ACPUnknownAuthMethodError":
|
||||
return RequestError.invalidParams({ methodId: error.methodId }, `unknown auth method: ${error.methodId}`)
|
||||
case "ACPServiceFailureError":
|
||||
return RequestError.internalError(
|
||||
{
|
||||
...(error.service ? { service: error.service } : {}),
|
||||
...(error.errorName ? { errorName: error.errorName } : {}),
|
||||
},
|
||||
error.safeMessage,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export function fromUnknown(error: unknown, service?: string) {
|
||||
const errorName = error instanceof Error ? error.name : undefined
|
||||
return new ServiceFailureError({ safeMessage: "Internal service failure", service, errorName })
|
||||
}
|
||||
|
||||
export * as ACPError from "./error"
|
||||
@@ -0,0 +1,189 @@
|
||||
import { isAbsolute, resolve } from "node:path"
|
||||
import type { ToolCall, ToolCallContent, ToolCallLocation, ToolCallUpdate, ToolKind } from "@agentclientprotocol/sdk"
|
||||
|
||||
export type ToolInput = Record<string, unknown>
|
||||
export type ToolContent = ReadonlyArray<
|
||||
| { readonly type: "text"; readonly text: string }
|
||||
| { readonly type: "file"; readonly uri: string; readonly mime: string; readonly name?: string }
|
||||
>
|
||||
|
||||
export function toToolKind(toolName: string): ToolKind {
|
||||
switch (toolName.toLocaleLowerCase()) {
|
||||
case "bash":
|
||||
case "shell":
|
||||
return "execute"
|
||||
case "webfetch":
|
||||
return "fetch"
|
||||
case "edit":
|
||||
case "apply_patch":
|
||||
case "patch":
|
||||
case "write":
|
||||
return "edit"
|
||||
case "grep":
|
||||
case "glob":
|
||||
case "context":
|
||||
case "context7_resolve_library_id":
|
||||
case "context7_get_library_docs":
|
||||
return "search"
|
||||
case "read":
|
||||
return "read"
|
||||
case "task":
|
||||
case "subagent":
|
||||
return "think"
|
||||
default:
|
||||
return "other"
|
||||
}
|
||||
}
|
||||
|
||||
export function toLocations(toolName: string, input: ToolInput, cwd?: string): ToolCallLocation[] {
|
||||
switch (toolName.toLocaleLowerCase()) {
|
||||
case "bash":
|
||||
case "shell": {
|
||||
const workdir = shellWorkdir(input, cwd)
|
||||
return workdir ? [{ path: workdir }] : []
|
||||
}
|
||||
case "read":
|
||||
case "edit":
|
||||
case "write":
|
||||
case "patch":
|
||||
case "apply_patch":
|
||||
return locationFrom(input.filePath ?? input.filepath)
|
||||
case "external_directory":
|
||||
return locationFrom(input.filePath ?? input.filepath, input.parentDir, input.directories)
|
||||
case "grep":
|
||||
case "glob":
|
||||
case "context":
|
||||
case "context7_resolve_library_id":
|
||||
case "context7_get_library_docs":
|
||||
return locationFrom(input.path)
|
||||
default:
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export function pendingToolCall(input: {
|
||||
readonly toolCallId: string
|
||||
readonly toolName: string
|
||||
readonly state: { readonly input: ToolInput; readonly title?: string }
|
||||
readonly cwd?: string
|
||||
}): ToolCall {
|
||||
return {
|
||||
toolCallId: input.toolCallId,
|
||||
title: toolTitle(input.toolName, input.state.input, input.state.title),
|
||||
kind: toToolKind(input.toolName),
|
||||
status: "pending",
|
||||
locations: toLocations(input.toolName, input.state.input, input.cwd),
|
||||
rawInput: rawInput(input.toolName, input.state.input, input.cwd),
|
||||
}
|
||||
}
|
||||
|
||||
export function runningToolUpdate(input: {
|
||||
readonly toolCallId: string
|
||||
readonly toolName: string
|
||||
readonly state: { readonly input: ToolInput; readonly title?: string }
|
||||
readonly content?: ToolContent
|
||||
readonly cwd?: string
|
||||
}): ToolCallUpdate {
|
||||
return {
|
||||
toolCallId: input.toolCallId,
|
||||
status: "in_progress",
|
||||
kind: toToolKind(input.toolName),
|
||||
title: toolTitle(input.toolName, input.state.input, input.state.title),
|
||||
locations: toLocations(input.toolName, input.state.input, input.cwd),
|
||||
rawInput: rawInput(input.toolName, input.state.input, input.cwd),
|
||||
...(input.content?.length ? { content: toolContent(input.content) } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
export function completedToolUpdate(input: {
|
||||
readonly toolCallId: string
|
||||
readonly toolName: string
|
||||
readonly input: ToolInput
|
||||
readonly content: ToolContent
|
||||
readonly structured: Readonly<Record<string, unknown>>
|
||||
readonly result?: unknown
|
||||
}): ToolCallUpdate {
|
||||
return {
|
||||
toolCallId: input.toolCallId,
|
||||
status: "completed",
|
||||
content: toolContent(input.content),
|
||||
rawOutput: {
|
||||
structured: input.structured,
|
||||
...(input.result === undefined ? {} : { result: input.result }),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function errorToolUpdate(input: {
|
||||
readonly toolCallId: string
|
||||
readonly toolName: string
|
||||
readonly input: ToolInput
|
||||
readonly content: ToolContent
|
||||
readonly structured: Readonly<Record<string, unknown>>
|
||||
readonly error: string
|
||||
readonly cwd?: string
|
||||
}): ToolCallUpdate {
|
||||
return {
|
||||
toolCallId: input.toolCallId,
|
||||
status: "failed",
|
||||
kind: toToolKind(input.toolName),
|
||||
title: toolTitle(input.toolName, input.input, undefined),
|
||||
locations: toLocations(input.toolName, input.input, input.cwd),
|
||||
rawInput: rawInput(input.toolName, input.input, input.cwd),
|
||||
content: [
|
||||
...toolContent(input.content),
|
||||
{ type: "content", content: { type: "text", text: input.error } },
|
||||
],
|
||||
rawOutput: { structured: input.structured, error: input.error },
|
||||
}
|
||||
}
|
||||
|
||||
function toolContent(content: ToolContent): ToolCallContent[] {
|
||||
return content.flatMap((part): ToolCallContent[] => {
|
||||
if (part.type === "text") return [{ type: "content", content: { type: "text", text: part.text } }]
|
||||
const match = /^data:([^;,]+)(?:;[^,]*)*;base64,(.*)$/.exec(part.uri)
|
||||
if (!match?.[1]?.startsWith("image/") || match[2] === undefined) return []
|
||||
return [{ type: "content", content: { type: "image", mimeType: match[1], data: match[2] } }]
|
||||
})
|
||||
}
|
||||
|
||||
function toolTitle(toolName: string, input: ToolInput, fallback: string | undefined) {
|
||||
if (isShell(toolName)) return stringValue(input.command) ?? stringValue(input.cmd) ?? fallback ?? toolName
|
||||
return fallback || toolName
|
||||
}
|
||||
|
||||
function rawInput(toolName: string, input: ToolInput, cwd?: string): ToolInput {
|
||||
if (!isShell(toolName) || input.cwd || input.workdir) return input
|
||||
const workdir = shellWorkdir(input, cwd)
|
||||
return workdir ? { ...input, cwd: workdir } : input
|
||||
}
|
||||
|
||||
function shellWorkdir(input: ToolInput, cwd?: string) {
|
||||
const explicit = stringValue(input.workdir) ?? stringValue(input.cwd)
|
||||
if (!explicit) return cwd
|
||||
return isAbsolute(explicit) ? explicit : resolve(cwd ?? process.cwd(), explicit)
|
||||
}
|
||||
|
||||
function isShell(toolName: string) {
|
||||
const tool = toolName.toLocaleLowerCase()
|
||||
return tool === "bash" || tool === "shell"
|
||||
}
|
||||
|
||||
function locationFrom(...values: unknown[]): ToolCallLocation[] {
|
||||
return Array.from(
|
||||
new Set(
|
||||
values.flatMap((value): string[] => {
|
||||
if (Array.isArray(value)) return value.filter((item): item is string => typeof item === "string" && item.length > 0)
|
||||
const path = stringValue(value)
|
||||
return path ? [path] : []
|
||||
}),
|
||||
),
|
||||
(path) => ({ path }),
|
||||
)
|
||||
}
|
||||
|
||||
function stringValue(value: unknown) {
|
||||
return typeof value === "string" ? value : undefined
|
||||
}
|
||||
|
||||
export * as ACPTool from "./tool"
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { buildConfigOptions, formatVariantName, parseModelSelection, type ConfigOptionProvider } from "../../src/acp/config-option"
|
||||
|
||||
const providers: ConfigOptionProvider[] = [
|
||||
{
|
||||
id: "anthropic",
|
||||
name: "Anthropic",
|
||||
models: [
|
||||
{ id: "claude/sonnet-4", name: "Claude Sonnet 4", variants: ["default", "high", "very-high"] },
|
||||
{ id: "claude-haiku", name: "Claude Haiku" },
|
||||
],
|
||||
},
|
||||
{ id: "openai", name: "OpenAI", models: [{ id: "gpt-5", name: "GPT-5", variants: ["minimal", "low"] }] },
|
||||
]
|
||||
|
||||
describe("acp config options", () => {
|
||||
test("builds model, effort, and mode options", () => {
|
||||
const options = buildConfigOptions({
|
||||
providers,
|
||||
currentModel: { providerID: "anthropic", modelID: "claude/sonnet-4" },
|
||||
currentVariant: "high",
|
||||
modes: [{ id: "build", name: "Build" }, { id: "plan", name: "Plan" }],
|
||||
currentModeId: "build",
|
||||
})
|
||||
expect(options.map((option) => option.id)).toEqual(["model", "effort", "mode"])
|
||||
expect(options[0]?.currentValue).toBe("anthropic/claude/sonnet-4")
|
||||
expect(options[1]?.currentValue).toBe("high")
|
||||
})
|
||||
|
||||
test("parses slash-containing model ids before variants", () => {
|
||||
expect(parseModelSelection("anthropic/claude/sonnet-4", providers)).toEqual({
|
||||
model: { providerID: "anthropic", modelID: "claude/sonnet-4" },
|
||||
})
|
||||
expect(parseModelSelection("anthropic/claude/sonnet-4/high", providers)).toEqual({
|
||||
model: { providerID: "anthropic", modelID: "claude/sonnet-4" },
|
||||
variant: "high",
|
||||
})
|
||||
})
|
||||
|
||||
test("formats variant names", () => {
|
||||
expect(formatVariantName("very_high-effort")).toBe("Very High Effort")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { pathToFileURL } from "node:url"
|
||||
import { contentBlockToParts, partsToContentChunks } from "../../src/acp/content"
|
||||
|
||||
describe("acp content", () => {
|
||||
test("converts annotated text and images", () => {
|
||||
expect(contentBlockToParts({ type: "text", text: "internal", annotations: { audience: ["assistant"] } })).toEqual([
|
||||
{ type: "text", text: "internal", synthetic: true },
|
||||
])
|
||||
expect(contentBlockToParts({ type: "image", data: "AAAA", mimeType: "image/png" })).toEqual([
|
||||
{ type: "file", url: "data:image/png;base64,AAAA", filename: "image", mime: "image/png" },
|
||||
])
|
||||
})
|
||||
|
||||
test("converts file and embedded resources", () => {
|
||||
expect(
|
||||
contentBlockToParts({ type: "resource_link", uri: "file:///tmp/notes.txt", name: "notes.txt" }),
|
||||
).toEqual([{ type: "file", url: "file:///tmp/notes.txt", filename: "notes.txt", mime: "text/plain" }])
|
||||
expect(
|
||||
contentBlockToParts({ type: "resource", resource: { uri: "mcp://context", text: "hello" } }),
|
||||
).toEqual([{ type: "text", text: "[mcp://context]\nhello" }])
|
||||
})
|
||||
|
||||
test("replays files and data urls", () => {
|
||||
expect(
|
||||
partsToContentChunks([
|
||||
{ type: "file", url: "file:///tmp/readme.md", filename: "readme.md", mime: "text/markdown" },
|
||||
{ type: "file", url: "data:text/plain;base64,aGVsbG8=", filename: "note.txt", mime: "text/plain" },
|
||||
]),
|
||||
).toEqual([
|
||||
{ content: { type: "resource_link", uri: "file:///tmp/readme.md", name: "readme.md", mimeType: "text/markdown" } },
|
||||
{
|
||||
content: {
|
||||
type: "resource",
|
||||
resource: { uri: pathToFileURL("note.txt").href, mimeType: "text/plain", text: "hello" },
|
||||
},
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { completedToolUpdate, errorToolUpdate, pendingToolCall, runningToolUpdate, toLocations, toToolKind } from "../../src/acp/tool"
|
||||
|
||||
describe("acp tools", () => {
|
||||
test("maps kinds and locations", () => {
|
||||
expect(toToolKind("shell")).toBe("execute")
|
||||
expect(toToolKind("edit")).toBe("edit")
|
||||
expect(toToolKind("grep")).toBe("search")
|
||||
expect(toLocations("read", { filePath: "/tmp/a.ts" })).toEqual([{ path: "/tmp/a.ts" }])
|
||||
expect(toLocations("shell", { command: "pwd" }, "/workspace")).toEqual([{ path: "/workspace" }])
|
||||
})
|
||||
|
||||
test("builds tool lifecycle updates", () => {
|
||||
expect(pendingToolCall({ toolCallId: "call", toolName: "read", state: { input: { filePath: "/tmp/a" } } })).toMatchObject({
|
||||
toolCallId: "call",
|
||||
status: "pending",
|
||||
kind: "read",
|
||||
})
|
||||
expect(runningToolUpdate({ toolCallId: "call", toolName: "read", state: { input: {} } })).toMatchObject({
|
||||
toolCallId: "call",
|
||||
status: "in_progress",
|
||||
})
|
||||
expect(completedToolUpdate({ toolCallId: "call", toolName: "read", input: {}, content: [{ type: "text", text: "done" }], structured: {} })).toMatchObject({
|
||||
toolCallId: "call",
|
||||
status: "completed",
|
||||
content: [{ type: "content", content: { type: "text", text: "done" } }],
|
||||
})
|
||||
expect(errorToolUpdate({ toolCallId: "call", toolName: "read", input: {}, content: [], structured: {}, error: "failed" })).toMatchObject({
|
||||
toolCallId: "call",
|
||||
status: "failed",
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user