Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
696ba14
ci: increase ARM runner to 8 vCPUs for faster Tauri builds
thdxr Jan 30, 2026
65b7aeb
Merge branch 'anomalyco:dev' into dev
validatedev Feb 16, 2026
9b0ad6c
Merge branch 'anomalyco:dev' into dev
validatedev Feb 28, 2026
08382c7
refactor(acp): extract tool and command formatting into dedicated mod…
validatedev Apr 22, 2026
12edc9e
test(acp): add comprehensive tests for tool call formatting and spec …
validatedev Apr 22, 2026
0d81692
refactor(acp): drop parse-command module, consolidate tool-format hel…
validatedev Apr 23, 2026
fe33dd1
chore: refresh PR mergeability
validatedev May 15, 2026
206042f
fix(acp): port tool call raw input updates
validatedev Jun 4, 2026
bcc4aed
Merge branch 'dev' into fix/acp-show-proper-run-command-message
validatedev Jun 5, 2026
57d2e76
Merge branch 'dev' into fix/acp-show-proper-run-command-message
validatedev Jun 5, 2026
371942c
Merge branch 'dev' into fix/acp-show-proper-run-command-message
validatedev Jun 5, 2026
9cb0f41
Merge branch 'dev' into fix/acp-show-proper-run-command-message
validatedev Jun 6, 2026
2e879c6
Merge remote-tracking branch 'origin/dev' into fix/acp-show-proper-ru…
validatedev Jun 10, 2026
ed5473f
Merge remote-tracking branch 'upstream/dev' into fix/acp-show-proper-…
validatedev Jun 10, 2026
4d910ba
test(acp): align permission tool kind expectation with bash mapping
validatedev Jun 10, 2026
dcd244d
Merge remote-tracking branch 'origin/fix/acp-show-proper-run-command-…
validatedev Jun 10, 2026
577d09f
Merge branch 'dev' into fix/acp-show-proper-run-command-message
validatedev Jun 10, 2026
4d4bbf5
Merge branch 'dev' into fix/acp-show-proper-run-command-message
validatedev Jun 12, 2026
b222e57
Merge branch 'dev' into fix/acp-show-proper-run-command-message
validatedev Jun 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 14 additions & 7 deletions packages/opencode/src/acp/event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,9 @@ export class Subscription {
for (const part of message.parts) {
await this.recordFetchedPart(message.info.sessionID, message, part)
if (part.type === "tool") {
await this.handleToolPart(message.info.sessionID, part)
const session = await Effect.runPromise(this.input.session.tryGet(message.info.sessionID))
const cwd = message.info.role === "assistant" ? message.info.path?.cwd : undefined
await this.handleToolPart(message.info.sessionID, part, cwd ?? session?.cwd ?? process.cwd())
continue
}
await this.replayContentPart(message, part)
Expand Down Expand Up @@ -146,7 +148,7 @@ export class Subscription {
}),
)
if (part.type === "tool") {
await this.handleToolPart(session.id, part)
await this.handleToolPart(session.id, part, session.cwd)
}
}

Expand Down Expand Up @@ -231,16 +233,16 @@ export class Subscription {
)
}

private async handleToolPart(sessionId: string, part: ToolPart) {
await this.toolStart(sessionId, part)
private async handleToolPart(sessionId: string, part: ToolPart, cwd: string) {
await this.toolStart(sessionId, part, cwd)

switch (part.state.status) {
case "pending":
this.shellSnapshots.delete(part.callID)
return

case "running":
await this.runningTool(sessionId, part)
await this.runningTool(sessionId, part, cwd)
return

case "completed":
Expand All @@ -253,6 +255,7 @@ export class Subscription {
toolCallId: part.callID,
toolName: part.tool,
state: part.state,
cwd,
}),
},
})
Expand All @@ -268,14 +271,15 @@ export class Subscription {
toolCallId: part.callID,
toolName: part.tool,
state: part.state,
cwd,
}),
},
})
return
}
}

private async runningTool(sessionId: string, part: ToolPart) {
private async runningTool(sessionId: string, part: ToolPart, cwd: string) {
if (part.state.status !== "running") return

const output = part.tool === "bash" ? shellOutputSnapshot(part.state) : undefined
Expand All @@ -289,6 +293,7 @@ export class Subscription {
toolCallId: part.callID,
toolName: part.tool,
state: part.state,
cwd,
}),
},
})
Expand All @@ -306,12 +311,13 @@ export class Subscription {
toolName: part.tool,
state: part.state,
output,
cwd,
}),
},
})
}

private async toolStart(sessionId: string, part: ToolPart) {
private async toolStart(sessionId: string, part: ToolPart, cwd: string) {
if (this.toolStarts.has(part.callID)) return
this.toolStarts.add(part.callID)
await this.input.connection.sessionUpdate({
Expand All @@ -322,6 +328,7 @@ export class Subscription {
toolCallId: part.callID,
toolName: part.tool,
state: part.state,
cwd,
}),
},
})
Expand Down
172 changes: 142 additions & 30 deletions packages/opencode/src/acp/tool.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,12 @@
import type { ToolCall, ToolCallContent, ToolCallLocation, ToolCallUpdate, ToolKind } from "@agentclientprotocol/sdk"
import { isAbsolute, resolve } from "path"
import type {
ToolCall,
ToolCallContent,
ToolCallLocation,
ToolCallStatus,
ToolCallUpdate,
ToolKind,
} from "@agentclientprotocol/sdk"

export type ToolInput = Record<string, unknown>

Expand Down Expand Up @@ -29,18 +37,33 @@ export type ErrorToolState = {
readonly metadata?: unknown
}

type ToolState =
| { readonly status: "pending"; readonly input: ToolInput; readonly title?: string }
| RunningToolState
| (CompletedToolState & { readonly title?: string })
| ErrorToolState

export type ImageAttachment = {
readonly mimeType: string
readonly data: string
}

const TOOL_STATUS_MAP = {
pending: "pending",
running: "in_progress",
completed: "completed",
error: "failed",
} as const satisfies Record<ToolState["status"], ToolCallStatus>

const FENCE_RE = /^`{3,}/gm

export function toToolKind(toolName: string): ToolKind {
const tool = toolName.toLocaleLowerCase()

switch (tool) {
case "bash":
case "shell":
return "execute"
return "other"

case "webfetch":
return "fetch"
Expand Down Expand Up @@ -69,10 +92,14 @@ export function toToolKind(toolName: string): ToolKind {
}
}

export function toLocations(toolName: string, input: ToolInput): ToolCallLocation[] {
export function toLocations(toolName: string, input: ToolInput, cwd?: string): ToolCallLocation[] {
const tool = toolName.toLocaleLowerCase()

switch (tool) {
case "bash":
case "shell":
return shellLocation(input, cwd)

case "read":
case "edit":
case "write":
Expand All @@ -88,18 +115,18 @@ export function toLocations(toolName: string, input: ToolInput): ToolCallLocatio
case "context7_get_library_docs":
return locationFrom(input.path)

case "bash":
case "shell":
return []

default:
return []
}
}

export function completedToolContent(toolName: string, state: CompletedToolState): ToolCallContent[] {
const text =
toolName.toLocaleLowerCase() === "read" ? (readDisplayText(state.metadata) ?? state.output) : state.output
toolName.toLocaleLowerCase() === "read"
? (readDisplayText(state.metadata) ?? state.output)
: isShell(toolName)
? fenceWith(state.output, "sh")
: state.output
const content: ToolCallContent[] = [
{
type: "content",
Expand All @@ -121,15 +148,27 @@ export function completedToolContent(toolName: string, state: CompletedToolState
export function pendingToolCall(input: {
readonly toolCallId: string
readonly toolName: string
readonly state: { readonly input: ToolInput; readonly title?: string }
readonly state?: ToolState
readonly cwd?: string
}): ToolCall {
if (input.state) {
return {
toolCallId: input.toolCallId,
title: toolTitle(input.toolName, input.state),
kind: toToolKind(input.toolName),
status: TOOL_STATUS_MAP[input.state.status],
locations: toLocations(input.toolName, input.state.input, input.cwd),
rawInput: rawInput(input.toolName, input.state.input, input.cwd),
}
}

return {
toolCallId: input.toolCallId,
title: input.state.title || input.toolName,
title: input.toolName,
kind: toToolKind(input.toolName),
status: "pending",
locations: toLocations(input.toolName, input.state.input),
rawInput: input.state.input,
locations: [],
rawInput: {},
}
}

Expand All @@ -138,14 +177,15 @@ export function runningToolUpdate(input: {
readonly toolName: string
readonly state: RunningToolState
readonly output?: string
readonly cwd?: string
}): ToolCallUpdate {
const content = input.output
? [
{
type: "content" as const,
content: {
type: "text" as const,
text: input.output,
text: isShell(input.toolName) ? fenceWith(input.output, "sh") : input.output,
},
},
]
Expand All @@ -155,9 +195,9 @@ export function runningToolUpdate(input: {
toolCallId: input.toolCallId,
status: "in_progress",
kind: toToolKind(input.toolName),
title: input.state.title ?? input.toolName,
locations: toLocations(input.toolName, input.state.input),
rawInput: input.state.input,
title: toolTitle(input.toolName, input.state),
locations: toLocations(input.toolName, input.state.input, input.cwd),
rawInput: rawInput(input.toolName, input.state.input, input.cwd),
...(content ? { content } : {}),
}
}
Expand All @@ -166,57 +206,59 @@ export function duplicateRunningToolUpdate(input: {
readonly toolCallId: string
readonly toolName: string
readonly state: RunningToolState
readonly cwd?: string
}): ToolCallUpdate {
return {
toolCallId: input.toolCallId,
status: "in_progress",
kind: toToolKind(input.toolName),
title: input.state.title ?? input.toolName,
locations: toLocations(input.toolName, input.state.input),
rawInput: input.state.input,
title: toolTitle(input.toolName, input.state),
locations: toLocations(input.toolName, input.state.input, input.cwd),
rawInput: rawInput(input.toolName, input.state.input, input.cwd),
}
}

export function completedToolUpdate(input: {
readonly toolCallId: string
readonly toolName: string
readonly state: CompletedToolState & { readonly title: string }
readonly state: CompletedToolState & { readonly title?: string }
readonly cwd?: string
}): ToolCallUpdate {
return {
toolCallId: input.toolCallId,
status: "completed",
kind: toToolKind(input.toolName),
title: input.state.title,
title: toolTitle(input.toolName, input.state),
locations: toLocations(input.toolName, input.state.input, input.cwd),
rawInput: rawInput(input.toolName, input.state.input, input.cwd),
content: completedToolContent(input.toolName, input.state),
rawInput: input.state.input,
rawOutput: completedToolRawOutput(input.state),
rawOutput: completedToolRawOutputForTool(input.toolName, input.state),
}
}

export function errorToolUpdate(input: {
readonly toolCallId: string
readonly toolName: string
readonly state: ErrorToolState
readonly cwd?: string
}): ToolCallUpdate {
return {
toolCallId: input.toolCallId,
status: "failed",
kind: toToolKind(input.toolName),
title: input.toolName,
rawInput: input.state.input,
title: toolTitle(input.toolName, input.state),
locations: toLocations(input.toolName, input.state.input, input.cwd),
rawInput: rawInput(input.toolName, input.state.input, input.cwd),
content: [
{
type: "content",
content: {
type: "text",
text: input.state.error,
text: isShell(input.toolName) ? fenceWith(input.state.error, "sh") : input.state.error,
},
},
],
rawOutput: {
error: input.state.error,
metadata: input.state.metadata,
},
rawOutput: errorToolRawOutputForTool(input.toolName, input.state),
}
}

Expand Down Expand Up @@ -253,6 +295,76 @@ export function shellOutputSnapshot(state: { readonly metadata?: unknown }) {
return stringValue((state.metadata as Record<string, unknown>).output)
}

function completedToolRawOutputForTool(toolName: string, state: CompletedToolState) {
if (!isShell(toolName)) return completedToolRawOutput(state)
return {
stdout: state.output,
...(state.metadata !== undefined ? { metadata: state.metadata } : {}),
...(state.attachments?.length ? { attachments: state.attachments } : {}),
}
}

function errorToolRawOutputForTool(toolName: string, state: ErrorToolState) {
if (!isShell(toolName)) {
return {
error: state.error,
metadata: state.metadata,
}
}
return {
stderr: state.error,
...(state.metadata !== undefined ? { metadata: state.metadata } : {}),
}
}

function toolTitle(toolName: string, state: ToolState) {
if (isShell(toolName)) return stringValue(state.input.description) ?? shellCommand(state.input) ?? "Terminal"
return ("title" in state && state.title) || toolName
}

function rawInput(toolName: string, input: ToolInput, cwd?: string) {
if (!isShell(toolName)) return input
const workdir = shellWorkdir(input, cwd)
if (!workdir || input.cwd || input.workdir || input.workingDir || input.directory) return input
return { ...input, cwd: workdir }
}

function shellLocation(input: ToolInput, cwd?: string): ToolCallLocation[] {
const workdir = shellWorkdir(input, cwd)
if (!workdir || (!shellCommand(input) && !input.workdir && !input.cwd && !input.workingDir && !input.directory)) return []
return [{ path: workdir }]
}

function shellCommand(input: ToolInput) {
return stringValue(input.command) ?? stringValue(input.cmd)
}

function shellWorkdir(input: ToolInput, cwd?: string) {
const workdir =
stringValue(input.workdir) ?? stringValue(input.cwd) ?? stringValue(input.workingDir) ?? stringValue(input.directory)
return resolvePath(workdir, cwd) ?? cwd
}

function resolvePath(value: string | undefined, cwd?: string) {
if (!value) return undefined
if (isAbsolute(value)) return value
return resolve(cwd ?? process.cwd(), value)
}

function isShell(toolName: string) {
const tool = toolName.toLocaleLowerCase()
return tool === "bash" || tool === "shell"
}

function fenceWith(text: string, lang: string) {
if (!text) return text
let fence = "```"
for (const match of text.matchAll(FENCE_RE)) {
while (match[0].length >= fence.length) fence += "`"
}
return `${fence}${lang}\n${text.replace(/\n+$/, "")}\n${fence}`
}

export const mapToolKind = toToolKind
export const extractLocations = toLocations
export const buildCompletedToolContent = completedToolContent
Expand Down
Loading
Loading