Files
lam-agent-vicinae/src/__tests__/sessions.test.ts
T
adrianbonpin 5e083d8142 feat: snip a region into chat, and make links clickable
Two changes: screenshot input gated on the model really reporting vision,
and a fix for URLs that arrived as code.

Links
- A fence whose body is all URLs, a lone `https://...` inline span, and a
  line that is only a URL are rewritten into [url](url) markdown links.
  Vicinae attaches only the table and strikethrough GFM extensions, so
  autolink is off and a bare URL is dead text; clickability comes from
  MarkdownModel turning CMARK_NODE_LINK into an <a href>. A fenced URL was
  a dark panel with a Copy button and no way to open it.
- promoteStandaloneCodeSpans was itself promoting a lone URL span into a
  fence, so it made this worse. Link restoration now runs first.
- The default prompt says never to fence a URL.
- Anything that is not one of those three shapes is copied through, so a
  curl invocation containing an address is never rewritten.

Screenshots
- capture.ts shells out to an auto-detected tool: grimblast, grim + slurp,
  spectacle, maim, scrot, flameshot, or screencapture on macOS. There is
  no screenshot call in @vicinae/api and Clipboard moves no binary, so the
  extension owns the capture and reads the file it wrote rather than
  guessing at the clipboard. Cancel is detected by whether an image landed
  on disk, because the tools disagree on exit codes.
- In chat: "Snip a Region" in the action panel (cmd+shift+S) attaches to
  the current conversation. Standalone: "Snip to Lam" starts a new one.
- vision.ts caches the /api/show capability answer per host and model. A
  failed check is "unknown", never "no", so a dropped connection is not
  reported as a fact about the user's model. Actions still fail closed.
- Only the path is stored, base64 is read at send time, and the image goes
  on its own turn only. Capture is never a model-callable tool.

Verified: 244 tests, tsc, eslint, and vici build (both entrypoints).
2026-10-04 15:13:15 +08:00

163 lines
5.3 KiB
TypeScript

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: [],
attachments: [],
};
}
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([]);
});
});