Files
hikari-desktop/src/lib/stores/agents.test.ts
T
hikari 452fe185df
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m21s
CI / Lint & Test (push) Has started running
CI / Build Linux (push) Has been cancelled
CI / Build Windows (cross-compile) (push) Has been cancelled
feat: CLI v2.1.68–v2.1.74 compatibility updates (#221)
## 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>
2026-03-13 01:34:44 -07:00

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);
});
});
});