mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-03-22 05:37:20 +08:00
Render V1 paired tool summaries (#13451)
Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
95
frontend/__tests__/components/v1/get-event-content.test.tsx
Normal file
95
frontend/__tests__/components/v1/get-event-content.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -265,6 +265,11 @@ export function EventMessage({
|
||||
<GenericEventMessageWrapper
|
||||
event={event}
|
||||
isLastMessage={isLastMessage}
|
||||
correspondingAction={
|
||||
correspondingAction && isActionEvent(correspondingAction)
|
||||
? correspondingAction
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user