diff --git a/packages/coding-agent/src/modes/acp/acp-agent.ts b/packages/coding-agent/src/modes/acp/acp-agent.ts index 7ff07b073..f57c3736d 100644 --- a/packages/coding-agent/src/modes/acp/acp-agent.ts +++ b/packages/coding-agent/src/modes/acp/acp-agent.ts @@ -66,7 +66,11 @@ import { import { ACP_BUILTIN_SLASH_COMMANDS, executeAcpBuiltinSlashCommand } from "../../slash-commands/acp-builtins"; import { parseThinkingLevel } from "../../thinking"; import { createAcpClientBridge } from "./acp-client-bridge"; -import { mapAgentSessionEventToAcpSessionUpdates, mapToolKind } from "./acp-event-mapper"; +import { + buildToolCallStartUpdate, + mapAgentSessionEventToAcpSessionUpdates, + normalizeReplayToolArguments, +} from "./acp-event-mapper"; import { ACP_TERMINAL_AUTH_FLAG } from "./terminal-auth"; const ACP_DEFAULT_MODE_ID = "default"; @@ -1590,7 +1594,7 @@ export class AcpAgent implements Agent { #messageToReplayNotifications(sessionId: string, message: ReplayableMessage, cwd: string): SessionNotification[] { if (message.role === "assistant") { - return this.#replayAssistantMessage(sessionId, message); + return this.#replayAssistantMessage(sessionId, message, cwd); } if ( message.role === "user" || @@ -1631,7 +1635,7 @@ export class AcpAgent implements Agent { return []; } - #replayAssistantMessage(sessionId: string, message: ReplayableMessage): SessionNotification[] { + #replayAssistantMessage(sessionId: string, message: ReplayableMessage, cwd: string): SessionNotification[] { const notifications: SessionNotification[] = []; const messageId = crypto.randomUUID(); if (Array.isArray(message.content)) { @@ -1673,16 +1677,14 @@ export class AcpAgent implements Agent { "name" in item && typeof item.name === "string" ) { - const update: SessionUpdate = { - sessionUpdate: "tool_call", + const args = normalizeReplayToolArguments("arguments" in item ? item.arguments : undefined).args; + const update = buildToolCallStartUpdate({ toolCallId: item.id, - title: item.name, - kind: mapToolKind(item.name), + toolName: item.name, + args, status: "completed", - }; - if ("arguments" in item && typeof item.arguments === "string") { - update.rawInput = item.arguments; - } + cwd, + }); notifications.push({ sessionId, update }); } } diff --git a/packages/coding-agent/src/modes/acp/acp-event-mapper.ts b/packages/coding-agent/src/modes/acp/acp-event-mapper.ts index 37b702a61..63034c8b3 100644 --- a/packages/coding-agent/src/modes/acp/acp-event-mapper.ts +++ b/packages/coding-agent/src/modes/acp/acp-event-mapper.ts @@ -335,6 +335,18 @@ export function buildToolCallStartUpdate(input: { return update; } +export function normalizeReplayToolArguments(value: unknown): { args: unknown } { + if (typeof value !== "string") { + return { args: value ?? {} }; + } + try { + const parsed: unknown = JSON.parse(value); + return { args: parsed }; + } catch { + return { args: value }; + } +} + function buildToolStartContent(toolName: string, args: unknown): ToolCallContent[] { if (!isCommandToolName(toolName)) { return []; diff --git a/packages/coding-agent/test/acp-event-mapper.test.ts b/packages/coding-agent/test/acp-event-mapper.test.ts index 646abdf59..c30dda7a9 100644 --- a/packages/coding-agent/test/acp-event-mapper.test.ts +++ b/packages/coding-agent/test/acp-event-mapper.test.ts @@ -2,7 +2,11 @@ import { describe, expect, it } from "bun:test"; import path from "node:path"; import type { SessionNotification } from "@agentclientprotocol/sdk"; import { zSessionNotification } from "@agentclientprotocol/sdk/dist/schema/zod.gen.js"; -import { mapAgentSessionEventToAcpSessionUpdates } from "../src/modes/acp/acp-event-mapper"; +import { + buildToolCallStartUpdate, + mapAgentSessionEventToAcpSessionUpdates, + normalizeReplayToolArguments, +} from "../src/modes/acp/acp-event-mapper"; import type { AgentSessionEvent } from "../src/session/agent-session"; import { expectAcpStructure, expectAcpStructureRejects } from "./helpers/acp-schema"; @@ -273,6 +277,91 @@ describe("ACP event mapper", () => { expect(update.content).toEqual([{ type: "content", content: { type: "text", text: "$ npm run check" } }]); }); + it("builds replayed bash tool calls from JSON string arguments", () => { + const replayArgs = normalizeReplayToolArguments(JSON.stringify({ command: "npm test", cwd: "/repo" })); + const update = buildToolCallStartUpdate({ + toolCallId: "toolu_replay_1", + toolName: "bash", + args: replayArgs.args, + status: "completed", + }); + + expectAcpStructure(zSessionNotification, { sessionId: "session-1", update }); + expect(update).toMatchObject({ + sessionUpdate: "tool_call", + toolCallId: "toolu_replay_1", + title: "bash: npm test", + kind: "execute", + status: "completed", + rawInput: { command: "npm test", cwd: "/repo" }, + content: [{ type: "content", content: { type: "text", text: "$ npm test" } }], + }); + }); + + it("builds replayed read tool-call locations against the replay cwd", () => { + const replayArgs = normalizeReplayToolArguments(JSON.stringify({ path: "src/foo.ts" })); + const update = buildToolCallStartUpdate({ + toolCallId: "toolu_replay_read", + toolName: "read", + args: replayArgs.args, + cwd: path.resolve("/repo"), + status: "completed", + }); + + expectAcpStructure(zSessionNotification, { sessionId: "session-1", update }); + expect(update).toMatchObject({ + sessionUpdate: "tool_call", + toolCallId: "toolu_replay_read", + title: "read: src/foo.ts", + kind: "read", + status: "completed", + rawInput: { path: "src/foo.ts" }, + locations: [{ path: path.resolve("/repo", "src/foo.ts") }], + }); + expect("content" in update).toBe(false); + }); + + it("keeps malformed replay arguments as raw input without command content", () => { + const replayArgs = normalizeReplayToolArguments("{not json"); + const update = buildToolCallStartUpdate({ + toolCallId: "toolu_replay_bad", + toolName: "bash", + args: replayArgs.args, + status: "completed", + }); + + expectAcpStructure(zSessionNotification, { sessionId: "session-1", update }); + expect(update).toMatchObject({ + sessionUpdate: "tool_call", + toolCallId: "toolu_replay_bad", + title: "bash", + kind: "execute", + status: "completed", + rawInput: "{not json", + }); + expect("content" in update).toBe(false); + }); + + it("keeps object replay arguments unchanged and builds command content", () => { + const rawArgs = { command: "bun test", cwd: "/repo" }; + const replayArgs = normalizeReplayToolArguments(rawArgs); + const update = buildToolCallStartUpdate({ + toolCallId: "toolu_replay_object", + toolName: "bash", + args: replayArgs.args, + status: "completed", + }); + + expect(replayArgs.args).toBe(rawArgs); + expectAcpStructure(zSessionNotification, { sessionId: "session-1", update }); + expect(update).toMatchObject({ + title: "bash: bun test", + status: "completed", + rawInput: rawArgs, + content: [{ type: "content", content: { type: "text", text: "$ bun test" } }], + }); + }); + it("does not add command text content to non-command tool starts", () => { const updates = mapAgentSessionEventToAcpSessionUpdates( {