feat: initial Lam Agent extension (#1)
This commit was merged in pull request #1.
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