mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-10-07 16:38:34 +08:00
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:
co-authored by
Debug Agent
Claude Opus 4.7
openhands
parent
e5c1ebcff9
commit
451498bdbf
+142
@@ -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;
|
||||
};
|
||||
|
||||
+7
-2
@@ -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 (
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user