diff --git a/CLAUDE.md b/CLAUDE.md index b6ee0c80d..4aa90da55 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -121,7 +121,7 @@ curl -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8200/v1/me/profile **AI backend runtime split:** -- `agent_runtime/` — pure domain. `execution/` (graph, deep agent builder, runtime contracts), `capabilities/` (tools, skills, MCP loaders + middleware + permissions), `context/memory`, `delegation/subagents`, `persistence/` (records, schema, ports), `observability/`, `api/` (presentation/service layer for the runtime API). +- `agent_runtime/` — pure domain, 21 packages. Runtime core: `execution/` (graph, deep agent builder, runtime contracts), `capabilities/` (tools, skills, MCP loaders + middleware + permissions), `context/memory`, `delegation/subagents`, `persistence/` (records + ports), `observability/`, `api/` (presentation/service layer for the runtime API), `prompts/`, `control_plane/`, `hyperparameters/`, `deployment/`. Generative Surfaces: `surfaces_v2/` (the Work Ledger), `effects/` (pure staging domain), `artifacts/`, `presentation/`. Known-misplaced, target home `backend`: `harness_quality/`, `pricing/`, `budgets/`, `release/`, `retention/` — see [BOUNDARY-AUDIT.md](docs/audit/ai-backend-smells/BOUNDARY-AUDIT.md). - `runtime_api/` — FastAPI app exposing conversations, runs, event replay, SSE streaming, cancel, approvals. - `runtime_worker/` — separate process that claims queued runs, drives the LangGraph execution, and emits typed `RuntimeEventEnvelope` records (`model_delta`, `final_response`, `run_completed`, tool/subagent/stream events). The API can also start an in-process worker via `RUNTIME_START_IN_PROCESS_WORKER=true` for local dev. - `runtime_adapters/` — `in_memory` for tests/dev, `file` (JSONL session folders) for the desktop. Selected by `RUNTIME_STORE_BACKEND`, dispatched through `runtime_adapters/registry.py`; adding a backend is a provider module plus one registration, with no edit to any dispatch code. diff --git a/apps/frontend/src/features/agents/AgentsRoute.test.tsx b/apps/frontend/src/features/agents/AgentsRoute.test.tsx deleted file mode 100644 index c9dc93929..000000000 --- a/apps/frontend/src/features/agents/AgentsRoute.test.tsx +++ /dev/null @@ -1,821 +0,0 @@ -import { - act, - fireEvent, - render, - screen, - waitFor, -} from "@testing-library/react"; -import { - afterEach, - beforeEach, - describe, - expect, - it, - vi, - type Mock, -} from "vitest"; - -import type { - Agent, - AgentId, - AgentListResponse, - AgentStreamEnvelope, - AgentUsageResponse, - AgentVersion, - AgentVersionId, - AgentVersionListResponse, - TenantId, - UserId, -} from "../../api/_agents-stub"; - -// Mock the agentsApi module so the tests don't have to drive the real -// fetch / SSE plumbing — that surface is covered in `agentsApi.test.ts`. -const agentsApiMocks = vi.hoisted(() => ({ - fetchAgents: vi.fn(), - fetchAgent: vi.fn(), - fetchAgentVersions: vi.fn(), - fetchAgentUsage: vi.fn(), - installAgent: vi.fn(), - uninstallAgent: vi.fn(), - duplicateAgent: vi.fn(), - patchAgent: vi.fn(), - snapshotAgentVersion: vi.fn(), - streamAgentEvents: vi.fn(), -})); -vi.mock("../../api/agentsApi", async () => { - const actual = await vi.importActual( - "../../api/agentsApi", - ); - return { - ...actual, - fetchAgents: agentsApiMocks.fetchAgents, - fetchAgent: agentsApiMocks.fetchAgent, - fetchAgentVersions: agentsApiMocks.fetchAgentVersions, - fetchAgentUsage: agentsApiMocks.fetchAgentUsage, - installAgent: agentsApiMocks.installAgent, - uninstallAgent: agentsApiMocks.uninstallAgent, - duplicateAgent: agentsApiMocks.duplicateAgent, - patchAgent: agentsApiMocks.patchAgent, - snapshotAgentVersion: agentsApiMocks.snapshotAgentVersion, - streamAgentEvents: agentsApiMocks.streamAgentEvents, - }; -}); - -// Imports below this line resolve through the mocks above. -import { AgentsRoute, applyAgentEnvelope } from "./AgentsRoute"; - -const IDENTITY = { orgId: "org_test", userId: "user_test" }; - -function agentFixture(overrides: Partial = {}): Agent { - return { - id: "agent_1" as AgentId, - tenant_id: "tenant_1" as TenantId, - name: "Inbox Triage", - slug: "inbox-triage", - description: "Triage incoming approvals.", - icon_emoji: "📥", - color_hue: 220, - version: 1, - status: "available", - origin: "system", - owner_user_id: null, - instructions: "You are a triage assistant.", - model_default: { - model_id: "anthropic:claude-sonnet-4-7-1m", - reasoning_depth: "balanced", - }, - connectors_default: [], - skills: [], - permissions: { - autonomy: "manual_approval", - max_tool_calls_per_run: 10, - max_output_tokens: 4000, - read_only: false, - }, - memory_ref: null, - created_at: "2026-05-01T00:00:00Z", - updated_at: "2026-05-18T09:00:00Z", - viewer_install_status: "available", - viewer_usage_7d: null, - ...overrides, - }; -} - -function versionFixture(overrides: Partial = {}): AgentVersion { - return { - id: "agentver_1" as AgentVersionId, - agent_id: "agent_1" as AgentId, - version: 1, - instructions_snapshot: "You are a triage assistant.", - model_default_snapshot: { - model_id: "anthropic:claude-sonnet-4-7-1m", - reasoning_depth: "balanced", - }, - skills_snapshot: [], - connectors_default_snapshot: [], - permissions_snapshot: { - autonomy: "manual_approval", - max_tool_calls_per_run: 10, - max_output_tokens: 4000, - read_only: false, - }, - created_at: "2026-05-18T09:00:00Z", - created_by: "user_test" as UserId, - label: null, - ...overrides, - }; -} - -function listResponse(items: ReadonlyArray): AgentListResponse { - return { items, next_cursor: null }; -} - -function versionListResponse( - items: ReadonlyArray, -): AgentVersionListResponse { - return { items, next_cursor: null }; -} - -function usageResponse(): AgentUsageResponse { - return { - agent_id: "agent_1" as AgentId, - period: "week", - rollups: [], - totals: { - agent_id: "agent_1" as AgentId, - period: "week", - run_count: 5, - token_in: 120, - token_out: 240, - cost_usd_micro: 1234, - }, - }; -} - -function envelope( - type: AgentStreamEnvelope["event_type"], - payload: AgentStreamEnvelope["payload"], - agentId: AgentId, - sequenceNo = 1, -): AgentStreamEnvelope { - return { - sequence_no: sequenceNo, - event_type: type, - agent_id: agentId, - payload, - emitted_at: "2026-05-18T09:00:00Z", - }; -} - -function captureStreamCallbacks(closeMock = vi.fn()): { - readonly close: Mock; - readonly lastCall: () => { - onEvent: (e: AgentStreamEnvelope) => void; - onError: (e: Event) => void; - onOpen?: () => void; - }; -} { - let lastCallbacks: { - onEvent: (e: AgentStreamEnvelope) => void; - onError: (e: Event) => void; - onOpen?: () => void; - } = { onEvent: () => undefined, onError: () => undefined }; - agentsApiMocks.streamAgentEvents.mockImplementation( - ({ - onEvent, - onError, - onOpen, - }: { - onEvent: (e: AgentStreamEnvelope) => void; - onError: (e: Event) => void; - onOpen?: () => void; - }) => { - lastCallbacks = { onEvent, onError, onOpen }; - return { close: closeMock }; - }, - ); - return { - close: closeMock, - lastCall: () => lastCallbacks, - }; -} - -// Default the version-history + usage fetches to resolved-empty so they -// don't reject and surface errors in render tests that don't care about -// the detail pane's data path. -function defaultDetailFetches(): void { - agentsApiMocks.fetchAgentVersions.mockResolvedValue(versionListResponse([])); - agentsApiMocks.fetchAgentUsage.mockResolvedValue(usageResponse()); -} - -// =========================================================================== -// PURE REDUCER — applyAgentEnvelope -// =========================================================================== - -describe("applyAgentEnvelope", () => { - it("flips status in place on agent_status_changed", () => { - const a = agentFixture({ id: "a" as AgentId, status: "available" }); - const next = applyAgentEnvelope( - [a], - envelope( - "agent_status_changed", - { - agent_id: "a" as AgentId, - status: "disabled", - prior_status: "available", - }, - "a" as AgentId, - ), - ); - expect(next[0].status).toBe("disabled"); - }); - - it("is a no-op for agent_installed / agent_uninstalled / agent_updated at the list layer", () => { - const a = agentFixture({ id: "a" as AgentId }); - const before = [a]; - - const installed = applyAgentEnvelope( - before, - envelope( - "agent_installed", - { agent_id: "a" as AgentId, user_id: "user_test" as UserId }, - "a" as AgentId, - ), - ); - expect(installed).toBe(before); - - const uninstalled = applyAgentEnvelope( - before, - envelope( - "agent_uninstalled", - { agent_id: "a" as AgentId, user_id: "user_test" as UserId }, - "a" as AgentId, - ), - ); - expect(uninstalled).toBe(before); - - const updated = applyAgentEnvelope( - before, - envelope( - "agent_updated", - { agent_id: "a" as AgentId, version: 2 }, - "a" as AgentId, - ), - ); - expect(updated).toBe(before); - }); - - it("returns the same array on agent_status_changed for an unknown id", () => { - const a = agentFixture({ id: "a" as AgentId, status: "available" }); - const before = [a]; - const after = applyAgentEnvelope( - before, - envelope( - "agent_status_changed", - { - agent_id: "b" as AgentId, - status: "disabled", - prior_status: "available", - }, - "b" as AgentId, - ), - ); - expect(after).toBe(before); - }); -}); - -// =========================================================================== -// RENDER — happy + error paths -// =========================================================================== - -describe("AgentsRoute render", () => { - beforeEach(() => { - agentsApiMocks.fetchAgents.mockReset(); - agentsApiMocks.fetchAgent.mockReset(); - agentsApiMocks.fetchAgentVersions.mockReset(); - agentsApiMocks.fetchAgentUsage.mockReset(); - agentsApiMocks.installAgent.mockReset(); - agentsApiMocks.uninstallAgent.mockReset(); - agentsApiMocks.duplicateAgent.mockReset(); - agentsApiMocks.patchAgent.mockReset(); - agentsApiMocks.snapshotAgentVersion.mockReset(); - agentsApiMocks.streamAgentEvents.mockReset(); - agentsApiMocks.streamAgentEvents.mockReturnValue({ close: vi.fn() }); - defaultDetailFetches(); - }); - afterEach(() => { - vi.clearAllMocks(); - }); - - it("renders the loading state, then the ready list", async () => { - agentsApiMocks.fetchAgents.mockResolvedValueOnce( - listResponse([agentFixture({ name: "Inbox Triage" })]), - ); - - render(); - - expect(screen.getByTestId("agents-route")).toHaveAttribute( - "data-state", - "loading", - ); - - await waitFor(() => { - expect(screen.getByTestId("agents-route")).toHaveAttribute( - "data-state", - "ready", - ); - }); - expect(screen.getByText("Inbox Triage")).toBeInTheDocument(); - expect(screen.getByTestId("agents-route")).toHaveAttribute( - "data-item-count", - "1", - ); - }); - - it("renders the empty state when the server returns no items", async () => { - agentsApiMocks.fetchAgents.mockResolvedValueOnce(listResponse([])); - render(); - await waitFor(() => { - expect(screen.getByTestId("agents-route-empty")).toBeInTheDocument(); - }); - }); - - it("renders the error state on fetch failure and retries on click", async () => { - agentsApiMocks.fetchAgents.mockRejectedValueOnce(new Error("boom")); - agentsApiMocks.fetchAgents.mockResolvedValueOnce( - listResponse([agentFixture()]), - ); - - render(); - - await waitFor(() => { - expect(screen.getByTestId("agents-route-error")).toBeInTheDocument(); - }); - expect( - screen.getByTestId("agents-route-error-message").textContent, - ).toContain("boom"); - - fireEvent.click(screen.getByTestId("agents-route-retry")); - - await waitFor(() => { - expect(screen.getByTestId("agents-route")).toHaveAttribute( - "data-state", - "ready", - ); - }); - expect(agentsApiMocks.fetchAgents).toHaveBeenCalledTimes(2); - }); -}); - -// =========================================================================== -// SSE — deltas merge + refetch for install/uninstall/update events -// =========================================================================== - -describe("AgentsRoute SSE", () => { - beforeEach(() => { - agentsApiMocks.fetchAgents.mockReset(); - agentsApiMocks.fetchAgent.mockReset(); - agentsApiMocks.streamAgentEvents.mockReset(); - defaultDetailFetches(); - }); - afterEach(() => { - vi.clearAllMocks(); - }); - - it("subscribes after the initial load and flips status on agent_status_changed deltas", async () => { - agentsApiMocks.fetchAgents.mockResolvedValueOnce( - listResponse([agentFixture({ id: "a" as AgentId, status: "available" })]), - ); - const sse = captureStreamCallbacks(); - - render(); - - await waitFor(() => { - expect(agentsApiMocks.streamAgentEvents).toHaveBeenCalledTimes(1); - }); - - act(() => { - sse.lastCall().onEvent( - envelope( - "agent_status_changed", - { - agent_id: "a" as AgentId, - status: "disabled", - prior_status: "available", - }, - "a" as AgentId, - 1, - ), - ); - }); - - await waitFor(() => { - expect(screen.getByTestId("agents-route-row")).toHaveAttribute( - "data-agent-status", - "disabled", - ); - }); - }); - - it("refetches the affected row on agent_installed (merged-overrides view, sub-PRD §3.3)", async () => { - agentsApiMocks.fetchAgents.mockResolvedValueOnce( - listResponse([ - agentFixture({ - id: "a" as AgentId, - viewer_install_status: "available", - }), - ]), - ); - agentsApiMocks.fetchAgent.mockResolvedValueOnce( - agentFixture({ - id: "a" as AgentId, - viewer_install_status: "installed", - }), - ); - const sse = captureStreamCallbacks(); - - render(); - - await waitFor(() => { - expect(agentsApiMocks.streamAgentEvents).toHaveBeenCalledTimes(1); - }); - - act(() => { - sse.lastCall().onEvent( - envelope( - "agent_installed", - { - agent_id: "a" as AgentId, - user_id: "user_test" as UserId, - scope: "user", - }, - "a" as AgentId, - 5, - ), - ); - }); - - await waitFor(() => { - expect(agentsApiMocks.fetchAgent).toHaveBeenCalledWith(IDENTITY, "a"); - }); - }); - - it("closes the active stream when the stream errors out (reconnect is then scheduled)", async () => { - agentsApiMocks.fetchAgents.mockResolvedValueOnce( - listResponse([agentFixture()]), - ); - const sse = captureStreamCallbacks(); - - render(); - - await waitFor(() => { - expect(agentsApiMocks.streamAgentEvents).toHaveBeenCalledTimes(1); - }); - - // Trigger an error → component closes the active handle and queues - // an exponential-backoff reconnect via setTimeout. Same pattern as - // ProjectsRoute / RoutinesRoute — we assert on the observable close - // side-effect; the reconnect timing is covered structurally by the - // RECONNECT_BACKOFF_* constants. - act(() => { - sse.lastCall().onError(new Event("error")); - }); - expect(sse.close).toHaveBeenCalled(); - }); -}); - -// =========================================================================== -// MUTATIONS — install / uninstall / duplicate -// =========================================================================== - -describe("AgentsRoute mutations", () => { - beforeEach(() => { - agentsApiMocks.fetchAgents.mockReset(); - agentsApiMocks.fetchAgent.mockReset(); - agentsApiMocks.installAgent.mockReset(); - agentsApiMocks.uninstallAgent.mockReset(); - agentsApiMocks.duplicateAgent.mockReset(); - agentsApiMocks.streamAgentEvents.mockReset(); - agentsApiMocks.streamAgentEvents.mockReturnValue({ close: vi.fn() }); - defaultDetailFetches(); - }); - afterEach(() => { - vi.clearAllMocks(); - }); - - it("calls installAgent and merges the updated row", async () => { - const a = agentFixture({ - id: "a" as AgentId, - viewer_install_status: "available", - }); - agentsApiMocks.fetchAgents.mockResolvedValueOnce(listResponse([a])); - agentsApiMocks.installAgent.mockResolvedValueOnce( - agentFixture({ - id: "a" as AgentId, - viewer_install_status: "installed", - status: "installed", - }), - ); - - render(); - - await waitFor(() => { - expect(screen.getByTestId("agents-route-install")).toBeInTheDocument(); - }); - - fireEvent.click(screen.getByTestId("agents-route-install")); - - await waitFor(() => { - expect(agentsApiMocks.installAgent).toHaveBeenCalledWith(IDENTITY, "a"); - }); - await waitFor(() => { - expect(screen.getByTestId("agents-route-uninstall")).toBeInTheDocument(); - }); - }); - - it("calls uninstallAgent and merges the updated row", async () => { - const a = agentFixture({ - id: "a" as AgentId, - viewer_install_status: "installed", - status: "installed", - }); - agentsApiMocks.fetchAgents.mockResolvedValueOnce(listResponse([a])); - agentsApiMocks.uninstallAgent.mockResolvedValueOnce( - agentFixture({ - id: "a" as AgentId, - viewer_install_status: "available", - status: "available", - }), - ); - - render(); - - await waitFor(() => { - expect(screen.getByTestId("agents-route-uninstall")).toBeInTheDocument(); - }); - fireEvent.click(screen.getByTestId("agents-route-uninstall")); - - await waitFor(() => { - expect(agentsApiMocks.uninstallAgent).toHaveBeenCalledWith(IDENTITY, "a"); - }); - await waitFor(() => { - expect(screen.getByTestId("agents-route-install")).toBeInTheDocument(); - }); - }); - - it("calls duplicateAgent and prepends the forked row", async () => { - const a = agentFixture({ id: "a" as AgentId, origin: "system" }); - agentsApiMocks.fetchAgents.mockResolvedValueOnce(listResponse([a])); - agentsApiMocks.duplicateAgent.mockResolvedValueOnce( - agentFixture({ - id: "a_custom" as AgentId, - origin: "custom", - owner_user_id: "user_test" as UserId, - name: "Inbox Triage (custom)", - viewer_install_status: "draft", - status: "draft", - }), - ); - - render(); - - await waitFor(() => { - expect(screen.getByTestId("agents-route-duplicate")).toBeInTheDocument(); - }); - fireEvent.click(screen.getByTestId("agents-route-duplicate")); - - await waitFor(() => { - expect(agentsApiMocks.duplicateAgent).toHaveBeenCalledWith(IDENTITY, "a"); - }); - await waitFor(() => { - expect(screen.getByText("Inbox Triage (custom)")).toBeInTheDocument(); - }); - }); - - it("surfaces a pending-error banner when install fails and keeps rendering the list", async () => { - const a = agentFixture({ - id: "a" as AgentId, - viewer_install_status: "available", - }); - agentsApiMocks.fetchAgents.mockResolvedValueOnce(listResponse([a])); - agentsApiMocks.installAgent.mockRejectedValueOnce( - new Error("install_forbidden"), - ); - - render(); - - await waitFor(() => { - expect(screen.getByTestId("agents-route-install")).toBeInTheDocument(); - }); - fireEvent.click(screen.getByTestId("agents-route-install")); - - await waitFor(() => { - expect( - screen.getByTestId("agents-route-pending-error"), - ).toBeInTheDocument(); - }); - expect( - screen.getByTestId("agents-route-pending-error").textContent, - ).toContain("install_forbidden"); - // The list itself is still rendered — the user can retry. - expect(screen.getByTestId("agents-route-row")).toBeInTheDocument(); - }); -}); - -// =========================================================================== -// DETAIL PANEL — editor + version history + usage -// =========================================================================== - -describe("AgentsRoute detail panel", () => { - beforeEach(() => { - agentsApiMocks.fetchAgents.mockReset(); - agentsApiMocks.fetchAgent.mockReset(); - agentsApiMocks.fetchAgentVersions.mockReset(); - agentsApiMocks.fetchAgentUsage.mockReset(); - agentsApiMocks.patchAgent.mockReset(); - agentsApiMocks.snapshotAgentVersion.mockReset(); - agentsApiMocks.streamAgentEvents.mockReset(); - agentsApiMocks.streamAgentEvents.mockReturnValue({ close: vi.fn() }); - }); - afterEach(() => { - vi.clearAllMocks(); - }); - - it("opens the detail panel on row select and surfaces version history + usage", async () => { - const a = agentFixture({ id: "a" as AgentId }); - agentsApiMocks.fetchAgents.mockResolvedValueOnce(listResponse([a])); - agentsApiMocks.fetchAgentVersions.mockResolvedValueOnce( - versionListResponse([versionFixture({ version: 1, label: "Initial" })]), - ); - agentsApiMocks.fetchAgentUsage.mockResolvedValueOnce(usageResponse()); - - render(); - - await waitFor(() => { - expect(screen.getByTestId("agents-route-select")).toBeInTheDocument(); - }); - fireEvent.click(screen.getByTestId("agents-route-select")); - - await waitFor(() => { - expect(screen.getByTestId("agents-route-detail")).toBeInTheDocument(); - }); - - // Version history block fetches against the selected agent id. - await waitFor(() => { - expect(agentsApiMocks.fetchAgentVersions).toHaveBeenCalledWith( - IDENTITY, - "a", - { limit: 20 }, - ); - }); - await waitFor(() => { - expect(screen.getByTestId("agents-route-version-row")).toHaveAttribute( - "data-version", - "1", - ); - }); - - // Usage block fetches and renders the totals. - await waitFor(() => { - expect(agentsApiMocks.fetchAgentUsage).toHaveBeenCalledWith( - IDENTITY, - "a", - { period: "week" }, - ); - }); - await waitFor(() => { - expect(screen.getByTestId("agents-route-usage-totals")).toHaveAttribute( - "data-run-count", - "5", - ); - }); - }); - - it("saves the edited instructions through patchAgent and merges the updated row", async () => { - const a = agentFixture({ - id: "a" as AgentId, - instructions: "old", - origin: "custom", - owner_user_id: "user_test" as UserId, - }); - agentsApiMocks.fetchAgents.mockResolvedValueOnce(listResponse([a])); - agentsApiMocks.fetchAgentVersions.mockResolvedValueOnce( - versionListResponse([]), - ); - agentsApiMocks.fetchAgentUsage.mockResolvedValueOnce(usageResponse()); - agentsApiMocks.patchAgent.mockResolvedValueOnce( - agentFixture({ - id: "a" as AgentId, - instructions: "new", - origin: "custom", - owner_user_id: "user_test" as UserId, - }), - ); - - render(); - - await waitFor(() => { - expect(screen.getByTestId("agents-route-select")).toBeInTheDocument(); - }); - fireEvent.click(screen.getByTestId("agents-route-select")); - - await waitFor(() => { - expect( - screen.getByTestId("agents-route-instructions"), - ).toBeInTheDocument(); - }); - const ta = screen.getByTestId("agents-route-instructions"); - fireEvent.change(ta, { target: { value: "new" } }); - fireEvent.click(screen.getByTestId("agents-route-save")); - - await waitFor(() => { - expect(agentsApiMocks.patchAgent).toHaveBeenCalledWith(IDENTITY, "a", { - instructions: "new", - }); - }); - }); - - it("surfaces a 409 agent_origin_immutable error when patching a system/community agent", async () => { - const a = agentFixture({ id: "a" as AgentId, origin: "system" }); - agentsApiMocks.fetchAgents.mockResolvedValueOnce(listResponse([a])); - agentsApiMocks.fetchAgentVersions.mockResolvedValueOnce( - versionListResponse([]), - ); - agentsApiMocks.fetchAgentUsage.mockResolvedValueOnce(usageResponse()); - agentsApiMocks.patchAgent.mockRejectedValueOnce( - new Error("agent_origin_immutable"), - ); - - render(); - - await waitFor(() => { - expect(screen.getByTestId("agents-route-select")).toBeInTheDocument(); - }); - fireEvent.click(screen.getByTestId("agents-route-select")); - - await waitFor(() => { - expect(screen.getByTestId("agents-route-save")).toBeInTheDocument(); - }); - fireEvent.click(screen.getByTestId("agents-route-save")); - - await waitFor(() => { - expect( - screen.getByTestId("agents-route-pending-error"), - ).toBeInTheDocument(); - }); - expect( - screen.getByTestId("agents-route-pending-error").textContent, - ).toContain("agent_origin_immutable"); - }); - - it("snapshots a new version via snapshotAgentVersion + refetches the agent", async () => { - const a = agentFixture({ - id: "a" as AgentId, - version: 2, - origin: "custom", - owner_user_id: "user_test" as UserId, - }); - agentsApiMocks.fetchAgents.mockResolvedValueOnce(listResponse([a])); - agentsApiMocks.fetchAgentVersions.mockResolvedValueOnce( - versionListResponse([]), - ); - agentsApiMocks.fetchAgentUsage.mockResolvedValueOnce(usageResponse()); - agentsApiMocks.snapshotAgentVersion.mockResolvedValueOnce( - versionFixture({ version: 3, label: "v3-label" }), - ); - agentsApiMocks.fetchAgent.mockResolvedValueOnce( - agentFixture({ - id: "a" as AgentId, - version: 3, - origin: "custom", - owner_user_id: "user_test" as UserId, - }), - ); - - render(); - - await waitFor(() => { - expect(screen.getByTestId("agents-route-select")).toBeInTheDocument(); - }); - fireEvent.click(screen.getByTestId("agents-route-select")); - - await waitFor(() => { - expect(screen.getByTestId("agents-route-snapshot")).toBeInTheDocument(); - }); - fireEvent.change(screen.getByTestId("agents-route-version-label"), { - target: { value: "v3-label" }, - }); - fireEvent.click(screen.getByTestId("agents-route-snapshot")); - - await waitFor(() => { - expect(agentsApiMocks.snapshotAgentVersion).toHaveBeenCalledWith( - IDENTITY, - "a", - { label: "v3-label" }, - ); - }); - await waitFor(() => { - expect(agentsApiMocks.fetchAgent).toHaveBeenCalledWith(IDENTITY, "a"); - }); - }); -}); diff --git a/apps/frontend/src/features/agents/AgentsRoute.tsx b/apps/frontend/src/features/agents/AgentsRoute.tsx deleted file mode 100644 index a2818a77e..000000000 --- a/apps/frontend/src/features/agents/AgentsRoute.tsx +++ /dev/null @@ -1,860 +0,0 @@ -// AgentsRoute — data binder for the Phase 8 Agents destination -// (the 14th destination per -// `docs/atlas-new-design/destinations/agents-prd.md`). -// -// Mirrors the P6-C ProjectsRoute / P5-C RoutinesRoute pattern: -// 1. Fetches `GET /v1/agents` via `agentsApi` and owns -// loading / error / ready states (sub-PRD §4.1 list view). -// 2. Opens the `/v1/agents/stream` SSE channel (sub-PRD §4.12) -// with exponential-backoff reconnect, tracking the highest -// `sequence_no` for `?after_sequence=N` resume (cross-audit §5.2). -// 3. Merges `agent_installed` / `agent_uninstalled` / -// `agent_updated` / `agent_status_changed` envelopes into the -// local list — installs and status flips refetch the affected -// row so the merged-overrides view stays correct (sub-PRD §3.3). -// 4. Proxies install / uninstall mutations back to the backend, -// optimistically driving the SSE-merged local list while the -// server confirms. -// 5. Renders a host-side scaffolding today with a detail panel -// that surfaces the editor + version history + per-agent usage. -// The package-shipped `` exists as a Wave-0 -// placeholder; this route is the feature-binder that adds the -// real fetch / mutate / SSE behaviour the destination -// component does not own. -// -// Why a feature-level wrapper, not props on `` -// today: the package component is intentionally a dignified -// placeholder (cross-audit + impl-plan §1.6). Owning the data flow -// + state mutation + SSE here lets the destination component reshape -// without forcing an App.tsx-level rewrite — same compromise the -// InboxRoute / TodosRoute / RoutinesRoute / ProjectsRoute waves made. - -import { - useCallback, - useEffect, - useRef, - useState, - type ReactElement, -} from "react"; - -import type { RequestIdentity } from "../../api/config"; -import { - duplicateAgent, - fetchAgent, - fetchAgents, - fetchAgentUsage, - fetchAgentVersions, - installAgent, - patchAgent, - snapshotAgentVersion, - streamAgentEvents, - uninstallAgent, -} from "../../api/agentsApi"; -import type { - Agent, - AgentId, - AgentListResponse, - AgentStreamEnvelope, - AgentUsageResponse, - AgentVersion, - AgentVersionListResponse, -} from "../../api/_agents-stub"; -import { errorMessage } from "../../utils/errors"; - -/** Reconnect backoff bounds (mirrors ProjectsRoute / RoutinesRoute). */ -const RECONNECT_BACKOFF_MIN_MS = 1_000; -const RECONNECT_BACKOFF_MAX_MS = 30_000; - -interface AgentsRouteProps { - readonly identity: RequestIdentity; -} - -type ViewState = - | { readonly kind: "loading" } - | { readonly kind: "error"; readonly message: string } - | { - readonly kind: "ready"; - readonly items: ReadonlyArray; - readonly highestSequenceNo: number; - }; - -/** - * Apply one durable SSE envelope to the local agents list. Pure - * function so a test can drive it without a mounted component. - * - * Semantics (sub-PRD §4.12 event types): - * - `agent_installed` → no-op at list layer; caller refetches the affected row. - * - `agent_uninstalled` → no-op at list layer; caller refetches the affected row. - * - `agent_updated` → no-op at list layer; caller refetches the affected row. - * - `agent_version_snapshot` → no-op (version row, not agent row). - * - `agent_status_changed` → in-place status flip when row is present. - * - * For every event type we leave list mutation that affects the - * merged-overrides view to a refetch effect in the component — the - * status flip is the only one that can be reflected purely from the - * envelope payload itself. - */ -export function applyAgentEnvelope( - items: ReadonlyArray, - envelope: AgentStreamEnvelope, -): ReadonlyArray { - if (envelope.event_type !== "agent_status_changed") { - return items; - } - const idx = items.findIndex((a) => a.id === envelope.agent_id); - if (idx === -1) { - return items; - } - const payload = envelope.payload as { - readonly status?: Agent["status"]; - }; - if (payload.status === undefined) { - return items; - } - const next = items.slice(); - next[idx] = { ...next[idx], status: payload.status }; - return next; -} - -export function AgentsRoute({ identity }: AgentsRouteProps): ReactElement { - const [state, setState] = useState({ kind: "loading" }); - const [reloadToken, setReloadToken] = useState(0); - const [pendingError, setPendingError] = useState(null); - const [selectedId, setSelectedId] = useState(null); - - // ---- Initial fetch ------------------------------------------------ - useEffect(() => { - let cancelled = false; - setState({ kind: "loading" }); - - fetchAgents(identity, { limit: 50 }) - .then((list: AgentListResponse) => { - if (cancelled) return; - setState({ - kind: "ready", - items: list.items, - highestSequenceNo: 0, - }); - }) - .catch((error: unknown) => { - if (cancelled) return; - setState({ - kind: "error", - message: errorMessage(error, "Could not load agents."), - }); - }); - - return () => { - cancelled = true; - }; - }, [identity, reloadToken]); - - // ---- SSE subscription with exponential-backoff reconnect --------- - const backoffRef = useRef(RECONNECT_BACKOFF_MIN_MS); - useEffect(() => { - if (state.kind !== "ready") { - return; - } - let cancelled = false; - let activeHandle: { close(): void } | null = null; - let reconnectTimer: ReturnType | null = null; - backoffRef.current = RECONNECT_BACKOFF_MIN_MS; - - function open(): void { - if (cancelled) return; - let afterSequence = 0; - setState((prev) => { - if (prev.kind === "ready") afterSequence = prev.highestSequenceNo; - return prev; - }); - - activeHandle = streamAgentEvents({ - identity, - afterSequence: afterSequence > 0 ? afterSequence : undefined, - onOpen: () => { - backoffRef.current = RECONNECT_BACKOFF_MIN_MS; - }, - onEvent: (envelope) => { - if (cancelled) return; - setState((prev) => { - if (prev.kind !== "ready") return prev; - const items = applyAgentEnvelope(prev.items, envelope); - const highestSequenceNo = Math.max( - prev.highestSequenceNo, - envelope.sequence_no, - ); - return { kind: "ready", items, highestSequenceNo }; - }); - - // Refetch-driven merge for events that touch the merged- - // overrides view (sub-PRD §3.3). The reducer can't synthesize - // these because the install state + 7d usage projection are - // server-computed. - if ( - envelope.event_type === "agent_installed" || - envelope.event_type === "agent_uninstalled" || - envelope.event_type === "agent_updated" - ) { - void fetchAgent(identity, envelope.agent_id) - .then((agent) => { - if (cancelled) return; - setState((prev) => { - if (prev.kind !== "ready") return prev; - const idx = prev.items.findIndex((a) => a.id === agent.id); - if (idx === -1) { - return { ...prev, items: [agent, ...prev.items] }; - } - const next = prev.items.slice(); - next[idx] = agent; - return { ...prev, items: next }; - }); - }) - .catch(() => undefined); - } - }, - onError: () => { - if (cancelled) return; - activeHandle?.close(); - activeHandle = null; - const delay = backoffRef.current; - backoffRef.current = Math.min( - backoffRef.current * 2, - RECONNECT_BACKOFF_MAX_MS, - ); - reconnectTimer = setTimeout(open, delay); - }, - }); - } - - open(); - - return () => { - cancelled = true; - if (reconnectTimer !== null) { - clearTimeout(reconnectTimer); - } - activeHandle?.close(); - }; - }, [identity, state.kind]); - - // ---- Mutation helpers (install / uninstall / duplicate / patch) -- - - const mergeUpdated = useCallback((updated: Agent): void => { - setState((prev) => { - if (prev.kind !== "ready") return prev; - const idx = prev.items.findIndex((a) => a.id === updated.id); - if (idx === -1) { - return { ...prev, items: [updated, ...prev.items] }; - } - const next = prev.items.slice(); - next[idx] = updated; - return { ...prev, items: next }; - }); - }, []); - - const handleInstall = useCallback( - async (id: AgentId): Promise => { - setPendingError(null); - try { - const updated = await installAgent(identity, id); - mergeUpdated(updated); - } catch (error: unknown) { - setPendingError(errorMessage(error, "Could not install agent.")); - } - }, - [identity, mergeUpdated], - ); - - const handleUninstall = useCallback( - async (id: AgentId): Promise => { - setPendingError(null); - try { - const updated = await uninstallAgent(identity, id); - mergeUpdated(updated); - } catch (error: unknown) { - setPendingError(errorMessage(error, "Could not uninstall agent.")); - } - }, - [identity, mergeUpdated], - ); - - const handleDuplicate = useCallback( - async (id: AgentId): Promise => { - setPendingError(null); - try { - const forked = await duplicateAgent(identity, id); - mergeUpdated(forked); - } catch (error: unknown) { - setPendingError(errorMessage(error, "Could not duplicate agent.")); - } - }, - [identity, mergeUpdated], - ); - - // ---- Render ------------------------------------------------------- - if (state.kind === "error") { - return ( -
-
-
- Could not load agents -
-
- {state.message} -
- -
-
- ); - } - - const items = state.kind === "ready" ? state.items : []; - const selected = - selectedId !== null - ? (items.find((a) => a.id === selectedId) ?? null) - : null; - - return ( -
- {/* List pane */} -
- {pendingError !== null && ( -
- {pendingError} -
- )} - {state.kind === "loading" ? ( -
- Loading agents… -
- ) : items.length === 0 ? ( -
- No agents yet. -
- ) : ( -
    - {items.map((agent) => ( -
  • - - {agent.viewer_install_status === "installed" ? ( - - ) : ( - - )} - -
  • - ))} -
- )} -
- - {/* Detail pane (editor + version history + usage) */} - {selected !== null && ( - setSelectedId(null)} - onSaved={mergeUpdated} - onError={(msg) => setPendingError(msg)} - /> - )} -
- ); -} - -// =========================================================================== -// Detail panel — editor + version history + per-agent usage -// =========================================================================== - -interface AgentDetailPanelProps { - readonly identity: RequestIdentity; - readonly agent: Agent; - readonly onClose: () => void; - readonly onSaved: (agent: Agent) => void; - readonly onError: (message: string) => void; -} - -function AgentDetailPanel({ - identity, - agent, - onClose, - onSaved, - onError, -}: AgentDetailPanelProps): ReactElement { - // Editor — keyed by agent id so switching the selection resets the form. - const [draftInstructions, setDraftInstructions] = useState( - agent.instructions, - ); - const [versionLabel, setVersionLabel] = useState(""); - const editorKey = agent.id; - // Sync the draft instructions when the upstream agent record changes - // (e.g. an SSE-driven refetch lands while the detail pane is open). - useEffect(() => { - setDraftInstructions(agent.instructions); - }, [editorKey, agent.instructions]); - - const handleSave = useCallback(async (): Promise => { - try { - const updated = await patchAgent(identity, agent.id, { - instructions: draftInstructions, - }); - onSaved(updated); - } catch (error: unknown) { - onError(errorMessage(error, "Could not save agent.")); - } - }, [identity, agent.id, draftInstructions, onSaved, onError]); - - const handleSnapshot = useCallback(async (): Promise => { - try { - await snapshotAgentVersion(identity, agent.id, { - label: versionLabel.length > 0 ? versionLabel : undefined, - }); - setVersionLabel(""); - // Refresh the agent record so the bumped version reflects in the - // header — the explicit fetch sidesteps waiting for the SSE delta. - const refreshed = await fetchAgent(identity, agent.id); - onSaved(refreshed); - } catch (error: unknown) { - onError(errorMessage(error, "Could not snapshot agent version.")); - } - }, [identity, agent.id, versionLabel, onSaved, onError]); - - return ( -
-
-
-
- - {agent.name} -
-
- {agent.origin} · v{agent.version} · {agent.status} -
-
- -
- - {/* Editor */} -
- -