Gate agent plugin tabs by execution environment (#20657)

This commit is contained in:
Nicky Leach authored and GitHub committed 2026-11-10 15:04:44 -08:00
1 parent e9343d8501
commit 0d4b1d8c89
17 files changed
+275 -18

No files matched your search

+10
View File
@@ -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
+11
View File
@@ -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
```
+2
View File
@@ -87,6 +87,8 @@ export interface Environment {
driver: EnvironmentDriver;
status: EnvironmentStatus;
config: Record<string, unknown>;
/** Driver identity from the read API, including when config is redacted. */
pluginDriver?: Pick<PluginEnvironmentConfig, "pluginKey" | "driverKey">;
envVars: AgentEnvConfig;
metadata: Record<string, unknown> | null;
createdAt: Date;
+2
View File
@@ -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.
@@ -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);
});
});
+4
View File
@@ -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"] });
}
@@ -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");
+7
View File
@@ -427,10 +427,17 @@ export function environmentRoutes(
}
function presentEnvironmentForRead<T extends {
driver: string;
config: Record<string, unknown> | null;
envVars?: Record<string, unknown> | null;
metadata: Record<string, unknown> | 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 };
@@ -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",
@@ -33,7 +33,7 @@ vi.mock("./SidebarNavItem", () => ({
SidebarNavItem: ({ to, label }: { to: string; label: string }) => <a href={to}>{label}</a>,
}));
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(<QueryClientProvider client={client}><MemoryRouter>
<AgentContextualSidebar agentRef="agent" agentId="agent-1" agentName="Agent" />
</MemoryRouter></QueryClientProvider>);
expect(markup.includes("Desktop")).toBe(environmentId === "computer");
expect(markup).toContain("Insights"); client.clear();
});
+3 -1
View File
@@ -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 (
+21
View File
@@ -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<ReturnType<typeof agentsApi.get>>);
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<ReturnType<typeof agentsApi.get>>);
vi.spyOn(environmentsApi, "list").mockResolvedValue([]);
await render(); expect(panels).toEqual([]);
});
+10 -6
View File
@@ -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)) };
}
+24 -2
View File
@@ -22,9 +22,11 @@ vi.mock("./AgentDetail", async () => {
} };
});
vi.mock("@/plugins/slots", () => ({ usePluginSlots: () => state, PluginSlotMount: (props: unknown) => { mount(props); return <p>Plugin contribution</p>; } }));
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 <QueryClientProvider client={client}><BreadcrumbProvider companyName="Test company"><AgentDetailPage /></BreadcrumbProvider></QueryClientProvider>;
}
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; }
});
+6 -4
View File
@@ -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 <p role="alert">Agent unavailable</p>;
if (!agent || isLoading || slotsLoading) return <p role="status">Loading agent view…</p>;
if (!agent || isLoading || slotsLoading || environmentLoading) return <p role="status">Loading agent view…</p>;
return <div className="mx-auto max-w-5xl space-y-4">
<Link to={`/agents/${encodeURIComponent(agentId)}/overview`} className="text-sm text-muted-foreground">{agent.name}</Link>
{slot ? <PluginSlotMount slot={slot} context={{ companyId: agent.companyId, companyPrefix: companyPrefix ?? null, entityId: agent.id, entityType: "agent" }} missingBehavior="placeholder" />
@@ -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<typeof useAgentEnvironmentSlots>;
let agentId: string | null;
let slots: ResolvedPluginSlot[];
function Harness() { result = useAgentEnvironmentSlots(slots, agentId, "company"); return null; }
async function render() {
await act(async () => root.render(<QueryClientProvider client={client}><Harness /></QueryClientProvider>));
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]);
});
@@ -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)),
};
}