Files
lam-agent-vicinae/src/__tests__/markdown.test.ts
T
adrianbonpin 03a7b30ebf feat: mark each speaker with a role icon in the transcript
Nothing in the transcript said who was speaking. Each message is now
prefixed with a role marker: person for the user, sparkle for Lam.

Emoji rather than icon images, because Vicinae's markdown renderer is
minimal and there is no reliable way to inline an `icon://` reference
into markdown, whereas plain text always renders.

Also records in the README why the transcript needs both panes, since
that came up: Detail accepts no typed input, so a detail-only chat
command would have nothing to type into, and list rows are a fixed
single line, so full text cannot live in the list. Input needs a List,
full text needs a Detail.

Verified on screen that both markers render.
2026-10-04 05:03:11 +08:00

156 lines
5.0 KiB
TypeScript

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<ThreadMessage> & Pick<ThreadMessage, "role">): 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.",
);
});
});