feat: initial Lam Agent extension (#1)

This commit was merged in pull request #1.
This commit is contained in:
2026-10-03 20:32:40 +00:00
parent 1d39aabb50
commit 8b507e7953
36 changed files with 7136 additions and 2 deletions
+173
View File
@@ -0,0 +1,173 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import path from "node:path";
import { readFileSync } from "node:fs";
import { pickToolCalls, runAgent } from "../lib/agent";
import { OllamaError } from "../lib/ollama";
import { toolsFor } from "../lib/tools";
import type { ApiMessage, ToolCall } from "../lib/types";
/**
* The agent loop is the riskiest code in the extension: it drives the tool
* protocol, and the failure modes are subtle (a dropped tool result, an
* infinite loop, a swallowed HTTP error). These tests stub `fetch` with real
* `Response` objects so the actual NDJSON parsing path runs, rather than
* mocking the client and testing nothing.
*/
function ndjsonResponse(events: unknown[], status = 200): Response {
const body = `${events.map((event) => JSON.stringify(event)).join("\n")}\n`;
return new Response(body, { status, headers: { "Content-Type": "application/x-ndjson" } });
}
function toolCall(name: string, args: Record<string, unknown> = {}): Record<string, unknown> {
return { message: { role: "assistant", content: "", tool_calls: [{ function: { name, arguments: args } }] }, done: true };
}
function answer(text: string): Record<string, unknown>[] {
return [{ message: { role: "assistant", content: text }, done: true, done_reason: "stop" }];
}
const fetchMock = vi.fn();
beforeEach(() => {
fetchMock.mockReset();
vi.stubGlobal("fetch", fetchMock);
});
afterEach(() => {
vi.unstubAllGlobals();
});
function baseOptions(messages: ApiMessage[]) {
return {
host: "https://ollama.com",
apiKey: "test-key",
model: "test-model",
messages,
tools: toolsFor({ webEnabled: false }),
toolContext: { host: "https://ollama.com", apiKey: "test-key", now: new Date("2026-01-02T03:04:05Z") },
};
}
describe("pickToolCalls", () => {
it("keeps the previous calls when a chunk carries none", () => {
const calls: ToolCall[] = [{ function: { name: "web_search", arguments: {} } }];
expect(pickToolCalls(calls, undefined)).toBe(calls);
expect(pickToolCalls(calls, [])).toBe(calls);
});
it("prefers the newest non-empty array", () => {
const old: ToolCall[] = [{ function: { name: "a", arguments: {} } }];
const next: ToolCall[] = [{ function: { name: "b", arguments: {} } }];
expect(pickToolCalls(old, next)).toBe(next);
});
});
describe("runAgent", () => {
it("accumulates streamed content into the final answer", async () => {
fetchMock.mockResolvedValueOnce(
ndjsonResponse([
{ message: { role: "assistant", content: "Hello" }, done: false },
{ message: { role: "assistant", content: " world" }, done: false },
{ message: { role: "assistant", content: "" }, done: true, done_reason: "stop" },
]),
);
const deltas: string[] = [];
const result = await runAgent({
...baseOptions([{ role: "user", content: "hi" }]),
events: { onContentDelta: (delta) => deltas.push(delta) },
});
expect(result.content).toBe("Hello world");
expect(deltas).toEqual(["Hello", " world"]);
expect(result.toolCalls).toEqual([]);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("separates reasoning output from the answer", async () => {
fetchMock.mockResolvedValueOnce(
ndjsonResponse([
{ message: { role: "assistant", thinking: "weighing options" }, done: false },
{ message: { role: "assistant", content: "Answer" }, done: true },
]),
);
const result = await runAgent(baseOptions([{ role: "user", content: "hi" }]));
expect(result.thinking).toBe("weighing options");
expect(result.content).toBe("Answer");
});
it("executes a requested tool and feeds the result back to the model", async () => {
fetchMock
.mockResolvedValueOnce(ndjsonResponse([toolCall("get_datetime")]))
.mockResolvedValueOnce(ndjsonResponse(answer("It is early January 2026.")));
const result = await runAgent(baseOptions([{ role: "user", content: "what time is it" }]));
expect(result.content).toBe("It is early January 2026.");
expect(result.toolCalls).toHaveLength(1);
expect(result.toolCalls[0].name).toBe("get_datetime");
expect(result.toolCalls[0].isError).toBe(false);
expect(result.toolCalls[0].result).toContain("2026");
// The second request must replay the assistant tool call and the tool
// result, otherwise the model has no idea what the tool returned.
expect(fetchMock).toHaveBeenCalledTimes(2);
const secondBody = JSON.parse(fetchMock.mock.calls[1][1].body as string) as { messages: ApiMessage[] };
const toolMessage = secondBody.messages.find((message) => message.role === "tool");
expect(toolMessage).toBeDefined();
expect(toolMessage?.tool_name).toBe("get_datetime");
expect(toolMessage?.content).toContain("2026");
});
it("reports an unknown tool as an error without aborting the turn", async () => {
fetchMock
.mockResolvedValueOnce(ndjsonResponse([toolCall("no_such_tool")]))
.mockResolvedValueOnce(ndjsonResponse(answer("I could not do that.")));
const result = await runAgent(baseOptions([{ role: "user", content: "hi" }]));
expect(result.toolCalls[0].isError).toBe(true);
expect(result.toolCalls[0].result).toContain("Unknown tool");
expect(result.content).toBe("I could not do that.");
});
it("stops after the iteration cap instead of looping forever", async () => {
// Every response asks for another tool call, so only the cap can end this.
for (let i = 0; i < 10; i++) {
fetchMock.mockResolvedValueOnce(ndjsonResponse([toolCall("get_datetime")]));
}
const result = await runAgent(baseOptions([{ role: "user", content: "hi" }]));
expect(result.content).toContain("Stopped after");
expect(result.toolCalls).toHaveLength(5);
expect(fetchMock).toHaveBeenCalledTimes(5);
});
it("surfaces an actionable hint when the API key is rejected", async () => {
fetchMock.mockResolvedValueOnce(new Response("unauthorized", { status: 401 }));
await expect(runAgent(baseOptions([{ role: "user", content: "hi" }]))).rejects.toThrow(OllamaError);
fetchMock.mockResolvedValueOnce(new Response("unauthorized", { status: 401 }));
await expect(runAgent(baseOptions([{ role: "user", content: "hi" }]))).rejects.toMatchObject({
status: 401,
hint: expect.stringContaining("ollama.com/settings/keys"),
});
});
});
describe("module wiring", () => {
it("keeps the request, tool, and agent layers free of Vicinae imports", () => {
// These modules must stay renderer-free: they are pure logic, they are what
// the unit tests cover, and nothing in them should need a Vicinae runtime.
// (`sessions.ts` is deliberately excluded: it legitimately needs LocalStorage.)
for (const file of ["agent.ts", "ollama.ts", "tools.ts", "location.ts", "history.ts", "markdown.ts"]) {
const source = readFileSync(path.resolve(__dirname, `../lib/${file}`), "utf8");
expect(source, `${file} should not import @vicinae/api`).not.toMatch(/from\s+"@vicinae\/api"/);
}
});
});
+68
View File
@@ -0,0 +1,68 @@
import { describe, expect, it } from "vitest";
import { DEFAULT_SYSTEM_PROMPT, RECENCY_HINT } from "../lib/constants";
import { buildMessages, composeSystemPrompt } from "../lib/history";
import type { ChatTurn } from "../lib/types";
function turn(prompt: string, response: string): ChatTurn {
return { id: prompt, prompt, response, thinking: "", model: "m", createdAt: 0, toolCalls: [] };
}
describe("buildMessages", () => {
it("opens with the system prompt and closes with the new question", () => {
const messages = buildMessages("SYSTEM", [], "hello");
expect(messages).toEqual([
{ role: "system", content: "SYSTEM" },
{ role: "user", content: "hello" },
]);
});
it("replays completed turns in order", () => {
const messages = buildMessages("SYS", [turn("q1", "a1"), turn("q2", "a2")], "q3");
expect(messages.map((message) => message.role)).toEqual([
"system",
"user",
"assistant",
"user",
"assistant",
"user",
]);
expect(messages[5]).toEqual({ role: "user", content: "q3" });
});
it("skips turns that never produced an answer", () => {
// A cancelled or failed turn would otherwise be replayed to the model as an
// empty assistant message, which some models treat as a hard stop.
const messages = buildMessages("SYS", [turn("q1", ""), turn("q2", "a2")], "q3");
expect(messages).toHaveLength(4);
expect(messages[1]).toEqual({ role: "user", content: "q2" });
});
it("does not replay tool messages from earlier turns", () => {
const withTools: ChatTurn = {
...turn("q1", "a1"),
toolCalls: [{ name: "web_search", arguments: {}, result: "a huge page", isError: false }],
};
const messages = buildMessages("SYS", [withTools], "q2");
expect(messages.some((message) => message.role === "tool")).toBe(false);
expect(JSON.stringify(messages)).not.toContain("a huge page");
});
});
describe("composeSystemPrompt", () => {
it("uses the default prompt when the user has not set one", () => {
expect(composeSystemPrompt("", false)).toBe(DEFAULT_SYSTEM_PROMPT);
});
it("prefers the user's prompt when set", () => {
expect(composeSystemPrompt("Be terse.", false)).toBe("Be terse.");
});
it("appends the recency hint only when web tools are on", () => {
expect(composeSystemPrompt("Be terse.", true)).toBe(`Be terse.\n\n${RECENCY_HINT}`);
expect(composeSystemPrompt("Be terse.", false)).not.toContain(RECENCY_HINT);
});
});
+68
View File
@@ -0,0 +1,68 @@
import { describe, expect, it } from "vitest";
import { describeTimezone, getLocationContext, utcOffsetLabel, weekdayName } from "../lib/location";
describe("describeTimezone", () => {
it("splits a simple zone into region and city", () => {
expect(describeTimezone("Asia/Manila")).toEqual({ region: "Asia", city: "Manila" });
});
it("handles zones with more than two segments", () => {
expect(describeTimezone("America/Argentina/Salta")).toEqual({ region: "America", city: "Salta" });
});
it("underscores become spaces", () => {
expect(describeTimezone("America/New_York")).toEqual({ region: "America", city: "New York" });
});
it("returns nothing for a bare zone like UTC", () => {
expect(describeTimezone("UTC")).toEqual({});
});
});
describe("utcOffsetLabel", () => {
it("renders a GMT offset for a fixed zone", () => {
const date = new Date("2026-01-02T03:04:05Z");
expect(utcOffsetLabel(date, "Asia/Manila")).toContain("+08:00");
});
it("reflects daylight saving rather than a fixed offset", () => {
const winter = utcOffsetLabel(new Date("2026-01-15T12:00:00Z"), "America/New_York");
const summer = utcOffsetLabel(new Date("2026-07-15T12:00:00Z"), "America/New_York");
expect(winter).not.toBe(summer);
expect(winter).toContain("-05:00");
expect(summer).toContain("-04:00");
});
});
describe("weekdayName", () => {
it("names the weekday in the requested zone", () => {
// 2026-01-02T03:04:05Z is a Friday in UTC.
expect(weekdayName(new Date("2026-01-02T03:04:05Z"), "UTC")).toBe("Friday");
});
it("respects the zone when the local date differs from UTC", () => {
// 2026-01-02T23:30Z is still Friday in UTC but already Saturday in Manila.
const late = new Date("2026-01-02T23:30:00Z");
expect(weekdayName(late, "UTC")).toBe("Friday");
expect(weekdayName(late, "Asia/Manila")).toBe("Saturday");
});
});
describe("getLocationContext", () => {
it("reports the requested zone with its parts", () => {
const context = getLocationContext(new Date("2026-01-02T03:04:05Z"), "Asia/Manila");
expect(context.timezone).toBe("Asia/Manila");
expect(context.city).toBe("Manila");
expect(context.region).toBe("Asia");
expect(context.utcOffset).toContain("+08:00");
expect(context.locale.length).toBeGreaterThan(0);
});
it("still returns a usable context for an unknown zone", () => {
const context = getLocationContext(new Date("2026-01-02T03:04:05Z"), "Not/AReal_Zone");
expect(context.timezone).toBe("Not/AReal_Zone");
expect(context.utcOffset).toBe("UTC");
});
});
+117
View File
@@ -0,0 +1,117 @@
import { describe, expect, it } from "vitest";
import {
STREAMING_CURSOR,
buildTurnMarkdown,
pendingMarkdown,
previewText,
sessionToPlainText,
} from "../lib/markdown";
import type { ChatTurn } 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" });
expect(markdown).toContain("> What is 2+2?");
expect(markdown.indexOf("> What is 2+2?")).toBeLessThan(markdown.indexOf("4"));
});
it("keeps a multi-line question inside the quote block", () => {
const markdown = buildTurnMarkdown({ prompt: "line one\nline two", response: "ok" });
expect(markdown).toContain("> line one\n> line two");
});
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 });
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("surfaces an error instead of hiding it", () => {
const markdown = buildTurnMarkdown({ prompt: "q", response: "", 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 },
],
});
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…_");
});
});
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);
});
});
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", () => {
const text = sessionToPlainText([turn("q", "", "Ollama rejected the API key.")]);
expect(text).toContain("Ollama rejected the API key.");
});
});
+86
View File
@@ -0,0 +1,86 @@
import { describe, expect, it } from "vitest";
import { normalizeHost, parseNdjsonStream } from "../lib/ollama";
/**
* The NDJSON reader is the part of the client most likely to break in a way
* that is invisible locally and obvious in production: a chunk boundary landing
* mid-character or mid-line. These cases cover exactly that.
*/
async function collect<T>(chunks: Uint8Array[]): Promise<T[]> {
const stream = new ReadableStream<Uint8Array>({
start(controller) {
for (const chunk of chunks) controller.enqueue(chunk);
controller.close();
},
});
const out: T[] = [];
for await (const item of parseNdjsonStream<T>(stream)) out.push(item);
return out;
}
function encode(text: string): Uint8Array {
return new TextEncoder().encode(text);
}
describe("normalizeHost", () => {
it("falls back to Ollama Cloud when unset", () => {
expect(normalizeHost(undefined)).toBe("https://ollama.com");
expect(normalizeHost("")).toBe("https://ollama.com");
expect(normalizeHost(" ")).toBe("https://ollama.com");
});
it("strips trailing slashes so endpoint paths do not double up", () => {
expect(normalizeHost("http://localhost:11434/")).toBe("http://localhost:11434");
expect(normalizeHost("https://ollama.com///")).toBe("https://ollama.com");
});
it("trims surrounding whitespace", () => {
expect(normalizeHost(" https://ollama.com ")).toBe("https://ollama.com");
});
});
describe("parseNdjsonStream", () => {
it("parses one object per line", async () => {
const result = await collect<{ n: number }>([encode('{"n":1}\n{"n":2}\n{"n":3}\n')]);
expect(result).toEqual([{ n: 1 }, { n: 2 }, { n: 3 }]);
});
it("reassembles objects split across chunk boundaries", async () => {
// The same payload, byte-sliced in awkward places. Concatenated, the chunks
// read: '{"message":{"content":"hello"}}\n{"message":{"content":" world"}}\n'
const result = await collect<{ message: { content: string } }>([
encode('{"message":{"con'),
encode('tent":"hel'),
encode('lo"}}\n{"message'),
encode('":{"content":" world"}}\n'),
]);
expect(result.map((event) => event.message.content)).toEqual(["hello", " world"]);
});
it("reassembles a multi-byte character split across chunks", async () => {
const payload = `${JSON.stringify({ text: "café ☕ — ok" })}\n`;
const bytes = encode(payload);
// Split inside the first multi-byte sequence (the "é").
const cut = payload.indexOf("é") + 1;
const result = await collect<{ text: string }>([bytes.slice(0, cut), bytes.slice(cut)]);
expect(result).toEqual([{ text: "café ☕ — ok" }]);
});
it("emits a trailing line that was never newline-terminated", async () => {
const result = await collect<{ n: number }>([encode('{"n":1}\n{"n":2}')]);
expect(result).toEqual([{ n: 1 }, { n: 2 }]);
});
it("skips blank lines", async () => {
const result = await collect<{ n: number }>([encode('{"n":1}\n\n\n{"n":2}\n\n')]);
expect(result).toEqual([{ n: 1 }, { n: 2 }]);
});
it("yields nothing for an empty stream", async () => {
expect(await collect<unknown>([])).toEqual([]);
});
});
+161
View File
@@ -0,0 +1,161 @@
import { beforeEach, describe, expect, it } from "vitest";
import { __resetLocalStorage } from "../__mocks__/@vicinae/api";
import { MAX_STORED_CHARS_PER_TURN, MAX_TURNS_PER_SESSION } from "../lib/constants";
import {
capTurn,
capTurns,
createSession,
createTurn,
clearSessions,
deleteSession,
listSessions,
loadSession,
saveSession,
withTitleFromFirstTurn,
} from "../lib/sessions";
import type { ChatTurn } from "../lib/types";
function turn(index: number, response = `answer ${index}`): ChatTurn {
return {
id: `turn-${index}`,
prompt: `question ${index}`,
response,
thinking: "",
model: "test-model",
createdAt: 1_000 + index,
toolCalls: [],
};
}
beforeEach(() => {
__resetLocalStorage();
});
describe("session store", () => {
it("round-trips a session through LocalStorage", async () => {
const session = { ...createSession("test-model", 1_000), title: "First chat", turns: [turn(1)] };
await saveSession(session);
const summaries = await listSessions();
expect(summaries).toHaveLength(1);
expect(summaries[0]).toMatchObject({ id: session.id, title: "First chat", turnCount: 1 });
const loaded = await loadSession(session.id);
expect(loaded?.turns[0].response).toBe("answer 1");
expect(loaded?.model).toBe("test-model");
});
it("returns undefined for an unknown session id", async () => {
expect(await loadSession("does-not-exist")).toBeUndefined();
});
it("orders sessions by most recently updated", async () => {
const older = { ...createSession("m", 1_000), title: "older", updatedAt: 1_000 };
const newer = { ...createSession("m", 2_000), title: "newer", updatedAt: 5_000 };
await saveSession(older);
await saveSession(newer);
const summaries = await listSessions();
expect(summaries.map((summary) => summary.title)).toEqual(["newer", "older"]);
});
it("updates an existing index entry instead of duplicating it", async () => {
const session = createSession("m", 1_000);
await saveSession(session);
await saveSession({ ...session, title: "renamed", updatedAt: 2_000 });
const summaries = await listSessions();
expect(summaries).toHaveLength(1);
expect(summaries[0].title).toBe("renamed");
});
it("deletes a session and its index entry", async () => {
const session = createSession("m");
await saveSession(session);
await deleteSession(session.id);
expect(await listSessions()).toHaveLength(0);
expect(await loadSession(session.id)).toBeUndefined();
});
it("clears everything", async () => {
await saveSession(createSession("m", 1));
await saveSession(createSession("m", 2));
await clearSessions();
expect(await listSessions()).toHaveLength(0);
});
it("survives a corrupt index rather than throwing", async () => {
const { LocalStorage } = await import("../__mocks__/@vicinae/api");
await LocalStorage.setItem("lam.sessions.index", "{not json");
await expect(listSessions()).resolves.toEqual([]);
});
it("returns undefined for a corrupt session body", async () => {
const { LocalStorage } = await import("../__mocks__/@vicinae/api");
await LocalStorage.setItem("lam.session.broken", '{"id":"broken"}');
await expect(loadSession("broken")).resolves.toBeUndefined();
});
});
describe("caps", () => {
it("keeps only the most recent turns", () => {
const many = Array.from({ length: MAX_TURNS_PER_SESSION + 5 }, (_, index) => turn(index));
const capped = capTurns(many);
expect(capped).toHaveLength(MAX_TURNS_PER_SESSION);
// The oldest five should be the ones dropped, not the newest.
expect(capped[0].id).toBe("turn-5");
expect(capped[capped.length - 1].id).toBe(`turn-${MAX_TURNS_PER_SESSION + 4}`);
});
it("truncates an oversized turn and marks it", () => {
const huge = turn(1, "x".repeat(MAX_STORED_CHARS_PER_TURN + 500));
const capped = capTurn(huge);
expect(capped.response.length).toBeLessThan(MAX_STORED_CHARS_PER_TURN + 100);
expect(capped.response).toContain("[truncated]");
});
it("applies caps on save so storage cannot grow without bound", async () => {
const session = {
...createSession("m", 1_000),
turns: Array.from({ length: MAX_TURNS_PER_SESSION + 10 }, (_, index) => turn(index)),
};
await saveSession(session);
const loaded = await loadSession(session.id);
expect(loaded?.turns).toHaveLength(MAX_TURNS_PER_SESSION);
});
});
describe("withTitleFromFirstTurn", () => {
it("names the session from its first prompt", () => {
const session = { ...createSession("m", 1_000), turns: [turn(1)] };
expect(withTitleFromFirstTurn(session).title).toBe("question 1");
});
it("leaves an already-named session alone", () => {
const session = { ...createSession("m", 1_000), title: "Kept", turns: [turn(1)] };
expect(withTitleFromFirstTurn(session).title).toBe("Kept");
});
it("leaves an empty session unnamed", () => {
expect(withTitleFromFirstTurn(createSession("m", 1_000)).title).toBe("New chat");
});
});
describe("createTurn", () => {
it("starts with an empty response so the UI can stream into it", () => {
const created = createTurn("hello", "test-model", 1_000);
expect(created.response).toBe("");
expect(created.prompt).toBe("hello");
expect(created.toolCalls).toEqual([]);
});
});
+65
View File
@@ -0,0 +1,65 @@
import { describe, expect, it } from "vitest";
import { errorMessage, formatRelativeTime, titleFromPrompt, truncate } from "../lib/utils";
describe("truncate", () => {
it("collapses whitespace so list rows never wrap unexpectedly", () => {
expect(truncate("hello\n\n world", 40)).toBe("hello world");
});
it("leaves short text alone", () => {
expect(truncate("short", 40)).toBe("short");
});
it("clips with an ellipsis at the requested width", () => {
const result = truncate("a".repeat(100), 10);
expect(result).toBe(`${"a".repeat(9)}…`);
expect(result).toHaveLength(10);
});
});
describe("formatRelativeTime", () => {
const now = 1_700_000_000_000;
it("reports very recent timestamps as just now", () => {
expect(formatRelativeTime(now - 5_000, now)).toBe("just now");
});
it("reports minutes and hours", () => {
expect(formatRelativeTime(now - 12 * 60_000, now)).toBe("12m ago");
expect(formatRelativeTime(now - 3 * 3_600_000, now)).toBe("3h ago");
});
it("reports days within the last week", () => {
expect(formatRelativeTime(now - 2 * 86_400_000, now)).toBe("2d ago");
});
it("treats clock skew as just now rather than a negative age", () => {
expect(formatRelativeTime(now + 60_000, now)).toBe("just now");
});
});
describe("titleFromPrompt", () => {
it("uses the first non-empty line, stripped of markdown decoration", () => {
expect(titleFromPrompt("\n\n## How do I deploy?\nmore text", 60)).toBe("How do I deploy?");
});
it("falls back to a placeholder for an empty prompt", () => {
expect(titleFromPrompt(" \n ", 60)).toBe("New chat");
});
});
describe("errorMessage", () => {
it("unwraps Error instances", () => {
expect(errorMessage(new Error("boom"))).toBe("boom");
});
it("passes strings through", () => {
expect(errorMessage("plain failure")).toBe("plain failure");
});
it("survives values that cannot be serialised", () => {
const circular: Record<string, unknown> = {};
circular.self = circular;
expect(typeof errorMessage(circular)).toBe("string");
});
});