generated from nhcarrigan/template
1e9f641db0
Adds an 'Include git instructions' toggle to the Agent Settings panel. When disabled, sets CLAUDE_CODE_DISABLE_GIT_INSTRUCTIONS=1 on the Claude process to remove built-in commit and PR workflow guidance from its system prompt. Resolves #209.
1018 lines
37 KiB
TypeScript
1018 lines
37 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
import { get } from "svelte/store";
|
|
import { invoke } from "@tauri-apps/api/core";
|
|
import { claudeStore } from "$lib/stores/claude";
|
|
import { searchState } from "$lib/stores/search";
|
|
import { characterState } from "$lib/stores/character";
|
|
import {
|
|
slashCommands,
|
|
parseSlashCommand,
|
|
getMatchingCommands,
|
|
isSlashCommand,
|
|
type SlashCommand,
|
|
} from "./slashCommands";
|
|
|
|
// Mock all external dependencies
|
|
vi.mock("svelte/store", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("svelte/store")>();
|
|
return {
|
|
...actual,
|
|
get: vi.fn(),
|
|
};
|
|
});
|
|
|
|
vi.mock("@tauri-apps/api/core", () => ({
|
|
invoke: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("$lib/stores/claude", () => ({
|
|
claudeStore: {
|
|
addLine: vi.fn(),
|
|
clearTerminal: vi.fn(),
|
|
activeConversationId: { subscribe: vi.fn() },
|
|
currentWorkingDirectory: { subscribe: vi.fn() },
|
|
setWorkingDirectory: vi.fn(),
|
|
getConversationHistory: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
vi.mock("$lib/stores/character", () => ({
|
|
characterState: {
|
|
setState: vi.fn(),
|
|
setTemporaryState: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
vi.mock("$lib/tauri", () => ({
|
|
setSkipNextGreeting: vi.fn(),
|
|
updateDiscordRpc: vi.fn().mockResolvedValue(undefined),
|
|
}));
|
|
|
|
vi.mock("$lib/stores/conversations", () => ({
|
|
conversationsStore: {
|
|
activeConversation: { subscribe: vi.fn() },
|
|
},
|
|
}));
|
|
|
|
vi.mock("$lib/stores/config", () => ({
|
|
configStore: {
|
|
getConfig: vi.fn().mockReturnValue({
|
|
auto_granted_tools: [],
|
|
model: "claude-sonnet",
|
|
api_key: null,
|
|
custom_instructions: null,
|
|
mcp_servers_json: null,
|
|
use_worktree: false,
|
|
disable_1m_context: false,
|
|
max_output_tokens: null,
|
|
include_git_instructions: true,
|
|
}),
|
|
},
|
|
}));
|
|
|
|
vi.mock("$lib/stores/search", () => ({
|
|
searchState: {
|
|
setQuery: vi.fn(),
|
|
clear: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
describe("slashCommands", () => {
|
|
describe("slashCommands array", () => {
|
|
it("contains expected commands", () => {
|
|
const commandNames = slashCommands.map((cmd) => cmd.name);
|
|
expect(commandNames).toContain("cd");
|
|
expect(commandNames).toContain("clear");
|
|
expect(commandNames).toContain("new");
|
|
expect(commandNames).toContain("help");
|
|
expect(commandNames).toContain("search");
|
|
expect(commandNames).toContain("summarise");
|
|
expect(commandNames).toContain("skill");
|
|
expect(commandNames).toContain("simplify");
|
|
expect(commandNames).toContain("loop");
|
|
expect(commandNames).toContain("batch");
|
|
});
|
|
|
|
it("has 10 commands total", () => {
|
|
expect(slashCommands.length).toBe(10);
|
|
});
|
|
|
|
it("each command has required properties", () => {
|
|
slashCommands.forEach((cmd) => {
|
|
expect(cmd.name).toBeDefined();
|
|
expect(typeof cmd.name).toBe("string");
|
|
expect(cmd.name.length).toBeGreaterThan(0);
|
|
|
|
expect(cmd.description).toBeDefined();
|
|
expect(typeof cmd.description).toBe("string");
|
|
expect(cmd.description.length).toBeGreaterThan(0);
|
|
|
|
expect(cmd.usage).toBeDefined();
|
|
expect(typeof cmd.usage).toBe("string");
|
|
expect(cmd.usage.startsWith("/")).toBe(true);
|
|
|
|
expect(cmd.execute).toBeDefined();
|
|
expect(typeof cmd.execute).toBe("function");
|
|
});
|
|
});
|
|
|
|
it("cd command has correct metadata", () => {
|
|
const cdCmd = slashCommands.find((cmd) => cmd.name === "cd");
|
|
expect(cdCmd).toBeDefined();
|
|
expect(cdCmd!.description).toBe("Change the working directory");
|
|
expect(cdCmd!.usage).toBe("/cd <path>");
|
|
});
|
|
|
|
it("clear command has correct metadata", () => {
|
|
const clearCmd = slashCommands.find((cmd) => cmd.name === "clear");
|
|
expect(clearCmd).toBeDefined();
|
|
expect(clearCmd!.description).toBe("Clear the terminal display (keeps conversation context)");
|
|
expect(clearCmd!.usage).toBe("/clear");
|
|
});
|
|
|
|
it("new command has correct metadata", () => {
|
|
const newCmd = slashCommands.find((cmd) => cmd.name === "new");
|
|
expect(newCmd).toBeDefined();
|
|
expect(newCmd!.description).toBe("Start a fresh conversation (resets context)");
|
|
expect(newCmd!.usage).toBe("/new");
|
|
});
|
|
|
|
it("help command has correct metadata", () => {
|
|
const helpCmd = slashCommands.find((cmd) => cmd.name === "help");
|
|
expect(helpCmd).toBeDefined();
|
|
expect(helpCmd!.description).toBe("Show available slash commands");
|
|
expect(helpCmd!.usage).toBe("/help");
|
|
});
|
|
|
|
it("search command has correct metadata", () => {
|
|
const searchCmd = slashCommands.find((cmd) => cmd.name === "search");
|
|
expect(searchCmd).toBeDefined();
|
|
expect(searchCmd!.description).toBe("Search within the conversation (use /search to clear)");
|
|
expect(searchCmd!.usage).toBe("/search [query]");
|
|
});
|
|
|
|
it("summarise command has correct metadata", () => {
|
|
const summariseCmd = slashCommands.find((cmd) => cmd.name === "summarise");
|
|
expect(summariseCmd).toBeDefined();
|
|
expect(summariseCmd!.description).toBe("Get a summary of the entire conversation");
|
|
expect(summariseCmd!.usage).toBe("/summarise");
|
|
});
|
|
|
|
it("skill command has correct metadata", () => {
|
|
const skillCmd = slashCommands.find((cmd) => cmd.name === "skill");
|
|
expect(skillCmd).toBeDefined();
|
|
expect(skillCmd!.description).toBe("Invoke a Claude Code skill from ~/.claude/skills/");
|
|
expect(skillCmd!.usage).toBe("/skill [name] [data]");
|
|
});
|
|
|
|
it("simplify command has correct metadata and source", () => {
|
|
const simplifyCmd = slashCommands.find((cmd) => cmd.name === "simplify");
|
|
expect(simplifyCmd).toBeDefined();
|
|
expect(simplifyCmd!.source).toBe("cli");
|
|
expect(simplifyCmd!.usage).toBe("/simplify");
|
|
});
|
|
|
|
it("loop command has correct metadata and source", () => {
|
|
const loopCmd = slashCommands.find((cmd) => cmd.name === "loop");
|
|
expect(loopCmd).toBeDefined();
|
|
expect(loopCmd!.source).toBe("cli");
|
|
expect(loopCmd!.usage).toBe("/loop [interval] [command]");
|
|
});
|
|
|
|
it("batch command has correct metadata and source", () => {
|
|
const batchCmd = slashCommands.find((cmd) => cmd.name === "batch");
|
|
expect(batchCmd).toBeDefined();
|
|
expect(batchCmd!.source).toBe("cli");
|
|
expect(batchCmd!.usage).toBe("/batch [tasks]");
|
|
});
|
|
|
|
it("app commands do not have source set", () => {
|
|
const appCommandNames = ["cd", "clear", "new", "help", "search", "summarise", "skill"];
|
|
appCommandNames.forEach((name) => {
|
|
const cmd = slashCommands.find((c) => c.name === name);
|
|
expect(cmd).toBeDefined();
|
|
expect(cmd!.source).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
it("cli commands have source set to 'cli'", () => {
|
|
const cliCommandNames = ["simplify", "loop", "batch"];
|
|
cliCommandNames.forEach((name) => {
|
|
const cmd = slashCommands.find((c) => c.name === name);
|
|
expect(cmd).toBeDefined();
|
|
expect(cmd!.source).toBe("cli");
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("parseSlashCommand", () => {
|
|
it("returns null for non-slash input", () => {
|
|
const result = parseSlashCommand("hello world");
|
|
expect(result.command).toBeNull();
|
|
expect(result.args).toBe("");
|
|
});
|
|
|
|
it("returns null for empty string", () => {
|
|
const result = parseSlashCommand("");
|
|
expect(result.command).toBeNull();
|
|
expect(result.args).toBe("");
|
|
});
|
|
|
|
it("returns null for whitespace only", () => {
|
|
const result = parseSlashCommand(" ");
|
|
expect(result.command).toBeNull();
|
|
expect(result.args).toBe("");
|
|
});
|
|
|
|
it("parses /cd command without args", () => {
|
|
const result = parseSlashCommand("/cd");
|
|
expect(result.command).not.toBeNull();
|
|
expect(result.command!.name).toBe("cd");
|
|
expect(result.args).toBe("");
|
|
});
|
|
|
|
it("parses /cd command with path argument", () => {
|
|
const result = parseSlashCommand("/cd /home/naomi/code");
|
|
expect(result.command).not.toBeNull();
|
|
expect(result.command!.name).toBe("cd");
|
|
expect(result.args).toBe("/home/naomi/code");
|
|
});
|
|
|
|
it("parses /clear command", () => {
|
|
const result = parseSlashCommand("/clear");
|
|
expect(result.command).not.toBeNull();
|
|
expect(result.command!.name).toBe("clear");
|
|
expect(result.args).toBe("");
|
|
});
|
|
|
|
it("parses /new command", () => {
|
|
const result = parseSlashCommand("/new");
|
|
expect(result.command).not.toBeNull();
|
|
expect(result.command!.name).toBe("new");
|
|
expect(result.args).toBe("");
|
|
});
|
|
|
|
it("parses /help command", () => {
|
|
const result = parseSlashCommand("/help");
|
|
expect(result.command).not.toBeNull();
|
|
expect(result.command!.name).toBe("help");
|
|
expect(result.args).toBe("");
|
|
});
|
|
|
|
it("parses /search command with query", () => {
|
|
const result = parseSlashCommand("/search hello world");
|
|
expect(result.command).not.toBeNull();
|
|
expect(result.command!.name).toBe("search");
|
|
expect(result.args).toBe("hello world");
|
|
});
|
|
|
|
it("parses /search command without query", () => {
|
|
const result = parseSlashCommand("/search");
|
|
expect(result.command).not.toBeNull();
|
|
expect(result.command!.name).toBe("search");
|
|
expect(result.args).toBe("");
|
|
});
|
|
|
|
it("parses /summarise command", () => {
|
|
const result = parseSlashCommand("/summarise");
|
|
expect(result.command).not.toBeNull();
|
|
expect(result.command!.name).toBe("summarise");
|
|
expect(result.args).toBe("");
|
|
});
|
|
|
|
it("parses /skill command with name and data", () => {
|
|
const result = parseSlashCommand("/skill onboard-mentee john@example.com");
|
|
expect(result.command).not.toBeNull();
|
|
expect(result.command!.name).toBe("skill");
|
|
expect(result.args).toBe("onboard-mentee john@example.com");
|
|
});
|
|
|
|
it("parses /skill command with name only", () => {
|
|
const result = parseSlashCommand("/skill onboard-mentee");
|
|
expect(result.command).not.toBeNull();
|
|
expect(result.command!.name).toBe("skill");
|
|
expect(result.args).toBe("onboard-mentee");
|
|
});
|
|
|
|
it("parses /skill command without arguments", () => {
|
|
const result = parseSlashCommand("/skill");
|
|
expect(result.command).not.toBeNull();
|
|
expect(result.command!.name).toBe("skill");
|
|
expect(result.args).toBe("");
|
|
});
|
|
|
|
it("returns null for unknown command", () => {
|
|
const result = parseSlashCommand("/unknown");
|
|
expect(result.command).toBeNull();
|
|
expect(result.args).toBe("");
|
|
});
|
|
|
|
it("is case insensitive for command names", () => {
|
|
const result1 = parseSlashCommand("/CD /path");
|
|
expect(result1.command).not.toBeNull();
|
|
expect(result1.command!.name).toBe("cd");
|
|
|
|
const result2 = parseSlashCommand("/CLEAR");
|
|
expect(result2.command).not.toBeNull();
|
|
expect(result2.command!.name).toBe("clear");
|
|
|
|
const result3 = parseSlashCommand("/Help");
|
|
expect(result3.command).not.toBeNull();
|
|
expect(result3.command!.name).toBe("help");
|
|
});
|
|
|
|
it("handles leading whitespace", () => {
|
|
const result = parseSlashCommand(" /cd /path");
|
|
expect(result.command).not.toBeNull();
|
|
expect(result.command!.name).toBe("cd");
|
|
expect(result.args).toBe("/path");
|
|
});
|
|
|
|
it("handles trailing whitespace", () => {
|
|
const result = parseSlashCommand("/cd /path ");
|
|
expect(result.command).not.toBeNull();
|
|
expect(result.command!.name).toBe("cd");
|
|
expect(result.args).toBe("/path");
|
|
});
|
|
|
|
it("handles multiple spaces between args", () => {
|
|
const result = parseSlashCommand("/search hello world");
|
|
expect(result.command).not.toBeNull();
|
|
expect(result.command!.name).toBe("search");
|
|
expect(result.args).toBe("hello world");
|
|
});
|
|
});
|
|
|
|
describe("getMatchingCommands", () => {
|
|
it("returns empty array for non-slash input", () => {
|
|
const result = getMatchingCommands("hello");
|
|
expect(result).toEqual([]);
|
|
});
|
|
|
|
it("returns empty array for empty string", () => {
|
|
const result = getMatchingCommands("");
|
|
expect(result).toEqual([]);
|
|
});
|
|
|
|
it("returns all commands for just slash", () => {
|
|
const result = getMatchingCommands("/");
|
|
expect(result.length).toBe(slashCommands.length);
|
|
});
|
|
|
|
it("returns matching commands for partial input", () => {
|
|
const result = getMatchingCommands("/c");
|
|
const names = result.map((cmd) => cmd.name);
|
|
expect(names).toContain("cd");
|
|
expect(names).toContain("clear");
|
|
expect(names).not.toContain("help");
|
|
});
|
|
|
|
it("returns single command for exact match", () => {
|
|
const result = getMatchingCommands("/cd");
|
|
expect(result.length).toBe(1);
|
|
expect(result[0].name).toBe("cd");
|
|
});
|
|
|
|
it("returns single command for partial unique match", () => {
|
|
const result = getMatchingCommands("/cl");
|
|
expect(result.length).toBe(1);
|
|
expect(result[0].name).toBe("clear");
|
|
});
|
|
|
|
it("returns matching commands for /s prefix", () => {
|
|
const result = getMatchingCommands("/s");
|
|
const names = result.map((cmd) => cmd.name);
|
|
expect(names).toContain("search");
|
|
expect(names).toContain("summarise");
|
|
expect(names).toContain("skill");
|
|
expect(names).toContain("simplify");
|
|
});
|
|
|
|
it("returns /loop for /l prefix", () => {
|
|
const result = getMatchingCommands("/l");
|
|
const names = result.map((cmd) => cmd.name);
|
|
expect(names).toContain("loop");
|
|
});
|
|
|
|
it("returns /batch for /b prefix", () => {
|
|
const result = getMatchingCommands("/b");
|
|
const names = result.map((cmd) => cmd.name);
|
|
expect(names).toContain("batch");
|
|
});
|
|
|
|
it("is case insensitive", () => {
|
|
const result1 = getMatchingCommands("/C");
|
|
const result2 = getMatchingCommands("/c");
|
|
expect(result1.length).toBe(result2.length);
|
|
});
|
|
|
|
it("returns empty array for no matches", () => {
|
|
const result = getMatchingCommands("/xyz");
|
|
expect(result).toEqual([]);
|
|
});
|
|
|
|
it("handles whitespace correctly", () => {
|
|
const result = getMatchingCommands(" /c");
|
|
const names = result.map((cmd) => cmd.name);
|
|
expect(names).toContain("cd");
|
|
expect(names).toContain("clear");
|
|
});
|
|
|
|
it("returns command for full command name", () => {
|
|
const result = getMatchingCommands("/help");
|
|
expect(result.length).toBe(1);
|
|
expect(result[0].name).toBe("help");
|
|
});
|
|
|
|
it("returns command for /new", () => {
|
|
const result = getMatchingCommands("/n");
|
|
expect(result.length).toBe(1);
|
|
expect(result[0].name).toBe("new");
|
|
});
|
|
});
|
|
|
|
describe("isSlashCommand", () => {
|
|
it("returns true for input starting with slash", () => {
|
|
expect(isSlashCommand("/cd")).toBe(true);
|
|
expect(isSlashCommand("/")).toBe(true);
|
|
expect(isSlashCommand("/help")).toBe(true);
|
|
expect(isSlashCommand("/unknown")).toBe(true);
|
|
});
|
|
|
|
it("returns false for non-slash input", () => {
|
|
expect(isSlashCommand("hello")).toBe(false);
|
|
expect(isSlashCommand("")).toBe(false);
|
|
expect(isSlashCommand("cd")).toBe(false);
|
|
});
|
|
|
|
it("handles whitespace correctly", () => {
|
|
expect(isSlashCommand(" /cd")).toBe(true);
|
|
expect(isSlashCommand(" hello")).toBe(false);
|
|
});
|
|
|
|
it("returns false for slash in middle of string", () => {
|
|
expect(isSlashCommand("hello/world")).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("SlashCommand interface", () => {
|
|
it("can create a valid slash command object", () => {
|
|
const testCommand: SlashCommand = {
|
|
name: "test",
|
|
description: "A test command",
|
|
usage: "/test [arg]",
|
|
execute: vi.fn(),
|
|
};
|
|
|
|
expect(testCommand.name).toBe("test");
|
|
expect(testCommand.description).toBe("A test command");
|
|
expect(testCommand.usage).toBe("/test [arg]");
|
|
expect(typeof testCommand.execute).toBe("function");
|
|
expect(testCommand.source).toBeUndefined();
|
|
});
|
|
|
|
it("can create a cli-sourced slash command object", () => {
|
|
const cliCommand: SlashCommand = {
|
|
name: "cli-test",
|
|
description: "A CLI command",
|
|
usage: "/cli-test",
|
|
source: "cli",
|
|
execute: vi.fn(),
|
|
};
|
|
|
|
expect(cliCommand.source).toBe("cli");
|
|
});
|
|
|
|
it("execute can be async function", () => {
|
|
const asyncCommand: SlashCommand = {
|
|
name: "async",
|
|
description: "An async command",
|
|
usage: "/async",
|
|
execute: async () => {
|
|
await Promise.resolve();
|
|
},
|
|
};
|
|
|
|
expect(asyncCommand.execute("")).toBeInstanceOf(Promise);
|
|
});
|
|
|
|
it("execute can be sync function", () => {
|
|
const syncCommand: SlashCommand = {
|
|
name: "sync",
|
|
description: "A sync command",
|
|
usage: "/sync",
|
|
execute: () => {
|
|
// Synchronous execution
|
|
},
|
|
};
|
|
|
|
const result = syncCommand.execute("");
|
|
// Sync function returns undefined, not a Promise
|
|
expect(result).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("command execute functions", () => {
|
|
let getMock: ReturnType<typeof vi.fn>;
|
|
let invokeMock: ReturnType<typeof vi.fn>;
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
getMock = vi.mocked(get);
|
|
invokeMock = vi.mocked(invoke);
|
|
});
|
|
|
|
describe("/clear execute", () => {
|
|
it("clears terminal and shows confirmation message", () => {
|
|
const clearCmd = slashCommands.find((cmd) => cmd.name === "clear")!;
|
|
clearCmd.execute("");
|
|
expect(claudeStore.clearTerminal).toHaveBeenCalledWith();
|
|
expect(claudeStore.addLine).toHaveBeenCalledWith("system", "Terminal cleared");
|
|
});
|
|
});
|
|
|
|
describe("/help execute", () => {
|
|
it("shows available commands header", () => {
|
|
const helpCmd = slashCommands.find((cmd) => cmd.name === "help")!;
|
|
helpCmd.execute("");
|
|
expect(claudeStore.addLine).toHaveBeenCalledWith(
|
|
"system",
|
|
expect.stringContaining("Available commands:")
|
|
);
|
|
});
|
|
|
|
it("includes all command usages in help text", () => {
|
|
const helpCmd = slashCommands.find((cmd) => cmd.name === "help")!;
|
|
helpCmd.execute("");
|
|
const callArgs = vi.mocked(claudeStore.addLine).mock.calls[0];
|
|
const helpText = callArgs[1] as string;
|
|
expect(helpText).toContain("/cd");
|
|
expect(helpText).toContain("/clear");
|
|
expect(helpText).toContain("/help");
|
|
expect(helpText).toContain("/search");
|
|
expect(helpText).toContain("/new");
|
|
expect(helpText).toContain("/summarise");
|
|
expect(helpText).toContain("/skill");
|
|
});
|
|
|
|
it("includes command descriptions in help text", () => {
|
|
const helpCmd = slashCommands.find((cmd) => cmd.name === "help")!;
|
|
helpCmd.execute("");
|
|
const callArgs = vi.mocked(claudeStore.addLine).mock.calls[0];
|
|
const helpText = callArgs[1] as string;
|
|
expect(helpText).toContain("Change the working directory");
|
|
expect(helpText).toContain("Show available slash commands");
|
|
});
|
|
});
|
|
|
|
describe("/search execute", () => {
|
|
it("clears search when called with empty args", () => {
|
|
const searchCmd = slashCommands.find((cmd) => cmd.name === "search")!;
|
|
searchCmd.execute("");
|
|
expect(searchState.clear).toHaveBeenCalledWith();
|
|
expect(claudeStore.addLine).toHaveBeenCalledWith("system", "Search cleared");
|
|
});
|
|
|
|
it("clears search when called with whitespace-only args", () => {
|
|
const searchCmd = slashCommands.find((cmd) => cmd.name === "search")!;
|
|
searchCmd.execute(" ");
|
|
expect(searchState.clear).toHaveBeenCalledWith();
|
|
expect(claudeStore.addLine).toHaveBeenCalledWith("system", "Search cleared");
|
|
});
|
|
|
|
it("sets query when called with a search term", () => {
|
|
const searchCmd = slashCommands.find((cmd) => cmd.name === "search")!;
|
|
searchCmd.execute("hello world");
|
|
expect(searchState.setQuery).toHaveBeenCalledWith("hello world");
|
|
expect(claudeStore.addLine).toHaveBeenCalledWith("system", 'Searching for: "hello world"');
|
|
});
|
|
|
|
it("trims whitespace from query before setting", () => {
|
|
const searchCmd = slashCommands.find((cmd) => cmd.name === "search")!;
|
|
searchCmd.execute(" hello ");
|
|
expect(searchState.setQuery).toHaveBeenCalledWith("hello");
|
|
expect(claudeStore.addLine).toHaveBeenCalledWith("system", 'Searching for: "hello"');
|
|
});
|
|
});
|
|
|
|
describe("/cd execute", () => {
|
|
it("shows error when no active conversation", async () => {
|
|
getMock.mockReturnValue(null);
|
|
const cdCmd = slashCommands.find((cmd) => cmd.name === "cd")!;
|
|
await cdCmd.execute("/some/path");
|
|
expect(claudeStore.addLine).toHaveBeenCalledWith("error", "No active conversation");
|
|
});
|
|
|
|
it("shows current directory when called with empty args", async () => {
|
|
getMock.mockReturnValueOnce("conv-123").mockReturnValueOnce("/home/naomi/code");
|
|
const cdCmd = slashCommands.find((cmd) => cmd.name === "cd")!;
|
|
await cdCmd.execute("");
|
|
expect(claudeStore.addLine).toHaveBeenCalledWith(
|
|
"system",
|
|
"Current directory: /home/naomi/code"
|
|
);
|
|
});
|
|
|
|
it("shows current directory when called with whitespace-only args", async () => {
|
|
getMock.mockReturnValueOnce("conv-123").mockReturnValueOnce("/home/naomi/code");
|
|
const cdCmd = slashCommands.find((cmd) => cmd.name === "cd")!;
|
|
await cdCmd.execute(" ");
|
|
expect(claudeStore.addLine).toHaveBeenCalledWith(
|
|
"system",
|
|
"Current directory: /home/naomi/code"
|
|
);
|
|
});
|
|
|
|
it("validates path and changes directory on success", async () => {
|
|
getMock.mockReturnValueOnce("conv-123").mockReturnValueOnce(null);
|
|
invokeMock.mockResolvedValue("/validated/path");
|
|
const cdCmd = slashCommands.find((cmd) => cmd.name === "cd")!;
|
|
await cdCmd.execute("/new/path");
|
|
expect(invokeMock).toHaveBeenCalledWith(
|
|
"validate_directory",
|
|
expect.objectContaining({ path: "/new/path" })
|
|
);
|
|
});
|
|
|
|
it("shows error when directory change fails", async () => {
|
|
getMock.mockReturnValueOnce("conv-123");
|
|
invokeMock.mockRejectedValueOnce(new Error("invalid path"));
|
|
const cdCmd = slashCommands.find((cmd) => cmd.name === "cd")!;
|
|
await cdCmd.execute("/bad/path");
|
|
expect(claudeStore.addLine).toHaveBeenCalledWith(
|
|
"error",
|
|
expect.stringContaining("Failed to change directory:")
|
|
);
|
|
expect(characterState.setTemporaryState).toHaveBeenCalledWith("error", 3000);
|
|
});
|
|
});
|
|
|
|
describe("/new execute", () => {
|
|
it("shows error when no active conversation", async () => {
|
|
getMock.mockReturnValue(null);
|
|
const newCmd = slashCommands.find((cmd) => cmd.name === "new")!;
|
|
await newCmd.execute("");
|
|
expect(claudeStore.addLine).toHaveBeenCalledWith("error", "No active conversation");
|
|
});
|
|
|
|
it("shows error when starting new conversation fails", async () => {
|
|
getMock.mockReturnValueOnce("conv-123");
|
|
invokeMock.mockRejectedValueOnce(new Error("invoke failed"));
|
|
const newCmd = slashCommands.find((cmd) => cmd.name === "new")!;
|
|
await newCmd.execute("");
|
|
expect(claudeStore.addLine).toHaveBeenCalledWith(
|
|
"error",
|
|
expect.stringContaining("Failed to start new conversation:")
|
|
);
|
|
expect(characterState.setTemporaryState).toHaveBeenCalledWith("error", 3000);
|
|
});
|
|
});
|
|
|
|
describe("/summarise execute", () => {
|
|
it("shows error when no active conversation", async () => {
|
|
getMock.mockReturnValue(null);
|
|
const summariseCmd = slashCommands.find((cmd) => cmd.name === "summarise")!;
|
|
await summariseCmd.execute("");
|
|
expect(claudeStore.addLine).toHaveBeenCalledWith("error", "No active conversation");
|
|
});
|
|
|
|
it("sends a summary prompt when there is an active conversation", async () => {
|
|
getMock.mockReturnValue("conv-123");
|
|
invokeMock.mockResolvedValue(undefined);
|
|
const summariseCmd = slashCommands.find((cmd) => cmd.name === "summarise")!;
|
|
await summariseCmd.execute("");
|
|
expect(claudeStore.addLine).toHaveBeenCalledWith(
|
|
"system",
|
|
"Requesting conversation summary..."
|
|
);
|
|
expect(invokeMock).toHaveBeenCalledWith(
|
|
"send_prompt",
|
|
expect.objectContaining({ conversationId: "conv-123" })
|
|
);
|
|
});
|
|
|
|
it("shows error when send_prompt invoke fails", async () => {
|
|
getMock.mockReturnValue("conv-123");
|
|
invokeMock.mockRejectedValue(new Error("network error"));
|
|
const summariseCmd = slashCommands.find((cmd) => cmd.name === "summarise")!;
|
|
await summariseCmd.execute("");
|
|
expect(claudeStore.addLine).toHaveBeenCalledWith(
|
|
"error",
|
|
expect.stringContaining("Failed to request summary:")
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("/skill execute", () => {
|
|
it("shows error when no active conversation", async () => {
|
|
getMock.mockReturnValue(null);
|
|
const skillCmd = slashCommands.find((cmd) => cmd.name === "skill")!;
|
|
await skillCmd.execute("onboard-mentee");
|
|
expect(claudeStore.addLine).toHaveBeenCalledWith("error", "No active conversation");
|
|
});
|
|
|
|
it("lists available skills when called with no name", async () => {
|
|
getMock.mockReturnValue("conv-123");
|
|
invokeMock.mockResolvedValue(["onboard-mentee", "other-skill"]);
|
|
const skillCmd = slashCommands.find((cmd) => cmd.name === "skill")!;
|
|
await skillCmd.execute("");
|
|
expect(invokeMock).toHaveBeenCalledWith("list_skills");
|
|
expect(claudeStore.addLine).toHaveBeenCalledWith(
|
|
"system",
|
|
expect.stringContaining("onboard-mentee")
|
|
);
|
|
});
|
|
|
|
it("shows empty message when no skills are found", async () => {
|
|
getMock.mockReturnValue("conv-123");
|
|
invokeMock.mockResolvedValue([]);
|
|
const skillCmd = slashCommands.find((cmd) => cmd.name === "skill")!;
|
|
await skillCmd.execute("");
|
|
expect(claudeStore.addLine).toHaveBeenCalledWith(
|
|
"system",
|
|
expect.stringContaining("No skills found")
|
|
);
|
|
});
|
|
|
|
it("invokes skill when called with a name and no data", async () => {
|
|
getMock.mockReturnValue("conv-123");
|
|
invokeMock.mockResolvedValue(undefined);
|
|
const skillCmd = slashCommands.find((cmd) => cmd.name === "skill")!;
|
|
await skillCmd.execute("onboard-mentee");
|
|
expect(claudeStore.addLine).toHaveBeenCalledWith(
|
|
"system",
|
|
"Invoking skill: onboard-mentee"
|
|
);
|
|
expect(invokeMock).toHaveBeenCalledWith(
|
|
"send_prompt",
|
|
expect.objectContaining({ conversationId: "conv-123" })
|
|
);
|
|
});
|
|
|
|
it("invokes skill with additional data in the prompt", async () => {
|
|
getMock.mockReturnValue("conv-123");
|
|
invokeMock.mockResolvedValue(undefined);
|
|
const skillCmd = slashCommands.find((cmd) => cmd.name === "skill")!;
|
|
await skillCmd.execute("onboard-mentee some extra data");
|
|
expect(invokeMock).toHaveBeenCalledWith("send_prompt", {
|
|
conversationId: "conv-123",
|
|
message: expect.stringContaining("some extra data"),
|
|
});
|
|
});
|
|
|
|
it("shows error when listing skills fails", async () => {
|
|
getMock.mockReturnValue("conv-123");
|
|
invokeMock.mockRejectedValue(new Error("list error"));
|
|
const skillCmd = slashCommands.find((cmd) => cmd.name === "skill")!;
|
|
await skillCmd.execute("");
|
|
expect(claudeStore.addLine).toHaveBeenCalledWith(
|
|
"error",
|
|
expect.stringContaining("Failed to list skills:")
|
|
);
|
|
});
|
|
|
|
it("shows error and resets character state when invoking skill fails", async () => {
|
|
getMock.mockReturnValue("conv-123");
|
|
invokeMock.mockRejectedValue(new Error("invoke error"));
|
|
const skillCmd = slashCommands.find((cmd) => cmd.name === "skill")!;
|
|
await skillCmd.execute("onboard-mentee");
|
|
expect(claudeStore.addLine).toHaveBeenCalledWith(
|
|
"error",
|
|
expect.stringContaining("Failed to invoke skill:")
|
|
);
|
|
expect(characterState.setTemporaryState).toHaveBeenCalledWith("error", 3000);
|
|
});
|
|
});
|
|
|
|
describe("/simplify execute", () => {
|
|
it("shows error when no active conversation", async () => {
|
|
getMock.mockReturnValue(null);
|
|
const simplifyCmd = slashCommands.find((cmd) => cmd.name === "simplify")!;
|
|
await simplifyCmd.execute("");
|
|
expect(claudeStore.addLine).toHaveBeenCalledWith("error", "No active conversation");
|
|
});
|
|
|
|
it("sends /simplify prompt to Claude when there is an active conversation", async () => {
|
|
getMock.mockReturnValue("conv-123");
|
|
invokeMock.mockResolvedValue(undefined);
|
|
const simplifyCmd = slashCommands.find((cmd) => cmd.name === "simplify")!;
|
|
await simplifyCmd.execute("");
|
|
expect(invokeMock).toHaveBeenCalledWith("send_prompt", {
|
|
conversationId: "conv-123",
|
|
message: "/simplify",
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("/loop execute", () => {
|
|
it("shows error when no active conversation", async () => {
|
|
getMock.mockReturnValue(null);
|
|
const loopCmd = slashCommands.find((cmd) => cmd.name === "loop")!;
|
|
await loopCmd.execute("5m /help");
|
|
expect(claudeStore.addLine).toHaveBeenCalledWith("error", "No active conversation");
|
|
});
|
|
|
|
it("sends /loop with args when args are provided", async () => {
|
|
getMock.mockReturnValue("conv-123");
|
|
invokeMock.mockResolvedValue(undefined);
|
|
const loopCmd = slashCommands.find((cmd) => cmd.name === "loop")!;
|
|
await loopCmd.execute("5m /help");
|
|
expect(invokeMock).toHaveBeenCalledWith("send_prompt", {
|
|
conversationId: "conv-123",
|
|
message: "/loop 5m /help",
|
|
});
|
|
});
|
|
|
|
it("sends /loop without args when no args provided", async () => {
|
|
getMock.mockReturnValue("conv-123");
|
|
invokeMock.mockResolvedValue(undefined);
|
|
const loopCmd = slashCommands.find((cmd) => cmd.name === "loop")!;
|
|
await loopCmd.execute("");
|
|
expect(invokeMock).toHaveBeenCalledWith("send_prompt", {
|
|
conversationId: "conv-123",
|
|
message: "/loop",
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("/batch execute", () => {
|
|
it("shows error when no active conversation", async () => {
|
|
getMock.mockReturnValue(null);
|
|
const batchCmd = slashCommands.find((cmd) => cmd.name === "batch")!;
|
|
await batchCmd.execute("task1, task2");
|
|
expect(claudeStore.addLine).toHaveBeenCalledWith("error", "No active conversation");
|
|
});
|
|
|
|
it("sends /batch with args when args are provided", async () => {
|
|
getMock.mockReturnValue("conv-123");
|
|
invokeMock.mockResolvedValue(undefined);
|
|
const batchCmd = slashCommands.find((cmd) => cmd.name === "batch")!;
|
|
await batchCmd.execute("task1, task2");
|
|
expect(invokeMock).toHaveBeenCalledWith("send_prompt", {
|
|
conversationId: "conv-123",
|
|
message: "/batch task1, task2",
|
|
});
|
|
});
|
|
|
|
it("sends /batch without args when no args provided", async () => {
|
|
getMock.mockReturnValue("conv-123");
|
|
invokeMock.mockResolvedValue(undefined);
|
|
const batchCmd = slashCommands.find((cmd) => cmd.name === "batch")!;
|
|
await batchCmd.execute("");
|
|
expect(invokeMock).toHaveBeenCalledWith("send_prompt", {
|
|
conversationId: "conv-123",
|
|
message: "/batch",
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("/cd success path", () => {
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it("changes directory and shows success message", async () => {
|
|
getMock
|
|
.mockReturnValueOnce("conv-123") // get(claudeStore.activeConversationId)
|
|
.mockReturnValueOnce("/current") // get(claudeStore.currentWorkingDirectory)
|
|
.mockReturnValueOnce(null); // get(conversationsStore.activeConversation)
|
|
vi.mocked(claudeStore.getConversationHistory).mockReturnValue("");
|
|
invokeMock
|
|
.mockResolvedValueOnce("/new/path") // validate_directory
|
|
.mockResolvedValueOnce(undefined) // stop_claude
|
|
.mockResolvedValueOnce(undefined); // start_claude
|
|
|
|
const cdCmd = slashCommands.find((cmd) => cmd.name === "cd")!;
|
|
const promise = cdCmd.execute("/new/path");
|
|
await vi.runAllTimersAsync();
|
|
await promise;
|
|
|
|
expect(claudeStore.addLine).toHaveBeenCalledWith(
|
|
"system",
|
|
"Changed directory to: /new/path"
|
|
);
|
|
expect(characterState.setState).toHaveBeenCalledWith("idle");
|
|
});
|
|
|
|
it("sends context restoration message when conversation history exists", async () => {
|
|
getMock
|
|
.mockReturnValueOnce("conv-123") // get(claudeStore.activeConversationId)
|
|
.mockReturnValueOnce("/current") // get(claudeStore.currentWorkingDirectory)
|
|
.mockReturnValueOnce(null); // get(conversationsStore.activeConversation)
|
|
vi.mocked(claudeStore.getConversationHistory).mockReturnValue(
|
|
"previous conversation history"
|
|
);
|
|
invokeMock
|
|
.mockResolvedValueOnce("/new/path") // validate_directory
|
|
.mockResolvedValueOnce(undefined) // stop_claude
|
|
.mockResolvedValueOnce(undefined) // start_claude
|
|
.mockResolvedValueOnce(undefined); // send_prompt
|
|
|
|
const cdCmd = slashCommands.find((cmd) => cmd.name === "cd")!;
|
|
const promise = cdCmd.execute("/new/path");
|
|
await vi.runAllTimersAsync();
|
|
await promise;
|
|
|
|
expect(invokeMock).toHaveBeenCalledWith(
|
|
"send_prompt",
|
|
expect.objectContaining({
|
|
message: expect.stringContaining("previous conversation history"),
|
|
})
|
|
);
|
|
expect(claudeStore.addLine).toHaveBeenCalledWith(
|
|
"system",
|
|
"Changed directory to: /new/path"
|
|
);
|
|
});
|
|
|
|
it("calls updateDiscordRpc when activeConversation is available", async () => {
|
|
const activeConv = {
|
|
name: "Test Conversation",
|
|
model: "claude-sonnet",
|
|
startedAt: new Date("2026-03-03T12:00:00Z"),
|
|
grantedTools: new Set<string>(),
|
|
};
|
|
getMock
|
|
.mockReturnValueOnce("conv-123") // get(claudeStore.activeConversationId)
|
|
.mockReturnValueOnce("/current") // get(claudeStore.currentWorkingDirectory)
|
|
.mockReturnValueOnce(activeConv); // get(conversationsStore.activeConversation)
|
|
vi.mocked(claudeStore.getConversationHistory).mockReturnValue("");
|
|
invokeMock
|
|
.mockResolvedValueOnce("/new/path")
|
|
.mockResolvedValueOnce(undefined)
|
|
.mockResolvedValueOnce(undefined);
|
|
const { updateDiscordRpc } = await import("$lib/tauri");
|
|
|
|
const cdCmd = slashCommands.find((cmd) => cmd.name === "cd")!;
|
|
const promise = cdCmd.execute("/new/path");
|
|
await vi.runAllTimersAsync();
|
|
await promise;
|
|
|
|
expect(updateDiscordRpc).toHaveBeenCalledWith(
|
|
"Test Conversation",
|
|
expect.any(String),
|
|
expect.any(Date)
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("/new success path", () => {
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it("starts a new conversation and shows success message", async () => {
|
|
getMock
|
|
.mockReturnValueOnce("conv-123") // get(claudeStore.activeConversationId)
|
|
.mockReturnValueOnce(null); // get(conversationsStore.activeConversation)
|
|
invokeMock
|
|
.mockResolvedValueOnce("/working/dir") // get_working_directory
|
|
.mockResolvedValueOnce(undefined) // interrupt_claude
|
|
.mockResolvedValueOnce(undefined); // start_claude
|
|
|
|
const newCmd = slashCommands.find((cmd) => cmd.name === "new")!;
|
|
const promise = newCmd.execute("");
|
|
await vi.runAllTimersAsync();
|
|
await promise;
|
|
|
|
expect(claudeStore.addLine).toHaveBeenCalledWith("system", "New conversation started!");
|
|
expect(characterState.setState).toHaveBeenCalledWith("idle");
|
|
});
|
|
|
|
it("calls updateDiscordRpc when activeConversation is available", async () => {
|
|
const activeConv = {
|
|
name: "My Conv",
|
|
model: "claude-sonnet",
|
|
startedAt: new Date("2026-03-03T12:00:00Z"),
|
|
grantedTools: new Set<string>(["tool1"]),
|
|
};
|
|
getMock.mockReturnValueOnce("conv-123").mockReturnValueOnce(activeConv);
|
|
invokeMock
|
|
.mockResolvedValueOnce("/working/dir")
|
|
.mockResolvedValueOnce(undefined)
|
|
.mockResolvedValueOnce(undefined);
|
|
const { updateDiscordRpc } = await import("$lib/tauri");
|
|
|
|
const newCmd = slashCommands.find((cmd) => cmd.name === "new")!;
|
|
const promise = newCmd.execute("");
|
|
await vi.runAllTimersAsync();
|
|
await promise;
|
|
|
|
expect(updateDiscordRpc).toHaveBeenCalledWith(
|
|
"My Conv",
|
|
expect.any(String),
|
|
expect.any(Date)
|
|
);
|
|
});
|
|
});
|
|
});
|
|
});
|