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:
@@ -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", () => {
|
||||
|
||||
@@ -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
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 */
|
||||
|
||||
Reference in New Issue
Block a user