feat(frontend): render ACPToolCallEvent in conversation viewer (#13994)

Co-authored-by: Debug Agent <debug@example.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
simonrosenberg
2026-04-29 15:15:43 -07:00
committed by GitHub
co-authored by Debug Agent Claude Opus 4.7 openhands
parent e5c1ebcff9
commit 451498bdbf
16 changed files with 665 additions and 4 deletions
@@ -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>): 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,
);
});
});
@@ -0,0 +1,109 @@
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> = {},
): 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,
isArchived: false,
});
});
it("renders an ACP card through the same GenericEventMessage wrapper as observations", () => {
renderWithProviders(
<EventMessage
event={makeEvent()}
messages={[]}
isLastMessage={false}
isInLast10Actions={false}
/>,
);
// 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(
<EventMessage
event={makeEvent()}
messages={[]}
isLastMessage={false}
isInLast10Actions={false}
/>,
);
// 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(
<EventMessage
event={makeEvent({ status: "in_progress", raw_output: null })}
messages={[]}
isLastMessage={false}
isInLast10Actions={false}
/>,
);
// 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(
<EventMessage
event={makeEvent()}
messages={[]}
isLastMessage={false}
isInLast10Actions={false}
/>,
);
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();
});
});
@@ -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",
@@ -0,0 +1,87 @@
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. but with an ``ACP · `` prefix so
* the card is visually distinguishable from regular OpenHands actions.
*/
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;
};
@@ -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";
@@ -263,6 +271,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 "ACP · 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
@@ -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 => {
@@ -6,6 +6,7 @@ import {
isAgentErrorEvent,
isConversationStateUpdateEvent,
isHookExecutionEvent,
isACPToolCallEvent,
} from "#/types/v1/type-guards";
export const shouldRenderEvent = (event: OpenHandsEvent) => {
@@ -56,6 +57,11 @@ export const shouldRenderEvent = (event: OpenHandsEvent) => {
return true;
}
// Render ACP sub-agent tool call events
if (isACPToolCallEvent(event)) {
return true;
}
// Don't render any other event types (system events, etc.)
return false;
};
@@ -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
@@ -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 <HookExecutionEventMessage event={event} />;
}
// 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 (
<GenericEventMessageWrapper event={event} isLastMessage={isLastMessage} />
);
}
// Finish actions
if (isActionEvent(event) && event.action.kind === "FinishAction") {
return (
+5
View File
@@ -1277,6 +1277,11 @@ export enum I18nKey {
HOOK$ERROR = "HOOK$ERROR",
HOOK$OUTPUT = "HOOK$OUTPUT",
HOOK$STDERR = "HOOK$STDERR",
ACTION_MESSAGE$ACP_RUN = "ACTION_MESSAGE$ACP_RUN",
ACTION_MESSAGE$ACP_EDIT = "ACTION_MESSAGE$ACP_EDIT",
ACTION_MESSAGE$ACP_READ = "ACTION_MESSAGE$ACP_READ",
ACTION_MESSAGE$ACP_FETCH = "ACTION_MESSAGE$ACP_FETCH",
ACTION_MESSAGE$ACP_TOOL = "ACTION_MESSAGE$ACP_TOOL",
COMMON$TYPE_EMAIL_AND_PRESS_SPACE = "COMMON$TYPE_EMAIL_AND_PRESS_SPACE",
ORG$INVITE_ORG_MEMBERS = "ORG$INVITE_ORG_MEMBERS",
ORG$MANAGE_ORGANIZATION = "ORG$MANAGE_ORGANIZATION",
+85
View File
@@ -21708,6 +21708,91 @@
"uk": "Стандартна помилка",
"ca": "Stderr"
},
"ACTION_MESSAGE$ACP_RUN": {
"en": "ACP · Running <cmd>{{title}}</cmd>",
"ja": "ACP · <cmd>{{title}}</cmd> を実行中",
"zh-CN": "ACP · 正在运行 <cmd>{{title}}</cmd>",
"zh-TW": "ACP · 正在執行 <cmd>{{title}}</cmd>",
"ko-KR": "ACP · <cmd>{{title}}</cmd> 실행 중",
"no": "ACP · Kjører <cmd>{{title}}</cmd>",
"ar": "ACP · تشغيل <cmd>{{title}}</cmd>",
"de": "ACP · Führe <cmd>{{title}}</cmd> aus",
"fr": "ACP · Exécution de <cmd>{{title}}</cmd>",
"it": "ACP · Esecuzione di <cmd>{{title}}</cmd>",
"pt": "ACP · Executando <cmd>{{title}}</cmd>",
"es": "ACP · Ejecutando <cmd>{{title}}</cmd>",
"tr": "ACP · <cmd>{{title}}</cmd> çalıştırılıyor",
"uk": "ACP · Виконується <cmd>{{title}}</cmd>",
"ca": "ACP · Executant <cmd>{{title}}</cmd>"
},
"ACTION_MESSAGE$ACP_EDIT": {
"en": "ACP · Editing <cmd>{{title}}</cmd>",
"ja": "ACP · <cmd>{{title}}</cmd> を編集中",
"zh-CN": "ACP · 正在编辑 <cmd>{{title}}</cmd>",
"zh-TW": "ACP · 正在編輯 <cmd>{{title}}</cmd>",
"ko-KR": "ACP · <cmd>{{title}}</cmd> 편집 중",
"no": "ACP · Redigerer <cmd>{{title}}</cmd>",
"ar": "ACP · تحرير <cmd>{{title}}</cmd>",
"de": "ACP · Bearbeite <cmd>{{title}}</cmd>",
"fr": "ACP · Édition de <cmd>{{title}}</cmd>",
"it": "ACP · Modifica di <cmd>{{title}}</cmd>",
"pt": "ACP · Editando <cmd>{{title}}</cmd>",
"es": "ACP · Editando <cmd>{{title}}</cmd>",
"tr": "ACP · <cmd>{{title}}</cmd> düzenleniyor",
"uk": "ACP · Редагується <cmd>{{title}}</cmd>",
"ca": "ACP · Editant <cmd>{{title}}</cmd>"
},
"ACTION_MESSAGE$ACP_READ": {
"en": "ACP · Reading <cmd>{{title}}</cmd>",
"ja": "ACP · <cmd>{{title}}</cmd> を読み取り中",
"zh-CN": "ACP · 正在读取 <cmd>{{title}}</cmd>",
"zh-TW": "ACP · 正在讀取 <cmd>{{title}}</cmd>",
"ko-KR": "ACP · <cmd>{{title}}</cmd> 읽는 중",
"no": "ACP · Leser <cmd>{{title}}</cmd>",
"ar": "ACP · قراءة <cmd>{{title}}</cmd>",
"de": "ACP · Lese <cmd>{{title}}</cmd>",
"fr": "ACP · Lecture de <cmd>{{title}}</cmd>",
"it": "ACP · Lettura di <cmd>{{title}}</cmd>",
"pt": "ACP · Lendo <cmd>{{title}}</cmd>",
"es": "ACP · Leyendo <cmd>{{title}}</cmd>",
"tr": "ACP · <cmd>{{title}}</cmd> okunuyor",
"uk": "ACP · Читається <cmd>{{title}}</cmd>",
"ca": "ACP · Llegint <cmd>{{title}}</cmd>"
},
"ACTION_MESSAGE$ACP_FETCH": {
"en": "ACP · Fetching <cmd>{{title}}</cmd>",
"ja": "ACP · <cmd>{{title}}</cmd> を取得中",
"zh-CN": "ACP · 正在获取 <cmd>{{title}}</cmd>",
"zh-TW": "ACP · 正在取得 <cmd>{{title}}</cmd>",
"ko-KR": "ACP · <cmd>{{title}}</cmd> 가져오는 중",
"no": "ACP · Henter <cmd>{{title}}</cmd>",
"ar": "ACP · جلب <cmd>{{title}}</cmd>",
"de": "ACP · Lade <cmd>{{title}}</cmd>",
"fr": "ACP · Récupération de <cmd>{{title}}</cmd>",
"it": "ACP · Recupero di <cmd>{{title}}</cmd>",
"pt": "ACP · Buscando <cmd>{{title}}</cmd>",
"es": "ACP · Obteniendo <cmd>{{title}}</cmd>",
"tr": "ACP · <cmd>{{title}}</cmd> alınıyor",
"uk": "ACP · Завантажується <cmd>{{title}}</cmd>",
"ca": "ACP · Obtenint <cmd>{{title}}</cmd>"
},
"ACTION_MESSAGE$ACP_TOOL": {
"en": "ACP · <cmd>{{title}}</cmd>",
"ja": "ACP · <cmd>{{title}}</cmd>",
"zh-CN": "ACP · <cmd>{{title}}</cmd>",
"zh-TW": "ACP · <cmd>{{title}}</cmd>",
"ko-KR": "ACP · <cmd>{{title}}</cmd>",
"no": "ACP · <cmd>{{title}}</cmd>",
"ar": "ACP · <cmd>{{title}}</cmd>",
"de": "ACP · <cmd>{{title}}</cmd>",
"fr": "ACP · <cmd>{{title}}</cmd>",
"it": "ACP · <cmd>{{title}}</cmd>",
"pt": "ACP · <cmd>{{title}}</cmd>",
"es": "ACP · <cmd>{{title}}</cmd>",
"tr": "ACP · <cmd>{{title}}</cmd>",
"uk": "ACP · <cmd>{{title}}</cmd>",
"ca": "ACP · <cmd>{{title}}</cmd>"
},
"COMMON$TYPE_EMAIL_AND_PRESS_SPACE": {
"en": "Type email and press Space",
"ja": "メールアドレスを入力してスペースキーを押してください",
@@ -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;
}
@@ -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";
@@ -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
+9
View File
@@ -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
+19 -1
View File
@@ -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