Gate built-in agents behind experimental flag

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-07-09 18:50:40 +00:00
1 parent 622e0cf799
commit e341c878d6
13 files changed
+226 -5

No files matched your search

+1
View File
@@ -55,6 +55,7 @@ export interface InstanceExperimentalSettings {
enableExperimentalFileViewer: boolean;
enableCloudSync: boolean;
enableExternalObjects: boolean;
enableBuiltInAgents: boolean;
enableGoalsSidebarLink: boolean;
enableServerInfoDebugView: boolean;
autoRestartDevServerWhenIdle: boolean;
@@ -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,
});
});
});
@@ -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),
@@ -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<string, unknown> = {}) {
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",
@@ -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",
@@ -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);
});
});
+6 -1
View File
@@ -780,8 +780,13 @@ export async function startServer(): Promise<StartedServer> {
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,
+13 -2
View File
@@ -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, {
+2
View File
@@ -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,
+10 -2
View File
@@ -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))
+80
View File
@@ -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(
<QueryClientProvider client={queryClient}>
<ToastProvider>
<Agents />
</ToastProvider>
</QueryClientProvider>,
);
});
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(
<QueryClientProvider client={queryClient}>
<ToastProvider>
<Agents />
</ToastProvider>
</QueryClientProvider>,
);
});
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 () => {
@@ -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<HTMLButtonElement>(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();
@@ -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() {
</Card>
<Card className="block p-5">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1.5">
<h2 className="text-sm font-semibold">Built-in Agents</h2>
<p className="max-w-2xl text-sm text-muted-foreground">
Show Paperclip-managed built-in agent surfaces, including built-in roster badges, the Built-in agents
tab, and built-in agent setup controls.
</p>
</div>
<ToggleSwitch
checked={enableBuiltInAgents}
onCheckedChange={() => toggleMutation.mutate({ enableBuiltInAgents: !enableBuiltInAgents })}
disabled={toggleMutation.isPending}
aria-label="Toggle built-in agents experimental setting"
/>
</div>
</section>
<section className="rounded-xl border border-border bg-card p-5">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1.5">
<h2 className="text-sm font-semibold">Experimental File Viewer</h2>