diff --git a/src/lib/components/AgentMonitorPanel.svelte b/src/lib/components/AgentMonitorPanel.svelte index 9fcd85b..ef7770c 100644 --- a/src/lib/components/AgentMonitorPanel.svelte +++ b/src/lib/components/AgentMonitorPanel.svelte @@ -270,6 +270,14 @@ /> {/if} + {agent.characterName} + + {agent.characterName} + { const conversationId = "test-conversation-1"; const otherConversationId = "test-conversation-2"; - const createMockAgent = (overrides?: Partial): AgentInfo => ({ + type AgentInput = Omit; + + const createMockAgent = (overrides?: Partial): AgentInput => ({ toolUseId: "toolu_test123", description: "Test agent", subagentType: "Explore", @@ -37,7 +40,29 @@ describe("agents store", () => { const agents = get(getAgentsForConversation(conversationId)); expect(agents).toHaveLength(1); - expect(agents[0]).toEqual(agent); + expect(agents[0]).toMatchObject(agent); + }); + + 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", () => { @@ -49,8 +74,8 @@ describe("agents store", () => { const agents = get(getAgentsForConversation(conversationId)); expect(agents).toHaveLength(2); - expect(agents[0]).toEqual(agent1); - expect(agents[1]).toEqual(agent2); + expect(agents[0]).toMatchObject(agent1); + expect(agents[1]).toMatchObject(agent2); }); it("keeps agents in different conversations separate", () => { @@ -65,8 +90,8 @@ describe("agents store", () => { expect(agents1).toHaveLength(1); expect(agents2).toHaveLength(1); - expect(agents1[0]).toEqual(agent1); - expect(agents2[0]).toEqual(agent2); + expect(agents1[0]).toMatchObject(agent1); + expect(agents2[0]).toMatchObject(agent2); }); }); @@ -256,7 +281,7 @@ describe("agents store", () => { expect(agents1).toHaveLength(0); expect(agents2).toHaveLength(1); - expect(agents2[0]).toEqual(agent2); + expect(agents2[0]).toMatchObject(agent2); }); it("does nothing if conversation doesn't exist", () => { diff --git a/src/lib/stores/agents.ts b/src/lib/stores/agents.ts index 989e103..406a4ca 100644 --- a/src/lib/stores/agents.ts +++ b/src/lib/stores/agents.ts @@ -1,5 +1,6 @@ import { writable, derived } from "svelte/store"; import type { AgentInfo } from "$lib/types/agents"; +import { assignCharacter } from "$lib/utils/agentCharacters"; // Map of conversation ID -> agents in that conversation const agentsByConversation = writable>({}); @@ -8,12 +9,17 @@ function createAgentStore() { return { subscribe: agentsByConversation.subscribe, - addAgent(conversationId: string, agent: AgentInfo) { + addAgent(conversationId: string, agent: Omit) { agentsByConversation.update((state) => { const existing = state[conversationId] || []; + const activeNames = existing.map((a) => a.characterName); + const character = assignCharacter(activeNames); return { ...state, - [conversationId]: [...existing, agent], + [conversationId]: [ + ...existing, + { ...agent, characterName: character.name, characterAvatar: character.avatar }, + ], }; }); }, diff --git a/src/lib/types/agents.ts b/src/lib/types/agents.ts index 870e674..2cb53cd 100644 --- a/src/lib/types/agents.ts +++ b/src/lib/types/agents.ts @@ -10,6 +10,8 @@ export interface AgentInfo { status: AgentStatus; parentToolUseId?: string; durationMs?: number; + characterName: string; + characterAvatar: string; } export interface AgentStartPayload { diff --git a/src/lib/utils/agentCharacters.test.ts b/src/lib/utils/agentCharacters.test.ts new file mode 100644 index 0000000..67d6562 --- /dev/null +++ b/src/lib/utils/agentCharacters.test.ts @@ -0,0 +1,69 @@ +import { describe, it, expect } from "vitest"; +import { CHARACTER_POOL, assignCharacter } from "./agentCharacters"; + +describe("agentCharacters", () => { + describe("CHARACTER_POOL", () => { + it("contains exactly 6 characters", () => { + expect(CHARACTER_POOL).toHaveLength(6); + }); + + it("each character has a name and avatar", () => { + for (const character of CHARACTER_POOL) { + expect(character.name).toBeTruthy(); + expect(character.avatar).toBeTruthy(); + expect(character.avatar).toMatch(/^https:\/\//u); + } + }); + + it("all names are unique", () => { + const names = CHARACTER_POOL.map((c) => c.name); + const uniqueNames = new Set(names); + expect(uniqueNames.size).toBe(CHARACTER_POOL.length); + }); + }); + + describe("assignCharacter", () => { + it("returns a character from the pool", () => { + const character = assignCharacter([]); + const names = CHARACTER_POOL.map((c) => c.name); + expect(names).toContain(character.name); + }); + + it("avoids names already in use when possible", () => { + const takenNames = ["Amari", "Keiko", "Minori", "Reina", "Tatsumi"]; + // Run many times to confirm we never get a taken name + for (let i = 0; i < 50; i++) { + const character = assignCharacter(takenNames); + expect(takenNames).not.toContain(character.name); + expect(character.name).toBe("Yumiko"); + } + }); + + it("picks from the full pool when all 6 names are taken", () => { + const allNames = CHARACTER_POOL.map((c) => c.name); + const seen = new Set(); + // Run enough times that we'd statistically see variety + for (let i = 0; i < 100; i++) { + const character = assignCharacter(allNames); + seen.add(character.name); + } + // Should still pick valid characters + for (const name of seen) { + expect(allNames).toContain(name); + } + // With 100 runs and 6 characters, we should see at least 2 distinct names + expect(seen.size).toBeGreaterThan(1); + }); + + it("returns a character with both name and avatar", () => { + const character = assignCharacter([]); + expect(character.name).toBeTruthy(); + expect(character.avatar).toBeTruthy(); + }); + + it("works when the active list is empty", () => { + const character = assignCharacter([]); + expect(character).toBeDefined(); + }); + }); +}); diff --git a/src/lib/utils/agentCharacters.ts b/src/lib/utils/agentCharacters.ts new file mode 100644 index 0000000..62de584 --- /dev/null +++ b/src/lib/utils/agentCharacters.ts @@ -0,0 +1,23 @@ +export interface AgentCharacter { + name: string; + avatar: string; +} + +export const CHARACTER_POOL: readonly AgentCharacter[] = [ + { name: "Amari", avatar: "https://cdn.nhcarrigan.com/amari.png" }, + { name: "Keiko", avatar: "https://cdn.nhcarrigan.com/keiko.png" }, + { name: "Minori", avatar: "https://cdn.nhcarrigan.com/minori.png" }, + { name: "Reina", avatar: "https://cdn.nhcarrigan.com/reina.png" }, + { name: "Tatsumi", avatar: "https://cdn.nhcarrigan.com/tatsumi.png" }, + { name: "Yumiko", avatar: "https://cdn.nhcarrigan.com/yumiko.png" }, +]; + +/** + * Picks a character for a new subagent. + * Avoids names already assigned to active agents unless all six are taken. + */ +export function assignCharacter(activeNames: readonly string[]): AgentCharacter { + const available = CHARACTER_POOL.filter((c) => !activeNames.includes(c.name)); + const pool = available.length > 0 ? available : [...CHARACTER_POOL]; + return pool[Math.floor(Math.random() * pool.length)]; +}