Apply PR #11303: feat: let ACP client expose the input/output properly

This commit is contained in:
opencode-agent[bot]
2026-02-01 14:06:47 +00:00
4 changed files with 629 additions and 226 deletions

View File

@@ -23,8 +23,6 @@ import {
type SetSessionModelRequest,
type SetSessionModeRequest,
type SetSessionModeResponse,
type ToolCallContent,
type ToolKind,
} from "@agentclientprotocol/sdk"
import { Log } from "../util/log"
@@ -40,6 +38,7 @@ import { z } from "zod"
import { LoadAPIKeyError } from "ai"
import type { Event, OpencodeClient, SessionMessageResponse } from "@opencode-ai/sdk/v2"
import { applyPatch } from "diff"
import { toolCallFromPart, toolResultFromPart } from "./tool-format"
type ModeOption = { id: string; name: string; description?: string }
type ModelOption = { modelId: string; name: string }
@@ -65,6 +64,7 @@ export namespace ACP {
private eventAbort = new AbortController()
private eventStarted = false
private permissionQueues = new Map<string, Promise<void>>()
private emittedToolCalls = new Set<string>()
private permissionOptions: PermissionOption[] = [
{ optionId: "once", kind: "allow_once", name: "Allow once" },
{ optionId: "always", kind: "allow_always", name: "Always allow" },
@@ -117,16 +117,17 @@ export namespace ACP {
.then(async () => {
const directory = session.cwd
const permissionInfo = toolCallFromPart(permission.permission, permission.metadata ?? {})
const res = await this.connection
.requestPermission({
sessionId: permission.sessionID,
toolCall: {
toolCallId: permission.tool?.callID ?? permission.id,
status: "pending",
title: permission.permission,
rawInput: permission.metadata,
kind: toToolKind(permission.permission),
locations: toLocations(permission.permission, permission.metadata),
title: permissionInfo.title,
rawInput: permissionInfo.rawInput,
kind: permissionInfo.kind,
locations: permissionInfo.locations,
},
options: this.permissionOptions,
})
@@ -218,72 +219,38 @@ export namespace ACP {
if (part.type === "tool") {
switch (part.state.status) {
case "pending":
await this.connection
.sessionUpdate({
sessionId,
update: {
sessionUpdate: "tool_call",
toolCallId: part.callID,
title: part.tool,
kind: toToolKind(part.tool),
status: "pending",
locations: [],
rawInput: {},
},
})
.catch((error) => {
log.error("failed to send tool pending to ACP", { error })
})
return
case "running":
await this.connection
.sessionUpdate({
sessionId,
update: {
sessionUpdate: "tool_call_update",
toolCallId: part.callID,
status: "in_progress",
kind: toToolKind(part.tool),
title: part.tool,
locations: toLocations(part.tool, part.state.input),
rawInput: part.state.input,
},
})
.catch((error) => {
log.error("failed to send tool in_progress to ACP", { error })
})
case "running": {
const toolCallId = part.callID
const info = toolCallFromPart(part.tool, part.state.input)
if (!this.emittedToolCalls.has(toolCallId)) {
this.emittedToolCalls.add(toolCallId)
await this.connection
.sessionUpdate({
sessionId,
update: {
sessionUpdate: "tool_call",
toolCallId,
title: info.title,
kind: info.kind,
status: "in_progress",
locations: info.locations,
rawInput: info.rawInput,
},
})
.catch((error) => {
log.error("failed to send tool_call to ACP", { error })
})
}
return
}
case "completed": {
const kind = toToolKind(part.tool)
const content: ToolCallContent[] = [
{
type: "content",
content: {
type: "text",
text: part.state.output,
},
},
]
if (kind === "edit") {
const input = part.state.input
const filePath = typeof input["filePath"] === "string" ? input["filePath"] : ""
const oldText = typeof input["oldString"] === "string" ? input["oldString"] : ""
const newText =
typeof input["newString"] === "string"
? input["newString"]
: typeof input["content"] === "string"
? input["content"]
: ""
content.push({
type: "diff",
path: filePath,
oldText,
newText,
})
}
const input = part.state.input
const info = toolCallFromPart(part.tool, input)
const result = toolResultFromPart(part.tool, input, part.state.output, false)
if (part.tool === "todowrite") {
const parsedTodos = z.array(Todo.Info).safeParse(JSON.parse(part.state.output))
@@ -312,6 +279,8 @@ export namespace ACP {
}
}
this.emittedToolCalls.delete(part.callID)
await this.connection
.sessionUpdate({
sessionId,
@@ -319,14 +288,7 @@ export namespace ACP {
sessionUpdate: "tool_call_update",
toolCallId: part.callID,
status: "completed",
kind,
content,
title: part.state.title,
rawInput: part.state.input,
rawOutput: {
output: part.state.output,
metadata: part.state.metadata,
},
rawOutput: result.rawOutput,
},
})
.catch((error) => {
@@ -334,7 +296,12 @@ export namespace ACP {
})
return
}
case "error":
case "error": {
const input = part.state.input
const result = toolResultFromPart(part.tool, input, part.state.error, true)
this.emittedToolCalls.delete(part.callID)
await this.connection
.sessionUpdate({
sessionId,
@@ -342,27 +309,14 @@ export namespace ACP {
sessionUpdate: "tool_call_update",
toolCallId: part.callID,
status: "failed",
kind: toToolKind(part.tool),
title: part.tool,
rawInput: part.state.input,
content: [
{
type: "content",
content: {
type: "text",
text: part.state.error,
},
},
],
rawOutput: {
error: part.state.error,
},
rawOutput: result.rawOutput,
},
})
.catch((error) => {
log.error("failed to send tool error to ACP", { error })
})
return
}
}
}
@@ -700,72 +654,58 @@ export namespace ACP {
for (const part of message.parts) {
if (part.type === "tool") {
const toolCallId = part.callID
const input = part.state.input
const info = toolCallFromPart(part.tool, input)
switch (part.state.status) {
case "pending":
await this.connection
.sessionUpdate({
sessionId,
update: {
sessionUpdate: "tool_call",
toolCallId: part.callID,
title: part.tool,
kind: toToolKind(part.tool),
status: "pending",
locations: [],
rawInput: {},
},
})
.catch((err) => {
log.error("failed to send tool pending to ACP", { error: err })
})
break
case "running":
await this.connection
.sessionUpdate({
sessionId,
update: {
sessionUpdate: "tool_call_update",
toolCallId: part.callID,
status: "in_progress",
kind: toToolKind(part.tool),
title: part.tool,
locations: toLocations(part.tool, part.state.input),
rawInput: part.state.input,
},
})
.catch((err) => {
log.error("failed to send tool in_progress to ACP", { error: err })
})
break
case "completed":
const kind = toToolKind(part.tool)
const content: ToolCallContent[] = [
{
type: "content",
content: {
type: "text",
text: part.state.output,
},
},
]
if (kind === "edit") {
const input = part.state.input
const filePath = typeof input["filePath"] === "string" ? input["filePath"] : ""
const oldText = typeof input["oldString"] === "string" ? input["oldString"] : ""
const newText =
typeof input["newString"] === "string"
? input["newString"]
: typeof input["content"] === "string"
? input["content"]
: ""
content.push({
type: "diff",
path: filePath,
oldText,
newText,
})
case "running": {
if (!this.emittedToolCalls.has(toolCallId)) {
this.emittedToolCalls.add(toolCallId)
await this.connection
.sessionUpdate({
sessionId,
update: {
sessionUpdate: "tool_call",
toolCallId,
title: info.title,
kind: info.kind,
status: "in_progress",
locations: info.locations,
rawInput: info.rawInput,
},
})
.catch((err) => {
log.error("failed to send tool_call to ACP", { error: err })
})
}
break
}
case "completed": {
if (!this.emittedToolCalls.has(toolCallId)) {
this.emittedToolCalls.add(toolCallId)
await this.connection
.sessionUpdate({
sessionId,
update: {
sessionUpdate: "tool_call",
toolCallId,
title: info.title,
kind: info.kind,
status: "in_progress",
locations: info.locations,
rawInput: info.rawInput,
},
})
.catch((err) => {
log.error("failed to send tool_call to ACP", { error: err })
})
}
this.emittedToolCalls.delete(toolCallId)
const result = toolResultFromPart(part.tool, input, part.state.output, false)
if (part.tool === "todowrite") {
const parsedTodos = z.array(Todo.Info).safeParse(JSON.parse(part.state.output))
@@ -799,51 +739,56 @@ export namespace ACP {
sessionId,
update: {
sessionUpdate: "tool_call_update",
toolCallId: part.callID,
toolCallId,
status: "completed",
kind,
content,
title: part.state.title,
rawInput: part.state.input,
rawOutput: {
output: part.state.output,
metadata: part.state.metadata,
},
rawOutput: result.rawOutput,
},
})
.catch((err) => {
log.error("failed to send tool completed to ACP", { error: err })
})
break
case "error":
}
case "error": {
if (!this.emittedToolCalls.has(toolCallId)) {
this.emittedToolCalls.add(toolCallId)
await this.connection
.sessionUpdate({
sessionId,
update: {
sessionUpdate: "tool_call",
toolCallId,
title: info.title,
kind: info.kind,
status: "in_progress",
locations: info.locations,
rawInput: info.rawInput,
},
})
.catch((err) => {
log.error("failed to send tool_call to ACP", { error: err })
})
}
this.emittedToolCalls.delete(toolCallId)
const result = toolResultFromPart(part.tool, input, part.state.error, true)
await this.connection
.sessionUpdate({
sessionId,
update: {
sessionUpdate: "tool_call_update",
toolCallId: part.callID,
toolCallId,
status: "failed",
kind: toToolKind(part.tool),
title: part.tool,
rawInput: part.state.input,
content: [
{
type: "content",
content: {
type: "text",
text: part.state.error,
},
},
],
rawOutput: {
error: part.state.error,
},
rawOutput: result.rawOutput,
},
})
.catch((err) => {
log.error("failed to send tool error to ACP", { error: err })
})
break
}
}
} else if (part.type === "text") {
if (part.text) {
@@ -1303,53 +1248,6 @@ export namespace ACP {
}
}
function toToolKind(toolName: string): ToolKind {
const tool = toolName.toLocaleLowerCase()
switch (tool) {
case "bash":
return "execute"
case "webfetch":
return "fetch"
case "edit":
case "patch":
case "write":
return "edit"
case "grep":
case "glob":
case "context7_resolve_library_id":
case "context7_get_library_docs":
return "search"
case "list":
case "read":
return "read"
default:
return "other"
}
}
function toLocations(toolName: string, input: Record<string, any>): { path: string }[] {
const tool = toolName.toLocaleLowerCase()
switch (tool) {
case "read":
case "edit":
case "write":
return input["filePath"] ? [{ path: input["filePath"] }] : []
case "glob":
case "grep":
return input["path"] ? [{ path: input["path"] }] : []
case "bash":
return []
case "list":
return input["path"] ? [{ path: input["path"] }] : []
default:
return []
}
}
async function defaultModel(config: ACPConfig, cwd?: string) {
const sdk = config.sdk
const configured = config.defaultModel

View File

@@ -0,0 +1,21 @@
import type { ToolKind } from "@agentclientprotocol/sdk"
export namespace ParseCommand {
export interface Result {
kind: ToolKind
title: string
locations: { path: string }[]
terminalOutput: boolean
}
export function format(command: string, description: string, cwd: string): Result {
const title = description || command || "Terminal"
return {
kind: "other",
title,
locations: cwd ? [{ path: cwd }] : [],
terminalOutput: true,
}
}
}

View File

@@ -0,0 +1,452 @@
import type { ToolCallContent, ToolKind } from "@agentclientprotocol/sdk"
import { ParseCommand } from "./parse-command"
export interface ToolCallInfo {
title: string
kind: ToolKind
content: ToolCallContent[]
locations: { path: string; line?: number }[]
rawInput: unknown
}
export interface ToolResultInfo {
content: ToolCallContent[]
rawOutput: unknown
title?: string
}
function normalize(name: string): string {
return name.toLowerCase().replace(/^mcp__acp__/, "")
}
function truncate(str: string, max: number): string {
return str.length > max ? str.substring(0, max - 3) + "..." : str
}
function escapeBackticks(str: string): string {
return str.replaceAll("`", "\\`")
}
function wrapBackticks(str: string): string {
return "`" + escapeBackticks(str) + "`"
}
function markdownEscape(text: string): string {
let fence = "```"
for (const match of text.matchAll(/^`{3,}/gm)) {
while (match[0].length >= fence.length) fence += "`"
}
return fence + "\n" + text + (text.endsWith("\n") ? "" : "\n") + fence
}
function textContent(text: string): ToolCallContent {
return { type: "content", content: { type: "text", text } }
}
function diffContent(path: string, oldText: string | null, newText: string): ToolCallContent {
return { type: "diff", path, oldText, newText }
}
function str(v: unknown): string {
return typeof v === "string" ? v : ""
}
function num(v: unknown): number | undefined {
return typeof v === "number" ? v : undefined
}
function getFilePath(input: Record<string, unknown>): string {
return str(input.filePath ?? input.file_path ?? input.filepath ?? input.path)
}
function getOldString(input: Record<string, unknown>): string {
return str(input.oldString ?? input.old_string)
}
function getNewString(input: Record<string, unknown>): string {
return str(input.newString ?? input.new_string ?? input.content ?? input.new_content)
}
function getCommand(input: Record<string, unknown>): string {
return str(input.command ?? input.cmd)
}
function getDescription(input: Record<string, unknown>): string {
return str(input.description ?? input.desc)
}
function getPattern(input: Record<string, unknown>): string {
return str(input.pattern ?? input.filePattern ?? input.glob)
}
function getQuery(input: Record<string, unknown>): string {
return str(input.query ?? input.q)
}
function getUrl(input: Record<string, unknown>): string {
return str(input.url ?? input.uri)
}
function getDiff(input: Record<string, unknown>): string {
return str(input.diff ?? input.patch ?? input.unifiedDiff)
}
export function toolCallFromPart(tool: string, input: Record<string, unknown>): ToolCallInfo {
const name = normalize(tool)
switch (name) {
case "bash":
case "shell":
case "terminal": {
const command = getCommand(input)
const description = getDescription(input)
const cwd = str(input.cwd ?? input.workdir ?? input.workingDir ?? input.directory)
const result = ParseCommand.format(command, description, cwd)
return {
title: result.title,
kind: result.kind,
content: [],
locations: result.locations,
rawInput: input,
}
}
case "bashoutput": {
return {
title: "Tail Logs",
kind: "execute",
content: [],
locations: [],
rawInput: input,
}
}
case "read":
case "view": {
const filePath = getFilePath(input)
const offset = num(input.offset) ?? num(input.line) ?? 0
const limit = num(input.limit) ?? 0
let suffix = ""
if (limit) {
suffix = ` (${offset + 1} - ${offset + limit})`
} else if (offset) {
suffix = ` (from line ${offset + 1})`
}
return {
title: filePath ? `Read ${filePath}${suffix}` : "Read File",
kind: "read",
content: [],
locations: filePath ? [{ path: filePath, line: offset }] : [],
rawInput: input,
}
}
case "list":
case "ls": {
const path = str(input.path)
return {
title: path ? `List \`${path}\`` : "List directory",
kind: "search",
content: [],
locations: path ? [{ path }] : [],
rawInput: input,
}
}
case "edit":
case "str_replace": {
const filePath = getFilePath(input)
const oldString = getOldString(input)
const newString = getNewString(input)
return {
title: filePath ? `Edit \`${filePath}\`` : "Edit",
kind: "edit",
content: filePath ? [diffContent(filePath, oldString, newString)] : [],
locations: filePath ? [{ path: filePath }] : [],
rawInput: input,
}
}
case "patch": {
const filePath = getFilePath(input)
const patchText = getDiff(input)
return {
title: filePath ? `Patch \`${filePath}\`` : "Patch",
kind: "edit",
content: patchText ? [textContent(patchText)] : [],
locations: filePath ? [{ path: filePath }] : [],
rawInput: input,
}
}
case "write":
case "create": {
const filePath = getFilePath(input)
const content = getNewString(input)
return {
title: filePath ? `Write ${filePath}` : "Write",
kind: "edit",
content: filePath ? [diffContent(filePath, null, content)] : [],
locations: filePath ? [{ path: filePath }] : [],
rawInput: input,
}
}
case "glob":
case "find": {
const path = str(input.path)
const pattern = getPattern(input)
let label = "Find"
if (path) label += ` \`${path}\``
if (pattern) label += ` \`${pattern}\``
return {
title: label,
kind: "search",
content: [],
locations: path ? [{ path }] : [],
rawInput: input,
}
}
case "grep":
case "search": {
const pattern = getPattern(input)
const path = str(input.path)
let label = "grep"
if (pattern) label += ` "${truncate(pattern, 30)}"`
if (path) label += ` ${path}`
return {
title: label,
kind: "search",
content: [],
locations: path ? [{ path }] : [],
rawInput: input,
}
}
case "webfetch":
case "fetch": {
const url = getUrl(input)
const prompt = str(input.prompt)
return {
title: url ? `Fetch ${truncate(url, 40)}` : "Fetch",
kind: "fetch",
content: prompt ? [textContent(prompt)] : [],
locations: [],
rawInput: input,
}
}
case "websearch": {
const query = getQuery(input)
return {
title: query ? `"${truncate(query, 40)}"` : "Search",
kind: "fetch",
content: [],
locations: [],
rawInput: input,
}
}
case "task": {
const description = getDescription(input)
const prompt = str(input.prompt)
return {
title: description || "Task",
kind: "think",
content: prompt ? [textContent(prompt)] : [],
locations: [],
rawInput: input,
}
}
case "todowrite":
case "todoread": {
return {
title: "Update TODOs",
kind: "think",
content: [],
locations: [],
rawInput: input,
}
}
case "plan_exit": {
return {
title: "Exit Plan Mode",
kind: "think",
content: [],
locations: [],
rawInput: input,
}
}
case "plan_enter": {
return {
title: "Enter Plan Mode",
kind: "think",
content: [],
locations: [],
rawInput: input,
}
}
case "apply_patch": {
const filePath = getFilePath(input)
const patchText = getDiff(input)
return {
title: filePath ? `Apply Patch \`${filePath}\`` : "Apply Patch",
kind: "edit",
content: patchText ? [textContent(patchText)] : [],
locations: filePath ? [{ path: filePath }] : [],
rawInput: input,
}
}
case "multiedit": {
return {
title: "Multi Edit",
kind: "edit",
content: [],
locations: [],
rawInput: input,
}
}
case "batch": {
return {
title: "Batch",
kind: "other",
content: [],
locations: [],
rawInput: input,
}
}
case "skill": {
const name = str(input.name)
return {
title: name ? `Skill: ${name}` : "Skill",
kind: "other",
content: [],
locations: [],
rawInput: input,
}
}
case "question": {
const question = getQuery(input) || str(input.question)
return {
title: question ? truncate(question, 40) : "Question",
kind: "other",
content: [],
locations: [],
rawInput: input,
}
}
case "lsp": {
return {
title: "LSP",
kind: "other",
content: [],
locations: [],
rawInput: input,
}
}
case "codesearch": {
const query = getQuery(input)
return {
title: query ? `Search: ${truncate(query, 30)}` : "Code Search",
kind: "search",
content: [],
locations: [],
rawInput: input,
}
}
default: {
const description = getDescription(input)
const command = getCommand(input)
const title = description || command || tool
return {
title: truncate(title, 50),
kind: "other",
content: [],
locations: [],
rawInput: input,
}
}
}
}
export function toolResultFromPart(
tool: string,
input: Record<string, unknown>,
output: string,
isError: boolean,
): ToolResultInfo {
const name = normalize(tool)
const displayText = isError ? markdownEscape(output) : output
const content: ToolCallContent[] = [textContent(displayText)]
switch (name) {
case "bash":
case "shell":
case "terminal": {
return {
content,
rawOutput: isError ? { stderr: output } : { stdout: output },
}
}
case "edit":
case "str_replace": {
const filePath = getFilePath(input)
const oldString = getOldString(input)
const newString = getNewString(input)
if (filePath && !isError) {
content.push(diffContent(filePath, oldString, newString))
}
return {
content,
rawOutput: { stdout: output },
}
}
case "patch":
case "apply_patch": {
const filePath = getFilePath(input)
const patchText = getDiff(input)
if (filePath && patchText && !isError) {
content.push(textContent(patchText))
}
return {
content,
rawOutput: { stdout: output },
}
}
case "write":
case "create": {
const filePath = getFilePath(input)
const fileContent = getNewString(input)
if (filePath && !isError) {
content.push(diffContent(filePath, null, fileContent))
}
return {
content,
rawOutput: { stdout: output },
}
}
default: {
return {
content,
rawOutput: isError ? { stderr: output } : { stdout: output },
}
}
}
}

View File

@@ -0,0 +1,32 @@
import { describe, expect, it } from "bun:test"
import { ParseCommand } from "../../src/acp/parse-command"
describe("ParseCommand", () => {
describe("format", () => {
it("uses description as title when provided", () => {
const result = ParseCommand.format("ls", "List files in current directory", "/home/user")
expect(result.title).toBe("List files in current directory")
expect(result.kind).toBe("other")
})
it("falls back to command when no description", () => {
const result = ParseCommand.format("ls -la", "", "/home/user")
expect(result.title).toBe("ls -la")
})
it("includes cwd in locations", () => {
const result = ParseCommand.format("ls", "List files", "/home/user")
expect(result.locations).toEqual([{ path: "/home/user" }])
})
it("handles empty cwd", () => {
const result = ParseCommand.format("ls", "List files", "")
expect(result.locations).toEqual([])
})
it("sets terminalOutput to true", () => {
const result = ParseCommand.format("npm install", "Install dependencies", "/home/user")
expect(result.terminalOutput).toBe(true)
})
})
})