Files
lam-agent-vicinae/src/__tests__/markdown.test.ts
T
adrianbonpin bf2b5f40a2 fix: name each tool once, and stream the reasoning live
The transcript listed every tool call in order, so a research turn read
`_Tools: web_search, web_search, web_search, web_fetch (failed), ..._` over
three lines of the same two words. Each tool is now named once, in the order
it was first used, with the failures counted: `_Tools: web_search, web_fetch
(4 failed)_`.

`Show Tool Calls` now actually hides the list. The old rule kept the whole
list on screen whenever any call failed or was refused as a repeat, which is
what left the setting looking broken. Failures are still mentioned, in one
sentence and without the names: `_2 tool calls failed._`, or `_a call kept
repeating, so the turn was cut short._`.

Reasoning now streams into the transcript while a turn is in flight whatever
`Show Reasoning` says, because a model that is thinking rather than writing
has nothing else on screen; the preference governs only where the trace
settles once the turn ends. The live view is the segment since the last tool
call, cleared at each tool start and kept whole for storage, so a dozen
rounds of thinking no longer stack on screen at once.
2026-10-06 22:18:13 +08:00

648 lines
23 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: [],
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 `<a href>` 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)_");
});
});