diff --git a/packages/shared/src/types/instance.ts b/packages/shared/src/types/instance.ts index 0a00c8cbe18..79cbbd3d7a4 100644 --- a/packages/shared/src/types/instance.ts +++ b/packages/shared/src/types/instance.ts @@ -55,6 +55,7 @@ export interface InstanceExperimentalSettings { enableExperimentalFileViewer: boolean; enableCloudSync: boolean; enableExternalObjects: boolean; + enableBuiltInAgents: boolean; enableGoalsSidebarLink: boolean; enableServerInfoDebugView: boolean; autoRestartDevServerWhenIdle: boolean; diff --git a/packages/shared/src/validators/instance.test.ts b/packages/shared/src/validators/instance.test.ts index 34e6174894f..eb2117a0c01 100644 --- a/packages/shared/src/validators/instance.test.ts +++ b/packages/shared/src/validators/instance.test.ts @@ -29,6 +29,12 @@ describe("instance experimental settings validators", () => { expect(settings.enableWorktreeRunExecution).toBe(false); }); + it("defaults built-in agents off", () => { + const settings = instanceExperimentalSettingsSchema.parse({}); + + expect(settings.enableBuiltInAgents).toBe(false); + }); + it("accepts worktree run execution patches", () => { expect( patchInstanceExperimentalSettingsSchema.parse({ @@ -68,4 +74,14 @@ describe("instance experimental settings validators", () => { enableGoalsSidebarLink: true, }); }); + + it("accepts built-in agents patches", () => { + expect( + patchInstanceExperimentalSettingsSchema.parse({ + enableBuiltInAgents: true, + }), + ).toEqual({ + enableBuiltInAgents: true, + }); + }); }); diff --git a/packages/shared/src/validators/instance.ts b/packages/shared/src/validators/instance.ts index 158edeffe78..bdd0a53a656 100644 --- a/packages/shared/src/validators/instance.ts +++ b/packages/shared/src/validators/instance.ts @@ -49,6 +49,7 @@ export const instanceExperimentalSettingsSchema = z.object({ enableExperimentalFileViewer: z.boolean().default(false), enableCloudSync: z.boolean().default(false), enableExternalObjects: z.boolean().default(false), + enableBuiltInAgents: z.boolean().default(false), enableGoalsSidebarLink: z.boolean().default(false), enableServerInfoDebugView: z.boolean().default(false), autoRestartDevServerWhenIdle: z.boolean().default(false), diff --git a/server/src/__tests__/built-in-agent-routes.test.ts b/server/src/__tests__/built-in-agent-routes.test.ts index 8654b46081a..c56de9ebbdc 100644 --- a/server/src/__tests__/built-in-agent-routes.test.ts +++ b/server/src/__tests__/built-in-agent-routes.test.ts @@ -9,6 +9,10 @@ const mockAccessService = vi.hoisted(() => ({ decide: vi.fn(), })); +const mockInstanceSettingsService = vi.hoisted(() => ({ + getExperimental: vi.fn(), +})); + const mockBuiltInAgentService = vi.hoisted(() => ({ list: vi.fn(), ensure: vi.fn(), @@ -66,6 +70,7 @@ function builtInState(overrides: Record = {}) { function registerModuleMocks() { vi.doMock("../services/index.js", () => ({ accessService: () => mockAccessService, + instanceSettingsService: () => mockInstanceSettingsService, logActivity: mockLogActivity, })); vi.doMock("../services/built-in-agents.js", () => ({ @@ -95,6 +100,7 @@ describe("built-in agent routes", () => { registerModuleMocks(); vi.clearAllMocks(); mockAccessService.decide.mockResolvedValue(allowDecision()); + mockInstanceSettingsService.getExperimental.mockResolvedValue({ enableBuiltInAgents: true }); mockBuiltInAgentService.list.mockResolvedValue([builtInState()]); mockBuiltInAgentService.ensure.mockResolvedValue(builtInState()); mockBuiltInAgentService.provision.mockResolvedValue({ state: builtInState(), approval: null }); @@ -133,6 +139,23 @@ describe("built-in agent routes", () => { expect(mockBuiltInAgentService.list).not.toHaveBeenCalled(); }); + it("returns 404 and does not load built-in state when the experimental flag is disabled", async () => { + mockInstanceSettingsService.getExperimental.mockResolvedValue({ enableBuiltInAgents: false }); + const app = await createApp({ + type: "board", + userId: "board-user", + companyIds: [companyId], + source: "session", + isInstanceAdmin: false, + }); + + const res = await request(app).get(`/api/companies/${companyId}/built-in-agents`); + + expect(res.status, JSON.stringify(res.body)).toBe(404); + expect(res.body.error).toContain("Built-in agents are not enabled"); + expect(mockBuiltInAgentService.list).not.toHaveBeenCalled(); + }); + it("provisions through the agents:create gate and passes optional adapter overrides", async () => { const app = await createApp({ type: "board", diff --git a/server/src/__tests__/instance-settings-routes.test.ts b/server/src/__tests__/instance-settings-routes.test.ts index 2c717b557e0..337551b270f 100644 --- a/server/src/__tests__/instance-settings-routes.test.ts +++ b/server/src/__tests__/instance-settings-routes.test.ts @@ -82,6 +82,7 @@ describe("instance settings routes", () => { enableExperimentalFileViewer: false, enableCloudSync: false, enableExternalObjects: false, + enableBuiltInAgents: false, enableGoalsSidebarLink: false, enableServerInfoDebugView: false, autoRestartDevServerWhenIdle: false, @@ -105,6 +106,7 @@ describe("instance settings routes", () => { enableTaskWatchdogs: false, enableCloudSync: false, enableExternalObjects: false, + enableBuiltInAgents: false, enableGoalsSidebarLink: false, enableServerInfoDebugView: false, autoRestartDevServerWhenIdle: false, @@ -127,6 +129,7 @@ describe("instance settings routes", () => { enableExperimentalFileViewer: true, enableCloudSync: true, enableExternalObjects: false, + enableBuiltInAgents: false, enableGoalsSidebarLink: false, enableServerInfoDebugView: false, autoRestartDevServerWhenIdle: false, @@ -155,6 +158,7 @@ describe("instance settings routes", () => { enableTaskWatchdogs: true, enableCloudSync: true, enableExternalObjects: false, + enableBuiltInAgents: true, enableGoalsSidebarLink: false, enableServerInfoDebugView: true, autoRestartDevServerWhenIdle: false, @@ -211,6 +215,7 @@ describe("instance settings routes", () => { enableTaskWatchdogs: false, enableCloudSync: false, enableExternalObjects: false, + enableBuiltInAgents: false, enableGoalsSidebarLink: false, enableServerInfoDebugView: false, autoRestartDevServerWhenIdle: false, @@ -309,6 +314,24 @@ describe("instance settings routes", () => { }); }); + it("allows local board users to update built-in agents", async () => { + const app = await createApp({ + type: "board", + userId: "local-board", + source: "local_implicit", + isInstanceAdmin: true, + }); + + await request(app) + .patch("/api/instance/settings/experimental") + .send({ enableBuiltInAgents: true }) + .expect(200); + + expect(mockInstanceSettingsService.updateExperimental).toHaveBeenCalledWith({ + enableBuiltInAgents: true, + }); + }); + it("allows local board users to update the goals sidebar link", async () => { const app = await createApp({ type: "board", diff --git a/server/src/__tests__/instance-settings-service.test.ts b/server/src/__tests__/instance-settings-service.test.ts index 5f2ca069950..e7b45a2a449 100644 --- a/server/src/__tests__/instance-settings-service.test.ts +++ b/server/src/__tests__/instance-settings-service.test.ts @@ -10,6 +10,7 @@ describe("instance settings service", () => { enableExperimentalFileViewer: true, enableTaskWatchdogs: true, enableCloudSync: true, + enableBuiltInAgents: true, enableGoalsSidebarLink: true, enableServerInfoDebugView: true, autoRestartDevServerWhenIdle: true, @@ -28,6 +29,7 @@ describe("instance settings service", () => { enableExperimentalFileViewer: true, enableTaskWatchdogs: true, enableCloudSync: true, + enableBuiltInAgents: true, enableGoalsSidebarLink: true, enableServerInfoDebugView: true, autoRestartDevServerWhenIdle: true, @@ -98,4 +100,10 @@ describe("instance settings service", () => { normalizeExperimentalSettings({ enableConferenceRoomChat: "yes" }).enableConferenceRoomChat, ).toBe(false); }); + + it("defaults enableBuiltInAgents to false for empty and legacy stored settings", () => { + expect(normalizeExperimentalSettings(undefined).enableBuiltInAgents).toBe(false); + expect(normalizeExperimentalSettings({}).enableBuiltInAgents).toBe(false); + expect(normalizeExperimentalSettings({ enableExternalObjects: true }).enableBuiltInAgents).toBe(false); + }); }); diff --git a/server/src/index.ts b/server/src/index.ts index bed988dfeda..d2c7c36d0e7 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -780,8 +780,13 @@ export async function startServer(): Promise { logger.error({ err }, "startup reconciliation of codex_local managed homes failed"); }); - void reconcileBuiltInAgentsOnStartup(db as any) + void instanceSettingsService(db as any).getExperimental() + .then((experimental) => { + if (experimental.enableBuiltInAgents !== true) return null; + return reconcileBuiltInAgentsOnStartup(db as any); + }) .then((result) => { + if (!result) return; if (result.reconciled > 0 || result.unknown > 0 || result.duplicates > 0) { logger.warn( result, diff --git a/server/src/routes/built-in-agents.ts b/server/src/routes/built-in-agents.ts index 06bbdef015a..3dab8c475c1 100644 --- a/server/src/routes/built-in-agents.ts +++ b/server/src/routes/built-in-agents.ts @@ -2,8 +2,8 @@ import { Router, type Request } from "express"; import type { Db } from "@paperclipai/db"; import { builtInAgentProvisionSchema } from "@paperclipai/shared"; import { validate } from "../middleware/validate.js"; -import { forbidden } from "../errors.js"; -import { accessService, logActivity } from "../services/index.js"; +import { forbidden, notFound } from "../errors.js"; +import { accessService, instanceSettingsService, logActivity } from "../services/index.js"; import { builtInAgentService } from "../services/built-in-agents.js"; import { authorizationDeniedDetails } from "../services/authorization.js"; import { assertCompanyAccess, getActorInfo } from "./authz.js"; @@ -25,6 +25,14 @@ export function builtInAgentRoutes(db: Db) { const router = Router(); const access = accessService(db); const svc = builtInAgentService(db); + const settings = instanceSettingsService(db); + + async function assertBuiltInAgentsEnabled() { + const experimental = await settings.getExperimental(); + if (experimental.enableBuiltInAgents !== true) { + throw notFound("Built-in agents are not enabled"); + } + } async function assertCanProvisionBuiltInAgents(req: Request, companyId: string) { assertCompanyAccess(req, companyId); @@ -69,6 +77,7 @@ export function builtInAgentRoutes(db: Db) { router.get("/companies/:companyId/built-in-agents", async (req, res) => { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); + await assertBuiltInAgentsEnabled(); const states = await svc.list(companyId); res.json(states.map(redactBuiltInAgentListState)); }); @@ -79,6 +88,7 @@ export function builtInAgentRoutes(db: Db) { async (req, res) => { const companyId = req.params.companyId as string; const key = req.params.key as string; + await assertBuiltInAgentsEnabled(); await assertCanProvisionBuiltInAgents(req, companyId); const actor = getActorInfo(req); const result = await svc.provision(companyId, key, req.body, { @@ -110,6 +120,7 @@ export function builtInAgentRoutes(db: Db) { router.post("/companies/:companyId/built-in-agents/:key/reset", async (req, res) => { const companyId = req.params.companyId as string; const key = req.params.key as string; + await assertBuiltInAgentsEnabled(); await assertCanProvisionBuiltInAgents(req, companyId); const state = await svc.reset(companyId, key); await logBuiltInAgentMutation(req, { diff --git a/server/src/services/instance-settings.ts b/server/src/services/instance-settings.ts index 55f08f1f290..9cc54d2c929 100644 --- a/server/src/services/instance-settings.ts +++ b/server/src/services/instance-settings.ts @@ -54,6 +54,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta enableTaskWatchdogs: parsed.data.enableTaskWatchdogs ?? false, enableCloudSync: parsed.data.enableCloudSync ?? false, enableExternalObjects: parsed.data.enableExternalObjects ?? false, + enableBuiltInAgents: parsed.data.enableBuiltInAgents ?? false, enableGoalsSidebarLink: parsed.data.enableGoalsSidebarLink ?? false, enableServerInfoDebugView: parsed.data.enableServerInfoDebugView ?? false, autoRestartDevServerWhenIdle: parsed.data.autoRestartDevServerWhenIdle ?? false, @@ -76,6 +77,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta enableExperimentalFileViewer: false, enableCloudSync: false, enableExternalObjects: false, + enableBuiltInAgents: false, enableGoalsSidebarLink: false, enableServerInfoDebugView: false, autoRestartDevServerWhenIdle: false, diff --git a/ui/src/pages/AgentDetail.tsx b/ui/src/pages/AgentDetail.tsx index 2b477778381..045e5d587f9 100644 --- a/ui/src/pages/AgentDetail.tsx +++ b/ui/src/pages/AgentDetail.tsx @@ -711,12 +711,20 @@ export function AgentDetail() { ? resourceMembershipState(membershipsQuery.data, "agent", resolvedAgentId) : "joined"; + const { data: experimentalSettings } = useQuery({ + queryKey: queryKeys.instance.experimentalSettings, + queryFn: () => instanceSettingsApi.getExperimental(), + enabled: !!resolvedCompanyId, + }); + const builtInAgentsEnabled = experimentalSettings?.enableBuiltInAgents === true; const { data: builtInStates } = useQuery({ queryKey: queryKeys.builtInAgents.list(resolvedCompanyId!), queryFn: () => builtInAgentsApi.list(resolvedCompanyId!), - enabled: !!resolvedCompanyId, + enabled: !!resolvedCompanyId && builtInAgentsEnabled, }); - const builtInState = builtInStates?.find((entry) => entry.agentId === resolvedAgentId) ?? null; + const builtInState = builtInAgentsEnabled + ? builtInStates?.find((entry) => entry.agentId === resolvedAgentId) ?? null + : null; const builtInFeatureLabel = builtInState ? builtInState.definition.featureKeys .map((key) => key.charAt(0).toUpperCase() + key.slice(1)) diff --git a/ui/src/pages/Agents.test.tsx b/ui/src/pages/Agents.test.tsx index 447fdb094a2..c141b7a13cb 100644 --- a/ui/src/pages/Agents.test.tsx +++ b/ui/src/pages/Agents.test.tsx @@ -214,9 +214,11 @@ const environmentCapabilities: EnvironmentCapabilities = { function makeInstanceSettings({ defaultEnvironmentId = null, enableEnvironments = true, + enableBuiltInAgents = false, }: { defaultEnvironmentId?: string | null; enableEnvironments?: boolean; + enableBuiltInAgents?: boolean; } = {}) { return { id: "instance-settings-1", @@ -242,6 +244,7 @@ function makeInstanceSettings({ enableExperimentalFileViewer: false, enableCloudSync: false, enableExternalObjects: false, + enableBuiltInAgents, autoRestartDevServerWhenIdle: false, enableIssueGraphLivenessAutoRecovery: false, issueGraphLivenessAutoRecoveryLookbackHours: 24, @@ -380,6 +383,7 @@ describe("Agents", () => { it("uses the built-in agents route segment as the built-in filter", async () => { mockRouterState.pathname = "/agents/builtin"; + mockInstanceSettingsApi.get.mockResolvedValue(makeInstanceSettings({ enableBuiltInAgents: true })); const builtInAgent = makeAgent({ id: "built-in-agent", name: "Briefs Agent", @@ -747,6 +751,82 @@ describe("Agents", () => { expect(container.querySelector('select[aria-label="Group agents"]')).toBeNull(); }); + it("hides built-in agent surfaces while the experimental flag is disabled", async () => { + mockRouterState.pathname = "/agents/builtin"; + + root = createRoot(container); + await act(async () => { + root!.render( + + + + + , + ); + }); + await flushReact(); + await flushReact(); + + expect(mockBuiltInAgentsApi.list).not.toHaveBeenCalled(); + expect(container.textContent).not.toContain("Built-in"); + expect(mockRouterState.navigate).toHaveBeenCalledWith("/agents/all", { replace: true }); + }); + + it("shows and filters built-in agents when the experimental flag is enabled", async () => { + mockRouterState.pathname = "/agents/builtin"; + mockInstanceSettingsApi.get.mockResolvedValue(makeInstanceSettings({ enableBuiltInAgents: true })); + mockAgentsApi.list.mockResolvedValue([ + makeAgent({ + id: "built-in-agent", + name: "Briefs Agent", + urlKey: "briefs-agent", + }), + makeAgent({ + id: "regular-agent", + name: "Regular Agent", + urlKey: "regular-agent", + }), + ]); + mockAgentsApi.org.mockResolvedValue([ + { + id: "built-in-agent", + name: "Briefs Agent", + role: "engineer", + status: "active", + reports: [], + }, + { + id: "regular-agent", + name: "Regular Agent", + role: "engineer", + status: "active", + reports: [], + }, + ]); + mockBuiltInAgentsApi.list.mockResolvedValue([ + makeBuiltInAgentState({ agentId: "built-in-agent" }), + ]); + + root = createRoot(container); + await act(async () => { + root!.render( + + + + + , + ); + }); + await flushReact(); + await flushReact(); + + expect(mockBuiltInAgentsApi.list).toHaveBeenCalledWith("company-1"); + expect(container.textContent).toContain("Built-in"); + expect(container.textContent).toContain("Briefs Agent"); + expect(container.textContent).not.toContain("Regular Agent"); + expect(mockRouterState.navigate).not.toHaveBeenCalledWith("/agents/all", { replace: true }); + }); + it("gives list-view rows a fixed-width title so meta columns align (PAP-86)", async () => { root = createRoot(container); await act(async () => { diff --git a/ui/src/pages/InstanceExperimentalSettings.test.tsx b/ui/src/pages/InstanceExperimentalSettings.test.tsx index 28829419784..ce9227ea567 100644 --- a/ui/src/pages/InstanceExperimentalSettings.test.tsx +++ b/ui/src/pages/InstanceExperimentalSettings.test.tsx @@ -48,6 +48,8 @@ const GOALS_SIDEBAR_LINK_TOGGLE_SELECTOR = 'button[aria-label="Toggle goals sidebar link experimental setting"]'; const SERVER_INFO_TOGGLE_SELECTOR = 'button[aria-label="Toggle server info debug view experimental setting"]'; +const BUILT_IN_AGENTS_TOGGLE_SELECTOR = + 'button[aria-label="Toggle built-in agents experimental setting"]'; function defaultExperimentalSettings(): InstanceExperimentalSettingsPayload { return { @@ -59,6 +61,7 @@ function defaultExperimentalSettings(): InstanceExperimentalSettingsPayload { enableIssuePlanDecompositions: false, enableExperimentalFileViewer: false, enableExternalObjects: false, + enableBuiltInAgents: false, enableGoalsSidebarLink: false, enableTaskWatchdogs: false, enableCloudSync: false, @@ -274,6 +277,26 @@ describe("InstanceExperimentalSettings — Conference Room Chat card (PAP-11233) expect(toggle?.getAttribute("aria-checked")).toBe("true"); }); + it("renders and patches the Built-in Agents experimental toggle", async () => { + await renderPage(); + + expect(container.textContent).toContain("Built-in Agents"); + expect(container.textContent).toContain("Show Paperclip-managed built-in agent surfaces"); + + const toggle = container.querySelector(BUILT_IN_AGENTS_TOGGLE_SELECTOR); + expect(toggle?.getAttribute("aria-checked")).toBe("false"); + + await act(async () => { + toggle?.click(); + }); + await flushReact(); + + expect(mockInstanceSettingsApi.updateExperimental).toHaveBeenCalledWith({ + enableBuiltInAgents: true, + }); + expect(toggle?.getAttribute("aria-checked")).toBe("true"); + }); + it("renders and patches the Server Info Debug View experimental toggle", async () => { await renderPage(); diff --git a/ui/src/pages/InstanceExperimentalSettings.tsx b/ui/src/pages/InstanceExperimentalSettings.tsx index 5aa72810f23..4ff141cdb9a 100644 --- a/ui/src/pages/InstanceExperimentalSettings.tsx +++ b/ui/src/pages/InstanceExperimentalSettings.tsx @@ -171,6 +171,7 @@ export function InstanceExperimentalSettings() { queryClient.setQueryData(queryKeys.instance.experimentalSettings, updatedSettings); await Promise.all([ queryClient.invalidateQueries({ queryKey: queryKeys.instance.experimentalSettings }), + queryClient.invalidateQueries({ queryKey: ["built-in-agents"] }), queryClient.invalidateQueries({ queryKey: queryKeys.health }), ]); }, @@ -246,6 +247,7 @@ export function InstanceExperimentalSettings() { const enableTaskWatchdogs = experimentalQuery.data?.enableTaskWatchdogs === true; const enableCloudSync = experimentalQuery.data?.enableCloudSync === true; const enableExternalObjects = experimentalQuery.data?.enableExternalObjects === true; + const enableBuiltInAgents = experimentalQuery.data?.enableBuiltInAgents === true; const enableGoalsSidebarLink = experimentalQuery.data?.enableGoalsSidebarLink === true; const enableServerInfoDebugView = experimentalQuery.data?.enableServerInfoDebugView === true; const autoRestartDevServerWhenIdle = experimentalQuery.data?.autoRestartDevServerWhenIdle === true; @@ -363,6 +365,24 @@ export function InstanceExperimentalSettings() { +
+
+

Built-in Agents

+

+ Show Paperclip-managed built-in agent surfaces, including built-in roster badges, the Built-in agents + tab, and built-in agent setup controls. +

+
+ toggleMutation.mutate({ enableBuiltInAgents: !enableBuiltInAgents })} + disabled={toggleMutation.isPending} + aria-label="Toggle built-in agents experimental setting" + /> +
+ + +

Experimental File Viewer