fix(panel): resolve agent names from the live roster so they never drift

A review task assigned to the pr-reviewer rendered as a truncated raw
UUID instead of its name. Root cause: the panel resolved assignees from a
hardcoded static roster in agent-utils.ts that had drifted — it never
gained the board-adjacent agents added backend-side (intake-1,
secretary-1, pr-reviewer-1). Their UUIDs hit no map entry, so
getAgentDisplayName fell through to the unknown-UUID branch and returned
agentId.slice(0, 8). Every assignee surface (task table, task detail,
subtasks, journals, communications, commit cards) shares that resolver, so
all of them showed the fragment.

Make the live /api/agents roster the source of truth instead of a static
duplicate that silently rots:

- agent-utils: add a runtime registry (registerAgentRoster) keyed by both
  UUID and slug; resolveToSlug / getAgentDisplayName / isKnownAgent consult
  it first. The static maps remain only as an offline / first-paint
  fallback (now complete with the three agents).
- api/agents: surface the backend UUID on AgentDefinition (getAll/getOne
  previously dropped it), so the registry can key by UUID.
- use-agents: add useAgentRosterSync (registers the live roster) and derive
  useAgents from live definitions, falling back to the static roster.
- providers: mount the sync once inside QueryClientProvider.

Now any agent the backend knows about resolves, including ones added after
this change — the panel can no longer drift out of sync.

Tests: agent-utils unit tests cover the three agents end-to-end, a
live-roster-only agent (drift-proofing), live-overrides-static, and a
regression guard for the existing roster.
This commit is contained in:
Renn F
2026-06-18 22:49:09 +02:00
parent 7d9215412b
commit ddcee7ceb8
5 changed files with 233 additions and 17 deletions
+9
View File
@@ -5,6 +5,14 @@ import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { ThemeProvider } from "next-themes";
import { useState } from "react";
import { Toaster } from "@/components/ui/sonner";
import { useAgentRosterSync } from "@/hooks/use-agents";
// Keeps the agent display-name resolver (agent-utils) in sync with the live
// `/api/agents` roster. Must live inside QueryClientProvider. Renders nothing.
function AgentRosterSync() {
useAgentRosterSync();
return null;
}
export function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(
@@ -35,6 +43,7 @@ export function Providers({ children }: { children: React.ReactNode }) {
disableTransitionOnChange
>
<QueryClientProvider client={queryClient}>
<AgentRosterSync />
{children}
<Toaster position="top-right" />
<ReactQueryDevtools initialIsOpen={false} />
+59 -7
View File
@@ -1,17 +1,25 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useEffect } from "react";
import { orchestratorApi, type SpawnAgentRequest } from "@/lib/api/orchestrator";
import { agentsApi } from "@/lib/api/agents";
import { agentsApi, type AgentDefinition } from "@/lib/api/agents";
import { registerAgentRoster } from "@/lib/agent-utils";
import type { Agent, AgentRole, Team, AgentState } from "@/types";
export type { AgentDefinition } from "@/lib/api/agents";
// Static agent roster for RoboCo (18 agents)
// Offline / first-paint fallback only — the live `/api/agents` roster
// (useAgentDefinitions) is authoritative. Keep this list in sync when adding
// agents, but it is not the source of truth and may lag the backend.
const AGENT_ROSTER: Agent[] = [
// Board / Management
{ id: "1", agent_id: "main-pm", name: "Main PM", role: "main_pm" as AgentRole, team: null, cell: null, status: "idle" as AgentState },
{ id: "2", agent_id: "product-owner", name: "Product Owner", role: "product_owner" as AgentRole, team: "board" as Team, cell: null, status: "idle" as AgentState },
{ id: "3", agent_id: "head-marketing", name: "Head of Marketing", role: "head_marketing" as AgentRole, team: "board" as Team, cell: null, status: "idle" as AgentState },
{ id: "4", agent_id: "auditor", name: "Auditor", role: "auditor" as AgentRole, team: null, cell: null, status: "idle" as AgentState },
// Board-adjacent singletons
{ id: "20", agent_id: "intake-1", name: "Intake", role: "prompter" as AgentRole, team: "board" as Team, cell: null, status: "idle" as AgentState },
{ id: "21", agent_id: "secretary-1", name: "Secretary", role: "secretary" as AgentRole, team: "board" as Team, cell: null, status: "idle" as AgentState },
{ id: "22", agent_id: "pr-reviewer-1", name: "PR Reviewer", role: "pr_reviewer" as AgentRole, team: "board" as Team, cell: null, status: "idle" as AgentState },
// Backend Cell
{ id: "5", agent_id: "be-dev-1", name: "Backend Dev 1", role: "developer" as AgentRole, team: "backend" as Team, cell: "backend", status: "idle" as AgentState },
{ id: "6", agent_id: "be-dev-2", name: "Backend Dev 2", role: "developer" as AgentRole, team: "backend" as Team, cell: "backend", status: "idle" as AgentState },
@@ -51,16 +59,55 @@ export function useAgentDefinitions() {
});
}
/**
* Register the live `/api/agents` roster into the display-name resolver
* (agent-utils). Mount once near the app root so every surface that resolves an
* assignee (task table, task detail, journals, communications, commits) shows
* the real agent name instead of a raw UUID, and never drifts as agents are
* added backend-side. Returns nothing — it's a side-effecting sync.
*/
export function useAgentRosterSync(): void {
const { data: definitions } = useAgentDefinitions();
useEffect(() => {
if (definitions && definitions.length > 0) {
registerAgentRoster(
definitions.map((d) => ({ uuid: d.uuid, slug: d.id, name: d.name })),
);
}
}, [definitions]);
}
// Map team → cell (cells carry a cell name; board/management agents have none).
const TEAM_CELLS: ReadonlyArray<Team> = ["backend", "frontend", "ux_ui"] as Team[];
function definitionToAgent(def: AgentDefinition): Agent {
return {
id: def.uuid,
agent_id: def.id,
name: def.name,
role: (def.role ?? "developer") as AgentRole,
team: def.team,
cell: def.team && TEAM_CELLS.includes(def.team) ? def.team : null,
status: "idle" as AgentState,
};
}
// Hooks
// Returns the static agent roster (optionally enriched with live status)
// Returns the agent roster (live definitions when loaded, static fallback
// otherwise), enriched with live orchestrator status.
export function useAgents() {
const { data: definitions } = useAgentDefinitions();
const { data: orchestratorStatus } = useOrchestratorStatus();
// A stable signature so the derived roster refetches when the live set
// changes (react-query keys on this, not on the closure).
const rosterKey = definitions?.map((d) => d.id).join(",") ?? "static";
return useQuery({
queryKey: [...agentKeys.all, "roster"],
queryKey: [...agentKeys.all, "roster", rosterKey],
queryFn: async (): Promise<Agent[]> => {
// Build a map of agent statuses from the agents array
// Build a map of agent statuses from the orchestrator status array
const statusMap = new Map<string, string>();
if (orchestratorStatus?.agents) {
for (const agentStatus of orchestratorStatus.agents) {
@@ -68,8 +115,13 @@ export function useAgents() {
}
}
// Enrich static roster with live status from orchestrator
return AGENT_ROSTER.map((agent) => {
const base: Agent[] =
definitions && definitions.length > 0
? definitions.map(definitionToAgent)
: AGENT_ROSTER;
// Enrich with live status from orchestrator
return base.map((agent) => {
const liveState = statusMap.get(agent.agent_id);
return {
...agent,
@@ -0,0 +1,89 @@
import { describe, it, expect } from "vitest";
import {
resolveToSlug,
getAgentDisplayName,
getAgentInitials,
isKnownAgent,
registerAgentRoster,
} from "@/lib/agent-utils";
// Canonical UUIDs from the backend roster (roboco/foundation/identity.py).
const PR_REVIEWER_UUID = "00000000-0000-0000-0004-000000000007";
const SECRETARY_UUID = "00000000-0000-0000-0004-000000000006";
const INTAKE_UUID = "00000000-0000-0000-0004-000000000005";
const BE_DEV_1_UUID = "00000000-0000-0000-0001-000000000001";
describe("agent-utils board-adjacent agents", () => {
// Regression: these three agents exist in the backend roster but were
// missing from the panel's static maps, so a task assigned to one rendered
// as a truncated raw UUID instead of the agent's name.
it("resolves the pr-reviewer UUID to its slug and name", () => {
expect(resolveToSlug(PR_REVIEWER_UUID)).toBe("pr-reviewer-1");
expect(getAgentDisplayName(PR_REVIEWER_UUID)).toBe("PR Reviewer");
expect(isKnownAgent("pr-reviewer-1")).toBe(true);
});
it("resolves the secretary UUID to its slug and name", () => {
expect(resolveToSlug(SECRETARY_UUID)).toBe("secretary-1");
expect(getAgentDisplayName(SECRETARY_UUID)).toBe("Secretary");
expect(isKnownAgent("secretary-1")).toBe(true);
});
it("resolves the intake UUID to its slug and name", () => {
expect(resolveToSlug(INTAKE_UUID)).toBe("intake-1");
expect(getAgentDisplayName(INTAKE_UUID)).toBe("Intake");
expect(isKnownAgent("intake-1")).toBe(true);
});
it("never falls back to a truncated UUID for a seeded agent", () => {
// The bug symptom: an unresolved UUID returns its first 8 chars.
for (const uuid of [PR_REVIEWER_UUID, SECRETARY_UUID, INTAKE_UUID]) {
expect(getAgentDisplayName(uuid)).not.toBe(uuid.slice(0, 8));
}
});
it("gives each new agent a 3-letter code (not a generic fallback)", () => {
expect(getAgentInitials(PR_REVIEWER_UUID)).toBe("PRR");
expect(getAgentInitials(SECRETARY_UUID)).toBe("SEC");
expect(getAgentInitials(INTAKE_UUID)).toBe("INT");
});
});
describe("agent-utils live roster (drift-proofing)", () => {
// The static map is only a fallback. Once the live /api/agents roster is
// registered, ANY agent the backend knows about resolves — including ones
// added after this file was written, so the panel can never drift again.
it("resolves an agent that exists only in the live roster", () => {
const FUTURE_UUID = "00000000-0000-0000-0009-000000000001";
// Not resolvable before registration — falls back to the truncated UUID.
expect(getAgentDisplayName(FUTURE_UUID)).toBe("00000000");
registerAgentRoster([
{ uuid: FUTURE_UUID, slug: "future-agent-1", name: "Future Agent" },
]);
expect(resolveToSlug(FUTURE_UUID)).toBe("future-agent-1");
expect(getAgentDisplayName(FUTURE_UUID)).toBe("Future Agent");
expect(getAgentDisplayName("future-agent-1")).toBe("Future Agent");
expect(isKnownAgent("future-agent-1")).toBe(true);
});
it("lets the live roster override a stale static name", () => {
const PR_REVIEWER_UUID = "00000000-0000-0000-0004-000000000007";
registerAgentRoster([
{ uuid: PR_REVIEWER_UUID, slug: "pr-reviewer-1", name: "Code Reviewer" },
]);
expect(getAgentDisplayName(PR_REVIEWER_UUID)).toBe("Code Reviewer");
});
});
describe("agent-utils existing roster (regression guard)", () => {
it("still resolves a cell agent", () => {
expect(resolveToSlug(BE_DEV_1_UUID)).toBe("be-dev-1");
expect(getAgentDisplayName(BE_DEV_1_UUID)).toBe("Backend Dev 1");
});
it("returns Unassigned for null", () => {
expect(getAgentDisplayName(null)).toBe("Unassigned");
});
});
+72 -10
View File
@@ -1,11 +1,51 @@
/**
* Agent Display Utilities
*
* Utilities for resolving agent IDs (slugs or UUIDs) to human-readable names.
* Resolve agent IDs (slugs or UUIDs) to human-readable names.
*
* The source of truth is the LIVE roster fetched from `/api/agents` and
* registered via `registerAgentRoster` (see `useAgentRosterSync`). That roster
* always matches the backend seed, so it can never drift as agents are added.
* The static maps below are only an offline / first-paint fallback (and the
* canonical fixture for tests); they are NOT the authority and may lag the
* backend until the live roster loads.
*/
// Static UUID → slug mapping (from backend seeds/initial_data.py)
// NEVER change these after initial deployment
// ---------------------------------------------------------------------------
// Live roster — populated at runtime from /api/agents. Keyed by BOTH the
// backend UUID and the slug, so resolution works whichever identifier a caller
// holds (task.assigned_to is a UUID; many UI props pass a slug).
// ---------------------------------------------------------------------------
interface AgentRecord {
slug: string;
name: string;
}
const liveByKey = new Map<string, AgentRecord>();
/**
* Register the live agent roster (from `/api/agents`). Idempotent; later calls
* overwrite earlier entries so a roster change is reflected immediately.
*/
export function registerAgentRoster(
agents: ReadonlyArray<{
uuid?: string | null;
slug?: string | null;
name?: string | null;
}>,
): void {
for (const agent of agents) {
const slug = agent.slug ?? undefined;
if (!slug) continue;
const record: AgentRecord = { slug, name: agent.name ?? slug };
liveByKey.set(slug, record);
if (agent.uuid) liveByKey.set(agent.uuid, record);
}
}
// Static UUID → slug mapping (from backend seeds/initial_data.py).
// Offline fallback only — the live roster is authoritative. NEVER change a
// UUID here after initial deployment; only add new agents.
const AGENT_UUIDS: Record<string, string> = {
// CEO (Human)
"00000000-0000-0000-0000-000000000001": "ceo",
@@ -32,6 +72,10 @@ const AGENT_UUIDS: Record<string, string> = {
"00000000-0000-0000-0004-000000000002": "product-owner",
"00000000-0000-0000-0004-000000000003": "head-marketing",
"00000000-0000-0000-0004-000000000004": "auditor",
// Board-adjacent singletons (CEO-facing / read-only)
"00000000-0000-0000-0004-000000000005": "intake-1",
"00000000-0000-0000-0004-000000000006": "secretary-1",
"00000000-0000-0000-0004-000000000007": "pr-reviewer-1",
};
// Static agent name mapping (slug -> display name)
@@ -63,6 +107,10 @@ const AGENT_NAMES: Record<string, string> = {
// CEO (human)
"ceo": "CEO",
"CEO": "CEO",
// Board-adjacent singletons
"intake-1": "Intake",
"secretary-1": "Secretary",
"pr-reviewer-1": "PR Reviewer",
};
/**
@@ -70,7 +118,10 @@ const AGENT_NAMES: Record<string, string> = {
*/
export function resolveToSlug(agentId: string | null | undefined): string {
if (!agentId) return "";
// If it's a known UUID, return the slug
// Live roster first (covers any agent the backend knows about)...
const live = liveByKey.get(agentId);
if (live) return live.slug;
// ...then the static UUID → slug fallback.
if (AGENT_UUIDS[agentId]) {
return AGENT_UUIDS[agentId];
}
@@ -88,17 +139,24 @@ export function resolveToSlug(agentId: string | null | undefined): string {
export function getAgentDisplayName(agentId: string | null | undefined): string {
if (!agentId) return "Unassigned";
// First resolve UUID to slug if applicable
const slug = resolveToSlug(agentId);
// Live roster first — keyed by both UUID and slug, so a direct hit gives the
// real name regardless of which identifier the caller passed.
const liveDirect = liveByKey.get(agentId);
if (liveDirect) return liveDirect.name;
// Check if it's a known slug
// Resolve UUID → slug (live-aware), then try the live roster by slug.
const slug = resolveToSlug(agentId);
const liveBySlug = liveByKey.get(slug);
if (liveBySlug) return liveBySlug.name;
// Static fallback for known slugs.
if (AGENT_NAMES[slug]) {
return AGENT_NAMES[slug];
}
// Check if the original was a UUID that we couldn't resolve
// Unresolved UUID (roster not loaded yet and not in the static map) —
// show the first 8 chars rather than the full 36.
if (agentId.length === 36 && agentId.includes("-")) {
// Unknown UUID - show first 8 chars
return agentId.slice(0, 8);
}
@@ -134,6 +192,10 @@ const AGENT_CODES: Record<string, string> = {
// CEO
"ceo": "CEO",
"CEO": "CEO",
// Board-adjacent singletons
"intake-1": "INT",
"secretary-1": "SEC",
"pr-reviewer-1": "PRR",
};
/**
@@ -163,5 +225,5 @@ export function getAgentInitials(agentId: string | null | undefined): string {
*/
export function isKnownAgent(agentId: string | null | undefined): boolean {
if (!agentId) return false;
return agentId in AGENT_NAMES;
return liveByKey.has(agentId) || agentId in AGENT_NAMES;
}
+4
View File
@@ -10,6 +10,7 @@ import { isMockMode, mockAgents } from "@/lib/mock-data";
export interface AgentDefinition {
id: string; // slug from backend (e.g., "be-dev-1")
uuid: string; // stable backend UUID (assigned_to / claimed_by reference this)
name: string;
role: AgentRole | null;
team: Team | null;
@@ -31,6 +32,7 @@ export const agentsApi = {
if (isMockMode()) {
return mockAgents.map((a) => ({
id: a.slug || a.id,
uuid: a.id,
name: a.name,
role: a.role,
team: a.team,
@@ -48,6 +50,7 @@ export const agentsApi = {
return data.map((a) => ({
id: a.slug || a.id, // Use slug as ID, fallback to UUID
uuid: a.id, // raw backend UUID (tasks reference agents by this)
name: a.name || "Unknown",
role: (a.role as AgentRole) || null,
team: (a.team as Team) || null,
@@ -62,6 +65,7 @@ export const agentsApi = {
const a = response.data;
return {
id: a.slug,
uuid: a.id,
name: a.name,
role: a.role as AgentRole,
team: a.team as Team | null,