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: [], attachments: [], ...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 for a message that has no attachment", () => { // Images only ever come from an attachment this extension took itself. A URL // the model invented is not resolvable and renders as an empty block. 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("says what a running tool is doing exactly once, before any text arrives", () => { // The reported bug, from a screenshot: the activity line and the answer // placeholder both printed "Reading a page", one directly above the other. const markdown = buildThreadMarkdown([ message({ role: "assistant", isStreaming: true, text: "", activity: { label: "Reading a page", count: 1 }, }), ]); expect(markdown.match(/Reading a page/g)).toHaveLength(1); // With nothing to read yet, the activity line is the stand-in, so the cursor // rides on it rather than sitting on a line of its own. expect(markdown).toContain(`_Reading a page…_ ${STREAMING_CURSOR}`); }); it("keeps the activity line once the answer has started arriving", () => { // The other half of the fix: a tool that starts mid-answer has to stay on // screen, which is why the line is not just a stand-in for missing text. const markdown = buildThreadMarkdown([ message({ role: "assistant", isStreaming: true, text: "Here is what I found.", activity: { label: "Reading a page", count: 1 }, }), ]); expect(markdown).toContain("_Reading a page…_"); expect(markdown).toContain("Here is what I found."); // And the cursor follows the answer, not the activity line. expect(markdown).toContain(`Here is what I found. ${STREAMING_CURSOR}`); expect(markdown).not.toContain(`_Reading a page…_ ${STREAMING_CURSOR}`); }); it("names a repeat rather than leaving a bare count to be interpreted", () => { const markdown = buildThreadMarkdown([ message({ role: "assistant", isStreaming: true, activity: { label: "Searching the web", count: 2 }, }), ]); expect(markdown).toContain("the same call again (2)"); }); it("drops the activity line when the message is no longer streaming", () => { const markdown = buildThreadMarkdown([ message({ role: "assistant", text: "done", activity: { label: "Searching the web", count: 1 } }), ]); expect(markdown).not.toContain("Searching the web"); expect(markdown).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("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("turns a fenced list of URLs into links, because a fence is not clickable", () => { // The reported case: asked for alternatives, the model answered with a code // block of bare GitHub addresses. `MdCodeBlock.qml` draws that as a dark // panel with a Copy button and no link handling, so the address could not be // opened. Only `CMARK_NODE_LINK` becomes an `` the selection // controller can activate. const markdown = buildThreadMarkdown([ message({ role: "assistant", text: "GitHub links:\n\n```\nhttps://github.com/jasonkneen/openclicky\nhttps://github.com/unn-Known1/clickyX\n```", }), ]); expect(markdown).not.toContain("```"); expect(markdown).toContain( "[https://github.com/jasonkneen/openclicky](https://github.com/jasonkneen/openclicky)", ); }); it("links a lone URL the model put in inline code", () => { const markdown = buildThreadMarkdown([ message({ role: "assistant", text: "`https://example.com`" }), ]); expect(markdown).not.toContain("```"); expect(markdown).toContain("[https://example.com](https://example.com)"); }); it("lists tools on one quiet line, each named once, 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` (1 failed)_"); // Italic, not a bold header, so it stays below the answer. expect(markdown).not.toContain("**Tools"); }); it("names a tool once however many times it was called", () => { // The reported case: a research turn listed the same two tools twenty times // over three lines, which is a wall of text that says nothing a name does // not. Names in order of first use, no repetition. const markdown = buildThreadMarkdown([ message({ role: "assistant", text: "a", toolCalls: [ { name: "web_search", arguments: {}, result: "ok", isError: false }, { name: "web_fetch", arguments: {}, result: "ok", isError: false }, { name: "web_search", arguments: {}, result: "ok", isError: false }, { name: "web_search", arguments: {}, result: "ok", isError: false }, ], }), ]); expect(markdown).toContain("_Tools: `web_search`, `web_fetch`_"); expect(markdown.match(/web_search/g)).toHaveLength(1); }); it("counts every failed call, not just the failed tools", () => { const markdown = buildThreadMarkdown([ message({ role: "assistant", text: "a", toolCalls: [ { name: "web_fetch", arguments: {}, result: "boom", isError: true }, { name: "web_fetch", arguments: {}, result: "boom", isError: true }, { name: "web_fetch", arguments: {}, result: "boom", isError: true }, { name: "web_search", arguments: {}, result: "ok", isError: false }, ], }), ]); expect(markdown).toContain("_Tools: `web_fetch`, `web_search` (3 failed)_"); expect(markdown.match(/web_fetch/g)).toHaveLength(1); }); 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("names a refused repeat, and does not call it a failure", () => { const markdown = buildThreadMarkdown([ message({ role: "assistant", text: "a", toolCalls: [ { name: "web_search", arguments: {}, result: "ok", isError: false }, { name: "web_search", arguments: {}, result: "Refused", isError: true, repeated: true, }, ], }), ]); expect(markdown).toContain("_Tools: `web_search` (stopped: the same call kept repeating)_"); expect(markdown).not.toContain("failed"); }); it("says a call failed and a call repeated, when both happened", () => { const markdown = buildThreadMarkdown([ message({ role: "assistant", text: "a", toolCalls: [ { name: "web_fetch", arguments: {}, result: "boom", isError: true }, { name: "web_search", arguments: {}, result: "Refused", isError: true, repeated: true }, ], }), ]); expect(markdown).toContain( "_Tools: `web_fetch`, `web_search` (1 failed, stopped: the same call kept repeating)_", ); }); it("hides a failed call's name, but still says that a call failed", () => { // Off means the list is off, not that a failure goes unmentioned: the answer // alone would not account for a tool that did not work. const markdown = buildThreadMarkdown( [ message({ role: "assistant", text: "a", toolCalls: [{ name: "web_fetch", arguments: {}, result: "boom", isError: true }], }), ], { showToolCalls: false, showReasoning: false }, ); expect(markdown).toContain("_1 tool call failed._"); expect(markdown).not.toContain("web_fetch"); expect(markdown).not.toContain("_Tools:"); }); it("counts the failures rather than naming every failed call when hidden", () => { const markdown = buildThreadMarkdown( [ message({ role: "assistant", text: "a", toolCalls: [ { name: "web_fetch", arguments: {}, result: "boom", isError: true }, { name: "web_search", arguments: {}, result: "boom", isError: true }, ], }), ], { showToolCalls: false, showReasoning: false }, ); expect(markdown).toContain("_2 tool calls failed._"); }); it("says a turn was cut short by a repeat, without naming the call", () => { // Still the one the user most needs to know about: a model going in circles // is exactly the thing a hidden tool list would conceal. const markdown = buildThreadMarkdown( [ message({ role: "assistant", text: "a", toolCalls: [ { name: "web_search", arguments: {}, result: "Refused", isError: true, repeated: true }, ], }), ], { showToolCalls: false, showReasoning: false }, ); expect(markdown).toContain("_a call kept repeating, so the turn was cut short._"); expect(markdown).not.toContain("web_search"); }); it("says both when a call failed and another kept repeating, with tools hidden", () => { const markdown = buildThreadMarkdown( [ message({ role: "assistant", text: "a", toolCalls: [ { name: "web_fetch", arguments: {}, result: "boom", isError: true }, { name: "web_search", arguments: {}, result: "Refused", isError: true, repeated: true }, ], }), ], { showToolCalls: false, showReasoning: false }, ); expect(markdown).toContain("_1 tool call failed and a call kept repeating, so the turn was cut short._"); }); it("still hides the ordinary calls when tool calls are off", () => { const markdown = buildThreadMarkdown( [ message({ role: "assistant", text: "a", toolCalls: [{ name: "get_datetime", arguments: {}, result: "ok", isError: false }], }), ], { showToolCalls: false, showReasoning: false }, ); expect(markdown).not.toContain("_Tools:"); }); 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", ); }); it("streams the reasoning live even when Show Reasoning is off", () => { // While a turn is in flight the reasoning is the only sign of progress, so // it is shown whatever the preference says. It is the live view only. const markdown = buildThreadMarkdown( [message({ role: "assistant", text: "", isStreaming: true, thinking: "weighing the options" })], { showToolCalls: false, showReasoning: false }, ); expect(markdown).toContain("weighing the options"); expect(markdown).toContain("Reasoning"); // The cursor trails the reasoning, because nothing else is arriving. expect(markdown).toContain(`> weighing the options ${STREAMING_CURSOR}`); // And the "Thinking…" stand-in is dropped, since the reasoning says it. expect(markdown).not.toContain("Thinking…"); }); it("drops the reasoning when the turn ends and Show Reasoning is off", () => { const markdown = buildThreadMarkdown( [message({ role: "assistant", text: "the answer", thinking: "weighing the options" })], { showToolCalls: false, showReasoning: false }, ); expect(markdown).toContain("the answer"); expect(markdown).not.toContain("weighing the options"); expect(markdown).not.toContain("Reasoning"); }); it("keeps the reasoning under the answer when the turn ends and Show Reasoning is on", () => { const markdown = buildThreadMarkdown([ message({ role: "assistant", text: "the answer", thinking: "weighing the options" }), ]); expect(markdown).toContain("the answer"); expect(markdown).toContain("weighing the options"); expect(markdown.indexOf("the answer")).toBeLessThan(markdown.indexOf("weighing the options")); }); it("starts the live reasoning without a leading blank line", () => { // Nothing else is on screen while the model thinks, so the reasoning is the // first block, and a stray blank line above it would read as a gap. const markdown = buildThreadMarkdown( [message({ role: "assistant", text: "", isStreaming: true, thinking: "weighing the options" })], { showToolCalls: false, showReasoning: false }, ); expect(markdown.startsWith("_Reasoning_")).toBe(true); }); it("rides the answer, not the reasoning, once an answer has arrived", () => { // The cursor rides the answer once there is one, even while reasoning from // the round before it is still on screen. const markdown = buildThreadMarkdown( [ message({ role: "assistant", text: "partial answer", isStreaming: true, thinking: "weighing the options", }), ], { showToolCalls: false, showReasoning: false }, ); expect(markdown).toContain(`partial answer ${STREAMING_CURSOR}`); expect(markdown).not.toContain(`weighing the options ${STREAMING_CURSOR}`); }); }); describe("sessionToPlainText", () => { function turn(prompt: string, response: string, error?: string): ChatTurn { return { id: prompt, prompt, response, thinking: "", model: "m", createdAt: 0, toolCalls: [], attachments: [], 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.", ); }); }); describe("attachments in the transcript", () => { const screenshot = { path: "/tmp/snip.png", mime: "image/png" }; it("shows the attachment instead of just naming it", () => { // Vicinae resolves a local file through `ImageURL::local`, so the capture is // rendered rather than described. `MdImage.qml` caps the height at 200px, so // it is a thumbnail, and the alt text is what is left if the file is gone. const markdown = buildThreadMarkdown([ message({ role: "user", text: "what is this?", attachments: [screenshot] }), ]); expect(markdown).toContain("![Screenshot](file:///tmp/snip.png)"); }); it("keeps the question above its image", () => { const markdown = buildThreadMarkdown([ message({ role: "user", text: "what is this?", attachments: [screenshot] }), ]); expect(markdown.indexOf("> what is this?")).toBeLessThan(markdown.indexOf("![")); }); it("renders every attachment on a turn", () => { const markdown = buildThreadMarkdown([ message({ role: "user", text: "compare", attachments: [screenshot, { path: "/tmp/second.png", mime: "image/png" }], }), ]); expect(markdown).toContain("file:///tmp/snip.png"); expect(markdown).toContain("file:///tmp/second.png"); }); it("never puts an image on an answer, which has no attachments", () => { const markdown = buildThreadMarkdown([ message({ role: "assistant", text: "a cat", attachments: [screenshot] }), ]); expect(markdown).not.toContain("!["); }); it("says nothing about attachments when there are none", () => { const markdown = buildThreadMarkdown([message({ role: "user", text: "q" })]); expect(markdown).not.toContain("Screenshot"); }); it("notes the screenshot when a conversation is copied as plain text", () => { const question = { id: "t1", prompt: "what is this?", response: "a cat", thinking: "", model: "m", createdAt: 0, toolCalls: [], attachments: [screenshot], }; const text = sessionToPlainText([question]); expect(text).toContain("_(screenshot attached)_"); }); });