Skip to content

Commit

Permalink
Fix issue All-Hands-AI#5478: Add color to the line next to "Ran a XXX…
Browse files Browse the repository at this point in the history
… Command" based on return value (All-Hands-AI#5483)

Co-authored-by: Graham Neubig <[email protected]>
  • Loading branch information
openhands-agent and neubig authored Dec 11, 2024
1 parent 907c65c commit 6a6ce5f
Show file tree
Hide file tree
Showing 20 changed files with 447 additions and 137 deletions.
60 changes: 60 additions & 0 deletions frontend/__tests__/components/chat/expandable-message.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { describe, expect, it } from "vitest";
import { screen } from "@testing-library/react";
import { renderWithProviders } from "test-utils";
import { ExpandableMessage } from "#/components/features/chat/expandable-message";

describe("ExpandableMessage", () => {
it("should render with neutral border for non-action messages", () => {
renderWithProviders(<ExpandableMessage message="Hello" type="thought" />);
const element = screen.getByText("Hello");
const container = element.closest("div.flex.gap-2.items-center.justify-between");
expect(container).toHaveClass("border-neutral-300");
expect(screen.queryByTestId("status-icon")).not.toBeInTheDocument();
});

it("should render with neutral border for error messages", () => {
renderWithProviders(<ExpandableMessage message="Error occurred" type="error" />);
const element = screen.getByText("Error occurred");
const container = element.closest("div.flex.gap-2.items-center.justify-between");
expect(container).toHaveClass("border-neutral-300");
expect(screen.queryByTestId("status-icon")).not.toBeInTheDocument();
});

it("should render with success icon for successful action messages", () => {
renderWithProviders(
<ExpandableMessage
message="Command executed successfully"
type="action"
success={true}
/>
);
const element = screen.getByText("Command executed successfully");
const container = element.closest("div.flex.gap-2.items-center.justify-between");
expect(container).toHaveClass("border-neutral-300");
const icon = screen.getByTestId("status-icon");
expect(icon).toHaveClass("fill-success");
});

it("should render with error icon for failed action messages", () => {
renderWithProviders(
<ExpandableMessage
message="Command failed"
type="action"
success={false}
/>
);
const element = screen.getByText("Command failed");
const container = element.closest("div.flex.gap-2.items-center.justify-between");
expect(container).toHaveClass("border-neutral-300");
const icon = screen.getByTestId("status-icon");
expect(icon).toHaveClass("fill-danger");
});

it("should render with neutral border and no icon for action messages without success prop", () => {
renderWithProviders(<ExpandableMessage message="Running command" type="action" />);
const element = screen.getByText("Running command");
const container = element.closest("div.flex.gap-2.items-center.justify-between");
expect(container).toHaveClass("border-neutral-300");
expect(screen.queryByTestId("status-icon")).not.toBeInTheDocument();
});
});
35 changes: 23 additions & 12 deletions frontend/src/components/features/chat/expandable-message.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,21 @@ import { code } from "../markdown/code";
import { ol, ul } from "../markdown/list";
import ArrowUp from "#/icons/angle-up-solid.svg?react";
import ArrowDown from "#/icons/angle-down-solid.svg?react";
import CheckCircle from "#/icons/check-circle-solid.svg?react";
import XCircle from "#/icons/x-circle-solid.svg?react";

interface ExpandableMessageProps {
id?: string;
message: string;
type: string;
success?: boolean;
}

export function ExpandableMessage({
id,
message,
type,
success,
}: ExpandableMessageProps) {
const { t, i18n } = useTranslation();
const [showDetails, setShowDetails] = useState(true);
Expand All @@ -31,22 +35,14 @@ export function ExpandableMessage({
}
}, [id, message, i18n.language]);

const border = type === "error" ? "border-danger" : "border-neutral-300";
const textColor = type === "error" ? "text-danger" : "text-neutral-300";
let arrowClasses = "h-4 w-4 ml-2 inline";
if (type === "error") {
arrowClasses += " fill-danger";
} else {
arrowClasses += " fill-neutral-300";
}
const arrowClasses = "h-4 w-4 ml-2 inline fill-neutral-300";
const statusIconClasses = "h-4 w-4 ml-2 inline";

return (
<div
className={`flex gap-2 items-center justify-start border-l-2 pl-2 my-2 py-2 ${border}`}
>
<div className="flex gap-2 items-center justify-between border-l-2 border-neutral-300 pl-2 my-2 py-2">
<div className="text-sm leading-4 flex flex-col gap-2 max-w-full">
{headline && (
<p className={`${textColor} font-bold`}>
<p className="text-neutral-300 font-bold">
{headline}
<button
type="button"
Expand Down Expand Up @@ -75,6 +71,21 @@ export function ExpandableMessage({
</Markdown>
)}
</div>
{type === "action" && success !== undefined && (
<div className="flex-shrink-0">
{success ? (
<CheckCircle
data-testid="status-icon"
className={`${statusIconClasses} fill-success`}
/>
) : (
<XCircle
data-testid="status-icon"
className={`${statusIconClasses} fill-danger`}
/>
)}
</div>
)}
</div>
);
}
1 change: 1 addition & 0 deletions frontend/src/components/features/chat/messages.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export function Messages({
type={message.type}
id={message.translationID}
message={message.content}
success={message.success}
/>
);
}
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/icons/check-circle-solid.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 4 additions & 0 deletions frontend/src/icons/x-circle-solid.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions frontend/src/message.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ type Message = {
timestamp: string;
imageUrls?: string[];
type?: "thought" | "error" | "action";
success?: boolean;
pending?: boolean;
translationID?: string;
eventID?: number;
Expand Down
27 changes: 25 additions & 2 deletions frontend/src/state/chat-slice.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,25 @@
import { createSlice, PayloadAction } from "@reduxjs/toolkit";

import { ActionSecurityRisk } from "#/state/security-analyzer-slice";
import { OpenHandsObservation } from "#/types/core/observations";
import {
OpenHandsObservation,
CommandObservation,
IPythonObservation,
} from "#/types/core/observations";
import { OpenHandsAction } from "#/types/core/actions";
import { OpenHandsEventType } from "#/types/core/base";

type SliceState = { messages: Message[] };

const MAX_CONTENT_LENGTH = 1000;

const HANDLED_ACTIONS = ["run", "run_ipython", "write", "read", "browse"];
const HANDLED_ACTIONS: OpenHandsEventType[] = [
"run",
"run_ipython",
"write",
"read",
"browse",
];

function getRiskText(risk: ActionSecurityRisk) {
switch (risk) {
Expand Down Expand Up @@ -131,6 +142,18 @@ export const chatSlice = createSlice({
return;
}
causeMessage.translationID = translationID;
// Set success property based on observation type
if (observationID === "run") {
const commandObs = observation.payload as CommandObservation;
causeMessage.success = commandObs.extras.exit_code === 0;
} else if (observationID === "run_ipython") {
// For IPython, we consider it successful if there's no error message
const ipythonObs = observation.payload as IPythonObservation;
causeMessage.success = !ipythonObs.message
.toLowerCase()
.includes("error");
}

if (observationID === "run" || observationID === "run_ipython") {
let { content } = observation.payload;
if (content.length > MAX_CONTENT_LENGTH) {
Expand Down
17 changes: 17 additions & 0 deletions frontend/src/types/core/observations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,21 @@ export interface BrowseObservation extends OpenHandsObservationEvent<"browse"> {
};
}

export interface WriteObservation extends OpenHandsObservationEvent<"write"> {
source: "agent";
extras: {
path: string;
content: string;
};
}

export interface ReadObservation extends OpenHandsObservationEvent<"read"> {
source: "agent";
extras: {
path: string;
};
}

export interface ErrorObservation extends OpenHandsObservationEvent<"error"> {
source: "user";
extras: {
Expand All @@ -65,4 +80,6 @@ export type OpenHandsObservation =
| IPythonObservation
| DelegateObservation
| BrowseObservation
| WriteObservation
| ReadObservation
| ErrorObservation;
1 change: 1 addition & 0 deletions frontend/tailwind.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export default {
'root-secondary': '#262626',
'hyperlink': '#007AFF',
'danger': '#EF3744',
'success': '#4CAF50',
},
},
},
Expand Down
25 changes: 24 additions & 1 deletion frontend/test-utils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,31 @@ import { configureStore } from "@reduxjs/toolkit";
// eslint-disable-next-line import/no-extraneous-dependencies
import { RenderOptions, render } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nextProvider } from "react-i18next";
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import { AppStore, RootState, rootReducer } from "./src/store";
import { AuthProvider } from "#/context/auth-context";
import { UserPrefsProvider } from "#/context/user-prefs-context";

// Initialize i18n for tests
i18n
.use(initReactI18next)
.init({
lng: "en",
fallbackLng: "en",
ns: ["translation"],
defaultNS: "translation",
resources: {
en: {
translation: {},
},
},
interpolation: {
escapeValue: false,
},
});

const setupStore = (preloadedState?: Partial<RootState>): AppStore =>
configureStore({
reducer: rootReducer,
Expand Down Expand Up @@ -40,7 +61,9 @@ export function renderWithProviders(
<UserPrefsProvider>
<AuthProvider>
<QueryClientProvider client={new QueryClient()}>
{children}
<I18nextProvider i18n={i18n}>
{children}
</I18nextProvider>
</QueryClientProvider>
</AuthProvider>
</UserPrefsProvider>
Expand Down
8 changes: 7 additions & 1 deletion frontend/vitest.setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,13 @@ HTMLElement.prototype.scrollTo = vi.fn();
// Mock the i18n provider
vi.mock("react-i18next", async (importOriginal) => ({
...(await importOriginal<typeof import("react-i18next")>()),
useTranslation: () => ({ t: (key: string) => key }),
useTranslation: () => ({
t: (key: string) => key,
i18n: {
language: "en",
exists: () => false,
},
}),
}));

// Mock requests during tests
Expand Down
2 changes: 1 addition & 1 deletion openhands/agenthub/codeact_agent/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,4 +110,4 @@ The agent is implemented in two main files:
2. `function_calling.py`: Tool definitions and function calling interface with:
- Tool parameter specifications
- Tool descriptions and examples
- Function calling response parsing
- Function calling response parsing
8 changes: 8 additions & 0 deletions openhands/events/observation/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ def error(self) -> bool:
def message(self) -> str:
return f'Command `{self.command}` executed with exit code {self.exit_code}.'

@property
def success(self) -> bool:
return not self.error

def __str__(self) -> str:
return f'**CmdOutputObservation (source={self.source}, exit code={self.exit_code})**\n{self.content}'

Expand All @@ -42,5 +46,9 @@ def error(self) -> bool:
def message(self) -> str:
return 'Code executed in IPython cell.'

@property
def success(self) -> bool:
return True # IPython cells are always considered successful

def __str__(self) -> str:
return f'**IPythonRunCellObservation**\n{self.content}'
3 changes: 3 additions & 0 deletions openhands/events/serialization/event.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,9 @@ def event_to_dict(event: 'Event') -> dict:
elif 'observation' in d:
d['content'] = props.pop('content', '')
d['extras'] = props
# Include success field for CmdOutputObservation
if hasattr(event, 'success'):
d['success'] = event.success
else:
raise ValueError('Event must be either action or observation')
return d
Expand Down
1 change: 1 addition & 0 deletions openhands/events/serialization/observation.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,4 +50,5 @@ def observation_from_dict(observation: dict) -> Observation:
observation.pop('message', None)
content = observation.pop('content', '')
extras = observation.pop('extras', {})

return observation_class(content=content, **extras)
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ reportlab = "*"
[tool.coverage.run]
concurrency = ["gevent"]


[tool.poetry.group.runtime.dependencies]
jupyterlab = "*"
notebook = "*"
Expand Down Expand Up @@ -128,6 +129,7 @@ ignore = ["D1"]
[tool.ruff.lint.pydocstyle]
convention = "google"


[tool.poetry.group.evaluation.dependencies]
streamlit = "*"
whatthepatch = "*"
Expand Down
27 changes: 27 additions & 0 deletions tests/unit/test_command_success.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
from openhands.events.observation.commands import (
CmdOutputObservation,
IPythonRunCellObservation,
)


def test_cmd_output_success():
# Test successful command
obs = CmdOutputObservation(
command_id=1, command='ls', content='file1.txt\nfile2.txt', exit_code=0
)
assert obs.success is True
assert obs.error is False

# Test failed command
obs = CmdOutputObservation(
command_id=2, command='ls', content='No such file or directory', exit_code=1
)
assert obs.success is False
assert obs.error is True


def test_ipython_cell_success():
# IPython cells are always successful
obs = IPythonRunCellObservation(code='print("Hello")', content='Hello')
assert obs.success is True
assert obs.error is False
Loading

0 comments on commit 6a6ce5f

Please sign in to comment.