diff --git a/.pr/issue-132/01-home.png b/.pr/issue-132/01-home.png
new file mode 100644
index 0000000000..dea811ce0e
Binary files /dev/null and b/.pr/issue-132/01-home.png differ
diff --git a/.pr/issue-132/02-conversation-changes.png b/.pr/issue-132/02-conversation-changes.png
new file mode 100644
index 0000000000..2e39775655
Binary files /dev/null and b/.pr/issue-132/02-conversation-changes.png differ
diff --git a/.pr/issue-132/04-acp-cards-collapsed.png b/.pr/issue-132/04-acp-cards-collapsed.png
new file mode 100644
index 0000000000..e7b40f5c64
Binary files /dev/null and b/.pr/issue-132/04-acp-cards-collapsed.png differ
diff --git a/.pr/issue-132/04-acp-rendering-notes.md b/.pr/issue-132/04-acp-rendering-notes.md
new file mode 100644
index 0000000000..20f6e60f1f
--- /dev/null
+++ b/.pr/issue-132/04-acp-rendering-notes.md
@@ -0,0 +1,62 @@
+# Visual proof of ACP tool-call rendering — addendum to demo.gif
+
+This addresses the reviewer note that the original demo did not show ACP
+events being rendered.
+
+## Why a synthetic injection
+
+`openhands-agent-server@1.20.1` cannot drive an ACP sub-agent inside the
+sandbox (no Claude Code / Codex / Gemini CLI binaries, no ACP transport
+configured), so the only way to exercise this code path against
+`npm run dev` is to inject `ACPToolCallEvent` objects directly into the
+running `useEventStore`. The injection is gated by
+`if (import.meta.env.DEV)` and tree-shaken from production builds — it
+only re-exports the existing Zustand store onto `window.__OH_EVENT_STORE__`
+for fixture/preview tooling. No production behaviour changes.
+
+## What the GIF shows
+
+`demo.gif` alternates between two real screenshots from the dev server:
+
+1. **`04-acp-cards-collapsed.png`** — Three `ACPToolCallEvent` cards
+ rendered under a synthetic user message:
+ - `Running gh pr diff 14246` (tool_kind=`execute`, status=`completed`)
+ - `Reading src/components/v1/chat/event-message.tsx` (tool_kind=`read`, status=`completed`)
+ - `Editing src/utils/handle-event-for-ui.ts` (tool_kind=`edit`, status=`failed`)
+ The first two show the green "completed" success indicator; the third
+ has no indicator (failed terminal state). All three render through
+ the same `GenericEventMessageWrapper` path that ports OpenHands#13994 +
+ #14246 + #14247 added.
+2. **`05-acp-cards-expanded.png`** — Same conversation with the first
+ two cards expanded:
+ - The `execute` card surfaces `Command:` and `Output:` blocks with
+ the SHTTP-style code block treatment.
+ - The `read` card surfaces `Input:` (JSON) and `Output:` (file
+ contents) — the same shape `getACPToolCallContent` produces for
+ read tools.
+ The localized titles are pulled from `ACTION_MESSAGE$ACP_RUN`,
+ `ACTION_MESSAGE$ACP_READ`, `ACTION_MESSAGE$ACP_EDIT` etc.; the
+ `make-i18n` step generated the matching declaration enum and locale
+ bundles for all 15 locales.
+
+## Functional verification
+
+```
+$ npx vitest run \
+ __tests__/components/v1/chat/event-content-helpers/get-acp-tool-call-content.test.ts \
+ __tests__/components/v1/chat/event-message-acp-tool-call.test.tsx
+…
+Test Files 2 passed (2)
+ Tests 20 passed (20)
+```
+
+Plus the existing handle-event-for-ui and type-guard suites already in
+the PR.
+
+## Capture script
+
+The capture is reproducible via `tmp-capture-acp.mjs` (committed
+alongside this note for traceability) — it stubs the conversation
+events endpoint, calls `useEventStore.setState({...})` with three
+synthetic `ACPToolCallEvent`s plus a lead-in user `MessageEvent`, then
+screenshots collapsed and expanded states.
diff --git a/.pr/issue-132/05-acp-cards-expanded.png b/.pr/issue-132/05-acp-cards-expanded.png
new file mode 100644
index 0000000000..c8b319bd5f
Binary files /dev/null and b/.pr/issue-132/05-acp-cards-expanded.png differ
diff --git a/.pr/issue-132/capture.mjs b/.pr/issue-132/capture.mjs
new file mode 100644
index 0000000000..8c887b3b06
--- /dev/null
+++ b/.pr/issue-132/capture.mjs
@@ -0,0 +1,180 @@
+import { chromium } from "playwright";
+import { mkdirSync } from "node:fs";
+
+mkdirSync(".pr/issue-132", { recursive: true });
+
+const URL = "https://work-1-yrsrggnfhzzshrxo.prod-runtime.all-hands.dev";
+const OUT = ".pr/issue-132";
+const CONV = "2c9236f8-7405-469a-afbe-f91788ba7e93";
+
+const acpEvents = [
+ // Lead-in user message so v1UserEventsExist passes and the chat renders.
+ {
+ id: "user-evt-0",
+ timestamp: "2026-05-07T14:29:55.000Z",
+ source: "user",
+ kind: "MessageEvent",
+ llm_message: {
+ role: "user",
+ content: [
+ { type: "text", text: "Use Claude Code to inspect the diff for upstream PR 14246, peek at event-message.tsx, then try to patch handle-event-for-ui.ts." },
+ ],
+ },
+ activated_skills: [],
+ },
+ // Execute (shell command, completed) — should render as
+ // "ACPToolCallEvent · gh pr diff 14246" with Command/Output blocks
+ {
+ id: "acp-evt-1",
+ timestamp: "2026-05-07T14:30:00.000Z",
+ source: "agent",
+ kind: "ACPToolCallEvent",
+ tool_call_id: "tc_exec_1",
+ title: "gh pr diff 14246",
+ status: "completed",
+ tool_kind: "execute",
+ raw_input: { command: "gh pr diff 14246 --repo OpenHands/OpenHands" },
+ raw_output:
+ "diff --git a/frontend/src/components/.../acp-tool-call-card.tsx\n@@ ...\n+import { ACPToolCallEvent } from ...\n+\n+const result = getACPToolCallResult(event);\n+return ;",
+ content: null,
+ is_error: false,
+ },
+ // Read (file read, completed) — rendered with JSON Input: block
+ {
+ id: "acp-evt-2",
+ timestamp: "2026-05-07T14:30:05.000Z",
+ source: "agent",
+ kind: "ACPToolCallEvent",
+ tool_call_id: "tc_read_1",
+ title: "src/components/v1/chat/event-message.tsx",
+ status: "completed",
+ tool_kind: "read",
+ raw_input: { path: "src/components/v1/chat/event-message.tsx", limit: 80 },
+ raw_output:
+ "import React from 'react';\nimport { isACPToolCallEvent } from '#/types/v1/type-guards';\n…\nif (isACPToolCallEvent(event)) {\n return ;\n}",
+ content: null,
+ is_error: false,
+ },
+ // Edit (failed) — rendered with **Error:** block instead of Output:
+ {
+ id: "acp-evt-3",
+ timestamp: "2026-05-07T14:30:10.000Z",
+ source: "agent",
+ kind: "ACPToolCallEvent",
+ tool_call_id: "tc_edit_1",
+ title: "src/utils/handle-event-for-ui.ts",
+ status: "failed",
+ tool_kind: "edit",
+ raw_input: { path: "src/utils/handle-event-for-ui.ts", anchor: "isACPToolCallEvent" },
+ raw_output: "anchor not found: isACPToolCallEvent",
+ content: null,
+ is_error: true,
+ },
+];
+
+const browser = await chromium.launch({ headless: true });
+const ctx = await browser.newContext({ viewport: { width: 1400, height: 900 } });
+const page = await ctx.newPage();
+
+// Intercept the events stream so the live conversation can't overwrite our
+// injected uiEvents. The frontend uses an SSE-style endpoint
+// /api/conversations//events for backfill. Cancel WebSocket / EventSource
+// traffic too.
+await page.route("**/api/conversations/*/events*", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({ items: [], next_page_id: null, has_more: false }),
+ });
+});
+
+await page.goto(`${URL}/conversations/${CONV}`, { waitUntil: "networkidle" });
+await page.waitForTimeout(2500);
+
+// Inject the synthetic events into the dev-only window.__OH_EVENT_STORE__.
+// We replace the uiEvents directly so the chat renders only our cards
+// (clean demo) without interference from existing conversation traffic.
+page.on("console", (msg) => console.log("[browser]", msg.type(), msg.text()));
+
+const injected = await page.evaluate((events) => {
+ const store = (window).__OH_EVENT_STORE__;
+ if (!store?.setState) return { ok: false, reason: "store not exposed on window" };
+ store.setState({
+ events,
+ eventIds: new Set(events.map((e) => e.id)),
+ uiEvents: events,
+ });
+ console.log("OH-DEBUG store after setState:", JSON.stringify({
+ eventCount: store.getState().events.length,
+ uiEventCount: store.getState().uiEvents.length,
+ sample: store.getState().uiEvents[0],
+ }));
+ return { ok: true, count: events.length };
+}, acpEvents);
+console.log("inject:", injected);
+
+await page.waitForTimeout(1500);
+
+// Debug: re-read store
+const after = await page.evaluate(() => {
+ const s = (window).__OH_EVENT_STORE__?.getState();
+ return {
+ events: s?.events?.length,
+ uiEvents: s?.uiEvents?.length,
+ sampleKind: s?.uiEvents?.[0]?.kind,
+ chatChildren:
+ document.querySelector("main, [role='main']")?.innerHTML?.slice(0, 500),
+ };
+});
+console.log("post-wait:", after);
+
+// Hide the right-side Changes panel if open so the chat takes more width
+const changesBtn = page.getByRole("button", { name: /changes/i });
+if (await changesBtn.count()) {
+ try {
+ await changesBtn.first().click();
+ await page.waitForTimeout(400);
+ } catch {}
+}
+await page.screenshot({ path: `${OUT}/04-acp-cards-collapsed.png`, fullPage: false });
+
+// Expand the cards by clicking each cursor-pointer header button
+const headers = page.locator("button.cursor-pointer.text-left");
+const count = await headers.count();
+console.log("headers found:", count);
+for (let i = 0; i < count; i += 1) {
+ try {
+ await headers.nth(i).click();
+ await page.waitForTimeout(200);
+ } catch (e) {
+ console.log("click err:", String(e).slice(0, 200));
+ }
+}
+await page.waitForTimeout(500);
+await page.screenshot({ path: `${OUT}/05-acp-cards-expanded.png`, fullPage: false });
+
+// Frame 3: re-collapse the first two cards so the failed Edit card moves
+// into the viewport, capturing the Error block.
+await page.evaluate(() => {
+ const headers = Array.from(document.querySelectorAll("button.cursor-pointer.text-left"));
+ const collapse = headers.filter(
+ (b) =>
+ b.textContent?.includes("gh pr diff 14246") ||
+ b.textContent?.includes("event-message.tsx"),
+ );
+ collapse.forEach((h) => h.click());
+});
+await page.waitForTimeout(400);
+// Then expand the failed card if it isn't already
+await page.evaluate(() => {
+ const headers = Array.from(document.querySelectorAll("button.cursor-pointer.text-left"));
+ const edit = headers.find((b) => b.textContent?.includes("handle-event-for-ui"));
+ // ensure expanded — if collapsed (chevron rotated), click to open
+ const chev = edit?.querySelector("[aria-expanded='false'], .rotate-0, svg");
+ if (edit && chev) edit.click();
+});
+await page.waitForTimeout(500);
+await page.screenshot({ path: `${OUT}/06-acp-card-failed.png`, fullPage: false });
+
+await browser.close();
+console.log("done");
diff --git a/.pr/issue-132/demo.gif b/.pr/issue-132/demo.gif
new file mode 100644
index 0000000000..40786fa979
Binary files /dev/null and b/.pr/issue-132/demo.gif differ
diff --git a/.pr/issue-132/visual-verification.md b/.pr/issue-132/visual-verification.md
new file mode 100644
index 0000000000..b725910996
--- /dev/null
+++ b/.pr/issue-132/visual-verification.md
@@ -0,0 +1,83 @@
+# Visual verification — Issue #132 (ACP tool-call rendering)
+
+This port mirrors three upstream OpenHands frontend PRs:
+
+- [OpenHands#13994](https://github.com/OpenHands/OpenHands/pull/13994) — initial ACP tool-call event support.
+- [OpenHands#14246](https://github.com/OpenHands/OpenHands/pull/14246) — drop the `ACP · ` prefix from titles.
+- [OpenHands#14247](https://github.com/OpenHands/OpenHands/pull/14247) — suppress `in_progress` events so empty-args cards don't flash.
+
+## Live verification (`npm run dev`)
+
+`agent-canvas` was rebuilt from the `port/acp-tool-call-rendering` branch
+and run against a local `openhands-agent-server` 1.20.1 (started by
+`scripts/dev-safe.mjs`). The frontend started cleanly on port `12000` and
+served a working chat UI with the new ACP code path active.
+
+Frames in `demo.gif`:
+
+1. **`01-home.png`** — Agent Canvas home page (`Let's Start Building!`)
+ confirming `npm run dev` is up and the bundle loaded with the new
+ `ACPToolCallEvent` type, type-guard, and rendering path.
+2. **`02-conversation-changes.png`** — Inside an existing conversation
+ with the `Changes` panel open. The panel lists every new and modified
+ file in this port:
+ - New: `src/types/v1/core/events/acp-tool-call-event.ts`
+ - New: `src/components/v1/chat/event-content-helpers/get-acp-tool-call-content.ts`
+ - Modified: `event-message.tsx`, `should-render-event.ts`,
+ `generic-event-message-wrapper.tsx`, `get-event-content.tsx`,
+ `get-observation-result.ts`, `handle-event-for-ui.ts`,
+ `type-guards.ts`, `openhands-event.ts`, `events/index.ts`,
+ `i18n/translation.json`
+ - Plus four matching test files under `__tests__/`.
+
+## Functional verification (unit tests)
+
+39 new/modified ACP-specific test cases pass:
+
+```
+$ npx vitest run \
+ __tests__/components/v1/chat/event-content-helpers/get-acp-tool-call-content.test.ts \
+ __tests__/components/v1/chat/event-content-helpers/should-render-event.test.ts \
+ __tests__/utils/handle-event-for-ui.test.ts \
+ __tests__/components/v1/chat/event-message-acp-tool-call.test.tsx
+…
+Test Files 4 passed (4)
+ Tests 39 passed (39)
+```
+
+Coverage:
+
+- `getACPToolCallTitleKey` — picks the right `ACTION_MESSAGE$ACP_*` key
+ for every `tool_kind` (execute / edit / read / fetch / other / null).
+- `getACPToolCallContent` — formats execute calls with `Command:` +
+ `Output:` blocks (matching `getTerminalObservationContent`), formats
+ non-execute calls with a JSON `Input:` block, swaps `Output:` for
+ `**Error:**` when `is_error`, falls back to the shared
+ `OBSERVATION$COMMAND_NO_OUTPUT` copy, truncates >`MAX_CONTENT_LENGTH`,
+ and serialises structured outputs as JSON.
+- `getACPToolCallResult` — maps `status` + `is_error` to the same
+ `success | error | undefined` triplet used for observation events;
+ `in_progress` returns `undefined` so no check mark renders mid-call.
+- `shouldRenderEvent` — suppresses `in_progress` ACP events (matches
+ upstream PR #14247) while still rendering `completed` / `failed` /
+ `null` (backwards-compat) statuses.
+- `handleEventForUI` — dedupes ACP events by `tool_call_id`: the first
+ event is appended; later events with the same id replace the existing
+ entry at its original position; events with different ids are kept
+ separate (matches upstream PR #14246's discussion of streaming
+ state transitions).
+- `EventMessage` dispatch — confirms ACPToolCallEvent renders through
+ the same `GenericEventMessageWrapper` as observation events, shows
+ the success indicator on completed calls, hides it for in-progress
+ calls, and exposes the markdown body when the card is expanded.
+
+## Why no live ACP capture in the GIF
+
+Triggering a real ACP sub-agent (Claude Code, Codex, Gemini CLI) in this
+sandbox is out of scope — those require a separately configured ACP
+backend in the agent server. The upstream PR #14246 includes a
+Storybook story (`ACPToolCallCard.stories.tsx`) that exercises the
+exact component path this port now uses; that story plus the 39
+agent-canvas unit cases above provide the rendering verification, and
+the `demo.gif` here verifies the unmodified non-ACP UI still renders
+cleanly with the new code in place (no regressions).
diff --git a/__tests__/components/v1/chat/event-content-helpers/get-acp-tool-call-content.test.ts b/__tests__/components/v1/chat/event-content-helpers/get-acp-tool-call-content.test.ts
new file mode 100644
index 0000000000..1ba5ef1550
--- /dev/null
+++ b/__tests__/components/v1/chat/event-content-helpers/get-acp-tool-call-content.test.ts
@@ -0,0 +1,142 @@
+import { describe, it, expect } from "vitest";
+import {
+ getACPToolCallContent,
+ getACPToolCallTitleKey,
+} from "#/components/v1/chat/event-content-helpers/get-acp-tool-call-content";
+import { getACPToolCallResult } from "#/components/v1/chat/event-content-helpers/get-observation-result";
+import { ACPToolCallEvent } from "#/types/v1/core/events/acp-tool-call-event";
+
+const baseEvent: ACPToolCallEvent = {
+ kind: "ACPToolCallEvent",
+ id: "evt-1",
+ timestamp: "2026-04-16T19:32:29.828069",
+ source: "agent",
+ tool_call_id: "toolu_123",
+ title: "gh pr diff 490 --repo OpenHands/evaluation",
+ tool_kind: "execute",
+ status: "completed",
+ raw_input: { command: "gh pr diff 490 --repo OpenHands/evaluation" },
+ raw_output: "diff --git a/foo b/foo\n+added\n",
+ content: null,
+ is_error: false,
+};
+
+const makeEvent = (overrides: Partial): ACPToolCallEvent => ({
+ ...baseEvent,
+ ...overrides,
+});
+
+describe("getACPToolCallTitleKey", () => {
+ it.each([
+ ["execute", "ACTION_MESSAGE$ACP_RUN"],
+ ["edit", "ACTION_MESSAGE$ACP_EDIT"],
+ ["read", "ACTION_MESSAGE$ACP_READ"],
+ ["fetch", "ACTION_MESSAGE$ACP_FETCH"],
+ ["other", "ACTION_MESSAGE$ACP_TOOL"],
+ ] as const)("maps tool_kind=%s to %s", (toolKind, expectedKey) => {
+ expect(getACPToolCallTitleKey(makeEvent({ tool_kind: toolKind }))).toBe(
+ expectedKey,
+ );
+ });
+
+ it("falls back to ACP_TOOL when tool_kind is null", () => {
+ expect(getACPToolCallTitleKey(makeEvent({ tool_kind: null }))).toBe(
+ "ACTION_MESSAGE$ACP_TOOL",
+ );
+ });
+});
+
+describe("getACPToolCallContent", () => {
+ it("renders execute tool calls with Command: and Output: blocks, matching terminal observations", () => {
+ const content = getACPToolCallContent(baseEvent);
+
+ expect(content).toContain(
+ "Command: `gh pr diff 490 --repo OpenHands/evaluation`",
+ );
+ expect(content).toContain("Output:");
+ expect(content).toContain("```");
+ expect(content).toContain("diff --git a/foo b/foo");
+ });
+
+ it("renders non-execute tool calls with an Input: JSON block", () => {
+ const content = getACPToolCallContent(
+ makeEvent({
+ tool_kind: "edit",
+ raw_input: { path: "/workspace/foo.py", content: "print('hi')\n" },
+ raw_output: "ok",
+ }),
+ );
+
+ expect(content).toContain("Input:");
+ expect(content).toContain("```json");
+ expect(content).toContain('"path": "/workspace/foo.py"');
+ expect(content).toContain("Output:");
+ expect(content).toContain("ok");
+ });
+
+ it("uses **Error:** for the output block when is_error is true", () => {
+ const content = getACPToolCallContent(
+ makeEvent({ is_error: true, raw_output: "permission denied" }),
+ );
+
+ expect(content).toContain("**Error:**");
+ expect(content).toContain("permission denied");
+ expect(content).not.toContain("Output:\n```\npermission denied");
+ });
+
+ it("falls back to the shared no-output message when raw_output is empty", () => {
+ const content = getACPToolCallContent(
+ makeEvent({ raw_output: null, raw_input: { command: "true" } }),
+ );
+
+ // Mirrors getTerminalObservationContent which uses the same i18n key.
+ expect(content).toContain("Output:");
+ expect(content).toContain("OBSERVATION$COMMAND_NO_OUTPUT");
+ });
+
+ it("truncates very long output to MAX_CONTENT_LENGTH with an ellipsis", () => {
+ const huge = "x".repeat(5000);
+ const content = getACPToolCallContent(makeEvent({ raw_output: huge }));
+
+ // MAX_CONTENT_LENGTH = 1000 in shared.ts; mirror that budget.
+ expect(content).toMatch(/x{1000}\.\.\./);
+ expect(content).not.toMatch(/x{1001}/);
+ });
+
+ it("serialises structured raw_output as JSON", () => {
+ const content = getACPToolCallContent(
+ makeEvent({
+ tool_kind: "fetch",
+ raw_input: { url: "https://example.com" },
+ raw_output: { status: 200, body: "ok" },
+ }),
+ );
+
+ expect(content).toContain('"status": 200');
+ expect(content).toContain('"body": "ok"');
+ });
+});
+
+describe("getACPToolCallResult", () => {
+ it("returns success for completed, non-error events", () => {
+ expect(getACPToolCallResult(baseEvent)).toBe("success");
+ });
+
+ it("returns error for failed status", () => {
+ expect(getACPToolCallResult(makeEvent({ status: "failed" }))).toBe("error");
+ });
+
+ it("returns error when is_error is true regardless of status", () => {
+ expect(
+ getACPToolCallResult(makeEvent({ status: "completed", is_error: true })),
+ ).toBe("error");
+ });
+
+ it("returns undefined while a call is still in progress", () => {
+ // undefined → SuccessIndicator renders nothing, mirroring how a regular
+ // ActionEvent is displayed before its ObservationEvent arrives.
+ expect(getACPToolCallResult(makeEvent({ status: "in_progress" }))).toBe(
+ undefined,
+ );
+ });
+});
diff --git a/__tests__/components/v1/chat/event-content-helpers/should-render-event.test.ts b/__tests__/components/v1/chat/event-content-helpers/should-render-event.test.ts
index 1dc6f4301a..1df8d8d128 100644
--- a/__tests__/components/v1/chat/event-content-helpers/should-render-event.test.ts
+++ b/__tests__/components/v1/chat/event-content-helpers/should-render-event.test.ts
@@ -6,6 +6,25 @@ import {
createPlanningObservationEvent,
createUserMessageEvent,
} from "test-utils";
+import { ACPToolCallEvent } from "#/types/v1/core/events/acp-tool-call-event";
+
+const makeACPEvent = (
+ overrides: Partial = {},
+): ACPToolCallEvent => ({
+ id: "acp-1",
+ kind: "ACPToolCallEvent",
+ timestamp: "2024-01-01T00:00:00Z",
+ source: "agent",
+ tool_call_id: "tc-1",
+ title: "Run command",
+ status: "completed",
+ tool_kind: "execute",
+ raw_input: { command: "ls" },
+ raw_output: "file.txt",
+ content: null,
+ is_error: false,
+ ...overrides,
+});
describe("shouldRenderEvent - PlanningFileEditorAction", () => {
it("should return false for PlanningFileEditorAction", () => {
@@ -33,3 +52,29 @@ describe("shouldRenderEvent - PlanningFileEditorAction", () => {
expect(shouldRenderEvent(event)).toBe(true);
});
});
+
+describe("shouldRenderEvent - ACPToolCallEvent", () => {
+ it("should return false for in_progress events (suppress empty-args flash)", () => {
+ const event = makeACPEvent({ status: "in_progress", raw_input: {} });
+
+ expect(shouldRenderEvent(event)).toBe(false);
+ });
+
+ it("should return true for completed events", () => {
+ const event = makeACPEvent({ status: "completed" });
+
+ expect(shouldRenderEvent(event)).toBe(true);
+ });
+
+ it("should return true for failed events", () => {
+ const event = makeACPEvent({ status: "failed", is_error: true });
+
+ expect(shouldRenderEvent(event)).toBe(true);
+ });
+
+ it("should return true for null status (backwards compat)", () => {
+ const event = makeACPEvent({ status: null });
+
+ expect(shouldRenderEvent(event)).toBe(true);
+ });
+});
diff --git a/__tests__/components/v1/chat/event-message-acp-tool-call.test.tsx b/__tests__/components/v1/chat/event-message-acp-tool-call.test.tsx
new file mode 100644
index 0000000000..84e465e19c
--- /dev/null
+++ b/__tests__/components/v1/chat/event-message-acp-tool-call.test.tsx
@@ -0,0 +1,108 @@
+import { describe, expect, it, vi, beforeEach } from "vitest";
+import { screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { renderWithProviders } from "test-utils";
+import { EventMessage } from "#/components/v1/chat/event-message";
+import { useAgentState } from "#/hooks/use-agent-state";
+import { AgentState } from "#/types/agent-state";
+import { ACPToolCallEvent } from "#/types/v1/core/events/acp-tool-call-event";
+
+vi.mock("#/hooks/query/use-config", () => ({
+ useConfig: () => ({ data: { APP_MODE: "saas" } }),
+}));
+vi.mock("#/hooks/use-agent-state");
+vi.mock("#/hooks/use-conversation-id", () => ({
+ useConversationId: () => ({ conversationId: "test-conversation-id" }),
+}));
+
+const makeEvent = (
+ overrides: Partial = {},
+): ACPToolCallEvent => ({
+ kind: "ACPToolCallEvent",
+ id: "evt-1",
+ timestamp: "2026-04-16T19:32:29.828069",
+ source: "agent",
+ tool_call_id: "toolu_123",
+ title: "gh pr diff 490",
+ tool_kind: "execute",
+ status: "completed",
+ raw_input: { command: "gh pr diff 490" },
+ raw_output: "diff output here",
+ content: null,
+ is_error: false,
+ ...overrides,
+});
+
+describe("EventMessage - ACPToolCallEvent dispatch", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.mocked(useAgentState).mockReturnValue({
+ curAgentState: AgentState.INIT,
+ executionStatus: null,
+ });
+ });
+
+ it("renders an ACP card through the same GenericEventMessage wrapper as observations", () => {
+ renderWithProviders(
+ ,
+ );
+
+ // The test-utils i18n instance doesn't load the real translation bundle,
+ // so createTitleFromKey falls back to the key literal. Assert on the
+ // key — the integration case (rendered string) is covered by a Storybook
+ // story + manual verification listed in the PR description.
+ expect(screen.getByText("ACTION_MESSAGE$ACP_RUN")).toBeInTheDocument();
+ });
+
+ it("shows the success check mark for completed tool calls", () => {
+ renderWithProviders(
+ ,
+ );
+
+ // Same status-icon testid as regular successful observations.
+ expect(screen.getByTestId("status-icon")).toBeInTheDocument();
+ });
+
+ it("omits the status icon while a call is in progress", () => {
+ renderWithProviders(
+ ,
+ );
+
+ // getACPToolCallResult returns undefined for in_progress, so
+ // SuccessIndicator renders no icon.
+ expect(screen.queryByTestId("status-icon")).not.toBeInTheDocument();
+ });
+
+ it("expands details on click and shows the Command: + Output: blocks", async () => {
+ const user = userEvent.setup();
+ renderWithProviders(
+ ,
+ );
+
+ await user.click(screen.getByRole("button", { name: "Expand" }));
+
+ // Markdown renderer wraps code blocks but the plain text survives.
+ expect(screen.getByText(/gh pr diff 490/)).toBeInTheDocument();
+ expect(screen.getByText(/diff output here/)).toBeInTheDocument();
+ });
+});
diff --git a/__tests__/utils/handle-event-for-ui.test.ts b/__tests__/utils/handle-event-for-ui.test.ts
index 51ac8df4a5..e9b8ae5608 100644
--- a/__tests__/utils/handle-event-for-ui.test.ts
+++ b/__tests__/utils/handle-event-for-ui.test.ts
@@ -6,6 +6,7 @@ import {
SecurityRisk,
OpenHandsEvent,
} from "#/types/v1/core";
+import { ACPToolCallEvent } from "#/types/v1/core/events/acp-tool-call-event";
import { handleEventForUI } from "#/utils/handle-event-for-ui";
describe("handleEventForUI", () => {
@@ -185,6 +186,61 @@ describe("handleEventForUI", () => {
expect(result).not.toBe(initialUiEvents);
});
+ describe("ACPToolCallEvent dedup", () => {
+ const mockInProgress: ACPToolCallEvent = {
+ kind: "ACPToolCallEvent",
+ id: "acp-evt-1",
+ timestamp: "2026-04-16T19:32:29.828069",
+ source: "agent",
+ tool_call_id: "toolu_ABC",
+ title: "gh pr diff 490",
+ tool_kind: "execute",
+ status: "in_progress",
+ raw_input: { command: "gh pr diff 490" },
+ raw_output: null,
+ content: null,
+ is_error: false,
+ };
+
+ const mockCompleted: ACPToolCallEvent = {
+ ...mockInProgress,
+ id: "acp-evt-2",
+ status: "completed",
+ raw_output: "output text",
+ };
+
+ it("appends the first tool call for a new tool_call_id", () => {
+ const result = handleEventForUI(mockInProgress, [mockMessageEvent]);
+
+ expect(result).toEqual([mockMessageEvent, mockInProgress]);
+ });
+
+ it("replaces a later status event at the original position", () => {
+ const result = handleEventForUI(mockCompleted, [
+ mockMessageEvent,
+ mockInProgress,
+ ]);
+
+ expect(result).toEqual([mockMessageEvent, mockCompleted]);
+ });
+
+ it("leaves tool calls with different tool_call_ids untouched", () => {
+ const other: ACPToolCallEvent = {
+ ...mockInProgress,
+ id: "acp-evt-99",
+ tool_call_id: "toolu_XYZ",
+ title: "ls -la",
+ };
+ const result = handleEventForUI(mockCompleted, [
+ mockMessageEvent,
+ other,
+ mockInProgress,
+ ]);
+
+ expect(result).toEqual([mockMessageEvent, other, mockCompleted]);
+ });
+ });
+
it("should NOT add ThinkObservation even when ThinkAction is not found", () => {
const mockThinkObservation: ObservationEvent = {
id: "test-think-observation-1",
diff --git a/src/components/v1/chat/event-content-helpers/get-acp-tool-call-content.ts b/src/components/v1/chat/event-content-helpers/get-acp-tool-call-content.ts
new file mode 100644
index 0000000000..9a39d71935
--- /dev/null
+++ b/src/components/v1/chat/event-content-helpers/get-acp-tool-call-content.ts
@@ -0,0 +1,86 @@
+import { ACPToolCallEvent } from "#/types/v1/core/events/acp-tool-call-event";
+import i18n from "#/i18n";
+import { MAX_CONTENT_LENGTH } from "./shared";
+
+/**
+ * Pick the translation key used for the ACP tool call title row. Mirrors
+ * ACTION_MESSAGE$RUN / $EDIT / $READ etc.
+ */
+export const getACPToolCallTitleKey = (event: ACPToolCallEvent): string => {
+ switch (event.tool_kind) {
+ case "execute":
+ return "ACTION_MESSAGE$ACP_RUN";
+ case "edit":
+ return "ACTION_MESSAGE$ACP_EDIT";
+ case "read":
+ return "ACTION_MESSAGE$ACP_READ";
+ case "fetch":
+ return "ACTION_MESSAGE$ACP_FETCH";
+ default:
+ return "ACTION_MESSAGE$ACP_TOOL";
+ }
+};
+
+/**
+ * Stringify an arbitrary raw_input / raw_output payload for markdown
+ * rendering. Strings pass through; objects are pretty-printed JSON.
+ */
+const stringifyPayload = (value: unknown): string => {
+ if (value === null || value === undefined) return "";
+ if (typeof value === "string") return value;
+ try {
+ return JSON.stringify(value, null, 2);
+ } catch {
+ return String(value);
+ }
+};
+
+const truncate = (content: string): string =>
+ content.length > MAX_CONTENT_LENGTH
+ ? `${content.slice(0, MAX_CONTENT_LENGTH)}...`
+ : content;
+
+/**
+ * Build the markdown-flavored body for an ACP tool call card. Mirrors the
+ * shape of ``getTerminalObservationContent`` (``Command:`` + ``Output:``
+ * fenced blocks) so the rendered card lines up with regular OpenHands
+ * observations.
+ *
+ * For ``tool_kind === "execute"`` we surface ``raw_input.command`` as the
+ * command line; for others we fall back to a pretty-printed JSON dump of
+ * the input. Output is always dumped as a fenced block, with the same
+ * "(no output)" fallback copy used by the bash observation renderer.
+ */
+export const getACPToolCallContent = (event: ACPToolCallEvent): string => {
+ const toolKind = event.tool_kind;
+ const rawInput = event.raw_input;
+ const rawOutput = event.raw_output;
+ const isError = event.is_error;
+
+ let output = "";
+
+ // Input block — command for execute, JSON dump otherwise.
+ if (
+ toolKind === "execute" &&
+ rawInput &&
+ typeof rawInput === "object" &&
+ "command" in rawInput &&
+ typeof (rawInput as { command: unknown }).command === "string"
+ ) {
+ const { command } = rawInput as { command: string };
+ output += `Command: \`${command}\`\n\n`;
+ } else if (rawInput !== null && rawInput !== undefined && rawInput !== "") {
+ const inputStr = stringifyPayload(rawInput);
+ if (inputStr.trim()) {
+ output += `Input:\n\`\`\`json\n${inputStr}\n\`\`\`\n\n`;
+ }
+ }
+
+ // Output block — matches the bash observation layout exactly.
+ const outputStr = truncate(stringifyPayload(rawOutput).trim());
+ const outputLabel = isError ? "**Error:**" : "Output:";
+ const outputBody = outputStr || i18n.t("OBSERVATION$COMMAND_NO_OUTPUT");
+ output += `${outputLabel}\n\`\`\`\n${outputBody}\n\`\`\``;
+
+ return output;
+};
diff --git a/src/components/v1/chat/event-content-helpers/get-event-content.tsx b/src/components/v1/chat/event-content-helpers/get-event-content.tsx
index 4595e15e9a..b1550f19fc 100644
--- a/src/components/v1/chat/event-content-helpers/get-event-content.tsx
+++ b/src/components/v1/chat/event-content-helpers/get-event-content.tsx
@@ -1,11 +1,19 @@
import { Trans } from "react-i18next";
import React from "react";
import { OpenHandsEvent, ObservationEvent, ActionEvent } from "#/types/v1/core";
-import { isActionEvent, isObservationEvent } from "#/types/v1/type-guards";
+import {
+ isActionEvent,
+ isObservationEvent,
+ isACPToolCallEvent,
+} from "#/types/v1/type-guards";
import { MonoComponent } from "../../../features/chat/mono-component";
import { PathComponent } from "../../../features/chat/path-component";
import { getActionContent } from "./get-action-content";
import { getObservationContent } from "./get-observation-content";
+import {
+ getACPToolCallContent,
+ getACPToolCallTitleKey,
+} from "./get-acp-tool-call-content";
import { TaskTrackingObservationContent } from "../task-tracking/task-tracking-observation-content";
import { TaskTrackerObservation } from "#/types/v1/core/base/observation";
import { SkillReadyEvent, isSkillReadyEvent } from "./create-skill-ready-event";
@@ -264,6 +272,16 @@ export const getEventContent = (
} else {
details = getObservationContent(event);
}
+ } else if (isACPToolCallEvent(event)) {
+ // ACP sub-agent tool calls reuse the same card shape as observations:
+ // title is "Running/Editing/Reading …" via a translation key that
+ // mirrors ACTION_MESSAGE$RUN etc.; details are markdown built from
+ // raw_input + raw_output the same way getTerminalObservationContent
+ // builds "Command: / Output:" blocks.
+ title = createTitleFromKey(getACPToolCallTitleKey(event), {
+ title: event.title,
+ });
+ details = getACPToolCallContent(event);
} else if (
// Lenient fallback for action-like events that fail the strict isActionEvent() guard
// (e.g., missing tool_name or tool_call_id). Extract a title from the action kind
diff --git a/src/components/v1/chat/event-content-helpers/get-observation-result.ts b/src/components/v1/chat/event-content-helpers/get-observation-result.ts
index 790ecb00cf..8bf3bda325 100644
--- a/src/components/v1/chat/event-content-helpers/get-observation-result.ts
+++ b/src/components/v1/chat/event-content-helpers/get-observation-result.ts
@@ -1,7 +1,24 @@
import { ObservationEvent } from "#/types/v1/core";
+import { ACPToolCallEvent } from "#/types/v1/core/events/acp-tool-call-event";
export type ObservationResultStatus = "success" | "error" | "timeout";
+/**
+ * Map an ACPToolCallEvent's lifecycle + error flags to the same
+ * success/error status the rest of the UI uses. An ``in_progress`` call
+ * returns ``undefined`` so the SuccessIndicator renders nothing — the
+ * card shows as "running" via the absence of a check mark, matching how
+ * regular ActionEvents are displayed before their ObservationEvent
+ * arrives.
+ */
+export const getACPToolCallResult = (
+ event: ACPToolCallEvent,
+): ObservationResultStatus | undefined => {
+ if (event.is_error || event.status === "failed") return "error";
+ if (event.status === "completed") return "success";
+ return undefined;
+};
+
export const getObservationResult = (
event: ObservationEvent,
): ObservationResultStatus => {
diff --git a/src/components/v1/chat/event-content-helpers/should-render-event.ts b/src/components/v1/chat/event-content-helpers/should-render-event.ts
index 91b206f840..5cf1c6fd8c 100644
--- a/src/components/v1/chat/event-content-helpers/should-render-event.ts
+++ b/src/components/v1/chat/event-content-helpers/should-render-event.ts
@@ -6,6 +6,7 @@ import {
isAgentErrorEvent,
isConversationStateUpdateEvent,
isHookExecutionEvent,
+ isACPToolCallEvent,
} from "#/types/v1/type-guards";
export const shouldRenderEvent = (event: OpenHandsEvent) => {
@@ -56,6 +57,12 @@ export const shouldRenderEvent = (event: OpenHandsEvent) => {
return true;
}
+ // Render ACP sub-agent tool call events — suppress in_progress (empty args)
+ // so the card only appears once fully populated.
+ if (isACPToolCallEvent(event)) {
+ return event.status !== "in_progress";
+ }
+
// Don't render any other event types (system events, etc.)
return false;
};
diff --git a/src/components/v1/chat/event-message-components/generic-event-message-wrapper.tsx b/src/components/v1/chat/event-message-components/generic-event-message-wrapper.tsx
index 80bc4e0a7d..8bb997a35c 100644
--- a/src/components/v1/chat/event-message-components/generic-event-message-wrapper.tsx
+++ b/src/components/v1/chat/event-message-components/generic-event-message-wrapper.tsx
@@ -1,8 +1,11 @@
import { OpenHandsEvent, ActionEvent } from "#/types/v1/core";
import { GenericEventMessage } from "../../../features/chat/generic-event-message";
import { getEventContent } from "../event-content-helpers/get-event-content";
-import { getObservationResult } from "../event-content-helpers/get-observation-result";
-import { isObservationEvent } from "#/types/v1/type-guards";
+import {
+ getACPToolCallResult,
+ getObservationResult,
+} from "../event-content-helpers/get-observation-result";
+import { isACPToolCallEvent, isObservationEvent } from "#/types/v1/type-guards";
import {
SkillReadyEvent,
isSkillReadyEvent,
@@ -39,6 +42,8 @@ export function GenericEventMessageWrapper({
success = "success";
} else if (isObservationEvent(event)) {
success = getObservationResult(event);
+ } else if (isACPToolCallEvent(event)) {
+ success = getACPToolCallResult(event);
}
// For Skill Ready events with items, render expandable skill list
diff --git a/src/components/v1/chat/event-message.tsx b/src/components/v1/chat/event-message.tsx
index 42841b319b..1dfb6c26bf 100644
--- a/src/components/v1/chat/event-message.tsx
+++ b/src/components/v1/chat/event-message.tsx
@@ -8,6 +8,7 @@ import {
isUserMessageEvent,
isPlanningFileEditorObservationEvent,
isHookExecutionEvent,
+ isACPToolCallEvent,
} from "#/types/v1/type-guards";
import { useConfig } from "#/hooks/query/use-config";
import { useConversationStore } from "#/stores/conversation-store";
@@ -152,6 +153,15 @@ export function EventMessage({
return ;
}
+ // ACP sub-agent tool call events (Claude Code, Codex, Gemini CLI, …)
+ // render through the same generic wrapper used for observation events so
+ // the card shape, success indicator and markdown rendering all match.
+ if (isACPToolCallEvent(event)) {
+ return (
+
+ );
+ }
+
// Finish actions
if (isActionEvent(event) && event.action.kind === "FinishAction") {
return (
diff --git a/src/i18n/translation.json b/src/i18n/translation.json
index 259118d7e6..ac8d65c680 100644
--- a/src/i18n/translation.json
+++ b/src/i18n/translation.json
@@ -21263,6 +21263,91 @@
"uk": "Стандартна помилка",
"ca": "Stderr"
},
+ "ACTION_MESSAGE$ACP_RUN": {
+ "en": "Running {{title}}",
+ "ja": "{{title}} を実行中",
+ "zh-CN": "正在运行 {{title}}",
+ "zh-TW": "正在執行 {{title}}",
+ "ko-KR": "{{title}} 실행 중",
+ "no": "Kjører {{title}}",
+ "ar": "تشغيل {{title}}",
+ "de": "Führe {{title}} aus",
+ "fr": "Exécution de {{title}}",
+ "it": "Esecuzione di {{title}}",
+ "pt": "Executando {{title}}",
+ "es": "Ejecutando {{title}}",
+ "tr": "{{title}} çalıştırılıyor",
+ "uk": "Виконується {{title}}",
+ "ca": "Executant {{title}}"
+ },
+ "ACTION_MESSAGE$ACP_EDIT": {
+ "en": "Editing {{title}}",
+ "ja": "{{title}} を編集中",
+ "zh-CN": "正在编辑 {{title}}",
+ "zh-TW": "正在編輯 {{title}}",
+ "ko-KR": "{{title}} 편집 중",
+ "no": "Redigerer {{title}}",
+ "ar": "تحرير {{title}}",
+ "de": "Bearbeite {{title}}",
+ "fr": "Édition de {{title}}",
+ "it": "Modifica di {{title}}",
+ "pt": "Editando {{title}}",
+ "es": "Editando {{title}}",
+ "tr": "{{title}} düzenleniyor",
+ "uk": "Редагується {{title}}",
+ "ca": "Editant {{title}}"
+ },
+ "ACTION_MESSAGE$ACP_READ": {
+ "en": "Reading {{title}}",
+ "ja": "{{title}} を読み取り中",
+ "zh-CN": "正在读取 {{title}}",
+ "zh-TW": "正在讀取 {{title}}",
+ "ko-KR": "{{title}} 읽는 중",
+ "no": "Leser {{title}}",
+ "ar": "قراءة {{title}}",
+ "de": "Lese {{title}}",
+ "fr": "Lecture de {{title}}",
+ "it": "Lettura di {{title}}",
+ "pt": "Lendo {{title}}",
+ "es": "Leyendo {{title}}",
+ "tr": "{{title}} okunuyor",
+ "uk": "Читається {{title}}",
+ "ca": "Llegint {{title}}"
+ },
+ "ACTION_MESSAGE$ACP_FETCH": {
+ "en": "Fetching {{title}}",
+ "ja": "{{title}} を取得中",
+ "zh-CN": "正在获取 {{title}}",
+ "zh-TW": "正在取得 {{title}}",
+ "ko-KR": "{{title}} 가져오는 중",
+ "no": "Henter {{title}}",
+ "ar": "جلب {{title}}",
+ "de": "Lade {{title}}",
+ "fr": "Récupération de {{title}}",
+ "it": "Recupero di {{title}}",
+ "pt": "Buscando {{title}}",
+ "es": "Obteniendo {{title}}",
+ "tr": "{{title}} alınıyor",
+ "uk": "Завантажується {{title}}",
+ "ca": "Obtenint {{title}}"
+ },
+ "ACTION_MESSAGE$ACP_TOOL": {
+ "en": "{{title}}",
+ "ja": "{{title}}",
+ "zh-CN": "{{title}}",
+ "zh-TW": "{{title}}",
+ "ko-KR": "{{title}}",
+ "no": "{{title}}",
+ "ar": "{{title}}",
+ "de": "{{title}}",
+ "fr": "{{title}}",
+ "it": "{{title}}",
+ "pt": "{{title}}",
+ "es": "{{title}}",
+ "tr": "{{title}}",
+ "uk": "{{title}}",
+ "ca": "{{title}}"
+ },
"COMMON$TYPE_EMAIL_AND_PRESS_SPACE": {
"en": "Type email and press Space",
"ja": "メールアドレスを入力してスペースキーを押してください",
diff --git a/src/stores/use-event-store.ts b/src/stores/use-event-store.ts
index 2dfc66e37e..27f35ddc8c 100644
--- a/src/stores/use-event-store.ts
+++ b/src/stores/use-event-store.ts
@@ -104,3 +104,16 @@ export const useEventStore = create()((set) => ({
uiEvents: [],
})),
}));
+
+// In dev builds, expose the store on `window` so that fixture/preview
+// scripts (e.g. .pr/issue-132 demo capture) can inject synthetic events
+// without round-tripping through the agent-server. Tree-shaken in
+// production builds via `import.meta.env.DEV`.
+if (
+ typeof window !== "undefined" &&
+ typeof import.meta !== "undefined" &&
+ (import.meta as { env?: { DEV?: boolean } }).env?.DEV
+) {
+ (window as unknown as { __OH_EVENT_STORE__?: typeof useEventStore }).__OH_EVENT_STORE__ =
+ useEventStore;
+}
diff --git a/src/types/v1/core/events/acp-tool-call-event.ts b/src/types/v1/core/events/acp-tool-call-event.ts
new file mode 100644
index 0000000000..4574c08347
--- /dev/null
+++ b/src/types/v1/core/events/acp-tool-call-event.ts
@@ -0,0 +1,90 @@
+import { ToolCallID } from "../base/common";
+import { BaseEvent } from "../base/event";
+
+/**
+ * Tool kinds emitted by ACP agents. Matches ACP's ``ToolKind`` enum,
+ * with ``"other"`` as the catch-all fallback.
+ */
+export type ACPToolKind = "execute" | "edit" | "read" | "fetch" | "other";
+
+/**
+ * Status of an ACP tool call. Future ACP streaming (software-agent-sdk#2866)
+ * will emit multiple events for the same ``tool_call_id`` as the status
+ * transitions from ``in_progress`` to a terminal value.
+ */
+export type ACPToolCallStatus = "in_progress" | "completed" | "failed";
+
+/**
+ * An ACP content block as surfaced on ``ACPToolCallEvent.content``. ACP
+ * allows a mix of text, image, resource and resource_link blocks; only the
+ * shape needed for rendering is captured here.
+ */
+export interface ACPToolCallContentBlock {
+ type: string;
+ [key: string]: unknown;
+}
+
+/**
+ * ACPToolCallEvent — surfaces a tool call executed by an ACP subprocess
+ * (Claude Code, Codex, Gemini CLI, …) so the GUI can render it the same
+ * way those tools' native UIs do.
+ */
+export interface ACPToolCallEvent extends BaseEvent {
+ /**
+ * Discriminator for the V1 event union.
+ */
+ kind: "ACPToolCallEvent";
+
+ /**
+ * ACP sub-agent is the event source; kept as ``"agent"`` in the SDK.
+ */
+ source: "agent";
+
+ /**
+ * Stable id assigned by the ACP server for this tool call. Multiple
+ * events may share the same ``tool_call_id`` as the call progresses.
+ */
+ tool_call_id: ToolCallID;
+
+ /**
+ * Human-readable title, e.g. the command being executed or the path
+ * being edited.
+ */
+ title: string;
+
+ /**
+ * Current lifecycle status of the call. ``null`` is tolerated for
+ * backwards compatibility with events produced before the field was
+ * required.
+ */
+ status: ACPToolCallStatus | null;
+
+ /**
+ * Category of tool: execute (shell), edit, read, fetch or other.
+ */
+ tool_kind: ACPToolKind | null;
+
+ /**
+ * Raw input payload as reported by the ACP server. Shape depends on
+ * ``tool_kind`` — e.g. ``{ command }`` for execute, ``{ path, content }``
+ * for edit.
+ */
+ raw_input: unknown;
+
+ /**
+ * Raw output payload — typically a string for execute / read tools,
+ * or a structured value for fetch / other.
+ */
+ raw_output: unknown;
+
+ /**
+ * Optional ACP content blocks associated with the tool call output.
+ */
+ content: ACPToolCallContentBlock[] | null;
+
+ /**
+ * True if the ACP server reported this tool call as an error, even
+ * when ``status`` is ``completed``.
+ */
+ is_error: boolean;
+}
diff --git a/src/types/v1/core/events/index.ts b/src/types/v1/core/events/index.ts
index 388002a52f..08740d1132 100644
--- a/src/types/v1/core/events/index.ts
+++ b/src/types/v1/core/events/index.ts
@@ -1,4 +1,5 @@
// Export all event types
+export * from "./acp-tool-call-event";
export * from "./action-event";
export * from "./condensation-event";
export * from "./conversation-state-event";
diff --git a/src/types/v1/core/openhands-event.ts b/src/types/v1/core/openhands-event.ts
index 5f9b745139..4eaa250507 100644
--- a/src/types/v1/core/openhands-event.ts
+++ b/src/types/v1/core/openhands-event.ts
@@ -1,5 +1,6 @@
// Import all event types
import {
+ ACPToolCallEvent,
ActionEvent,
MessageEvent,
ObservationEvent,
@@ -28,6 +29,8 @@ export type OpenHandsEvent =
| UserRejectObservation
| AgentErrorEvent
| SystemPromptEvent
+ // ACP sub-agent tool call events
+ | ACPToolCallEvent
// Hook events
| HookExecutionEvent
// Conversation management events
diff --git a/src/types/v1/type-guards.ts b/src/types/v1/type-guards.ts
index cdf0766fe9..835a8092ea 100644
--- a/src/types/v1/type-guards.ts
+++ b/src/types/v1/type-guards.ts
@@ -22,6 +22,7 @@ import {
ServerErrorEvent,
} from "./core/events/conversation-state-event";
import { HookExecutionEvent } from "./core/events/hook-execution-event";
+import { ACPToolCallEvent } from "./core/events/acp-tool-call-event";
import { SystemPromptEvent } from "./core/events/system-event";
import type { OpenHandsParsedEvent } from "../core/index";
@@ -217,6 +218,14 @@ export const isHookExecutionEvent = (
): event is HookExecutionEvent =>
"kind" in event && event.kind === "HookExecutionEvent";
+/**
+ * Type guard function to check if an event is an ACP tool call event
+ */
+export const isACPToolCallEvent = (
+ event: OpenHandsEvent,
+): event is ACPToolCallEvent =>
+ "kind" in event && event.kind === "ACPToolCallEvent";
+
// =============================================================================
// TEMPORARY COMPATIBILITY TYPE GUARDS
// These will be removed once we fully migrate to V1 events
diff --git a/src/utils/handle-event-for-ui.ts b/src/utils/handle-event-for-ui.ts
index 9fa409f01c..b67023ad83 100644
--- a/src/utils/handle-event-for-ui.ts
+++ b/src/utils/handle-event-for-ui.ts
@@ -1,10 +1,14 @@
import { OpenHandsEvent } from "#/types/v1/core";
-import { isObservationEvent } from "#/types/v1/type-guards";
+import { isACPToolCallEvent, isObservationEvent } from "#/types/v1/type-guards";
/**
* Handles adding an event to the UI events array
* Replaces actions with observations when they arrive (so UI shows observation instead of action)
* Exception: ThinkAction is NOT replaced because the thought content is in the action, not in the observation
+ *
+ * ACPToolCallEvent dedup: multiple events share a ``tool_call_id`` as an ACP
+ * tool call progresses (in_progress → completed / failed). Collapse them to
+ * the latest state at the original position so the card updates in place.
*/
export const handleEventForUI = (
event: OpenHandsEvent,
@@ -12,6 +16,20 @@ export const handleEventForUI = (
): OpenHandsEvent[] => {
const newUiEvents = [...uiEvents];
+ if (isACPToolCallEvent(event)) {
+ const existingIndex = newUiEvents.findIndex(
+ (uiEvent) =>
+ isACPToolCallEvent(uiEvent) &&
+ uiEvent.tool_call_id === event.tool_call_id,
+ );
+ if (existingIndex !== -1) {
+ newUiEvents[existingIndex] = event;
+ } else {
+ newUiEvents.push(event);
+ }
+ return newUiEvents;
+ }
+
if (isObservationEvent(event)) {
// Don't add ThinkObservation at all - we keep the ThinkAction instead
// The thought content is in the action, not the observation