generated from nhcarrigan/template
452fe185df
## Summary This PR brings Hikari Desktop up to full compatibility with Claude Code CLI versions v2.1.68 through v2.1.74, implementing all changelog items audited in issues #200–#218. ## Changes ### Bug Fixes - Remove deprecated Claude Opus 4.0 and 4.1 models from the model selector - Auto-migrate users pinned to deprecated models to Opus 4.6 ### New Features - Add cron tool support (`CronCreate`, `CronDelete`, `CronList`) with character state mapping and `CLAUDE_CODE_DISABLE_CRON` settings toggle - Handle `EnterWorktree` and `ExitWorktree` tools in character state mapping and tool display - Add CLI update check with npm registry indicator in the version bar - Add `agent_type` field and support the Agent tool rename from CLI v2.1.69 - Consume `worktree` field from status line hook events - Display per-agent model override in the agent monitor tree - Expose Claude Code CLI built-in slash commands (`/simplify`, `/loop`, `/batch`, `/memory`, `/context`) in the command menu with CLI badges - Add `includeGitInstructions` toggle in settings - Add `ENABLE_CLAUDEAI_MCP_SERVERS` opt-out setting - Linkify MCP binary file paths (PDFs, audio, Office docs) in markdown output - Add auto-memory panel, `/memory` slash command shortcut, and unified toast notification system - Toast notifications for `WorktreeCreate` and `WorktreeRemove` hook events - Sort session resume list by most recent activity, with most recent user message as preview - Convert WSL Linux paths to Windows UNC paths when opening binary files via `open_binary_file` command - Expose `autoMemoryDirectory` setting in ConfigSidebar (Agent Settings section) - Add `/context` as a CLI built-in in the slash command menu - Expose `modelOverrides` setting as a JSON textarea in ConfigSidebar (for AWS Bedrock, Google Vertex, etc.) > **Note:** The CLI update check commit does not have a corresponding issue — it was a bonus addition during the audit sprint. ## Closes Closes #200 Closes #201 Closes #202 Closes #205 Closes #206 Closes #207 Closes #208 Closes #209 Closes #210 Closes #211 Closes #212 Closes #213 Closes #214 Closes #215 Closes #216 Closes #217 Closes #218 Reviewed-on: #221 Co-authored-by: Hikari <hikari@nhcarrigan.com> Co-committed-by: Hikari <hikari@nhcarrigan.com>
415 lines
15 KiB
TypeScript
415 lines
15 KiB
TypeScript
import { describe, it, expect, beforeEach } from "vitest";
|
|
import { agentStore, getAgentsForConversation, runningAgentCount } from "./agents";
|
|
import { get } from "svelte/store";
|
|
import type { AgentInfo } from "$lib/types/agents";
|
|
import { CHARACTER_POOL } from "$lib/utils/agentCharacters";
|
|
|
|
describe("agents store", () => {
|
|
const conversationId = "test-conversation-1";
|
|
const otherConversationId = "test-conversation-2";
|
|
|
|
type AgentInput = Omit<AgentInfo, "characterName" | "characterAvatar">;
|
|
|
|
const createMockAgent = (overrides?: Partial<AgentInput>): AgentInput => ({
|
|
toolUseId: "toolu_test123",
|
|
description: "Test agent",
|
|
subagentType: "Explore",
|
|
startedAt: Date.now(),
|
|
status: "running",
|
|
...overrides,
|
|
});
|
|
|
|
beforeEach(() => {
|
|
// Clear all conversations by subscribing and getting state
|
|
let state: Record<string, AgentInfo[]> = {};
|
|
const unsub = agentStore.subscribe((s) => {
|
|
state = s;
|
|
});
|
|
unsub();
|
|
|
|
// Clear each conversation
|
|
for (const convId of Object.keys(state)) {
|
|
agentStore.clearConversation(convId);
|
|
}
|
|
});
|
|
|
|
describe("addAgent", () => {
|
|
it("adds an agent to a conversation", () => {
|
|
const agent = createMockAgent();
|
|
agentStore.addAgent(conversationId, agent);
|
|
|
|
const agents = get(getAgentsForConversation(conversationId));
|
|
expect(agents).toHaveLength(1);
|
|
expect(agents[0]).toMatchObject(agent);
|
|
});
|
|
|
|
it("preserves model field when provided", () => {
|
|
const agent = createMockAgent({ model: "claude-opus-4-6" });
|
|
agentStore.addAgent(conversationId, agent);
|
|
|
|
const agents = get(getAgentsForConversation(conversationId));
|
|
expect(agents[0].model).toBe("claude-opus-4-6");
|
|
});
|
|
|
|
it("leaves model undefined when not provided", () => {
|
|
const agent = createMockAgent();
|
|
agentStore.addAgent(conversationId, agent);
|
|
|
|
const agents = get(getAgentsForConversation(conversationId));
|
|
expect(agents[0].model).toBeUndefined();
|
|
});
|
|
|
|
it("assigns a character name and avatar to added agents", () => {
|
|
const agent = createMockAgent();
|
|
agentStore.addAgent(conversationId, agent);
|
|
|
|
const agents = get(getAgentsForConversation(conversationId));
|
|
const validNames = CHARACTER_POOL.map((c) => c.name);
|
|
expect(validNames).toContain(agents[0].characterName);
|
|
expect(agents[0].characterAvatar).toMatch(/^https:\/\//u);
|
|
});
|
|
|
|
it("avoids duplicate character names across agents when possible", () => {
|
|
// Add 6 agents - each should ideally get a unique character
|
|
for (let i = 0; i < 6; i++) {
|
|
agentStore.addAgent(conversationId, createMockAgent({ toolUseId: `tool${i.toString()}` }));
|
|
}
|
|
|
|
const agents = get(getAgentsForConversation(conversationId));
|
|
const names = agents.map((a) => a.characterName);
|
|
const uniqueNames = new Set(names);
|
|
expect(uniqueNames.size).toBe(6);
|
|
});
|
|
|
|
it("adds multiple agents to the same conversation", () => {
|
|
const agent1 = createMockAgent({ toolUseId: "tool1" });
|
|
const agent2 = createMockAgent({ toolUseId: "tool2" });
|
|
|
|
agentStore.addAgent(conversationId, agent1);
|
|
agentStore.addAgent(conversationId, agent2);
|
|
|
|
const agents = get(getAgentsForConversation(conversationId));
|
|
expect(agents).toHaveLength(2);
|
|
expect(agents[0]).toMatchObject(agent1);
|
|
expect(agents[1]).toMatchObject(agent2);
|
|
});
|
|
|
|
it("keeps agents in different conversations separate", () => {
|
|
const agent1 = createMockAgent({ toolUseId: "tool1" });
|
|
const agent2 = createMockAgent({ toolUseId: "tool2" });
|
|
|
|
agentStore.addAgent(conversationId, agent1);
|
|
agentStore.addAgent(otherConversationId, agent2);
|
|
|
|
const agents1 = get(getAgentsForConversation(conversationId));
|
|
const agents2 = get(getAgentsForConversation(otherConversationId));
|
|
|
|
expect(agents1).toHaveLength(1);
|
|
expect(agents2).toHaveLength(1);
|
|
expect(agents1[0]).toMatchObject(agent1);
|
|
expect(agents2[0]).toMatchObject(agent2);
|
|
});
|
|
});
|
|
|
|
describe("updateAgentId", () => {
|
|
it("updates the agent_id for a specific agent", () => {
|
|
const agent = createMockAgent({ agentId: undefined });
|
|
agentStore.addAgent(conversationId, agent);
|
|
|
|
agentStore.updateAgentId(conversationId, agent.toolUseId, "agent-abc123");
|
|
|
|
const agents = get(getAgentsForConversation(conversationId));
|
|
expect(agents[0].agentId).toBe("agent-abc123");
|
|
});
|
|
|
|
it("does nothing if conversation doesn't exist", () => {
|
|
agentStore.updateAgentId("nonexistent", "tool1", "agent1");
|
|
// Should not throw
|
|
expect(true).toBe(true);
|
|
});
|
|
|
|
it("does nothing if tool_use_id doesn't exist", () => {
|
|
const agent = createMockAgent();
|
|
agentStore.addAgent(conversationId, agent);
|
|
|
|
agentStore.updateAgentId(conversationId, "nonexistent-tool", "agent1");
|
|
|
|
const agents = get(getAgentsForConversation(conversationId));
|
|
expect(agents[0].agentId).toBeUndefined();
|
|
});
|
|
|
|
it("updates agentType when provided alongside agentId", () => {
|
|
const agent = createMockAgent({ agentId: undefined });
|
|
agentStore.addAgent(conversationId, agent);
|
|
|
|
agentStore.updateAgentId(conversationId, agent.toolUseId, "agent-abc123", "general-purpose");
|
|
|
|
const agents = get(getAgentsForConversation(conversationId));
|
|
expect(agents[0].agentId).toBe("agent-abc123");
|
|
expect(agents[0].agentType).toBe("general-purpose");
|
|
});
|
|
|
|
it("does not set agentType when not provided", () => {
|
|
const agent = createMockAgent({ agentId: undefined });
|
|
agentStore.addAgent(conversationId, agent);
|
|
|
|
agentStore.updateAgentId(conversationId, agent.toolUseId, "agent-abc123");
|
|
|
|
const agents = get(getAgentsForConversation(conversationId));
|
|
expect(agents[0].agentId).toBe("agent-abc123");
|
|
expect(agents[0].agentType).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("endAgent", () => {
|
|
it("marks an agent as completed", () => {
|
|
const agent = createMockAgent({ status: "running" });
|
|
agentStore.addAgent(conversationId, agent);
|
|
|
|
const endTime = Date.now();
|
|
agentStore.endAgent(conversationId, agent.toolUseId, endTime, false);
|
|
|
|
const agents = get(getAgentsForConversation(conversationId));
|
|
expect(agents[0].status).toBe("completed");
|
|
expect(agents[0].endedAt).toBe(endTime);
|
|
expect(agents[0].durationMs).toBeGreaterThanOrEqual(0); // Duration can be 0 if timestamps are the same
|
|
});
|
|
|
|
it("marks an agent as errored", () => {
|
|
const agent = createMockAgent({ status: "running" });
|
|
agentStore.addAgent(conversationId, agent);
|
|
|
|
const endTime = Date.now();
|
|
agentStore.endAgent(conversationId, agent.toolUseId, endTime, true);
|
|
|
|
const agents = get(getAgentsForConversation(conversationId));
|
|
expect(agents[0].status).toBe("errored");
|
|
expect(agents[0].endedAt).toBe(endTime);
|
|
});
|
|
|
|
it("calculates duration correctly", () => {
|
|
const startTime = Date.now() - 5000; // 5 seconds ago
|
|
const agent = createMockAgent({ startedAt: startTime, status: "running" });
|
|
agentStore.addAgent(conversationId, agent);
|
|
|
|
const endTime = Date.now();
|
|
agentStore.endAgent(conversationId, agent.toolUseId, endTime, false);
|
|
|
|
const agents = get(getAgentsForConversation(conversationId));
|
|
expect(agents[0].durationMs).toBeGreaterThanOrEqual(5000);
|
|
expect(agents[0].durationMs).toBeLessThanOrEqual(6000); // Allow some buffer
|
|
});
|
|
|
|
it("does nothing if conversation doesn't exist", () => {
|
|
agentStore.endAgent("nonexistent", "tool1", Date.now(), false);
|
|
// Should not throw
|
|
expect(true).toBe(true);
|
|
});
|
|
|
|
it("does nothing if agent doesn't exist", () => {
|
|
const agent = createMockAgent();
|
|
agentStore.addAgent(conversationId, agent);
|
|
|
|
agentStore.endAgent(conversationId, "nonexistent-tool", Date.now(), false);
|
|
|
|
const agents = get(getAgentsForConversation(conversationId));
|
|
expect(agents[0].status).toBe("running"); // Status unchanged
|
|
});
|
|
|
|
it("stores lastAssistantMessage when provided", () => {
|
|
const agent = createMockAgent({ status: "running" });
|
|
agentStore.addAgent(conversationId, agent);
|
|
|
|
agentStore.endAgent(
|
|
conversationId,
|
|
agent.toolUseId,
|
|
Date.now(),
|
|
false,
|
|
"Task completed successfully."
|
|
);
|
|
|
|
const agents = get(getAgentsForConversation(conversationId));
|
|
expect(agents[0].lastAssistantMessage).toBe("Task completed successfully.");
|
|
});
|
|
|
|
it("leaves lastAssistantMessage undefined when not provided", () => {
|
|
const agent = createMockAgent({ status: "running" });
|
|
agentStore.addAgent(conversationId, agent);
|
|
|
|
agentStore.endAgent(conversationId, agent.toolUseId, Date.now(), false);
|
|
|
|
const agents = get(getAgentsForConversation(conversationId));
|
|
expect(agents[0].lastAssistantMessage).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("markAllErrored", () => {
|
|
it("marks all running agents as errored", () => {
|
|
const agent1 = createMockAgent({ toolUseId: "tool1", status: "running" });
|
|
const agent2 = createMockAgent({ toolUseId: "tool2", status: "running" });
|
|
const agent3 = createMockAgent({ toolUseId: "tool3", status: "completed" });
|
|
|
|
agentStore.addAgent(conversationId, agent1);
|
|
agentStore.addAgent(conversationId, agent2);
|
|
agentStore.addAgent(conversationId, agent3);
|
|
|
|
agentStore.markAllErrored(conversationId);
|
|
|
|
const agents = get(getAgentsForConversation(conversationId));
|
|
expect(agents[0].status).toBe("errored");
|
|
expect(agents[0].endedAt).toBeGreaterThan(0);
|
|
expect(agents[1].status).toBe("errored");
|
|
expect(agents[1].endedAt).toBeGreaterThan(0);
|
|
expect(agents[2].status).toBe("completed"); // Already completed, unchanged
|
|
});
|
|
|
|
it("does nothing if conversation doesn't exist", () => {
|
|
agentStore.markAllErrored("nonexistent");
|
|
// Should not throw
|
|
expect(true).toBe(true);
|
|
});
|
|
|
|
it("does nothing if conversation has no running agents", () => {
|
|
const agent = createMockAgent({ status: "completed" });
|
|
agentStore.addAgent(conversationId, agent);
|
|
|
|
agentStore.markAllErrored(conversationId);
|
|
|
|
const agents = get(getAgentsForConversation(conversationId));
|
|
expect(agents[0].status).toBe("completed"); // Unchanged
|
|
});
|
|
});
|
|
|
|
describe("clearCompleted", () => {
|
|
it("removes completed and errored agents", () => {
|
|
const agent1 = createMockAgent({ toolUseId: "tool1", status: "running" });
|
|
const agent2 = createMockAgent({ toolUseId: "tool2", status: "completed" });
|
|
const agent3 = createMockAgent({ toolUseId: "tool3", status: "errored" });
|
|
|
|
agentStore.addAgent(conversationId, agent1);
|
|
agentStore.addAgent(conversationId, agent2);
|
|
agentStore.addAgent(conversationId, agent3);
|
|
|
|
agentStore.clearCompleted(conversationId);
|
|
|
|
const agents = get(getAgentsForConversation(conversationId));
|
|
expect(agents).toHaveLength(1);
|
|
expect(agents[0].toolUseId).toBe("tool1"); // Only running agent remains
|
|
});
|
|
|
|
it("does nothing if conversation doesn't exist", () => {
|
|
agentStore.clearCompleted("nonexistent");
|
|
// Should not throw
|
|
expect(true).toBe(true);
|
|
});
|
|
|
|
it("clears all agents if all are completed", () => {
|
|
const agent1 = createMockAgent({ toolUseId: "tool1", status: "completed" });
|
|
const agent2 = createMockAgent({ toolUseId: "tool2", status: "errored" });
|
|
|
|
agentStore.addAgent(conversationId, agent1);
|
|
agentStore.addAgent(conversationId, agent2);
|
|
|
|
agentStore.clearCompleted(conversationId);
|
|
|
|
const agents = get(getAgentsForConversation(conversationId));
|
|
expect(agents).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe("clearConversation", () => {
|
|
it("removes all agents from a conversation", () => {
|
|
const agent1 = createMockAgent({ toolUseId: "tool1" });
|
|
const agent2 = createMockAgent({ toolUseId: "tool2" });
|
|
|
|
agentStore.addAgent(conversationId, agent1);
|
|
agentStore.addAgent(conversationId, agent2);
|
|
|
|
agentStore.clearConversation(conversationId);
|
|
|
|
const agents = get(getAgentsForConversation(conversationId));
|
|
expect(agents).toHaveLength(0);
|
|
});
|
|
|
|
it("only removes agents from the specified conversation", () => {
|
|
const agent1 = createMockAgent({ toolUseId: "tool1" });
|
|
const agent2 = createMockAgent({ toolUseId: "tool2" });
|
|
|
|
agentStore.addAgent(conversationId, agent1);
|
|
agentStore.addAgent(otherConversationId, agent2);
|
|
|
|
agentStore.clearConversation(conversationId);
|
|
|
|
const agents1 = get(getAgentsForConversation(conversationId));
|
|
const agents2 = get(getAgentsForConversation(otherConversationId));
|
|
|
|
expect(agents1).toHaveLength(0);
|
|
expect(agents2).toHaveLength(1);
|
|
expect(agents2[0]).toMatchObject(agent2);
|
|
});
|
|
|
|
it("does nothing if conversation doesn't exist", () => {
|
|
agentStore.clearConversation("nonexistent");
|
|
// Should not throw
|
|
expect(true).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("runningAgentCount", () => {
|
|
it("counts running agents across all conversations", () => {
|
|
const agent1 = createMockAgent({ toolUseId: "tool1", status: "running" });
|
|
const agent2 = createMockAgent({ toolUseId: "tool2", status: "running" });
|
|
const agent3 = createMockAgent({ toolUseId: "tool3", status: "completed" });
|
|
const agent4 = createMockAgent({ toolUseId: "tool4", status: "running" });
|
|
|
|
agentStore.addAgent(conversationId, agent1);
|
|
agentStore.addAgent(conversationId, agent2);
|
|
agentStore.addAgent(conversationId, agent3);
|
|
agentStore.addAgent(otherConversationId, agent4);
|
|
|
|
const count = get(runningAgentCount);
|
|
expect(count).toBe(3); // 2 from first conversation + 1 from second
|
|
});
|
|
|
|
it("returns 0 when no agents are running", () => {
|
|
const agent1 = createMockAgent({ status: "completed" });
|
|
const agent2 = createMockAgent({ status: "errored" });
|
|
|
|
agentStore.addAgent(conversationId, agent1);
|
|
agentStore.addAgent(otherConversationId, agent2);
|
|
|
|
const count = get(runningAgentCount);
|
|
expect(count).toBe(0);
|
|
});
|
|
|
|
it("updates when agents complete", () => {
|
|
const agent = createMockAgent({ status: "running" });
|
|
agentStore.addAgent(conversationId, agent);
|
|
|
|
let count = get(runningAgentCount);
|
|
expect(count).toBe(1);
|
|
|
|
agentStore.endAgent(conversationId, agent.toolUseId, Date.now(), false);
|
|
|
|
count = get(runningAgentCount);
|
|
expect(count).toBe(0);
|
|
});
|
|
|
|
it("updates when conversation is cleared", () => {
|
|
const agent1 = createMockAgent({ toolUseId: "tool1", status: "running" });
|
|
const agent2 = createMockAgent({ toolUseId: "tool2", status: "running" });
|
|
|
|
agentStore.addAgent(conversationId, agent1);
|
|
agentStore.addAgent(conversationId, agent2);
|
|
|
|
let count = get(runningAgentCount);
|
|
expect(count).toBe(2);
|
|
|
|
agentStore.clearConversation(conversationId);
|
|
|
|
count = get(runningAgentCount);
|
|
expect(count).toBe(0);
|
|
});
|
|
});
|
|
});
|