Render V1 paired tool summaries (#13451)

Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
Xingyao Wang
2026-03-18 10:52:05 +00:00
committed by GitHub
parent 26fa1185a4
commit 28ecf06404
7 changed files with 148 additions and 12 deletions

View File

@@ -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();
});
});

View File

@@ -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();
};

View File

@@ -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<string, unknown> = {};
@@ -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<string, unknown> = {};
@@ -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") {

View File

@@ -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)) {

View File

@@ -265,6 +265,11 @@ export function EventMessage({
<GenericEventMessageWrapper
event={event}
isLastMessage={isLastMessage}
correspondingAction={
correspondingAction && isActionEvent(correspondingAction)
? correspondingAction
: undefined
}
/>
</>
);

View File

@@ -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],
);

View File

@@ -58,4 +58,9 @@ export interface ActionEvent<T extends Action = Action> 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;
}