import { describe, expect, it } from "vitest"; import { initialState, reduce, type ChatState } from "./reducer"; import type { ServerMessage } from "./protocol"; function afterServer( state: ChatState, ...messages: ServerMessage[] ): ChatState { return messages.reduce( (s, message) => reduce(s, { type: "server", message }), state, ); } describe("reduce", () => { it("stores the session and goes online on hello", () => { const state = afterServer(initialState, { type: "hello", sessionId: "s1", turnActive: true, }); expect(state.sessionId).toBe("s1"); expect(state.connection).toBe("online"); expect(state.turnActive).toBe(true); }); it("resets the thread on hello so reconnect replays are idempotent", () => { let state = afterServer( initialState, { type: "hello", sessionId: "s1" }, { type: "user_message", text: "hi" }, ); expect(state.thread).toHaveLength(1); state = afterServer( state, { type: "hello", sessionId: "s1" }, { type: "user_message", text: "hi" }, ); expect(state.thread).toHaveLength(1); }); it("appends user messages to the thread", () => { const state = afterServer(initialState, { type: "user_message", text: "do the thing", }); expect(state.thread).toEqual([{ kind: "user", text: "do the thing" }]); }); it("accumulates agent chunks into one open item", () => { const chunk = (text: string): ServerMessage => ({ type: "session_update", update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text }, }, }); const state = afterServer(initialState, chunk("Hello "), chunk("world")); expect(state.thread).toEqual([ { kind: "agent", text: "Hello world", closed: false }, ]); }); it("starts a fresh agent item after the turn ends", () => { const chunk = (text: string): ServerMessage => ({ type: "session_update", update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text }, }, }); const state = afterServer( initialState, chunk("first answer"), { type: "turn_ended", stopReason: "end_turn" }, chunk("second answer"), ); expect(state.thread).toHaveLength(2); expect(state.thread[0]).toMatchObject({ kind: "agent", text: "first answer", closed: true, }); expect(state.thread[1]).toMatchObject({ kind: "agent", text: "second answer", closed: false, }); }); it("tracks the turn flag through markers", () => { let state = afterServer(initialState, { type: "turn_started" }); expect(state.turnActive).toBe(true); state = afterServer(state, { type: "turn_ended", stopReason: "end_turn" }); expect(state.turnActive).toBe(false); }); it("renders server errors as thread items", () => { const state = afterServer(initialState, { type: "error", message: "boom" }); expect(state.thread).toEqual([{ kind: "error", text: "boom" }]); }); it("ignores unknown update kinds without breaking", () => { const state = afterServer(initialState, { type: "session_update", update: { sessionUpdate: "something_new" }, }); expect(state.thread).toEqual([]); }); it("accumulates thought chunks separately from agent messages", () => { const state = afterServer( initialState, { type: "session_update", update: { sessionUpdate: "agent_thought_chunk", content: { type: "text", text: "hmm " }, }, }, { type: "session_update", update: { sessionUpdate: "agent_thought_chunk", content: { type: "text", text: "ok" }, }, }, { type: "session_update", update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text: "Answer" }, }, }, ); expect(state.thread).toHaveLength(2); expect(state.thread[0]).toMatchObject({ kind: "thought", text: "hmm ok" }); expect(state.thread[1]).toMatchObject({ kind: "agent", text: "Answer" }); }); it("creates a tool call and merges its updates", () => { let state = afterServer(initialState, { type: "session_update", update: { sessionUpdate: "tool_call", toolCallId: "call-1", title: "Reading files", kind: "read", status: "pending", locations: [{ path: "/p/main.go" }], }, }); expect(state.thread).toEqual([ { kind: "tool", call: { toolCallId: "call-1", title: "Reading files", toolKind: "read", status: "pending", contents: [], locations: [{ path: "/p/main.go" }], }, }, ]); state = afterServer(state, { type: "session_update", update: { sessionUpdate: "tool_call_update", toolCallId: "call-1", status: "completed", content: [ { type: "content", content: { type: "text", text: "42 lines" } }, ], }, }); expect(state.thread).toHaveLength(1); const item = state.thread[0]; if (item.kind !== "tool") throw new Error("expected a tool item"); expect(item.call.status).toBe("completed"); expect(item.call.title).toBe("Reading files"); expect(item.call.contents).toHaveLength(1); }); it("creates a tool item when an update arrives for an unknown id", () => { const state = afterServer(initialState, { type: "session_update", update: { sessionUpdate: "tool_call_update", toolCallId: "ghost", status: "in_progress", }, }); expect(state.thread).toHaveLength(1); expect(state.thread[0]).toMatchObject({ kind: "tool", call: { toolCallId: "ghost", status: "in_progress" }, }); }); it("replaces the plan on each plan update", () => { let state = afterServer(initialState, { type: "session_update", update: { sessionUpdate: "plan", entries: [{ content: "step 1", priority: "high", status: "pending" }], }, }); expect(state.plan).toHaveLength(1); state = afterServer(state, { type: "session_update", update: { sessionUpdate: "plan", entries: [ { content: "step 1", priority: "high", status: "completed" }, { content: "step 2", priority: "low", status: "pending" }, ], }, }); expect(state.plan).toHaveLength(2); expect(state.plan[0].status).toBe("completed"); }); it("tracks permission requests until they are resolved", () => { let state = afterServer(initialState, { type: "permission_request", requestId: "perm-1", request: { toolCall: { title: "Run make test" }, options: [{ optionId: "allow", name: "Allow", kind: "allow_once" }], }, }); expect(state.permissions).toEqual([ { requestId: "perm-1", title: "Run make test", options: [{ optionId: "allow", name: "Allow", kind: "allow_once" }], }, ]); // replayed duplicates are ignored state = afterServer(state, { type: "permission_request", requestId: "perm-1", request: { options: [] }, }); expect(state.permissions).toHaveLength(1); state = afterServer(state, { type: "permission_resolved", requestId: "perm-1", }); expect(state.permissions).toEqual([]); }); it("clears plan and permissions on hello", () => { let state = afterServer( initialState, { type: "permission_request", requestId: "perm-1", request: { options: [] }, }, { type: "session_update", update: { sessionUpdate: "plan", entries: [{ content: "x", priority: "low", status: "pending" }], }, }, ); state = afterServer(state, { type: "hello", sessionId: "s1" }); expect(state.permissions).toEqual([]); expect(state.plan).toEqual([]); }); it("stores the agent's available commands and clears them on hello", () => { let state = reduce(initialState, { type: "server", message: { type: "session_update", update: { sessionUpdate: "available_commands_update", availableCommands: [ { name: "review", description: "Review changes" }, { name: "compact", description: "Summarise", input: { hint: "x" } }, ], }, }, }); expect(state.commands.map((c) => c.name)).toEqual(["review", "compact"]); state = reduce(state, { type: "server", message: { type: "hello", sessionId: "s2" }, }); expect(state.commands).toEqual([]); }); it("tracks connection status changes", () => { const state = reduce(initialState, { type: "connection", status: "offline", }); expect(state.connection).toBe("offline"); }); it("never mutates the previous state", () => { const before = afterServer(initialState, { type: "user_message", text: "a", }); const snapshot = JSON.parse(JSON.stringify(before)); afterServer( before, { type: "user_message", text: "b" }, { type: "turn_started" }, ); expect(before).toEqual(snapshot); }); });