mirror of
https://github.com/OpenHands/OpenHands.git
synced 2025-12-25 21:36:52 +08:00
feat(frontend): V1 WebSocket handler (#11221)
Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
parent
0f92bdc9a8
commit
ef49994700
60
frontend/__mocks__/zustand.ts
Normal file
60
frontend/__mocks__/zustand.ts
Normal file
@ -0,0 +1,60 @@
|
||||
import { act } from "@testing-library/react";
|
||||
import { vi, afterEach } from "vitest";
|
||||
import type * as ZustandExportedTypes from "zustand";
|
||||
|
||||
export * from "zustand";
|
||||
|
||||
const { create: actualCreate, createStore: actualCreateStore } =
|
||||
await vi.importActual<typeof ZustandExportedTypes>("zustand");
|
||||
|
||||
// a variable to hold reset functions for all stores declared in the app
|
||||
export const storeResetFns = new Set<() => void>();
|
||||
|
||||
const createUncurried = <T>(
|
||||
stateCreator: ZustandExportedTypes.StateCreator<T>,
|
||||
) => {
|
||||
const store = actualCreate(stateCreator);
|
||||
const initialState = store.getInitialState();
|
||||
storeResetFns.add(() => {
|
||||
store.setState(initialState, true);
|
||||
});
|
||||
return store;
|
||||
};
|
||||
|
||||
// when creating a store, we get its initial state, create a reset function and add it in the set
|
||||
export const create = (<T>(
|
||||
stateCreator: ZustandExportedTypes.StateCreator<T>,
|
||||
) =>
|
||||
// to support curried version of create
|
||||
typeof stateCreator === "function"
|
||||
? createUncurried(stateCreator)
|
||||
: createUncurried) as typeof ZustandExportedTypes.create;
|
||||
|
||||
const createStoreUncurried = <T>(
|
||||
stateCreator: ZustandExportedTypes.StateCreator<T>,
|
||||
) => {
|
||||
const store = actualCreateStore(stateCreator);
|
||||
const initialState = store.getInitialState();
|
||||
storeResetFns.add(() => {
|
||||
store.setState(initialState, true);
|
||||
});
|
||||
return store;
|
||||
};
|
||||
|
||||
// when creating a store, we get its initial state, create a reset function and add it in the set
|
||||
export const createStore = (<T>(
|
||||
stateCreator: ZustandExportedTypes.StateCreator<T>,
|
||||
) =>
|
||||
// to support curried version of createStore
|
||||
typeof stateCreator === "function"
|
||||
? createStoreUncurried(stateCreator)
|
||||
: createStoreUncurried) as typeof ZustandExportedTypes.createStore;
|
||||
|
||||
// reset all stores after each test run
|
||||
afterEach(() => {
|
||||
act(() => {
|
||||
storeResetFns.forEach((resetFn) => {
|
||||
resetFn();
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -23,6 +23,7 @@ import { useConfig } from "#/hooks/query/use-config";
|
||||
import { useGetTrajectory } from "#/hooks/mutation/use-get-trajectory";
|
||||
import { useUploadFiles } from "#/hooks/mutation/use-upload-files";
|
||||
import { OpenHandsAction } from "#/types/core/actions";
|
||||
import { useEventStore } from "#/stores/use-event-store";
|
||||
|
||||
// Mock the hooks
|
||||
vi.mock("#/context/ws-client-provider");
|
||||
@ -176,7 +177,7 @@ describe("ChatInterface - Chat Suggestions", () => {
|
||||
});
|
||||
|
||||
test("should hide chat suggestions when there is a user message", () => {
|
||||
const userEvent: OpenHandsAction = {
|
||||
const mockUserEvent: OpenHandsAction = {
|
||||
id: 1,
|
||||
source: "user",
|
||||
action: "message",
|
||||
@ -189,10 +190,11 @@ describe("ChatInterface - Chat Suggestions", () => {
|
||||
timestamp: "2025-07-01T00:00:00Z",
|
||||
};
|
||||
|
||||
(useWsClient as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
send: vi.fn(),
|
||||
isLoadingMessages: false,
|
||||
parsedEvents: [userEvent],
|
||||
useEventStore.setState({
|
||||
events: [mockUserEvent],
|
||||
uiEvents: [],
|
||||
addEvent: vi.fn(),
|
||||
clearEvents: vi.fn(),
|
||||
});
|
||||
|
||||
renderWithQueryClient(<ChatInterface />, queryClient);
|
||||
|
||||
397
frontend/__tests__/conversation-websocket-handler.test.tsx
Normal file
397
frontend/__tests__/conversation-websocket-handler.test.tsx
Normal file
@ -0,0 +1,397 @@
|
||||
import { describe, it, expect, beforeAll, afterAll, afterEach } from "vitest";
|
||||
import { screen, waitFor, render } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { useOptimisticUserMessageStore } from "#/stores/optimistic-user-message-store";
|
||||
import {
|
||||
createMockMessageEvent,
|
||||
createMockUserMessageEvent,
|
||||
createMockAgentErrorEvent,
|
||||
} from "#/mocks/mock-ws-helpers";
|
||||
import {
|
||||
ConnectionStatusComponent,
|
||||
EventStoreComponent,
|
||||
OptimisticUserMessageStoreComponent,
|
||||
ErrorMessageStoreComponent,
|
||||
} from "./helpers/websocket-test-components";
|
||||
import { ConversationWebSocketProvider } from "#/contexts/conversation-websocket-context";
|
||||
import { conversationWebSocketTestSetup } from "./helpers/msw-websocket-setup";
|
||||
|
||||
// MSW WebSocket mock setup
|
||||
const { wsLink, server: mswServer } = conversationWebSocketTestSetup();
|
||||
|
||||
beforeAll(() => mswServer.listen());
|
||||
afterEach(() => {
|
||||
mswServer.resetHandlers();
|
||||
});
|
||||
afterAll(() => mswServer.close());
|
||||
|
||||
// Helper function to render components with ConversationWebSocketProvider
|
||||
function renderWithWebSocketContext(
|
||||
children: React.ReactNode,
|
||||
conversationId = "test-conversation-default",
|
||||
) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ConversationWebSocketProvider conversationId={conversationId}>
|
||||
{children}
|
||||
</ConversationWebSocketProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("Conversation WebSocket Handler", () => {
|
||||
// 1. Connection Lifecycle Tests
|
||||
describe("Connection Management", () => {
|
||||
it("should establish WebSocket connection to /events/socket URL", async () => {
|
||||
// This will fail because we haven't created the context yet
|
||||
renderWithWebSocketContext(<ConnectionStatusComponent />);
|
||||
|
||||
// Initially should be CONNECTING
|
||||
expect(screen.getByTestId("connection-state")).toHaveTextContent(
|
||||
"CONNECTING",
|
||||
);
|
||||
|
||||
// Wait for connection to be established
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("connection-state")).toHaveTextContent(
|
||||
"OPEN",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it.todo("should provide manual disconnect functionality");
|
||||
});
|
||||
|
||||
// 2. Event Processing Tests
|
||||
describe("Event Stream Processing", () => {
|
||||
it("should update event store with received WebSocket events", async () => {
|
||||
// Create a mock MessageEvent to send through WebSocket
|
||||
const mockMessageEvent = createMockMessageEvent();
|
||||
|
||||
// Set up MSW to send the event when connection is established
|
||||
mswServer.use(
|
||||
wsLink.addEventListener("connection", ({ client, server }) => {
|
||||
server.connect();
|
||||
// Send the mock event after connection
|
||||
client.send(JSON.stringify(mockMessageEvent));
|
||||
}),
|
||||
);
|
||||
|
||||
// Render components that use both WebSocket and event store
|
||||
renderWithWebSocketContext(<EventStoreComponent />);
|
||||
|
||||
// Wait for connection and event processing
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("events-count")).toHaveTextContent("1");
|
||||
});
|
||||
|
||||
// Verify the event was added to the store
|
||||
expect(screen.getByTestId("latest-event-id")).toHaveTextContent(
|
||||
"test-event-123",
|
||||
);
|
||||
expect(screen.getByTestId("ui-events-count")).toHaveTextContent("1");
|
||||
});
|
||||
|
||||
it("should handle malformed/invalid event data gracefully", async () => {
|
||||
// Set up MSW to send various invalid events when connection is established
|
||||
mswServer.use(
|
||||
wsLink.addEventListener("connection", ({ client, server }) => {
|
||||
server.connect();
|
||||
|
||||
// Send invalid JSON
|
||||
client.send("invalid json string");
|
||||
|
||||
// Send valid JSON but missing required fields
|
||||
client.send(JSON.stringify({ message: "missing required fields" }));
|
||||
|
||||
// Send valid JSON with wrong data types
|
||||
client.send(
|
||||
JSON.stringify({
|
||||
id: 123, // should be string
|
||||
timestamp: "2023-01-01T00:00:00Z",
|
||||
source: "agent",
|
||||
}),
|
||||
);
|
||||
|
||||
// Send null values for required fields
|
||||
client.send(
|
||||
JSON.stringify({
|
||||
id: null,
|
||||
timestamp: "2023-01-01T00:00:00Z",
|
||||
source: "agent",
|
||||
}),
|
||||
);
|
||||
|
||||
// Send a valid event after invalid ones to ensure processing continues
|
||||
client.send(
|
||||
JSON.stringify({
|
||||
id: "valid-event-123",
|
||||
timestamp: new Date().toISOString(),
|
||||
source: "agent",
|
||||
llm_message: {
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "text", text: "Valid message after invalid ones" },
|
||||
],
|
||||
},
|
||||
activated_microagents: [],
|
||||
extended_content: [],
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
// Render components that use both WebSocket and event store
|
||||
renderWithWebSocketContext(<EventStoreComponent />);
|
||||
|
||||
// Wait for connection and event processing
|
||||
// Only the valid event should be added to the store
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("events-count")).toHaveTextContent("1");
|
||||
});
|
||||
|
||||
// Verify only the valid event was added
|
||||
expect(screen.getByTestId("latest-event-id")).toHaveTextContent(
|
||||
"valid-event-123",
|
||||
);
|
||||
expect(screen.getByTestId("ui-events-count")).toHaveTextContent("1");
|
||||
});
|
||||
});
|
||||
|
||||
// 3. State Management Tests
|
||||
describe("State Management Integration", () => {
|
||||
it("should clear optimistic user messages when confirmed", async () => {
|
||||
// First, set an optimistic user message
|
||||
const { setOptimisticUserMessage } =
|
||||
useOptimisticUserMessageStore.getState();
|
||||
setOptimisticUserMessage("This is an optimistic message");
|
||||
|
||||
// Create a mock user MessageEvent to send through WebSocket
|
||||
const mockUserMessageEvent = createMockUserMessageEvent();
|
||||
|
||||
// Set up MSW to send the user message event when connection is established
|
||||
mswServer.use(
|
||||
wsLink.addEventListener("connection", ({ client, server }) => {
|
||||
server.connect();
|
||||
// Send the mock user message event after connection
|
||||
client.send(JSON.stringify(mockUserMessageEvent));
|
||||
}),
|
||||
);
|
||||
|
||||
// Render components that use both WebSocket and optimistic user message store
|
||||
renderWithWebSocketContext(<OptimisticUserMessageStoreComponent />);
|
||||
|
||||
// Initially should show the optimistic message
|
||||
expect(screen.getByTestId("optimistic-user-message")).toHaveTextContent(
|
||||
"This is an optimistic message",
|
||||
);
|
||||
|
||||
// Wait for connection and user message event processing
|
||||
// The optimistic message should be cleared when user message is confirmed
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("optimistic-user-message")).toHaveTextContent(
|
||||
"none",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// 4. Cache Management Tests
|
||||
describe("Cache Management", () => {
|
||||
it.todo(
|
||||
"should invalidate file changes cache on file edit/write/command events",
|
||||
);
|
||||
it.todo("should invalidate specific file diff cache on file modifications");
|
||||
it.todo("should prevent cache refetch during high message rates");
|
||||
it.todo("should not invalidate cache for non-file-related events");
|
||||
it.todo("should invalidate cache with correct conversation ID context");
|
||||
});
|
||||
|
||||
// 5. Error Handling Tests
|
||||
describe("Error Handling & Recovery", () => {
|
||||
it("should update error message store on AgentErrorEvent", async () => {
|
||||
// Create a mock AgentErrorEvent to send through WebSocket
|
||||
const mockAgentErrorEvent = createMockAgentErrorEvent();
|
||||
|
||||
// Set up MSW to send the error event when connection is established
|
||||
mswServer.use(
|
||||
wsLink.addEventListener("connection", ({ client, server }) => {
|
||||
server.connect();
|
||||
// Send the mock error event after connection
|
||||
client.send(JSON.stringify(mockAgentErrorEvent));
|
||||
}),
|
||||
);
|
||||
|
||||
// Render components that use both WebSocket and error message store
|
||||
renderWithWebSocketContext(<ErrorMessageStoreComponent />);
|
||||
|
||||
// Initially should show "none"
|
||||
expect(screen.getByTestId("error-message")).toHaveTextContent("none");
|
||||
|
||||
// Wait for connection and error event processing
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("error-message")).toHaveTextContent(
|
||||
"Failed to execute command: Permission denied",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("should set error message store on WebSocket connection errors", async () => {
|
||||
// Set up MSW to simulate connection error
|
||||
mswServer.use(
|
||||
wsLink.addEventListener("connection", ({ client }) => {
|
||||
// Simulate connection error by closing immediately
|
||||
client.close(1006, "Connection failed");
|
||||
}),
|
||||
);
|
||||
|
||||
// Render components that use both WebSocket and error message store
|
||||
renderWithWebSocketContext(
|
||||
<>
|
||||
<ErrorMessageStoreComponent />
|
||||
<ConnectionStatusComponent />
|
||||
</>,
|
||||
);
|
||||
|
||||
// Initially should show "none"
|
||||
expect(screen.getByTestId("error-message")).toHaveTextContent("none");
|
||||
|
||||
// Wait for connection error and error message to be set
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("connection-state")).toHaveTextContent(
|
||||
"CLOSED",
|
||||
);
|
||||
});
|
||||
|
||||
// Should set error message on connection failure
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("error-message")).not.toHaveTextContent(
|
||||
"none",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("should set error message store on WebSocket disconnect with error", async () => {
|
||||
// Set up MSW to connect first, then disconnect with error
|
||||
mswServer.use(
|
||||
wsLink.addEventListener("connection", ({ client, server }) => {
|
||||
server.connect();
|
||||
|
||||
// Simulate disconnect with error after a short delay
|
||||
setTimeout(() => {
|
||||
client.close(1006, "Unexpected disconnect");
|
||||
}, 100);
|
||||
}),
|
||||
);
|
||||
|
||||
// Render components that use both WebSocket and error message store
|
||||
renderWithWebSocketContext(
|
||||
<>
|
||||
<ErrorMessageStoreComponent />
|
||||
<ConnectionStatusComponent />
|
||||
</>,
|
||||
);
|
||||
|
||||
// Initially should show "none"
|
||||
expect(screen.getByTestId("error-message")).toHaveTextContent("none");
|
||||
|
||||
// Wait for connection to be established first
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("connection-state")).toHaveTextContent(
|
||||
"OPEN",
|
||||
);
|
||||
});
|
||||
|
||||
// Wait for disconnect and error message to be set
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("connection-state")).toHaveTextContent(
|
||||
"CLOSED",
|
||||
);
|
||||
});
|
||||
|
||||
// Should set error message on unexpected disconnect
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("error-message")).not.toHaveTextContent(
|
||||
"none",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("should clear error message store when connection is restored", async () => {
|
||||
let connectionAttempt = 0;
|
||||
|
||||
// Set up MSW to fail first connection, then succeed on retry
|
||||
mswServer.use(
|
||||
wsLink.addEventListener("connection", ({ client, server }) => {
|
||||
connectionAttempt += 1;
|
||||
|
||||
if (connectionAttempt === 1) {
|
||||
// First attempt fails
|
||||
client.close(1006, "Initial connection failed");
|
||||
} else {
|
||||
// Second attempt succeeds
|
||||
server.connect();
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// Render components that use both WebSocket and error message store
|
||||
renderWithWebSocketContext(
|
||||
<>
|
||||
<ErrorMessageStoreComponent />
|
||||
<ConnectionStatusComponent />
|
||||
</>,
|
||||
);
|
||||
|
||||
// Initially should show "none"
|
||||
expect(screen.getByTestId("error-message")).toHaveTextContent("none");
|
||||
|
||||
// Wait for first connection failure and error message
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("connection-state")).toHaveTextContent(
|
||||
"CLOSED",
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("error-message")).not.toHaveTextContent(
|
||||
"none",
|
||||
);
|
||||
});
|
||||
|
||||
// Simulate reconnection attempt (this would normally be triggered by the WebSocket context)
|
||||
// For now, we'll just verify the pattern - when connection is restored, error should clear
|
||||
// This test will fail until the WebSocket handler implements the clear logic
|
||||
|
||||
// Note: This test demonstrates the expected behavior but may need adjustment
|
||||
// based on how the actual reconnection logic is implemented
|
||||
});
|
||||
|
||||
it.todo("should track and display errors with proper metadata");
|
||||
it.todo("should set appropriate error states on connection failures");
|
||||
it.todo(
|
||||
"should handle WebSocket close codes appropriately (1000, 1006, etc.)",
|
||||
);
|
||||
});
|
||||
|
||||
// 6. Connection State Validation Tests
|
||||
describe("Connection State Management", () => {
|
||||
it.todo("should only connect when conversation is in RUNNING status");
|
||||
it.todo("should handle STARTING conversation state appropriately");
|
||||
it.todo("should disconnect when conversation is STOPPED");
|
||||
it.todo("should validate runtime status before connecting");
|
||||
});
|
||||
|
||||
// 7. Message Sending Tests
|
||||
describe("Message Sending", () => {
|
||||
it.todo("should send user actions through WebSocket when connected");
|
||||
it.todo("should handle send attempts when disconnected");
|
||||
});
|
||||
});
|
||||
46
frontend/__tests__/helpers/README.md
Normal file
46
frontend/__tests__/helpers/README.md
Normal file
@ -0,0 +1,46 @@
|
||||
# Test Helpers
|
||||
|
||||
This directory contains reusable test utilities and components for the OpenHands frontend test suite.
|
||||
|
||||
## Files
|
||||
|
||||
### `websocket-test-components.tsx`
|
||||
Contains React test components for accessing and displaying WebSocket-related store values:
|
||||
- `ConnectionStatusComponent` - Displays WebSocket connection state
|
||||
- `EventStoreComponent` - Displays event store values (events count, UI events count, latest event ID)
|
||||
- `OptimisticUserMessageStoreComponent` - Displays optimistic user message store values
|
||||
- `ErrorMessageStoreComponent` - Displays error message store values
|
||||
|
||||
These components are designed to be used in tests to verify that WebSocket events are properly processed and stored.
|
||||
|
||||
|
||||
|
||||
### `msw-websocket-setup.ts`
|
||||
Contains MSW (Mock Service Worker) setup utilities for WebSocket testing:
|
||||
- `createWebSocketLink()` - Creates a WebSocket link for MSW testing
|
||||
- `createWebSocketMockServer()` - Creates and configures an MSW server for WebSocket testing
|
||||
- `createWebSocketTestSetup()` - Creates a complete WebSocket testing setup
|
||||
- `conversationWebSocketTestSetup()` - Standard setup for conversation WebSocket handler tests
|
||||
|
||||
## Usage
|
||||
|
||||
```typescript
|
||||
import {
|
||||
ConnectionStatusComponent,
|
||||
EventStoreComponent,
|
||||
} from "./__tests__/helpers/websocket-test-components";
|
||||
import { conversationWebSocketTestSetup } from "./__tests__/helpers/msw-websocket-setup";
|
||||
|
||||
// Set up MSW server
|
||||
const { wsLink, server } = conversationWebSocketTestSetup();
|
||||
|
||||
// Render components with WebSocket context (helper function defined in test file)
|
||||
renderWithWebSocketContext(<ConnectionStatusComponent />);
|
||||
```
|
||||
|
||||
## Benefits
|
||||
|
||||
- **Reusability**: Test components and utilities can be shared across multiple test files
|
||||
- **Maintainability**: Changes to test setup only need to be made in one place
|
||||
- **Consistency**: Ensures consistent test setup across different WebSocket-related tests
|
||||
- **Readability**: Test files are cleaner and focus on test logic rather than setup boilerplate
|
||||
42
frontend/__tests__/helpers/msw-websocket-setup.ts
Normal file
42
frontend/__tests__/helpers/msw-websocket-setup.ts
Normal file
@ -0,0 +1,42 @@
|
||||
import { ws } from "msw";
|
||||
import { setupServer } from "msw/node";
|
||||
|
||||
/**
|
||||
* Creates a WebSocket link for MSW testing
|
||||
* @param url - WebSocket URL to mock (default: "ws://localhost/events/socket")
|
||||
* @returns MSW WebSocket link
|
||||
*/
|
||||
export const createWebSocketLink = (url = "ws://localhost/events/socket") =>
|
||||
ws.link(url);
|
||||
|
||||
/**
|
||||
* Creates and configures an MSW server for WebSocket testing
|
||||
* @param wsLink - WebSocket link to use for the server
|
||||
* @returns Configured MSW server
|
||||
*/
|
||||
export const createWebSocketMockServer = (wsLink: ReturnType<typeof ws.link>) =>
|
||||
setupServer(
|
||||
wsLink.addEventListener("connection", ({ server }) => {
|
||||
server.connect();
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* Creates a complete WebSocket testing setup with server and link
|
||||
* @param url - WebSocket URL to mock (default: "ws://localhost/events/socket")
|
||||
* @returns Object containing the WebSocket link and configured server
|
||||
*/
|
||||
export const createWebSocketTestSetup = (
|
||||
url = "ws://localhost/events/socket",
|
||||
) => {
|
||||
const wsLink = createWebSocketLink(url);
|
||||
const server = createWebSocketMockServer(wsLink);
|
||||
|
||||
return { wsLink, server };
|
||||
};
|
||||
|
||||
/**
|
||||
* Standard WebSocket test setup for conversation WebSocket handler tests
|
||||
*/
|
||||
export const conversationWebSocketTestSetup = () =>
|
||||
createWebSocketTestSetup("ws://localhost/events/socket");
|
||||
64
frontend/__tests__/helpers/websocket-test-components.tsx
Normal file
64
frontend/__tests__/helpers/websocket-test-components.tsx
Normal file
@ -0,0 +1,64 @@
|
||||
import React from "react";
|
||||
import { useConversationWebSocket } from "#/contexts/conversation-websocket-context";
|
||||
import { useEventStore } from "#/stores/use-event-store";
|
||||
import { useErrorMessageStore } from "#/stores/error-message-store";
|
||||
import { useOptimisticUserMessageStore } from "#/stores/optimistic-user-message-store";
|
||||
import { isV1Event } from "#/types/v1/type-guards";
|
||||
import { OpenHandsEvent } from "#/types/v1/core";
|
||||
|
||||
/**
|
||||
* Test component to access and display WebSocket connection state
|
||||
*/
|
||||
export function ConnectionStatusComponent() {
|
||||
const { connectionState } = useConversationWebSocket();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div data-testid="connection-state">{connectionState}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test component to access and display event store values
|
||||
*/
|
||||
export function EventStoreComponent() {
|
||||
const { events, uiEvents } = useEventStore();
|
||||
return (
|
||||
<div>
|
||||
<div data-testid="events-count">{events.length}</div>
|
||||
<div data-testid="ui-events-count">{uiEvents.length}</div>
|
||||
<div data-testid="latest-event-id">
|
||||
{isV1Event(events[events.length - 1])
|
||||
? (events[events.length - 1] as OpenHandsEvent).id
|
||||
: "none"}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test component to access and display optimistic user message store values
|
||||
*/
|
||||
export function OptimisticUserMessageStoreComponent() {
|
||||
const { optimisticUserMessage } = useOptimisticUserMessageStore();
|
||||
return (
|
||||
<div>
|
||||
<div data-testid="optimistic-user-message">
|
||||
{optimisticUserMessage || "none"}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test component to access and display error message store values
|
||||
*/
|
||||
export function ErrorMessageStoreComponent() {
|
||||
const { errorMessage } = useErrorMessageStore();
|
||||
return (
|
||||
<div>
|
||||
<div data-testid="error-message">{errorMessage || "none"}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,10 +1,7 @@
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach } from "node:test";
|
||||
import { beforeAll, describe, expect, it, vi, afterEach } from "vitest";
|
||||
import { useTerminal } from "#/hooks/use-terminal";
|
||||
import { Command, useCommandStore } from "#/state/command-store";
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
import { renderWithProviders } from "../../test-utils";
|
||||
import { useAgentStore } from "#/stores/agent-store";
|
||||
|
||||
// Mock the WsClient context
|
||||
vi.mock("#/context/ws-client-provider", () => ({
|
||||
@ -16,15 +13,7 @@ vi.mock("#/context/ws-client-provider", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
interface TestTerminalComponentProps {
|
||||
commands: Command[];
|
||||
}
|
||||
|
||||
function TestTerminalComponent({ commands }: TestTerminalComponentProps) {
|
||||
// Set commands in Zustand store
|
||||
useCommandStore.setState({ commands });
|
||||
// Set agent state in Zustand store
|
||||
useAgentStore.setState({ curAgentState: AgentState.RUNNING });
|
||||
function TestTerminalComponent() {
|
||||
const ref = useTerminal();
|
||||
return <div ref={ref} />;
|
||||
}
|
||||
@ -57,10 +46,12 @@ describe("useTerminal", () => {
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Reset command store between tests
|
||||
useCommandStore.setState({ commands: [] });
|
||||
});
|
||||
|
||||
it("should render", () => {
|
||||
renderWithProviders(<TestTerminalComponent commands={[]} />);
|
||||
renderWithProviders(<TestTerminalComponent />);
|
||||
});
|
||||
|
||||
it("should render the commands in the terminal", () => {
|
||||
@ -69,26 +60,12 @@ describe("useTerminal", () => {
|
||||
{ content: "hello", type: "output" },
|
||||
];
|
||||
|
||||
renderWithProviders(<TestTerminalComponent commands={commands} />);
|
||||
// Set commands in store before rendering to ensure they're picked up during initialization
|
||||
useCommandStore.setState({ commands });
|
||||
|
||||
renderWithProviders(<TestTerminalComponent />);
|
||||
|
||||
expect(mockTerminal.writeln).toHaveBeenNthCalledWith(1, "echo hello");
|
||||
expect(mockTerminal.writeln).toHaveBeenNthCalledWith(2, "hello");
|
||||
});
|
||||
|
||||
// This test is no longer relevant as secrets filtering has been removed
|
||||
it.skip("should hide secrets in the terminal", () => {
|
||||
const secret = "super_secret_github_token";
|
||||
const anotherSecret = "super_secret_another_token";
|
||||
const commands: Command[] = [
|
||||
{
|
||||
content: `export GITHUB_TOKEN=${secret},${anotherSecret},${secret}`,
|
||||
type: "input",
|
||||
},
|
||||
{ content: secret, type: "output" },
|
||||
];
|
||||
|
||||
renderWithProviders(<TestTerminalComponent commands={commands} />);
|
||||
|
||||
// This test is no longer relevant as secrets filtering has been removed
|
||||
});
|
||||
});
|
||||
|
||||
316
frontend/__tests__/hooks/use-websocket.test.ts
Normal file
316
frontend/__tests__/hooks/use-websocket.test.ts
Normal file
@ -0,0 +1,316 @@
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
beforeAll,
|
||||
afterAll,
|
||||
afterEach,
|
||||
vi,
|
||||
} from "vitest";
|
||||
import { ws } from "msw";
|
||||
import { setupServer } from "msw/node";
|
||||
import { useWebSocket } from "#/hooks/use-websocket";
|
||||
|
||||
describe.skip("useWebSocket", () => {
|
||||
// MSW WebSocket mock setup
|
||||
const wsLink = ws.link("ws://acme.com/ws");
|
||||
|
||||
const mswServer = setupServer(
|
||||
wsLink.addEventListener("connection", ({ client, server }) => {
|
||||
// Establish the connection
|
||||
server.connect();
|
||||
|
||||
// Send a welcome message to confirm connection
|
||||
client.send("Welcome to the WebSocket!");
|
||||
}),
|
||||
);
|
||||
|
||||
beforeAll(() => mswServer.listen());
|
||||
afterEach(() => mswServer.resetHandlers());
|
||||
afterAll(() => mswServer.close());
|
||||
|
||||
it("should establish a WebSocket connection", async () => {
|
||||
const { result } = renderHook(() => useWebSocket("ws://acme.com/ws"));
|
||||
|
||||
// Initially should not be connected
|
||||
expect(result.current.isConnected).toBe(false);
|
||||
expect(result.current.lastMessage).toBe(null);
|
||||
|
||||
// Wait for connection to be established
|
||||
await waitFor(() => {
|
||||
expect(result.current.isConnected).toBe(true);
|
||||
});
|
||||
|
||||
// Should receive the welcome message from our mock
|
||||
await waitFor(() => {
|
||||
expect(result.current.lastMessage).toBe("Welcome to the WebSocket!");
|
||||
});
|
||||
|
||||
// Confirm that the WebSocket connection is established when the hook is used
|
||||
expect(result.current.socket).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should handle incoming messages correctly", async () => {
|
||||
const { result } = renderHook(() => useWebSocket("ws://acme.com/ws"));
|
||||
|
||||
// Wait for connection to be established
|
||||
await waitFor(() => {
|
||||
expect(result.current.isConnected).toBe(true);
|
||||
});
|
||||
|
||||
// Should receive the welcome message from our mock
|
||||
await waitFor(() => {
|
||||
expect(result.current.lastMessage).toBe("Welcome to the WebSocket!");
|
||||
});
|
||||
|
||||
// Send another message from the mock server
|
||||
wsLink.broadcast("Hello from server!");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.lastMessage).toBe("Hello from server!");
|
||||
});
|
||||
|
||||
// Should have a messages array with all received messages
|
||||
expect(result.current.messages).toEqual([
|
||||
"Welcome to the WebSocket!",
|
||||
"Hello from server!",
|
||||
]);
|
||||
});
|
||||
|
||||
it("should handle connection errors gracefully", async () => {
|
||||
// Create a mock that will simulate an error
|
||||
const errorLink = ws.link("ws://error-test.com/ws");
|
||||
mswServer.use(
|
||||
errorLink.addEventListener("connection", ({ client }) => {
|
||||
// Simulate an error by closing the connection immediately
|
||||
client.close(1006, "Connection failed");
|
||||
}),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useWebSocket("ws://error-test.com/ws"));
|
||||
|
||||
// Initially should not be connected and no error
|
||||
expect(result.current.isConnected).toBe(false);
|
||||
expect(result.current.error).toBe(null);
|
||||
|
||||
// Wait for the connection to fail
|
||||
await waitFor(() => {
|
||||
expect(result.current.isConnected).toBe(false);
|
||||
});
|
||||
|
||||
// Should have error information (the close event should trigger error state)
|
||||
await waitFor(() => {
|
||||
expect(result.current.error).not.toBe(null);
|
||||
});
|
||||
|
||||
expect(result.current.error).toBeInstanceOf(Error);
|
||||
// Should have meaningful error message (could be from onerror or onclose)
|
||||
expect(
|
||||
result.current.error?.message.includes("WebSocket closed with code 1006"),
|
||||
).toBe(true);
|
||||
|
||||
// Should not crash the application
|
||||
expect(result.current.socket).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should close the WebSocket connection on unmount", async () => {
|
||||
const { result, unmount } = renderHook(() =>
|
||||
useWebSocket("ws://acme.com/ws"),
|
||||
);
|
||||
|
||||
// Wait for connection to be established
|
||||
await waitFor(() => {
|
||||
expect(result.current.isConnected).toBe(true);
|
||||
});
|
||||
|
||||
// Verify connection is active
|
||||
expect(result.current.isConnected).toBe(true);
|
||||
expect(result.current.socket).toBeTruthy();
|
||||
|
||||
const closeSpy = vi.spyOn(result.current.socket!, "close");
|
||||
|
||||
// Unmount the component (this should trigger the useEffect cleanup)
|
||||
unmount();
|
||||
|
||||
// Verify that WebSocket close was called during cleanup
|
||||
expect(closeSpy).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("should support query parameters in WebSocket URL", async () => {
|
||||
const baseUrl = "ws://acme.com/ws";
|
||||
const queryParams = {
|
||||
token: "abc123",
|
||||
userId: "user456",
|
||||
version: "v1",
|
||||
};
|
||||
|
||||
const { result } = renderHook(() => useWebSocket(baseUrl, { queryParams }));
|
||||
|
||||
// Wait for connection to be established
|
||||
await waitFor(() => {
|
||||
expect(result.current.isConnected).toBe(true);
|
||||
});
|
||||
|
||||
// Verify that the WebSocket was created with query parameters
|
||||
expect(result.current.socket).toBeTruthy();
|
||||
expect(result.current.socket!.url).toBe(
|
||||
"ws://acme.com/ws?token=abc123&userId=user456&version=v1",
|
||||
);
|
||||
});
|
||||
|
||||
it("should call onOpen handler when WebSocket connection opens", async () => {
|
||||
const onOpenSpy = vi.fn();
|
||||
const options = { onOpen: onOpenSpy };
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useWebSocket("ws://acme.com/ws", options),
|
||||
);
|
||||
|
||||
// Initially should not be connected
|
||||
expect(result.current.isConnected).toBe(false);
|
||||
expect(onOpenSpy).not.toHaveBeenCalled();
|
||||
|
||||
// Wait for connection to be established
|
||||
await waitFor(() => {
|
||||
expect(result.current.isConnected).toBe(true);
|
||||
});
|
||||
|
||||
// onOpen handler should have been called
|
||||
expect(onOpenSpy).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("should call onClose handler when WebSocket connection closes", async () => {
|
||||
const onCloseSpy = vi.fn();
|
||||
const options = { onClose: onCloseSpy };
|
||||
|
||||
const { result, unmount } = renderHook(() =>
|
||||
useWebSocket("ws://acme.com/ws", options),
|
||||
);
|
||||
|
||||
// Wait for connection to be established
|
||||
await waitFor(() => {
|
||||
expect(result.current.isConnected).toBe(true);
|
||||
});
|
||||
|
||||
expect(onCloseSpy).not.toHaveBeenCalled();
|
||||
|
||||
// Unmount to trigger close
|
||||
unmount();
|
||||
|
||||
// Wait for onClose handler to be called
|
||||
await waitFor(() => {
|
||||
expect(onCloseSpy).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
it("should call onMessage handler when WebSocket receives a message", async () => {
|
||||
const onMessageSpy = vi.fn();
|
||||
const options = { onMessage: onMessageSpy };
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useWebSocket("ws://acme.com/ws", options),
|
||||
);
|
||||
|
||||
// Wait for connection to be established
|
||||
await waitFor(() => {
|
||||
expect(result.current.isConnected).toBe(true);
|
||||
});
|
||||
|
||||
// Should receive the welcome message from our mock
|
||||
await waitFor(() => {
|
||||
expect(result.current.lastMessage).toBe("Welcome to the WebSocket!");
|
||||
});
|
||||
|
||||
// onMessage handler should have been called for the welcome message
|
||||
expect(onMessageSpy).toHaveBeenCalledOnce();
|
||||
|
||||
// Send another message from the mock server
|
||||
wsLink.broadcast("Hello from server!");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.lastMessage).toBe("Hello from server!");
|
||||
});
|
||||
|
||||
// onMessage handler should have been called twice now
|
||||
expect(onMessageSpy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("should call onError handler when WebSocket encounters an error", async () => {
|
||||
const onErrorSpy = vi.fn();
|
||||
const options = { onError: onErrorSpy };
|
||||
|
||||
// Create a mock that will simulate an error
|
||||
const errorLink = ws.link("ws://error-test.com/ws");
|
||||
mswServer.use(
|
||||
errorLink.addEventListener("connection", ({ client }) => {
|
||||
// Simulate an error by closing the connection immediately
|
||||
client.close(1006, "Connection failed");
|
||||
}),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useWebSocket("ws://error-test.com/ws", options),
|
||||
);
|
||||
|
||||
// Initially should not be connected and no error
|
||||
expect(result.current.isConnected).toBe(false);
|
||||
expect(onErrorSpy).not.toHaveBeenCalled();
|
||||
|
||||
// Wait for the connection to fail
|
||||
await waitFor(() => {
|
||||
expect(result.current.isConnected).toBe(false);
|
||||
});
|
||||
|
||||
// Should have error information
|
||||
await waitFor(() => {
|
||||
expect(result.current.error).not.toBe(null);
|
||||
});
|
||||
|
||||
// onError handler should have been called
|
||||
expect(onErrorSpy).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("should provide sendMessage function to send messages to WebSocket", async () => {
|
||||
const { result } = renderHook(() => useWebSocket("ws://acme.com/ws"));
|
||||
|
||||
// Wait for connection to be established
|
||||
await waitFor(() => {
|
||||
expect(result.current.isConnected).toBe(true);
|
||||
});
|
||||
|
||||
// Should have a sendMessage function
|
||||
expect(result.current.sendMessage).toBeDefined();
|
||||
expect(typeof result.current.sendMessage).toBe("function");
|
||||
|
||||
// Mock the WebSocket send method
|
||||
const sendSpy = vi.spyOn(result.current.socket!, "send");
|
||||
|
||||
// Send a message
|
||||
result.current.sendMessage("Hello WebSocket!");
|
||||
|
||||
// Verify that WebSocket.send was called with the correct message
|
||||
expect(sendSpy).toHaveBeenCalledOnce();
|
||||
expect(sendSpy).toHaveBeenCalledWith("Hello WebSocket!");
|
||||
});
|
||||
|
||||
it("should not send message when WebSocket is not connected", () => {
|
||||
const { result } = renderHook(() => useWebSocket("ws://acme.com/ws"));
|
||||
|
||||
// Initially should not be connected
|
||||
expect(result.current.isConnected).toBe(false);
|
||||
expect(result.current.sendMessage).toBeDefined();
|
||||
|
||||
// Mock the WebSocket send method (even though socket might be null)
|
||||
const sendSpy = vi.fn();
|
||||
if (result.current.socket) {
|
||||
vi.spyOn(result.current.socket, "send").mockImplementation(sendSpy);
|
||||
}
|
||||
|
||||
// Try to send a message when not connected
|
||||
result.current.sendMessage("Hello WebSocket!");
|
||||
|
||||
// Verify that WebSocket.send was not called
|
||||
expect(sendSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
130
frontend/__tests__/stores/use-event-store.test.ts
Normal file
130
frontend/__tests__/stores/use-event-store.test.ts
Normal file
@ -0,0 +1,130 @@
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { useEventStore } from "#/stores/use-event-store";
|
||||
import {
|
||||
ActionEvent,
|
||||
MessageEvent,
|
||||
ObservationEvent,
|
||||
SecurityRisk,
|
||||
} from "#/types/v1/core";
|
||||
|
||||
const mockUserMessageEvent: MessageEvent = {
|
||||
id: "test-event-1",
|
||||
timestamp: Date.now().toString(),
|
||||
source: "user",
|
||||
llm_message: {
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "Hello, world!" }],
|
||||
},
|
||||
activated_microagents: [],
|
||||
extended_content: [],
|
||||
};
|
||||
|
||||
const mockActionEvent: ActionEvent = {
|
||||
id: "test-action-1",
|
||||
timestamp: Date.now().toString(),
|
||||
source: "agent",
|
||||
thought: [{ type: "text", text: "I need to execute a bash command" }],
|
||||
thinking_blocks: [],
|
||||
action: {
|
||||
kind: "ExecuteBashAction",
|
||||
command: "echo hello",
|
||||
is_input: false,
|
||||
timeout: null,
|
||||
reset: false,
|
||||
},
|
||||
tool_name: "execute_bash",
|
||||
tool_call_id: "call_123",
|
||||
tool_call: {
|
||||
id: "call_123",
|
||||
type: "function",
|
||||
function: {
|
||||
name: "execute_bash",
|
||||
arguments: '{"command": "echo hello"}',
|
||||
},
|
||||
},
|
||||
llm_response_id: "response_123",
|
||||
security_risk: SecurityRisk.UNKNOWN,
|
||||
};
|
||||
|
||||
const mockObservationEvent: ObservationEvent = {
|
||||
id: "test-observation-1",
|
||||
timestamp: Date.now().toString(),
|
||||
source: "environment",
|
||||
tool_name: "execute_bash",
|
||||
tool_call_id: "call_123",
|
||||
observation: {
|
||||
kind: "ExecuteBashObservation",
|
||||
output: "hello\n",
|
||||
command: "echo hello",
|
||||
exit_code: 0,
|
||||
error: false,
|
||||
timeout: false,
|
||||
metadata: {
|
||||
exit_code: 0,
|
||||
pid: 12345,
|
||||
username: "user",
|
||||
hostname: "localhost",
|
||||
working_dir: "/home/user",
|
||||
py_interpreter_path: null,
|
||||
prefix: "",
|
||||
suffix: "",
|
||||
},
|
||||
},
|
||||
action_id: "test-action-1",
|
||||
};
|
||||
|
||||
describe("useEventStore", () => {
|
||||
it("should render initial state correctly", () => {
|
||||
const { result } = renderHook(() => useEventStore());
|
||||
expect(result.current.events).toEqual([]);
|
||||
});
|
||||
|
||||
it("should add an event to the store", () => {
|
||||
const { result } = renderHook(() => useEventStore());
|
||||
|
||||
act(() => {
|
||||
result.current.addEvent(mockUserMessageEvent);
|
||||
});
|
||||
|
||||
expect(result.current.events).toEqual([mockUserMessageEvent]);
|
||||
});
|
||||
|
||||
it("should retrieve events whose actions are replaced by their observations", () => {
|
||||
const { result } = renderHook(() => useEventStore());
|
||||
|
||||
act(() => {
|
||||
result.current.addEvent(mockUserMessageEvent);
|
||||
result.current.addEvent(mockActionEvent);
|
||||
result.current.addEvent(mockObservationEvent);
|
||||
});
|
||||
|
||||
expect(result.current.uiEvents).toEqual([
|
||||
mockUserMessageEvent,
|
||||
mockObservationEvent,
|
||||
]);
|
||||
});
|
||||
|
||||
it("should clear all events when clearEvents is called", () => {
|
||||
const { result } = renderHook(() => useEventStore());
|
||||
|
||||
// Add some events first
|
||||
act(() => {
|
||||
result.current.addEvent(mockUserMessageEvent);
|
||||
result.current.addEvent(mockActionEvent);
|
||||
});
|
||||
|
||||
// Verify events were added
|
||||
expect(result.current.events).toHaveLength(2);
|
||||
expect(result.current.uiEvents).toHaveLength(2);
|
||||
|
||||
// Clear events
|
||||
act(() => {
|
||||
result.current.clearEvents();
|
||||
});
|
||||
|
||||
// Verify events were cleared
|
||||
expect(result.current.events).toEqual([]);
|
||||
expect(result.current.uiEvents).toEqual([]);
|
||||
});
|
||||
});
|
||||
141
frontend/__tests__/utils/handle-event-for-ui.test.ts
Normal file
141
frontend/__tests__/utils/handle-event-for-ui.test.ts
Normal file
@ -0,0 +1,141 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
ActionEvent,
|
||||
ObservationEvent,
|
||||
MessageEvent,
|
||||
SecurityRisk,
|
||||
OpenHandsEvent,
|
||||
} from "#/types/v1/core";
|
||||
import { handleEventForUI } from "#/utils/handle-event-for-ui";
|
||||
|
||||
describe("handleEventForUI", () => {
|
||||
const mockObservationEvent: ObservationEvent = {
|
||||
id: "test-observation-1",
|
||||
timestamp: Date.now().toString(),
|
||||
source: "environment",
|
||||
tool_name: "execute_bash",
|
||||
tool_call_id: "call_123",
|
||||
observation: {
|
||||
kind: "ExecuteBashObservation",
|
||||
output: "hello\n",
|
||||
command: "echo hello",
|
||||
exit_code: 0,
|
||||
error: false,
|
||||
timeout: false,
|
||||
metadata: {
|
||||
exit_code: 0,
|
||||
pid: 12345,
|
||||
username: "user",
|
||||
hostname: "localhost",
|
||||
working_dir: "/home/user",
|
||||
py_interpreter_path: null,
|
||||
prefix: "",
|
||||
suffix: "",
|
||||
},
|
||||
},
|
||||
action_id: "test-action-1",
|
||||
};
|
||||
|
||||
const mockActionEvent: ActionEvent = {
|
||||
id: "test-action-1",
|
||||
timestamp: Date.now().toString(),
|
||||
source: "agent",
|
||||
thought: [{ type: "text", text: "I need to execute a bash command" }],
|
||||
thinking_blocks: [],
|
||||
action: {
|
||||
kind: "ExecuteBashAction",
|
||||
command: "echo hello",
|
||||
is_input: false,
|
||||
timeout: null,
|
||||
reset: false,
|
||||
},
|
||||
tool_name: "execute_bash",
|
||||
tool_call_id: "call_123",
|
||||
tool_call: {
|
||||
id: "call_123",
|
||||
type: "function",
|
||||
function: {
|
||||
name: "execute_bash",
|
||||
arguments: '{"command": "echo hello"}',
|
||||
},
|
||||
},
|
||||
llm_response_id: "response_123",
|
||||
security_risk: SecurityRisk.UNKNOWN,
|
||||
};
|
||||
|
||||
const mockMessageEvent: MessageEvent = {
|
||||
id: "test-event-1",
|
||||
timestamp: Date.now().toString(),
|
||||
source: "user",
|
||||
llm_message: {
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "Hello, world!" }],
|
||||
},
|
||||
activated_microagents: [],
|
||||
extended_content: [],
|
||||
};
|
||||
|
||||
it("should add non-observation events to the end of uiEvents", () => {
|
||||
const initialUiEvents = [mockMessageEvent];
|
||||
const result = handleEventForUI(mockActionEvent, initialUiEvents);
|
||||
|
||||
expect(result).toEqual([mockMessageEvent, mockActionEvent]);
|
||||
expect(result).not.toBe(initialUiEvents); // Should return a new array
|
||||
});
|
||||
|
||||
it("should replace corresponding action with observation when action exists", () => {
|
||||
const initialUiEvents = [mockMessageEvent, mockActionEvent];
|
||||
const result = handleEventForUI(mockObservationEvent, initialUiEvents);
|
||||
|
||||
expect(result).toEqual([mockMessageEvent, mockObservationEvent]);
|
||||
expect(result).not.toBe(initialUiEvents); // Should return a new array
|
||||
});
|
||||
|
||||
it("should add observation to end when corresponding action is not found", () => {
|
||||
const initialUiEvents = [mockMessageEvent];
|
||||
const result = handleEventForUI(mockObservationEvent, initialUiEvents);
|
||||
|
||||
expect(result).toEqual([mockMessageEvent, mockObservationEvent]);
|
||||
expect(result).not.toBe(initialUiEvents); // Should return a new array
|
||||
});
|
||||
|
||||
it("should handle empty uiEvents array", () => {
|
||||
const initialUiEvents: OpenHandsEvent[] = [];
|
||||
const result = handleEventForUI(mockObservationEvent, initialUiEvents);
|
||||
|
||||
expect(result).toEqual([mockObservationEvent]);
|
||||
expect(result).not.toBe(initialUiEvents); // Should return a new array
|
||||
});
|
||||
|
||||
it("should not mutate the original uiEvents array", () => {
|
||||
const initialUiEvents = [mockMessageEvent, mockActionEvent];
|
||||
const originalLength = initialUiEvents.length;
|
||||
const originalFirstEvent = initialUiEvents[0];
|
||||
|
||||
handleEventForUI(mockObservationEvent, initialUiEvents);
|
||||
|
||||
expect(initialUiEvents).toHaveLength(originalLength);
|
||||
expect(initialUiEvents[0]).toBe(originalFirstEvent);
|
||||
expect(initialUiEvents[1]).toBe(mockActionEvent); // Should not be replaced
|
||||
});
|
||||
|
||||
it("should replace the correct action when multiple actions exist", () => {
|
||||
const anotherActionEvent: ActionEvent = {
|
||||
...mockActionEvent,
|
||||
id: "test-action-2",
|
||||
};
|
||||
|
||||
const initialUiEvents = [
|
||||
mockMessageEvent,
|
||||
mockActionEvent,
|
||||
anotherActionEvent,
|
||||
];
|
||||
const result = handleEventForUI(mockObservationEvent, initialUiEvents);
|
||||
|
||||
expect(result).toEqual([
|
||||
mockMessageEvent,
|
||||
mockObservationEvent,
|
||||
anotherActionEvent,
|
||||
]);
|
||||
});
|
||||
});
|
||||
@ -7,7 +7,7 @@ import { TrajectoryActions } from "../trajectory/trajectory-actions";
|
||||
import { createChatMessage } from "#/services/chat-service";
|
||||
import { InteractiveChatBox } from "./interactive-chat-box";
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
import { isOpenHandsAction } from "#/types/core/guards";
|
||||
import { isOpenHandsAction, isActionOrObservation } from "#/types/core/guards";
|
||||
import { generateAgentStateChangeEvent } from "#/services/agent-state-service";
|
||||
import { FeedbackModal } from "../feedback/feedback-modal";
|
||||
import { useScrollToBottom } from "#/hooks/use-scroll-to-bottom";
|
||||
@ -24,6 +24,7 @@ import { LoadingSpinner } from "#/components/shared/loading-spinner";
|
||||
import { displayErrorToast } from "#/utils/custom-toast-handlers";
|
||||
import { useErrorMessageStore } from "#/stores/error-message-store";
|
||||
import { useOptimisticUserMessageStore } from "#/stores/optimistic-user-message-store";
|
||||
import { useEventStore } from "#/stores/use-event-store";
|
||||
import { ErrorMessageBanner } from "./error-message-banner";
|
||||
import {
|
||||
hasUserEvent,
|
||||
@ -34,6 +35,7 @@ import { useConfig } from "#/hooks/query/use-config";
|
||||
import { validateFiles } from "#/utils/file-validation";
|
||||
import { useConversationStore } from "#/state/conversation-store";
|
||||
import ConfirmationModeEnabled from "./confirmation-mode-enabled";
|
||||
import { isV0Event } from "#/types/v1/type-guards";
|
||||
|
||||
function getEntryPoint(
|
||||
hasRepository: boolean | null,
|
||||
@ -47,7 +49,8 @@ function getEntryPoint(
|
||||
export function ChatInterface() {
|
||||
const { setMessageToSend } = useConversationStore();
|
||||
const { errorMessage } = useErrorMessageStore();
|
||||
const { send, isLoadingMessages, parsedEvents } = useWsClient();
|
||||
const { send, isLoadingMessages } = useWsClient();
|
||||
const storeEvents = useEventStore((state) => state.events);
|
||||
const { setOptimisticUserMessage, getOptimisticUserMessage } =
|
||||
useOptimisticUserMessageStore();
|
||||
const { t } = useTranslation();
|
||||
@ -74,18 +77,24 @@ export function ChatInterface() {
|
||||
|
||||
const optimisticUserMessage = getOptimisticUserMessage();
|
||||
|
||||
const events = parsedEvents.filter(shouldRenderEvent);
|
||||
const events = storeEvents
|
||||
.filter(isV0Event)
|
||||
.filter(isActionOrObservation)
|
||||
.filter(shouldRenderEvent);
|
||||
|
||||
// Check if there are any substantive agent actions (not just system messages)
|
||||
const hasSubstantiveAgentActions = React.useMemo(
|
||||
() =>
|
||||
parsedEvents.some(
|
||||
(event) =>
|
||||
isOpenHandsAction(event) &&
|
||||
event.source === "agent" &&
|
||||
event.action !== "system",
|
||||
),
|
||||
[parsedEvents],
|
||||
storeEvents
|
||||
.filter(isV0Event)
|
||||
.filter(isActionOrObservation)
|
||||
.some(
|
||||
(event) =>
|
||||
isOpenHandsAction(event) &&
|
||||
event.source === "agent" &&
|
||||
event.action !== "system",
|
||||
),
|
||||
[storeEvents],
|
||||
);
|
||||
|
||||
const handleSendMessage = async (
|
||||
|
||||
@ -5,11 +5,13 @@ import { AgentState } from "#/types/agent-state";
|
||||
import { generateAgentStateChangeEvent } from "#/services/agent-state-service";
|
||||
import { useWsClient } from "#/context/ws-client-provider";
|
||||
import { ActionTooltip } from "../action-tooltip";
|
||||
import { isOpenHandsAction } from "#/types/core/guards";
|
||||
import { isOpenHandsAction, isActionOrObservation } from "#/types/core/guards";
|
||||
import { ActionSecurityRisk } from "#/stores/security-analyzer-store";
|
||||
import { RiskAlert } from "#/components/shared/risk-alert";
|
||||
import WarningIcon from "#/icons/u-warning.svg?react";
|
||||
import { useEventMessageStore } from "#/stores/event-message-store";
|
||||
import { useEventStore } from "#/stores/use-event-store";
|
||||
import { isV0Event } from "#/types/v1/type-guards";
|
||||
|
||||
export function ConfirmationButtons() {
|
||||
const submittedEventIds = useEventMessageStore(
|
||||
@ -21,10 +23,13 @@ export function ConfirmationButtons() {
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { send, parsedEvents } = useWsClient();
|
||||
const { send } = useWsClient();
|
||||
const events = useEventStore((state) => state.events);
|
||||
|
||||
// Find the most recent action awaiting confirmation
|
||||
const awaitingAction = parsedEvents
|
||||
const awaitingAction = events
|
||||
.filter(isV0Event)
|
||||
.filter(isActionOrObservation)
|
||||
.slice()
|
||||
.reverse()
|
||||
.find((ev) => {
|
||||
|
||||
@ -11,13 +11,11 @@ import {
|
||||
CommandAction,
|
||||
FileEditAction,
|
||||
FileWriteAction,
|
||||
OpenHandsAction,
|
||||
UserMessageAction,
|
||||
} from "#/types/core/actions";
|
||||
import { Conversation } from "#/api/open-hands.types";
|
||||
import { useUserProviders } from "#/hooks/use-user-providers";
|
||||
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
|
||||
import { OpenHandsObservation } from "#/types/core/observations";
|
||||
import {
|
||||
isAgentStateChangeObservation,
|
||||
isErrorObservation,
|
||||
@ -28,6 +26,7 @@ import {
|
||||
} from "#/types/core/guards";
|
||||
import { useErrorMessageStore } from "#/stores/error-message-store";
|
||||
import { useOptimisticUserMessageStore } from "#/stores/optimistic-user-message-store";
|
||||
import { useEventStore } from "#/stores/use-event-store";
|
||||
|
||||
export type WebSocketStatus = "CONNECTING" | "CONNECTED" | "DISCONNECTED";
|
||||
|
||||
@ -72,16 +71,12 @@ const isMessageAction = (
|
||||
interface UseWsClient {
|
||||
webSocketStatus: WebSocketStatus;
|
||||
isLoadingMessages: boolean;
|
||||
events: Record<string, unknown>[];
|
||||
parsedEvents: (OpenHandsAction | OpenHandsObservation)[];
|
||||
send: (event: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
const WsClientContext = React.createContext<UseWsClient>({
|
||||
webSocketStatus: "DISCONNECTED",
|
||||
isLoadingMessages: true,
|
||||
events: [],
|
||||
parsedEvents: [],
|
||||
send: () => {
|
||||
throw new Error("not connected");
|
||||
},
|
||||
@ -133,14 +128,11 @@ export function WsClientProvider({
|
||||
}: React.PropsWithChildren<WsClientProviderProps>) {
|
||||
const { setErrorMessage, removeErrorMessage } = useErrorMessageStore();
|
||||
const { removeOptimisticUserMessage } = useOptimisticUserMessageStore();
|
||||
const { addEvent, clearEvents } = useEventStore();
|
||||
const queryClient = useQueryClient();
|
||||
const sioRef = React.useRef<Socket | null>(null);
|
||||
const [webSocketStatus, setWebSocketStatus] =
|
||||
React.useState<WebSocketStatus>("DISCONNECTED");
|
||||
const [events, setEvents] = React.useState<Record<string, unknown>[]>([]);
|
||||
const [parsedEvents, setParsedEvents] = React.useState<
|
||||
(OpenHandsAction | OpenHandsObservation)[]
|
||||
>([]);
|
||||
const lastEventRef = React.useRef<Record<string, unknown> | null>(null);
|
||||
const { providers } = useUserProviders();
|
||||
|
||||
@ -188,7 +180,7 @@ export function WsClientProvider({
|
||||
}
|
||||
|
||||
if (isOpenHandsAction(event) || isOpenHandsObservation(event)) {
|
||||
setParsedEvents((prevEvents) => [...prevEvents, event]);
|
||||
addEvent(event); // Event is already OpenHandsParsedEvent
|
||||
}
|
||||
|
||||
if (isErrorObservation(event)) {
|
||||
@ -249,7 +241,6 @@ export function WsClientProvider({
|
||||
}
|
||||
}
|
||||
|
||||
setEvents((prevEvents) => [...prevEvents, event]);
|
||||
if (!Number.isNaN(parseInt(event.id as string, 10))) {
|
||||
lastEventRef.current = event;
|
||||
}
|
||||
@ -286,9 +277,7 @@ export function WsClientProvider({
|
||||
React.useEffect(() => {
|
||||
lastEventRef.current = null;
|
||||
|
||||
// reset events when conversationId changes
|
||||
setEvents([]);
|
||||
setParsedEvents([]);
|
||||
clearEvents();
|
||||
setWebSocketStatus("CONNECTING");
|
||||
}, [conversationId]);
|
||||
|
||||
@ -397,16 +386,9 @@ export function WsClientProvider({
|
||||
() => ({
|
||||
webSocketStatus,
|
||||
isLoadingMessages: messageRateHandler.isUnderThreshold,
|
||||
events,
|
||||
parsedEvents,
|
||||
send,
|
||||
}),
|
||||
[
|
||||
webSocketStatus,
|
||||
messageRateHandler.isUnderThreshold,
|
||||
events,
|
||||
parsedEvents,
|
||||
],
|
||||
[webSocketStatus, messageRateHandler.isUnderThreshold],
|
||||
);
|
||||
|
||||
return <WsClientContext value={value}>{children}</WsClientContext>;
|
||||
|
||||
156
frontend/src/contexts/conversation-websocket-context.tsx
Normal file
156
frontend/src/contexts/conversation-websocket-context.tsx
Normal file
@ -0,0 +1,156 @@
|
||||
import React, {
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
useCallback,
|
||||
useMemo,
|
||||
} from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useWebSocket } from "#/hooks/use-websocket";
|
||||
import { useEventStore } from "#/stores/use-event-store";
|
||||
import { useErrorMessageStore } from "#/stores/error-message-store";
|
||||
import { useOptimisticUserMessageStore } from "#/stores/optimistic-user-message-store";
|
||||
import {
|
||||
isV1Event,
|
||||
isAgentErrorEvent,
|
||||
isUserMessageEvent,
|
||||
isActionEvent,
|
||||
} from "#/types/v1/type-guards";
|
||||
import { handleActionEventCacheInvalidation } from "#/utils/cache-utils";
|
||||
|
||||
interface ConversationWebSocketContextType {
|
||||
connectionState: "CONNECTING" | "OPEN" | "CLOSED" | "CLOSING";
|
||||
}
|
||||
|
||||
const ConversationWebSocketContext = createContext<
|
||||
ConversationWebSocketContextType | undefined
|
||||
>(undefined);
|
||||
|
||||
export function ConversationWebSocketProvider({
|
||||
children,
|
||||
conversationId,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
conversationId?: string;
|
||||
}) {
|
||||
const [connectionState, setConnectionState] = useState<
|
||||
"CONNECTING" | "OPEN" | "CLOSED" | "CLOSING"
|
||||
>("CONNECTING");
|
||||
const queryClient = useQueryClient();
|
||||
const { addEvent } = useEventStore();
|
||||
const { setErrorMessage, removeErrorMessage } = useErrorMessageStore();
|
||||
const { removeOptimisticUserMessage } = useOptimisticUserMessageStore();
|
||||
|
||||
const handleMessage = useCallback(
|
||||
(messageEvent: MessageEvent) => {
|
||||
try {
|
||||
const event = JSON.parse(messageEvent.data);
|
||||
// Use type guard to validate v1 event structure
|
||||
if (isV1Event(event)) {
|
||||
addEvent(event);
|
||||
|
||||
// Handle AgentErrorEvent specifically
|
||||
if (isAgentErrorEvent(event)) {
|
||||
setErrorMessage(event.error);
|
||||
}
|
||||
|
||||
// Clear optimistic user message when a user message is confirmed
|
||||
if (isUserMessageEvent(event)) {
|
||||
removeOptimisticUserMessage();
|
||||
}
|
||||
|
||||
// Handle cache invalidation for ActionEvent
|
||||
if (isActionEvent(event)) {
|
||||
const currentConversationId =
|
||||
conversationId || "test-conversation-id"; // TODO: Get from context
|
||||
handleActionEventCacheInvalidation(
|
||||
event,
|
||||
currentConversationId,
|
||||
queryClient,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn("Failed to parse WebSocket message as JSON:", error);
|
||||
}
|
||||
},
|
||||
[addEvent, setErrorMessage, removeOptimisticUserMessage, queryClient],
|
||||
);
|
||||
|
||||
const websocketOptions = useMemo(
|
||||
() => ({
|
||||
onOpen: () => {
|
||||
setConnectionState("OPEN");
|
||||
removeErrorMessage(); // Clear any previous error messages on successful connection
|
||||
},
|
||||
onClose: (event: CloseEvent) => {
|
||||
setConnectionState("CLOSED");
|
||||
// Set error message for unexpected disconnects (not normal closure)
|
||||
if (event.code !== 1000) {
|
||||
setErrorMessage(
|
||||
`Connection lost: ${event.reason || "Unexpected disconnect"}`,
|
||||
);
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
setConnectionState("CLOSED");
|
||||
setErrorMessage("Failed to connect to server");
|
||||
},
|
||||
onMessage: handleMessage,
|
||||
}),
|
||||
[handleMessage, setErrorMessage, removeErrorMessage],
|
||||
);
|
||||
|
||||
const { socket } = useWebSocket(
|
||||
"ws://localhost/events/socket",
|
||||
websocketOptions,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (socket) {
|
||||
// Update state based on socket readyState
|
||||
const updateState = () => {
|
||||
switch (socket.readyState) {
|
||||
case WebSocket.CONNECTING:
|
||||
setConnectionState("CONNECTING");
|
||||
break;
|
||||
case WebSocket.OPEN:
|
||||
setConnectionState("OPEN");
|
||||
break;
|
||||
case WebSocket.CLOSING:
|
||||
setConnectionState("CLOSING");
|
||||
break;
|
||||
case WebSocket.CLOSED:
|
||||
setConnectionState("CLOSED");
|
||||
break;
|
||||
default:
|
||||
setConnectionState("CLOSED");
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
updateState();
|
||||
}
|
||||
}, [socket]);
|
||||
|
||||
const contextValue = useMemo(() => ({ connectionState }), [connectionState]);
|
||||
|
||||
return (
|
||||
<ConversationWebSocketContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</ConversationWebSocketContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export const useConversationWebSocket =
|
||||
(): ConversationWebSocketContextType => {
|
||||
const context = useContext(ConversationWebSocketContext);
|
||||
if (context === undefined) {
|
||||
throw new Error(
|
||||
"useConversationWebSocket must be used within a ConversationWebSocketProvider",
|
||||
);
|
||||
}
|
||||
return context;
|
||||
};
|
||||
55
frontend/src/contexts/websocket-provider-wrapper.tsx
Normal file
55
frontend/src/contexts/websocket-provider-wrapper.tsx
Normal file
@ -0,0 +1,55 @@
|
||||
import React from "react";
|
||||
import { WsClientProvider } from "#/context/ws-client-provider";
|
||||
import { ConversationWebSocketProvider } from "#/contexts/conversation-websocket-context";
|
||||
|
||||
interface WebSocketProviderWrapperProps {
|
||||
children: React.ReactNode;
|
||||
conversationId: string;
|
||||
version: 0 | 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* A wrapper component that conditionally renders either the old v0 WebSocket provider
|
||||
* or the new v1 WebSocket provider based on the version prop.
|
||||
*
|
||||
* @param version - 0 for old WsClientProvider, 1 for new ConversationWebSocketProvider
|
||||
* @param conversationId - The conversation ID to pass to the provider
|
||||
* @param children - The child components to wrap
|
||||
*
|
||||
* @example
|
||||
* // Use the old v0 provider
|
||||
* <WebSocketProviderWrapper version={0} conversationId="conv-123">
|
||||
* <ChatComponent />
|
||||
* </WebSocketProviderWrapper>
|
||||
*
|
||||
* @example
|
||||
* // Use the new v1 provider
|
||||
* <WebSocketProviderWrapper version={1} conversationId="conv-123">
|
||||
* <ChatComponent />
|
||||
* </WebSocketProviderWrapper>
|
||||
*/
|
||||
export function WebSocketProviderWrapper({
|
||||
children,
|
||||
conversationId,
|
||||
version,
|
||||
}: WebSocketProviderWrapperProps) {
|
||||
if (version === 0) {
|
||||
return (
|
||||
<WsClientProvider conversationId={conversationId}>
|
||||
{children}
|
||||
</WsClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
if (version === 1) {
|
||||
return (
|
||||
<ConversationWebSocketProvider conversationId={conversationId}>
|
||||
{children}
|
||||
</ConversationWebSocketProvider>
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Unsupported WebSocket provider version: ${version}. Supported versions are 0 and 1.`,
|
||||
);
|
||||
}
|
||||
@ -2,10 +2,9 @@ import { useTranslation } from "react-i18next";
|
||||
import React from "react";
|
||||
import posthog from "posthog-js";
|
||||
import { useParams, useNavigate } from "react-router";
|
||||
import { useWsClient } from "#/context/ws-client-provider";
|
||||
import { transformVSCodeUrl } from "#/utils/vscode-url-helper";
|
||||
import useMetricsStore from "#/stores/metrics-store";
|
||||
import { isSystemMessage } from "#/types/core/guards";
|
||||
import { isSystemMessage, isActionOrObservation } from "#/types/core/guards";
|
||||
import { ConversationStatus } from "#/types/conversation-status";
|
||||
import ConversationService from "#/api/conversation-service/conversation-service.api";
|
||||
import { useDeleteConversation } from "./mutation/use-delete-conversation";
|
||||
@ -14,6 +13,8 @@ import { useGetTrajectory } from "./mutation/use-get-trajectory";
|
||||
import { downloadTrajectory } from "#/utils/download-trajectory";
|
||||
import { displayErrorToast } from "#/utils/custom-toast-handlers";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { useEventStore } from "#/stores/use-event-store";
|
||||
import { isV0Event } from "#/types/v1/type-guards";
|
||||
|
||||
interface UseConversationNameContextMenuProps {
|
||||
conversationId?: string;
|
||||
@ -31,7 +32,7 @@ export function useConversationNameContextMenu({
|
||||
const { t } = useTranslation();
|
||||
const { conversationId: currentConversationId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const { parsedEvents } = useWsClient();
|
||||
const events = useEventStore((state) => state.events);
|
||||
const { mutate: deleteConversation } = useDeleteConversation();
|
||||
const { mutate: stopConversation } = useStopConversation();
|
||||
const { mutate: getTrajectory } = useGetTrajectory();
|
||||
@ -46,7 +47,10 @@ export function useConversationNameContextMenu({
|
||||
const [confirmStopModalVisible, setConfirmStopModalVisible] =
|
||||
React.useState(false);
|
||||
|
||||
const systemMessage = parsedEvents.find(isSystemMessage);
|
||||
const systemMessage = events
|
||||
.filter(isV0Event)
|
||||
.filter(isActionOrObservation)
|
||||
.find(isSystemMessage);
|
||||
|
||||
const handleDelete = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.preventDefault();
|
||||
|
||||
@ -3,6 +3,7 @@ import { useWsClient } from "#/context/ws-client-provider";
|
||||
import { generateAgentStateChangeEvent } from "#/services/agent-state-service";
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
import { displayErrorToast } from "#/utils/custom-toast-handlers";
|
||||
import { useEventStore } from "#/stores/use-event-store";
|
||||
|
||||
interface ServerError {
|
||||
error: boolean | string;
|
||||
@ -13,7 +14,8 @@ interface ServerError {
|
||||
const isServerError = (data: object): data is ServerError => "error" in data;
|
||||
|
||||
export const useHandleWSEvents = () => {
|
||||
const { events, send } = useWsClient();
|
||||
const { send } = useWsClient();
|
||||
const events = useEventStore((state) => state.events);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!events.length) {
|
||||
@ -35,7 +37,7 @@ export const useHandleWSEvents = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.type === "error") {
|
||||
if ("type" in event && event.type === "error") {
|
||||
const message: string = `${event.message}`;
|
||||
if (message.startsWith("Agent reached maximum")) {
|
||||
// We set the agent state to paused here - if the user clicks resume, it auto updates the max iterations
|
||||
|
||||
85
frontend/src/hooks/use-websocket.ts
Normal file
85
frontend/src/hooks/use-websocket.ts
Normal file
@ -0,0 +1,85 @@
|
||||
import React from "react";
|
||||
|
||||
export const useWebSocket = <T = string>(
|
||||
url: string,
|
||||
options?: {
|
||||
queryParams?: Record<string, string>;
|
||||
onOpen?: (event: Event) => void;
|
||||
onClose?: (event: CloseEvent) => void;
|
||||
onMessage?: (event: MessageEvent) => void;
|
||||
onError?: (event: Event) => void;
|
||||
},
|
||||
) => {
|
||||
const [isConnected, setIsConnected] = React.useState(false);
|
||||
const [lastMessage, setLastMessage] = React.useState<T | null>(null);
|
||||
const [messages, setMessages] = React.useState<T[]>([]);
|
||||
const [error, setError] = React.useState<Error | null>(null);
|
||||
const wsRef = React.useRef<WebSocket | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
// Build URL with query parameters if provided
|
||||
let wsUrl = url;
|
||||
if (options?.queryParams) {
|
||||
const params = new URLSearchParams(options.queryParams);
|
||||
wsUrl = `${url}?${params.toString()}`;
|
||||
}
|
||||
|
||||
const ws = new WebSocket(wsUrl);
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = (event) => {
|
||||
setIsConnected(true);
|
||||
setError(null); // Clear any previous errors
|
||||
options?.onOpen?.(event);
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
setLastMessage(event.data);
|
||||
setMessages((prev) => [...prev, event.data]);
|
||||
options?.onMessage?.(event);
|
||||
};
|
||||
|
||||
ws.onclose = (event) => {
|
||||
setIsConnected(false);
|
||||
// If the connection closes with an error code, treat it as an error
|
||||
if (event.code !== 1000) {
|
||||
// 1000 is normal closure
|
||||
setError(
|
||||
new Error(
|
||||
`WebSocket closed with code ${event.code}: ${event.reason || "Connection closed unexpectedly"}`,
|
||||
),
|
||||
);
|
||||
// Also call onError handler for error closures
|
||||
options?.onError?.(event);
|
||||
}
|
||||
options?.onClose?.(event);
|
||||
};
|
||||
|
||||
ws.onerror = (event) => {
|
||||
setIsConnected(false);
|
||||
options?.onError?.(event);
|
||||
};
|
||||
|
||||
return () => {
|
||||
ws.close();
|
||||
};
|
||||
}, [url, options]);
|
||||
|
||||
const sendMessage = React.useCallback(
|
||||
(data: string | ArrayBufferLike | Blob | ArrayBufferView) => {
|
||||
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(data);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return {
|
||||
isConnected,
|
||||
lastMessage,
|
||||
messages,
|
||||
error,
|
||||
socket: wsRef.current,
|
||||
sendMessage,
|
||||
};
|
||||
};
|
||||
@ -5,6 +5,8 @@ import {
|
||||
UserMessageAction,
|
||||
} from "#/types/core/actions";
|
||||
import { AgentStateChangeObservation } from "#/types/core/observations";
|
||||
import { MessageEvent } from "#/types/v1/core";
|
||||
import { AgentErrorEvent } from "#/types/v1/core/events/observation-event";
|
||||
import { MockSessionMessaage } from "./session-history.mock";
|
||||
|
||||
export const generateAgentStateChangeObservation = (
|
||||
@ -73,3 +75,56 @@ export const emitMessages = (
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// V1 Event Mock Factories for WebSocket Testing
|
||||
|
||||
/**
|
||||
* Creates a mock MessageEvent for testing WebSocket event handling
|
||||
*/
|
||||
export const createMockMessageEvent = (
|
||||
overrides: Partial<MessageEvent> = {},
|
||||
): MessageEvent => ({
|
||||
id: "test-event-123",
|
||||
timestamp: new Date().toISOString(),
|
||||
source: "agent",
|
||||
llm_message: {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "Hello from agent" }],
|
||||
},
|
||||
activated_microagents: [],
|
||||
extended_content: [],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
/**
|
||||
* Creates a mock user MessageEvent for testing WebSocket event handling
|
||||
*/
|
||||
export const createMockUserMessageEvent = (
|
||||
overrides: Partial<MessageEvent> = {},
|
||||
): MessageEvent => ({
|
||||
id: "user-message-123",
|
||||
timestamp: new Date().toISOString(),
|
||||
source: "user",
|
||||
llm_message: {
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "Hello from user" }],
|
||||
},
|
||||
activated_microagents: [],
|
||||
extended_content: [],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
/**
|
||||
* Creates a mock AgentErrorEvent for testing error handling
|
||||
*/
|
||||
export const createMockAgentErrorEvent = (
|
||||
overrides: Partial<AgentErrorEvent> = {},
|
||||
): AgentErrorEvent => ({
|
||||
id: "error-event-123",
|
||||
timestamp: new Date().toISOString(),
|
||||
source: "agent",
|
||||
tool_name: "str_replace_editor",
|
||||
tool_call_id: "tool-call-456",
|
||||
error: "Failed to execute command: Permission denied",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
@ -11,7 +11,6 @@ import { useAgentStore } from "#/stores/agent-store";
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
|
||||
import { useBatchFeedback } from "#/hooks/query/use-batch-feedback";
|
||||
import { WsClientProvider } from "#/context/ws-client-provider";
|
||||
import { EventHandler } from "../wrapper/event-handler";
|
||||
import { useConversationConfig } from "#/hooks/query/use-conversation-config";
|
||||
|
||||
@ -28,6 +27,7 @@ import { ConversationName } from "#/components/features/conversation/conversatio
|
||||
|
||||
import { ConversationTabs } from "#/components/features/conversation/conversation-tabs/conversation-tabs";
|
||||
import { useStartConversation } from "#/hooks/mutation/use-start-conversation";
|
||||
import { WebSocketProviderWrapper } from "#/contexts/websocket-provider-wrapper";
|
||||
|
||||
function AppContent() {
|
||||
useConversationConfig();
|
||||
@ -106,7 +106,7 @@ function AppContent() {
|
||||
});
|
||||
|
||||
return (
|
||||
<WsClientProvider conversationId={conversationId}>
|
||||
<WebSocketProviderWrapper version={0} conversationId={conversationId}>
|
||||
<ConversationSubscriptionsProvider>
|
||||
<EventHandler>
|
||||
<div
|
||||
@ -122,7 +122,7 @@ function AppContent() {
|
||||
</div>
|
||||
</EventHandler>
|
||||
</ConversationSubscriptionsProvider>
|
||||
</WsClientProvider>
|
||||
</WebSocketProviderWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
38
frontend/src/stores/use-event-store.ts
Normal file
38
frontend/src/stores/use-event-store.ts
Normal file
@ -0,0 +1,38 @@
|
||||
import { create } from "zustand";
|
||||
import { OpenHandsEvent } from "#/types/v1/core";
|
||||
import { handleEventForUI } from "#/utils/handle-event-for-ui";
|
||||
import { OpenHandsParsedEvent } from "#/types/core";
|
||||
import { isV1Event } from "#/types/v1/type-guards";
|
||||
|
||||
// While we transition to v1 events, our store can handle both v0 and v1 events
|
||||
type OHEvent = OpenHandsEvent | OpenHandsParsedEvent;
|
||||
|
||||
interface EventState {
|
||||
events: OHEvent[];
|
||||
uiEvents: OHEvent[];
|
||||
addEvent: (event: OHEvent) => void;
|
||||
clearEvents: () => void;
|
||||
}
|
||||
|
||||
export const useEventStore = create<EventState>()((set) => ({
|
||||
events: [],
|
||||
uiEvents: [],
|
||||
addEvent: (event: OHEvent) =>
|
||||
set((state) => {
|
||||
const newEvents = [...state.events, event];
|
||||
const newUiEvents = isV1Event(event)
|
||||
? // @ts-expect-error - temporary, needs proper typing
|
||||
handleEventForUI(event, state.uiEvents)
|
||||
: [...state.uiEvents, event];
|
||||
|
||||
return {
|
||||
events: newEvents,
|
||||
uiEvents: newUiEvents,
|
||||
};
|
||||
}),
|
||||
clearEvents: () =>
|
||||
set(() => ({
|
||||
events: [],
|
||||
uiEvents: [],
|
||||
})),
|
||||
}));
|
||||
@ -105,3 +105,8 @@ export const isStatusUpdate = (event: unknown): event is StatusUpdate =>
|
||||
"status_update" in event &&
|
||||
"type" in event &&
|
||||
"id" in event;
|
||||
|
||||
export const isActionOrObservation = (
|
||||
event: OpenHandsParsedEvent,
|
||||
): event is OpenHandsAction | OpenHandsObservation =>
|
||||
isOpenHandsAction(event) || isOpenHandsObservation(event);
|
||||
|
||||
@ -2,6 +2,9 @@ import { OpenHandsAction } from "./actions";
|
||||
import { OpenHandsObservation } from "./observations";
|
||||
import { OpenHandsVariance } from "./variances";
|
||||
|
||||
/**
|
||||
* @deprecated Will be removed once we fully transition to v1 events
|
||||
*/
|
||||
export type OpenHandsParsedEvent =
|
||||
| OpenHandsAction
|
||||
| OpenHandsObservation
|
||||
|
||||
115
frontend/src/types/v1/type-guards.ts
Normal file
115
frontend/src/types/v1/type-guards.ts
Normal file
@ -0,0 +1,115 @@
|
||||
import { OpenHandsEvent, ObservationEvent, BaseEvent } from "./core";
|
||||
import { AgentErrorEvent } from "./core/events/observation-event";
|
||||
import { MessageEvent } from "./core/events/message-event";
|
||||
import { ActionEvent } from "./core/events/action-event";
|
||||
import type { OpenHandsParsedEvent } from "../core/index";
|
||||
|
||||
/**
|
||||
* Type guard to check if an unknown value is a valid BaseEvent
|
||||
* @param value - The value to check
|
||||
* @returns true if the value is a valid BaseEvent
|
||||
*/
|
||||
export function isBaseEvent(value: unknown): value is BaseEvent {
|
||||
return (
|
||||
value !== null &&
|
||||
typeof value === "object" &&
|
||||
"id" in value &&
|
||||
"timestamp" in value &&
|
||||
"source" in value &&
|
||||
typeof value.id === "string" &&
|
||||
value.id.length > 0 &&
|
||||
typeof value.timestamp === "string" &&
|
||||
value.timestamp.length > 0 &&
|
||||
typeof value.source === "string" &&
|
||||
(value.source === "agent" ||
|
||||
value.source === "user" ||
|
||||
value.source === "environment")
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard function to check if an event is an observation event
|
||||
*/
|
||||
export const isObservationEvent = (
|
||||
event: OpenHandsEvent,
|
||||
): event is ObservationEvent =>
|
||||
event.source === "environment" &&
|
||||
"action_id" in event &&
|
||||
"observation" in event;
|
||||
|
||||
/**
|
||||
* Type guard function to check if an event is an agent error event
|
||||
*/
|
||||
export const isAgentErrorEvent = (
|
||||
event: OpenHandsEvent,
|
||||
): event is AgentErrorEvent =>
|
||||
event.source === "agent" &&
|
||||
"tool_name" in event &&
|
||||
"tool_call_id" in event &&
|
||||
"error" in event &&
|
||||
typeof event.tool_name === "string" &&
|
||||
typeof event.tool_call_id === "string" &&
|
||||
typeof event.error === "string";
|
||||
|
||||
/**
|
||||
* Type guard function to check if an event is a user message event
|
||||
*/
|
||||
export const isUserMessageEvent = (
|
||||
event: OpenHandsEvent,
|
||||
): event is MessageEvent =>
|
||||
"llm_message" in event &&
|
||||
typeof event.llm_message === "object" &&
|
||||
event.llm_message !== null &&
|
||||
"role" in event.llm_message &&
|
||||
event.llm_message.role === "user";
|
||||
|
||||
/**
|
||||
* Type guard function to check if an event is an action event
|
||||
*/
|
||||
export const isActionEvent = (event: OpenHandsEvent): event is ActionEvent =>
|
||||
event.source === "agent" &&
|
||||
"action" in event &&
|
||||
"tool_name" in event &&
|
||||
"tool_call_id" in event &&
|
||||
typeof event.tool_name === "string" &&
|
||||
typeof event.tool_call_id === "string";
|
||||
|
||||
// =============================================================================
|
||||
// TEMPORARY COMPATIBILITY TYPE GUARDS
|
||||
// These will be removed once we fully migrate to V1 events
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* TEMPORARY: Type guard to check if an event is a V1 OpenHandsEvent
|
||||
* Uses isBaseEvent to validate the complete event structure
|
||||
*
|
||||
* @deprecated This is temporary until full V1 migration is complete
|
||||
*/
|
||||
export function isV1Event(
|
||||
event: OpenHandsEvent | OpenHandsParsedEvent,
|
||||
): event is OpenHandsEvent {
|
||||
// Use isBaseEvent to validate the complete BaseEvent structure
|
||||
// This ensures the event has all required properties with correct types
|
||||
return isBaseEvent(event);
|
||||
}
|
||||
|
||||
/**
|
||||
* TEMPORARY: Type guard to check if an event is a V0 OpenHandsParsedEvent
|
||||
*
|
||||
* @deprecated This is temporary until full V1 migration is complete
|
||||
*/
|
||||
export function isV0Event(
|
||||
event: OpenHandsEvent | OpenHandsParsedEvent,
|
||||
): event is OpenHandsParsedEvent {
|
||||
// Handle null/undefined cases
|
||||
if (!event || typeof event !== "object") {
|
||||
return false;
|
||||
}
|
||||
|
||||
// V0 events have numeric IDs and either 'action' or 'observation' properties
|
||||
return (
|
||||
"id" in event &&
|
||||
typeof event.id === "number" &&
|
||||
("action" in event || "observation" in event)
|
||||
);
|
||||
}
|
||||
44
frontend/src/utils/cache-utils.ts
Normal file
44
frontend/src/utils/cache-utils.ts
Normal file
@ -0,0 +1,44 @@
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import type { ActionEvent } from "#/types/v1/core/events/action-event";
|
||||
import { stripWorkspacePrefix } from "./path-utils";
|
||||
|
||||
/**
|
||||
* Cache invalidation utilities for TanStack Query
|
||||
*/
|
||||
|
||||
/**
|
||||
* Handle cache invalidation for ActionEvent
|
||||
* Invalidates relevant query caches based on the action type
|
||||
*
|
||||
* @param event - The ActionEvent to process
|
||||
* @param conversationId - The conversation ID for cache keys
|
||||
* @param queryClient - The TanStack Query client instance
|
||||
*/
|
||||
export const handleActionEventCacheInvalidation = (
|
||||
event: ActionEvent,
|
||||
conversationId: string,
|
||||
queryClient: QueryClient,
|
||||
) => {
|
||||
const { action } = event;
|
||||
|
||||
// Invalidate file_changes cache for file-related actions
|
||||
if (
|
||||
action.kind === "StrReplaceEditorAction" ||
|
||||
action.kind === "ExecuteBashAction"
|
||||
) {
|
||||
queryClient.invalidateQueries(
|
||||
{
|
||||
queryKey: ["file_changes", conversationId],
|
||||
},
|
||||
{ cancelRefetch: false },
|
||||
);
|
||||
}
|
||||
|
||||
// Invalidate specific file diff cache for file modifications
|
||||
if (action.kind === "StrReplaceEditorAction" && action.path) {
|
||||
const strippedPath = stripWorkspacePrefix(action.path);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["file_diff", conversationId, strippedPath],
|
||||
});
|
||||
}
|
||||
};
|
||||
31
frontend/src/utils/handle-event-for-ui.ts
Normal file
31
frontend/src/utils/handle-event-for-ui.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import { OpenHandsEvent } from "#/types/v1/core";
|
||||
import { isObservationEvent } from "#/types/v1/type-guards";
|
||||
|
||||
/**
|
||||
* Handles adding an event to the UI events array, with special logic for observation events
|
||||
*/
|
||||
export const handleEventForUI = (
|
||||
event: OpenHandsEvent,
|
||||
uiEvents: OpenHandsEvent[],
|
||||
): OpenHandsEvent[] => {
|
||||
const newUiEvents = [...uiEvents];
|
||||
|
||||
if (isObservationEvent(event)) {
|
||||
// Find and replace the corresponding action from uiEvents
|
||||
const actionIndex = newUiEvents.findIndex(
|
||||
(uiEvent) => uiEvent.id === event.action_id,
|
||||
);
|
||||
if (actionIndex !== -1) {
|
||||
// Replace the action with the observation
|
||||
newUiEvents[actionIndex] = event;
|
||||
} else {
|
||||
// Action not found in uiEvents, just add the observation
|
||||
newUiEvents.push(event);
|
||||
}
|
||||
} else {
|
||||
// For non-observation events, just add them to uiEvents
|
||||
newUiEvents.push(event);
|
||||
}
|
||||
|
||||
return newUiEvents;
|
||||
};
|
||||
20
frontend/src/utils/path-utils.ts
Normal file
20
frontend/src/utils/path-utils.ts
Normal file
@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Path manipulation utilities
|
||||
*/
|
||||
|
||||
/**
|
||||
* Strip workspace prefix from file paths
|
||||
* Removes /workspace/ and the next directory level from paths
|
||||
*
|
||||
* @param path - The file path to process
|
||||
* @returns The path with workspace prefix removed
|
||||
*
|
||||
* @example
|
||||
* stripWorkspacePrefix("/workspace/repo/src/file.py") // returns "src/file.py"
|
||||
* stripWorkspacePrefix("/workspace/my-project/components/Button.tsx") // returns "components/Button.tsx"
|
||||
*/
|
||||
export const stripWorkspacePrefix = (path: string): string => {
|
||||
// Strip /workspace/ and the next directory level
|
||||
const workspaceMatch = path.match(/^\/workspace\/[^/]+\/(.*)$/);
|
||||
return workspaceMatch ? workspaceMatch[1] : path;
|
||||
};
|
||||
@ -30,6 +30,9 @@ vi.mock("#/hooks/use-is-on-tos-page", () => ({
|
||||
useIsOnTosPage: () => false,
|
||||
}));
|
||||
|
||||
// Import the Zustand mock to enable automatic store resets
|
||||
vi.mock("zustand");
|
||||
|
||||
// Mock requests during tests
|
||||
beforeAll(() => {
|
||||
server.listen({ onUnhandledRequest: "bypass" });
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user