import { describe, expect, it } from "vitest"; import { STREAMING_CURSOR, buildThreadMarkdown, quote, sessionToPlainText, } from "../lib/markdown"; import type { ChatTurn, ThreadMessage } from "../lib/types"; function message(overrides: Partial & Pick): ThreadMessage { return { id: "m1", text: "", 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("> What is 2+2?"); expect(markdown).toContain("4"); }); it("labels no speaker, so only the answer carries weight", () => { // A `**You**` / `**Lam**` header on each exchange gave both speakers the // same visual weight and pushed the answer behind chrome. const markdown = buildThreadMarkdown([ message({ role: "user", text: "q" }), message({ role: "assistant", text: "a" }), ]); expect(markdown).not.toContain("You"); expect(markdown).not.toContain("Lam"); // Nothing bold at all, other than an error. expect(markdown).not.toContain("**"); }); it("identifies the question by styling alone", () => { const markdown = buildThreadMarkdown([message({ role: "user", text: "just a question" })]); expect(markdown.trim()).toBe("> just a question"); }); it("groups an answer with its question, dividing only between exchanges", () => { const markdown = buildThreadMarkdown([ message({ id: "t2:assistant", role: "assistant", text: "second answer" }), message({ id: "t2:user", role: "user", text: "second question" }), message({ id: "t1:assistant", role: "assistant", text: "first answer" }), message({ id: "t1:user", role: "user", text: "first question" }), ]); // One rule between the two exchanges, none inside one. expect(markdown.match(/^---$/gm)?.length).toBe(1); // And the answer precedes the question it belongs to. expect(markdown.indexOf("second answer")).toBeLessThan(markdown.indexOf("second question")); expect(markdown.indexOf("second question")).toBeLessThan(markdown.indexOf("first answer")); }); it("renders no images, because markdown cannot resolve them", () => { // Images render block-level and centred at up to 200px (`MdImage.qml`), and // every candidate icon source fails. Keep the transcript text-only. const markdown = buildThreadMarkdown([ message({ role: "user", text: "q" }), message({ role: "assistant", text: "a" }), ]); expect(markdown).not.toContain("!["); }); 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 the activity line while a tool runs", () => { const markdown = buildThreadMarkdown([ message({ role: "assistant", isStreaming: true, status: "Searching the web" }), ]); expect(markdown).toContain("_Searching the web…_"); expect(markdown).not.toContain("Thinking"); }); 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("labels a bare code fence inside an answer", () => { // Vicinae gives a dark panel, a language header and a Copy button // (`MdCodeBlock.qml`); the label is what turns on syntax colouring. const markdown = buildThreadMarkdown([ message({ role: "assistant", text: "Run this:\n\n```\ngit status\n```" }), ]); expect(markdown).toContain("```bash"); }); it("leaves a fence the model already labelled alone", () => { const markdown = buildThreadMarkdown([ message({ role: "assistant", text: "```rust\nfn main() {}\n```" }), ]); expect(markdown).toContain("```rust"); expect(markdown).not.toContain("```bash"); }); it("lists tools on one quiet line, 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("_Tools: `web_search`, `web_fetch` (failed)_"); // Italic, not a bold header, so it stays below the answer. expect(markdown).not.toContain("**Tools"); }); it("omits tools and reasoning when the preferences are off", () => { const messages = [ message({ role: "assistant", text: "a", thinking: "let me think", toolCalls: [{ name: "web_search", arguments: {}, result: "ok", isError: false }], }), ]; const hidden = buildThreadMarkdown(messages, { showToolCalls: false, showReasoning: false }); expect(hidden).not.toContain("_Tools:"); expect(hidden).not.toContain("web_search"); expect(hidden).not.toContain("Reasoning"); expect(hidden).not.toContain("let me think"); // The answer itself must survive either way. expect(hidden).toContain("a"); }); it("shows one section without the other", () => { const messages = [ message({ role: "assistant", text: "a", thinking: "let me think", toolCalls: [{ name: "web_search", arguments: {}, result: "ok", isError: false }], }), ]; const toolsOnly = buildThreadMarkdown(messages, { showToolCalls: true, showReasoning: false }); expect(toolsOnly).toContain("_Tools:"); expect(toolsOnly).not.toContain("Reasoning"); const reasoningOnly = buildThreadMarkdown(messages, { showToolCalls: false, showReasoning: true }); expect(reasoningOnly).not.toContain("_Tools:"); expect(reasoningOnly).toContain("Reasoning"); }); 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("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("labels the speakers, because the transcript's styling does not survive a clipboard", () => { const text = sessionToPlainText([turn("q1", "a1")]); expect(text).toContain("**You**"); expect(text).toContain("**Lam**"); expect(text.indexOf("q1")).toBeLessThan(text.indexOf("a1")); }); it("reads oldest first, unlike the transcript", () => { const text = sessionToPlainText([turn("first", "a1"), turn("second", "a2")]); expect(text.indexOf("first")).toBeLessThan(text.indexOf("second")); }); it("includes the failure when a turn errored", () => { expect(sessionToPlainText([turn("q", "", "Ollama rejected the API key.")])).toContain( "Ollama rejected the API key.", ); }); });