Files
lam-agent-vicinae/src/__tests__/markdown.test.ts
T
adrianbonpin f6c8471045 feat: label code fences so Vicinae can colour them
Nothing needed building for the dark code panel: MdCodeBlock.qml already
provides a rounded secondaryBackground panel, a header naming the
language, a Copy button with "Copied!" feedback, and horizontal scroll.
The language string is the only missing piece, and it is also what turns
on highlighting. syntax::highlight resolves it with
definitionForName(language) and then definitionForFileName("file." +
language), so `bash` and `ts` colour correctly while an unrecognised
string such as `terminal` still renders a label but produces no
highlighting at all.

Two changes:

- The default prompt now requires a language on every fence, and forbids
  leaving commands inline in a sentence or padding them with a leading $,
  so commands stay copy-pasteable.
- code.ts labels fences the model left bare. It is deliberately
  conservative, because a wrong label is worse than none: it claims bash
  only on a clear shell signal, json only when the body actually parses,
  ts only on TypeScript-specific syntax, and leaves plain JavaScript
  alone rather than guessing.

Verified by rendering a probe with four fences and reading the result:

  ```bash     -> "bash" label, "git" coloured
  ```        -> no label, no colouring
  ```ts       -> "ts" label, const/number/length coloured
  ```terminal -> "terminal" label, no colouring

`bash` is used over `terminal` for shell commands precisely because
`terminal` costs the colouring.

Also fixes an index bug in labelCodeFences found by its own test: the
unlabelled branch pushed the wrong line back, duplicating code instead of
preserving the opening fence.

typecheck, eslint, 156 tests and vici build all pass.
2026-10-04 05:36:21 +08:00

250 lines
8.8 KiB
TypeScript

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