From 493d0438168208c0cf16615bb49b9fffdfb1c420 Mon Sep 17 00:00:00 2001 From: bo Date: Thu, 20 Aug 2026 22:46:15 +0800 Subject: [PATCH 1/3] feat(agents): add delegated session control plane --- AGENTS.md | 9 +- apps/server/src/routes/sessions.test.ts | 40 +- apps/server/src/routes/sessions.ts | 7 + apps/web/src/api/queries.ts | 4 +- apps/web/src/api/types.ts | 2 + .../features/ContextInspector.interaction.tsx | 90 +- .../SessionAgentsInspector.test.tsx | 147 +- .../SessionAgentsInspector.tsx | 62 +- .../session-inspector-projection.ts | 30 +- .../features/orchestration-surface.test.ts | 44 +- apps/web/src/context/global-sse.test.tsx | 82 +- apps/web/src/context/global-sse.tsx | 18 +- apps/web/src/hooks/use-live-session-diff.ts | 14 +- ...gation-control-plane-hard-cut-plan-goal.md | 260 +++ ...egation-control-plane-hard-cut-progress.md | 46 + .../__arch__/shared-code-unification.test.ts | 4 +- .../tool-output-policy-matrix.test.ts | 2 + packages/agent-core/src/agent-tree/index.ts | 9 + .../src/agent-tree/projection.test.ts | 198 +++ .../agent-core/src/agent-tree/projection.ts | 153 ++ .../src/agents/configured-agent.test.ts | 6 +- .../agent-core/src/agents/configured-agent.ts | 25 +- packages/agent-core/src/agents/constants.ts | 15 +- .../src/agents/definitions/analyst.ts | 4 +- .../src/agents/definitions/build.ts | 4 +- .../agents/definitions/definitions.test.ts | 34 +- .../src/agents/definitions/discussion.ts | 4 +- .../agent-core/src/agents/definitions/lead.ts | 6 +- .../agent-core/src/agents/factory.test.ts | 18 +- packages/agent-core/src/agents/factory.ts | 22 +- packages/agent-core/src/agents/index.ts | 2 +- packages/agent-core/src/agents/query/loop.ts | 10 +- .../src/agents/query/recovery.test.ts | 9 +- packages/agent-core/src/agents/query/types.ts | 13 +- .../src/agents/session-agent-manager.test.ts | 53 +- .../src/agents/session-agent-manager.ts | 67 +- packages/agent-core/src/delegation/types.ts | 33 + .../session-execution-manager.test.ts | 1392 ++++++++++++++- .../execution/session-execution-manager.ts | 1554 +++++++++++++++-- packages/agent-core/src/index.ts | 2 + ...ead-architecture-flows.integration.test.ts | 23 +- packages/agent-core/src/main.test.ts | 87 + ...ti-agent-control-plane.integration.test.ts | 824 +++++++++ packages/agent-core/src/runtime.ts | 115 +- .../src/session-input/service.test.ts | 333 +++- .../agent-core/src/session-input/service.ts | 346 +++- packages/agent-core/src/store/errors.ts | 11 + packages/agent-core/src/store/helpers.test.ts | 33 + packages/agent-core/src/store/helpers.ts | 54 +- packages/agent-core/src/store/index.ts | 1 + .../agent-core/src/store/projection.test.ts | 64 + packages/agent-core/src/store/projection.ts | 30 + .../src/store/session-store-manager.test.ts | 280 ++- .../src/store/session-store-manager.ts | 399 ++++- .../tools/builtins/background-output.test.ts | 13 + .../src/tools/builtins/background-output.ts | 7 +- .../src/tools/builtins/cancel-session.test.ts | 39 +- .../src/tools/builtins/cancel-session.ts | 12 +- .../agent-core/src/tools/builtins/index.ts | 6 + .../src/tools/builtins/list-agents.test.ts | 159 ++ .../src/tools/builtins/list-agents.ts | 197 +++ .../builtins/model-visible-contract.test.ts | 4 +- .../src/tools/builtins/send-message.test.ts | 107 ++ .../src/tools/builtins/send-message.ts | 99 ++ .../tools/builtins/wait-for-reminder.test.ts | 263 ++- .../src/tools/builtins/wait-for-reminder.ts | 245 ++- packages/agent-core/src/tools/names.ts | 2 + packages/agent-core/src/tools/types.ts | 9 +- packages/protocol/src/agent-tree.ts | 35 + packages/protocol/src/guards.test.ts | 46 + packages/protocol/src/guards.ts | 42 +- packages/protocol/src/index.ts | 1 + packages/protocol/src/reduce.test.ts | 25 + packages/protocol/src/reduce.ts | 22 +- packages/protocol/src/tools.test.ts | 15 + packages/protocol/src/tools.ts | 6 + packages/protocol/src/types.ts | 29 +- 77 files changed, 7866 insertions(+), 611 deletions(-) create mode 100644 docs/goals/multi-agent-delegation-control-plane-hard-cut-plan-goal.md create mode 100644 docs/goals/multi-agent-delegation-control-plane-hard-cut-progress.md create mode 100644 packages/agent-core/src/agent-tree/index.ts create mode 100644 packages/agent-core/src/agent-tree/projection.test.ts create mode 100644 packages/agent-core/src/agent-tree/projection.ts create mode 100644 packages/agent-core/src/multi-agent-control-plane.integration.test.ts create mode 100644 packages/agent-core/src/tools/builtins/list-agents.test.ts create mode 100644 packages/agent-core/src/tools/builtins/list-agents.ts create mode 100644 packages/agent-core/src/tools/builtins/send-message.test.ts create mode 100644 packages/agent-core/src/tools/builtins/send-message.ts create mode 100644 packages/protocol/src/agent-tree.ts diff --git a/AGENTS.md b/AGENTS.md index cf6d11ca..1613b93b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -256,7 +256,7 @@ packages/utils/src/ → Hono Runtime routes → Session-scoped Lead / Automation / HITL routes → SessionExecutionManager → ConfiguredAgent → query loop → store → SSE → Web UI -Delegation: `delegate(DelegationRequest)` creates a durable direct child; `resume_session` preserves its Agent, Profile, Skills, and responsibility. Every child finishes with a normal assistant response; synchronous delegation returns that final response directly, while background work is read through `background_output`. If a synchronous child suspends, its parent suspends on the original tool call; each resumes its own same logical Execution when ready. `SessionExecutionManager` is the sole owner of Execution lifecycle, admission, concurrency, live run resources, recovery, and terminal records. There is no Build owned-scope or lease subsystem. +Delegation control is a fixed seven-tool package: `delegate`, `list_agents`, `send_message`, `background_output`, `wait_for_reminder`, `cancel_session`, and `resume_session`. `delegate(DelegationRequest)` creates a durable direct child; `list_agents` reads the caller's descendant subtree through the same backend Agent Tree projection used by the Web tree; and `send_message` is the only parent-to-child message path, with `delivery: "steer" | "queue"` selecting the current Execution's next model boundary or the next Execution. `background_output` reads a direct child's result, `wait_for_reminder` waits on direct children, `cancel_session` strongly cascades to any descendant subtree, and `resume_session` continues a stopped direct child while preserving its Agent, Profile, Skills, and responsibility. Every child finishes with a normal assistant response; synchronous delegation returns that final response directly, while background work is read through `background_output`. If a synchronous child suspends, its parent suspends on the original tool call; each resumes its own same logical Execution when ready. `SessionExecutionManager` is the sole owner of Execution lifecycle, admission, concurrency, live run resources, recovery, and terminal records. There is no Build owned-scope or lease subsystem. ``` **Server + Web UI:** @@ -405,11 +405,12 @@ All six implement `Agent`: `store: StoreApi`, `run(options) **Delegation + tool filtering:** - Tool sets are hardcoded by `AgentDefinition`; typed RoleContract and Prompt layers describe behavior but never change runtime permissions. - Profiles route model resources only; Skills provide guidance only. Neither changes tools, delegation targets, or completion authority. +- `DELEGATION_CONTROL_TOOLS` is the fixed seven-tool package: `delegate`, `list_agents`, `send_message`, `background_output`, `wait_for_reminder`, `cancel_session`, and `resume_session`. Lead, Discussion, Analyst, and Build explicitly spread this package in their own `AgentDefinition`; Explore and Librarian do not configure it. - `lead` uses `childPolicy.maxDepth = 3`; `discussion`, `analyst`, and `build` use `maxDepth = 2`. Discussion may delegate Explore/Librarian. - Lead targets Analyst/Build/Explore/Librarian; Analyst targets Explore/Librarian; Build targets Explore. - `explore` and `librarian` have no `delegateTargets`; they are terminal read-only support agents. -- `agents/factory.ts` owns one immutable current-Agent/depth delegation capability snapshot and removes delegation tools at each definition's `childPolicy.maxDepth` or when no direct target exists. Prompt/Tool projection and SessionExecutionManager admission consume that same target/Profile/builtin-Skill authority; Provider-facing Tool schemas remain portable presentation contracts while strict internal schemas still validate execution input. -- `delegate` persists Agent, Profile, Skills, title, objective, and background choice. `resume_session` preserves that identity. Multiple Builds share general Session concurrency; there is no owned-scope or Build lease subsystem. +- `agents/factory.ts` owns one immutable current-Agent/depth delegation capability snapshot and only removes the explicitly configured delegation package at each definition's `childPolicy.maxDepth` or when no direct target exists; it never injects delegation tools. Prompt/Tool projection and SessionExecutionManager admission consume that same target/Profile/builtin-Skill authority; Provider-facing Tool schemas remain portable presentation contracts while strict internal schemas still validate execution input. +- `list_agents` and the Web Agent Tree use one backend projection of durable family topology plus live Execution/Link facts. `send_message` targets only a running direct child and uses `steer | queue`; `cancel_session` accepts any descendant and strongly cascades its subtree, while `wait_for_reminder` and `resume_session` remain direct-child operations. `delegate` persists Agent, Profile, Skills, title, objective, and background choice; `resume_session` preserves that identity. Multiple Builds share general Session concurrency; there is no owned-scope or Build lease subsystem. **Workflow Skills:** - Ordinary root Lead activates `orchestrate-work`; active Goal activates `run-goal`; root Discussion activates `shape-todo`, derived from authoritative runtime facts on every Execution. @@ -452,7 +453,7 @@ Successful root Lead/Discussion terminals update the durable Memory cursor; | Interaction | ask_user✅❌not-concurrent, todo_write❌, project_todo_update❌ | ask_user serializes (interactive); `project_todo_update` derives its Todo from the current bound root Discussion and requires `expectedRevision` | | Web | web_fetch✅ | — | | LSP | lsp_diagnostics✅, lsp_goto_definition✅, lsp_find_references✅, lsp_symbols✅ | Guard: workspace | -| Delegation / Skills | delegate❌, resume_session❌, background_output✅, wait_for_reminder✅, cancel_session❌, skill_list✅, skill_read✅ | `delegate` accepts only strict `{ agent_type, profile, title, objective, skills, background }`; `resume_session` accepts only `{ session_id, instruction, background }`; delegated roles return ordinary final assistant text. Only Lead has family cancel. | +| Delegation / Skills | delegate❌, list_agents✅, send_message❌, background_output✅, wait_for_reminder✅, cancel_session❌, resume_session❌, skill_list✅, skill_read✅ | The seven control tools are explicitly configured by Lead, Discussion, Analyst, and Build; Explore and Librarian do not receive them. `delegate` accepts only strict `{ agent_type, profile, title, objective, skills, background }`; `list_agents` returns only the bounded caller-descendant Agent Tree projection; `send_message` accepts `{ session_id, expected_execution_id, message, delivery: "steer" | "queue" }` for a running direct child; `background_output` reads direct-child results; `wait_for_reminder` waits on direct children; `cancel_session` strongly cascades any descendant subtree; and `resume_session` accepts only a stopped direct child with `{ session_id, instruction, background }`. Delegated roles return ordinary final assistant text. | | Tool output recovery | output_read✅, output_search✅ | All agents may retrieve only authorized, bounded artifact pages or search results. | | Memory | memory_read✅, memory_write❌ | memory_write rejects secrets | | Goal / Automation creation | create_goal❌, get_goal✅, update_goal❌, automation_create❌ | Before a root Lead calls strict `create_goal({ objective })`, it uses ordinary `ask_user` and interprets the answer semantically. Goal creation never parses an initial budget from objective text; users control budget through the Session API/UI. Before completion, Lead uses a fresh direct deep Analyst with `goal-review`, interprets its ordinary report, and calls strict `update_goal({ status, reason })`; Runtime retains only active-family and instance/generation consistency checks. | diff --git a/apps/server/src/routes/sessions.test.ts b/apps/server/src/routes/sessions.test.ts index fe7c42f5..1d094164 100644 --- a/apps/server/src/routes/sessions.test.ts +++ b/apps/server/src/routes/sessions.test.ts @@ -2,7 +2,7 @@ import { afterAll, beforeEach, describe, expect, test } from "bun:test"; import { mkdir, rm } from "node:fs/promises"; import { resolve } from "node:path"; import type { AgentRuntime } from "@archcode/agent-core"; -import { NotRootSessionError, ProjectRegistry, SessionAutomationReferenceConflictError, SessionDeleteConflictError, SessionDeleteInProgressError, SessionFamilyStopConflictError, SessionFamilyStopInProgressError, SessionModelSelectionNotAllowedError, silentLogger } from "@archcode/agent-core"; +import { AgentTreeProjectionError, NotRootSessionError, ProjectRegistry, SessionAutomationReferenceConflictError, SessionDeleteConflictError, SessionDeleteInProgressError, SessionFamilyStopConflictError, SessionFamilyStopInProgressError, SessionModelSelectionNotAllowedError, silentLogger } from "@archcode/agent-core"; import { createRuntimeApp } from "../app"; const tempRoot = resolve(import.meta.dir, "__test_tmp__", "sessions-routes"); @@ -183,6 +183,13 @@ function createTestRuntime(projectRegistry: ProjectRegistry) { return sessions.get(`${input.workspaceRoot}\0${input.sessionId}`)!; }, listSessionTree: async (workspaceRoot: string, rootSessionId: string) => { + if (rootSessionId === "tree-conflict") { + throw new AgentTreeProjectionError( + "active_execution_mismatch", + rootSessionId, + "Agent Tree snapshot changed during capture", + ); + } const key = `${workspaceRoot}\0${rootSessionId}`; const session = sessions.get(key); if (!session) throw new MissingSessionFileError(); @@ -191,9 +198,13 @@ function createTestRuntime(projectRegistry: ProjectRegistry) { } type RuntimeTreeNode = { session: { sessionId: string; rootSessionId: string; parentSessionId?: string; title: string | null; createdAt: number }; + depth: number; + latestExecutionStatus: string | null; + activeExecutionId: string | null; + linkStatus: string | null; children: RuntimeTreeNode[]; }; - const toNode = (nodeSession: StoredSessionBody): RuntimeTreeNode => ({ + const toNode = (nodeSession: StoredSessionBody, depth = 0): RuntimeTreeNode => ({ session: { sessionId: nodeSession.sessionId, rootSessionId: nodeSession.rootSessionId, @@ -201,9 +212,13 @@ function createTestRuntime(projectRegistry: ProjectRegistry) { title: nodeSession.title ?? null, createdAt: nodeSession.createdAt, }, + depth, + latestExecutionStatus: nodeSession.executions.at(-1)?.status ?? null, + activeExecutionId: null, + linkStatus: null, children: [...sessions.entries()] .filter(([entryKey, candidate]) => entryKey.startsWith(`${workspaceRoot}\0`) && candidate.parentSessionId === nodeSession.sessionId) - .map(([, candidate]) => toNode(candidate)), + .map(([, candidate]) => toNode(candidate, depth + 1)), }); return { @@ -723,12 +738,31 @@ describe("sessions routes", () => { title: "Root", createdAt: 1000, }, + depth: 0, + latestExecutionStatus: null, + activeExecutionId: null, + linkStatus: null, children: [], }, diagnostics: [], }); }); + test("GET /api/projects/:slug/sessions/:sessionId/tree returns 409 for an unstable projection", async () => { + const { app, project } = await createTestApp("tree-conflict"); + + const res = await app.request(`/api/projects/${project.slug}/sessions/tree-conflict/tree`); + + expect(res.status).toBe(409); + expect(await res.json()).toEqual({ + error: { + code: "BAD_REQUEST", + message: "Agent Tree snapshot changed during capture", + details: { scopeCode: "AGENT_TREE_SNAPSHOT_CONFLICT" }, + }, + }); + }); + test("GET /api/projects/:slug/sessions/:sessionId/tree returns 400 for child session", async () => { const { app, project, workspaceRoot, sessions } = await createTestApp("tree-child"); const childSession = createStoredSession({ sessionId: "child-session", rootSessionId: "root-session", parentSessionId: "root-session", createdAt: 1000, title: "Child" }); diff --git a/apps/server/src/routes/sessions.ts b/apps/server/src/routes/sessions.ts index a2171611..7ab4686a 100644 --- a/apps/server/src/routes/sessions.ts +++ b/apps/server/src/routes/sessions.ts @@ -1,11 +1,13 @@ import { Hono } from "hono"; import { + AgentTreeProjectionError, NotRootSessionError, SessionDeleteConflictError, SessionDeleteInProgressError, SessionAutomationReferenceConflictError, SessionFamilyStopConflictError, SessionFamilyStopInProgressError, + SessionFamilySnapshotConflictError, SessionFileNotFoundError, SessionGoalServiceError, SessionModelSelectionConflictError, @@ -149,6 +151,11 @@ export function createSessionsRoutes(runtime: AgentRuntime): Hono { if (error instanceof SessionFileNotFoundError || isMissingFileError(error)) { throw new SessionNotFoundError(sessionId); } + if (error instanceof AgentTreeProjectionError || error instanceof SessionFamilySnapshotConflictError) { + throw new ServerError("BAD_REQUEST", error.message, 409, { + scopeCode: "AGENT_TREE_SNAPSHOT_CONFLICT", + }); + } throw error; } }); diff --git a/apps/web/src/api/queries.ts b/apps/web/src/api/queries.ts index 5f78c9ae..4c499311 100644 --- a/apps/web/src/api/queries.ts +++ b/apps/web/src/api/queries.ts @@ -14,7 +14,7 @@ import type { ProjectAutomationInventoryItem, ProjectTodoPlan, ProjectTodoAttachmentListResponse, - SessionTreeResponse, + AgentTreeProjection, ProjectTodo, } from "./types"; import { @@ -172,7 +172,7 @@ export function sessionTreeQueryOptions(slug: string, rootSessionId: string) { return queryOptions({ queryKey: queryKeys.tree(slug, rootSessionId), queryFn: async () => { - const response = await apiFetch( + const response = await apiFetch( `/api/projects/${encodeURIComponent(slug)}/sessions/${encodeURIComponent(rootSessionId)}/tree`, ); return response; diff --git a/apps/web/src/api/types.ts b/apps/web/src/api/types.ts index 554b0f51..cb292ad8 100644 --- a/apps/web/src/api/types.ts +++ b/apps/web/src/api/types.ts @@ -1,5 +1,7 @@ export type { AgentDescriptor, + AgentTreeNode, + AgentTreeProjection, Project, DirectoryEntry, DirectoryListResponse, diff --git a/apps/web/src/components/features/ContextInspector.interaction.tsx b/apps/web/src/components/features/ContextInspector.interaction.tsx index 2b0d8d38..9e878ddf 100644 --- a/apps/web/src/components/features/ContextInspector.interaction.tsx +++ b/apps/web/src/components/features/ContextInspector.interaction.tsx @@ -4,7 +4,9 @@ import { act } from "react"; import { createRoot } from "react-dom/client"; import { MemoryRouter, Route, Routes } from "react-router-dom"; import { JSDOM } from "jsdom"; -import type { SessionTreeResponse } from "../../api/types"; +import type { AgentTreeProjection } from "../../api/types"; +import { queryKeys } from "../../api/queries"; +import { sessionRuntimeStore } from "../../store/session-runtime-store"; const originals = new Map(); @@ -12,7 +14,7 @@ mock.module("./context-inspector/SessionInspector", () => ({ SessionInspector: ({ activeTab }: { activeTab: string }) =>
{activeTab}
, })); -const treeResponse: SessionTreeResponse = { +const treeResponse: AgentTreeProjection = { root: { session: { sessionId: "root", @@ -27,6 +29,10 @@ const treeResponse: SessionTreeResponse = { createdAt: 1, updatedAt: 2, }, + depth: 0, + latestExecutionStatus: "completed", + activeExecutionId: null, + linkStatus: null, children: [{ session: { sessionId: "child", @@ -41,6 +47,10 @@ const treeResponse: SessionTreeResponse = { createdAt: 1, updatedAt: 2, }, + depth: 1, + latestExecutionStatus: "completed", + activeExecutionId: null, + linkStatus: "completed", children: [], }], }, @@ -48,10 +58,13 @@ const treeResponse: SessionTreeResponse = { }; const apiFetch = mock(async (path: string): Promise => { + if (path === "/api/projects/demo/sessions/root") return treeResponse.root.session; + if (path === "/api/projects/demo/sessions/child") return treeResponse.root.children[0]!.session; if (path === "/api/projects/demo/sessions/root/tree") return treeResponse; if (path === "/api/projects/demo/diff?sessionId=root") { return { files: [{ path: "src/index.ts", status: "modified", additions: 2, deletions: 1 }] }; } + if (path === "/api/projects/demo/diff?sessionId=child") return { files: [] }; throw new Error(`Unexpected Inspector request: ${path}`); }); @@ -86,7 +99,10 @@ function restoreDom(): void { originals.clear(); } -afterEach(restoreDom); +afterEach(() => { + restoreDom(); + sessionRuntimeStore.getState().reset(); +}); describe("ContextInspector keyboard tabs", () => { test("supports ArrowLeft/ArrowRight/Home/End while keeping focus and URL state aligned", async () => { @@ -104,7 +120,11 @@ describe("ContextInspector keyboard tabs", () => { , )); - await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); }); + await act(async () => { + for (let attempt = 0; attempt < 5; attempt += 1) { + await new Promise((resolve) => setTimeout(resolve, 0)); + } + }); const tab = (label: string) => Array.from(container.querySelectorAll('[role="tab"]')) .find((element) => element.textContent?.startsWith(label))!; @@ -142,4 +162,66 @@ describe("ContextInspector keyboard tabs", () => { queryClient.clear(); dom.window.close(); }); + + test("queries the shared Agent Tree by durable root after navigating to a child Session", async () => { + const dom = installDom(); + const container = document.getElementById("root")!; + const root = createRoot(container); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + apiFetch.mockClear(); + sessionRuntimeStore.getState().applySnapshot({ + type: "session.runtime.snapshot", + projectSlugs: ["demo"], + families: [{ projectSlug: "demo", rootSessionId: "root", activity: "running" }], + createdAt: 1, + }); + + await act(async () => root.render( + + + + } /> + + + , + )); + await act(async () => { + for (let attempt = 0; attempt < 5; attempt += 1) { + await new Promise((resolve) => setTimeout(resolve, 0)); + } + }); + + expect(apiFetch).toHaveBeenCalledWith("/api/projects/demo/sessions/child"); + expect(apiFetch).toHaveBeenCalledWith("/api/projects/demo/sessions/root/tree"); + expect(apiFetch).not.toHaveBeenCalledWith("/api/projects/demo/sessions/child/tree"); + expect(apiFetch).toHaveBeenCalledWith("/api/projects/demo/diff?sessionId=child"); + expect(container.querySelector('[data-testid="inspector-count-agents"]')?.textContent).toBe("2"); + const childDiffQuery = queryClient.getQueryCache().find({ + queryKey: queryKeys.diff("demo", "child"), + }); + expect((childDiffQuery?.options as { refetchInterval?: unknown }).refetchInterval).toBe(2_000); + + const diffCallsBeforeStop = apiFetch.mock.calls.filter( + ([path]) => path === "/api/projects/demo/diff?sessionId=child", + ).length; + await act(async () => { + sessionRuntimeStore.getState().applyChange({ + type: "session.runtime_changed", + projectSlug: "demo", + rootSessionId: "root", + activity: "idle", + createdAt: 2, + }); + for (let attempt = 0; attempt < 5; attempt += 1) { + await new Promise((resolve) => setTimeout(resolve, 0)); + } + }); + expect(apiFetch.mock.calls.filter( + ([path]) => path === "/api/projects/demo/diff?sessionId=child", + ).length).toBe(diffCallsBeforeStop + 1); + + await act(async () => root.unmount()); + queryClient.clear(); + dom.window.close(); + }); }); diff --git a/apps/web/src/components/features/context-inspector/SessionAgentsInspector.test.tsx b/apps/web/src/components/features/context-inspector/SessionAgentsInspector.test.tsx index 3250126e..9a94fbfb 100644 --- a/apps/web/src/components/features/context-inspector/SessionAgentsInspector.test.tsx +++ b/apps/web/src/components/features/context-inspector/SessionAgentsInspector.test.tsx @@ -4,17 +4,11 @@ import { createRoot } from "react-dom/client"; import { MemoryRouter, Route, Routes } from "react-router-dom"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { JSDOM } from "jsdom"; -import type { SessionTreeNode, SessionTreeResponse, ToolChildSessionLink } from "@archcode/protocol"; -import type { Session } from "../../../api/types"; +import type { AgentTreeNode, AgentTreeProjection } from "@archcode/protocol"; import { queryKeys } from "../../../api/queries"; -import { - __resetWebSessionStoresForTest, - currentSessionSnapshotGeneration, - getWebSessionStore, -} from "../../../store/session-store"; -import { sessionAuthoritativeSnapshot } from "../../../test-support/session-authoritative-snapshot"; import { SessionAgentsInspector } from "./SessionAgentsInspector"; import { flattenInspectorAgents } from "./session-inspector-projection"; +import { sessionRuntimeStore } from "../../../store/session-runtime-store"; const originals = new Map(); @@ -37,7 +31,6 @@ function installDom(): JSDOM { } function restoreDom(): void { - __resetWebSessionStoresForTest(); for (const [name, descriptor] of originals) { if (descriptor) Object.defineProperty(globalThis, name, descriptor); else Reflect.deleteProperty(globalThis, name); @@ -50,8 +43,12 @@ function node(input: { parentSessionId?: string; agentName: string; profile: "principal" | "deep" | "fast"; - children?: SessionTreeNode[]; -}): SessionTreeNode { + depth: number; + latestExecutionStatus: AgentTreeNode["latestExecutionStatus"]; + activeExecutionId?: string | null; + linkStatus?: AgentTreeNode["linkStatus"]; + children?: AgentTreeNode[]; +}): AgentTreeNode { return { session: { sessionId: input.sessionId, @@ -66,73 +63,48 @@ function node(input: { createdAt: 1, updatedAt: 2, }, - children: input.children ?? [], - }; -} - -function link(input: { - parentSessionId: string; - childSessionId: string; - status: ToolChildSessionLink["status"]; - childAgentName: string; - childProfile: "deep" | "fast"; - depth: number; -}): ToolChildSessionLink { - return { - parentSessionId: input.parentSessionId, - parentToolCallId: `delegate-${input.childSessionId}`, - toolName: "delegate", - childSessionId: input.childSessionId, - childExecutionId: `execution-${input.childSessionId}`, - childAgentName: input.childAgentName, - childProfile: input.childProfile, - childSkillNames: [], - title: input.childSessionId, depth: input.depth, - background: false, - status: input.status, - createdAt: 1, + latestExecutionStatus: input.latestExecutionStatus, + activeExecutionId: input.activeExecutionId ?? null, + linkStatus: input.linkStatus ?? null, + children: input.children ?? [], }; } -afterEach(restoreDom); +afterEach(() => { + restoreDom(); + sessionRuntimeStore.getState().reset(); +}); describe("SessionAgentsInspector", () => { - test("renders a grandchild status from the direct parent's authoritative Session snapshot", async () => { + test("renders all descendant statuses directly from the canonical Agent Tree projection", async () => { const dom = installDom(); const container = document.getElementById("root")!; const root = createRoot(container); - const rootLink = link({ - parentSessionId: "root", - childSessionId: "child", - status: "completed", - childAgentName: "build", - childProfile: "deep", - depth: 1, - }); - const grandchildLink = link({ - parentSessionId: "child", - childSessionId: "grandchild", - status: "waiting_for_human", - childAgentName: "explore", - childProfile: "fast", - depth: 2, - }); - const tree: SessionTreeResponse = { + const tree: AgentTreeProjection = { root: node({ sessionId: "root", agentName: "lead", profile: "principal", + depth: 0, + latestExecutionStatus: "running", + activeExecutionId: "root-execution", children: [node({ sessionId: "child", parentSessionId: "root", agentName: "build", profile: "deep", + depth: 1, + latestExecutionStatus: "completed", + linkStatus: "completed", children: [node({ sessionId: "grandchild", parentSessionId: "child", agentName: "explore", profile: "fast", + depth: 2, + latestExecutionStatus: "suspended", + linkStatus: "waiting_for_human", })], })], }), @@ -145,14 +117,6 @@ describe("SessionAgentsInspector", () => { { name: "build", displayName: "Build" }, { name: "explore", displayName: "Explore" }, ]); - queryClient.setQueryData(queryKeys.session("demo", "child"), { - sessionId: "child", - childSessionLinks: [grandchildLink], - } as Session); - getWebSessionStore("root", "demo").getState().applyAuthoritativeSnapshot(sessionAuthoritativeSnapshot("root", { - childSessionLinks: [rootLink], - eventCursor: -1, - }), currentSessionSnapshotGeneration()); await act(async () => root.render( @@ -178,6 +142,63 @@ describe("SessionAgentsInspector", () => { expect(rows[2]?.querySelector('[data-agent-role-icon="explore"]')).not.toBeNull(); expect(rows[1]?.querySelector('[data-agent-status="Completed"]')).not.toBeNull(); expect(rows[2]?.querySelector('[data-agent-status="Paused"]')).not.toBeNull(); + expect(queryClient.getQueryData(queryKeys.session("demo", "child"))).toBeUndefined(); + + await act(async () => root.unmount()); + queryClient.clear(); + dom.window.close(); + }); + + test("keeps canonical root activity and child terminal status on a child Session route", async () => { + const dom = installDom(); + const container = document.getElementById("root")!; + const root = createRoot(container); + const tree: AgentTreeProjection = { + root: node({ + sessionId: "root", + agentName: "lead", + profile: "principal", + depth: 0, + latestExecutionStatus: "running", + activeExecutionId: "root-execution", + children: [node({ + sessionId: "child", + parentSessionId: "root", + agentName: "build", + profile: "deep", + depth: 1, + latestExecutionStatus: "cancelled", + linkStatus: "cancelled", + })], + }), + diagnostics: [], + }; + const queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: Infinity, retry: false } } }); + queryClient.setQueryData(queryKeys.agents, [ + { name: "lead", displayName: "Lead" }, + { name: "build", displayName: "Build" }, + ]); + sessionRuntimeStore.getState().applySnapshot({ + type: "session.runtime.snapshot", + projectSlugs: ["demo"], + families: [{ projectSlug: "demo", rootSessionId: "root", activity: "running" }], + createdAt: 1, + }); + + await act(async () => root.render( + + + + } /> + + + , + )); + + const rows = Array.from(container.querySelectorAll('nav[aria-label="Agents"] > button')); + expect(rows[0]?.querySelector('[data-agent-status="Running"]')).not.toBeNull(); + expect(rows[1]?.querySelector('[data-agent-status="Stopped"]')).not.toBeNull(); + expect(rows[1]?.querySelector('[data-agent-status="Stopped"]')?.getAttribute("title")).toContain("Cancelled"); await act(async () => root.unmount()); queryClient.clear(); diff --git a/apps/web/src/components/features/context-inspector/SessionAgentsInspector.tsx b/apps/web/src/components/features/context-inspector/SessionAgentsInspector.tsx index b437279a..64414fbb 100644 --- a/apps/web/src/components/features/context-inspector/SessionAgentsInspector.tsx +++ b/apps/web/src/components/features/context-inspector/SessionAgentsInspector.tsx @@ -1,11 +1,9 @@ import { useMemo } from "react"; -import { useQueries } from "@tanstack/react-query"; import { useNavigate, useParams, useSearchParams } from "react-router-dom"; -import { sessionQueryOptions, useAgents } from "../../../api/queries"; -import type { SessionFamilyActivity, ToolChildSessionLink, ToolChildSessionLinkStatus } from "@archcode/protocol"; +import { useAgents } from "../../../api/queries"; +import type { SessionExecutionRecord, SessionFamilyActivity, ToolChildSessionLinkStatus } from "@archcode/protocol"; import { resolveAgentDisplayName } from "../../../lib/agent-constants"; import { useSessionFamilyActivity } from "../../../store/session-runtime-store"; -import { useSessionStore } from "../../../store/session-store"; import { InspectorNotice } from "./InspectorPrimitives"; import { buildAgentFocusSearch } from "./session-canvas-navigation"; import { childExecutionVisualKind, presentChildExecutionStatus } from "../../../lib/execution-status-presentation"; @@ -25,6 +23,7 @@ interface AgentStatusPresentation { export function resolveInspectorAgentStatus( rootActivity: SessionFamilyActivity | undefined, childStatus?: ToolChildSessionLinkStatus, + latestExecutionStatus?: SessionExecutionRecord["status"] | null, gate?: "Permission" | "Question", ): AgentStatusPresentation { if (gate !== undefined) return { label: gate, kind: "needs_you" }; @@ -33,27 +32,26 @@ export function resolveInspectorAgentStatus( const status = presentChildExecutionStatus(childStatus); return { label: status.label, kind: childExecutionVisualKind(childStatus), detail: status.detail }; } + if (latestExecutionStatus !== undefined && latestExecutionStatus !== null) { + if (latestExecutionStatus === "running") return { label: "Running", kind: "running" }; + if (latestExecutionStatus === "suspended") return { label: "Paused", kind: "pending" }; + if (latestExecutionStatus === "completed") return { label: "Completed", kind: "completed" }; + if (latestExecutionStatus === "failed") return { label: "Failed", kind: "failed" }; + if (latestExecutionStatus === "timed_out") return { label: "Failed", kind: "failed", detail: "Timed out" }; + if (latestExecutionStatus === "max_steps") return { label: "Failed", kind: "failed", detail: "Max steps" }; + return { + label: "Stopped", + kind: "stopped", + detail: latestExecutionStatus === "aborted" + ? "Aborted" + : latestExecutionStatus === "cancelled" ? "Cancelled" : "Interrupted", + }; + } const visual = sessionFamilyVisual(rootActivity); const label = sessionFamilyActivityLabel(rootActivity); return { label, ...visual }; } -export function buildInspectorChildStatusMap( - rootLinks: readonly ToolChildSessionLink[], - nestedParents: readonly { sessionId: string; childSessionLinks: readonly ToolChildSessionLink[] }[], -): Map { - const statusByChildSessionId = new Map(); - for (const link of rootLinks) statusByChildSessionId.set(link.childSessionId, link.status); - for (const parent of nestedParents) { - for (const link of parent.childSessionLinks) { - if (link.parentSessionId === parent.sessionId) { - statusByChildSessionId.set(link.childSessionId, link.status); - } - } - } - return statusByChildSessionId; -} - function agentRoleMark(displayName: string): string { return displayName.replace(/[^\p{L}\p{N}]/gu, "").slice(0, 2).toLocaleUpperCase() || "AG"; } @@ -72,28 +70,14 @@ export function SessionAgentsInspector({ projection }: { projection: SessionInsp const navigate = useNavigate(); const focused = searchParams.get("focus") ?? sessionId; const { data: agentDescriptors = [] } = useAgents(); - const rootActivity = useSessionFamilyActivity(slug, sessionId); - const childSessionLinks = useSessionStore(sessionId, (state) => state.childSessionLinks, slug); + const canonicalRootSessionId = projection.items[0]?.sessionId ?? ""; + const rootActivity = useSessionFamilyActivity(slug, canonicalRootSessionId); const pendingHitl = useAttentionVisibleScopedHitl([slug]); const gateByOwner = useMemo(() => new Map(pendingHitl.map((entry) => [ entry.ownerSessionId, entry.view.source.type === "ask_user" ? "Question" as const : "Permission" as const, ])), [pendingHitl]); const sessionAgents = projection.items; - const nestedParentSessionIds = useMemo( - () => sessionAgents - .filter((agent) => agent.sessionId !== sessionId && agent.hasChildren) - .map((agent) => agent.sessionId), - [sessionAgents, sessionId], - ); - const nestedParentQueries = useQueries({ - queries: nestedParentSessionIds.map((parentSessionId) => sessionQueryOptions(slug, parentSessionId)), - }); - const nestedParentSessions = nestedParentQueries.flatMap((query) => query.data === undefined ? [] : [query.data]); - const childStatusBySessionId = useMemo( - () => buildInspectorChildStatusMap(childSessionLinks, nestedParentSessions), - [childSessionLinks, nestedParentSessions], - ); if (projection.isLoading) return Loading agents…; if (projection.error) return Failed to load agents; @@ -104,9 +88,11 @@ export function SessionAgentsInspector({ projection }: { projection: SessionInsp