diff --git a/doc/plugins/PLUGIN_SPEC.md b/doc/plugins/PLUGIN_SPEC.md index b1616f8ded8..fc96a2351e5 100644 --- a/doc/plugins/PLUGIN_SPEC.md +++ b/doc/plugins/PLUGIN_SPEC.md @@ -412,6 +412,8 @@ export interface PaperclipPluginManifestV1 { /** Optional agent detail tab offered beside a task for manual selection. * Declared board GET route scoped to agent :entityId controls readiness for automatic opening. */ + /** Agent-only detail tab: require this plugin environment driver key. */ + agentEnvironmentDriver?: string; taskSidePanel?: { availabilityPath: string; /** Match runtime.activity.recorded for automatic opening; manual views need no mapping. */ @@ -424,6 +426,14 @@ export interface PaperclipPluginManifestV1 { } ``` +Agent detail tabs can declare `agentEnvironmentDriver` to restrict settings navigation, +direct plugin views, and manual task side panels to agents that use that driver. +The host matches the effective agent environment's plugin key and driver key. +An agent with no explicit environment inherits the instance default. Missing or +unavailable configuration hides the restricted view. A transient connection +failure preserves the last known configuration. Eligibility does not depend +on runtime readiness, agent lifecycle state, or automatic panel activation. + Rules: - `id` must be globally unique diff --git a/docs/api/agents.md b/docs/api/agents.md index e50ac479519..3fc63221c33 100644 --- a/docs/api/agents.md +++ b/docs/api/agents.md @@ -23,6 +23,17 @@ GET /api/agents/{agentId} Returns agent details including chain of command. +## Read execution environment identity + +Board users with organization access can list environments with +`GET /api/companies/{companyId}/environments` or read one with +`GET /api/environments/{environmentId}`. These routes read instance environments. +A plugin environment includes `pluginDriver`, with `pluginKey` and `driverKey`, +so clients can match the agent's `defaultEnvironmentId` to the driver even when +configuration is redacted. This field does not contain driver configuration, +environment variables, or credentials. An agent with no default inherits the +instance default. + ## Get Current Agent ``` diff --git a/packages/shared/src/types/environment.ts b/packages/shared/src/types/environment.ts index f28a839dd38..a380b9ce9d2 100644 --- a/packages/shared/src/types/environment.ts +++ b/packages/shared/src/types/environment.ts @@ -87,6 +87,8 @@ export interface Environment { driver: EnvironmentDriver; status: EnvironmentStatus; config: Record; + /** Driver identity from the read API, including when config is redacted. */ + pluginDriver?: Pick; envVars: AgentEnvConfig; metadata: Record | null; createdAt: Date; diff --git a/packages/shared/src/types/plugin.ts b/packages/shared/src/types/plugin.ts index 07c409d7dac..31fe2fb3346 100644 --- a/packages/shared/src/types/plugin.ts +++ b/packages/shared/src/types/plugin.ts @@ -500,6 +500,8 @@ export interface PluginUiSlotDeclaration { * Required for `detailTab`, `taskDetailView`, and `contextMenuItem`. */ entityTypes?: PluginUiSlotEntityType[]; + /** Only show this agent detail tab when the agent uses this plugin environment driver key. */ + agentEnvironmentDriver?: string; /** Also offer this agent detail tab for manual selection beside a task. * It uses the running agent, or the assigned agent while idle. * Matching run activity and readiness also open the panel automatically. diff --git a/packages/shared/src/validators/plugin.test.ts b/packages/shared/src/validators/plugin.test.ts index e145e15cb98..23bc443850d 100644 --- a/packages/shared/src/validators/plugin.test.ts +++ b/packages/shared/src/validators/plugin.test.ts @@ -541,3 +541,14 @@ describe("plugin task side-panel declarations", () => { expect(pluginManifestV1Schema.safeParse({ ...manifest, apiRoutes }).success).toBe(false); }); }); + + +describe("agent environment slot requirements", () => { + const slot = { type: "detailTab", id: "view", displayName: "View", exportName: "View", entityTypes: ["agent"], agentEnvironmentDriver: "tasks" }; + it("preserves the optional driver requirement", () => { + expect(pluginUiSlotDeclarationSchema.parse(slot).agentEnvironmentDriver).toBe("tasks"); + }); + it.each([{ agentEnvironmentDriver: "" }, { entityTypes: ["issue"] }, { entityTypes: ["agent", "issue"] }, { type: "page" }])("rejects unsupported requirements %j", patch => { + expect(pluginUiSlotDeclarationSchema.safeParse({ ...slot, ...patch }).success).toBe(false); + }); +}); diff --git a/packages/shared/src/validators/plugin.ts b/packages/shared/src/validators/plugin.ts index 318fac91061..054de474635 100644 --- a/packages/shared/src/validators/plugin.ts +++ b/packages/shared/src/validators/plugin.ts @@ -395,6 +395,7 @@ export const pluginUiSlotDeclarationSchema = z.object({ displayName: z.string().min(1), exportName: z.string().min(1), entityTypes: z.array(z.enum(PLUGIN_UI_SLOT_ENTITY_TYPES)).optional(), + agentEnvironmentDriver: z.string().min(1).optional(), taskSidePanel: z.object({ availabilityPath: z.string().regex(/^\/(?:[a-zA-Z0-9_-]+\/)*:entityId(?:\/[a-zA-Z0-9_-]+)*$/), activation: z.object({ namespace: runtimeActivityIdentifierSchema, activityKey: runtimeActivityIdentifierSchema }).strict().optional(), @@ -404,6 +405,9 @@ export const pluginUiSlotDeclarationSchema = z.object({ }).optional(), order: z.number().int().optional(), }).superRefine((value, ctx) => { + if (value.agentEnvironmentDriver && (value.type !== "detailTab" || value.entityTypes?.length !== 1 || value.entityTypes[0] !== "agent")) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "agentEnvironmentDriver requires an agent-only detailTab", path: ["agentEnvironmentDriver"] }); + } if (value.taskSidePanel && (value.type !== "detailTab" || !value.entityTypes?.includes("agent"))) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: "taskSidePanel requires an agent detailTab", path: ["taskSidePanel"] }); } diff --git a/server/src/__tests__/environment-routes.test.ts b/server/src/__tests__/environment-routes.test.ts index 67db6a706d5..14842a5661c 100644 --- a/server/src/__tests__/environment-routes.test.ts +++ b/server/src/__tests__/environment-routes.test.ts @@ -509,6 +509,29 @@ describe("environment routes", () => { }); }); + it.each(["list", "detail"])("exposes only plugin driver identity in the restricted environment %s", async view => { + const environment = { + ...createEnvironment(), + driver: "plugin", + config: { pluginKey: "example.runtime", driverKey: "tasks", driverConfig: { token: "fixture" } }, + envVars: { API_KEY: "fixture" }, + }; + mockEnvironmentService.list.mockResolvedValue([environment]); + mockEnvironmentService.getById.mockResolvedValue(environment); + const app = createApp({ + type: "board", userId: "user-2", source: "session", companyIds: ["company-1"], + memberships: [{ companyId: "company-1", status: "active", membershipRole: "member" }], + isInstanceAdmin: false, + }); + const response = await request(app).get(view === "list" ? "/api/companies/company-1/environments" : "/api/environments/env-1"); + expect(response.status).toBe(200); + expect(view === "list" ? response.body[0] : response.body).toMatchObject({ + id: "env-1", pluginDriver: { pluginKey: "example.runtime", driverKey: "tasks" }, + config: {}, envVars: {}, metadata: null, + }); + expect(JSON.stringify(response.body)).not.toContain("fixture"); + }); + describe("platform-provisioned environment floor on cloud-managed instances", () => { function createPlatformSandboxEnvironment() { const now = new Date("2026-04-16T05:00:00.000Z"); diff --git a/server/src/routes/environments.ts b/server/src/routes/environments.ts index 118bc6569d0..ffdad6429df 100644 --- a/server/src/routes/environments.ts +++ b/server/src/routes/environments.ts @@ -427,10 +427,17 @@ export function environmentRoutes( } function presentEnvironmentForRead | null; envVars?: Record | null; metadata: Record | null; }>(req: Request, environment: T): T { + if (environment.driver === "plugin") { + const { pluginKey, driverKey } = environment.config ?? {}; + if (typeof pluginKey === "string" && typeof driverKey === "string") { + environment = { ...environment, pluginDriver: { pluginKey, driverKey } }; + } + } if (environment.metadata) { const { [SANDBOX_PLATFORM_PROBE_METADATA_KEY]: _probe, ...metadata } = environment.metadata; environment = { ...environment, metadata }; diff --git a/tests/runner-e2e/source-sdk-public-inputs.json b/tests/runner-e2e/source-sdk-public-inputs.json index b65e6eefb1c..b5fa7d9b65c 100644 --- a/tests/runner-e2e/source-sdk-public-inputs.json +++ b/tests/runner-e2e/source-sdk-public-inputs.json @@ -386,7 +386,7 @@ "packages/shared/src/types/decision.ts": "b0d40077d481ce6180de6b02bad6df5bb0fbf977", "packages/shared/src/types/document-annotation.ts": "7769a42f1ccd707a2259774e7fe6aa536b5d183f", "packages/shared/src/types/email.ts": "556ff6a0dcefa01eb704d6d00d3b191de81ff4dc", - "packages/shared/src/types/environment.ts": "f28a839dd385bef5549a96b4c575e67371043f2d", + "packages/shared/src/types/environment.ts": "a380b9ce9d2d1763011e9adaa58615a3ef1e56c5", "packages/shared/src/types/execution-continuation.ts": "545998e33ab18d38268558d1a367f4aa12c2d5d3", "packages/shared/src/types/execution-projection.ts": "e72e641602b296f6d3e16d07700629df3039a7b1", "packages/shared/src/types/external-object.ts": "7a225e0f5a6777d449019a30530c858b753eb4c1", @@ -405,7 +405,7 @@ "packages/shared/src/types/issue.ts": "4598caecc50e60450a1204de832a4fcd7991096d", "packages/shared/src/types/live.ts": "b90e55e4de8600c6fc17d7fa8d89079d4ae655ec", "packages/shared/src/types/native-finalization.ts": "c026a73003ae232fea4f7624d561493b383df358", - "packages/shared/src/types/plugin.ts": "07c409d7daca3fc9ad550538d4a7ecac03176a07", + "packages/shared/src/types/plugin.ts": "31fe2fb3346dabbf83aa7e37193bb0dd229ea8a7", "packages/shared/src/types/project.ts": "2a20b4d8772d5f0fa5015828818b025abc7cedcf", "packages/shared/src/types/quota.ts": "a0686a9afb3111a4de093f66200dc0c9152e883e", "packages/shared/src/types/resource-memberships.ts": "9eab6fad50facf3e489f8c40e5bedd92e1d1faf8", @@ -485,8 +485,8 @@ "packages/shared/src/validators/native-finalization.ts": "d4e674de6ac7d36385e6f6c2172fa16ffc9b1830", "packages/shared/src/validators/onboarding-seed.ts": "de302a8400d4199053f379d9ed853522a3ac6d32", "packages/shared/src/validators/partial.ts": "fa60ec7481fc14175365e9e500c814b159f1ab53", - "packages/shared/src/validators/plugin.test.ts": "e145e15cb98ffe35880b9f8eea88809910ed5886", - "packages/shared/src/validators/plugin.ts": "318fac91061e0373f9acd7e9b29cb427dfdc4b7f", + "packages/shared/src/validators/plugin.test.ts": "23bc443850d46d815877a71a44625fd42aebb00a", + "packages/shared/src/validators/plugin.ts": "054de474635e080073e85413211cca4b3eb8681b", "packages/shared/src/validators/project.ts": "dc051327b2eaf4a28288a84e21c1f3baab906bc9", "packages/shared/src/validators/provider-trace.ts": "b38b6200a2e51513f5dca0f8b008f2fe68175e82", "packages/shared/src/validators/resource-memberships.ts": "b124ac8a8f24b4dbb03ae82bcc87541f6b4ecdfd", diff --git a/ui/src/components/AgentContextualSidebar.test.tsx b/ui/src/components/AgentContextualSidebar.test.tsx index 56f5de9d5b6..cf1afb9eefe 100644 --- a/ui/src/components/AgentContextualSidebar.test.tsx +++ b/ui/src/components/AgentContextualSidebar.test.tsx @@ -33,7 +33,7 @@ vi.mock("./SidebarNavItem", () => ({ SidebarNavItem: ({ to, label }: { to: string; label: string }) => {label}, })); -vi.mock("@/plugins/slots", () => ({ usePluginSlots: () => ({ slots: [{ pluginKey: "test.plugin", id: "insights", displayName: "Insights" }] }) })); +vi.mock("@/plugins/slots", () => ({ usePluginSlots: () => ({ slots: [{ pluginKey: "test.plugin", id: "insights", displayName: "Insights" }, { pluginKey: "example.computer", id: "desktop", displayName: "Desktop", agentEnvironmentDriver: "tasks" }] }) })); describe("AgentContextualSidebar", () => { it.each([false, true])("lists plugin tabs and keeps Channels and Tools out of the nav (%s)", (enabled) => { @@ -75,3 +75,14 @@ describe("AgentContextualSidebar", () => { expect(markup).toContain("Permissions / Trust"); }); }); + +it.each(["computer", "local"])("offers environment-specific navigation only for the configured %s agent", environmentId => { + const client = new QueryClient(); + client.setQueryData([...queryKeys.agents.detail("agent-1"), "company-1"], { id: "agent-1", companyId: "company-1", defaultEnvironmentId: environmentId }); + client.setQueryData(queryKeys.environments.list("company-1"), [{ id: "computer", driver: "plugin", config: {}, pluginDriver: { pluginKey: "example.computer", driverKey: "tasks" } }]); + const markup = renderToStaticMarkup( + + ); + expect(markup.includes("Desktop")).toBe(environmentId === "computer"); + expect(markup).toContain("Insights"); client.clear(); +}); diff --git a/ui/src/components/AgentContextualSidebar.tsx b/ui/src/components/AgentContextualSidebar.tsx index d8b6132d731..c1f96978061 100644 --- a/ui/src/components/AgentContextualSidebar.tsx +++ b/ui/src/components/AgentContextualSidebar.tsx @@ -15,6 +15,7 @@ import { Sparkles, Wrench, } from "lucide-react"; +import { useAgentEnvironmentSlots } from "@/plugins/useAgentEnvironmentSlots"; import { usePluginSlots } from "@/plugins/slots"; import { agentsApi } from "@/api/agents"; import { useCompany } from "@/context/CompanyContext"; @@ -65,7 +66,8 @@ export function AgentContextualSidebar({ enabled: shouldResolveAgent && Boolean(agentRef && selectedCompanyId), }); const resolvedId = agentId ?? resolvedAgent?.id; - const { slots: pluginTabs } = usePluginSlots({ slotTypes: ["detailTab"], entityType: "agent", companyId: resolvedAgent?.companyId ?? selectedCompanyId, enabled: Boolean(resolvedId) }); + const { slots: declaredTabs } = usePluginSlots({ slotTypes: ["detailTab"], entityType: "agent", companyId: resolvedAgent?.companyId ?? selectedCompanyId, enabled: Boolean(resolvedId) }); + const { slots: pluginTabs } = useAgentEnvironmentSlots(declaredTabs, resolvedId, resolvedAgent?.companyId ?? selectedCompanyId); const resolvedName = agentName ?? resolvedAgent?.name ?? "Agent"; return ( diff --git a/ui/src/hooks/useTaskPluginPanels.test.tsx b/ui/src/hooks/useTaskPluginPanels.test.tsx index bec7ca5b66c..2b15e91da68 100644 --- a/ui/src/hooks/useTaskPluginPanels.test.tsx +++ b/ui/src/hooks/useTaskPluginPanels.test.tsx @@ -4,6 +4,8 @@ import { createRoot, type Root } from "react-dom/client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { Issue, LiveEvent } from "@paperclipai/shared"; import { afterEach, beforeEach, expect, it, vi, type MockInstance } from "vitest"; +import { agentsApi } from "@/api/agents"; +import { environmentsApi } from "@/api/environments"; import { api, ApiError } from "@/api/client"; import { heartbeatsApi, type LiveRunForIssue } from "@/api/heartbeats"; import { useTaskRunLiveUpdates } from "@/context/LiveUpdatesProvider"; @@ -472,3 +474,22 @@ it.each(["route", "installation"])("rechecks readiness after a same-version %s c get.mockResolvedValue({ available: true }); await emit(event()); expect(get).toHaveBeenCalledTimes(5); expect(current).toHaveLength(1); }); + +it.each(["computer", "local"])("gates manual task views using the running agent's %s environment", async environmentId => { + const restricted = { ...slot, agentEnvironmentDriver: "tasks" }; + plugin.slots = [restricted]; + vi.spyOn(agentsApi, "get").mockResolvedValue({ id: "agent", companyId: "company", defaultEnvironmentId: environmentId } as Awaited>); + vi.spyOn(environmentsApi, "list").mockResolvedValue([{ id: "computer", name: "Computer", description: null, driver: "plugin", status: "active", config: {}, pluginDriver: { pluginKey: slot.pluginKey, driverKey: "tasks" }, envVars: {}, metadata: null, createdAt: new Date(0), updatedAt: new Date(0) }]); + await render(); + expect(panels.map(panel => panel.slot)).toEqual(environmentId === "computer" ? [restricted] : []); + if (environmentId === "local") { + expect(history).not.toHaveBeenCalled(); expect(get).not.toHaveBeenCalled(); + } +}); +it("gates an idle task view using its assigned agent's environment", async () => { + const restricted = { ...slot, agentEnvironmentDriver: "tasks" }; + plugin.slots = [restricted]; list.mockResolvedValue([]); + vi.spyOn(agentsApi, "get").mockResolvedValue({ id: "agent", companyId: "company", defaultEnvironmentId: "local" } as Awaited>); + vi.spyOn(environmentsApi, "list").mockResolvedValue([]); + await render(); expect(panels).toEqual([]); +}); diff --git a/ui/src/hooks/useTaskPluginPanels.ts b/ui/src/hooks/useTaskPluginPanels.ts index 6125f535aed..86d5115e993 100644 --- a/ui/src/hooks/useTaskPluginPanels.ts +++ b/ui/src/hooks/useTaskPluginPanels.ts @@ -8,6 +8,7 @@ import { useTaskRunLiveUpdates, useTranscriptLiveStream } from "@/context/LiveUp import { createLiveQueryRecovery } from "@/lib/live-query-recovery"; import { QueryNotReadyError } from "@/lib/query-client"; import { queryKeys } from "@/lib/queryKeys"; +import { useAgentEnvironmentSlots } from "@/plugins/useAgentEnvironmentSlots"; import { usePluginSlots, type ResolvedPluginSlot } from "@/plugins/slots"; export interface TaskPluginPanel { @@ -35,22 +36,26 @@ export function useTaskPluginPanels(issue: Issue | null | undefined, enabled = t slotTypes: ["detailTab"], entityType: "agent", taskSidePanel: true, companyId: issue?.companyId, enabled: savedTask, }); - const panels = useMemo(() => declaredSlots.filter(slot => slot.taskSidePanel), [declaredSlots]); - const slots = useMemo(() => panels.filter(slot => slot.taskSidePanel?.activation), [panels]); + const declaredPanels = useMemo(() => declaredSlots.filter(slot => slot.taskSidePanel), [declaredSlots]); const connected = useTaskRunLiveUpdates({ - companyId: savedTask && panels.length > 0 && !errorMessage ? issue?.companyId : null, + companyId: savedTask && declaredPanels.length > 0 && !errorMessage ? issue?.companyId : null, issueId: issue?.id, issueQueryRef: issue?.id ?? "pending", assigneeAgentId: issue?.assigneeAgentId, }); const { data: runs, isError: runsError, isPending: runsPending } = useQuery({ queryKey: queryKeys.issues.liveRuns(issue?.id ?? "pending"), queryFn: () => heartbeatsApi.liveRunsForIssue(issue!.id), - enabled: savedTask && panels.length > 0 && !errorMessage, + enabled: savedTask && declaredPanels.length > 0 && !errorMessage, refetchInterval: connected ? false : 3000, retry: (failures: number, error: Error) => failures < 2 && !(error instanceof ApiError && error.status >= 400 && error.status < 500), retryDelay: 1000, }); const run = runs?.find(run => run.status === "running" && (!issue?.executionRunId || run.id === issue.executionRunId)); + const agentId = (!runsError && run?.agentId) || issue?.assigneeAgentId; + const { slots: panels, isLoading: environmentLoading } = useAgentEnvironmentSlots( + declaredPanels, savedTask && !errorMessage ? agentId : null, issue?.companyId, + ); + const slots = useMemo(() => panels.filter(slot => slot.taskSidePanel?.activation), [panels]); const active = savedTask && slots.length > 0 && !!run && !runsError && !errorMessage; const declarations = slots.map(slot => [slot.pluginKey, slot.id, slot.pluginVersion, slot.taskSidePanel!.activation!.namespace, slot.taskSidePanel!.activation!.activityKey]); @@ -132,7 +137,6 @@ export function useTaskPluginPanels(issue: Issue | null | undefined, enabled = t const available = availability.map((result, index) => !activationsError && queryViewKind(result) !== "error" && result.data?.available === true && activations.some(panel => panel.pluginKey === slots[index]?.pluginKey && panel.slotId === slots[index]?.id)); const availableKey = available.join(","); - const agentId = (!runsError && run?.agentId) || issue?.assigneeAgentId; const manualPanels = useMemo(() => savedTask && !errorMessage && agentId ? panels.map(slot => ({ entityId: agentId, ...(!runsError && run ? { runId: run.id } : {}), @@ -141,5 +145,5 @@ export function useTaskPluginPanels(issue: Issue | null | undefined, enabled = t })) : EMPTY_PANELS, [savedTask, errorMessage, agentId, panels, run?.id, runsError, active, slots, availableKey]); return { panels: manualPanels, isLoading: savedTask && !errorMessage - && (isLoading || (panels.length > 0 && runsPending)) }; + && (isLoading || environmentLoading || (declaredPanels.length > 0 && runsPending)) }; } diff --git a/ui/src/pages/AgentDetailPage.test.tsx b/ui/src/pages/AgentDetailPage.test.tsx index 506221f50a1..9636be48066 100644 --- a/ui/src/pages/AgentDetailPage.test.tsx +++ b/ui/src/pages/AgentDetailPage.test.tsx @@ -22,9 +22,11 @@ vi.mock("./AgentDetail", async () => { } }; }); vi.mock("@/plugins/slots", () => ({ usePluginSlots: () => state, PluginSlotMount: (props: unknown) => { mount(props); return

Plugin contribution

; } })); -function view() { +function view(environmentId = "local", loadEnvironments = true) { const client = new QueryClient(); - client.setQueryData([...queryKeys.agents.detail("agent"), "company"], { id: "agent-id", companyId: "company", name: "Agent" }); + client.setQueryData([...queryKeys.agents.detail("agent"), "company"], { id: "agent-id", companyId: "company", name: "Agent", defaultEnvironmentId: environmentId }); + client.setQueryData([...queryKeys.agents.detail("agent-id"), "company"], { id: "agent-id", companyId: "company", defaultEnvironmentId: environmentId }); + if (loadEnvironments) client.setQueryData(queryKeys.environments.list("company"), [{ id: "computer", driver: "plugin", config: {}, pluginDriver: { pluginKey: "example.computer", driverKey: "tasks" } }]); return ; } function render() { @@ -72,3 +74,23 @@ it.each(["plugin:test.plugin:insights", "overview"])("updates headings on entry params.tab = "plugin:test.plugin:insights"; } }); + +it.each(["computer", "local"])("gates direct environment-specific views for %s", environmentId => { + const original = state.slots; + try { + state.slots = [...original, { pluginKey: "example.computer", id: "desktop", displayName: "Desktop", agentEnvironmentDriver: "tasks" }]; + params.tab = "plugin:example.computer:desktop"; + const markup = renderToStaticMarkup(view(environmentId)); + expect(markup.includes("Plugin contribution")).toBe(environmentId === "computer"); + if (environmentId === "local") expect(markup).toContain("This plugin view is unavailable"); + } finally { state.slots = original; params.tab = "plugin:test.plugin:insights"; } +}); + +it("renders an unrestricted view while another tab's environment is unknown", () => { + const original = state.slots; + try { + state.slots = [...original, { pluginKey: "example.computer", id: "desktop", displayName: "Desktop", agentEnvironmentDriver: "tasks" }]; + params.tab = "plugin:test.plugin:insights"; + expect(renderToStaticMarkup(view("computer", false))).toContain("Plugin contribution"); + } finally { state.slots = original; } +}); diff --git a/ui/src/pages/AgentDetailPage.tsx b/ui/src/pages/AgentDetailPage.tsx index a6d1515f9df..bf379b26ab2 100644 --- a/ui/src/pages/AgentDetailPage.tsx +++ b/ui/src/pages/AgentDetailPage.tsx @@ -1,10 +1,11 @@ -import { useEffect } from "react"; +import { useEffect, useMemo } from "react"; import { useBreadcrumbs } from "@/context/BreadcrumbContext"; import { useQuery } from "@tanstack/react-query"; import { useParams, Link } from "@/lib/router"; import { agentsApi } from "@/api/agents"; import { useCompany } from "@/context/CompanyContext"; import { queryKeys } from "@/lib/queryKeys"; +import { useAgentEnvironmentSlots } from "@/plugins/useAgentEnvironmentSlots"; import { PluginSlotMount, usePluginSlots } from "@/plugins/slots"; import { AgentDetail } from "./AgentDetail"; @@ -22,8 +23,9 @@ function AgentPluginDetail() { queryFn: () => agentsApi.get(agentId, companyId ?? undefined), enabled: Boolean(agentId && companyId), }); - const { slots, isLoading: slotsLoading, errorMessage } = usePluginSlots({ slotTypes: ["detailTab"], entityType: "agent", companyId: agent?.companyId, enabled: Boolean(agent) }); - const slot = slots.find(item => `plugin:${item.pluginKey}:${item.id}` === tab); + const { slots: declaredSlots, isLoading: slotsLoading, errorMessage } = usePluginSlots({ slotTypes: ["detailTab"], entityType: "agent", companyId: agent?.companyId, enabled: Boolean(agent) }); + const requestedSlots = useMemo(() => declaredSlots.filter(item => `plugin:${item.pluginKey}:${item.id}` === tab), [declaredSlots, tab]); + const { slots: [slot], isLoading: environmentLoading } = useAgentEnvironmentSlots(requestedSlots, agent?.id, agent?.companyId); const { setBreadcrumbs } = useBreadcrumbs(); useEffect(() => { setBreadcrumbs([ @@ -33,7 +35,7 @@ function AgentPluginDetail() { ]); }, [setBreadcrumbs, agent?.name, agentId, slot?.displayName]); if (error) return

Agent unavailable

; - if (!agent || isLoading || slotsLoading) return

Loading agent view…

; + if (!agent || isLoading || slotsLoading || environmentLoading) return

Loading agent view…

; return
{agent.name} {slot ? diff --git a/ui/src/plugins/useAgentEnvironmentSlots.test.tsx b/ui/src/plugins/useAgentEnvironmentSlots.test.tsx new file mode 100644 index 00000000000..d02a4cefa21 --- /dev/null +++ b/ui/src/plugins/useAgentEnvironmentSlots.test.tsx @@ -0,0 +1,82 @@ +// @vitest-environment jsdom +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { ApiError } from "@/api/client"; +import { agentsApi } from "@/api/agents"; +import { environmentsApi } from "@/api/environments"; +import { instanceSettingsApi } from "@/api/instanceSettings"; +import { queryKeys } from "@/lib/queryKeys"; +import type { ResolvedPluginSlot } from "./slots"; +import { useAgentEnvironmentSlots } from "./useAgentEnvironmentSlots"; + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; +const view = { pluginKey: "example.computer", id: "view", agentEnvironmentDriver: "tasks" } as ResolvedPluginSlot; +const ordinary = { pluginKey: "example.insights", id: "stats" } as ResolvedPluginSlot; +const environment = { id: "computer", driver: "plugin", config: {}, pluginDriver: { pluginKey: "example.computer", driverKey: "tasks" } }; +let client: QueryClient, root: Root, container: HTMLDivElement; +let result: ReturnType; +let agentId: string | null; +let slots: ResolvedPluginSlot[]; +function Harness() { result = useAgentEnvironmentSlots(slots, agentId, "company"); return null; } +async function render() { + await act(async () => root.render()); + await act(async () => { await new Promise(resolve => setTimeout(resolve, 30)); }); +} +beforeEach(() => { + client = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity } } }); + container = document.createElement("div"); root = createRoot(container); + agentId = "agent"; slots = [view, ordinary]; + client.setQueryData([...queryKeys.agents.detail("agent"), "company"], { id: "agent", companyId: "company", defaultEnvironmentId: "computer" }); + client.setQueryData(queryKeys.environments.list("company"), [environment]); + vi.spyOn(agentsApi, "get").mockRejectedValue(new Error("Agent unavailable")); + vi.spyOn(environmentsApi, "list").mockRejectedValue(new Error("Environments unavailable")); + vi.spyOn(instanceSettingsApi, "get").mockRejectedValue(new Error("Instance unavailable")); +}); +afterEach(async () => { await act(async () => root.unmount()); client.clear(); vi.restoreAllMocks(); }); +it("shows matching environment views and ordinary views without checking desktop readiness", async () => { + await render(); expect(result.slots).toEqual([view, ordinary]); expect(result.isLoading).toBe(false); + expect(instanceSettingsApi.get).not.toHaveBeenCalled(); +}); +it.each(["local", "other-plugin", "other-driver", "missing"])("hides a view for a %s environment", async kind => { + client.setQueryData(queryKeys.environments.list("company"), kind === "missing" ? [] : [{ ...environment, + driver: kind === "local" ? "local" : "plugin", pluginDriver: { ...environment.pluginDriver, + ...(kind === "other-plugin" ? { pluginKey: "example.other" } : {}), + ...(kind === "other-driver" ? { driverKey: "other" } : {}), + }, + }]); + await render(); expect(result.slots).toEqual([ordinary]); +}); +it("uses the inherited instance environment", async () => { + client.setQueryData([...queryKeys.agents.detail("agent"), "company"], { id: "agent", companyId: "company", defaultEnvironmentId: null }); + client.setQueryData(queryKeys.instance.settings, { defaultEnvironmentId: "computer" }); + await render(); expect(result.slots).toEqual([view, ordinary]); +}); +it.each(["agent", "environments", "instance"])("hides restricted views when %s cannot load", async kind => { + if (kind === "agent") client.removeQueries({ queryKey: queryKeys.agents.detail("agent") }); + if (kind === "environments") client.removeQueries({ queryKey: queryKeys.environments.list("company") }); + if (kind === "instance") client.setQueryData([...queryKeys.agents.detail("agent"), "company"], { id: "agent", companyId: "company", defaultEnvironmentId: null }); + await render(); expect(result.slots).toEqual([ordinary]); expect(result.isLoading).toBe(false); +}); +it("removes the view immediately when the target agent or its environment changes", async () => { + await render(); expect(result.slots).toContain(view); + agentId = "other"; + await render(); expect(result.slots).toEqual([ordinary]); + agentId = "agent"; + client.setQueryData([...queryKeys.agents.detail("agent"), "company"], { id: "agent", companyId: "company", defaultEnvironmentId: "local" }); + await render(); expect(result.slots).toEqual([ordinary]); +}); +it("does no environment discovery for ordinary contributions or an absent agent", async () => { + slots = [ordinary]; agentId = null; + await render(); expect(result.slots).toEqual([ordinary]); + expect(agentsApi.get).not.toHaveBeenCalled(); expect(environmentsApi.list).not.toHaveBeenCalled(); expect(instanceSettingsApi.get).not.toHaveBeenCalled(); +}); + +it.each([503, 403])("uses cached eligibility after a transient error and clears it after denial (%s)", async status => { + await render(); expect(result.slots).toContain(view); + vi.mocked(environmentsApi.list).mockRejectedValue(new ApiError("Unavailable", status, null)); + await act(async () => { await client.refetchQueries({ queryKey: queryKeys.environments.list("company") }); }); + await render(); + expect(result.slots).toEqual(status === 503 ? [view, ordinary] : [ordinary]); +}); diff --git a/ui/src/plugins/useAgentEnvironmentSlots.ts b/ui/src/plugins/useAgentEnvironmentSlots.ts new file mode 100644 index 00000000000..8abc5948b02 --- /dev/null +++ b/ui/src/plugins/useAgentEnvironmentSlots.ts @@ -0,0 +1,43 @@ +import { useMemo } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { agentsApi } from "@/api/agents"; +import { environmentsApi } from "@/api/environments"; +import { instanceSettingsApi } from "@/api/instanceSettings"; +import { queryViewKind } from "@/components/QueryView"; +import { queryKeys } from "@/lib/queryKeys"; +import type { ResolvedPluginSlot } from "./slots"; + +/** Environment requirements affect navigation, including manual task panels. */ +export function useAgentEnvironmentSlots(slots: ResolvedPluginSlot[], agentId: string | null | undefined, companyId: string | null | undefined) { + const required = slots.some(slot => slot.agentEnvironmentDriver); + const enabled = required && Boolean(agentId && companyId); + const agent = useQuery({ + queryKey: [...queryKeys.agents.detail(agentId ?? "pending"), companyId ?? null], + queryFn: () => agentsApi.get(agentId!, companyId!), + enabled, + }); + const config = enabled && agent.data?.companyId === companyId && queryViewKind(agent) !== "error" + ? agent.data : undefined; + const inherits = !!config && config.defaultEnvironmentId == null; + const instance = useQuery({ + queryKey: queryKeys.instance.settings, + queryFn: () => instanceSettingsApi.get(), + enabled: inherits, + }); + const environmentId = config?.defaultEnvironmentId + ?? (inherits && queryViewKind(instance) !== "error" ? instance.data?.defaultEnvironmentId : null); + const environments = useQuery({ + queryKey: queryKeys.environments.list(companyId ?? "pending"), + queryFn: () => environmentsApi.list(companyId!), + enabled: Boolean(environmentId), + }); + const environment = environmentId && queryViewKind(environments) !== "error" + ? environments.data?.find(environment => environment.id === environmentId) : undefined; + const filtered = useMemo(() => slots.filter(slot => !slot.agentEnvironmentDriver + || (environment?.driver === "plugin" && environment.pluginDriver?.pluginKey === slot.pluginKey + && environment.pluginDriver?.driverKey === slot.agentEnvironmentDriver)), [slots, environment]); + return { + slots: filtered, + isLoading: enabled && (agent.isPending || (inherits && instance.isPending) || (Boolean(environmentId) && environments.isPending)), + }; +}