import { describe, it, expect, beforeEach, vi } from "vitest";
// ============================================================================
// Module 1: permission-modes
// ============================================================================
import {
setPermissionMode,
getPermissionMode,
isToolAllowedByPermission,
type PermissionMode,
} from "./permission-modes.js";
describe("permission-modes", () => {
beforeEach(() => {
setPermissionMode("default");
});
describe("getPermissionMode / setPermissionMode", () => {
it("defaults to 'default' mode", () => {
expect(getPermissionMode()).toBe("default");
});
it("sets mode to 'plan'", () => {
const result = setPermissionMode("plan");
expect(result.success).toBe(true);
expect(result.message).toContain("plan");
expect(getPermissionMode()).toBe("plan");
});
it("sets mode to 'yolo'", () => {
const result = setPermissionMode("yolo");
expect(result.success).toBe(true);
expect(result.message).toContain("yolo");
expect(getPermissionMode()).toBe("yolo");
});
it("sets mode back to 'default'", () => {
setPermissionMode("yolo");
const result = setPermissionMode("default");
expect(result.success).toBe(true);
expect(getPermissionMode()).toBe("default");
});
it("returns success:true for all valid modes", () => {
const modes: PermissionMode[] = ["default", "plan", "yolo"];
for (const mode of modes) {
const result = setPermissionMode(mode);
expect(result.success).toBe(true);
}
});
it("includes the mode name in the result message", () => {
const result = setPermissionMode("plan");
expect(result.message).toBe("Permission mode: plan");
});
});
describe("isToolAllowedByPermission", () => {
it("allows all tools in default mode", () => {
setPermissionMode("default");
expect(isToolAllowedByPermission("bash")).toBe(true);
expect(isToolAllowedByPermission("write_file")).toBe(true);
expect(isToolAllowedByPermission("edit_file")).toBe(true);
expect(isToolAllowedByPermission("read_file")).toBe(true);
});
it("allows all tools in yolo mode", () => {
setPermissionMode("yolo");
expect(isToolAllowedByPermission("bash")).toBe(true);
expect(isToolAllowedByPermission("write_file")).toBe(true);
expect(isToolAllowedByPermission("delete_file")).toBe(true);
expect(isToolAllowedByPermission("anything_at_all")).toBe(true);
});
it("allows read-only tools in plan mode", () => {
setPermissionMode("plan");
const allowedTools = [
"read_file", "list_directory", "search_files", "search_content",
"glob", "grep", "web_fetch", "web_search", "task", "task_output",
"bash_output", "list_shells", "tasks", "config", "ask_user",
"ask_user_question", "enter_plan_mode", "exit_plan_mode",
];
for (const tool of allowedTools) {
expect(isToolAllowedByPermission(tool)).toBe(true);
}
});
it("blocks write/mutation tools in plan mode", () => {
setPermissionMode("plan");
expect(isToolAllowedByPermission("bash")).toBe(false);
expect(isToolAllowedByPermission("write_file")).toBe(false);
expect(isToolAllowedByPermission("edit_file")).toBe(false);
expect(isToolAllowedByPermission("multi_edit")).toBe(false);
expect(isToolAllowedByPermission("delete_file")).toBe(false);
});
it("blocks unknown tools in plan mode", () => {
setPermissionMode("plan");
expect(isToolAllowedByPermission("some_random_tool")).toBe(false);
expect(isToolAllowedByPermission("")).toBe(false);
});
it("reflects mode changes immediately", () => {
setPermissionMode("plan");
expect(isToolAllowedByPermission("bash")).toBe(false);
setPermissionMode("yolo");
expect(isToolAllowedByPermission("bash")).toBe(true);
setPermissionMode("plan");
expect(isToolAllowedByPermission("bash")).toBe(false);
});
});
});
// ============================================================================
// Module 2: model-manager
// ============================================================================
import {
setModel,
setModelById,
getModel,
getModelShortName,
estimateCostUsd,
} from "./model-manager.js";
describe("model-manager", () => {
beforeEach(() => {
setModelById("claude-opus-4-6");
});
describe("getModel / setModelById", () => {
it("defaults to claude-opus-4-6", () => {
expect(getModel()).toBe("claude-opus-4-6");
});
it("sets model by full ID", () => {
setModelById("claude-sonnet-4-20250514");
expect(getModel()).toBe("claude-sonnet-4-20250514");
});
it("accepts arbitrary model IDs (no validation)", () => {
setModelById("some-custom-model-id");
expect(getModel()).toBe("some-custom-model-id");
});
});
describe("setModel — alias resolution", () => {
it("resolves 'opus' to claude-opus-4-6", () => {
const result = setModel("opus");
expect(result.success).toBe(true);
expect(result.model).toBe("claude-opus-4-6");
expect(getModel()).toBe("claude-opus-4-6");
});
it("resolves 'sonnet' to claude-sonnet-4-20250514", () => {
const result = setModel("sonnet");
expect(result.success).toBe(true);
expect(result.model).toBe("claude-sonnet-4-20250514");
});
it("resolves 'haiku' to claude-haiku-4-5-20251001", () => {
const result = setModel("haiku");
expect(result.success).toBe(true);
expect(result.model).toBe("claude-haiku-4-5-20251001");
});
it("resolves bedrock aliases", () => {
const result = setModel("bedrock-sonnet");
expect(result.success).toBe(true);
expect(result.model).toContain("us.anthropic.claude-sonnet");
});
it("resolves gemini aliases", () => {
const result = setModel("gemini-pro");
expect(result.success).toBe(true);
expect(result.model).toBe("gemini-2.5-pro");
});
it("resolves gemini-flash alias", () => {
const result = setModel("gemini-flash");
expect(result.success).toBe(true);
expect(result.model).toBe("gemini-2.5-flash");
});
it("is case-insensitive for aliases", () => {
const result = setModel("OPUS");
expect(result.success).toBe(true);
expect(result.model).toBe("claude-opus-4-6");
});
it("handles mixed case", () => {
const result = setModel("Sonnet");
expect(result.success).toBe(true);
expect(result.model).toBe("claude-sonnet-4-20250514");
});
it("handles legacy 'claude-opus' prefix stripping", () => {
const result = setModel("claude-opus");
expect(result.success).toBe(true);
expect(result.model).toBe("claude-opus-4-6");
});
it("returns error for unknown model name", () => {
const result = setModel("gpt-4");
expect(result.success).toBe(false);
expect(result.error).toContain("Unknown model");
expect(result.error).toContain("gpt-4");
expect(result.model).toBe("claude-opus-4-6");
});
it("returns error for empty string", () => {
const result = setModel("");
expect(result.success).toBe(false);
expect(result.error).toContain("Unknown model");
});
it("lists valid model keys in error message", () => {
const result = setModel("nonexistent");
expect(result.error).toContain("opus");
expect(result.error).toContain("sonnet");
expect(result.error).toContain("haiku");
});
});
describe("getModelShortName", () => {
it("returns 'opus' for claude-opus-4-6", () => {
setModelById("claude-opus-4-6");
expect(getModelShortName()).toBe("opus");
});
it("returns 'sonnet' for claude-sonnet-4-20250514", () => {
setModelById("claude-sonnet-4-20250514");
expect(getModelShortName()).toBe("sonnet");
});
it("returns 'haiku' for claude-haiku-4-5-20251001", () => {
setModelById("claude-haiku-4-5-20251001");
expect(getModelShortName()).toBe("haiku");
});
it("falls back to 'sonnet' for unknown model IDs", () => {
setModelById("some-unknown-model");
expect(getModelShortName()).toBe("sonnet");
});
it("returns correct short name for gemini models", () => {
setModelById("gemini-2.5-pro");
expect(getModelShortName()).toBe("gemini-pro");
});
});
describe("estimateCostUsd", () => {
it("uses the active model when none specified", () => {
setModelById("claude-opus-4-6");
const cost = estimateCostUsd(1_000_000, 0);
expect(cost).toBeCloseTo(5.0);
});
it("uses the specified model when provided", () => {
setModelById("claude-opus-4-6");
const cost = estimateCostUsd(1_000_000, 0, "claude-sonnet-4-20250514");
expect(cost).toBeCloseTo(3.0);
});
it("includes thinking tokens", () => {
setModelById("claude-opus-4-6");
const cost = estimateCostUsd(0, 0, undefined, 1_000_000);
expect(cost).toBeCloseTo(25.0);
});
it("returns 0 for zero tokens", () => {
expect(estimateCostUsd(0, 0)).toBe(0);
});
it("calculates combined input + output cost", () => {
setModelById("claude-opus-4-6");
const cost = estimateCostUsd(1_000_000, 1_000_000);
expect(cost).toBeCloseTo(30.0);
});
});
});
// ============================================================================
// Module 3: session-persistence (mocked fs)
// ============================================================================
// Mock fs before session-persistence import. vi.mock is hoisted automatically.
vi.mock("fs", () => {
const store = new Map<string, string>();
return {
existsSync: vi.fn((path: string) => store.has(path) || store.has(path + "/__dir__")),
mkdirSync: vi.fn((path: string) => {
store.set(path + "/__dir__", "");
}),
writeFileSync: vi.fn((path: string, data: string) => {
store.set(path, data);
}),
readFileSync: vi.fn((path: string) => {
const content = store.get(path);
if (content === undefined) throw new Error(`ENOENT: no such file ${path}`);
return content;
}),
readdirSync: vi.fn((_path: string) => {
const files: string[] = [];
for (const key of store.keys()) {
if (key.startsWith(_path + "/") && key.endsWith(".json") && !key.includes("/__dir__")) {
files.push(key.replace(_path + "/", ""));
}
}
return files;
}),
appendFileSync: vi.fn((_path: string, _data: string) => {
const existing = store.get(_path) || "";
store.set(_path, existing + _data);
}),
__store: store,
__reset: () => store.clear(),
};
});
import {
saveSession,
loadSession,
listSessions,
findLatestSessionForCwd,
type SessionMeta,
} from "./session-persistence.js";
import * as fsMock from "fs";
describe("session-persistence", () => {
beforeEach(() => {
(fsMock as any).__reset();
vi.clearAllMocks();
});
describe("saveSession", () => {
it("saves a session and returns the session ID", () => {
const messages = [{ role: "user" as const, content: "Hello" }];
const id = saveSession(messages, "test-session-1");
expect(id).toBe("test-session-1");
});
it("generates a session ID if none provided", () => {
const messages = [{ role: "user" as const, content: "Hello" }];
const id = saveSession(messages);
expect(id).toMatch(/^session-\d+$/);
});
it("writes valid JSON to disk", () => {
const messages = [{ role: "user" as const, content: "Test message" }];
saveSession(messages, "json-test");
const written = (fsMock.writeFileSync as any).mock.calls.find(
(c: any[]) => c[0].includes("json-test")
);
expect(written).toBeDefined();
const data = JSON.parse(written[1]);
expect(data.meta).toBeDefined();
expect(data.messages).toEqual(messages);
});
it("includes correct meta fields", () => {
const messages = [{ role: "user" as const, content: "My first message" }];
saveSession(messages, "meta-test");
const written = (fsMock.writeFileSync as any).mock.calls.find(
(c: any[]) => c[0].includes("meta-test")
);
const data = JSON.parse(written[1]);
const meta: SessionMeta = data.meta;
expect(meta.id).toBe("meta-test");
expect(meta.model).toBe("claude-opus-4-6");
expect(meta.messageCount).toBe(1);
expect(meta.createdAt).toBeDefined();
expect(meta.updatedAt).toBeDefined();
expect(meta.cwd).toBeDefined();
});
it("extracts title from first user string message", () => {
const messages = [
{ role: "user" as const, content: "Build a web app with React and TypeScript" },
{ role: "assistant" as const, content: "Sure!" },
];
saveSession(messages, "title-test");
const written = (fsMock.writeFileSync as any).mock.calls.find(
(c: any[]) => c[0].includes("title-test")
);
const data = JSON.parse(written[1]);
expect(data.meta.title).toBe("Build a web app with React and TypeScript");
});
it("truncates long titles to 60 chars with ellipsis", () => {
const longMsg = "A".repeat(100);
const messages = [{ role: "user" as const, content: longMsg }];
saveSession(messages, "long-title");
const written = (fsMock.writeFileSync as any).mock.calls.find(
(c: any[]) => c[0].includes("long-title")
);
const data = JSON.parse(written[1]);
expect(data.meta.title).toBe("A".repeat(60) + "...");
expect(data.meta.title.length).toBe(63);
});
it("does not add ellipsis when title is exactly 60 chars", () => {
const exactMsg = "B".repeat(60);
const messages = [{ role: "user" as const, content: exactMsg }];
saveSession(messages, "exact-title");
const written = (fsMock.writeFileSync as any).mock.calls.find(
(c: any[]) => c[0].includes("exact-title")
);
const data = JSON.parse(written[1]);
expect(data.meta.title).toBe("B".repeat(60));
expect(data.meta.title).not.toContain("...");
});
it("uses 'Untitled session' when no user message found", () => {
const messages = [{ role: "assistant" as const, content: "I started talking first" }];
saveSession(messages, "no-user-msg");
const written = (fsMock.writeFileSync as any).mock.calls.find(
(c: any[]) => c[0].includes("no-user-msg")
);
const data = JSON.parse(written[1]);
expect(data.meta.title).toBe("Untitled session");
});
it("uses 'Untitled session' for empty messages array", () => {
const messages: any[] = [];
saveSession(messages, "empty-msgs");
const written = (fsMock.writeFileSync as any).mock.calls.find(
(c: any[]) => c[0].includes("empty-msgs")
);
const data = JSON.parse(written[1]);
expect(data.meta.title).toBe("Untitled session");
});
it("extracts title from array content blocks", () => {
const messages = [
{
role: "user" as const,
content: [{ type: "text" as const, text: "Help me debug this code" }],
},
];
saveSession(messages, "array-content");
const written = (fsMock.writeFileSync as any).mock.calls.find(
(c: any[]) => c[0].includes("array-content")
);
const data = JSON.parse(written[1]);
expect(data.meta.title).toBe("Help me debug this code");
});
it("calls mkdirSync to create sessions directory", () => {
const messages = [{ role: "user" as const, content: "hi" }];
saveSession(messages, "dir-test");
expect(fsMock.mkdirSync).toHaveBeenCalled();
});
it("appends to history file", () => {
const messages = [{ role: "user" as const, content: "hi" }];
saveSession(messages, "history-test");
expect(fsMock.appendFileSync).toHaveBeenCalled();
// Find the appendFileSync call that targets history.jsonl
const appendCall = (fsMock.appendFileSync as any).mock.calls.find(
(c: any[]) => typeof c[0] === "string" && c[0].includes("history.jsonl")
);
expect(appendCall).toBeDefined();
const entry = JSON.parse(appendCall[1].trim());
expect(entry.sessionId).toBe("history-test");
expect(entry.model).toBe("claude-opus-4-6");
});
});
describe("loadSession", () => {
it("returns null for non-existent session", () => {
const result = loadSession("does-not-exist");
expect(result).toBeNull();
});
it("loads a previously saved session", () => {
const messages = [{ role: "user" as const, content: "Load me" }];
saveSession(messages, "load-test");
const result = loadSession("load-test");
expect(result).not.toBeNull();
expect(result!.meta.id).toBe("load-test");
expect(result!.messages).toEqual(messages);
});
it("returns null for corrupted session file", () => {
const store = (fsMock as any).__store as Map<string, string>;
// Save a dummy session to discover the sessions directory path
saveSession([{ role: "user" as const, content: "x" }], "dummy");
const writeCall = (fsMock.writeFileSync as any).mock.calls.find(
(c: any[]) => c[0].includes("dummy.json")
);
const dir = writeCall[0].replace("/dummy.json", "");
// Write corrupted JSON to the same directory
store.set(dir + "/corrupted.json", "NOT VALID JSON {{{");
const result = loadSession("corrupted");
expect(result).toBeNull();
});
});
describe("listSessions", () => {
it("returns empty array when no sessions exist", () => {
const result = listSessions();
expect(result).toEqual([]);
});
it("returns sessions sorted reverse-alphabetically", () => {
saveSession([{ role: "user" as const, content: "first" }], "session-001");
saveSession([{ role: "user" as const, content: "second" }], "session-002");
saveSession([{ role: "user" as const, content: "third" }], "session-003");
const sessions = listSessions();
expect(sessions.length).toBe(3);
expect(sessions[0].id).toBe("session-003");
expect(sessions[1].id).toBe("session-002");
expect(sessions[2].id).toBe("session-001");
});
it("respects the limit parameter", () => {
saveSession([{ role: "user" as const, content: "a" }], "session-a");
saveSession([{ role: "user" as const, content: "b" }], "session-b");
saveSession([{ role: "user" as const, content: "c" }], "session-c");
const sessions = listSessions(2);
expect(sessions.length).toBe(2);
});
it("defaults to limit of 20", () => {
for (let i = 0; i < 25; i++) {
saveSession(
[{ role: "user" as const, content: `msg ${i}` }],
`session-${String(i).padStart(3, "0")}`
);
}
const sessions = listSessions();
expect(sessions.length).toBe(20);
});
it("skips files with missing meta", () => {
saveSession([{ role: "user" as const, content: "valid" }], "valid-session");
const store = (fsMock as any).__store as Map<string, string>;
const writeCall = (fsMock.writeFileSync as any).mock.calls.find(
(c: any[]) => c[0].includes("valid-session.json")
);
const dir = writeCall[0].replace("/valid-session.json", "");
store.set(dir + "/no-meta.json", JSON.stringify({ messages: [] }));
const sessions = listSessions();
const ids = sessions.map((s) => s.id);
expect(ids).toContain("valid-session");
expect(ids).not.toContain("no-meta");
});
});
describe("findLatestSessionForCwd", () => {
it("returns null when no sessions match current cwd", () => {
const result = findLatestSessionForCwd();
expect(result).toBeNull();
});
it("returns the first session matching process.cwd()", () => {
const messages = [{ role: "user" as const, content: "In this dir" }];
saveSession(messages, "cwd-session");
// saveSession stores process.cwd() as cwd, findLatestSessionForCwd
// compares against process.cwd(), so they should match
const result = findLatestSessionForCwd();
if (result) {
expect(result.id).toBe("cwd-session");
}
});
});
});