Skip to content

Commit e003a29

Browse files
nexxelnvalidatedev
authored andcommitted
fix(acp): show shell command in ACP tool calls (anomalyco#32304)
Co-authored-by: Mert Can Demir <validatedev@gmail.com>
1 parent 4f830b2 commit e003a29

4 files changed

Lines changed: 93 additions & 29 deletions

File tree

packages/opencode/src/acp/event.ts

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -80,10 +80,11 @@ export class Subscription {
8080
async replayMessage(message: SessionMessageResponse) {
8181
if (message.info.role !== "assistant" && message.info.role !== "user") return
8282

83+
const cwd = message.info.role === "assistant" ? message.info.path?.cwd : undefined
8384
for (const part of message.parts) {
8485
await this.recordFetchedPart(message.info.sessionID, message, part)
8586
if (part.type === "tool") {
86-
await this.handleToolPart(message.info.sessionID, part)
87+
await this.handleToolPart(message.info.sessionID, part, cwd ?? process.cwd())
8788
continue
8889
}
8990
await this.replayContentPart(message, part)
@@ -146,7 +147,7 @@ export class Subscription {
146147
}),
147148
)
148149
if (part.type === "tool") {
149-
await this.handleToolPart(session.id, part)
150+
await this.handleToolPart(session.id, part, session.cwd)
150151
}
151152
}
152153

@@ -231,16 +232,16 @@ export class Subscription {
231232
)
232233
}
233234

234-
private async handleToolPart(sessionId: string, part: ToolPart) {
235-
await this.toolStart(sessionId, part)
235+
private async handleToolPart(sessionId: string, part: ToolPart, cwd: string) {
236+
await this.toolStart(sessionId, part, cwd)
236237

237238
switch (part.state.status) {
238239
case "pending":
239240
this.shellSnapshots.delete(part.callID)
240241
return
241242

242243
case "running":
243-
await this.runningTool(sessionId, part)
244+
await this.runningTool(sessionId, part, cwd)
244245
return
245246

246247
case "completed":
@@ -253,6 +254,7 @@ export class Subscription {
253254
toolCallId: part.callID,
254255
toolName: part.tool,
255256
state: part.state,
257+
cwd,
256258
}),
257259
},
258260
})
@@ -268,14 +270,15 @@ export class Subscription {
268270
toolCallId: part.callID,
269271
toolName: part.tool,
270272
state: part.state,
273+
cwd,
271274
}),
272275
},
273276
})
274277
return
275278
}
276279
}
277280

278-
private async runningTool(sessionId: string, part: ToolPart) {
281+
private async runningTool(sessionId: string, part: ToolPart, cwd: string) {
279282
if (part.state.status !== "running") return
280283

281284
const output = part.tool === "bash" ? shellOutputSnapshot(part.state) : undefined
@@ -289,6 +292,7 @@ export class Subscription {
289292
toolCallId: part.callID,
290293
toolName: part.tool,
291294
state: part.state,
295+
cwd,
292296
}),
293297
},
294298
})
@@ -306,12 +310,13 @@ export class Subscription {
306310
toolName: part.tool,
307311
state: part.state,
308312
output,
313+
cwd,
309314
}),
310315
},
311316
})
312317
}
313318

314-
private async toolStart(sessionId: string, part: ToolPart) {
319+
private async toolStart(sessionId: string, part: ToolPart, cwd: string) {
315320
if (this.toolStarts.has(part.callID)) return
316321
this.toolStarts.add(part.callID)
317322
await this.input.connection.sessionUpdate({
@@ -322,6 +327,7 @@ export class Subscription {
322327
toolCallId: part.callID,
323328
toolName: part.tool,
324329
state: part.state,
330+
cwd,
325331
}),
326332
},
327333
})

packages/opencode/src/acp/tool.ts

Lines changed: 65 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { isAbsolute, resolve } from "path"
12
import type { ToolCall, ToolCallContent, ToolCallLocation, ToolCallUpdate, ToolKind } from "@agentclientprotocol/sdk"
23

34
export type ToolInput = Record<string, unknown>
@@ -69,10 +70,16 @@ export function toToolKind(toolName: string): ToolKind {
6970
}
7071
}
7172

72-
export function toLocations(toolName: string, input: ToolInput): ToolCallLocation[] {
73+
export function toLocations(toolName: string, input: ToolInput, cwd?: string): ToolCallLocation[] {
7374
const tool = toolName.toLocaleLowerCase()
7475

7576
switch (tool) {
77+
case "bash":
78+
case "shell": {
79+
const workdir = shellWorkdir(input, cwd)
80+
return workdir ? [{ path: workdir }] : []
81+
}
82+
7683
case "read":
7784
case "edit":
7885
case "write":
@@ -88,10 +95,6 @@ export function toLocations(toolName: string, input: ToolInput): ToolCallLocatio
8895
case "context7_get_library_docs":
8996
return locationFrom(input.path)
9097

91-
case "bash":
92-
case "shell":
93-
return []
94-
9598
default:
9699
return []
97100
}
@@ -122,14 +125,15 @@ export function pendingToolCall(input: {
122125
readonly toolCallId: string
123126
readonly toolName: string
124127
readonly state: { readonly input: ToolInput; readonly title?: string }
128+
readonly cwd?: string
125129
}): ToolCall {
126130
return {
127131
toolCallId: input.toolCallId,
128-
title: input.state.title || input.toolName,
132+
title: toolTitle(input.toolName, input.state.input, input.state.title),
129133
kind: toToolKind(input.toolName),
130134
status: "pending",
131-
locations: toLocations(input.toolName, input.state.input),
132-
rawInput: input.state.input,
135+
locations: toLocations(input.toolName, input.state.input, input.cwd),
136+
rawInput: rawInput(input.toolName, input.state.input, input.cwd),
133137
}
134138
}
135139

@@ -138,6 +142,7 @@ export function runningToolUpdate(input: {
138142
readonly toolName: string
139143
readonly state: RunningToolState
140144
readonly output?: string
145+
readonly cwd?: string
141146
}): ToolCallUpdate {
142147
const content = input.output
143148
? [
@@ -155,9 +160,9 @@ export function runningToolUpdate(input: {
155160
toolCallId: input.toolCallId,
156161
status: "in_progress",
157162
kind: toToolKind(input.toolName),
158-
title: input.state.title ?? input.toolName,
159-
locations: toLocations(input.toolName, input.state.input),
160-
rawInput: input.state.input,
163+
title: toolTitle(input.toolName, input.state.input, input.state.title),
164+
locations: toLocations(input.toolName, input.state.input, input.cwd),
165+
rawInput: rawInput(input.toolName, input.state.input, input.cwd),
161166
...(content ? { content } : {}),
162167
}
163168
}
@@ -166,29 +171,32 @@ export function duplicateRunningToolUpdate(input: {
166171
readonly toolCallId: string
167172
readonly toolName: string
168173
readonly state: RunningToolState
174+
readonly cwd?: string
169175
}): ToolCallUpdate {
170176
return {
171177
toolCallId: input.toolCallId,
172178
status: "in_progress",
173179
kind: toToolKind(input.toolName),
174-
title: input.state.title ?? input.toolName,
175-
locations: toLocations(input.toolName, input.state.input),
176-
rawInput: input.state.input,
180+
title: toolTitle(input.toolName, input.state.input, input.state.title),
181+
locations: toLocations(input.toolName, input.state.input, input.cwd),
182+
rawInput: rawInput(input.toolName, input.state.input, input.cwd),
177183
}
178184
}
179185

180186
export function completedToolUpdate(input: {
181187
readonly toolCallId: string
182188
readonly toolName: string
183-
readonly state: CompletedToolState & { readonly title: string }
189+
readonly state: CompletedToolState & { readonly title?: string }
190+
readonly cwd?: string
184191
}): ToolCallUpdate {
185192
return {
186193
toolCallId: input.toolCallId,
187194
status: "completed",
188195
kind: toToolKind(input.toolName),
189-
title: input.state.title,
196+
title: toolTitle(input.toolName, input.state.input, input.state.title),
197+
locations: toLocations(input.toolName, input.state.input, input.cwd),
190198
content: completedToolContent(input.toolName, input.state),
191-
rawInput: input.state.input,
199+
rawInput: rawInput(input.toolName, input.state.input, input.cwd),
192200
rawOutput: completedToolRawOutput(input.state),
193201
}
194202
}
@@ -197,13 +205,15 @@ export function errorToolUpdate(input: {
197205
readonly toolCallId: string
198206
readonly toolName: string
199207
readonly state: ErrorToolState
208+
readonly cwd?: string
200209
}): ToolCallUpdate {
201210
return {
202211
toolCallId: input.toolCallId,
203212
status: "failed",
204213
kind: toToolKind(input.toolName),
205-
title: input.toolName,
206-
rawInput: input.state.input,
214+
title: toolTitle(input.toolName, input.state.input, undefined),
215+
locations: toLocations(input.toolName, input.state.input, input.cwd),
216+
rawInput: rawInput(input.toolName, input.state.input, input.cwd),
207217
content: [
208218
{
209219
type: "content",
@@ -253,6 +263,42 @@ export function shellOutputSnapshot(state: { readonly metadata?: unknown }) {
253263
return stringValue((state.metadata as Record<string, unknown>).output)
254264
}
255265

266+
// For shell tools, surface the actual command as the title so it stays visible
267+
// before output lands; non-shell tools keep their model-provided title.
268+
function toolTitle(toolName: string, input: ToolInput, fallback: string | undefined) {
269+
if (isShell(toolName)) return shellCommand(input) ?? stringValue(input.description) ?? fallback ?? toolName
270+
return fallback || toolName
271+
}
272+
273+
// Enrich shell rawInput with the resolved working directory so clients can show
274+
// where the command runs, unless the model already specified one.
275+
function rawInput(toolName: string, input: ToolInput, cwd?: string): ToolInput {
276+
if (!isShell(toolName)) return input
277+
if (input.cwd || input.workdir) return input
278+
const workdir = shellWorkdir(input, cwd)
279+
return workdir ? { ...input, cwd: workdir } : input
280+
}
281+
282+
function shellWorkdir(input: ToolInput, cwd?: string) {
283+
const explicit = stringValue(input.workdir) ?? stringValue(input.cwd)
284+
return resolvePath(explicit, cwd) ?? cwd
285+
}
286+
287+
function resolvePath(value: string | undefined, cwd?: string) {
288+
if (!value) return undefined
289+
if (isAbsolute(value)) return value
290+
return resolve(cwd ?? process.cwd(), value)
291+
}
292+
293+
function shellCommand(input: ToolInput) {
294+
return stringValue(input.command) ?? stringValue(input.cmd)
295+
}
296+
297+
function isShell(toolName: string) {
298+
const tool = toolName.toLocaleLowerCase()
299+
return tool === "bash" || tool === "shell"
300+
}
301+
256302
export const mapToolKind = toToolKind
257303
export const extractLocations = toLocations
258304
export const buildCompletedToolContent = completedToolContent

packages/opencode/test/acp/event.test.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -517,7 +517,7 @@ describe("acp event routing", () => {
517517
expect(harness.updates).toHaveLength(0)
518518
})
519519

520-
it("emits synthetic pending before the first running tool update", async () => {
520+
it("exposes the shell command on the synthetic pending tool call", async () => {
521521
const harness = createHarness()
522522
await Effect.runPromise(harness.session.create({ id: "ses_tool", cwd: "/workspace" }))
523523

@@ -527,7 +527,14 @@ describe("acp event routing", () => {
527527
"tool_call",
528528
"tool_call_update",
529529
])
530-
expect(harness.updates[0]?.update).toMatchObject({ status: "pending", toolCallId: "call_1" })
530+
expect(harness.updates[0]?.update).toMatchObject({
531+
status: "pending",
532+
toolCallId: "call_1",
533+
title: "printf hello",
534+
kind: "execute",
535+
locations: [{ path: "/workspace" }],
536+
rawInput: { cmd: "printf hello", cwd: "/workspace" },
537+
})
531538
expect(harness.updates[1]?.update).toMatchObject({ status: "in_progress", toolCallId: "call_1" })
532539
})
533540

packages/opencode/test/acp/tool.test.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,12 @@ describe("acp tool conversion", () => {
3737
expect(toLocations("external_directory", { directories: ["/tmp/outside"], patterns: ["/tmp/outside/*"] })).toEqual([
3838
{ path: "/tmp/outside" },
3939
])
40-
expect(toLocations("bash", { filePath: "/tmp/nope.ts", path: "/tmp" })).toEqual([])
40+
expect(toLocations("bash", { cmd: "pwd" }, "/workspace")).toEqual([{ path: "/workspace" }])
41+
expect(toLocations("bash", { command: "pwd", workdir: "subdir" }, "/workspace")).toEqual([
42+
{ path: "/workspace/subdir" },
43+
])
44+
expect(toLocations("bash", { command: "pwd", workdir: "/abs/dir" }, "/workspace")).toEqual([{ path: "/abs/dir" }])
45+
expect(toLocations("bash", { command: "printf hello" })).toEqual([])
4146
expect(toLocations("read", { path: "/tmp/missing-file-path.ts" })).toEqual([])
4247
})
4348

0 commit comments

Comments
 (0)