diff options
Diffstat (limited to 'packages/frontend/tests/chat-store.test.ts')
| -rw-r--r-- | packages/frontend/tests/chat-store.test.ts | 261 |
1 files changed, 261 insertions, 0 deletions
diff --git a/packages/frontend/tests/chat-store.test.ts b/packages/frontend/tests/chat-store.test.ts new file mode 100644 index 0000000..de988a2 --- /dev/null +++ b/packages/frontend/tests/chat-store.test.ts @@ -0,0 +1,261 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import type { AgentEvent } from "../src/lib/types.js"; + +// We test the logic inline since runes require svelte compilation context. +// The chat store logic is tested via a plain reimplementation of the same logic. + +function generateId() { + return Math.random().toString(36).slice(2, 11); +} + +interface ToolCallDisplay { + id: string; + name: string; + arguments: Record<string, unknown>; + result?: string; + isError?: boolean; + isExpanded: boolean; +} + +interface ChatMessage { + id: string; + role: "user" | "assistant"; + content: string; + toolCalls?: ToolCallDisplay[]; + isStreaming?: boolean; +} + +// Plain JS version of the chat store logic (no runes) for unit testing +function createTestStore() { + let messages: ChatMessage[] = []; + let agentStatus: "idle" | "running" | "error" = "idle"; + let currentAssistantId: string | null = null; + + function getCurrentAssistantMessage(): ChatMessage | null { + if (!currentAssistantId) return null; + return messages.find((m) => m.id === currentAssistantId) ?? null; + } + + function ensureCurrentAssistantMessage(): ChatMessage { + let msg = getCurrentAssistantMessage(); + if (!msg) { + const id = generateId(); + currentAssistantId = id; + const newMsg: ChatMessage = { + id, + role: "assistant", + content: "", + toolCalls: [], + isStreaming: true, + }; + messages = [...messages, newMsg]; + msg = newMsg; + } + return msg; + } + + function handleEvent(event: AgentEvent) { + switch (event.type) { + case "status": { + agentStatus = event.status; + if (event.status === "idle" || event.status === "error") { + currentAssistantId = null; + } + break; + } + case "text-delta": { + ensureCurrentAssistantMessage(); + messages = messages.map((m) => { + if (m.id === currentAssistantId) { + return { + ...m, + content: m.content + event.delta, + isStreaming: true, + }; + } + return m; + }); + break; + } + case "tool-call": { + ensureCurrentAssistantMessage(); + const toolCall: ToolCallDisplay = { + id: event.toolCall.id, + name: event.toolCall.name, + arguments: event.toolCall.arguments, + isExpanded: false, + }; + messages = messages.map((m) => { + if (m.id === currentAssistantId) { + return { ...m, toolCalls: [...(m.toolCalls ?? []), toolCall] }; + } + return m; + }); + break; + } + case "tool-result": { + messages = messages.map((m) => { + if (m.id === currentAssistantId) { + return { + ...m, + toolCalls: (m.toolCalls ?? []).map((tc) => { + if (tc.id === event.toolResult.toolCallId) { + return { + ...tc, + result: event.toolResult.result, + isError: event.toolResult.isError, + }; + } + return tc; + }), + }; + } + return m; + }); + break; + } + case "done": { + messages = messages.map((m) => { + if (m.id === currentAssistantId) { + return { ...m, content: event.message.content, isStreaming: false }; + } + return m; + }); + currentAssistantId = null; + break; + } + case "error": { + messages = [ + ...messages, + { + id: generateId(), + role: "assistant", + content: `Error: ${event.error}`, + isStreaming: false, + }, + ]; + currentAssistantId = null; + agentStatus = "error"; + break; + } + } + } + + function sendMessage(text: string) { + const userMsg: ChatMessage = { + id: generateId(), + role: "user", + content: text, + }; + messages = [...messages, userMsg]; + currentAssistantId = null; + } + + function clear() { + messages = []; + currentAssistantId = null; + agentStatus = "idle"; + } + + return { + get messages() { + return messages; + }, + get agentStatus() { + return agentStatus; + }, + handleEvent, + sendMessage, + clear, + }; +} + +describe("chat store logic", () => { + let store: ReturnType<typeof createTestStore>; + + beforeEach(() => { + store = createTestStore(); + }); + + it("has correct initial state", () => { + expect(store.messages).toHaveLength(0); + expect(store.agentStatus).toBe("idle"); + }); + + it("sendMessage adds a user message", () => { + store.sendMessage("hello"); + expect(store.messages).toHaveLength(1); + expect(store.messages[0]?.role).toBe("user"); + expect(store.messages[0]?.content).toBe("hello"); + }); + + it("text-delta creates a streaming assistant message and appends deltas", () => { + store.handleEvent({ type: "text-delta", delta: "Hello" }); + expect(store.messages).toHaveLength(1); + expect(store.messages[0]?.role).toBe("assistant"); + expect(store.messages[0]?.content).toBe("Hello"); + expect(store.messages[0]?.isStreaming).toBe(true); + + store.handleEvent({ type: "text-delta", delta: " world" }); + expect(store.messages[0]?.content).toBe("Hello world"); + }); + + it("tool-call adds to current assistant message toolCalls", () => { + store.handleEvent({ type: "text-delta", delta: "Calling tool..." }); + store.handleEvent({ + type: "tool-call", + toolCall: { id: "tc1", name: "search", arguments: { query: "test" } }, + }); + const msg = store.messages[0]; + expect(msg?.toolCalls).toHaveLength(1); + expect(msg?.toolCalls?.[0]?.name).toBe("search"); + expect(msg?.toolCalls?.[0]?.id).toBe("tc1"); + }); + + it("tool-result fills in result on matching tool call", () => { + store.handleEvent({ type: "text-delta", delta: "..." }); + store.handleEvent({ + type: "tool-call", + toolCall: { id: "tc1", name: "search", arguments: { query: "x" } }, + }); + store.handleEvent({ + type: "tool-result", + toolResult: { toolCallId: "tc1", result: "found it", isError: false }, + }); + const tc = store.messages[0]?.toolCalls?.[0]; + expect(tc?.result).toBe("found it"); + expect(tc?.isError).toBe(false); + }); + + it("done finalizes the current assistant message", () => { + store.handleEvent({ type: "text-delta", delta: "partial" }); + store.handleEvent({ + type: "done", + message: { role: "assistant", content: "full content" }, + }); + expect(store.messages[0]?.content).toBe("full content"); + expect(store.messages[0]?.isStreaming).toBe(false); + }); + + it("error event adds an error message and sets status to error", () => { + store.handleEvent({ type: "error", error: "something went wrong" }); + expect(store.messages).toHaveLength(1); + expect(store.messages[0]?.content).toBe("Error: something went wrong"); + expect(store.agentStatus).toBe("error"); + }); + + it("status event updates agentStatus", () => { + store.handleEvent({ type: "status", status: "running" }); + expect(store.agentStatus).toBe("running"); + store.handleEvent({ type: "status", status: "idle" }); + expect(store.agentStatus).toBe("idle"); + }); + + it("clear resets all state", () => { + store.sendMessage("hi"); + store.handleEvent({ type: "text-delta", delta: "hello" }); + store.clear(); + expect(store.messages).toHaveLength(0); + expect(store.agentStatus).toBe("idle"); + }); +}); |
