import { describe, expect, it } from "vitest"; import { buildThread, type LiveState } from "../lib/thread"; import type { ChatTurn } from "../lib/types"; const idle: LiveState = { isStreaming: false, response: "", thinking: "" }; function turn(id: string, prompt: string, response: string, overrides: Partial = {}): ChatTurn { return { id, prompt, response, thinking: "", model: "m", createdAt: 100, toolCalls: [], attachments: [], ...overrides, }; } describe("buildThread", () => { it("returns nothing for an empty conversation", () => { expect(buildThread([], idle)).toEqual([]); }); it("puts the answer above its question", () => { const thread = buildThread([turn("t1", "hello", "hi there")], idle); expect(thread.map((message) => message.role)).toEqual(["assistant", "user"]); expect(thread[0].text).toBe("hi there"); expect(thread[1].text).toBe("hello"); }); it("shows the newest message first, regardless of who sent it", () => { // The search bar is at the top of the window, so the latest answer should be // the first thing under it rather than below the fold. const thread = buildThread( [turn("t1", "first question", "first answer"), turn("t2", "second question", "second answer")], idle, ); expect(thread.map((message) => message.text)).toEqual([ "second answer", "second question", "first answer", "first question", ]); }); it("alternates assistant then user", () => { const thread = buildThread( [turn("t1", "first question", "first answer"), turn("t2", "second question", "second answer")], idle, ); expect(thread.map((message) => message.role)).toEqual([ "assistant", "user", "assistant", "user", ]); }); it("gives every message a unique id", () => { const thread = buildThread([turn("t1", "a", "b"), turn("t2", "c", "d")], idle); const ids = thread.map((message) => message.id); expect(new Set(ids).size).toBe(ids.length); }); it("streams the newest turn only, and puts it first", () => { const live: LiveState = { isStreaming: true, response: "streaming now", thinking: "hmm" }; const thread = buildThread([turn("t1", "old", "old answer"), turn("t2", "new", "")], live); // The in-flight answer leads... expect(thread[0].role).toBe("assistant"); expect(thread[0].isStreaming).toBe(true); expect(thread[0].text).toBe("streaming now"); expect(thread[0].thinking).toBe("hmm"); expect(thread[1].role).toBe("user"); expect(thread[1].text).toBe("new"); expect(thread[1].isStreaming).toBe(false); // ...and the older turn follows, untouched. expect(thread[2].text).toBe("old answer"); expect(thread[3].text).toBe("old"); expect(thread[2].isStreaming).toBe(false); }); it("carries the activity line while a streaming answer has no text yet", () => { const live: LiveState = { isStreaming: true, response: "", thinking: "", status: "Searching the web" }; const thread = buildThread([turn("t1", "q", "")], live); expect(thread[0].status).toBe("Searching the web"); }); it("drops the activity line once text has arrived", () => { // The placeholder it feeds is only rendered when there is nothing to read. const live: LiveState = { isStreaming: true, response: "partial", thinking: "", status: "Searching the web", }; const thread = buildThread([turn("t1", "q", "")], live); expect(thread[0].status).toBeUndefined(); }); it("does not carry an activity line on a finished turn", () => { const live: LiveState = { isStreaming: true, response: "", thinking: "", status: "Searching the web" }; const thread = buildThread([turn("t1", "old", "done"), turn("t2", "q", "")], live); // Index 2 is the older, finished exchange's answer. expect(thread[2].status).toBeUndefined(); }); it("carries tool calls onto the assistant message", () => { const withTools = turn("t1", "q", "a", { toolCalls: [{ name: "web_search", arguments: {}, result: "ok", isError: false }], }); const thread = buildThread([withTools], idle); expect(thread[0].toolCalls).toHaveLength(1); expect(thread[0].toolCalls[0].name).toBe("web_search"); expect(thread[1].toolCalls).toEqual([]); }); it("carries a stored error onto a finished turn", () => { const thread = buildThread([turn("t1", "q", "", { error: "Request failed" })], idle); expect(thread[0].error).toBe("Request failed"); }); });