theo-agent-dashboard

Unnamed repository; edit this file 'description' to name the repository.
Log | Files | Refs | README

ChatView.test.tsx (14630B)


      1 import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
      2 import { render, screen } from "@testing-library/react";
      3 import { ChatView } from "./ChatView";
      4 import { useChatStore } from "../../store/chat";
      5 
      6 const handlers: ((msg: { type: string; data?: unknown }) => void)[] = [];
      7 const mockOnMessage = vi.fn((handler: unknown) => { handlers.push(handler as (msg: { type: string; data?: unknown }) => void); });
      8 const mockConnect = vi.fn();
      9 const mockDestroy = vi.fn();
     10 const mockSend = vi.fn();
     11 
     12 vi.mock("../../lib/ws", () => ({
     13   WsClient: vi.fn().mockImplementation(() => ({
     14     connect: mockConnect,
     15     onMessage: mockOnMessage,
     16     send: mockSend,
     17     destroy: mockDestroy,
     18   })),
     19 }));
     20 
     21 vi.mock("../Chat/MessageList", () => ({
     22   MessageList: ({ messages }: { messages: unknown[] }) => (
     23     <div data-testid="message-list">{messages.length} messages</div>
     24   ),
     25 }));
     26 
     27 vi.mock("../Composer/Composer", () => ({
     28   Composer: ({ onSend, disabled }: { onSend: (t: string) => void; disabled: boolean }) => (
     29     <div data-testid="composer">
     30       <button onClick={() => onSend("test")} disabled={disabled}>Send</button>
     31     </div>
     32   ),
     33 }));
     34 
     35 vi.mock("../../lib/api", () => ({
     36   api: {
     37     get: vi.fn().mockResolvedValue({ models: [] }),
     38     post: vi.fn(),
     39   },
     40   uploadAttachment: vi.fn(),
     41 }));
     42 
     43 vi.mock("../Composer/ModelPicker", () => ({
     44   ModelPicker: () => <div data-testid="model-picker">ModelPicker</div>,
     45 }));
     46 
     47 vi.mock("../BranchIndicator/BranchIndicator", () => ({
     48   BranchIndicator: () => <div data-testid="branch-indicator">BranchIndicator</div>,
     49 }));
     50 
     51 const mockCreateSession = vi.fn().mockResolvedValue({ id: "new-session" });
     52 const mockListSessions = vi.fn().mockResolvedValue(undefined);
     53 const mockSetActiveSession = vi.fn();
     54 
     55 vi.mock("../../store/session", () => ({
     56   useSessionStore: Object.assign(
     57     () => ({
     58       activeSessionId: null,
     59       updateSession: vi.fn(),
     60       incrementMessageCount: vi.fn(),
     61       createSession: mockCreateSession,
     62       listSessions: mockListSessions,
     63       setActiveSession: mockSetActiveSession,
     64     }),
     65     { getState: () => ({ activeSessionId: null,
     66       updateSession: vi.fn(),
     67       incrementMessageCount: vi.fn(), createSession: mockCreateSession, listSessions: mockListSessions, setActiveSession: mockSetActiveSession }) },
     68   ),
     69 }));
     70 
     71 function emit(msg: { type: string; data?: unknown }) {
     72   for (const h of handlers) h(msg);
     73 }
     74 
     75 beforeEach(() => {
     76   useChatStore.setState({ messages: [], streaming: false });
     77   handlers.length = 0;
     78   vi.clearAllMocks();
     79 });
     80 
     81 afterEach(() => {
     82   handlers.length = 0;
     83 });
     84 
     85 describe("ChatView", () => {
     86   it("renders message list and composer", () => {
     87     render(<ChatView />);
     88     expect(screen.getByTestId("message-list")).toBeInTheDocument();
     89     expect(screen.getByTestId("composer")).toBeInTheDocument();
     90   });
     91 
     92   it("shows connecting indicator initially", () => {
     93     render(<ChatView />);
     94     expect(screen.getByText("Connecting to Hermes...")).toBeInTheDocument();
     95   });
     96 
     97   it("disables composer when streaming", () => {
     98     useChatStore.setState({ streaming: true });
     99     render(<ChatView />);
    100     expect(screen.getByText("Send")).toBeDisabled();
    101   });
    102 
    103   it("sends message via WS and adds user message", async () => {
    104     render(<ChatView />);
    105     screen.getByText("Send").click();
    106     await vi.waitFor(() => {
    107       expect(mockSend).toHaveBeenCalledWith("chat", expect.objectContaining({ content: "test" }));
    108     });
    109     expect(useChatStore.getState().messages).toHaveLength(1);
    110     expect(useChatStore.getState().messages[0].role).toBe("user");
    111   });
    112 
    113   it("handles run.started event", () => {
    114     render(<ChatView />);
    115     emit({ type: "run.started" });
    116     expect(useChatStore.getState().streaming).toBe(true);
    117   });
    118 
    119   it("handles run.completed event", () => {
    120     useChatStore.setState({ streaming: true });
    121     render(<ChatView />);
    122     emit({ type: "run.completed" });
    123     expect(useChatStore.getState().streaming).toBe(false);
    124   });
    125 
    126   it("handles message.started event", () => {
    127     render(<ChatView />);
    128     emit({ type: "message.started", data: { id: "msg-1", role: "assistant", model: "gpt-4" } });
    129     expect(useChatStore.getState().messages).toHaveLength(1);
    130     expect(useChatStore.getState().messages[0].id).toBe("msg-1");
    131   });
    132 
    133   it("handles message.started without model", () => {
    134     render(<ChatView />);
    135     emit({ type: "message.started", data: { id: "msg-1", role: "assistant" } });
    136     expect(useChatStore.getState().messages[0].model).toBeUndefined();
    137   });
    138 
    139   it("handles message.started with invalid data", () => {
    140     render(<ChatView />);
    141     emit({ type: "message.started", data: "invalid" });
    142     expect(useChatStore.getState().messages).toHaveLength(0);
    143   });
    144 
    145   it("handles message.started with missing role", () => {
    146     render(<ChatView />);
    147     emit({ type: "message.started", data: { id: "msg-1" } });
    148     expect(useChatStore.getState().messages).toHaveLength(0);
    149   });
    150 
    151   it("handles assistant.delta event", () => {
    152     render(<ChatView />);
    153     emit({ type: "message.started", data: { id: "msg-1", role: "assistant" } });
    154     emit({ type: "assistant.delta", data: { id: "msg-1", content: "Hello " } });
    155     emit({ type: "assistant.delta", data: { id: "msg-1", content: "world" } });
    156     expect(useChatStore.getState().messages[0].content).toBe("Hello world");
    157   });
    158 
    159   it("handles assistant.delta for non-existent message", () => {
    160     render(<ChatView />);
    161     emit({ type: "assistant.delta", data: { id: "nonexistent", content: "test" } });
    162     expect(useChatStore.getState().messages).toHaveLength(0);
    163   });
    164 
    165   it("handles assistant.delta with invalid data", () => {
    166     render(<ChatView />);
    167     emit({ type: "assistant.delta", data: "invalid" });
    168     expect(useChatStore.getState().messages).toHaveLength(0);
    169   });
    170 
    171   it("handles assistant.completed event", () => {
    172     render(<ChatView />);
    173     emit({ type: "message.started", data: { id: "msg-1", role: "assistant" } });
    174     emit({ type: "assistant.completed", data: { id: "msg-1", content: "Final answer" } });
    175     expect(useChatStore.getState().messages[0].content).toBe("Final answer");
    176   });
    177 
    178   it("handles assistant.completed without content", () => {
    179     render(<ChatView />);
    180     emit({ type: "message.started", data: { id: "msg-1", role: "assistant" } });
    181     emit({ type: "assistant.completed", data: { id: "msg-1" } });
    182     expect(useChatStore.getState().messages[0].content).toBe("");
    183   });
    184 
    185   it("handles assistant.completed with invalid data", () => {
    186     render(<ChatView />);
    187     emit({ type: "assistant.completed", data: "invalid" });
    188     expect(useChatStore.getState().messages).toHaveLength(0);
    189   });
    190 
    191   it("handles tool.started event", () => {
    192     render(<ChatView />);
    193     emit({ type: "message.started", data: { id: "msg-1", role: "assistant" } });
    194     emit({ type: "tool.started", data: { messageId: "msg-1", toolCall: { id: "tc-1", name: "search", args: '{"q":"test"}' } } });
    195     expect(useChatStore.getState().messages[0].toolCalls).toHaveLength(1);
    196     expect(useChatStore.getState().messages[0].toolCalls[0].name).toBe("search");
    197   });
    198 
    199   it("handles tool.started with invalid data", () => {
    200     render(<ChatView />);
    201     emit({ type: "tool.started", data: "invalid" });
    202     expect(useChatStore.getState().messages).toHaveLength(0);
    203   });
    204 
    205   it("handles tool.completed event", () => {
    206     render(<ChatView />);
    207     emit({ type: "message.started", data: { id: "msg-1", role: "assistant" } });
    208     emit({ type: "tool.started", data: { messageId: "msg-1", toolCall: { id: "tc-1", name: "search", args: "{}" } } });
    209     emit({ type: "tool.completed", data: { messageId: "msg-1", toolCallId: "tc-1", result: "found it" } });
    210     expect(useChatStore.getState().messages[0].toolCalls[0].status).toBe("complete");
    211   });
    212 
    213   it("handles tool.completed without result", () => {
    214     render(<ChatView />);
    215     emit({ type: "message.started", data: { id: "msg-1", role: "assistant" } });
    216     emit({ type: "tool.started", data: { messageId: "msg-1", toolCall: { id: "tc-1", name: "search", args: "{}" } } });
    217     emit({ type: "tool.completed", data: { messageId: "msg-1", toolCallId: "tc-1" } });
    218     expect(useChatStore.getState().messages[0].toolCalls[0].result).toBeNull();
    219   });
    220 
    221   it("populates tool results from run.completed turn messages", () => {
    222     render(<ChatView />);
    223     emit({ type: "message.started", data: { id: "msg-1", role: "assistant" } });
    224     emit({ type: "tool.started", data: { messageId: "msg-1", toolCall: { id: "tc-1", name: "search", args: "{}" } } });
    225     emit({ type: "tool.completed", data: { messageId: "msg-1", toolCallId: "tc-1" } });
    226     // Result is null before run.completed
    227     expect(useChatStore.getState().messages[0].toolCalls[0].result).toBeNull();
    228     // run.completed includes turn messages with tool results
    229     emit({
    230       type: "run.completed",
    231       data: {
    232         completed: true,
    233         messages: [
    234           { role: "tool", tool_call_id: "tc-1", content: "found it" },
    235         ],
    236       },
    237     });
    238     expect(useChatStore.getState().messages[0].toolCalls[0].result).toBe("found it");
    239   });
    240 
    241   it("run.completed does not overwrite existing tool results", () => {
    242     render(<ChatView />);
    243     emit({ type: "message.started", data: { id: "msg-1", role: "assistant" } });
    244     emit({ type: "tool.started", data: { messageId: "msg-1", toolCall: { id: "tc-1", name: "search", args: "{}" } } });
    245     emit({ type: "tool.completed", data: { messageId: "msg-1", toolCallId: "tc-1", result: "original" } });
    246     emit({
    247       type: "run.completed",
    248       data: {
    249         completed: true,
    250         messages: [
    251           { role: "tool", tool_call_id: "tc-1", content: "overwritten" },
    252         ],
    253       },
    254     });
    255     // Should NOT overwrite the existing result
    256     expect(useChatStore.getState().messages[0].toolCalls[0].result).toBe("original");
    257   });
    258 
    259   it("run.completed handles multiple tool calls", () => {
    260     render(<ChatView />);
    261     emit({ type: "message.started", data: { id: "msg-1", role: "assistant" } });
    262     emit({ type: "tool.started", data: { messageId: "msg-1", toolCall: { id: "tc-1", name: "search", args: "{}" } } });
    263     emit({ type: "tool.started", data: { messageId: "msg-1", toolCall: { id: "tc-2", name: "read", args: "{}" } } });
    264     emit({ type: "tool.completed", data: { messageId: "msg-1", toolCallId: "tc-1" } });
    265     emit({ type: "tool.completed", data: { messageId: "msg-1", toolCallId: "tc-2" } });
    266     emit({
    267       type: "run.completed",
    268       data: {
    269         completed: true,
    270         messages: [
    271           { role: "tool", tool_call_id: "tc-1", content: "search result" },
    272           { role: "tool", tool_call_id: "tc-2", content: "file content" },
    273         ],
    274       },
    275     });
    276     expect(useChatStore.getState().messages[0].toolCalls[0].result).toBe("search result");
    277     expect(useChatStore.getState().messages[0].toolCalls[1].result).toBe("file content");
    278   });
    279 
    280   it("handles tool.completed with invalid data", () => {
    281     render(<ChatView />);
    282     emit({ type: "tool.completed", data: "invalid" });
    283     expect(useChatStore.getState().messages).toHaveLength(0);
    284   });
    285 
    286   it("handles tool.failed event", () => {
    287     render(<ChatView />);
    288     emit({ type: "message.started", data: { id: "msg-1", role: "assistant" } });
    289     emit({ type: "tool.started", data: { messageId: "msg-1", toolCall: { id: "tc-1", name: "search", args: "{}" } } });
    290     emit({ type: "tool.failed", data: { messageId: "msg-1", toolCallId: "tc-1", error: "timeout" } });
    291     expect(useChatStore.getState().messages[0].toolCalls[0].status).toBe("error");
    292   });
    293 
    294   it("handles tool.failed with default error message", () => {
    295     render(<ChatView />);
    296     emit({ type: "message.started", data: { id: "msg-1", role: "assistant" } });
    297     emit({ type: "tool.started", data: { messageId: "msg-1", toolCall: { id: "tc-1", name: "search", args: "{}" } } });
    298     emit({ type: "tool.failed", data: { messageId: "msg-1", toolCallId: "tc-1" } });
    299     expect(useChatStore.getState().messages[0].toolCalls[0].result).toBe("Tool failed");
    300   });
    301 
    302   it("handles tool.failed with invalid data", () => {
    303     render(<ChatView />);
    304     emit({ type: "tool.failed", data: "invalid" });
    305     expect(useChatStore.getState().messages).toHaveLength(0);
    306   });
    307 
    308   it("handles tool.progress event", () => {
    309     render(<ChatView />);
    310     emit({ type: "message.started", data: { id: "msg-1", role: "assistant" } });
    311     emit({ type: "tool.started", data: { messageId: "msg-1", toolCall: { id: "tc-1", name: "search", args: "{}" } } });
    312     emit({ type: "tool.progress", data: { messageId: "msg-1", toolCallId: "tc-1", progress: "50%" } });
    313     expect(useChatStore.getState().messages[0].toolCalls[0].result).toBe("50%");
    314   });
    315 
    316   it("handles tool.progress without progress value", () => {
    317     render(<ChatView />);
    318     emit({ type: "message.started", data: { id: "msg-1", role: "assistant" } });
    319     emit({ type: "tool.started", data: { messageId: "msg-1", toolCall: { id: "tc-1", name: "search", args: "{}" } } });
    320     emit({ type: "tool.progress", data: { messageId: "msg-1", toolCallId: "tc-1" } });
    321     expect(useChatStore.getState().messages[0].toolCalls[0].result).toBeNull();
    322   });
    323 
    324   it("handles tool.progress with invalid data", () => {
    325     render(<ChatView />);
    326     emit({ type: "tool.progress", data: "invalid" });
    327     expect(useChatStore.getState().messages).toHaveLength(0);
    328   });
    329 
    330   it("ignores unknown event types", () => {
    331     render(<ChatView />);
    332     emit({ type: "unknown.event" });
    333     expect(useChatStore.getState().messages).toHaveLength(0);
    334   });
    335 
    336   it("creates WebSocket and connects", () => {
    337     render(<ChatView />);
    338     expect(mockConnect).toHaveBeenCalled();
    339     expect(mockOnMessage).toHaveBeenCalled();
    340   });
    341 
    342   it("destroys WS on unmount", () => {
    343     const { unmount } = render(<ChatView />);
    344     unmount();
    345     expect(mockDestroy).toHaveBeenCalled();
    346   });
    347 
    348   it("renders model picker and branch indicator", () => {
    349     render(<ChatView />);
    350     expect(screen.getByTestId("model-picker")).toBeInTheDocument();
    351     expect(screen.getByTestId("branch-indicator")).toBeInTheDocument();
    352   });
    353 
    354   it("send includes model selection", async () => {
    355     render(<ChatView />);
    356     screen.getByText("Send").click();
    357     await vi.waitFor(() => {
    358       expect(mockSend).toHaveBeenCalledWith(
    359         "chat",
    360         expect.objectContaining({ model: null }),
    361       );
    362     });
    363   });
    364 
    365   it("run.started and run.completed toggle streaming", () => {
    366     render(<ChatView />);
    367     emit({ type: "run.started" });
    368     expect(useChatStore.getState().streaming).toBe(true);
    369     emit({ type: "run.completed" });
    370     expect(useChatStore.getState().streaming).toBe(false);
    371   });
    372 });