feat: snip a region into chat, and make links clickable (#14)

This commit was merged in pull request #14.
This commit is contained in:
2026-10-05 08:38:50 +00:00
parent f5d1ced4d6
commit 576c5946f7
44 changed files with 3890 additions and 120 deletions
+103 -5
View File
@@ -2,6 +2,7 @@ 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 { MAX_TOOL_ITERATIONS } from "../lib/constants";
import { OllamaError } from "../lib/ollama";
import { toolsFor } from "../lib/tools";
import type { ApiMessage, ToolCall } from "../lib/types";
@@ -136,15 +137,102 @@ describe("runAgent", () => {
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++) {
for (let i = 0; i < MAX_TOOL_ITERATIONS + 2; 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);
expect(result.toolCalls).toHaveLength(MAX_TOOL_ITERATIONS);
// Every tool round, plus one closing round with the tools taken away.
expect(fetchMock).toHaveBeenCalledTimes(MAX_TOOL_ITERATIONS + 1);
// The closing round produced no text either, so the notice is the last resort.
expect(result.content).toContain("Ran out of tool calls");
});
it("gives the model a closing round so a spent budget still ends in an answer", async () => {
// The reported failure: a broad research question used up the budget and the
// turn ended with a notice about the loop instead of an answer.
for (let i = 0; i < MAX_TOOL_ITERATIONS; i++) {
fetchMock.mockResolvedValueOnce(ndjsonResponse([toolCall("web_search")]));
}
fetchMock.mockResolvedValueOnce(
ndjsonResponse(answer("Meadows have Perch, at 2 fish each. I could not confirm the others.")),
);
const result = await runAgent(baseOptions([{ role: "user", content: "what fish are in valheim" }]));
expect(result.content).toContain("Meadows have Perch");
expect(result.content).not.toContain("Ran out of tool calls");
});
it("takes the tools away for the closing round, so it writes rather than searches again", async () => {
for (let i = 0; i < MAX_TOOL_ITERATIONS; i++) {
fetchMock.mockResolvedValueOnce(ndjsonResponse([toolCall("web_search")]));
}
fetchMock.mockResolvedValueOnce(ndjsonResponse(answer("Here is what I found.")));
await runAgent(baseOptions([{ role: "user", content: "hi" }]));
const first = JSON.parse(fetchMock.mock.calls[0][1].body as string) as { tools?: unknown };
const last = JSON.parse(fetchMock.mock.calls[MAX_TOOL_ITERATIONS][1].body as string) as {
tools?: unknown;
};
expect(first.tools).toBeDefined();
expect(last.tools).toBeUndefined();
});
it("asks the closing round to name what it could not confirm", async () => {
for (let i = 0; i < MAX_TOOL_ITERATIONS; i++) {
fetchMock.mockResolvedValueOnce(ndjsonResponse([toolCall("web_search")]));
}
fetchMock.mockResolvedValueOnce(ndjsonResponse(answer("ok")));
await runAgent(baseOptions([{ role: "user", content: "hi" }]));
const last = JSON.parse(fetchMock.mock.calls[MAX_TOOL_ITERATIONS][1].body as string) as {
messages: ApiMessage[];
};
const nudge = last.messages[last.messages.length - 1];
expect(nudge.role).toBe("user");
expect(nudge.content).toContain("no more tools are available");
expect(nudge.content).toContain("could not confirm");
});
it("falls back to the last preamble when the closing round comes back empty", async () => {
// A preamble is not normally the answer, but a turn that gathered things
// should still say more than a notice about its own loop.
const preamble = {
message: {
role: "assistant",
content: "Let me look that up.",
tool_calls: [{ function: { name: "web_search", arguments: {} } }],
},
done: true,
};
for (let i = 0; i < MAX_TOOL_ITERATIONS; i++) {
fetchMock.mockResolvedValueOnce(ndjsonResponse([preamble]));
}
fetchMock.mockResolvedValueOnce(ndjsonResponse([{ message: { role: "assistant", content: "" }, done: true }]));
const result = await runAgent(baseOptions([{ role: "user", content: "hi" }]));
expect(result.content).toBe("Let me look that up.");
});
it("still reports the tools it used when the budget runs out", async () => {
for (let i = 0; i < MAX_TOOL_ITERATIONS; i++) {
fetchMock.mockResolvedValueOnce(ndjsonResponse([toolCall("get_datetime")]));
}
fetchMock.mockResolvedValueOnce(ndjsonResponse(answer("done")));
const result = await runAgent(baseOptions([{ role: "user", content: "hi" }]));
expect(result.toolCalls).toHaveLength(MAX_TOOL_ITERATIONS);
expect(result.toolCalls.every((call) => call.name === "get_datetime")).toBe(true);
});
it("surfaces an actionable hint when the API key is rejected", async () => {
@@ -165,7 +253,17 @@ describe("module wiring", () => {
// 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"]) {
for (const file of [
"agent.ts",
"ollama.ts",
"tools.ts",
"location.ts",
"history.ts",
"markdown.ts",
"capture.ts",
"attachments.ts",
"startup.ts",
]) {
const source = readFileSync(path.resolve(__dirname, `../lib/${file}`), "utf8");
expect(source, `${file} should not import @vicinae/api`).not.toMatch(/from\s+"@vicinae\/api"/);
}
+177
View File
@@ -0,0 +1,177 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { imageSource, mimeForPath, pngSize, previewBox, readAttachment } from "../lib/attachments";
const tempDirs: string[] = [];
/** A 24-byte PNG header, which is all `pngSize` reads. */
function pngHeader(width: number, height: number): Buffer {
const bytes = Buffer.alloc(24);
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]).copy(bytes, 0);
bytes.writeUInt32BE(13, 8);
bytes.write("IHDR", 12, "ascii");
bytes.writeUInt32BE(width, 16);
bytes.writeUInt32BE(height, 20);
return bytes;
}
async function tempDir(): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), "lam-attach-"));
tempDirs.push(dir);
return dir;
}
afterEach(async () => {
await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
});
describe("mimeForPath", () => {
it("names the image types Ollama accepts", () => {
expect(mimeForPath("/tmp/snip.png")).toBe("image/png");
expect(mimeForPath("/tmp/photo.JPG")).toBe("image/jpeg");
expect(mimeForPath("/tmp/photo.jpeg")).toBe("image/jpeg");
expect(mimeForPath("/tmp/shot.webp")).toBe("image/webp");
});
it("does not guess a type for anything else", () => {
expect(mimeForPath("/tmp/notes.txt")).toBe("application/octet-stream");
expect(mimeForPath("/tmp/noextension")).toBe("application/octet-stream");
});
});
describe("pngSize", () => {
it("reads the dimensions out of the IHDR chunk", () => {
expect(pngSize(pngHeader(1600, 400))).toEqual({ width: 1600, height: 400 });
});
it("refuses anything that is not a PNG", () => {
// Guessing at another format would put a wrong aspect ratio into the layout.
expect(pngSize(Buffer.from("not a png at all, just some text"))).toBeUndefined();
expect(pngSize(Buffer.alloc(0))).toBeUndefined();
});
it("refuses a header that is present but truncated", () => {
expect(pngSize(pngHeader(10, 10).subarray(0, 20))).toBeUndefined();
});
it("refuses degenerate dimensions", () => {
expect(pngSize(pngHeader(0, 100))).toBeUndefined();
});
});
describe("previewBox", () => {
it("fits a wide capture to the width limit and keeps the aspect ratio", () => {
// This is the case that produced the dead space: 4:1 at full pane width was
// letterboxed into a 200px-tall container it barely used.
expect(previewBox(1600, 400)).toEqual({ width: 400, height: 100 });
});
it("fits a 16:9 capture to the height limit instead", () => {
expect(previewBox(1920, 1080)).toEqual({ width: 356, height: 200 });
});
it("fits a portrait capture by its height", () => {
expect(previewBox(400, 1600)).toEqual({ width: 50, height: 200 });
});
it("does not blow a small capture up into a blurry one", () => {
expect(previewBox(100, 50)).toEqual({ width: 100, height: 50 });
});
it("has nothing to say when the size is unknown", () => {
expect(previewBox(undefined, undefined)).toBeUndefined();
expect(previewBox(1600, undefined)).toBeUndefined();
expect(previewBox(1600, 0)).toBeUndefined();
});
});
describe("imageSource", () => {
it("produces a file URL, which is the only scheme the renderer resolves for a local file", () => {
expect(imageSource({ path: "/tmp/snip.png" })).toBe("file:///tmp/snip.png");
});
it("escapes a path with a space, which the user's own screenshot directory has", () => {
expect(imageSource({ path: "/Users/x/Pictures/Screenshot 2026-10-04.png" })).toBe(
"file:///Users/x/Pictures/Screenshot%202026-10-04.png",
);
});
it("escapes non-ascii", () => {
expect(imageSource({ path: "/tmp/caf\u00e9.png" })).toBe("file:///tmp/caf%C3%A9.png");
});
it("keeps a bare path out of the markdown, because that resolves to nothing", () => {
expect(imageSource({ path: "/tmp/snip.png" })).not.toBe("/tmp/snip.png");
});
it("carries the size hints the renderer reads", () => {
// `parseImageSize` looks for these names, and resolves the path from the URL
// before the query, so the hints never reach the file name.
expect(imageSource({ path: "/tmp/snip.png", width: 1600, height: 400 })).toBe(
"file:///tmp/snip.png?raycast-width=400&raycast-height=100",
);
});
it("omits the hints when the dimensions are unknown", () => {
expect(imageSource({ path: "/tmp/snip.png", width: undefined, height: undefined })).toBe(
"file:///tmp/snip.png",
);
});
});
describe("readAttachment", () => {
it("reads the bytes as bare base64, with no data URL prefix", async () => {
const dir = await tempDir();
const path = join(dir, "snip.png");
await writeFile(path, Buffer.from([0x89, 0x50, 0x4e, 0x47]));
const attachment = await readAttachment(path);
// Ollama wants the payload bare. A `data:image/png;base64,` prefix makes it
// reject the image, and the mistake is invisible until a request comes back
// with an error that says nothing about encoding.
expect(attachment.base64).toBe("iVBORw==");
expect(attachment.base64).not.toContain("data:");
expect(attachment.mime).toBe("image/png");
expect(attachment.path).toBe(path);
});
it("round-trips a payload larger than one base64 padding boundary", async () => {
const dir = await tempDir();
const path = join(dir, "snip.png");
const bytes = Buffer.alloc(300, 7);
await writeFile(path, bytes);
const attachment = await readAttachment(path);
expect(Buffer.from(attachment.base64, "base64")).toEqual(bytes);
});
it("keeps the dimensions, which the transcript needs long after this returns", async () => {
const dir = await tempDir();
const path = join(dir, "snip.png");
await writeFile(path, pngHeader(1600, 400));
const attachment = await readAttachment(path);
expect(attachment.width).toBe(1600);
expect(attachment.height).toBe(400);
});
it("omits the dimensions for an image it cannot measure", async () => {
const dir = await tempDir();
const path = join(dir, "snip.png");
await writeFile(path, Buffer.from([0x89, 0x50, 0x4e, 0x47]));
const attachment = await readAttachment(path);
expect(attachment.width).toBeUndefined();
expect(attachment.height).toBeUndefined();
});
it("fails loudly when the file is gone", async () => {
await expect(readAttachment("/nonexistent/snip.png")).rejects.toThrow();
});
});
+329
View File
@@ -0,0 +1,329 @@
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
CAPTURE_BACKENDS,
CapturePermissionError,
CaptureToolError,
NoCaptureToolError,
captureRegion,
selectBackend,
snipFileName,
type CaptureBackend,
} from "../lib/capture";
/** A `PATH` containing exactly the named tools. */
function pathWith(...tools: string[]) {
const present = new Set(tools);
return async (tool: string) => present.has(tool);
}
function backendNamed(name: string): CaptureBackend {
const backend = CAPTURE_BACKENDS.find((candidate) => candidate.name === name);
if (!backend) throw new Error(`no backend named ${name}`);
return backend;
}
/** A tool that ran, said nothing, and produced no image. */
function silent(): { stdout: Buffer; stderr: string } {
return { stdout: Buffer.alloc(0), stderr: "" };
}
const tempDirs: string[] = [];
async function tempDir(): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), "lam-snip-"));
tempDirs.push(dir);
return dir;
}
afterEach(async () => {
await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
});
describe("snipFileName", () => {
it("is sortable and filesystem-safe", () => {
expect(snipFileName(new Date(2026, 9, 4, 14, 59, 26))).toBe("snip-20261004-145926.png");
});
it("zero-pads every field so names sort as text", () => {
expect(snipFileName(new Date(2026, 0, 1, 0, 0, 0))).toBe("snip-20260101-000000.png");
});
});
describe("selectBackend", () => {
it("returns nothing when no tool is installed", async () => {
expect(await selectBackend(pathWith())).toBeUndefined();
});
it("prefers grimblast on a Hyprland machine", async () => {
const backend = await selectBackend(pathWith("grimblast", "grim", "slurp", "maim"));
expect(backend?.name).toBe("grimblast");
});
it("falls back to grim + slurp when grimblast is absent", async () => {
const backend = await selectBackend(pathWith("grim", "slurp", "maim"));
expect(backend?.name).toBe("grim + slurp");
});
it("needs both halves of a pair, not just one", async () => {
// grim without slurp cannot select a region, so it must not be chosen.
const backend = await selectBackend(pathWith("grim", "maim"));
expect(backend?.name).toBe("maim");
});
it("falls back through the X11 tools", async () => {
expect((await selectBackend(pathWith("maim")))?.name).toBe("maim");
expect((await selectBackend(pathWith("scrot")))?.name).toBe("scrot");
expect((await selectBackend(pathWith("flameshot")))?.name).toBe("flameshot");
});
it("recognises the macOS built-in tool", async () => {
expect((await selectBackend(pathWith("screencapture")))?.name).toBe("screencapture");
});
it("can be given a shorter candidate list, which is how macOS is restricted", async () => {
const onlyScreencapture = CAPTURE_BACKENDS.filter((b) => b.name === "screencapture");
const backend = await selectBackend(pathWith("screencapture", "maim"), onlyScreencapture);
expect(backend?.name).toBe("screencapture");
});
});
describe("backend commands", () => {
it("runs slurp before grim and passes the geometry through", async () => {
const calls: Array<{ tool: string; args: string[] }> = [];
const run = vi.fn(async (tool: string, args: string[]) => {
calls.push({ tool, args });
return tool === "slurp" ? { stdout: Buffer.from("120,340 800x600\n"), stderr: "" } : silent();
});
await backendNamed("grim + slurp").capture({ output: "/tmp/snip.png", run });
expect(calls).toEqual([
{ tool: "slurp", args: [] },
{ tool: "grim", args: ["-g", "120,340 800x600", "/tmp/snip.png"] },
]);
});
it("does not shell out to grim when the selection was cancelled", async () => {
const run = vi.fn(async () => silent());
await backendNamed("grim + slurp").capture({ output: "/tmp/snip.png", run });
expect(run).toHaveBeenCalledTimes(1);
expect(run).toHaveBeenCalledWith("slurp", [], expect.anything());
});
it("keeps a shell pipeline out of grim by using argv", () => {
// `grim -g "$(slurp)"` is the documented one-liner, but a shell here would
// be a needless injection surface and would not survive a path with spaces.
expect(backendNamed("grim + slurp").requires).toEqual(["grim", "slurp"]);
});
it("writes flameshot's stdout, because it refuses an output path", async () => {
const dir = await tempDir();
const output = join(dir, "snip.png");
const png = Buffer.from([0x89, 0x50, 0x4e, 0x47]);
await backendNamed("flameshot").capture({ output, run: async () => ({ stdout: png, stderr: "" }) });
expect(await readFile(output)).toEqual(png);
});
it("writes nothing when flameshot returned no image", async () => {
const dir = await tempDir();
const output = join(dir, "snip.png");
await backendNamed("flameshot").capture({ output, run: async () => silent() });
await expect(readFile(output)).rejects.toThrow();
});
it("asks screencapture for an interactive region and no sound", async () => {
const run = vi.fn(async () => silent());
await backendNamed("screencapture").capture({ output: "/tmp/snip.png", run });
expect(run).toHaveBeenCalledWith("screencapture", ["-i", "-x", "/tmp/snip.png"], expect.anything());
});
});
describe("captureRegion", () => {
/** A `run` that writes a real file, which is what the success path checks for. */
function writingRun(bytes = Buffer.from([0x89, 0x50])) {
return async (_tool: string, args: string[]) => {
await writeFile(args[args.length - 1], bytes);
return silent();
};
}
it("writes into the directory it was given and reports the path", async () => {
const dir = await tempDir();
const output = join(dir, "snip-20261004-145926.png");
const result = await captureRegion({
outputDir: dir,
platform: "darwin",
now: new Date(2026, 9, 4, 14, 59, 26),
hasCommand: pathWith("screencapture"),
run: writingRun(Buffer.from([0x89, 0x50])),
});
expect(result).toEqual({ path: output, bytes: 2, backend: "screencapture" });
});
it("creates the output directory on first use", async () => {
const dir = await tempDir();
const nested = join(dir, "snips");
const now = new Date(2026, 9, 4, 14, 59, 26);
await captureRegion({
outputDir: nested,
platform: "darwin",
now,
hasCommand: pathWith("screencapture"),
run: writingRun(Buffer.from([0x89])),
});
expect((await readFile(join(nested, snipFileName(now)))).length).toBeGreaterThan(0);
});
it("returns null when the user backed out and said nothing", async () => {
const dir = await tempDir();
const result = await captureRegion({
outputDir: dir,
platform: "darwin",
hasCommand: pathWith("screencapture"),
run: async () => silent(),
});
expect(result).toBeNull();
});
it("treats an empty file as a cancel rather than an attachment", async () => {
// At least one tool exits 0 on cancel, so exit codes cannot be trusted. A
// zero-byte PNG would be sent to the model as a broken image.
const dir = await tempDir();
const result = await captureRegion({
outputDir: dir,
platform: "darwin",
hasCommand: pathWith("screencapture"),
run: writingRun(Buffer.alloc(0)),
});
expect(result).toBeNull();
});
it("still succeeds when the tool also complained on stderr", async () => {
// Some tools warn on stderr and write the image anyway. The file is the
// source of truth, so a warning must not turn a good capture into an error.
const dir = await tempDir();
const result = await captureRegion({
outputDir: dir,
platform: "darwin",
hasCommand: pathWith("screencapture"),
run: async (_tool, args) => {
await writeFile(args[args.length - 1], Buffer.from([0x89]));
return { stdout: Buffer.alloc(0), stderr: "warning: something\n" };
},
});
expect(result?.bytes).toBe(1);
});
describe("macOS Screen Recording permission", () => {
// The message is the only difference between a refused permission and a
// cancelled selection: both leave no file behind, and screencapture has been
// reported to exit 0 when the permission is missing.
const denial = "could not create image from rect\n";
it("reports a permission problem instead of a cancel when the tool exits non-zero", async () => {
const dir = await tempDir();
await expect(
captureRegion({
outputDir: dir,
platform: "darwin",
hasCommand: pathWith("screencapture"),
run: async () => {
throw new CaptureToolError("screencapture", denial);
},
}),
).rejects.toThrow(CapturePermissionError);
});
it("reports a permission problem even when the tool exits 0", async () => {
// The documented exit-0 shape. Without reading stderr this is
// indistinguishable from Escape, which is how a silently useless feature
// gets shipped.
const dir = await tempDir();
await expect(
captureRegion({
outputDir: dir,
platform: "darwin",
hasCommand: pathWith("screencapture"),
run: async () => ({ stdout: Buffer.alloc(0), stderr: denial }),
}),
).rejects.toThrow(CapturePermissionError);
});
it("names Vicinae rather than the extension, because that is who holds the grant", async () => {
const dir = await tempDir();
const attempt = () =>
captureRegion({
outputDir: dir,
platform: "darwin",
hasCommand: pathWith("screencapture"),
run: async () => {
throw new CaptureToolError("screencapture", denial);
},
});
await expect(attempt()).rejects.toMatchObject({
name: "CapturePermissionError",
hint: expect.stringContaining("Vicinae"),
});
await expect(attempt()).rejects.toMatchObject({
hint: expect.stringContaining("Screen Recording"),
});
});
});
it("reports any other explanation rather than swallowing it", async () => {
const dir = await tempDir();
await expect(
captureRegion({
outputDir: dir,
platform: "linux",
hasCommand: pathWith("maim"),
run: async () => {
throw new CaptureToolError("maim", "maim: cannot open display\n");
},
}),
).rejects.toThrow(CaptureToolError);
});
it("explains what to install when nothing is available", async () => {
const dir = await tempDir();
await expect(
captureRegion({ outputDir: dir, platform: "linux", hasCommand: pathWith() }),
).rejects.toThrow(NoCaptureToolError);
});
it("does not invent a backend on macOS", async () => {
const dir = await tempDir();
// maim is available in this fake PATH but is meaningless on darwin.
await expect(
captureRegion({ outputDir: dir, platform: "darwin", hasCommand: pathWith("maim") }),
).rejects.toThrow(NoCaptureToolError);
});
});
+198
View File
@@ -4,6 +4,8 @@ import {
labelCodeFences,
promoteStandaloneCodeSpans,
repairCodeBlocks,
restoreUrlLinks,
separateTables,
} from "../lib/code";
describe("inferLanguage", () => {
@@ -132,11 +134,207 @@ describe("promoteStandaloneCodeSpans", () => {
});
});
describe("restoreUrlLinks", () => {
it("unwraps a fence that is nothing but URLs", () => {
// The reported case: "here are the links" arriving as a code block, so the
// one thing a URL is for, being clicked, does not work.
const input = [
"```",
"https://github.com/jasonkneen/openclicky",
"https://github.com/unn-Known1/clickyX",
"```",
].join("\n");
expect(restoreUrlLinks(input)).toBe(
[
"[https://github.com/jasonkneen/openclicky](https://github.com/jasonkneen/openclicky)",
"[https://github.com/unn-Known1/clickyX](https://github.com/unn-Known1/clickyX)",
].join("\n"),
);
});
it("unwraps a URL fence even when the model labelled it", () => {
expect(restoreUrlLinks("```bash\nhttps://example.com\n```")).toBe(
"[https://example.com](https://example.com)",
);
});
it("links a bare URL standing alone on a line", () => {
expect(restoreUrlLinks("https://example.com")).toBe("[https://example.com](https://example.com)");
});
it("links a lone inline span holding a URL", () => {
expect(restoreUrlLinks("`https://example.com`")).toBe(
"[https://example.com](https://example.com)",
);
});
it("keeps a bulleted list of links a bulleted list", () => {
const input = "- https://a.example\n- https://b.example";
expect(restoreUrlLinks(input)).toBe(
"- [https://a.example](https://a.example)\n- [https://b.example](https://b.example)",
);
});
it("keeps an ordered list marker", () => {
expect(restoreUrlLinks("1. https://example.com")).toBe(
"1. [https://example.com](https://example.com)",
);
});
it("drops sentence punctuation that is not part of the address", () => {
expect(restoreUrlLinks("See this:\n\nhttps://example.com.")).toContain(
"[https://example.com](https://example.com)",
);
});
it("brackets a destination that contains parentheses", () => {
const url = "https://en.wikipedia.org/wiki/Foo_(bar)";
expect(restoreUrlLinks(url)).toBe(`[${url}](<${url}>)`);
});
it("escapes brackets in the label, which an IPv6 literal contains", () => {
// Unescaped, the `]` in the label ends the link text early and the rest of
// the address is left as prose outside the link.
expect(restoreUrlLinks("http://[::1]:8080/")).toBe(
"[http://\\[::1\\]:8080/](http://[::1]:8080/)",
);
});
it("never touches a fence holding real code", () => {
const input = "```bash\ncurl https://example.com | jq .\n```";
expect(restoreUrlLinks(input)).toBe(input);
});
it("never touches a fence when one line is not a URL", () => {
const input = "```\nhttps://example.com\nnot a url\n```";
expect(restoreUrlLinks(input)).toBe(input);
});
it("leaves a URL embedded in a sentence alone", () => {
const input = "The docs at https://example.com cover it.";
expect(restoreUrlLinks(input)).toBe(input);
});
it("leaves a markdown link the model already wrote alone", () => {
const input = "[OpenClickly](https://github.com/jasonkneen/openclicky)";
expect(restoreUrlLinks(input)).toBe(input);
});
it("preserves a fence that is never closed and is real code", () => {
expect(restoreUrlLinks("```\ngit log")).toBe("```\ngit log");
});
it("is idempotent", () => {
const once = restoreUrlLinks("```\nhttps://example.com\n```");
expect(restoreUrlLinks(once)).toBe(once);
});
it("is a no-op on ordinary prose", () => {
const input = "There are no addresses in this sentence.";
expect(restoreUrlLinks(input)).toBe(input);
});
});
describe("separateTables", () => {
/** The table from the answer that prompted this, verbatim. */
const table = [
"| snip | source | aspect | dead space before | box after |",
"| --- | --- | --- | --- | --- |",
"| `…153223` | 626×184 | 3.40 | **~82px** | 400×118 |",
].join("\n");
it("gives a table the blank line it needs to be a table", () => {
// GFM tables cannot interrupt a paragraph, so glued to the line above they
// render as a paragraph of pipes and dashes.
const repaired = separateTables(`Here are the numbers:\n${table}`);
expect(repaired).toBe(`Here are the numbers:\n\n${table}`);
});
it("leaves a table alone when it already has one", () => {
const already = `Here are the numbers:\n\n${table}`;
expect(separateTables(already)).toBe(already);
});
it("leaves a table alone when it opens the answer", () => {
expect(separateTables(table)).toBe(table);
});
it("leaves a table alone when it follows a fence", () => {
const input = `\`\`\`bash\npwd\n\`\`\`\n${table}`;
expect(separateTables(input)).toBe(input);
});
it("does not touch a table inside a fence", () => {
// A table shown as example code has to stay exactly as written, blank line
// or not.
const fenced = `\`\`\`markdown\n${table}\n\`\`\``;
expect(separateTables(fenced)).toBe(fenced);
});
it("leaves prose containing a pipe alone", () => {
const input = "Use the pipe character:\nls | grep foo";
expect(separateTables(input)).toBe(input);
});
it("does not insert a blank line before a data row", () => {
// Only the header starts a table. A blank line inside one would split it.
const input = `text\n${table}\n| a | b | c | d | e |`;
expect(separateTables(input)).toBe(`text\n\n${table}\n| a | b | c | d | e |`);
});
it("recognises a one-dash delimiter, which GFM allows", () => {
const minimal = "| a | b |\n| - | - |\n| 1 | 2 |";
expect(separateTables(`text\n${minimal}`)).toBe(`text\n\n${minimal}`);
});
it("recognises alignment colons", () => {
const aligned = "| a | b |\n|:---|:---:|\n| 1 | 2 |";
expect(separateTables(`text\n${aligned}`)).toBe(`text\n\n${aligned}`);
});
it("is idempotent", () => {
const once = separateTables(`text\n${table}`);
expect(separateTables(once)).toBe(once);
});
});
describe("repairCodeBlocks", () => {
it("promotes a lone command and labels it in one pass", () => {
expect(repairCodeBlocks("`pwd`")).toBe("```bash\npwd\n```");
});
it("leaves a table exactly as the model wrote it", () => {
// The whole repair pass runs over every answer, and a table has to survive
// it untouched: pipes are not fences, not links, and not lone spans.
const table = [
"| snip | source | aspect | box after |",
"| --- | --- | --- | --- |",
"| `…153223` | 626×184 | 3.40 | 400×118 |",
"| [docs](https://example.com) | `pwd` | https://example.com | 400×60 |",
].join("\n");
expect(repairCodeBlocks(table)).toBe(table);
});
it("does not fence a lone URL", () => {
// Restoring links runs before promotion, so a recovered URL never becomes a
// fence again. Fencing it here was the bug the user saw.
const repaired = repairCodeBlocks("`https://example.com`");
expect(repaired).not.toContain("```");
expect(repaired).toBe("[https://example.com](https://example.com)");
});
it("unwraps a fenced list of links and leaves no fence behind", () => {
const repaired = repairCodeBlocks("```\nhttps://a.example\nhttps://b.example\n```");
expect(repaired).not.toContain("```");
expect(repaired).toContain("[https://a.example](https://a.example)");
});
it("still labels a bare multi-line fence", () => {
expect(repairCodeBlocks("```\ngit status\n```")).toBe("```bash\ngit status\n```");
});
+218
View File
@@ -0,0 +1,218 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { __resetLocalStorage } from "../__mocks__/@vicinae/api";
import { GEO_TTL_MS, buildPlace, forgetGeoPlace, ipPlace } from "../lib/geo";
/**
* The only code in the extension that sends anything about the user to a third
* party, so the parts worth pinning down are that it caches (rather than leaking
* the address on every question), that it never throws, and that a failure is
* reported instead of being served as a coarser answer.
*/
const fetchMock = vi.fn();
function answer(payload: unknown, status = 200): Response {
return new Response(JSON.stringify(payload), {
status,
headers: { "Content-Type": "application/json" },
});
}
/** A real ipwho.is payload, trimmed. Note the nested timezone and `country`. */
const CEBU = {
success: true,
city: "Cebu City",
region: "Central Visayas",
country: "Philippines",
latitude: 10.3167,
longitude: 123.8907,
timezone: { id: "Asia/Manila", utc: "+08:00" },
};
beforeEach(() => {
__resetLocalStorage();
fetchMock.mockReset();
vi.stubGlobal("fetch", fetchMock);
});
afterEach(() => {
vi.unstubAllGlobals();
});
describe("buildPlace", () => {
it("joins the parts that exist", () => {
expect(buildPlace({ city: "Cebu City", region: "Central Visayas", country: "Philippines" })).toBe(
"Cebu City, Central Visayas, Philippines",
);
});
it("drops repeats, which providers return for city-states", () => {
// "Singapore, Singapore, Singapore" reads like a bug rather than a place.
expect(buildPlace({ city: "Singapore", region: "Singapore", country: "Singapore" })).toBe("Singapore");
});
it("ignores repeats that differ only in case", () => {
expect(buildPlace({ city: "Manila", region: "manila" })).toBe("Manila");
});
it("drops empty and whitespace parts", () => {
expect(buildPlace({ city: " ", region: "", country: "Philippines" })).toBe("Philippines");
});
it("returns nothing when there is nothing", () => {
expect(buildPlace({})).toBe("");
});
});
describe("ipPlace", () => {
it("resolves a place from a provider answer", async () => {
fetchMock.mockResolvedValueOnce(answer(CEBU));
const result = await ipPlace({ now: 1_000 });
expect(result.status).toBe("ok");
if (result.status !== "ok") return;
expect(result.place.place).toBe("Cebu City, Central Visayas, Philippines");
expect(result.place.latitude).toBe(10.3167);
expect(result.place.timezone).toBe("Asia/Manila");
expect(result.place.source).toBe("ipwho.is");
});
it("names itself to the provider rather than arriving anonymously", async () => {
fetchMock.mockResolvedValueOnce(answer(CEBU));
await ipPlace({ now: 1_000 });
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe("https://ipwho.is/");
expect(init.headers.Accept).toBe("application/json");
expect(init.headers["User-Agent"]).toContain("lam-agent");
});
it("reads a timezone handed back as a plain string too", async () => {
// Providers differ here: ipwho.is nests it, others return a bare zone name.
fetchMock.mockResolvedValueOnce(answer({ ...CEBU, timezone: "Asia/Manila" }));
const result = await ipPlace({ now: 1_000 });
expect(result.status === "ok" && result.place.timezone).toBe("Asia/Manila");
});
it("serves a repeat from the cache instead of re-sending the address", async () => {
fetchMock.mockResolvedValueOnce(answer(CEBU));
await ipPlace({ now: 1_000 });
const second = await ipPlace({ now: 2_000 });
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(second.status).toBe("ok");
});
it("re-fetches once the cached place is stale", async () => {
fetchMock.mockResolvedValueOnce(answer(CEBU));
await ipPlace({ now: 1_000 });
fetchMock.mockResolvedValueOnce(answer({ ...CEBU, city: "Davao City" }));
const later = await ipPlace({ now: 1_000 + GEO_TTL_MS + 1 });
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(later.status === "ok" && later.place.city).toBe("Davao City");
});
it("treats exactly the TTL as still fresh", async () => {
fetchMock.mockResolvedValueOnce(answer(CEBU));
await ipPlace({ now: 1_000 });
await ipPlace({ now: 1_000 + GEO_TTL_MS });
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("ignores the cache when asked to refresh", async () => {
fetchMock.mockResolvedValueOnce(answer(CEBU));
await ipPlace({ now: 1_000 });
fetchMock.mockResolvedValueOnce(answer({ ...CEBU, city: "Davao City" }));
const refreshed = await ipPlace({ now: 1_001, refresh: true });
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(refreshed.status === "ok" && refreshed.place.city).toBe("Davao City");
});
it("reports a rate limit by status rather than pretending to know the place", async () => {
fetchMock.mockResolvedValueOnce(new Response("slow down", { status: 429 }));
const result = await ipPlace({ now: 1_000 });
expect(result).toEqual({ status: "failed", message: "ipwho.is answered HTTP 429" });
});
it("reports a provider that refuses in the body while answering 200", async () => {
// ipwho.is signals failure with success: false rather than a status code,
// so checking the status alone would read a refusal as a success.
fetchMock.mockResolvedValueOnce(answer({ success: false, message: "Reserved range" }));
const result = await ipPlace({ now: 1_000 });
expect(result).toEqual({ status: "failed", message: "Reserved range" });
});
it("reports a body that is not JSON", async () => {
fetchMock.mockResolvedValueOnce(new Response("<html>nope</html>", { status: 200 }));
const result = await ipPlace({ now: 1_000 });
expect(result.status).toBe("failed");
});
it("reports an answer with no usable place", async () => {
fetchMock.mockResolvedValueOnce(answer({ success: true, city: null, region: null, country: null }));
const result = await ipPlace({ now: 1_000 });
expect(result).toEqual({ status: "failed", message: "ipwho.is returned no usable place" });
});
it("survives the network being down without throwing", async () => {
fetchMock.mockRejectedValueOnce(new Error("offline"));
const result = await ipPlace({ now: 1_000 });
expect(result.status).toBe("failed");
});
it("does not cache a failure", async () => {
fetchMock.mockRejectedValueOnce(new Error("offline"));
await ipPlace({ now: 1_000 });
fetchMock.mockResolvedValueOnce(answer(CEBU));
const second = await ipPlace({ now: 1_001 });
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(second.status).toBe("ok");
});
it("ignores a corrupt cache entry rather than failing", async () => {
const { LocalStorage } = await import("../__mocks__/@vicinae/api");
await LocalStorage.setItem("lam.geo", "{not json");
fetchMock.mockResolvedValueOnce(answer(CEBU));
await expect(ipPlace({ now: 1_000 })).resolves.toMatchObject({ status: "ok" });
});
});
describe("forgetGeoPlace", () => {
it("drops the cached place so a move is not pinned for hours", async () => {
fetchMock.mockResolvedValueOnce(answer(CEBU));
await ipPlace({ now: 1_000 });
await forgetGeoPlace();
fetchMock.mockResolvedValueOnce(answer({ ...CEBU, city: "Davao City" }));
const after = await ipPlace({ now: 1_001 });
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(after.status === "ok" && after.place.city).toBe("Davao City");
});
});
+35 -1
View File
@@ -4,7 +4,7 @@ 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: [] };
return { id: prompt, prompt, response, thinking: "", model: "m", createdAt: 0, toolCalls: [], attachments: [] };
}
describe("buildMessages", () => {
@@ -66,3 +66,37 @@ describe("composeSystemPrompt", () => {
expect(composeSystemPrompt("Be terse.", false)).not.toContain(RECENCY_HINT);
});
});
describe("buildMessages images", () => {
it("attaches images to the question being asked", () => {
const messages = buildMessages("SYSTEM", [], "what is this?", ["BASE64"]);
expect(messages[messages.length - 1]).toEqual({
role: "user",
content: "what is this?",
images: ["BASE64"],
});
});
it("omits the images key entirely when there is no attachment", () => {
// The key has to be absent, not an empty array: Ollama treats `images: []`
// differently from no images at all on some models.
const messages = buildMessages("SYSTEM", [], "hello");
expect(messages[messages.length - 1]).toEqual({ role: "user", content: "hello" });
});
it("attaches several images to one question", () => {
const messages = buildMessages("SYSTEM", [], "compare these", ["A", "B"]);
expect(messages[messages.length - 1].images).toEqual(["A", "B"]);
});
it("does not replay images from earlier turns", () => {
// A screenshot is the largest thing a request can carry, and re-sending it
// with every later question is exactly what dropping tool output avoids.
const messages = buildMessages("SYSTEM", [turn("earlier", "answer")], "follow up");
expect(messages.some((message) => message.images !== undefined)).toBe(false);
});
});
+54 -10
View File
@@ -1,21 +1,53 @@
import { describe, expect, it } from "vitest";
import { describeTimezone, getLocationContext, utcOffsetLabel, weekdayName } from "../lib/location";
import {
describeTimezoneArea,
getLocationContext,
localeRegion,
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" });
describe("describeTimezoneArea", () => {
it("keeps the zone's own label", () => {
expect(describeTimezoneArea("Asia/Manila")).toEqual({ area: "Manila" });
});
it("handles zones with more than two segments", () => {
expect(describeTimezone("America/Argentina/Salta")).toEqual({ region: "America", city: "Salta" });
expect(describeTimezoneArea("America/Argentina/Salta")).toEqual({ area: "Salta" });
});
it("underscores become spaces", () => {
expect(describeTimezone("America/New_York")).toEqual({ region: "America", city: "New York" });
it("turns underscores into spaces", () => {
expect(describeTimezoneArea("America/New_York")).toEqual({ area: "New York" });
});
it("drops the continent grouping, which answers a question nobody asked", () => {
// "Asia" beside a locale region of "Philippines" reads as two answers to
// the same question.
expect(describeTimezoneArea("Asia/Manila")).not.toHaveProperty("region");
});
it("returns nothing for a bare zone like UTC", () => {
expect(describeTimezone("UTC")).toEqual({});
expect(describeTimezoneArea("UTC")).toEqual({});
});
});
describe("localeRegion", () => {
it("reads the region the user configured, rather than inferring one", () => {
expect(localeRegion("en-PH")).toEqual({ code: "PH", name: "Philippines" });
});
it("handles other regions", () => {
expect(localeRegion("de-DE")?.name).toBe("Germany");
expect(localeRegion("en-US")?.code).toBe("US");
});
it("has nothing to say for a locale with no region", () => {
expect(localeRegion("en")).toBeUndefined();
});
it("does not throw on a malformed locale", () => {
expect(localeRegion("not a locale")).toBeUndefined();
expect(localeRegion("")).toBeUndefined();
});
});
@@ -54,10 +86,11 @@ describe("getLocationContext", () => {
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);
// The zone's label is reported as such, never as the user's city.
expect(context.timezoneArea).toBe("Manila");
expect(context).not.toHaveProperty("city");
});
it("still returns a usable context for an unknown zone", () => {
@@ -65,4 +98,15 @@ describe("getLocationContext", () => {
expect(context.timezone).toBe("Not/AReal_Zone");
expect(context.utcOffset).toBe("UTC");
});
it("carries the configured place through verbatim", () => {
const context = getLocationContext(new Date(), "Asia/Manila", " Cebu City, Philippines ");
expect(context.place).toBe("Cebu City, Philippines");
});
it("treats an empty or whitespace place as unset", () => {
expect(getLocationContext(new Date(), "Asia/Manila", "").place).toBeUndefined();
expect(getLocationContext(new Date(), "Asia/Manila", " ").place).toBeUndefined();
expect(getLocationContext(new Date(), "Asia/Manila").place).toBeUndefined();
});
});
+109 -4
View File
@@ -15,6 +15,7 @@ function message(overrides: Partial<ThreadMessage> & Pick<ThreadMessage, "role">
isStreaming: false,
thinking: "",
toolCalls: [],
attachments: [],
...overrides,
};
}
@@ -74,9 +75,9 @@ describe("buildThreadMarkdown", () => {
expect(markdown.indexOf("second question")).toBeLessThan(markdown.indexOf("first answer"));
});
it("renders no images, because markdown cannot resolve them", () => {
// Images render block-level and centred at up to 200px (`MdImage.qml`), and
// every candidate icon source fails. Keep the transcript text-only.
it("renders no images for a message that has no attachment", () => {
// Images only ever come from an attachment this extension took itself. A URL
// the model invented is not resolvable and renders as an empty block.
const markdown = buildThreadMarkdown([
message({ role: "user", text: "q" }),
message({ role: "assistant", text: "a" }),
@@ -146,6 +147,34 @@ describe("buildThreadMarkdown", () => {
expect(markdown).not.toContain("```bash");
});
it("turns a fenced list of URLs into links, because a fence is not clickable", () => {
// The reported case: asked for alternatives, the model answered with a code
// block of bare GitHub addresses. `MdCodeBlock.qml` draws that as a dark
// panel with a Copy button and no link handling, so the address could not be
// opened. Only `CMARK_NODE_LINK` becomes an `<a href>` the selection
// controller can activate.
const markdown = buildThreadMarkdown([
message({
role: "assistant",
text: "GitHub links:\n\n```\nhttps://github.com/jasonkneen/openclicky\nhttps://github.com/unn-Known1/clickyX\n```",
}),
]);
expect(markdown).not.toContain("```");
expect(markdown).toContain(
"[https://github.com/jasonkneen/openclicky](https://github.com/jasonkneen/openclicky)",
);
});
it("links a lone URL the model put in inline code", () => {
const markdown = buildThreadMarkdown([
message({ role: "assistant", text: "`https://example.com`" }),
]);
expect(markdown).not.toContain("```");
expect(markdown).toContain("[https://example.com](https://example.com)");
});
it("lists tools on one quiet line, flagging failures", () => {
const markdown = buildThreadMarkdown([
message({
@@ -212,7 +241,17 @@ describe("buildThreadMarkdown", () => {
describe("sessionToPlainText", () => {
function turn(prompt: string, response: string, error?: string): ChatTurn {
return { id: prompt, prompt, response, thinking: "", model: "m", createdAt: 0, toolCalls: [], error };
return {
id: prompt,
prompt,
response,
thinking: "",
model: "m",
createdAt: 0,
toolCalls: [],
attachments: [],
error,
};
}
it("returns an empty string for an empty conversation", () => {
@@ -247,3 +286,69 @@ describe("sessionToPlainText", () => {
);
});
});
describe("attachments in the transcript", () => {
const screenshot = { path: "/tmp/snip.png", mime: "image/png" };
it("shows the attachment instead of just naming it", () => {
// Vicinae resolves a local file through `ImageURL::local`, so the capture is
// rendered rather than described. `MdImage.qml` caps the height at 200px, so
// it is a thumbnail, and the alt text is what is left if the file is gone.
const markdown = buildThreadMarkdown([
message({ role: "user", text: "what is this?", attachments: [screenshot] }),
]);
expect(markdown).toContain("![Screenshot](file:///tmp/snip.png)");
});
it("keeps the question above its image", () => {
const markdown = buildThreadMarkdown([
message({ role: "user", text: "what is this?", attachments: [screenshot] }),
]);
expect(markdown.indexOf("> what is this?")).toBeLessThan(markdown.indexOf("!["));
});
it("renders every attachment on a turn", () => {
const markdown = buildThreadMarkdown([
message({
role: "user",
text: "compare",
attachments: [screenshot, { path: "/tmp/second.png", mime: "image/png" }],
}),
]);
expect(markdown).toContain("file:///tmp/snip.png");
expect(markdown).toContain("file:///tmp/second.png");
});
it("never puts an image on an answer, which has no attachments", () => {
const markdown = buildThreadMarkdown([
message({ role: "assistant", text: "a cat", attachments: [screenshot] }),
]);
expect(markdown).not.toContain("![");
});
it("says nothing about attachments when there are none", () => {
const markdown = buildThreadMarkdown([message({ role: "user", text: "q" })]);
expect(markdown).not.toContain("Screenshot");
});
it("notes the screenshot when a conversation is copied as plain text", () => {
const question = {
id: "t1",
prompt: "what is this?",
response: "a cat",
thinking: "",
model: "m",
createdAt: 0,
toolCalls: [],
attachments: [screenshot],
};
const text = sessionToPlainText([question]);
expect(text).toContain("_(screenshot attached)_");
});
});
+69 -2
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { normalizeHost, parseNdjsonStream } from "../lib/ollama";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { modelCapabilities, normalizeHost, parseNdjsonStream, supportsVision } from "../lib/ollama";
/**
* The NDJSON reader is the part of the client most likely to break in a way
@@ -84,3 +84,70 @@ describe("parseNdjsonStream", () => {
expect(await collect<unknown>([])).toEqual([]);
});
});
describe("supportsVision", () => {
it("reads the capability Ollama reports for image input", () => {
expect(supportsVision(["completion", "tools", "vision"])).toBe(true);
});
it("is false for a text-only model", () => {
expect(supportsVision(["completion", "tools"])).toBe(false);
expect(supportsVision([])).toBe(false);
});
it("does not match a capability that merely contains the word", () => {
expect(supportsVision(["visions"])).toBe(false);
});
});
describe("modelCapabilities", () => {
const fetchMock = vi.fn();
beforeEach(() => {
fetchMock.mockReset();
vi.stubGlobal("fetch", fetchMock);
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("posts to /api/show and returns the capability list", async () => {
fetchMock.mockResolvedValueOnce(
new Response(JSON.stringify({ capabilities: ["completion", "vision"] }), { status: 200 }),
);
await expect(
modelCapabilities({ host: "https://ollama.com", apiKey: "k", model: "gemma4" }),
).resolves.toEqual(["completion", "vision"]);
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe("https://ollama.com/api/show");
expect(init.headers.Authorization).toBe("Bearer k");
expect(JSON.parse(init.body)).toEqual({ model: "gemma4" });
});
it("returns nothing when a model reports no capabilities", async () => {
fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({}), { status: 200 }));
await expect(
modelCapabilities({ host: "https://ollama.com", apiKey: "k", model: "gemma4" }),
).resolves.toEqual([]);
});
it("turns a 401 into the key hint rather than a bare status code", async () => {
fetchMock.mockResolvedValueOnce(new Response("unauthorized", { status: 401 }));
await expect(
modelCapabilities({ host: "https://ollama.com", apiKey: "bad", model: "gemma4" }),
).rejects.toMatchObject({ status: 401, hint: expect.stringContaining("ollama.com/settings/keys") });
});
it("normalises the host so the path does not double up a slash", async () => {
fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ capabilities: [] }), { status: 200 }));
await modelCapabilities({ host: "http://localhost:11434/", apiKey: "", model: "gemma4" });
expect(fetchMock.mock.calls[0][0]).toBe("http://localhost:11434/api/show");
});
});
+20
View File
@@ -53,6 +53,26 @@ describe("resolvePreferences", () => {
expect(resolved.showReasoning).toBe(true);
expect(resolved.think).toBeUndefined();
expect(resolved.resumeTimeoutMs).toBe(HOUR);
// An empty string rather than undefined, so callers never branch on it.
expect(resolved.place).toBe("");
});
it("leaves IP geolocation off unless it was switched on", () => {
// The one setting that sends something about the user to a third party is
// opted into, never defaulted into.
expect(resolvePreferences({}).ipGeolocation).toBe(false);
expect(resolvePreferences({ ipGeolocation: true }).ipGeolocation).toBe(true);
expect(resolvePreferences({ ipGeolocation: false }).ipGeolocation).toBe(false);
});
it("keeps the configured location, trimmed", () => {
expect(resolvePreferences({ location: " Cebu City, Philippines " }).place).toBe(
"Cebu City, Philippines",
);
});
it("treats a whitespace-only location as unset", () => {
expect(resolvePreferences({ location: " " }).place).toBe("");
});
it("honours the display toggles being switched off", () => {
+1
View File
@@ -24,6 +24,7 @@ function turn(index: number, response = `answer ${index}`): ChatTurn {
model: "test-model",
createdAt: 1_000 + index,
toolCalls: [],
attachments: [],
};
}
+57
View File
@@ -0,0 +1,57 @@
import { describe, expect, it } from "vitest";
import { readSnipContext, snipDirectory } from "../lib/snip";
/**
* The snip handoff crosses a command boundary as a serialized launch context, so
* every field arrives as unknown. This is the boundary where a wrong type would
* otherwise reach the file system as a path.
*/
describe("readSnipContext", () => {
it("reads the fields the snip command writes", () => {
expect(
readSnipContext({
snipPath: "/tmp/snip.png",
snipError: "no tool",
sessionId: "abc123",
}),
).toEqual({ snipPath: "/tmp/snip.png", snipError: "no tool", sessionId: "abc123" });
});
it("returns nothing for a launch with no context at all", () => {
expect(readSnipContext(undefined)).toEqual({});
expect(readSnipContext(null)).toEqual({});
});
it("ignores a context that is not an object", () => {
expect(readSnipContext("snip.png")).toEqual({});
expect(readSnipContext(42)).toEqual({});
expect(readSnipContext(["/tmp/snip.png"])).toEqual({});
});
it("drops a value that is not a string rather than passing it on", () => {
// A number or object here would reach `readAttachment` as a path.
expect(readSnipContext({ snipPath: 42, sessionId: { id: "x" } })).toEqual({});
});
it("treats an empty string as absent", () => {
expect(readSnipContext({ snipPath: "", sessionId: "" })).toEqual({});
});
it("ignores unknown keys, so a stray field cannot widen the contract", () => {
expect(readSnipContext({ snipPath: "/tmp/snip.png", somethingElse: "/etc/passwd" })).toEqual({
snipPath: "/tmp/snip.png",
});
});
it("reads a payload that only names a conversation, which is a cancelled snip", () => {
expect(readSnipContext({ sessionId: "abc123" })).toEqual({ sessionId: "abc123" });
});
});
describe("snipDirectory", () => {
it("keeps snips under the extension's support directory, not a temp directory", () => {
// A stored conversation holds the path, so it has to survive a reboot.
expect(snipDirectory()).toBe("/tmp/lam-agent-support/snips");
});
});
+203
View File
@@ -0,0 +1,203 @@
import { describe, expect, it, vi } from "vitest";
import { isHandoff, resolveStartup, type StartupPorts } from "../lib/startup";
import type { PendingAttachment, Session, SessionSummary } from "../lib/types";
/**
* The chat view's startup is the part that kept breaking: three asynchronous
* paths (a pushed session, a handed-over capture, an idle resume) that all have
* to end in a rendered chat. Two of those bugs shipped, so the decision tree is
* tested here rather than discovered from a screenshot of a spinner.
*/
const NOW = 1_000_000;
const HOUR = 3_600_000;
function session(id: string, updatedAt: number): Session {
return { id, title: id, model: "m", createdAt: updatedAt, updatedAt, turns: [] };
}
function summary(id: string, updatedAt: number): SessionSummary {
return { id, title: id, model: "m", createdAt: updatedAt, updatedAt, turnCount: 0, preview: "" };
}
function attachment(path: string): PendingAttachment {
return { path, mime: "image/png", base64: "AAAA" };
}
/** Ports that record what was asked for, so "did not resume" is checkable. */
function ports(overrides: Partial<StartupPorts> = {}) {
const loadSession = vi.fn(async (id: string) => session(id, NOW));
const listSessions = vi.fn(async () => [summary("latest", NOW)]);
const readAttachment = vi.fn(async (path: string) => attachment(path));
return {
loadSession,
listSessions,
readAttachment,
...overrides,
} satisfies StartupPorts;
}
function input(overrides: Partial<Parameters<typeof resolveStartup>[0]> = {}) {
return {
hasInitialSession: false,
now: NOW,
...overrides,
};
}
describe("isHandoff", () => {
it("is true when a file was handed over", () => {
expect(isHandoff({ initialAttachmentPath: "/tmp/snip.png" })).toBe(true);
});
it("is true when a conversation was named", () => {
expect(isHandoff({ initialSessionId: "abc" })).toBe(true);
});
it("is false on an ordinary open", () => {
expect(isHandoff({})).toBe(false);
});
});
describe("resolveStartup", () => {
describe("a snip hand-off", () => {
it("reads the attachment and restores the named conversation", async () => {
const p = ports();
const result = await resolveStartup(
input({ initialSessionId: "abc", initialAttachmentPath: "/tmp/snip.png" }),
p,
);
expect(result.session?.id).toBe("abc");
expect(result.attachment?.path).toBe("/tmp/snip.png");
});
it("does not fall through to the idle resume", async () => {
// The bug this file exists for: a hand-off has to finish startup even
// though it took the resume path off the table. Guarding the resume with
// "and there was no hand-off" while forgetting to clear the readiness flag
// left the view on its loading row forever.
const p = ports();
await resolveStartup(input({ initialAttachmentPath: "/tmp/snip.png" }), p);
expect(p.listSessions).not.toHaveBeenCalled();
});
it("does not attach the most recent conversation's session", async () => {
const p = ports();
const result = await resolveStartup(input({ initialAttachmentPath: "/tmp/snip.png" }), p);
expect(result.session).toBeUndefined();
});
it("still resolves when the named conversation has been deleted", async () => {
// A snip started from a conversation someone has since deleted should mean
// a fresh chat with the image, not a spinner or a crash.
const p = ports({ loadSession: vi.fn(async () => undefined) });
const result = await resolveStartup(
input({ initialSessionId: "gone", initialAttachmentPath: "/tmp/snip.png" }),
p,
);
expect(result.session).toBeUndefined();
expect(result.attachment?.path).toBe("/tmp/snip.png");
});
it("reports an unreadable attachment without refusing to open", async () => {
const p = ports({
readAttachment: vi.fn(async () => {
throw new Error("ENOENT: no such file");
}),
});
const result = await resolveStartup(input({ initialAttachmentPath: "/tmp/gone.png" }), p);
expect(result.attachment).toBeUndefined();
expect(result.attachmentError).toContain("ENOENT");
});
it("survives a storage failure while loading the named conversation", async () => {
const p = ports({
loadSession: vi.fn(async () => {
throw new Error("storage exploded");
}),
});
const result = await resolveStartup(input({ initialSessionId: "abc" }), p);
expect(result.session).toBeUndefined();
});
});
describe("an ordinary open", () => {
it("resumes the most recent conversation when it is fresh", async () => {
const p = ports({ listSessions: vi.fn(async () => [summary("latest", NOW - 60_000)]) });
const result = await resolveStartup(input({ resumeTimeoutMs: HOUR }), p);
expect(result.session?.id).toBe("latest");
expect(p.loadSession).toHaveBeenCalledWith("latest");
});
it("starts fresh when the newest conversation is older than the window", async () => {
const p = ports({ listSessions: vi.fn(async () => [summary("latest", NOW - 2 * HOUR)]) });
const result = await resolveStartup(input({ resumeTimeoutMs: HOUR }), p);
expect(result.session).toBeUndefined();
expect(p.loadSession).not.toHaveBeenCalled();
});
it("treats exactly the window as fresh", async () => {
const p = ports({ listSessions: vi.fn(async () => [summary("latest", NOW - HOUR)]) });
const result = await resolveStartup(input({ resumeTimeoutMs: HOUR }), p);
expect(result.session?.id).toBe("latest");
});
it("always resumes when the preference is 'never time out'", async () => {
const p = ports({ listSessions: vi.fn(async () => [summary("latest", NOW - 400 * HOUR)]) });
const result = await resolveStartup(input({ resumeTimeoutMs: undefined }), p);
expect(result.session?.id).toBe("latest");
});
it("starts fresh when there are no conversations at all", async () => {
const p = ports({ listSessions: vi.fn(async () => []) });
const result = await resolveStartup(input({ resumeTimeoutMs: HOUR }), p);
expect(result).toEqual({});
expect(p.loadSession).not.toHaveBeenCalled();
});
it("starts fresh rather than failing when the index cannot be read", async () => {
const p = ports({
listSessions: vi.fn(async () => {
throw new Error("corrupt index");
}),
});
const result = await resolveStartup(input({ resumeTimeoutMs: HOUR }), p);
expect(result).toEqual({});
});
it("does not resume when a session was pushed in by hand", async () => {
// The sessions command already knows which conversation to show.
const p = ports();
await resolveStartup(input({ hasInitialSession: true }), p);
expect(p.listSessions).not.toHaveBeenCalled();
expect(p.loadSession).not.toHaveBeenCalled();
});
});
});
+1
View File
@@ -13,6 +13,7 @@ function turn(id: string, prompt: string, response: string, overrides: Partial<C
model: "m",
createdAt: 100,
toolCalls: [],
attachments: [],
...overrides,
};
}
+128 -2
View File
@@ -225,13 +225,139 @@ describe("local tools", () => {
expect(result.content).toContain("Current date and time:");
});
it("describes location as a region rather than an address", async () => {
it("describes location as a region rather than an address when nothing is configured", async () => {
const result = await executeTool("get_location", {}, context);
expect(result.isError).toBe(false);
expect(result.content).toContain("Timezone:");
expect(result.content).toContain("Locale:");
expect(result.content).toContain("not a precise address");
expect(result.content).toContain("no Location is set in preferences");
expect(result.content).toContain("Ask which city they are in");
});
it("never claims a city derived from the timezone name", async () => {
// `Asia/Manila` is one zone covering the whole Philippines, so reporting
// "City: Manila" was wrong for anyone in Cebu and confidently so.
const result = await executeTool("get_location", {}, context);
expect(result.content).not.toMatch(/^City:/m);
expect(result.content).toContain("The timezone area is the timezone's own name, not the user's city.");
});
it("puts the configured place first and says to prefer it", async () => {
const result = await executeTool("get_location", {}, {
...context,
place: "Cebu City, Philippines",
});
expect(result.content).toContain("Place: Cebu City, Philippines");
expect(result.content).toContain("prefer it");
expect(result.content).not.toContain("no Location is set");
expect(result.content.indexOf("Place:")).toBeLessThan(result.content.indexOf("Timezone:"));
});
it("still reports the timezone alongside a configured place, for local time questions", async () => {
const result = await executeTool("get_location", {}, { ...context, place: "Cebu City" });
expect(result.content).toContain("Timezone:");
expect(result.content).toContain("UTC offset:");
});
});
describe("get_location with IP geolocation", () => {
const resolved = {
status: "ok" as const,
place: {
place: "Cebu City, Central Visayas, Philippines",
city: "Cebu City",
region: "Central Visayas",
country: "Philippines",
timezone: "Asia/Manila",
source: "ipwho.is",
fetchedAt: 1_000,
},
};
it("reports the resolved place and says how approximate it is", async () => {
const ipPlace = vi.fn(async () => resolved);
const result = await executeTool("get_location", {}, { ...context, ipPlace });
expect(result.isError).toBe(false);
expect(result.content).toContain("Place: Cebu City, Central Visayas, Philippines");
expect(result.content).toContain("rough guess");
expect(result.content).toContain("ipwho.is");
});
it("does not look the address up at all when the user has said where they are", async () => {
// The preference is both more accurate and free, so sending the IP anyway
// would be a privacy cost for nothing.
const ipPlace = vi.fn(async () => resolved);
const result = await executeTool("get_location", {}, {
...context,
place: "Cebu City, Philippines",
ipPlace,
});
expect(ipPlace).not.toHaveBeenCalled();
expect(result.content).toContain("Place: Cebu City, Philippines");
expect(result.content).not.toContain("ipwho.is");
});
it("surfaces a failed lookup instead of quietly serving the region answer", async () => {
const ipPlace = vi.fn(async () => ({ status: "failed" as const, message: "ipwho.is answered HTTP 429" }));
const result = await executeTool("get_location", {}, { ...context, ipPlace });
expect(result.content).toContain("IP geolocation is switched on but the lookup failed");
expect(result.content).toContain("HTTP 429");
// The region answer is still there, so the tool stays useful.
expect(result.content).toContain("Timezone:");
});
it("does not blame an unset preference when the lookup itself failed", async () => {
// These are different problems and send the user to different places. The
// original wording said "no Location is set in preferences" for both, which
// is exactly what made a broken lookup look like a missing setting.
const ipPlace = vi.fn(async () => ({ status: "failed" as const, message: "ipwho.is answered HTTP 429" }));
const result = await executeTool("get_location", {}, { ...context, ipPlace });
expect(result.content).not.toContain("no Location is set in preferences");
});
it("tells the model to pass a failed lookup on, since it is the only channel to the user", async () => {
const ipPlace = vi.fn(async () => ({ status: "failed" as const, message: "offline" }));
const result = await executeTool("get_location", {}, { ...context, ipPlace });
expect(result.content).toContain("Tell the user");
expect(result.content).toContain("Setting Location in preferences avoids the lookup");
});
it("flags an IP timezone that disagrees with the clock, which usually means a VPN", async () => {
const ipPlace = vi.fn(async () => ({
status: "ok" as const,
place: { ...resolved.place, timezone: "America/New_York" },
}));
const result = await executeTool("get_location", {}, { ...context, ipPlace });
expect(result.content).toContain("differs from the device timezone");
});
it("says nothing about a mismatch when the timezones agree", async () => {
const ipPlace = vi.fn(async () => resolved);
const result = await executeTool("get_location", {}, { ...context, ipPlace });
// The test host's own zone varies, so this asserts the absence of the note
// only for the case where the provider echoed the same value.
if (result.content.includes("IP-reported timezone")) {
expect(result.content).toContain("differs");
} else {
expect(result.content).not.toContain("IP-reported timezone");
}
});
});
+121
View File
@@ -0,0 +1,121 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { __resetLocalStorage } from "../__mocks__/@vicinae/api";
import { forgetVisionSupport, visionSupport } from "../lib/vision";
/**
* The capability check decides whether a snip feature is offered at all, so the
* cases that matter are the ones where the answer is not a clean yes or no: a
* dead network must not be mistaken for "this model cannot see".
*/
const fetchMock = vi.fn();
function showResponse(capabilities: string[]): Response {
return new Response(JSON.stringify({ capabilities }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
beforeEach(() => {
__resetLocalStorage();
fetchMock.mockReset();
vi.stubGlobal("fetch", fetchMock);
});
afterEach(() => {
vi.unstubAllGlobals();
});
const base = { host: "https://ollama.com", apiKey: "k", model: "gemma4" };
describe("visionSupport", () => {
it("reports yes when Ollama lists the vision capability", async () => {
fetchMock.mockResolvedValueOnce(showResponse(["completion", "vision"]));
await expect(visionSupport(base)).resolves.toBe("yes");
});
it("reports no for a text-only model", async () => {
fetchMock.mockResolvedValueOnce(showResponse(["completion", "tools"]));
await expect(visionSupport(base)).resolves.toBe("no");
});
it("asks /api/show for the model", async () => {
fetchMock.mockResolvedValueOnce(showResponse(["vision"]));
await visionSupport(base);
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe("https://ollama.com/api/show");
expect(JSON.parse(init.body)).toEqual({ model: "gemma4" });
});
it("serves a repeat question from the cache", async () => {
fetchMock.mockResolvedValueOnce(showResponse(["vision"]));
await visionSupport(base);
await expect(visionSupport(base)).resolves.toBe("yes");
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("caches a negative too, so a text model is not re-checked on every mount", async () => {
fetchMock.mockResolvedValueOnce(showResponse(["tools"]));
await visionSupport(base);
await expect(visionSupport(base)).resolves.toBe("no");
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("keys the cache per model, so switching model re-checks", async () => {
fetchMock.mockResolvedValueOnce(showResponse(["vision"]));
await visionSupport(base);
fetchMock.mockResolvedValueOnce(showResponse(["tools"]));
await expect(visionSupport({ ...base, model: "gpt-oss:120b" })).resolves.toBe("no");
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("re-checks when explicitly asked to, even with a cache entry", async () => {
fetchMock.mockResolvedValueOnce(showResponse(["tools"]));
await visionSupport(base);
fetchMock.mockResolvedValueOnce(showResponse(["vision"]));
await expect(visionSupport({ ...base, refresh: true })).resolves.toBe("yes");
});
it("reports unknown rather than no when the request fails", async () => {
// A dead network is not evidence that a model cannot see, and treating it as
// such would hide the feature for a reason the user cannot see.
fetchMock.mockRejectedValueOnce(new Error("offline"));
await expect(visionSupport(base)).resolves.toBe("unknown");
});
it("does not cache a failure", async () => {
fetchMock.mockRejectedValueOnce(new Error("offline"));
await visionSupport(base);
fetchMock.mockResolvedValueOnce(showResponse(["vision"]));
await expect(visionSupport(base)).resolves.toBe("yes");
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("reports a rejected key as unknown rather than crashing", async () => {
fetchMock.mockResolvedValueOnce(new Response("unauthorized", { status: 401 }));
await expect(visionSupport(base)).resolves.toBe("unknown");
});
});
describe("forgetVisionSupport", () => {
it("drops every cached answer, so a re-pulled model is not pinned to no", async () => {
fetchMock.mockResolvedValueOnce(showResponse(["vision"]));
await visionSupport(base);
await forgetVisionSupport();
fetchMock.mockResolvedValueOnce(showResponse(["vision"]));
await visionSupport(base);
expect(fetchMock).toHaveBeenCalledTimes(2);
});
});