From 28ecf0640425a4e27e1fde7d6b7b863a3e70de51 Mon Sep 17 00:00:00 2001 From: Xingyao Wang Date: Wed, 18 Mar 2026 10:52:05 +0000 Subject: [PATCH] Render V1 paired tool summaries (#13451) Co-authored-by: openhands --- .../components/v1/get-event-content.test.tsx | 95 +++++++++++++++++++ .../get-action-content.ts | 4 - .../get-event-content.tsx | 33 ++++++- .../generic-event-message-wrapper.tsx | 6 +- .../src/components/v1/chat/event-message.tsx | 5 + frontend/src/routes/shared-conversation.tsx | 12 ++- .../src/types/v1/core/events/action-event.ts | 5 + 7 files changed, 148 insertions(+), 12 deletions(-) create mode 100644 frontend/__tests__/components/v1/get-event-content.test.tsx diff --git a/frontend/__tests__/components/v1/get-event-content.test.tsx b/frontend/__tests__/components/v1/get-event-content.test.tsx new file mode 100644 index 0000000000..45d6512fbd --- /dev/null +++ b/frontend/__tests__/components/v1/get-event-content.test.tsx @@ -0,0 +1,95 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { getEventContent } from "#/components/v1/chat"; +import { ActionEvent, ObservationEvent, SecurityRisk } from "#/types/v1/core"; + +const terminalActionEvent: ActionEvent = { + id: "action-1", + timestamp: new Date().toISOString(), + source: "agent", + thought: [{ type: "text", text: "Checking repository status." }], + thinking_blocks: [], + action: { + kind: "TerminalAction", + command: "git status", + is_input: false, + timeout: null, + reset: false, + }, + tool_name: "terminal", + tool_call_id: "tool-1", + tool_call: { + id: "tool-1", + type: "function", + function: { + name: "terminal", + arguments: '{"command":"git status"}', + }, + }, + llm_response_id: "response-1", + security_risk: SecurityRisk.LOW, + summary: "Check repository status", +}; + +const terminalObservationEvent: ObservationEvent = { + id: "obs-1", + timestamp: new Date().toISOString(), + source: "environment", + tool_name: "terminal", + tool_call_id: "tool-1", + action_id: "action-1", + observation: { + kind: "TerminalObservation", + content: [{ type: "text", text: "On branch main" }], + command: "git status", + exit_code: 0, + is_error: false, + timeout: false, + metadata: { + exit_code: 0, + pid: 1, + username: "openhands", + hostname: "sandbox", + prefix: "", + suffix: "", + working_dir: "/workspace/project/OpenHands", + py_interpreter_path: null, + }, + }, +}; + +describe("getEventContent", () => { + it("uses the action summary as the full action title", () => { + const { title } = getEventContent(terminalActionEvent); + + render(<>{title}); + + expect(screen.getByText("Check repository status")).toBeInTheDocument(); + expect(screen.queryByText("$ git status")).not.toBeInTheDocument(); + }); + + it("falls back to command-based title when summary is missing", () => { + const actionWithoutSummary = { ...terminalActionEvent, summary: undefined }; + const { title } = getEventContent(actionWithoutSummary); + + render(<>{title}); + + // Without i18n loaded, the translation key renders as the raw key + expect(screen.getByText("ACTION_MESSAGE$RUN")).toBeInTheDocument(); + expect( + screen.queryByText("Check repository status"), + ).not.toBeInTheDocument(); + }); + + it("reuses the action summary as the full paired observation title", () => { + const { title } = getEventContent( + terminalObservationEvent, + terminalActionEvent, + ); + + render(<>{title}); + + expect(screen.getByText("Check repository status")).toBeInTheDocument(); + expect(screen.queryByText("$ git status")).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/v1/chat/event-content-helpers/get-action-content.ts b/frontend/src/components/v1/chat/event-content-helpers/get-action-content.ts index 62e907de27..c0f2bf9db8 100644 --- a/frontend/src/components/v1/chat/event-content-helpers/get-action-content.ts +++ b/frontend/src/components/v1/chat/event-content-helpers/get-action-content.ts @@ -56,10 +56,6 @@ const getSearchActionContent = ( if ("include" in action && action.include) { parts.push(`**Include:** \`${action.include}\``); } - const { summary } = event as { summary?: string }; - if (summary) { - parts.push(`**Summary:** ${summary}`); - } return parts.length > 0 ? parts.join("\n") : getNoContentActionContent(); }; diff --git a/frontend/src/components/v1/chat/event-content-helpers/get-event-content.tsx b/frontend/src/components/v1/chat/event-content-helpers/get-event-content.tsx index 55e9e939db..6e6e847639 100644 --- a/frontend/src/components/v1/chat/event-content-helpers/get-event-content.tsx +++ b/frontend/src/components/v1/chat/event-content-helpers/get-event-content.tsx @@ -1,6 +1,6 @@ import { Trans } from "react-i18next"; import React from "react"; -import { OpenHandsEvent, ObservationEvent } from "#/types/v1/core"; +import { OpenHandsEvent, ObservationEvent, ActionEvent } from "#/types/v1/core"; import { isActionEvent, isObservationEvent } from "#/types/v1/type-guards"; import { MonoComponent } from "../../../features/chat/mono-component"; import { PathComponent } from "../../../features/chat/path-component"; @@ -37,6 +37,13 @@ const createTitleFromKey = ( ); }; +const getSummaryTitleForActionEvent = ( + event: ActionEvent, +): React.ReactNode | null => { + const summary = event.summary?.trim().replace(/\s+/g, " ") || ""; + return summary || null; +}; + // Action Event Processing const getActionEventTitle = (event: OpenHandsEvent): React.ReactNode => { // Early return if not an action event @@ -44,6 +51,11 @@ const getActionEventTitle = (event: OpenHandsEvent): React.ReactNode => { return ""; } + const summaryTitle = getSummaryTitleForActionEvent(event); + if (summaryTitle) { + return summaryTitle; + } + const actionType = event.action.kind; let actionKey = ""; let actionValues: Record = {}; @@ -127,12 +139,22 @@ const getActionEventTitle = (event: OpenHandsEvent): React.ReactNode => { }; // Observation Event Processing -const getObservationEventTitle = (event: OpenHandsEvent): React.ReactNode => { +const getObservationEventTitle = ( + event: OpenHandsEvent, + correspondingAction?: ActionEvent, +): React.ReactNode => { // Early return if not an observation event if (!isObservationEvent(event)) { return ""; } + if (correspondingAction) { + const summaryTitle = getSummaryTitleForActionEvent(correspondingAction); + if (summaryTitle) { + return summaryTitle; + } + } + const observationType = event.observation.kind; let observationKey = ""; let observationValues: Record = {}; @@ -208,7 +230,10 @@ const getObservationEventTitle = (event: OpenHandsEvent): React.ReactNode => { return observationType; }; -export const getEventContent = (event: OpenHandsEvent | SkillReadyEvent) => { +export const getEventContent = ( + event: OpenHandsEvent | SkillReadyEvent, + correspondingAction?: ActionEvent, +) => { let title: React.ReactNode = ""; let details: string | React.ReactNode = ""; @@ -226,7 +251,7 @@ export const getEventContent = (event: OpenHandsEvent | SkillReadyEvent) => { title = getActionEventTitle(event); details = getActionContent(event); } else if (isObservationEvent(event)) { - title = getObservationEventTitle(event); + title = getObservationEventTitle(event, correspondingAction); // For TaskTrackerObservation, use React component instead of markdown if (event.observation.kind === "TaskTrackerObservation") { diff --git a/frontend/src/components/v1/chat/event-message-components/generic-event-message-wrapper.tsx b/frontend/src/components/v1/chat/event-message-components/generic-event-message-wrapper.tsx index a29d853694..3c2d404bdf 100644 --- a/frontend/src/components/v1/chat/event-message-components/generic-event-message-wrapper.tsx +++ b/frontend/src/components/v1/chat/event-message-components/generic-event-message-wrapper.tsx @@ -1,4 +1,4 @@ -import { OpenHandsEvent } from "#/types/v1/core"; +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"; @@ -13,13 +13,15 @@ import { ObservationResultStatus } from "../../../features/chat/event-content-he interface GenericEventMessageWrapperProps { event: OpenHandsEvent | SkillReadyEvent; isLastMessage: boolean; + correspondingAction?: ActionEvent; } export function GenericEventMessageWrapper({ event, isLastMessage, + correspondingAction, }: GenericEventMessageWrapperProps) { - const { title, details } = getEventContent(event); + const { title, details } = getEventContent(event, correspondingAction); // SkillReadyEvent is not an observation event, so skip the observation checks if (!isSkillReadyEvent(event)) { diff --git a/frontend/src/components/v1/chat/event-message.tsx b/frontend/src/components/v1/chat/event-message.tsx index 57b543bc8e..14ee59df46 100644 --- a/frontend/src/components/v1/chat/event-message.tsx +++ b/frontend/src/components/v1/chat/event-message.tsx @@ -265,6 +265,11 @@ export function EventMessage({ ); diff --git a/frontend/src/routes/shared-conversation.tsx b/frontend/src/routes/shared-conversation.tsx index d51ec2e11a..f21f144226 100644 --- a/frontend/src/routes/shared-conversation.tsx +++ b/frontend/src/routes/shared-conversation.tsx @@ -7,6 +7,8 @@ import { useSharedConversationEvents } from "#/hooks/query/use-shared-conversati import { Messages as V1Messages } from "#/components/v1/chat"; import { shouldRenderEvent } from "#/components/v1/chat/event-content-helpers/should-render-event"; import { LoadingSpinner } from "#/components/shared/loading-spinner"; +import { handleEventForUI } from "#/utils/handle-event-for-ui"; +import { OpenHandsEvent } from "#/types/v1/core"; import OpenHandsLogo from "#/assets/branding/openhands-logo.svg?react"; export default function SharedConversation() { @@ -30,9 +32,15 @@ export default function SharedConversation() { // Transform shared events to V1 format const v1Events = eventsData?.items || []; - // Filter events that should be rendered + // Reconstruct the same UI event stream used in live conversations so + // completed tool calls render as a single action/observation unit. const renderableEvents = React.useMemo( - () => v1Events.filter(shouldRenderEvent), + () => + v1Events + .reduce< + OpenHandsEvent[] + >((uiEvents, event) => handleEventForUI(event, uiEvents), []) + .filter(shouldRenderEvent), [v1Events], ); diff --git a/frontend/src/types/v1/core/events/action-event.ts b/frontend/src/types/v1/core/events/action-event.ts index 33d7ce647c..fd2408b5d6 100644 --- a/frontend/src/types/v1/core/events/action-event.ts +++ b/frontend/src/types/v1/core/events/action-event.ts @@ -58,4 +58,9 @@ export interface ActionEvent extends BaseEvent { * The LLM's assessment of the safety risk of this action */ security_risk: SecurityRisk; + + /** + * Optional LLM-generated summary used to label the tool call in the UI. + */ + summary?: string | null; }