Download __tests__/components/chat/chat-interface.test.tsx from SaylorTwift/openhands: direct link, hf CLI and curl.
- Browser
- Download file 35.5 kB
-
https://huggingface.co/SaylorTwift/openhands/resolve/main/__tests__/components/chat/chat-interface.test.tsx
- Command line
-
hf download hf://SaylorTwift/openhands/__tests__/components/chat/chat-interface.test.tsx
-
curl -L -o chat-interface.test.tsx https://huggingface.co/SaylorTwift/openhands/resolve/main/__tests__/components/chat/chat-interface.test.tsx
35.5 kB
| import { afterEach, beforeEach, describe, expect, it, test, vi } from "vitest"; | |
| import { | |
| fireEvent, | |
| render, | |
| screen, | |
| waitFor, | |
| within, | |
| } from "@testing-library/react"; | |
| import { MemoryRouter, Route, Routes } from "react-router"; | |
| import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; | |
| import { renderWithProviders, useParamsMock } from "test-utils"; | |
| import { SUGGESTIONS } from "#/utils/suggestions"; | |
| import { ChatInterface } from "#/components/features/chat/chat-interface"; | |
| import { | |
| useConversationId, | |
| useOptionalConversationId, | |
| } from "#/hooks/use-conversation-id"; | |
| import { useErrorMessageStore } from "#/stores/error-message-store"; | |
| import { useOptimisticUserMessageStore } from "#/stores/optimistic-user-message-store"; | |
| import { useConfig } from "#/hooks/query/use-config"; | |
| import { useUnifiedUploadFiles } from "#/hooks/mutation/use-unified-upload-files"; | |
| import { | |
| SecurityRisk, | |
| type ActionEvent, | |
| type MessageEvent, | |
| } from "#/types/agent-server/core"; | |
| import { useEventStore } from "#/stores/use-event-store"; | |
| import { useAgentState } from "#/hooks/use-agent-state"; | |
| import { useLoadOlderEvents } from "#/hooks/use-load-older-events"; | |
| import { useTaskPolling } from "#/hooks/query/use-task-polling"; | |
| import { AgentState } from "#/types/agent-state"; | |
| import { useConversationStore } from "#/stores/conversation-store"; | |
| import { useGoalStore } from "#/stores/goal-store"; | |
| import { act } from "@testing-library/react"; | |
| const mockSend = vi.fn(); | |
| vi.mock("#/hooks/use-send-message", () => ({ | |
| useSendMessage: () => ({ send: mockSend }), | |
| })); | |
| vi.mock("#/hooks/query/use-config"); | |
| vi.mock("#/hooks/mutation/use-unified-upload-files"); | |
| // Treat the LLM as configured by default so the "not configured" gate/banner | |
| // stays inert for these tests (its own behavior is covered elsewhere). | |
| vi.mock("#/hooks/use-llm-configured", () => ({ | |
| useLlmConfigured: () => ({ isConfigured: true, isLoading: false }), | |
| })); | |
| vi.mock("#/hooks/use-conversation-id", () => ({ | |
| useConversationId: vi.fn(), | |
| useOptionalConversationId: vi.fn(), | |
| })); | |
| vi.mock("#/hooks/use-user-providers", () => ({ | |
| useUserProviders: () => ({ | |
| providers: [], | |
| }), | |
| })); | |
| vi.mock("#/hooks/use-conversation-name-context-menu", () => ({ | |
| useConversationNameContextMenu: () => ({ | |
| isOpen: false, | |
| contextMenuRef: { current: null }, | |
| handleContextMenu: vi.fn(), | |
| handleClose: vi.fn(), | |
| handleRename: vi.fn(), | |
| handleDelete: vi.fn(), | |
| }), | |
| })); | |
| vi.mock("#/hooks/use-load-older-events", () => ({ | |
| useLoadOlderEvents: vi.fn(), | |
| })); | |
| vi.mock("#/hooks/query/use-task-polling", () => ({ | |
| useTaskPolling: vi.fn(), | |
| })); | |
| vi.mock("#/hooks/use-agent-state", () => ({ | |
| useAgentState: vi.fn(() => ({ | |
| curAgentState: AgentState.AWAITING_USER_INPUT, | |
| })), | |
| usePlanningAgentState: vi.fn(() => ({ | |
| localPlanningConversationId: null, | |
| curPlanningAgentState: AgentState.AWAITING_USER_INPUT, | |
| isPlanningAgentRunning: false, | |
| })), | |
| })); | |
| const trackInitialQuerySubmittedMock = vi.fn(); | |
| const trackUserMessageSentMock = vi.fn(); | |
| vi.mock("#/hooks/use-tracking", () => ({ | |
| useTracking: () => ({ | |
| trackInitialQuerySubmitted: trackInitialQuerySubmittedMock, | |
| trackUserMessageSent: trackUserMessageSentMock, | |
| }), | |
| })); | |
| // Helper function to render with Router context | |
| const renderChatInterfaceWithRouter = () => | |
| renderWithProviders( | |
| <MemoryRouter> | |
| <ChatInterface /> | |
| </MemoryRouter>, | |
| ); | |
| // Helper function to render with QueryClientProvider and Router (for newer tests) | |
| const renderWithQueryClient = ( | |
| ui: React.ReactElement, | |
| queryClient: QueryClient, | |
| route = "/test-conversation-id", | |
| ) => | |
| render( | |
| <QueryClientProvider client={queryClient}> | |
| <MemoryRouter initialEntries={[route]}> | |
| <Routes> | |
| <Route path="/:conversationId" element={ui} /> | |
| <Route path="/" element={ui} /> | |
| </Routes> | |
| </MemoryRouter> | |
| </QueryClientProvider>, | |
| ); | |
| beforeEach(() => { | |
| useParamsMock.mockReturnValue({ conversationId: "test-conversation-id" }); | |
| vi.mocked(useConversationId).mockReturnValue({ | |
| conversationId: "test-conversation-id", | |
| }); | |
| vi.mocked(useOptionalConversationId).mockReturnValue({ | |
| conversationId: "test-conversation-id", | |
| }); | |
| vi.mocked(useTaskPolling).mockReturnValue({ | |
| isTask: false, | |
| taskId: null, | |
| conversationId: "test-conversation-id", | |
| task: undefined, | |
| taskStatus: undefined, | |
| taskDetail: undefined, | |
| taskError: null, | |
| isLoadingTask: false, | |
| repositoryInfo: { | |
| selectedRepository: undefined, | |
| selectedBranch: undefined, | |
| gitProvider: undefined, | |
| }, | |
| }); | |
| // Default: pagination disabled (hasMore=false) so unrelated tests don't | |
| // accidentally trigger loadOlder via the on-mount auto-trigger effect. | |
| // Tests that exercise pagination override this. | |
| vi.mocked(useLoadOlderEvents).mockReturnValue({ | |
| isLoading: false, | |
| hasMore: false, | |
| loadOlder: vi.fn().mockResolvedValue(undefined), | |
| }); | |
| }); | |
| describe("ChatInterface - Chat Suggestions", () => { | |
| // Create a new QueryClient for each test | |
| let queryClient: QueryClient; | |
| beforeEach(() => { | |
| queryClient = new QueryClient({ | |
| defaultOptions: { | |
| queries: { | |
| retry: false, | |
| }, | |
| }, | |
| }); | |
| vi.mocked(useTaskPolling).mockReturnValue({ | |
| isTask: false, | |
| taskId: null, | |
| conversationId: "test-conversation-id", | |
| task: undefined, | |
| taskStatus: undefined, | |
| taskDetail: undefined, | |
| taskError: null, | |
| isLoadingTask: false, | |
| repositoryInfo: { | |
| selectedRepository: undefined, | |
| selectedBranch: undefined, | |
| gitProvider: undefined, | |
| }, | |
| }); | |
| useOptimisticUserMessageStore.setState({ | |
| pendingMessages: [], | |
| }); | |
| useErrorMessageStore.setState({ | |
| errorMessage: null, | |
| }); | |
| (useConfig as unknown as ReturnType<typeof vi.fn>).mockReturnValue({ | |
| data: {}, | |
| }); | |
| ( | |
| useUnifiedUploadFiles as unknown as ReturnType<typeof vi.fn> | |
| ).mockReturnValue({ | |
| mutateAsync: vi | |
| .fn() | |
| .mockResolvedValue({ skipped_files: [], uploaded_files: [] }), | |
| isLoading: false, | |
| }); | |
| }); | |
| test("should hide chat suggestions when there is a user message", () => { | |
| const mockUserEvent: MessageEvent = { | |
| id: "msg-1", | |
| timestamp: "2025-07-01T00:00:00Z", | |
| source: "user", | |
| llm_message: { | |
| role: "user", | |
| content: [{ type: "text", text: "Hello" }], | |
| }, | |
| activated_skills: [], | |
| extended_content: [], | |
| }; | |
| useEventStore.setState({ | |
| events: [mockUserEvent], | |
| eventIds: new Set(["msg-1"]), | |
| uiEvents: [mockUserEvent], | |
| }); | |
| renderWithQueryClient(<ChatInterface />, queryClient); | |
| // Check if ChatSuggestions is not rendered with user events | |
| expect(screen.queryByTestId("chat-suggestions")).not.toBeInTheDocument(); | |
| }); | |
| test("should hide chat suggestions when there is an optimistic user message", () => { | |
| useOptimisticUserMessageStore.getState().enqueuePendingMessage({ | |
| conversationId: "test-conversation-id", | |
| text: "Optimistic message", | |
| }); | |
| renderWithQueryClient(<ChatInterface />, queryClient); | |
| // Check if ChatSuggestions is not rendered with optimistic user message | |
| expect(screen.queryByTestId("chat-suggestions")).not.toBeInTheDocument(); | |
| }); | |
| test("should hide chat suggestions while a cloud start task is provisioning", () => { | |
| vi.mocked(useTaskPolling).mockReturnValue({ | |
| isTask: true, | |
| taskId: "abc", | |
| conversationId: null, | |
| task: undefined, | |
| taskStatus: "WORKING", | |
| taskDetail: undefined, | |
| taskError: null, | |
| isLoadingTask: false, | |
| repositoryInfo: { | |
| selectedRepository: undefined, | |
| selectedBranch: undefined, | |
| gitProvider: undefined, | |
| }, | |
| }); | |
| renderWithQueryClient(<ChatInterface />, queryClient, "/task-abc"); | |
| expect(screen.queryByTestId("chat-suggestions")).not.toBeInTheDocument(); | |
| }); | |
| test("should hide chat suggestions on a task route even when the task is READY", () => { | |
| vi.mocked(useTaskPolling).mockReturnValue({ | |
| isTask: true, | |
| taskId: "abc", | |
| conversationId: null, | |
| task: undefined, | |
| taskStatus: "READY", | |
| taskDetail: undefined, | |
| taskError: null, | |
| isLoadingTask: false, | |
| repositoryInfo: { | |
| selectedRepository: undefined, | |
| selectedBranch: undefined, | |
| gitProvider: undefined, | |
| }, | |
| }); | |
| renderWithQueryClient(<ChatInterface />, queryClient, "/task-abc"); | |
| expect(screen.queryByTestId("chat-suggestions")).not.toBeInTheDocument(); | |
| }); | |
| }); | |
| describe("ChatInterface - Empty state", () => { | |
| it.todo("should render suggestions if empty"); | |
| it("should render the default suggestions", () => { | |
| renderChatInterfaceWithRouter(); | |
| const suggestions = screen.getByTestId("chat-suggestions"); | |
| const repoSuggestions = Object.keys(SUGGESTIONS.repo); | |
| // check that there are at most 4 suggestions displayed | |
| const displayedSuggestions = within(suggestions).getAllByRole("button"); | |
| expect(displayedSuggestions.length).toBeLessThanOrEqual(4); | |
| // Check that each displayed suggestion is one of the repo suggestions | |
| displayedSuggestions.forEach((suggestion) => { | |
| expect(repoSuggestions).toContain(suggestion.textContent); | |
| }); | |
| }); | |
| }); | |
| describe("ChatInterface - Scroll-up loads older events", () => { | |
| let queryClient: QueryClient; | |
| beforeEach(() => { | |
| queryClient = new QueryClient({ | |
| defaultOptions: { queries: { retry: false } }, | |
| }); | |
| useOptimisticUserMessageStore.setState({ pendingMessages: [] }); | |
| useErrorMessageStore.setState({ errorMessage: null }); | |
| (useConfig as unknown as ReturnType<typeof vi.fn>).mockReturnValue({ | |
| data: {}, | |
| }); | |
| ( | |
| useUnifiedUploadFiles as unknown as ReturnType<typeof vi.fn> | |
| ).mockReturnValue({ | |
| mutateAsync: vi.fn(), | |
| isLoading: false, | |
| }); | |
| }); | |
| afterEach(() => { | |
| useEventStore.setState({ | |
| events: [], | |
| eventIds: new Set(), | |
| uiEvents: [], | |
| }); | |
| vi.clearAllMocks(); | |
| }); | |
| // Helper: install a controllable mock of useLoadOlderEvents and seed the | |
| // store with one renderable user message so the chat mounts a scroll area. | |
| const setupPaginationTest = () => { | |
| const loadOlder = vi.fn().mockResolvedValue(undefined); | |
| vi.mocked(useLoadOlderEvents).mockReturnValue({ | |
| isLoading: false, | |
| hasMore: true, | |
| loadOlder, | |
| }); | |
| const seedEvent: MessageEvent = { | |
| id: "msg-seed", | |
| timestamp: "2025-07-01T00:00:00Z", | |
| source: "user", | |
| llm_message: { | |
| role: "user", | |
| content: [{ type: "text", text: "Existing message" }], | |
| }, | |
| activated_skills: [], | |
| extended_content: [], | |
| }; | |
| useEventStore.setState({ | |
| events: [seedEvent], | |
| eventIds: new Set(["msg-seed"]), | |
| uiEvents: [seedEvent], | |
| }); | |
| return loadOlder; | |
| }; | |
| // The auto-scroll-to-bottom hook schedules a rAF that does | |
| // `dom.scrollTop = dom.scrollHeight`, which clobbers any value we set | |
| // here before the test's event fires. Pin `scrollTop` as a stable | |
| // getter so those assignments are no-ops. | |
| const setScrollMetrics = ( | |
| el: HTMLElement, | |
| metrics: { scrollTop: number; scrollHeight: number; clientHeight: number }, | |
| ) => { | |
| Object.defineProperty(el, "scrollTop", { | |
| configurable: true, | |
| get: () => metrics.scrollTop, | |
| set: () => { | |
| /* swallow assignments from auto-scroll-to-bottom */ | |
| }, | |
| }); | |
| Object.defineProperty(el, "scrollHeight", { | |
| configurable: true, | |
| writable: true, | |
| value: metrics.scrollHeight, | |
| }); | |
| Object.defineProperty(el, "clientHeight", { | |
| configurable: true, | |
| writable: true, | |
| value: metrics.clientHeight, | |
| }); | |
| }; | |
| it("calls loadOlder when the user scrolls near the top", async () => { | |
| const loadOlder = setupPaginationTest(); | |
| renderWithQueryClient(<ChatInterface />, queryClient); | |
| const scrollContainer = document.querySelector( | |
| "[data-testid='chat-scroll-container']", | |
| ) as HTMLElement | null; | |
| expect(scrollContainer).not.toBeNull(); | |
| // Pretend the user just scrolled near the top of an overflowing list. | |
| setScrollMetrics(scrollContainer!, { | |
| scrollTop: 0, | |
| scrollHeight: 5000, | |
| clientHeight: 800, | |
| }); | |
| // Drop any on-mount auto-trigger so we exclusively assert the scroll path. | |
| await new Promise((r) => { | |
| setTimeout(r, 0); | |
| }); | |
| loadOlder.mockClear(); | |
| fireEvent.scroll(scrollContainer!); | |
| await new Promise((r) => { | |
| setTimeout(r, 0); | |
| }); | |
| expect(loadOlder).toHaveBeenCalledTimes(1); | |
| }); | |
| it("auto-loads older events when the chat content does not overflow the viewport", async () => { | |
| // No overflow ⇒ no scrollbar ⇒ user can't trigger loadOlder by | |
| // scrolling. The component must auto-trigger via useEffect. | |
| const loadOlder = setupPaginationTest(); | |
| renderWithQueryClient(<ChatInterface />, queryClient); | |
| // Allow the on-mount auto-trigger useEffect to fire. | |
| await new Promise((r) => { | |
| setTimeout(r, 0); | |
| }); | |
| expect(loadOlder).toHaveBeenCalled(); | |
| }); | |
| it("loads older events when the user wheels up while pinned at scrollTop=0", async () => { | |
| // Overscroll-at-top: scrollTop is already 0 so the browser does not | |
| // dispatch a scroll event. Wheel handler must catch this. | |
| const loadOlder = setupPaginationTest(); | |
| renderWithQueryClient(<ChatInterface />, queryClient); | |
| const scrollContainer = document.querySelector( | |
| "[data-testid='chat-scroll-container']", | |
| ) as HTMLElement | null; | |
| expect(scrollContainer).not.toBeNull(); | |
| // Plenty of scrollable content overhead so the no-overflow branch | |
| // doesn't claim the call — we want to exercise the wheel path. | |
| setScrollMetrics(scrollContainer!, { | |
| scrollTop: 0, | |
| scrollHeight: 10000, | |
| clientHeight: 800, | |
| }); | |
| // Let any on-mount auto-trigger settle. | |
| await new Promise((r) => { | |
| setTimeout(r, 0); | |
| }); | |
| loadOlder.mockClear(); | |
| // User is pinned at the top and wheels upward — no scroll event | |
| // would fire here in a real browser. | |
| fireEvent.wheel(scrollContainer!, { deltaY: -100 }); | |
| await new Promise((r) => { | |
| setTimeout(r, 0); | |
| }); | |
| expect(loadOlder).toHaveBeenCalledTimes(1); | |
| }); | |
| it("shows an error banner if loading older events fails", async () => { | |
| // This test exercises the real useLoadOlderEvents (so a rejected | |
| // searchEvents propagates through to the chat's error banner). | |
| // Override the global mock with the actual implementation. | |
| const actualLoadOlderModule = await vi.importActual< | |
| typeof import("#/hooks/use-load-older-events") | |
| >("#/hooks/use-load-older-events"); | |
| vi.mocked(useLoadOlderEvents).mockImplementation( | |
| actualLoadOlderModule.useLoadOlderEvents, | |
| ); | |
| const seedEvent: MessageEvent = { | |
| id: "msg-seed", | |
| timestamp: "2025-07-01T00:00:00Z", | |
| source: "user", | |
| llm_message: { | |
| role: "user", | |
| content: [{ type: "text", text: "Existing message" }], | |
| }, | |
| activated_skills: [], | |
| extended_content: [], | |
| }; | |
| useEventStore.setState({ | |
| events: [seedEvent], | |
| eventIds: new Set(["msg-seed"]), | |
| uiEvents: [seedEvent], | |
| }); | |
| const useUserConversationModule = | |
| await import("#/hooks/query/use-user-conversation"); | |
| vi.spyOn(useUserConversationModule, "useUserConversation").mockReturnValue({ | |
| data: { | |
| conversation_id: "test-conversation-id", | |
| conversation_url: "https://example.com", | |
| session_api_key: "k", | |
| conversation_version: "V1", | |
| }, | |
| isLoading: false, | |
| isPending: false, | |
| isError: false, | |
| error: null, | |
| refetch: vi.fn(), | |
| } as unknown as ReturnType< | |
| typeof useUserConversationModule.useUserConversation | |
| >); | |
| const eventServiceModule = | |
| await import("#/api/event-service/event-service.api"); | |
| vi.spyOn(eventServiceModule.default, "searchEvents").mockRejectedValue( | |
| new Error("Older events request failed"), | |
| ); | |
| renderWithQueryClient(<ChatInterface />, queryClient); | |
| const scrollContainer = document.querySelector( | |
| "[data-testid='chat-scroll-container']", | |
| ) as HTMLElement | null; | |
| expect(scrollContainer).not.toBeNull(); | |
| Object.defineProperty(scrollContainer!, "scrollTop", { | |
| configurable: true, | |
| writable: true, | |
| value: 0, | |
| }); | |
| Object.defineProperty(scrollContainer!, "scrollHeight", { | |
| configurable: true, | |
| writable: true, | |
| value: 5000, | |
| }); | |
| scrollContainer!.dispatchEvent(new Event("scroll", { bubbles: true })); | |
| expect( | |
| await screen.findByText("Older events request failed"), | |
| ).toBeInTheDocument(); | |
| }); | |
| it("renders renderable agent events even when the loaded window has no user message (so the user has something to scroll up from)", () => { | |
| // Simulate the lazy-loaded "50 most recent" window landing in the | |
| // store with only agent / environment events — the original user | |
| // prompt is older than this window. | |
| // eslint-disable-next-line @typescript-eslint/no-explicit-any | |
| const agentAction: any = { | |
| id: "act-1", | |
| timestamp: "2025-07-01T00:00:10Z", | |
| source: "agent", | |
| kind: "ActionEvent", | |
| action: { | |
| kind: "ExecuteBashAction", | |
| command: "ls", | |
| }, | |
| tool_name: "terminal", | |
| tool_call_id: "call-1", | |
| tool_call: { | |
| id: "call-1", | |
| name: "terminal", | |
| arguments: { command: "ls" }, | |
| }, | |
| llm_response_id: "resp-1", | |
| thought: [], | |
| reasoning_content: "", | |
| thinking_blocks: [], | |
| }; | |
| useEventStore.setState({ | |
| events: [agentAction], | |
| eventIds: new Set(["act-1"]), | |
| uiEvents: [agentAction], | |
| }); | |
| renderWithQueryClient(<ChatInterface />, queryClient); | |
| // The scroll container exists (so the user can scroll up to load older). | |
| const scrollContainer = document.querySelector( | |
| "[data-testid='chat-scroll-container']", | |
| ) as HTMLElement | null; | |
| expect(scrollContainer).not.toBeNull(); | |
| // ChatSuggestions should NOT take over the chat area when agent | |
| // actions are present in the loaded window. | |
| expect(screen.queryByTestId("chat-suggestions")).not.toBeInTheDocument(); | |
| // The Messages list should be rendered (the bug was that it was | |
| // gated on `conversationUserEventsExist` and stayed hidden here, | |
| // leaving a blank chat with nothing to scroll). | |
| expect(scrollContainer!.children.length).toBeGreaterThan(0); | |
| }); | |
| }); | |
| describe("ChatInterface - Pending message queue", () => { | |
| let queryClient: QueryClient; | |
| beforeEach(() => { | |
| mockSend.mockReset(); | |
| queryClient = new QueryClient({ | |
| defaultOptions: { queries: { retry: false } }, | |
| }); | |
| useOptimisticUserMessageStore.setState({ pendingMessages: [] }); | |
| useErrorMessageStore.setState({ errorMessage: null }); | |
| (useConfig as unknown as ReturnType<typeof vi.fn>).mockReturnValue({ | |
| data: {}, | |
| }); | |
| ( | |
| useUnifiedUploadFiles as unknown as ReturnType<typeof vi.fn> | |
| ).mockReturnValue({ | |
| mutateAsync: vi | |
| .fn() | |
| .mockResolvedValue({ skipped_files: [], uploaded_files: [] }), | |
| isLoading: false, | |
| }); | |
| useEventStore.setState({ | |
| events: [], | |
| eventIds: new Set(), | |
| uiEvents: [], | |
| }); | |
| }); | |
| afterEach(() => { | |
| useOptimisticUserMessageStore.setState({ pendingMessages: [] }); | |
| }); | |
| function submitMessage(text: string) { | |
| // The chat input is a contenteditable div; the conversation store exposes | |
| // `submittedMessage` which CustomChatInput watches and forwards to the | |
| // ChatInterface's `onSubmit` handler. Driving that store directly is the | |
| // most reliable way to simulate "the user pressed send" in jsdom. | |
| act(() => { | |
| useConversationStore.setState({ submittedMessage: text }); | |
| }); | |
| } | |
| function renderInterface() { | |
| render( | |
| <QueryClientProvider client={queryClient}> | |
| <MemoryRouter initialEntries={["/test-conversation-id"]}> | |
| <Routes> | |
| <Route path=":conversationId" element={<ChatInterface />} /> | |
| </Routes> | |
| </MemoryRouter> | |
| </QueryClientProvider>, | |
| ); | |
| } | |
| it("shows the message in 'sending' state immediately when submitted", async () => { | |
| let resolveSend: ((value: unknown) => void) | undefined; | |
| mockSend.mockImplementation( | |
| () => | |
| new Promise((resolve) => { | |
| resolveSend = resolve; | |
| }), | |
| ); | |
| renderInterface(); | |
| submitMessage("hello world"); | |
| const pendingMessage = await screen.findByTestId("user-message"); | |
| expect(pendingMessage).toHaveTextContent("hello world"); | |
| expect(pendingMessage).toHaveAttribute("data-pending-status", "sending"); | |
| expect(screen.getByTestId("chat-message-sending")).toBeInTheDocument(); | |
| expect(screen.queryByTestId("chat-message-retry")).not.toBeInTheDocument(); | |
| resolveSend?.({ queued: false }); | |
| }); | |
| it("flips the message to 'error' with a retry link when send rejects", async () => { | |
| mockSend.mockRejectedValue(new Error("network down")); | |
| renderInterface(); | |
| submitMessage("hello"); | |
| await waitFor(() => { | |
| expect(screen.getByTestId("user-message")).toHaveAttribute( | |
| "data-pending-status", | |
| "error", | |
| ); | |
| }); | |
| expect(screen.getByTestId("chat-message-error")).toBeInTheDocument(); | |
| expect(screen.getByTestId("chat-message-retry")).toBeInTheDocument(); | |
| }); | |
| it("queues multiple submitted messages, each with its own pending entry", async () => { | |
| mockSend.mockResolvedValue({ queued: false }); | |
| renderInterface(); | |
| submitMessage("first"); | |
| submitMessage("second"); | |
| await waitFor(() => { | |
| expect(screen.getAllByTestId("user-message")).toHaveLength(2); | |
| }); | |
| const messages = screen.getAllByTestId("user-message"); | |
| expect(messages[0]).toHaveTextContent("first"); | |
| expect(messages[1]).toHaveTextContent("second"); | |
| }); | |
| }); | |
| describe("ChatInterface - Auto-scroll on submit (issue #817)", () => { | |
| let queryClient: QueryClient; | |
| beforeEach(() => { | |
| mockSend.mockReset(); | |
| mockSend.mockResolvedValue({ queued: false }); | |
| queryClient = new QueryClient({ | |
| defaultOptions: { queries: { retry: false } }, | |
| }); | |
| useOptimisticUserMessageStore.setState({ pendingMessages: [] }); | |
| useErrorMessageStore.setState({ errorMessage: null }); | |
| (useConfig as unknown as ReturnType<typeof vi.fn>).mockReturnValue({ | |
| data: {}, | |
| }); | |
| ( | |
| useUnifiedUploadFiles as unknown as ReturnType<typeof vi.fn> | |
| ).mockReturnValue({ | |
| mutateAsync: vi | |
| .fn() | |
| .mockResolvedValue({ skipped_files: [], uploaded_files: [] }), | |
| isLoading: false, | |
| }); | |
| useEventStore.setState({ | |
| events: [], | |
| eventIds: new Set(), | |
| uiEvents: [], | |
| }); | |
| }); | |
| afterEach(() => { | |
| useOptimisticUserMessageStore.setState({ pendingMessages: [] }); | |
| }); | |
| it("scrolls to bottom when a new prompt is submitted while the user is scrolled up", async () => { | |
| // Arrange: render and grab the chat scroll container. | |
| render( | |
| <QueryClientProvider client={queryClient}> | |
| <MemoryRouter initialEntries={["/test-conversation-id"]}> | |
| <Routes> | |
| <Route path=":conversationId" element={<ChatInterface />} /> | |
| </Routes> | |
| </MemoryRouter> | |
| </QueryClientProvider>, | |
| ); | |
| const scrollContainer = document.querySelector( | |
| "[data-testid='chat-scroll-container']", | |
| ) as HTMLElement | null; | |
| expect(scrollContainer).not.toBeNull(); | |
| // Let the mount-time auto-scroll rAF (which re-arms autoScroll=true) | |
| // run and settle BEFORE the scroll-up sim. Otherwise that rAF would | |
| // fire later and undo the autoScroll=false precondition for the bug. | |
| await new Promise((r) => { | |
| setTimeout(r, 0); | |
| }); | |
| // Capture every scrollTop write so we can detect the rAF inside | |
| // scrollDomToBottom landing `dom.scrollTop = dom.scrollHeight`. | |
| const scrollWrites: number[] = []; | |
| let scrollTopRead = 200; | |
| Object.defineProperty(scrollContainer!, "scrollTop", { | |
| configurable: true, | |
| get: () => scrollTopRead, | |
| set: (value: number) => { | |
| scrollWrites.push(value); | |
| }, | |
| }); | |
| Object.defineProperty(scrollContainer!, "scrollHeight", { | |
| configurable: true, | |
| writable: true, | |
| value: 10000, | |
| }); | |
| Object.defineProperty(scrollContainer!, "clientHeight", { | |
| configurable: true, | |
| writable: true, | |
| value: 800, | |
| }); | |
| // Simulate the user scrolling up: first event seeds prev=200, the | |
| // second event at scrollTop=50 is detected as scrolling up and flips | |
| // the hook's `autoScroll` to false (the precondition for the bug). | |
| fireEvent.scroll(scrollContainer!); | |
| scrollTopRead = 50; | |
| fireEvent.scroll(scrollContainer!); | |
| scrollWrites.length = 0; | |
| // Act: submit a new prompt (same path InteractiveChatBox uses). | |
| act(() => { | |
| useConversationStore.setState({ submittedMessage: "hello" }); | |
| }); | |
| // Assert: scrollDomToBottom landed scrollHeight onto scrollTop even | |
| // though autoScroll was off. Without the fix the auto-scroll effect | |
| // would skip the call and `scrollWrites` would stay empty. | |
| await waitFor(() => { | |
| expect(scrollWrites).toContain(10000); | |
| }); | |
| }); | |
| it("follows the live goal banner into view when an active goal advances", async () => { | |
| vi.mocked(useOptionalConversationId).mockReturnValue({ | |
| conversationId: "test-conversation-id", | |
| }); | |
| useGoalStore.setState({ statusByConversation: {} }); | |
| render( | |
| <QueryClientProvider client={queryClient}> | |
| <MemoryRouter initialEntries={["/test-conversation-id"]}> | |
| <Routes> | |
| <Route path=":conversationId" element={<ChatInterface />} /> | |
| </Routes> | |
| </MemoryRouter> | |
| </QueryClientProvider>, | |
| ); | |
| const scrollContainer = document.querySelector( | |
| "[data-testid='chat-scroll-container']", | |
| ) as HTMLElement | null; | |
| expect(scrollContainer).not.toBeNull(); | |
| // Let the mount-time auto-scroll settle; the user stays pinned to the | |
| // bottom (autoScroll=true) since we never simulate a scroll-up. | |
| await new Promise((r) => { | |
| setTimeout(r, 0); | |
| }); | |
| const scrollWrites: number[] = []; | |
| Object.defineProperty(scrollContainer!, "scrollTop", { | |
| configurable: true, | |
| get: () => 9200, | |
| set: (value: number) => { | |
| scrollWrites.push(value); | |
| }, | |
| }); | |
| Object.defineProperty(scrollContainer!, "scrollHeight", { | |
| configurable: true, | |
| writable: true, | |
| value: 10000, | |
| }); | |
| Object.defineProperty(scrollContainer!, "clientHeight", { | |
| configurable: true, | |
| writable: true, | |
| value: 800, | |
| }); | |
| scrollWrites.length = 0; | |
| // Act: an in-progress goal advances — a store update only, with no change | |
| // to `renderableEvents` (in-progress goal events are filtered out). | |
| act(() => { | |
| useGoalStore.getState().setStatus("test-conversation-id", { | |
| active: true, | |
| status: "running", | |
| iteration: 1, | |
| max_iterations: 10, | |
| objective: "make pytest pass", | |
| verdict: { score: 0.5, complete: false, missing: "needs tests" }, | |
| }); | |
| }); | |
| // Assert: the bottom-following effect scrolled the banner into view. | |
| // Without wiring the active goal status into that effect, scrollWrites | |
| // would stay empty. | |
| await waitFor(() => { | |
| expect(scrollWrites).toContain(10000); | |
| }); | |
| }); | |
| }); | |
| describe("ChatInterface - Status Indicator", () => { | |
| it("shows the unresolved terminal action while the agent is running", () => { | |
| const terminalAction: ActionEvent = { | |
| id: "action-running-terminal", | |
| timestamp: "2026-07-27T18:00:00Z", | |
| source: "agent", | |
| thought: [], | |
| thinking_blocks: [], | |
| action: { | |
| kind: "TerminalAction", | |
| command: "git status", | |
| is_input: false, | |
| timeout: null, | |
| reset: false, | |
| }, | |
| tool_name: "terminal", | |
| tool_call_id: "tool-running-terminal", | |
| tool_call: { | |
| id: "tool-running-terminal", | |
| type: "function", | |
| function: { | |
| name: "terminal", | |
| arguments: '{"command":"git status"}', | |
| }, | |
| }, | |
| llm_response_id: "response-running-terminal", | |
| security_risk: SecurityRisk.LOW, | |
| }; | |
| useEventStore.setState({ | |
| events: [terminalAction], | |
| eventIds: new Set([terminalAction.id]), | |
| uiEvents: [terminalAction], | |
| }); | |
| vi.mocked(useAgentState).mockReturnValue({ | |
| curAgentState: AgentState.RUNNING, | |
| }); | |
| renderChatInterfaceWithRouter(); | |
| const chip = screen.getByTestId("live-activity-chip"); | |
| expect(chip).toHaveTextContent("ACTION_MESSAGE$RUN"); | |
| expect(chip.parentElement).toHaveClass("inset-x-9"); | |
| }); | |
| it("hides the live activity chip when the agent is no longer running", () => { | |
| vi.mocked(useAgentState).mockReturnValue({ | |
| curAgentState: AgentState.PAUSED, | |
| }); | |
| renderChatInterfaceWithRouter(); | |
| expect(screen.queryByTestId("live-activity-chip")).not.toBeInTheDocument(); | |
| }); | |
| it("should render ChatStatusIndicator when agent is not awaiting user input / conversation is NOT ready", () => { | |
| vi.mocked(useAgentState).mockReturnValue({ | |
| curAgentState: AgentState.LOADING, | |
| }); | |
| renderChatInterfaceWithRouter(); | |
| expect(screen.getByTestId("chat-status-indicator")).toBeInTheDocument(); | |
| }); | |
| it("should NOT render ChatStatusIndicator when agent is awaiting user input / conversation is ready", () => { | |
| vi.mocked(useAgentState).mockReturnValue({ | |
| curAgentState: AgentState.AWAITING_USER_INPUT, | |
| }); | |
| renderChatInterfaceWithRouter(); | |
| expect( | |
| screen.queryByTestId("chat-status-indicator"), | |
| ).not.toBeInTheDocument(); | |
| }); | |
| }); | |
| describe("ChatInterface - Tracking", () => { | |
| let queryClient: QueryClient; | |
| beforeEach(() => { | |
| vi.clearAllMocks(); | |
| mockSend.mockResolvedValue({ queued: false }); | |
| queryClient = new QueryClient({ | |
| defaultOptions: { queries: { retry: false } }, | |
| }); | |
| useOptimisticUserMessageStore.setState({ pendingMessages: [] }); | |
| useErrorMessageStore.setState({ errorMessage: null }); | |
| (useConfig as unknown as ReturnType<typeof vi.fn>).mockReturnValue({ | |
| data: {}, | |
| }); | |
| ( | |
| useUnifiedUploadFiles as unknown as ReturnType<typeof vi.fn> | |
| ).mockReturnValue({ | |
| mutateAsync: vi | |
| .fn() | |
| .mockResolvedValue({ skipped_files: [], uploaded_files: [] }), | |
| isLoading: false, | |
| }); | |
| useEventStore.setState({ events: [], eventIds: new Set(), uiEvents: [] }); | |
| }); | |
| function renderInterface() { | |
| render( | |
| <QueryClientProvider client={queryClient}> | |
| <MemoryRouter initialEntries={["/test-conversation-id"]}> | |
| <Routes> | |
| <Route path=":conversationId" element={<ChatInterface />} /> | |
| </Routes> | |
| </MemoryRouter> | |
| </QueryClientProvider>, | |
| ); | |
| } | |
| it("calls trackInitialQuerySubmitted when the first message is sent (empty conversation)", async () => { | |
| renderInterface(); | |
| act(() => { | |
| useConversationStore.setState({ submittedMessage: "my first task" }); | |
| }); | |
| await waitFor(() => { | |
| expect(trackInitialQuerySubmittedMock).toHaveBeenCalledWith( | |
| expect.objectContaining({ | |
| queryCharacterLength: "my first task".length, | |
| }), | |
| ); | |
| }); | |
| }); | |
| it("calls trackUserMessageSent when a follow-up message is sent (non-empty conversation)", async () => { | |
| // totalEvents = uiEvents.filter(shouldRenderAgentServerEvent).length. | |
| // A MessageEvent (has llm_message.role + content) passes that filter, | |
| // so seeding uiEvents with one makes totalEvents = 1. | |
| useEventStore.setState({ | |
| events: [], | |
| eventIds: new Set(), | |
| uiEvents: [ | |
| { | |
| id: "e1", | |
| source: "user", | |
| llm_message: { role: "user", content: "prior message" }, | |
| } as never, | |
| ], | |
| }); | |
| renderInterface(); | |
| act(() => { | |
| useConversationStore.setState({ submittedMessage: "follow-up" }); | |
| }); | |
| await waitFor(() => { | |
| expect(trackUserMessageSentMock).toHaveBeenCalledWith( | |
| expect.objectContaining({ | |
| currentMessageLength: "follow-up".length, | |
| }), | |
| ); | |
| }); | |
| expect(trackInitialQuerySubmittedMock).not.toHaveBeenCalled(); | |
| }); | |
| }); | |
| describe("ChatInterface - Build plan keyboard shortcut", () => { | |
| let queryClient: QueryClient; | |
| const BUILD_PROMPT = | |
| "Execute the plan based on the .agents_tmp/PLAN.md file."; | |
| beforeEach(() => { | |
| vi.clearAllMocks(); | |
| mockSend.mockResolvedValue({ queued: false }); | |
| queryClient = new QueryClient({ | |
| defaultOptions: { queries: { retry: false } }, | |
| }); | |
| useOptimisticUserMessageStore.setState({ pendingMessages: [] }); | |
| useErrorMessageStore.setState({ errorMessage: null }); | |
| (useConfig as unknown as ReturnType<typeof vi.fn>).mockReturnValue({ | |
| data: {}, | |
| }); | |
| ( | |
| useUnifiedUploadFiles as unknown as ReturnType<typeof vi.fn> | |
| ).mockReturnValue({ | |
| mutateAsync: vi | |
| .fn() | |
| .mockResolvedValue({ skipped_files: [], uploaded_files: [] }), | |
| isLoading: false, | |
| }); | |
| useEventStore.setState({ events: [], eventIds: new Set(), uiEvents: [] }); | |
| }); | |
| function renderInterface() { | |
| render( | |
| <QueryClientProvider client={queryClient}> | |
| <MemoryRouter initialEntries={["/test-conversation-id"]}> | |
| <Routes> | |
| <Route path=":conversationId" element={<ChatInterface />} /> | |
| </Routes> | |
| </MemoryRouter> | |
| </QueryClientProvider>, | |
| ); | |
| } | |
| const pressBuildShortcut = () => { | |
| fireEvent.keyDown(document, { key: "Enter", metaKey: true }); | |
| fireEvent.keyDown(document, { key: "Enter", ctrlKey: true }); | |
| }; | |
| const sentBuildPrompt = () => | |
| mockSend.mock.calls.some(([message]) => | |
| JSON.stringify(message).includes(BUILD_PROMPT), | |
| ); | |
| it("does not send the build prompt in code mode", () => { | |
| act(() => { | |
| useConversationStore.setState({ | |
| conversationMode: "code", | |
| planContent: null, | |
| }); | |
| }); | |
| renderInterface(); | |
| pressBuildShortcut(); | |
| expect(sentBuildPrompt()).toBe(false); | |
| }); | |
| it("does not send the build prompt in code mode when a plan exists", () => { | |
| act(() => { | |
| useConversationStore.setState({ | |
| conversationMode: "code", | |
| planContent: "# Plan\n\n- step one", | |
| }); | |
| }); | |
| renderInterface(); | |
| pressBuildShortcut(); | |
| expect(sentBuildPrompt()).toBe(false); | |
| }); | |
| it("does not send the build prompt in plan mode when no plan exists", () => { | |
| act(() => { | |
| useConversationStore.setState({ | |
| conversationMode: "plan", | |
| planContent: null, | |
| }); | |
| }); | |
| renderInterface(); | |
| pressBuildShortcut(); | |
| expect(sentBuildPrompt()).toBe(false); | |
| }); | |
| it("sends the build prompt in plan mode when a plan exists", async () => { | |
| act(() => { | |
| useConversationStore.setState({ | |
| conversationMode: "plan", | |
| planContent: "# Plan\n\n- step one", | |
| }); | |
| }); | |
| renderInterface(); | |
| fireEvent.keyDown(document, { key: "Enter", metaKey: true }); | |
| await waitFor(() => { | |
| expect(sentBuildPrompt()).toBe(true); | |
| }); | |
| }); | |
| }); | |