mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-11 05:11:49 +01:00
Gate agent plugin tabs by execution environment (#20657)
This commit is contained in:
1 parent
e9343d8501
commit
0d4b1d8c89
17 files changed
+275
-18
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
```
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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 (
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
@@ -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)) };
|
||||
}
|
||||
@@ -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; }
|
||||
});
|
||||
@@ -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)),
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user