diff --git a/bun.lock b/bun.lock index 4f08f1b31b..bde9c1bb06 100644 --- a/bun.lock +++ b/bun.lock @@ -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:*", diff --git a/packages/cli/package.json b/packages/cli/package.json index 144cf7368c..1958357017 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -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:*", diff --git a/packages/cli/src/acp/config-option.ts b/packages/cli/src/acp/config-option.ts new file mode 100644 index 0000000000..57346f536b --- /dev/null +++ b/packages/cli/src/acp/config-option.ts @@ -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 +} + +export type ConfigOptionProvider = { + id: string + name: string + models: ReadonlyArray +} + +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" diff --git a/packages/cli/src/acp/content.ts b/packages/cli/src/acp/content.ts new file mode 100644 index 0000000000..b3098c7011 --- /dev/null +++ b/packages/cli/src/acp/content.ts @@ -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" diff --git a/packages/cli/src/acp/error.ts b/packages/cli/src/acp/error.ts new file mode 100644 index 0000000000..0204a000ca --- /dev/null +++ b/packages/cli/src/acp/error.ts @@ -0,0 +1,79 @@ +import { RequestError } from "@agentclientprotocol/sdk" +import { Schema } from "effect" + +export class SessionNotFoundError extends Schema.TaggedErrorClass()("ACPSessionNotFoundError", { + sessionId: Schema.String, +}) {} + +export class InvalidConfigOptionError extends Schema.TaggedErrorClass()( + "ACPInvalidConfigOptionError", + { configId: Schema.String }, +) {} + +export class InvalidModelError extends Schema.TaggedErrorClass()("ACPInvalidModelError", { + modelId: Schema.String, + providerId: Schema.optional(Schema.String), +}) {} + +export class InvalidEffortError extends Schema.TaggedErrorClass()("ACPInvalidEffortError", { + effort: Schema.String, +}) {} + +export class InvalidModeError extends Schema.TaggedErrorClass()("ACPInvalidModeError", { + mode: Schema.String, +}) {} + +export class UnknownAuthMethodError extends Schema.TaggedErrorClass()( + "ACPUnknownAuthMethodError", + { methodId: Schema.String }, +) {} + +export class ServiceFailureError extends Schema.TaggedErrorClass()("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" diff --git a/packages/cli/src/acp/tool.ts b/packages/cli/src/acp/tool.ts new file mode 100644 index 0000000000..e5cdd1a833 --- /dev/null +++ b/packages/cli/src/acp/tool.ts @@ -0,0 +1,189 @@ +import { isAbsolute, resolve } from "node:path" +import type { ToolCall, ToolCallContent, ToolCallLocation, ToolCallUpdate, ToolKind } from "@agentclientprotocol/sdk" + +export type ToolInput = Record +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> + 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> + 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" diff --git a/packages/cli/test/acp/config-option.test.ts b/packages/cli/test/acp/config-option.test.ts new file mode 100644 index 0000000000..1abf353eca --- /dev/null +++ b/packages/cli/test/acp/config-option.test.ts @@ -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") + }) +}) diff --git a/packages/cli/test/acp/content.test.ts b/packages/cli/test/acp/content.test.ts new file mode 100644 index 0000000000..1718529bf6 --- /dev/null +++ b/packages/cli/test/acp/content.test.ts @@ -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" }, + }, + }, + ]) + }) +}) diff --git a/packages/cli/test/acp/tool.test.ts b/packages/cli/test/acp/tool.test.ts new file mode 100644 index 0000000000..fbc03ed097 --- /dev/null +++ b/packages/cli/test/acp/tool.test.ts @@ -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", + }) + }) +})