generated from nhcarrigan/template
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.
This commit is contained in:
@@ -270,6 +270,14 @@
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
{/if}
|
{/if}
|
||||||
|
<img
|
||||||
|
src={agent.characterAvatar}
|
||||||
|
alt={agent.characterName}
|
||||||
|
class="w-5 h-5 rounded-full object-cover"
|
||||||
|
/>
|
||||||
|
<span class="text-[10px] font-medium text-[var(--text-primary)]">
|
||||||
|
{agent.characterName}
|
||||||
|
</span>
|
||||||
<span
|
<span
|
||||||
class="px-1.5 py-0.5 text-[10px] rounded border {getStatusBadgeClass(
|
class="px-1.5 py-0.5 text-[10px] rounded border {getStatusBadgeClass(
|
||||||
agent.status
|
agent.status
|
||||||
|
|||||||
@@ -2,12 +2,15 @@ import { describe, it, expect, beforeEach } from "vitest";
|
|||||||
import { agentStore, getAgentsForConversation, runningAgentCount } from "./agents";
|
import { agentStore, getAgentsForConversation, runningAgentCount } from "./agents";
|
||||||
import { get } from "svelte/store";
|
import { get } from "svelte/store";
|
||||||
import type { AgentInfo } from "$lib/types/agents";
|
import type { AgentInfo } from "$lib/types/agents";
|
||||||
|
import { CHARACTER_POOL } from "$lib/utils/agentCharacters";
|
||||||
|
|
||||||
describe("agents store", () => {
|
describe("agents store", () => {
|
||||||
const conversationId = "test-conversation-1";
|
const conversationId = "test-conversation-1";
|
||||||
const otherConversationId = "test-conversation-2";
|
const otherConversationId = "test-conversation-2";
|
||||||
|
|
||||||
const createMockAgent = (overrides?: Partial<AgentInfo>): AgentInfo => ({
|
type AgentInput = Omit<AgentInfo, "characterName" | "characterAvatar">;
|
||||||
|
|
||||||
|
const createMockAgent = (overrides?: Partial<AgentInput>): AgentInput => ({
|
||||||
toolUseId: "toolu_test123",
|
toolUseId: "toolu_test123",
|
||||||
description: "Test agent",
|
description: "Test agent",
|
||||||
subagentType: "Explore",
|
subagentType: "Explore",
|
||||||
@@ -37,7 +40,29 @@ describe("agents store", () => {
|
|||||||
|
|
||||||
const agents = get(getAgentsForConversation(conversationId));
|
const agents = get(getAgentsForConversation(conversationId));
|
||||||
expect(agents).toHaveLength(1);
|
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", () => {
|
it("adds multiple agents to the same conversation", () => {
|
||||||
@@ -49,8 +74,8 @@ describe("agents store", () => {
|
|||||||
|
|
||||||
const agents = get(getAgentsForConversation(conversationId));
|
const agents = get(getAgentsForConversation(conversationId));
|
||||||
expect(agents).toHaveLength(2);
|
expect(agents).toHaveLength(2);
|
||||||
expect(agents[0]).toEqual(agent1);
|
expect(agents[0]).toMatchObject(agent1);
|
||||||
expect(agents[1]).toEqual(agent2);
|
expect(agents[1]).toMatchObject(agent2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps agents in different conversations separate", () => {
|
it("keeps agents in different conversations separate", () => {
|
||||||
@@ -65,8 +90,8 @@ describe("agents store", () => {
|
|||||||
|
|
||||||
expect(agents1).toHaveLength(1);
|
expect(agents1).toHaveLength(1);
|
||||||
expect(agents2).toHaveLength(1);
|
expect(agents2).toHaveLength(1);
|
||||||
expect(agents1[0]).toEqual(agent1);
|
expect(agents1[0]).toMatchObject(agent1);
|
||||||
expect(agents2[0]).toEqual(agent2);
|
expect(agents2[0]).toMatchObject(agent2);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -256,7 +281,7 @@ describe("agents store", () => {
|
|||||||
|
|
||||||
expect(agents1).toHaveLength(0);
|
expect(agents1).toHaveLength(0);
|
||||||
expect(agents2).toHaveLength(1);
|
expect(agents2).toHaveLength(1);
|
||||||
expect(agents2[0]).toEqual(agent2);
|
expect(agents2[0]).toMatchObject(agent2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does nothing if conversation doesn't exist", () => {
|
it("does nothing if conversation doesn't exist", () => {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { writable, derived } from "svelte/store";
|
import { writable, derived } from "svelte/store";
|
||||||
import type { AgentInfo } from "$lib/types/agents";
|
import type { AgentInfo } from "$lib/types/agents";
|
||||||
|
import { assignCharacter } from "$lib/utils/agentCharacters";
|
||||||
|
|
||||||
// Map of conversation ID -> agents in that conversation
|
// Map of conversation ID -> agents in that conversation
|
||||||
const agentsByConversation = writable<Record<string, AgentInfo[]>>({});
|
const agentsByConversation = writable<Record<string, AgentInfo[]>>({});
|
||||||
@@ -8,12 +9,17 @@ function createAgentStore() {
|
|||||||
return {
|
return {
|
||||||
subscribe: agentsByConversation.subscribe,
|
subscribe: agentsByConversation.subscribe,
|
||||||
|
|
||||||
addAgent(conversationId: string, agent: AgentInfo) {
|
addAgent(conversationId: string, agent: Omit<AgentInfo, "characterName" | "characterAvatar">) {
|
||||||
agentsByConversation.update((state) => {
|
agentsByConversation.update((state) => {
|
||||||
const existing = state[conversationId] || [];
|
const existing = state[conversationId] || [];
|
||||||
|
const activeNames = existing.map((a) => a.characterName);
|
||||||
|
const character = assignCharacter(activeNames);
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
[conversationId]: [...existing, agent],
|
[conversationId]: [
|
||||||
|
...existing,
|
||||||
|
{ ...agent, characterName: character.name, characterAvatar: character.avatar },
|
||||||
|
],
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ export interface AgentInfo {
|
|||||||
status: AgentStatus;
|
status: AgentStatus;
|
||||||
parentToolUseId?: string;
|
parentToolUseId?: string;
|
||||||
durationMs?: number;
|
durationMs?: number;
|
||||||
|
characterName: string;
|
||||||
|
characterAvatar: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AgentStartPayload {
|
export interface AgentStartPayload {
|
||||||
|
|||||||
@@ -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<string>();
|
||||||
|
// 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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)];
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user