mirror of
https://github.com/anomalyco/opencode.git
synced 2026-02-01 22:48:16 +00:00
Apply PR #11303: feat: let ACP client expose the input/output properly
This commit is contained in:
@@ -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
|
||||
|
||||
21
packages/opencode/src/acp/parse-command.ts
Normal file
21
packages/opencode/src/acp/parse-command.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
452
packages/opencode/src/acp/tool-format.ts
Normal file
452
packages/opencode/src/acp/tool-format.ts
Normal 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 },
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
32
packages/opencode/test/acp/parse-command.test.ts
Normal file
32
packages/opencode/test/acp/parse-command.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user