feat: message-per-row transcript, session resume, drop the composer (#4)
This commit was merged in pull request #4.
This commit is contained in:
@@ -1,84 +1,103 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
STREAMING_CURSOR,
|
||||
buildTurnMarkdown,
|
||||
pendingMarkdown,
|
||||
buildThreadMarkdown,
|
||||
previewText,
|
||||
quote,
|
||||
sessionToPlainText,
|
||||
} from "../lib/markdown";
|
||||
import type { ChatTurn } from "../lib/types";
|
||||
import type { ChatTurn, ThreadMessage } from "../lib/types";
|
||||
|
||||
describe("buildTurnMarkdown", () => {
|
||||
it("renders the question as a quote block above the answer", () => {
|
||||
const markdown = buildTurnMarkdown({ prompt: "What is 2+2?", response: "4" });
|
||||
function message(overrides: Partial<ThreadMessage> & Pick<ThreadMessage, "role">): ThreadMessage {
|
||||
return {
|
||||
id: "m1",
|
||||
text: "",
|
||||
preview: "",
|
||||
createdAt: 0,
|
||||
isStreaming: false,
|
||||
thinking: "",
|
||||
toolCalls: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
expect(markdown).toContain("> What is 2+2?");
|
||||
expect(markdown.indexOf("> What is 2+2?")).toBeLessThan(markdown.indexOf("4"));
|
||||
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("keeps a multi-line question inside the quote block", () => {
|
||||
const markdown = buildTurnMarkdown({ prompt: "line one\nline two", response: "ok" });
|
||||
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("> line one\n> line two");
|
||||
expect(markdown).toContain("**You**");
|
||||
expect(markdown).toContain("> What is 2+2?");
|
||||
expect(markdown).toContain("**Lam**");
|
||||
expect(markdown).toContain("4");
|
||||
});
|
||||
|
||||
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 = buildTurnMarkdown({ prompt: "q", response: "partial", isStreaming: true });
|
||||
const finished = buildTurnMarkdown({ prompt: "q", response: "partial", isStreaming: false });
|
||||
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 is still empty", () => {
|
||||
expect(buildTurnMarkdown({ prompt: "q", response: "", isStreaming: true })).toContain("Thinking");
|
||||
expect(buildTurnMarkdown({ prompt: "q", response: "", isStreaming: false })).toContain("No response");
|
||||
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 = buildTurnMarkdown({ prompt: "q", response: "", error: "Ollama rejected the API key." });
|
||||
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 = buildTurnMarkdown({
|
||||
prompt: "q",
|
||||
response: "a",
|
||||
toolCalls: [
|
||||
{ name: "web_search", arguments: {}, result: "ok", isError: false },
|
||||
{ name: "web_fetch", arguments: {}, result: "boom", isError: true },
|
||||
],
|
||||
});
|
||||
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("omits the reasoning section when there is none", () => {
|
||||
expect(buildTurnMarkdown({ prompt: "q", response: "a" })).not.toContain("Reasoning");
|
||||
expect(buildTurnMarkdown({ prompt: "q", response: "a", thinking: "hmm" })).toContain("Reasoning");
|
||||
});
|
||||
|
||||
it("includes the model and timestamp in the footer", () => {
|
||||
const markdown = buildTurnMarkdown({
|
||||
prompt: "q",
|
||||
response: "a",
|
||||
model: "gpt-oss:120b",
|
||||
createdAt: 1_700_000_000_000,
|
||||
});
|
||||
expect(markdown).toContain("`gpt-oss:120b`");
|
||||
});
|
||||
});
|
||||
|
||||
describe("pendingMarkdown", () => {
|
||||
it("shows the question with an activity line", () => {
|
||||
const markdown = pendingMarkdown("q", "Searching the web");
|
||||
expect(markdown).toContain("> q");
|
||||
expect(markdown).toContain("_Searching the web…_");
|
||||
});
|
||||
|
||||
it("falls back to a generic line when there is no status", () => {
|
||||
expect(pendingMarkdown("q")).toContain("_Thinking…_");
|
||||
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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -90,6 +109,10 @@ describe("previewText", () => {
|
||||
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", () => {
|
||||
@@ -111,7 +134,8 @@ describe("sessionToPlainText", () => {
|
||||
});
|
||||
|
||||
it("includes the failure when a turn errored", () => {
|
||||
const text = sessionToPlainText([turn("q", "", "Ollama rejected the API key.")]);
|
||||
expect(text).toContain("Ollama rejected the API key.");
|
||||
expect(sessionToPlainText([turn("q", "", "Ollama rejected the API key.")])).toContain(
|
||||
"Ollama rejected the API key.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { MESSAGE_PREVIEW_LENGTH, buildThread, type LiveState } from "../lib/thread";
|
||||
import type { ChatTurn } from "../lib/types";
|
||||
|
||||
const idle: LiveState = { isStreaming: false, response: "", thinking: "" };
|
||||
|
||||
function turn(id: string, prompt: string, response: string, overrides: Partial<ChatTurn> = {}): ChatTurn {
|
||||
return {
|
||||
id,
|
||||
prompt,
|
||||
response,
|
||||
thinking: "",
|
||||
model: "m",
|
||||
createdAt: 100,
|
||||
toolCalls: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("buildThread", () => {
|
||||
it("returns nothing for an empty conversation", () => {
|
||||
expect(buildThread([], idle)).toEqual([]);
|
||||
});
|
||||
|
||||
it("flattens a turn into a user message followed by an assistant message", () => {
|
||||
const thread = buildThread([turn("t1", "hello", "hi there")], idle);
|
||||
|
||||
expect(thread.map((message) => message.role)).toEqual(["user", "assistant"]);
|
||||
expect(thread[0].text).toBe("hello");
|
||||
expect(thread[1].text).toBe("hi there");
|
||||
});
|
||||
|
||||
it("keeps messages in chronological order across turns", () => {
|
||||
const thread = buildThread(
|
||||
[turn("t1", "first question", "first answer"), turn("t2", "second question", "second answer")],
|
||||
idle,
|
||||
);
|
||||
|
||||
expect(thread.map((message) => message.text)).toEqual([
|
||||
"first question",
|
||||
"first answer",
|
||||
"second question",
|
||||
"second answer",
|
||||
]);
|
||||
});
|
||||
|
||||
it("gives every message a unique id", () => {
|
||||
const thread = buildThread([turn("t1", "a", "b"), turn("t2", "c", "d")], idle);
|
||||
const ids = thread.map((message) => message.id);
|
||||
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
});
|
||||
|
||||
it("builds one-line previews", () => {
|
||||
const thread = buildThread([turn("t1", "hello\n\nworld", "a\nlong\n\nanswer")], idle);
|
||||
|
||||
expect(thread[0].preview).toBe("hello world");
|
||||
expect(thread[1].preview).toBe("a long answer");
|
||||
});
|
||||
|
||||
it("clips a preview longer than the row can show", () => {
|
||||
const long = "x".repeat(MESSAGE_PREVIEW_LENGTH + 50);
|
||||
const thread = buildThread([turn("t1", long, long)], idle);
|
||||
|
||||
expect(thread[0].preview).toHaveLength(MESSAGE_PREVIEW_LENGTH);
|
||||
});
|
||||
|
||||
it("streams the newest turn only", () => {
|
||||
const live: LiveState = { isStreaming: true, response: "streaming now", thinking: "hmm" };
|
||||
const thread = buildThread([turn("t1", "old", "old answer"), turn("t2", "new", "")], live);
|
||||
|
||||
// The older turn stays put...
|
||||
expect(thread[1].isStreaming).toBe(false);
|
||||
expect(thread[1].text).toBe("old answer");
|
||||
|
||||
// ...and the newest assistant message reads from the live buffer.
|
||||
expect(thread[3].isStreaming).toBe(true);
|
||||
expect(thread[3].text).toBe("streaming now");
|
||||
expect(thread[3].thinking).toBe("hmm");
|
||||
|
||||
// The user's line is never "streaming".
|
||||
expect(thread[2].isStreaming).toBe(false);
|
||||
});
|
||||
|
||||
it("falls back to the activity line while an answer has no text yet", () => {
|
||||
const live: LiveState = { isStreaming: true, response: "", thinking: "", status: "Searching the web" };
|
||||
const thread = buildThread([turn("t1", "q", "")], live);
|
||||
|
||||
expect(thread[1].preview).toBe("Searching the web");
|
||||
});
|
||||
|
||||
it("falls back to a generic label while thinking", () => {
|
||||
const live: LiveState = { isStreaming: true, response: "", thinking: "" };
|
||||
const thread = buildThread([turn("t1", "q", "")], live);
|
||||
|
||||
expect(thread[1].preview).toBe("Thinking…");
|
||||
});
|
||||
|
||||
it("previews a stored error when a finished turn has no answer", () => {
|
||||
const thread = buildThread([turn("t1", "q", "", { error: "Request failed" })], idle);
|
||||
|
||||
expect(thread[1].preview).toBe("Request failed");
|
||||
expect(thread[1].error).toBe("Request failed");
|
||||
});
|
||||
|
||||
it("carries tool calls onto the assistant message", () => {
|
||||
const withTools = turn("t1", "q", "a", {
|
||||
toolCalls: [{ name: "web_search", arguments: {}, result: "ok", isError: false }],
|
||||
});
|
||||
const thread = buildThread([withTools], idle);
|
||||
|
||||
expect(thread[0].toolCalls).toEqual([]);
|
||||
expect(thread[1].toolCalls).toHaveLength(1);
|
||||
expect(thread[1].toolCalls[0].name).toBe("web_search");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user