From d605ea15a1baddfc50cfa70f22dc8ce2c1839cbe Mon Sep 17 00:00:00 2001 From: Hikari Date: Mon, 23 Feb 2026 20:15:33 -0800 Subject: [PATCH] feat: assign anime girl characters to subagents in agent monitor Each subagent is given a unique anime girl name and avatar when it starts, drawn from a six-character pool (Amari, Keiko, Minori, Reina, Tatsumi, Yumiko). Names are assigned without repetition until all six are taken, then reused. The agent monitor panel displays the character avatar and name alongside the subagent type badge. --- src/lib/components/AgentMonitorPanel.svelte | 8 +++ src/lib/stores/agents.test.ts | 39 +++++++++--- src/lib/stores/agents.ts | 10 ++- src/lib/types/agents.ts | 2 + src/lib/utils/agentCharacters.test.ts | 69 +++++++++++++++++++++ src/lib/utils/agentCharacters.ts | 23 +++++++ 6 files changed, 142 insertions(+), 9 deletions(-) create mode 100644 src/lib/utils/agentCharacters.test.ts create mode 100644 src/lib/utils/agentCharacters.ts 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)]; +}