import { describe, expect, it } from "vitest"; import { ASSISTANT_MARKER, STREAMING_CURSOR, USER_MARKER, buildThreadMarkdown, previewText, quote, sessionToPlainText, } from "../lib/markdown"; import type { ChatTurn, ThreadMessage } from "../lib/types"; function message(overrides: Partial & Pick): ThreadMessage { return { id: "m1", text: "", preview: "", createdAt: 0, isStreaming: false, thinking: "", toolCalls: [], ...overrides, }; } describe("quote", () => { it("keeps a multi-line message inside one quote block", () => { expect(quote("line one\nline two")).toBe("> line one\n> line two"); }); }); describe("buildThreadMarkdown", () => { it("returns nothing for an empty conversation", () => { expect(buildThreadMarkdown([])).toBe(""); }); it("renders a question as a quote and an answer as body text", () => { const markdown = buildThreadMarkdown([ message({ role: "user", text: "What is 2+2?" }), message({ role: "assistant", text: "4" }), ]); expect(markdown).toContain("**You**"); expect(markdown).toContain("> What is 2+2?"); expect(markdown).toContain("**Lam**"); expect(markdown).toContain("4"); }); it("marks each speaker with a role icon", () => { const markdown = buildThreadMarkdown([ message({ role: "user", text: "q" }), message({ role: "assistant", text: "a" }), ]); expect(markdown).toContain(USER_MARKER); expect(markdown).toContain(ASSISTANT_MARKER); // The user marker must not leak onto the answer or vice versa. expect(markdown.indexOf(USER_MARKER)).toBeLessThan(markdown.indexOf(ASSISTANT_MARKER)); }); it("keeps the conversation in order", () => { const markdown = buildThreadMarkdown([ message({ id: "a", role: "user", text: "first" }), message({ id: "b", role: "assistant", text: "second" }), message({ id: "c", role: "user", text: "third" }), ]); expect(markdown.indexOf("first")).toBeLessThan(markdown.indexOf("second")); expect(markdown.indexOf("second")).toBeLessThan(markdown.indexOf("third")); }); it("marks a streaming answer with a cursor", () => { const streaming = buildThreadMarkdown([ message({ role: "assistant", text: "partial", isStreaming: true }), ]); const finished = buildThreadMarkdown([message({ role: "assistant", text: "partial" })]); expect(streaming).toContain(STREAMING_CURSOR); expect(finished).not.toContain(STREAMING_CURSOR); }); it("shows a placeholder while an answer has no text yet", () => { expect(buildThreadMarkdown([message({ role: "assistant", isStreaming: true })])).toContain("Thinking"); expect(buildThreadMarkdown([message({ role: "assistant" })])).toContain("No response"); }); it("surfaces an error instead of hiding it", () => { const markdown = buildThreadMarkdown([ message({ role: "assistant", error: "Ollama rejected the API key." }), ]); expect(markdown).toContain("Ollama rejected the API key."); }); it("lists tools that were used, flagging failures", () => { const markdown = buildThreadMarkdown([ message({ role: "assistant", text: "a", toolCalls: [ { name: "web_search", arguments: {}, result: "ok", isError: false }, { name: "web_fetch", arguments: {}, result: "boom", isError: true }, ], }), ]); expect(markdown).toContain("`web_search`"); expect(markdown).toContain("`web_fetch` — failed"); }); it("only shows a reasoning section when there is reasoning", () => { expect(buildThreadMarkdown([message({ role: "assistant", text: "a" })])).not.toContain("Reasoning"); expect(buildThreadMarkdown([message({ role: "assistant", text: "a", thinking: "hmm" })])).toContain( "Reasoning", ); }); }); describe("previewText", () => { it("collapses newlines so a preview stays on one row", () => { expect(previewText("a\n\nb", 40)).toBe("a b"); }); it("clips with an ellipsis", () => { expect(previewText("a".repeat(50), 5)).toHaveLength(5); }); it("leaves short text alone", () => { expect(previewText("hi", 40)).toBe("hi"); }); }); describe("sessionToPlainText", () => { function turn(prompt: string, response: string, error?: string): ChatTurn { return { id: prompt, prompt, response, thinking: "", model: "m", createdAt: 0, toolCalls: [], error }; } it("returns an empty string for an empty conversation", () => { expect(sessionToPlainText([])).toBe(""); }); it("keeps every question and answer", () => { const text = sessionToPlainText([turn("q1", "a1"), turn("q2", "a2")]); expect(text).toContain("## q1"); expect(text).toContain("a1"); expect(text).toContain("## q2"); expect(text).toContain("a2"); }); it("includes the failure when a turn errored", () => { expect(sessionToPlainText([turn("q", "", "Ollama rejected the API key.")])).toContain( "Ollama rejected the API key.", ); }); });