feat: Ollama models command, with load/pull/delete and unload (#16)

This commit was merged in pull request #16.
This commit is contained in:
2026-10-05 09:43:00 +00:00
parent 05fd9ff13b
commit 26e05dea5b
22 changed files with 1948 additions and 65 deletions
+94 -1
View File
@@ -1,13 +1,18 @@
import { describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { __resetLocalStorage, __setPreferenceValues } from "../__mocks__/@vicinae/api";
import { DEFAULT_HOST, DEFAULT_MAX_TOOL_ROUNDS, DEFAULT_MODEL } from "../lib/constants";
import {
clearModelSelection,
describeAutoDelete,
describeMaxToolRounds,
describeResumeTimeout,
resolveAutoDelete,
resolveMaxToolRounds,
resolvePreferences,
loadPreferences,
readModelSelection,
resolveResumeTimeout,
selectModel,
} from "../lib/preferences";
const MINUTE = 60_000;
@@ -203,3 +208,91 @@ describe("describeMaxToolRounds", () => {
expect(describeMaxToolRounds(1)).toBe("1 round per answer");
});
});
describe("the model selection", () => {
beforeEach(() => {
__resetLocalStorage();
__setPreferenceValues({ apiKey: "k", model: "from-preference:1b" });
});
afterEach(() => {
__setPreferenceValues({});
});
it("uses the preference when nothing has been picked", async () => {
const resolved = await loadPreferences();
expect(resolved.model).toBe("from-preference:1b");
expect(resolved.modelSource).toBe("preference");
expect(resolved.preferenceModel).toBe("from-preference:1b");
});
it("prefers a picked model over the preference, and says so", async () => {
// The whole reason this exists: an extension cannot write its own
// preferences, so the pick has to live somewhere else and win.
await selectModel("picked:9b");
const resolved = await loadPreferences();
expect(resolved.model).toBe("picked:9b");
expect(resolved.modelSource).toBe("selection");
// Both values are carried, so a screen can answer "what would a reset give
// me back".
expect(resolved.preferenceModel).toBe("from-preference:1b");
});
it("round-trips a pick through storage", async () => {
await selectModel("first:1b");
await expect(readModelSelection()).resolves.toBe("first:1b");
await selectModel("second:2b");
await expect(readModelSelection()).resolves.toBe("second:2b");
});
it("hands the model back to the preference when the selection is cleared", async () => {
await selectModel("picked:9b");
await clearModelSelection();
const resolved = await loadPreferences();
expect(resolved.model).toBe("from-preference:1b");
expect(resolved.modelSource).toBe("preference");
});
it("ignores a stored selection that is empty or whitespace", async () => {
// A blank value is not a model name, and treating it as one would leave the
// chat asking for "" on every turn.
await selectModel(" ");
await expect(readModelSelection()).resolves.toBeUndefined();
await expect(loadPreferences()).resolves.toMatchObject({ model: "from-preference:1b" });
});
it("trims a stored selection", async () => {
await selectModel(" spaced:1b ");
await expect(readModelSelection()).resolves.toBe("spaced:1b");
});
it("falls back to the preference when storage cannot be read", async () => {
const { LocalStorage } = await import("../__mocks__/@vicinae/api");
const original = LocalStorage.getItem;
LocalStorage.getItem = async () => {
throw new Error("storage is unavailable");
};
try {
await expect(loadPreferences()).resolves.toMatchObject({ model: "from-preference:1b" });
} finally {
LocalStorage.getItem = original;
}
});
it("leaves the model source as the preference when resolving without storage", () => {
expect(resolvePreferences({ model: "plain:1b" })).toMatchObject({
model: "plain:1b",
modelSource: "preference",
preferenceModel: "plain:1b",
});
});
});