feat: initial Lam Agent extension for Vicinae
Agentic AI chat inside the Vicinae launcher, powered by Ollama. - chat: the search bar is the composer (Return sends), cmd+return opens a multi-line composer, answers stream into a markdown detail panel, the newest turn renders first, and Tab reaches a conversation switcher in the search bar accessory - sessions: browse, reopen, copy, and delete past conversations - setup: verifies the Ollama connection and lists the models the key can actually reach, rather than trusting a hand-typed model name - tools: get_datetime, get_location, web_search, web_fetch. All read-only, and the tool-calling loop is capped at 5 rounds Notes on two constraints that shaped the design: The search bar is single-line and there is no API to change it, so "Return sends" and "cmd+return inserts a newline" cannot both live in one field. They are split across the search bar and a pushed Form instead. An extension can read preferences but cannot write them, so setup hands over the exact model name to paste rather than saving it. The logic layer (ollama, agent, tools, location, history, markdown) holds no Vicinae imports and is unit tested without a runtime; a test enforces that boundary. 77 tests cover NDJSON chunk-boundary parsing, the tool loop, session caps, and timezone handling.
This commit is contained in:
@@ -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"/);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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.");
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user