From ef49994700fd0f552c91e41769c7bab7313cfbb7 Mon Sep 17 00:00:00 2001 From: "sp.wack" <83104063+amanape@users.noreply.github.com> Date: Fri, 10 Oct 2025 16:29:27 +0400 Subject: [PATCH] feat(frontend): V1 WebSocket handler (#11221) Co-authored-by: openhands --- frontend/__mocks__/zustand.ts | 60 +++ .../components/chat/chat-interface.test.tsx | 12 +- .../conversation-websocket-handler.test.tsx | 397 ++++++++++++++++++ frontend/__tests__/helpers/README.md | 46 ++ .../__tests__/helpers/msw-websocket-setup.ts | 42 ++ .../helpers/websocket-test-components.tsx | 64 +++ .../__tests__/hooks/use-terminal.test.tsx | 41 +- .../__tests__/hooks/use-websocket.test.ts | 316 ++++++++++++++ .../__tests__/stores/use-event-store.test.ts | 130 ++++++ .../utils/handle-event-for-ui.test.ts | 141 +++++++ .../features/chat/chat-interface.tsx | 29 +- .../shared/buttons/confirmation-buttons.tsx | 11 +- frontend/src/context/ws-client-provider.tsx | 28 +- .../conversation-websocket-context.tsx | 156 +++++++ .../contexts/websocket-provider-wrapper.tsx | 55 +++ .../use-conversation-name-context-menu.ts | 12 +- frontend/src/hooks/use-handle-ws-events.ts | 6 +- frontend/src/hooks/use-websocket.ts | 85 ++++ frontend/src/mocks/mock-ws-helpers.ts | 55 +++ frontend/src/routes/conversation.tsx | 6 +- frontend/src/stores/use-event-store.ts | 38 ++ frontend/src/types/core/guards.ts | 5 + frontend/src/types/core/index.ts | 3 + frontend/src/types/v1/type-guards.ts | 115 +++++ frontend/src/utils/cache-utils.ts | 44 ++ frontend/src/utils/handle-event-for-ui.ts | 31 ++ frontend/src/utils/path-utils.ts | 20 + frontend/vitest.setup.ts | 3 + 28 files changed, 1869 insertions(+), 82 deletions(-) create mode 100644 frontend/__mocks__/zustand.ts create mode 100644 frontend/__tests__/conversation-websocket-handler.test.tsx create mode 100644 frontend/__tests__/helpers/README.md create mode 100644 frontend/__tests__/helpers/msw-websocket-setup.ts create mode 100644 frontend/__tests__/helpers/websocket-test-components.tsx create mode 100644 frontend/__tests__/hooks/use-websocket.test.ts create mode 100644 frontend/__tests__/stores/use-event-store.test.ts create mode 100644 frontend/__tests__/utils/handle-event-for-ui.test.ts create mode 100644 frontend/src/contexts/conversation-websocket-context.tsx create mode 100644 frontend/src/contexts/websocket-provider-wrapper.tsx create mode 100644 frontend/src/hooks/use-websocket.ts create mode 100644 frontend/src/stores/use-event-store.ts create mode 100644 frontend/src/types/v1/type-guards.ts create mode 100644 frontend/src/utils/cache-utils.ts create mode 100644 frontend/src/utils/handle-event-for-ui.ts create mode 100644 frontend/src/utils/path-utils.ts diff --git a/frontend/__mocks__/zustand.ts b/frontend/__mocks__/zustand.ts new file mode 100644 index 0000000000..f3c3e443b2 --- /dev/null +++ b/frontend/__mocks__/zustand.ts @@ -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("zustand"); + +// a variable to hold reset functions for all stores declared in the app +export const storeResetFns = new Set<() => void>(); + +const createUncurried = ( + stateCreator: ZustandExportedTypes.StateCreator, +) => { + 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 = (( + stateCreator: ZustandExportedTypes.StateCreator, +) => + // to support curried version of create + typeof stateCreator === "function" + ? createUncurried(stateCreator) + : createUncurried) as typeof ZustandExportedTypes.create; + +const createStoreUncurried = ( + stateCreator: ZustandExportedTypes.StateCreator, +) => { + 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 = (( + stateCreator: ZustandExportedTypes.StateCreator, +) => + // 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(); + }); + }); +}); diff --git a/frontend/__tests__/components/chat/chat-interface.test.tsx b/frontend/__tests__/components/chat/chat-interface.test.tsx index e4f5c1b0f0..d895f9de58 100644 --- a/frontend/__tests__/components/chat/chat-interface.test.tsx +++ b/frontend/__tests__/components/chat/chat-interface.test.tsx @@ -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).mockReturnValue({ - send: vi.fn(), - isLoadingMessages: false, - parsedEvents: [userEvent], + useEventStore.setState({ + events: [mockUserEvent], + uiEvents: [], + addEvent: vi.fn(), + clearEvents: vi.fn(), }); renderWithQueryClient(, queryClient); diff --git a/frontend/__tests__/conversation-websocket-handler.test.tsx b/frontend/__tests__/conversation-websocket-handler.test.tsx new file mode 100644 index 0000000000..db59d764d4 --- /dev/null +++ b/frontend/__tests__/conversation-websocket-handler.test.tsx @@ -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( + + + {children} + + , + ); +} + +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(); + + // 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(); + + // 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(); + + // 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(); + + // 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(); + + // 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( + <> + + + , + ); + + // 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( + <> + + + , + ); + + // 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( + <> + + + , + ); + + // 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"); + }); +}); diff --git a/frontend/__tests__/helpers/README.md b/frontend/__tests__/helpers/README.md new file mode 100644 index 0000000000..c28a8bb8ec --- /dev/null +++ b/frontend/__tests__/helpers/README.md @@ -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(); +``` + +## 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 diff --git a/frontend/__tests__/helpers/msw-websocket-setup.ts b/frontend/__tests__/helpers/msw-websocket-setup.ts new file mode 100644 index 0000000000..560009e27e --- /dev/null +++ b/frontend/__tests__/helpers/msw-websocket-setup.ts @@ -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) => + 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"); diff --git a/frontend/__tests__/helpers/websocket-test-components.tsx b/frontend/__tests__/helpers/websocket-test-components.tsx new file mode 100644 index 0000000000..15fd2b4617 --- /dev/null +++ b/frontend/__tests__/helpers/websocket-test-components.tsx @@ -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 ( +
+
{connectionState}
+
+ ); +} + +/** + * Test component to access and display event store values + */ +export function EventStoreComponent() { + const { events, uiEvents } = useEventStore(); + return ( +
+
{events.length}
+
{uiEvents.length}
+
+ {isV1Event(events[events.length - 1]) + ? (events[events.length - 1] as OpenHandsEvent).id + : "none"} +
+
+ ); +} + +/** + * Test component to access and display optimistic user message store values + */ +export function OptimisticUserMessageStoreComponent() { + const { optimisticUserMessage } = useOptimisticUserMessageStore(); + return ( +
+
+ {optimisticUserMessage || "none"} +
+
+ ); +} + +/** + * Test component to access and display error message store values + */ +export function ErrorMessageStoreComponent() { + const { errorMessage } = useErrorMessageStore(); + return ( +
+
{errorMessage || "none"}
+
+ ); +} diff --git a/frontend/__tests__/hooks/use-terminal.test.tsx b/frontend/__tests__/hooks/use-terminal.test.tsx index 898eed5334..d1937986af 100644 --- a/frontend/__tests__/hooks/use-terminal.test.tsx +++ b/frontend/__tests__/hooks/use-terminal.test.tsx @@ -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
; } @@ -57,10 +46,12 @@ describe("useTerminal", () => { afterEach(() => { vi.clearAllMocks(); + // Reset command store between tests + useCommandStore.setState({ commands: [] }); }); it("should render", () => { - renderWithProviders(); + renderWithProviders(); }); it("should render the commands in the terminal", () => { @@ -69,26 +60,12 @@ describe("useTerminal", () => { { content: "hello", type: "output" }, ]; - renderWithProviders(); + // Set commands in store before rendering to ensure they're picked up during initialization + useCommandStore.setState({ commands }); + + renderWithProviders(); 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(); - - // This test is no longer relevant as secrets filtering has been removed - }); }); diff --git a/frontend/__tests__/hooks/use-websocket.test.ts b/frontend/__tests__/hooks/use-websocket.test.ts new file mode 100644 index 0000000000..7152f371c2 --- /dev/null +++ b/frontend/__tests__/hooks/use-websocket.test.ts @@ -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(); + }); +}); diff --git a/frontend/__tests__/stores/use-event-store.test.ts b/frontend/__tests__/stores/use-event-store.test.ts new file mode 100644 index 0000000000..79ea9e1509 --- /dev/null +++ b/frontend/__tests__/stores/use-event-store.test.ts @@ -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([]); + }); +}); diff --git a/frontend/__tests__/utils/handle-event-for-ui.test.ts b/frontend/__tests__/utils/handle-event-for-ui.test.ts new file mode 100644 index 0000000000..01f8009c27 --- /dev/null +++ b/frontend/__tests__/utils/handle-event-for-ui.test.ts @@ -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, + ]); + }); +}); diff --git a/frontend/src/components/features/chat/chat-interface.tsx b/frontend/src/components/features/chat/chat-interface.tsx index d4eb108a23..176999cd93 100644 --- a/frontend/src/components/features/chat/chat-interface.tsx +++ b/frontend/src/components/features/chat/chat-interface.tsx @@ -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 ( diff --git a/frontend/src/components/shared/buttons/confirmation-buttons.tsx b/frontend/src/components/shared/buttons/confirmation-buttons.tsx index 5fe7daec60..c3fbddaa19 100644 --- a/frontend/src/components/shared/buttons/confirmation-buttons.tsx +++ b/frontend/src/components/shared/buttons/confirmation-buttons.tsx @@ -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) => { diff --git a/frontend/src/context/ws-client-provider.tsx b/frontend/src/context/ws-client-provider.tsx index 13312f9e30..8e0ef3830c 100644 --- a/frontend/src/context/ws-client-provider.tsx +++ b/frontend/src/context/ws-client-provider.tsx @@ -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[]; - parsedEvents: (OpenHandsAction | OpenHandsObservation)[]; send: (event: Record) => void; } const WsClientContext = React.createContext({ webSocketStatus: "DISCONNECTED", isLoadingMessages: true, - events: [], - parsedEvents: [], send: () => { throw new Error("not connected"); }, @@ -133,14 +128,11 @@ export function WsClientProvider({ }: React.PropsWithChildren) { const { setErrorMessage, removeErrorMessage } = useErrorMessageStore(); const { removeOptimisticUserMessage } = useOptimisticUserMessageStore(); + const { addEvent, clearEvents } = useEventStore(); const queryClient = useQueryClient(); const sioRef = React.useRef(null); const [webSocketStatus, setWebSocketStatus] = React.useState("DISCONNECTED"); - const [events, setEvents] = React.useState[]>([]); - const [parsedEvents, setParsedEvents] = React.useState< - (OpenHandsAction | OpenHandsObservation)[] - >([]); const lastEventRef = React.useRef | 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 {children}; diff --git a/frontend/src/contexts/conversation-websocket-context.tsx b/frontend/src/contexts/conversation-websocket-context.tsx new file mode 100644 index 0000000000..fc851dd75c --- /dev/null +++ b/frontend/src/contexts/conversation-websocket-context.tsx @@ -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 ( + + {children} + + ); +} + +export const useConversationWebSocket = + (): ConversationWebSocketContextType => { + const context = useContext(ConversationWebSocketContext); + if (context === undefined) { + throw new Error( + "useConversationWebSocket must be used within a ConversationWebSocketProvider", + ); + } + return context; + }; diff --git a/frontend/src/contexts/websocket-provider-wrapper.tsx b/frontend/src/contexts/websocket-provider-wrapper.tsx new file mode 100644 index 0000000000..59c3b925f3 --- /dev/null +++ b/frontend/src/contexts/websocket-provider-wrapper.tsx @@ -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 + * + * + * + * + * @example + * // Use the new v1 provider + * + * + * + */ +export function WebSocketProviderWrapper({ + children, + conversationId, + version, +}: WebSocketProviderWrapperProps) { + if (version === 0) { + return ( + + {children} + + ); + } + + if (version === 1) { + return ( + + {children} + + ); + } + + throw new Error( + `Unsupported WebSocket provider version: ${version}. Supported versions are 0 and 1.`, + ); +} diff --git a/frontend/src/hooks/use-conversation-name-context-menu.ts b/frontend/src/hooks/use-conversation-name-context-menu.ts index 9d661a6001..ea39c0cb54 100644 --- a/frontend/src/hooks/use-conversation-name-context-menu.ts +++ b/frontend/src/hooks/use-conversation-name-context-menu.ts @@ -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) => { event.preventDefault(); diff --git a/frontend/src/hooks/use-handle-ws-events.ts b/frontend/src/hooks/use-handle-ws-events.ts index b665aed461..3b4b7b0bd1 100644 --- a/frontend/src/hooks/use-handle-ws-events.ts +++ b/frontend/src/hooks/use-handle-ws-events.ts @@ -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 diff --git a/frontend/src/hooks/use-websocket.ts b/frontend/src/hooks/use-websocket.ts new file mode 100644 index 0000000000..644f6f972a --- /dev/null +++ b/frontend/src/hooks/use-websocket.ts @@ -0,0 +1,85 @@ +import React from "react"; + +export const useWebSocket = ( + url: string, + options?: { + queryParams?: Record; + 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(null); + const [messages, setMessages] = React.useState([]); + const [error, setError] = React.useState(null); + const wsRef = React.useRef(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, + }; +}; diff --git a/frontend/src/mocks/mock-ws-helpers.ts b/frontend/src/mocks/mock-ws-helpers.ts index 29b84a0a28..7bf575eb25 100644 --- a/frontend/src/mocks/mock-ws-helpers.ts +++ b/frontend/src/mocks/mock-ws-helpers.ts @@ -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 => ({ + 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 => ({ + 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 => ({ + 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, +}); diff --git a/frontend/src/routes/conversation.tsx b/frontend/src/routes/conversation.tsx index fff008e656..4952ff225e 100644 --- a/frontend/src/routes/conversation.tsx +++ b/frontend/src/routes/conversation.tsx @@ -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 ( - +
- + ); } diff --git a/frontend/src/stores/use-event-store.ts b/frontend/src/stores/use-event-store.ts new file mode 100644 index 0000000000..307f4ced0d --- /dev/null +++ b/frontend/src/stores/use-event-store.ts @@ -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()((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: [], + })), +})); diff --git a/frontend/src/types/core/guards.ts b/frontend/src/types/core/guards.ts index bc03952be0..6d7bf5fcdd 100644 --- a/frontend/src/types/core/guards.ts +++ b/frontend/src/types/core/guards.ts @@ -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); diff --git a/frontend/src/types/core/index.ts b/frontend/src/types/core/index.ts index aea07fcc2f..2f74f9cddc 100644 --- a/frontend/src/types/core/index.ts +++ b/frontend/src/types/core/index.ts @@ -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 diff --git a/frontend/src/types/v1/type-guards.ts b/frontend/src/types/v1/type-guards.ts new file mode 100644 index 0000000000..de3c9b45f3 --- /dev/null +++ b/frontend/src/types/v1/type-guards.ts @@ -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) + ); +} diff --git a/frontend/src/utils/cache-utils.ts b/frontend/src/utils/cache-utils.ts new file mode 100644 index 0000000000..c2285fea71 --- /dev/null +++ b/frontend/src/utils/cache-utils.ts @@ -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], + }); + } +}; diff --git a/frontend/src/utils/handle-event-for-ui.ts b/frontend/src/utils/handle-event-for-ui.ts new file mode 100644 index 0000000000..dd47517e48 --- /dev/null +++ b/frontend/src/utils/handle-event-for-ui.ts @@ -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; +}; diff --git a/frontend/src/utils/path-utils.ts b/frontend/src/utils/path-utils.ts new file mode 100644 index 0000000000..0435fc085b --- /dev/null +++ b/frontend/src/utils/path-utils.ts @@ -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; +}; diff --git a/frontend/vitest.setup.ts b/frontend/vitest.setup.ts index 1bee8dff86..42ce972d11 100644 --- a/frontend/vitest.setup.ts +++ b/frontend/vitest.setup.ts @@ -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" });