feat(FN-1164): add agent org hierarchy query APIs

- Add OrgTreeNode recursive type in core types and export it from the package index
- Add AgentStore.getChainOfCommand() with cycle protection and bounded traversal depth
- Add AgentStore.getOrgTree() to build sorted root/child hierarchies from reportsTo relationships
- Add AgentStore.resolveAgent() to resolve agents by exact ID or normalized shortname
- Add comprehensive AgentStore tests covering hierarchy traversal, tree construction, and shortname resolution
This commit is contained in:
gsxdsm
2026-04-08 02:19:06 -07:00
parent 6e00061684
commit 55c694479f
4 changed files with 278 additions and 1 deletions

View File

@@ -618,6 +618,179 @@ describe("AgentStore", () => {
});
});
// ── Org Hierarchy ────────────────────────────────────────────────
describe("getChainOfCommand", () => {
it("returns empty array for nonexistent agent", async () => {
const chain = await store.getChainOfCommand("agent-missing");
expect(chain).toEqual([]);
});
it("returns only self when agent has no manager", async () => {
const solo = await store.createAgent({ name: "Solo", role: "executor" });
const chain = await store.getChainOfCommand(solo.id);
expect(chain.map((agent) => agent.id)).toEqual([solo.id]);
});
it("returns self → manager → grand-manager", async () => {
const grandManager = await store.createAgent({ name: "Grand", role: "executor" });
const manager = await store.createAgent({
name: "Manager",
role: "executor",
reportsTo: grandManager.id,
});
const agent = await store.createAgent({
name: "Worker",
role: "executor",
reportsTo: manager.id,
});
const chain = await store.getChainOfCommand(agent.id);
expect(chain.map((item) => item.id)).toEqual([agent.id, manager.id, grandManager.id]);
});
it("stops traversal when a cycle is detected", async () => {
const a = await store.createAgent({ name: "Cycle A", role: "executor" });
const b = await store.createAgent({
name: "Cycle B",
role: "executor",
reportsTo: a.id,
});
await store.updateAgent(a.id, { reportsTo: b.id });
const chain = await store.getChainOfCommand(a.id);
expect(chain.map((agent) => agent.id)).toEqual([a.id, b.id]);
expect(chain.length).toBeLessThanOrEqual(20);
});
});
describe("getOrgTree", () => {
it("returns empty array when no agents exist", async () => {
const tree = await store.getOrgTree();
expect(tree).toEqual([]);
});
it("returns all agents as roots when no one has reportsTo", async () => {
vi.useFakeTimers();
try {
vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
const first = await store.createAgent({ name: "First", role: "executor" });
vi.setSystemTime(new Date("2026-01-02T00:00:00Z"));
const second = await store.createAgent({ name: "Second", role: "executor" });
const tree = await store.getOrgTree();
expect(tree).toHaveLength(2);
expect(tree.map((node) => node.agent.id)).toEqual([first.id, second.id]);
expect(tree[0].children).toEqual([]);
expect(tree[1].children).toEqual([]);
} finally {
vi.useRealTimers();
}
});
it("builds a nested hierarchy and sorts children by createdAt ascending", async () => {
vi.useFakeTimers();
try {
vi.setSystemTime(new Date("2026-02-01T00:00:00Z"));
const root = await store.createAgent({ name: "Root", role: "executor" });
vi.setSystemTime(new Date("2026-02-02T00:00:00Z"));
const childOlder = await store.createAgent({
name: "Child Older",
role: "executor",
reportsTo: root.id,
});
vi.setSystemTime(new Date("2026-02-03T00:00:00Z"));
const childYounger = await store.createAgent({
name: "Child Younger",
role: "executor",
reportsTo: root.id,
});
vi.setSystemTime(new Date("2026-02-04T00:00:00Z"));
const grandChild = await store.createAgent({
name: "Grand Child",
role: "executor",
reportsTo: childOlder.id,
});
const tree = await store.getOrgTree();
expect(tree).toHaveLength(1);
expect(tree[0].agent.id).toBe(root.id);
expect(tree[0].children.map((node) => node.agent.id)).toEqual([
childOlder.id,
childYounger.id,
]);
expect(tree[0].children[0].children.map((node) => node.agent.id)).toEqual([
grandChild.id,
]);
} finally {
vi.useRealTimers();
}
});
it("treats agents with missing managers as root nodes", async () => {
const root = await store.createAgent({ name: "Root", role: "executor" });
const orphan = await store.createAgent({
name: "Orphan",
role: "executor",
reportsTo: "agent-nonexistent",
});
const tree = await store.getOrgTree();
expect(tree.map((node) => node.agent.id)).toEqual([root.id, orphan.id]);
});
});
describe("resolveAgent", () => {
it("resolves by exact agent ID", async () => {
const created = await store.createAgent({ name: "ID Match", role: "executor" });
const resolved = await store.resolveAgent(created.id);
expect(resolved?.id).toBe(created.id);
});
it("resolves by normalized name", async () => {
const created = await store.createAgent({ name: "My Agent", role: "executor" });
const resolved = await store.resolveAgent("my-agent");
expect(resolved?.id).toBe(created.id);
});
it("returns null when multiple agents share the same normalized shortname", async () => {
await store.createAgent({ name: "My Agent", role: "executor" });
await store.createAgent({ name: "my-agent", role: "reviewer" });
const resolved = await store.resolveAgent("my-agent");
expect(resolved).toBeNull();
});
it("returns null for unknown shortnames", async () => {
await store.createAgent({ name: "Known Agent", role: "executor" });
const resolved = await store.resolveAgent("not-found");
expect(resolved).toBeNull();
});
it("matches shortnames case-insensitively", async () => {
const created = await store.createAgent({ name: "My Agent", role: "executor" });
const resolved = await store.resolveAgent("MY-AGENT");
expect(resolved?.id).toBe(created.id);
});
it("normalizes special characters in names", async () => {
const created = await store.createAgent({ name: "Test Agent v2!", role: "executor" });
const resolved = await store.resolveAgent("test-agent-v2");
expect(resolved?.id).toBe(created.id);
});
});
// ── updateAgentState ──────────────────────────────────────────────
describe("updateAgentState", () => {

View File

@@ -33,6 +33,7 @@ import type {
AgentConfigRevision,
AgentConfigSnapshot,
AgentAccessState,
OrgTreeNode,
} from "./types.js";
import { AGENT_VALID_TRANSITIONS, agentToConfigSnapshot, diffConfigSnapshots } from "./types.js";
import { computeAccessState } from "./agent-permissions.js";
@@ -885,6 +886,103 @@ export class AgentStore extends EventEmitter {
return all.filter((a) => a.reportsTo === agentId);
}
/**
* Walk the chain of command for an agent.
* @param agentId - Starting agent ID
* @returns Ordered chain [self, manager, grandManager, ...]
*/
async getChainOfCommand(agentId: string): Promise<Agent[]> {
const chain: Agent[] = [];
const visited = new Set<string>();
let currentId: string | undefined = agentId;
for (let depth = 0; depth < 20 && currentId; depth += 1) {
if (visited.has(currentId)) {
break;
}
visited.add(currentId);
const agent = await this.getAgent(currentId);
if (!agent) {
return depth === 0 ? [] : chain;
}
chain.push(agent);
currentId = agent.reportsTo;
}
return chain;
}
/**
* Build the recursive org tree for all agents.
* @returns Root nodes with nested children
*/
async getOrgTree(): Promise<OrgTreeNode[]> {
const agents = await this.listAgents();
if (agents.length === 0) {
return [];
}
const agentsById = new Map(agents.map((agent) => [agent.id, agent]));
const childrenByParent = new Map<string, Agent[]>();
const roots: Agent[] = [];
for (const agent of agents) {
if (!agent.reportsTo || !agentsById.has(agent.reportsTo)) {
roots.push(agent);
continue;
}
const siblings = childrenByParent.get(agent.reportsTo) ?? [];
siblings.push(agent);
childrenByParent.set(agent.reportsTo, siblings);
}
const sortByCreatedAtAsc = (a: Agent, b: Agent): number =>
new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
for (const children of childrenByParent.values()) {
children.sort(sortByCreatedAtAsc);
}
roots.sort(sortByCreatedAtAsc);
const buildNode = (agent: Agent): OrgTreeNode => ({
agent,
children: (childrenByParent.get(agent.id) ?? []).map((child) => buildNode(child)),
});
return roots.map((root) => buildNode(root));
}
/**
* Resolve an agent by exact ID or normalized shortname derived from display name.
* @param shortname - Agent ID or normalized agent name
* @returns Matching agent when unambiguous; otherwise null
*/
async resolveAgent(shortname: string): Promise<Agent | null> {
const normalize = (value: string): string =>
value
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
const all = await this.listAgents();
const exact = all.find((agent) => agent.id === shortname);
if (exact) {
return exact;
}
const normalizedTarget = normalize(shortname);
if (!normalizedTarget) {
return null;
}
const matches = all.filter((agent) => normalize(agent.name) === normalizedTarget);
return matches.length === 1 ? matches[0] : null;
}
// ─────────────────────────────────────────────────────────────────────────
// Rich Run Storage
// ─────────────────────────────────────────────────────────────────────────

View File

@@ -1,5 +1,5 @@
export { COLUMNS, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, WORKFLOW_STEP_TEMPLATES, AGENT_PERMISSIONS, agentToConfigSnapshot, diffConfigSnapshots } from "./types.js";
export type { Column, IssueInfo, IssueState, PrInfo, PrStatus, Task, TaskAttachment, TaskComment, TaskCommentInput, TaskCreateInput, TaskDetail, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, MergeDetails, MergeResult, Settings, GlobalSettings, ProjectSettings, SettingsScope, TaskStep, StepStatus, TaskLogEntry, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, HeartbeatInvocationSource, AgentTaskSession, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, NtfyNotificationEvent, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, Mailbox } from "./types.js";
export type { Column, IssueInfo, IssueState, PrInfo, PrStatus, Task, TaskAttachment, TaskComment, TaskCommentInput, TaskCreateInput, TaskDetail, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, MergeDetails, MergeResult, Settings, GlobalSettings, ProjectSettings, SettingsScope, TaskStep, StepStatus, TaskLogEntry, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, HeartbeatInvocationSource, AgentTaskSession, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, NtfyNotificationEvent, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, Mailbox } from "./types.js";
export { AGENT_VALID_TRANSITIONS } from "./types.js";
export {
BUILTIN_AGENT_PROMPTS,

View File

@@ -1603,6 +1603,12 @@ export interface Agent {
instructionsText?: string;
}
/** Recursive node in the agent org tree. */
export interface OrgTreeNode {
agent: Agent;
children: OrgTreeNode[];
}
export type MessageResponseMode = "immediate" | "on-heartbeat";
/** Per-agent heartbeat configuration, stored in agent.runtimeConfig */