Przeglądaj źródła

fix(acp): show shell command in ACP tool calls (#32304)

Co-authored-by: Mert Can Demir <validatedev@gmail.com>
Shoubhit Dash 2 miesięcy temu
rodzic
commit
51461429f4

+ 13 - 7
packages/opencode/src/acp/event.ts

@@ -80,10 +80,11 @@ export class Subscription {
   async replayMessage(message: SessionMessageResponse) {
     if (message.info.role !== "assistant" && message.info.role !== "user") return
 
+    const cwd = message.info.role === "assistant" ? message.info.path?.cwd : undefined
     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)
+        await this.handleToolPart(message.info.sessionID, part, cwd ?? process.cwd())
         continue
       }
       await this.replayContentPart(message, part)
@@ -146,7 +147,7 @@ export class Subscription {
       }),
     )
     if (part.type === "tool") {
-      await this.handleToolPart(session.id, part)
+      await this.handleToolPart(session.id, part, session.cwd)
     }
   }
 
@@ -231,8 +232,8 @@ 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":
@@ -240,7 +241,7 @@ export class Subscription {
         return
 
       case "running":
-        await this.runningTool(sessionId, part)
+        await this.runningTool(sessionId, part, cwd)
         return
 
       case "completed":
@@ -253,6 +254,7 @@ export class Subscription {
               toolCallId: part.callID,
               toolName: part.tool,
               state: part.state,
+              cwd,
             }),
           },
         })
@@ -268,6 +270,7 @@ export class Subscription {
               toolCallId: part.callID,
               toolName: part.tool,
               state: part.state,
+              cwd,
             }),
           },
         })
@@ -275,7 +278,7 @@ export class Subscription {
     }
   }
 
-  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
@@ -289,6 +292,7 @@ export class Subscription {
               toolCallId: part.callID,
               toolName: part.tool,
               state: part.state,
+              cwd,
             }),
           },
         })
@@ -306,12 +310,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({
@@ -322,6 +327,7 @@ export class Subscription {
           toolCallId: part.callID,
           toolName: part.tool,
           state: part.state,
+          cwd,
         }),
       },
     })

+ 65 - 19
packages/opencode/src/acp/tool.ts

@@ -1,3 +1,4 @@
+import { isAbsolute, resolve } from "path"
 import type { ToolCall, ToolCallContent, ToolCallLocation, ToolCallUpdate, ToolKind } from "@agentclientprotocol/sdk"
 
 export type ToolInput = Record<string, unknown>
@@ -69,10 +70,16 @@ 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": {
+      const workdir = shellWorkdir(input, cwd)
+      return workdir ? [{ path: workdir }] : []
+    }
+
     case "read":
     case "edit":
     case "write":
@@ -88,10 +95,6 @@ export function toLocations(toolName: string, input: ToolInput): ToolCallLocatio
     case "context7_get_library_docs":
       return locationFrom(input.path)
 
-    case "bash":
-    case "shell":
-      return []
-
     default:
       return []
   }
@@ -122,14 +125,15 @@ 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: input.state.title || input.toolName,
+    title: toolTitle(input.toolName, input.state.input, input.state.title),
     kind: toToolKind(input.toolName),
     status: "pending",
-    locations: toLocations(input.toolName, input.state.input),
-    rawInput: input.state.input,
+    locations: toLocations(input.toolName, input.state.input, input.cwd),
+    rawInput: rawInput(input.toolName, input.state.input, input.cwd),
   }
 }
 
@@ -138,6 +142,7 @@ export function runningToolUpdate(input: {
   readonly toolName: string
   readonly state: RunningToolState
   readonly output?: string
+  readonly cwd?: string
 }): ToolCallUpdate {
   const content = input.output
     ? [
@@ -155,9 +160,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.input, input.state.title),
+    locations: toLocations(input.toolName, input.state.input, input.cwd),
+    rawInput: rawInput(input.toolName, input.state.input, input.cwd),
     ...(content ? { content } : {}),
   }
 }
@@ -166,29 +171,32 @@ 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.input, input.state.title),
+    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.input, input.state.title),
+    locations: toLocations(input.toolName, input.state.input, input.cwd),
     content: completedToolContent(input.toolName, input.state),
-    rawInput: input.state.input,
+    rawInput: rawInput(input.toolName, input.state.input, input.cwd),
     rawOutput: completedToolRawOutput(input.state),
   }
 }
@@ -197,13 +205,15 @@ 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.input, undefined),
+    locations: toLocations(input.toolName, input.state.input, input.cwd),
+    rawInput: rawInput(input.toolName, input.state.input, input.cwd),
     content: [
       {
         type: "content",
@@ -253,6 +263,42 @@ export function shellOutputSnapshot(state: { readonly metadata?: unknown }) {
   return stringValue((state.metadata as Record<string, unknown>).output)
 }
 
+// For shell tools, surface the actual command as the title so it stays visible
+// before output lands; non-shell tools keep their model-provided title.
+function toolTitle(toolName: string, input: ToolInput, fallback: string | undefined) {
+  if (isShell(toolName)) return shellCommand(input) ?? stringValue(input.description) ?? fallback ?? toolName
+  return fallback || toolName
+}
+
+// Enrich shell rawInput with the resolved working directory so clients can show
+// where the command runs, unless the model already specified one.
+function rawInput(toolName: string, input: ToolInput, cwd?: string): ToolInput {
+  if (!isShell(toolName)) return input
+  if (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)
+  return resolvePath(explicit, 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 shellCommand(input: ToolInput) {
+  return stringValue(input.command) ?? stringValue(input.cmd)
+}
+
+function isShell(toolName: string) {
+  const tool = toolName.toLocaleLowerCase()
+  return tool === "bash" || tool === "shell"
+}
+
 export const mapToolKind = toToolKind
 export const extractLocations = toLocations
 export const buildCompletedToolContent = completedToolContent

+ 9 - 2
packages/opencode/test/acp/event.test.ts

@@ -517,7 +517,7 @@ describe("acp event routing", () => {
     expect(harness.updates).toHaveLength(0)
   })
 
-  it("emits synthetic pending before the first running tool update", async () => {
+  it("exposes the shell command on the synthetic pending tool call", async () => {
     const harness = createHarness()
     await Effect.runPromise(harness.session.create({ id: "ses_tool", cwd: "/workspace" }))
 
@@ -527,7 +527,14 @@ describe("acp event routing", () => {
       "tool_call",
       "tool_call_update",
     ])
-    expect(harness.updates[0]?.update).toMatchObject({ status: "pending", toolCallId: "call_1" })
+    expect(harness.updates[0]?.update).toMatchObject({
+      status: "pending",
+      toolCallId: "call_1",
+      title: "printf hello",
+      kind: "execute",
+      locations: [{ path: "/workspace" }],
+      rawInput: { cmd: "printf hello", cwd: "/workspace" },
+    })
     expect(harness.updates[1]?.update).toMatchObject({ status: "in_progress", toolCallId: "call_1" })
   })
 

+ 6 - 1
packages/opencode/test/acp/tool.test.ts

@@ -37,7 +37,12 @@ describe("acp tool conversion", () => {
     expect(toLocations("external_directory", { directories: ["/tmp/outside"], patterns: ["/tmp/outside/*"] })).toEqual([
       { path: "/tmp/outside" },
     ])
-    expect(toLocations("bash", { filePath: "/tmp/nope.ts", path: "/tmp" })).toEqual([])
+    expect(toLocations("bash", { cmd: "pwd" }, "/workspace")).toEqual([{ path: "/workspace" }])
+    expect(toLocations("bash", { command: "pwd", workdir: "subdir" }, "/workspace")).toEqual([
+      { path: "/workspace/subdir" },
+    ])
+    expect(toLocations("bash", { command: "pwd", workdir: "/abs/dir" }, "/workspace")).toEqual([{ path: "/abs/dir" }])
+    expect(toLocations("bash", { command: "printf hello" })).toEqual([])
     expect(toLocations("read", { path: "/tmp/missing-file-path.ts" })).toEqual([])
   })