From de5f23eed0eb668106b4071af65f9876aa4da163 Mon Sep 17 00:00:00 2001
From: "@daniel-lxs" <57051444+daniel-lxs@users.noreply.github.com>
Date: Thu, 24 Sep 2026 15:11:43 +0000
Subject: [PATCH 1/3] [Improve] Move Auto tool approvals to Nightly Experiments
---
.changeset/nightly-auto-approvals.md | 7 ++
...utoApprovalsNightlySetting.client.test.tsx | 26 +++++++
...grationToolAutoApprovalsNightlySetting.tsx | 32 +++++++++
...grationToolAutoModeSetting.client.test.tsx | 18 +++++
.../McpToolManagementDialog.client.test.tsx | 21 ++++++
.../settings/experiment-settings-controls.tsx | 4 +-
.../NightlyExperimentsPage.client.test.tsx | 10 +++
.../useDeploymentExperiments.client.test.tsx | 24 +++++++
.../web/src/hooks/useDeploymentExperiments.ts | 6 ++
...oolAutoApprovalsExperiment.client.test.tsx | 69 +++++++++++++++++++
...eIntegrationToolAutoApprovalsExperiment.ts | 26 +++++--
.../deployment-experiments/index.test.ts | 32 ++++++++-
.../commands/deployment-experiments/index.ts | 15 ++++
.../integration-tool-policies/index.test.ts | 27 ++++++++
.../integration-tool-policies/index.ts | 11 ++-
apps/web/src/trpc/routers/_app.ts | 5 ++
.../trpc/routers/nightly-experiments.test.ts | 3 +
.../integration-tool-auto-evaluation.test.ts | 41 +++++++++++
.../integration-tool-auto-evaluation.ts | 19 ++---
.../integration-tool-approvals.test.ts | 37 ++++++++++
.../src/lib/integration-tool-auto-settings.ts | 23 +++++--
.../__tests__/metadata-descriptions.test.ts | 13 ++++
packages/feature-flags/src/config.ts | 7 +-
23 files changed, 447 insertions(+), 29 deletions(-)
create mode 100644 .changeset/nightly-auto-approvals.md
create mode 100644 apps/web/src/components/settings/IntegrationToolAutoApprovalsNightlySetting.client.test.tsx
create mode 100644 apps/web/src/components/settings/IntegrationToolAutoApprovalsNightlySetting.tsx
create mode 100644 apps/web/src/hooks/useIntegrationToolAutoApprovalsExperiment.client.test.tsx
create mode 100644 apps/web/src/trpc/commands/integration-tool-policies/index.test.ts
diff --git a/.changeset/nightly-auto-approvals.md b/.changeset/nightly-auto-approvals.md
new file mode 100644
index 0000000000..4050abee03
--- /dev/null
+++ b/.changeset/nightly-auto-approvals.md
@@ -0,0 +1,7 @@
+---
+'@roomote/web': patch
+---
+
+
+
+Move Auto tool approvals to Nightly Experiments and keep saved customer opt-ins disabled.
diff --git a/apps/web/src/components/settings/IntegrationToolAutoApprovalsNightlySetting.client.test.tsx b/apps/web/src/components/settings/IntegrationToolAutoApprovalsNightlySetting.client.test.tsx
new file mode 100644
index 0000000000..4dd6be7c9c
--- /dev/null
+++ b/apps/web/src/components/settings/IntegrationToolAutoApprovalsNightlySetting.client.test.tsx
@@ -0,0 +1,26 @@
+import { fireEvent, render, screen } from '@testing-library/react';
+
+const setEnabled = vi.fn();
+vi.mock('@/hooks/useDeploymentExperiments', () => ({
+ useDeploymentExperiment: () => ({
+ enabled: false,
+ isLoading: false,
+ isUpdating: false,
+ setEnabled,
+ }),
+}));
+
+import { IntegrationToolAutoApprovalsNightlySetting } from './IntegrationToolAutoApprovalsNightlySetting';
+
+it('offers the Auto switch on the nightly page and updates its flag', () => {
+ render();
+
+ expect(screen.getByText('Auto tool approvals')).toBeInTheDocument();
+ expect(
+ screen.getByText(/Jev is required for Auto to make decisions/),
+ ).toBeInTheDocument();
+ fireEvent.click(
+ screen.getByRole('switch', { name: 'Toggle Auto tool approvals' }),
+ );
+ expect(setEnabled).toHaveBeenCalledWith(true);
+});
diff --git a/apps/web/src/components/settings/IntegrationToolAutoApprovalsNightlySetting.tsx b/apps/web/src/components/settings/IntegrationToolAutoApprovalsNightlySetting.tsx
new file mode 100644
index 0000000000..934d5372dc
--- /dev/null
+++ b/apps/web/src/components/settings/IntegrationToolAutoApprovalsNightlySetting.tsx
@@ -0,0 +1,32 @@
+'use client';
+
+import { ShieldQuestion, Switch } from '@/components/system';
+import { useDeploymentExperiment } from '@/hooks/useDeploymentExperiments';
+
+import { Section } from './Section';
+
+export function IntegrationToolAutoApprovalsNightlySetting() {
+ const { enabled, isLoading, isUpdating, setEnabled } =
+ useDeploymentExperiment(
+ 'integrationToolAutoApprovals',
+ 'Failed to update Auto tool approvals.',
+ );
+
+ return (
+
+
+
+
+ Expose the Auto-approval decisions card in Settings → Agent Guidance.
+ Jev is required for Auto to make decisions, and saved per-tool choices
+ continue to take priority.
+
+
+
+ );
+}
diff --git a/apps/web/src/components/settings/IntegrationToolAutoModeSetting.client.test.tsx b/apps/web/src/components/settings/IntegrationToolAutoModeSetting.client.test.tsx
index 4745aeab9c..6ceb896c15 100644
--- a/apps/web/src/components/settings/IntegrationToolAutoModeSetting.client.test.tsx
+++ b/apps/web/src/components/settings/IntegrationToolAutoModeSetting.client.test.tsx
@@ -146,4 +146,22 @@ describe('IntegrationToolAutoModeSetting', () => {
policy: 'Existing guidance',
});
});
+
+ it('only allows Auto-on when the resolved model is Jev', () => {
+ state.settings = {
+ mode: 'off',
+ policy: '',
+ model: { kind: 'helper', model: 'openai/gpt-test' },
+ };
+ const { rerender } = render();
+ expect(
+ screen.getByRole('switch', { name: 'Enable auto-approval' }),
+ ).toBeDisabled();
+
+ state.settings = { mode: 'off', policy: '', model: { kind: 'judgment' } };
+ rerender();
+ expect(
+ screen.getByRole('switch', { name: 'Enable auto-approval' }),
+ ).toBeEnabled();
+ });
});
diff --git a/apps/web/src/components/settings/McpToolManagementDialog.client.test.tsx b/apps/web/src/components/settings/McpToolManagementDialog.client.test.tsx
index d8a8927165..0780ed36a8 100644
--- a/apps/web/src/components/settings/McpToolManagementDialog.client.test.tsx
+++ b/apps/web/src/components/settings/McpToolManagementDialog.client.test.tsx
@@ -134,6 +134,27 @@ describe('McpToolManagementDialog tool approvals', () => {
}
});
+ it('keeps explicit manual approvals available while Auto is off', () => {
+ renderDialog();
+
+ const search = within(
+ screen.getByRole('group', {
+ name: 'Approval mode for web_search_exa',
+ }),
+ );
+ fireEvent.click(search.getByRole('button', { name: 'Always ask' }));
+ fireEvent.click(search.getByRole('button', { name: 'Disable' }));
+
+ expect(state.setModeCalls).toEqual([
+ { integrationId: 'exa', toolName: 'web_search_exa', mode: 'ask' },
+ { integrationId: 'exa', toolName: 'web_search_exa', mode: 'reject' },
+ ]);
+ expect(search.getByRole('button', { name: 'Always allow' })).toBeEnabled();
+ expect(
+ search.queryByRole('button', { name: 'Auto' }),
+ ).not.toBeInTheDocument();
+ });
+
it('does not fire the admin-only policy query while the dialog is closed or for non-admin viewers', () => {
state.autoEnabled = true;
state.policiesQueryEnabled = undefined;
diff --git a/apps/web/src/components/settings/experiment-settings-controls.tsx b/apps/web/src/components/settings/experiment-settings-controls.tsx
index b545203c38..b0fbe0f7e4 100644
--- a/apps/web/src/components/settings/experiment-settings-controls.tsx
+++ b/apps/web/src/components/settings/experiment-settings-controls.tsx
@@ -10,6 +10,7 @@ import { getDeploymentExperimentAudience } from '@roomote/feature-flags';
import { BrowserNotificationsExperimentalSetting } from './BrowserNotificationsExperimentalSetting';
import { DizzyExperimentalSetting } from './DizzyExperimentalSetting';
+import { IntegrationToolAutoApprovalsNightlySetting } from './IntegrationToolAutoApprovalsNightlySetting';
import { PrivateSessionsExperimentalSetting } from './PrivateSessionsExperimentalSetting';
import { SessionTaskCommunicationTriageExperimentalSetting } from './SessionTaskCommunicationTriageExperimentalSetting';
@@ -21,8 +22,7 @@ const DEPLOYMENT_EXPERIMENT_SETTINGS: Record<
sessionTaskCommunicationTriage:
SessionTaskCommunicationTriageExperimentalSetting,
browserNotifications: BrowserNotificationsExperimentalSetting,
- // The Auto tool approvals control was deliberately removed in PR #3180.
- integrationToolAutoApprovals: null,
+ integrationToolAutoApprovals: IntegrationToolAutoApprovalsNightlySetting,
dizzy: DizzyExperimentalSetting,
};
diff --git a/apps/web/src/components/settings/pages/NightlyExperimentsPage.client.test.tsx b/apps/web/src/components/settings/pages/NightlyExperimentsPage.client.test.tsx
index 225bdadb53..abf730a4ec 100644
--- a/apps/web/src/components/settings/pages/NightlyExperimentsPage.client.test.tsx
+++ b/apps/web/src/components/settings/pages/NightlyExperimentsPage.client.test.tsx
@@ -48,6 +48,15 @@ vi.mock('@/components/settings/SettingsShell', () => ({
),
}));
+vi.mock(
+ '@/components/settings/IntegrationToolAutoApprovalsNightlySetting',
+ () => ({
+ IntegrationToolAutoApprovalsNightlySetting: () => (
+
Auto tool approvals setting
+ ),
+ }),
+);
+
import { NightlyExperimentsPage } from './NightlyExperimentsPage';
describe('NightlyExperimentsPage', () => {
@@ -83,6 +92,7 @@ describe('NightlyExperimentsPage', () => {
),
).toBeInTheDocument();
expect(screen.getByText('Dizzy')).toBeInTheDocument();
+ expect(screen.getByText('Auto tool approvals setting')).toBeInTheDocument();
expect(
screen.getByText(
'Continuously spin the Roomote logo mark in the collapsed sidebar and mobile header.',
diff --git a/apps/web/src/hooks/useDeploymentExperiments.client.test.tsx b/apps/web/src/hooks/useDeploymentExperiments.client.test.tsx
index 98b2f895b6..9b967dd1c3 100644
--- a/apps/web/src/hooks/useDeploymentExperiments.client.test.tsx
+++ b/apps/web/src/hooks/useDeploymentExperiments.client.test.tsx
@@ -65,6 +65,9 @@ vi.mock('@/trpc/client', () => ({
dizzyEnabled: {
queryKey: () => ['dizzy-enabled'],
},
+ integrationToolAutoApprovalsEnabled: {
+ queryKey: () => ['integration-tool-auto-approvals-enabled'],
+ },
set: {
mutationOptions: (options: unknown) => {
mocks.mutationRoute = 'internal-nightly';
@@ -148,6 +151,27 @@ describe('useDeploymentExperiments', () => {
});
});
+ it('refreshes Auto runtime consumers after changing its nightly experiment', async () => {
+ renderHook(() =>
+ useDeploymentExperiments('Save failed', 'internal-nightly'),
+ );
+
+ await mutationOptions.onSettled!(
+ undefined as never,
+ undefined as never,
+ { id: 'integrationToolAutoApprovals', enabled: true } as never,
+ undefined as never,
+ undefined as never,
+ );
+
+ expect(mocks.invalidateQueries).toHaveBeenCalledWith({
+ queryKey: ['nightly-experiments'],
+ });
+ expect(mocks.invalidateQueries).toHaveBeenCalledWith({
+ queryKey: ['integration-tool-auto-approvals-enabled'],
+ });
+ });
+
it('optimistically updates and rolls back only the changed deployment flag', async () => {
const previous = getDeploymentExperimentValues(undefined);
mocks.getQueryData.mockReturnValue(previous);
diff --git a/apps/web/src/hooks/useDeploymentExperiments.ts b/apps/web/src/hooks/useDeploymentExperiments.ts
index a952b76ed4..9d0d860622 100644
--- a/apps/web/src/hooks/useDeploymentExperiments.ts
+++ b/apps/web/src/hooks/useDeploymentExperiments.ts
@@ -100,6 +100,12 @@ export function useDeploymentExperiments(
queryKey: trpc.nightlyExperiments.dizzyEnabled.queryKey(),
});
}
+ if (isNightly && variables.id === 'integrationToolAutoApprovals') {
+ void queryClient.invalidateQueries({
+ queryKey:
+ trpc.nightlyExperiments.integrationToolAutoApprovalsEnabled.queryKey(),
+ });
+ }
},
};
diff --git a/apps/web/src/hooks/useIntegrationToolAutoApprovalsExperiment.client.test.tsx b/apps/web/src/hooks/useIntegrationToolAutoApprovalsExperiment.client.test.tsx
new file mode 100644
index 0000000000..a21b5c441d
--- /dev/null
+++ b/apps/web/src/hooks/useIntegrationToolAutoApprovalsExperiment.client.test.tsx
@@ -0,0 +1,69 @@
+import { renderHook } from '@testing-library/react';
+
+const { queryState, state } = vi.hoisted(() => ({
+ queryState: { data: false as boolean | undefined, isPending: false },
+ state: {
+ queryOptions: vi.fn(),
+ nightlyExperimentsEnabled: false,
+ },
+}));
+
+vi.mock('@tanstack/react-query', () => ({
+ useQuery: (options: unknown) => {
+ state.queryOptions(options);
+ return queryState;
+ },
+}));
+
+vi.mock('@/hooks/useUser', () => ({
+ useAuthorizedUser: () => ({
+ nightlyExperimentsEnabled: state.nightlyExperimentsEnabled,
+ }),
+}));
+
+vi.mock('@/trpc/client', () => ({
+ useTRPC: () => ({
+ nightlyExperiments: {
+ integrationToolAutoApprovalsEnabled: {
+ queryOptions: (_input: undefined, options: unknown) => ({ options }),
+ },
+ },
+ }),
+}));
+
+import { useIntegrationToolAutoApprovalsExperiment } from './useIntegrationToolAutoApprovalsExperiment';
+
+describe('useIntegrationToolAutoApprovalsExperiment', () => {
+ beforeEach(() => {
+ state.nightlyExperimentsEnabled = false;
+ queryState.data = false;
+ queryState.isPending = false;
+ });
+
+ it('keeps Auto off and skips runtime reads outside nightly deployments', () => {
+ queryState.data = true;
+
+ const { result } = renderHook(() =>
+ useIntegrationToolAutoApprovalsExperiment(),
+ );
+
+ expect(result.current).toEqual({ enabled: false, isLoading: false });
+ expect(state.queryOptions).toHaveBeenCalledWith(
+ expect.objectContaining({ options: { enabled: false } }),
+ );
+ });
+
+ it('uses the nightly runtime value for all signed-in viewers on opted-in deployments', () => {
+ state.nightlyExperimentsEnabled = true;
+ queryState.data = true;
+
+ const { result } = renderHook(() =>
+ useIntegrationToolAutoApprovalsExperiment(),
+ );
+
+ expect(result.current.enabled).toBe(true);
+ expect(state.queryOptions).toHaveBeenCalledWith(
+ expect.objectContaining({ options: { enabled: true } }),
+ );
+ });
+});
diff --git a/apps/web/src/hooks/useIntegrationToolAutoApprovalsExperiment.ts b/apps/web/src/hooks/useIntegrationToolAutoApprovalsExperiment.ts
index 8df92e316d..e19c72d287 100644
--- a/apps/web/src/hooks/useIntegrationToolAutoApprovalsExperiment.ts
+++ b/apps/web/src/hooks/useIntegrationToolAutoApprovalsExperiment.ts
@@ -1,14 +1,28 @@
'use client';
-import { useDeploymentExperiment } from './useDeploymentExperiments';
+import { useQuery } from '@tanstack/react-query';
+
+import { useAuthorizedUser } from '@/hooks/useUser';
+import { useTRPC } from '@/trpc/client';
/**
- * Auto tool approvals are experimental; per-tool approvals are not. While
- * this is off, a tool nobody has made a choice about simply runs.
+ * Auto tool approvals are an internal nightly experiment; per-tool approvals
+ * are not. The runtime value is readable by members only on opted-in internal
+ * deployments, so ordinary deployments never query the admin-only settings
+ * procedure.
*/
export function useIntegrationToolAutoApprovalsExperiment() {
- return useDeploymentExperiment(
- 'integrationToolAutoApprovals',
- 'Failed to update Auto tool approvals.',
+ const { nightlyExperimentsEnabled } = useAuthorizedUser();
+ const trpc = useTRPC();
+ const query = useQuery(
+ trpc.nightlyExperiments.integrationToolAutoApprovalsEnabled.queryOptions(
+ undefined,
+ { enabled: nightlyExperimentsEnabled === true },
+ ),
);
+
+ return {
+ enabled: nightlyExperimentsEnabled === true && query.data === true,
+ isLoading: nightlyExperimentsEnabled === true && query.isPending,
+ };
}
diff --git a/apps/web/src/trpc/commands/deployment-experiments/index.test.ts b/apps/web/src/trpc/commands/deployment-experiments/index.test.ts
index 1fc24daccd..2972a1dc54 100644
--- a/apps/web/src/trpc/commands/deployment-experiments/index.test.ts
+++ b/apps/web/src/trpc/commands/deployment-experiments/index.test.ts
@@ -6,6 +6,7 @@ import {
getDeploymentExperimentsCommand,
getDizzyExperimentEnabledCommand,
getNightlyExperimentsCommand,
+ getIntegrationToolAutoApprovalsEnabledCommand,
setDeploymentExperimentCommand,
setNightlyExperimentCommand,
} from './index';
@@ -44,6 +45,7 @@ describe('deployment experiment commands', () => {
expect(firstValues).toEqual(secondValues);
expect(firstValues).not.toHaveProperty('results');
+ expect(firstValues).not.toHaveProperty('integrationToolAutoApprovals');
});
it('allows only admins to update shared values and leaves user metadata dormant', async () => {
@@ -68,6 +70,12 @@ describe('deployment experiment commands', () => {
enabled: true,
}),
).resolves.toMatchObject({ privateSessions: true });
+ await expect(
+ setDeploymentExperimentCommand(auth(admin.id, true), {
+ id: 'integrationToolAutoApprovals',
+ enabled: true,
+ }),
+ ).rejects.toThrow('Unauthorized');
await expect(
getDeploymentExperimentsCommand(auth(member.id, false)),
).resolves.toMatchObject({ privateSessions: true });
@@ -114,7 +122,10 @@ describe('deployment experiment commands', () => {
await expect(
getNightlyExperimentsCommand(auth(admin.id, true, true)),
- ).resolves.toEqual({ dizzy: false });
+ ).resolves.toEqual({
+ integrationToolAutoApprovals: false,
+ dizzy: false,
+ });
await expect(
getDeploymentExperimentsCommand(auth(admin.id, true, true)),
).resolves.toHaveProperty('privateSessions');
@@ -135,9 +146,19 @@ describe('deployment experiment commands', () => {
await expect(
getDizzyExperimentEnabledCommand(auth(member.id, false, false)),
).rejects.toThrow('Unauthorized');
+ await expect(
+ getIntegrationToolAutoApprovalsEnabledCommand(
+ auth(member.id, false, false),
+ ),
+ ).rejects.toThrow('Unauthorized');
await expect(
getDizzyExperimentEnabledCommand(auth(member.id, false, true)),
).resolves.toBe(false);
+ await expect(
+ getIntegrationToolAutoApprovalsEnabledCommand(
+ auth(member.id, false, true),
+ ),
+ ).resolves.toBe(false);
await setNightlyExperimentCommand(auth(admin.id, true, true), {
id: 'dizzy',
@@ -147,6 +168,15 @@ describe('deployment experiment commands', () => {
await expect(
getDizzyExperimentEnabledCommand(auth(member.id, false, true)),
).resolves.toBe(true);
+ await setNightlyExperimentCommand(auth(admin.id, true, true), {
+ id: 'integrationToolAutoApprovals',
+ enabled: true,
+ });
+ await expect(
+ getIntegrationToolAutoApprovalsEnabledCommand(
+ auth(member.id, false, true),
+ ),
+ ).resolves.toBe(true);
await expect(
getNightlyExperimentsCommand(auth(member.id, false, true)),
).rejects.toThrow('Unauthorized');
diff --git a/apps/web/src/trpc/commands/deployment-experiments/index.ts b/apps/web/src/trpc/commands/deployment-experiments/index.ts
index c5f0d906ae..c46959671f 100644
--- a/apps/web/src/trpc/commands/deployment-experiments/index.ts
+++ b/apps/web/src/trpc/commands/deployment-experiments/index.ts
@@ -65,6 +65,21 @@ export async function getDizzyExperimentEnabledCommand(
return (await getDeploymentExperiments()).dizzy;
}
+/**
+ * Auto is deployment-wide runtime behavior, so signed-in users on an
+ * explicitly opted-in internal deployment may read its enabled state. Only
+ * admins can manage the switch or see the other nightly experiment values.
+ */
+export async function getIntegrationToolAutoApprovalsEnabledCommand(
+ auth: UserAuthSuccess,
+): Promise {
+ if (auth.nightlyExperimentsEnabled !== true) {
+ throw new Error('Unauthorized');
+ }
+
+ return (await getDeploymentExperiments()).integrationToolAutoApprovals;
+}
+
export async function setNightlyExperimentCommand(
auth: UserAuthSuccess,
input: { id: DeploymentExperimentId; enabled: boolean },
diff --git a/apps/web/src/trpc/commands/integration-tool-policies/index.test.ts b/apps/web/src/trpc/commands/integration-tool-policies/index.test.ts
new file mode 100644
index 0000000000..fa4658c38b
--- /dev/null
+++ b/apps/web/src/trpc/commands/integration-tool-policies/index.test.ts
@@ -0,0 +1,27 @@
+import type { UserAuthSuccess } from '@/types';
+
+import {
+ getIntegrationToolAutoSettingsCommand,
+ setIntegrationToolAutoSettingsCommand,
+} from './index';
+
+describe('Auto settings nightly access', () => {
+ const customerAdmin = {
+ userId: 'admin',
+ isAdmin: true,
+ nightlyExperimentsEnabled: false,
+ } as UserAuthSuccess;
+
+ it('keeps old Auto settings unavailable on deployments without nightly opt-in', async () => {
+ await expect(
+ getIntegrationToolAutoSettingsCommand(customerAdmin),
+ ).rejects.toThrow('Auto tool approvals are not enabled.');
+
+ await expect(
+ setIntegrationToolAutoSettingsCommand(customerAdmin, {
+ mode: 'on',
+ policy: '',
+ }),
+ ).rejects.toThrow('Auto tool approvals are not enabled.');
+ });
+});
diff --git a/apps/web/src/trpc/commands/integration-tool-policies/index.ts b/apps/web/src/trpc/commands/integration-tool-policies/index.ts
index 4f3dd2d21d..091d8c2766 100644
--- a/apps/web/src/trpc/commands/integration-tool-policies/index.ts
+++ b/apps/web/src/trpc/commands/integration-tool-policies/index.ts
@@ -68,7 +68,8 @@ export async function setIntegrationToolPoliciesCommand(
return listIntegrationToolPolicies();
}
-const autoApprovalsEnabled = () =>
+const autoApprovalsEnabled = (auth: UserAuthSuccess) =>
+ auth.nightlyExperimentsEnabled === true &&
isDeploymentExperimentEnabled('integrationToolAutoApprovals');
/**
@@ -211,6 +212,12 @@ export async function getIntegrationToolAutoSettingsCommand(
auth: UserAuthSuccess,
) {
assertAdmin(auth);
+ if (!(await autoApprovalsEnabled(auth))) {
+ throw new TRPCError({
+ code: 'NOT_FOUND',
+ message: 'Auto tool approvals are not enabled.',
+ });
+ }
const [settings, model] = await Promise.all([
getIntegrationToolAutoSettings(),
resolveDecisionModel(AUTO_DECISION_REQUIREMENTS).catch(() => null),
@@ -231,7 +238,7 @@ export async function setIntegrationToolAutoSettingsCommand(
input: IntegrationToolAutoSettings,
) {
assertAdmin(auth);
- if (!(await autoApprovalsEnabled())) {
+ if (!(await autoApprovalsEnabled(auth))) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Auto tool approvals are not enabled.',
diff --git a/apps/web/src/trpc/routers/_app.ts b/apps/web/src/trpc/routers/_app.ts
index d382a714b4..0580d610aa 100644
--- a/apps/web/src/trpc/routers/_app.ts
+++ b/apps/web/src/trpc/routers/_app.ts
@@ -230,6 +230,7 @@ import {
import {
getDizzyExperimentEnabledCommand,
getDeploymentExperimentsCommand,
+ getIntegrationToolAutoApprovalsEnabledCommand,
getNightlyExperimentsCommand,
setDeploymentExperimentCommand,
setNightlyExperimentCommand,
@@ -3681,6 +3682,10 @@ export const appRouter = createRouter({
dizzyEnabled: protectedProcedure.query(({ ctx: { auth } }) =>
getDizzyExperimentEnabledCommand(auth),
),
+ integrationToolAutoApprovalsEnabled: protectedProcedure.query(
+ ({ ctx: { auth } }) =>
+ getIntegrationToolAutoApprovalsEnabledCommand(auth),
+ ),
get: protectedProcedure.query(({ ctx: { auth } }) =>
getNightlyExperimentsCommand(auth),
),
diff --git a/apps/web/src/trpc/routers/nightly-experiments.test.ts b/apps/web/src/trpc/routers/nightly-experiments.test.ts
index 731aa357fa..cdb147de22 100644
--- a/apps/web/src/trpc/routers/nightly-experiments.test.ts
+++ b/apps/web/src/trpc/routers/nightly-experiments.test.ts
@@ -23,6 +23,9 @@ describe('nightly experiment API access', () => {
await expect(caller.nightlyExperiments.dizzyEnabled()).rejects.toThrow(
'Unauthorized',
);
+ await expect(
+ caller.nightlyExperiments.integrationToolAutoApprovalsEnabled(),
+ ).rejects.toThrow('Unauthorized');
});
it('keeps management routes admin-only on opted-in deployments', async () => {
diff --git a/packages/cloud-agents/src/server/__tests__/integration-tool-auto-evaluation.test.ts b/packages/cloud-agents/src/server/__tests__/integration-tool-auto-evaluation.test.ts
index 8f5d867b85..a90274d517 100644
--- a/packages/cloud-agents/src/server/__tests__/integration-tool-auto-evaluation.test.ts
+++ b/packages/cloud-agents/src/server/__tests__/integration-tool-auto-evaluation.test.ts
@@ -5,6 +5,10 @@ const mocks = vi.hoisted(() => ({
recordShadow: vi.fn(async () => undefined),
settings: vi.fn(async () => ({ mode: 'off', policy: '' })),
experiment: vi.fn(async () => true),
+ nightlyExperimentsEnabled: true as boolean | string,
+ isEnvFlagEnabled: vi.fn(
+ (value: unknown) => value === true || value === 'true' || value === '1',
+ ),
}));
vi.mock('../typesafe-judgment', () => ({
@@ -17,6 +21,14 @@ vi.mock('@roomote/db/server', async () => ({
recordIntegrationToolAutoEvaluation: mocks.record,
recordIntegrationToolShadowEvaluation: mocks.recordShadow,
}));
+vi.mock('@roomote/env', () => ({
+ Env: {
+ get R_NIGHTLY_EXPERIMENTS_ENABLED() {
+ return mocks.nightlyExperimentsEnabled;
+ },
+ },
+ isEnvFlagEnabled: mocks.isEnvFlagEnabled,
+}));
import {
evaluateIntegrationToolAutoDecision,
@@ -62,6 +74,8 @@ beforeEach(() => {
mocks.settings.mockResolvedValue({ mode: 'off', policy: '' });
mocks.resolveModel.mockResolvedValue({ kind: 'judgment' });
mocks.experiment.mockResolvedValue(true);
+ mocks.nightlyExperimentsEnabled = true;
+ mocks.isEnvFlagEnabled.mockClear();
});
describe('recommendFromAutoAnswers', () => {
@@ -432,6 +446,33 @@ describe('resolveIntegrationToolAutoState', () => {
expect(mocks.experiment).toHaveBeenCalledWith(
'integrationToolAutoApprovals',
);
+ expect(mocks.resolveModel).toHaveBeenCalledWith({
+ excludeRoomoteModel: true,
+ });
+ });
+
+ it('keeps saved customer opt-ins off unless the deployment explicitly opts into nightly experiments', async () => {
+ mocks.nightlyExperimentsEnabled = false;
+ mocks.settings.mockResolvedValue({ mode: 'on', policy: 'Old guidance' });
+
+ await expect(resolveIntegrationToolAutoState()).resolves.toEqual({
+ mode: 'off',
+ settings: { mode: 'on', policy: 'Old guidance' },
+ model: null,
+ });
+ expect(mocks.resolveModel).not.toHaveBeenCalled();
+ });
+
+ it('treats a string false nightly flag as disabled when env validation is skipped', async () => {
+ mocks.nightlyExperimentsEnabled = 'false';
+ mocks.settings.mockResolvedValue({ mode: 'on', policy: 'Old guidance' });
+
+ await expect(resolveIntegrationToolAutoState()).resolves.toMatchObject({
+ mode: 'off',
+ model: null,
+ });
+ expect(mocks.isEnvFlagEnabled).toHaveBeenCalledWith('false');
+ expect(mocks.resolveModel).not.toHaveBeenCalled();
});
it('shadows while off with a hosted model, so its judgment can be reviewed', async () => {
diff --git a/packages/cloud-agents/src/server/integration-tool-auto-evaluation.ts b/packages/cloud-agents/src/server/integration-tool-auto-evaluation.ts
index 6b98b36da0..b140f361b0 100644
--- a/packages/cloud-agents/src/server/integration-tool-auto-evaluation.ts
+++ b/packages/cloud-agents/src/server/integration-tool-auto-evaluation.ts
@@ -3,6 +3,7 @@ import {
isDeploymentExperimentEnabled,
recordIntegrationToolShadowEvaluation,
} from '@roomote/db/server';
+import { Env, isEnvFlagEnabled } from '@roomote/env';
import type {
IntegrationToolAutoEvaluation,
IntegrationToolAutoSettings,
@@ -367,19 +368,19 @@ export type IntegrationToolAutoState = {
};
export async function resolveIntegrationToolAutoState(): Promise {
- const [enabled, settings, model] = await Promise.all([
+ const [enabled, settings] = await Promise.all([
isDeploymentExperimentEnabled('integrationToolAutoApprovals'),
getIntegrationToolAutoSettings(),
- resolveDecisionModel(AUTO_DECISION_REQUIREMENTS).catch(() => null),
]);
+ if (!isEnvFlagEnabled(Env.R_NIGHTLY_EXPERIMENTS_ENABLED) || !enabled) {
+ return { mode: 'off', settings, model: null };
+ }
+
+ const model = await resolveDecisionModel(AUTO_DECISION_REQUIREMENTS).catch(
+ () => null,
+ );
const hosted = model?.kind === 'judgment';
- const mode = !enabled
- ? 'off'
- : settings.mode === 'on'
- ? 'on'
- : hosted
- ? 'shadow'
- : 'off';
+ const mode = settings.mode === 'on' ? 'on' : hosted ? 'shadow' : 'off';
return { mode, settings, model: model?.kind ?? null };
}
diff --git a/packages/db/src/lib/__tests__/integration-tool-approvals.test.ts b/packages/db/src/lib/__tests__/integration-tool-approvals.test.ts
index bfeb9303bc..ae3e29c63f 100644
--- a/packages/db/src/lib/__tests__/integration-tool-approvals.test.ts
+++ b/packages/db/src/lib/__tests__/integration-tool-approvals.test.ts
@@ -1,9 +1,11 @@
import {
db,
+ deploymentSettings,
eq,
integrationToolApprovalRequests,
integrationToolAutoEvaluations,
sessionFactory,
+ sql,
sessions,
taskFactory,
userFactory,
@@ -495,6 +497,41 @@ describe('Auto mode', () => {
await setIntegrationToolAutoSettings({ mode: 'off', policy: '' });
});
+ it('does not revive a saved Auto-on mode when the nightly experiment is first enabled', async () => {
+ await db
+ .insert(deploymentSettings)
+ .values({
+ id: 'default',
+ metadata: {
+ integration_tool_auto: {
+ mode: 'on',
+ policy: 'Reads are fine. Never send messages.',
+ },
+ },
+ setupCompletedAt: null,
+ })
+ .onConflictDoUpdate({
+ target: deploymentSettings.id,
+ set: {
+ metadata: sql`coalesce(${deploymentSettings.metadata}, '{}'::jsonb) - 'integration_tool_auto_nightly' || ${JSON.stringify(
+ {
+ integration_tool_auto: {
+ mode: 'on',
+ policy: 'Reads are fine. Never send messages.',
+ },
+ },
+ )}::jsonb`,
+ },
+ });
+
+ await expect(getIntegrationToolAutoSettings()).resolves.toEqual({
+ mode: 'off',
+ policy: 'Reads are fine. Never send messages.',
+ });
+
+ await setIntegrationToolAutoSettings({ mode: 'off', policy: '' });
+ });
+
it('stores Always allow as a choice of its own, unlike the default', async () => {
const userId = await user();
const integrationId = `always-${Date.now()}`;
diff --git a/packages/db/src/lib/integration-tool-auto-settings.ts b/packages/db/src/lib/integration-tool-auto-settings.ts
index c24addc000..ccaea3e7b9 100644
--- a/packages/db/src/lib/integration-tool-auto-settings.ts
+++ b/packages/db/src/lib/integration-tool-auto-settings.ts
@@ -9,12 +9,15 @@ import { type DatabaseOrTransaction, db } from '../db';
import { deploymentSettings } from '../schema';
const DEFAULT_DEPLOYMENT_ID = 'default';
-const METADATA_KEY = 'integration_tool_auto';
+const METADATA_KEY = 'integration_tool_auto_nightly';
+const LEGACY_METADATA_KEY = 'integration_tool_auto';
/**
* Deployment-wide Auto mode for tool approvals, kept with the other
- * deployment settings. Off by default: default tools run as they always
- * have until an admin turns Auto on.
+ * deployment settings. The nightly key intentionally does not inherit the
+ * former customer-preview mode, so turning on the internal experiment cannot
+ * silently revive an old Auto opt-in. Preserve its saved guidance, but require
+ * a fresh choice to turn Auto on.
*/
const DEFAULTS: IntegrationToolAutoSettings = { mode: 'off', policy: '' };
@@ -25,10 +28,18 @@ export async function getIntegrationToolAutoSettings(
where: eq(deploymentSettings.id, DEFAULT_DEPLOYMENT_ID),
columns: { metadata: true },
});
- const parsed = integrationToolAutoSettingsSchema.safeParse(
- (deployment?.metadata as Record | null)?.[METADATA_KEY],
+ const metadata = deployment?.metadata as Record | null;
+ const current = integrationToolAutoSettingsSchema.safeParse(
+ metadata?.[METADATA_KEY],
);
- return parsed.success ? parsed.data : DEFAULTS;
+ if (current.success) return current.data;
+
+ const legacy = integrationToolAutoSettingsSchema.safeParse(
+ metadata?.[LEGACY_METADATA_KEY],
+ );
+ return legacy.success
+ ? { mode: 'off', policy: legacy.data.policy }
+ : DEFAULTS;
}
export async function setIntegrationToolAutoSettings(
diff --git a/packages/feature-flags/src/__tests__/metadata-descriptions.test.ts b/packages/feature-flags/src/__tests__/metadata-descriptions.test.ts
index 9833032362..cae8e6c590 100644
--- a/packages/feature-flags/src/__tests__/metadata-descriptions.test.ts
+++ b/packages/feature-flags/src/__tests__/metadata-descriptions.test.ts
@@ -23,6 +23,7 @@ describe('metadata descriptions', () => {
'composerSuggestions',
'integration_keys_enabled',
'code_mode_integrations_experiment_enabled',
+ 'integration_tool_auto_approvals_experiment_enabled',
'integration_tool_approvals_experiment_enabled',
'results_page_enabled',
])('classifies removed experiment metadata %s as legacy', (key) => {
@@ -40,6 +41,11 @@ describe('metadata descriptions', () => {
expect(
getBooleanMetadataDescriptorByKey('anonymous_analytics_enabled').kind,
).toBe('deployment-control');
+ expect(
+ getBooleanMetadataDescriptorByKey(
+ 'integration_tool_auto_approvals_nightly_experiment_enabled',
+ ).kind,
+ ).toBe('deployment-control');
});
it('enables deployment experiments only from explicit true metadata', () => {
@@ -47,6 +53,7 @@ describe('metadata descriptions', () => {
getDeploymentExperimentValues({
results_page_enabled: true,
private_sessions_experiment_enabled: true,
+ integration_tool_auto_approvals_experiment_enabled: true,
integration_keys_enabled: 'true',
}),
).toEqual({
@@ -56,6 +63,11 @@ describe('metadata descriptions', () => {
sessionTaskCommunicationTriage: false,
dizzy: false,
});
+ expect(
+ getDeploymentExperimentValues({
+ integration_tool_auto_approvals_nightly_experiment_enabled: true,
+ }).integrationToolAutoApprovals,
+ ).toBe(true);
});
it('requires an explicit supported audience for every experiment', () => {
@@ -71,6 +83,7 @@ describe('metadata descriptions', () => {
undefined,
);
expect(getDeploymentExperimentIdsForAudience('internal-nightly')).toEqual([
+ 'integrationToolAutoApprovals',
'dizzy',
]);
});
diff --git a/packages/feature-flags/src/config.ts b/packages/feature-flags/src/config.ts
index e7758a794b..c9574b0d9a 100644
--- a/packages/feature-flags/src/config.ts
+++ b/packages/feature-flags/src/config.ts
@@ -27,7 +27,7 @@ export const DEPLOYMENT_EXPERIMENT_AUDIENCE = {
privateSessions: 'customer-preview',
sessionTaskCommunicationTriage: 'customer-preview',
browserNotifications: 'customer-preview',
- integrationToolAutoApprovals: 'customer-preview',
+ integrationToolAutoApprovals: 'internal-nightly',
dizzy: 'internal-nightly',
} as const satisfies Record<
DeploymentExperimentId,
@@ -37,8 +37,9 @@ export const DEPLOYMENT_EXPERIMENT_AUDIENCE = {
export const DEPLOYMENT_EXPERIMENT_METADATA_KEYS = {
privateSessions: 'private_sessions_experiment_enabled',
browserNotifications: 'browser_notifications_experiment_enabled',
+ // A new key deliberately leaves the former customer-preview opt-ins dormant.
integrationToolAutoApprovals:
- 'integration_tool_auto_approvals_experiment_enabled',
+ 'integration_tool_auto_approvals_nightly_experiment_enabled',
sessionTaskCommunicationTriage:
'session_task_communication_triage_experiment_enabled',
dizzy: 'dizzy_experiment_enabled',
@@ -85,7 +86,7 @@ export const DEPLOYMENT_METADATA_BOOLEAN_CONFIG: Record<
kind: 'deployment-control',
group: null,
description:
- 'Show the Auto-approval decisions card in Settings → Agent Guidance for admins to turn on.',
+ 'Show the Auto-approval decisions card in Settings → Agent Guidance for admins on internal nightly deployments.',
},
[DEPLOYMENT_EXPERIMENT_METADATA_KEYS.sessionTaskCommunicationTriage]: {
kind: 'deployment-control',
From 44413017eb0aac7dcd9bb0cbe7ab9173eef6a42e Mon Sep 17 00:00:00 2001
From: "@daniel-lxs" <57051444+daniel-lxs@users.noreply.github.com>
Date: Thu, 24 Sep 2026 15:35:49 +0000
Subject: [PATCH 2/3] [Improve] Keep Auto approvals independent of shared
Nightly controls
---
.../settings/experiment-settings-controls.tsx | 4 +-
.../settings/pages/NightlyExperimentsPage.tsx | 7 +-
.../deployment-experiments/index.test.ts | 27 --------
.../integration-tool-auto-approvals.test.ts | 67 +++++++++++++++++++
.../integration-tool-auto-approvals.test.ts | 35 ++++++++++
.../__tests__/metadata-descriptions.test.ts | 12 ----
6 files changed, 110 insertions(+), 42 deletions(-)
create mode 100644 apps/web/src/trpc/commands/deployment-experiments/integration-tool-auto-approvals.test.ts
create mode 100644 packages/feature-flags/src/__tests__/integration-tool-auto-approvals.test.ts
diff --git a/apps/web/src/components/settings/experiment-settings-controls.tsx b/apps/web/src/components/settings/experiment-settings-controls.tsx
index b0fbe0f7e4..b545203c38 100644
--- a/apps/web/src/components/settings/experiment-settings-controls.tsx
+++ b/apps/web/src/components/settings/experiment-settings-controls.tsx
@@ -10,7 +10,6 @@ import { getDeploymentExperimentAudience } from '@roomote/feature-flags';
import { BrowserNotificationsExperimentalSetting } from './BrowserNotificationsExperimentalSetting';
import { DizzyExperimentalSetting } from './DizzyExperimentalSetting';
-import { IntegrationToolAutoApprovalsNightlySetting } from './IntegrationToolAutoApprovalsNightlySetting';
import { PrivateSessionsExperimentalSetting } from './PrivateSessionsExperimentalSetting';
import { SessionTaskCommunicationTriageExperimentalSetting } from './SessionTaskCommunicationTriageExperimentalSetting';
@@ -22,7 +21,8 @@ const DEPLOYMENT_EXPERIMENT_SETTINGS: Record<
sessionTaskCommunicationTriage:
SessionTaskCommunicationTriageExperimentalSetting,
browserNotifications: BrowserNotificationsExperimentalSetting,
- integrationToolAutoApprovals: IntegrationToolAutoApprovalsNightlySetting,
+ // The Auto tool approvals control was deliberately removed in PR #3180.
+ integrationToolAutoApprovals: null,
dizzy: DizzyExperimentalSetting,
};
diff --git a/apps/web/src/components/settings/pages/NightlyExperimentsPage.tsx b/apps/web/src/components/settings/pages/NightlyExperimentsPage.tsx
index 3295f84d45..bf2ab2b9c7 100644
--- a/apps/web/src/components/settings/pages/NightlyExperimentsPage.tsx
+++ b/apps/web/src/components/settings/pages/NightlyExperimentsPage.tsx
@@ -5,6 +5,7 @@ import {
ExperimentSettingsControls,
getExperimentSettingIds,
} from '@/components/settings/experiment-settings-controls';
+import { IntegrationToolAutoApprovalsNightlySetting } from '@/components/settings/IntegrationToolAutoApprovalsNightlySetting';
import { EmptyState, Moon, RetryableLoadError } from '@/components/system';
import { useDeploymentExperiments } from '@/hooks/useDeploymentExperiments';
@@ -38,7 +39,11 @@ export function NightlyExperimentsPage() {
description="There are no internal nightly controls available on this deployment."
/>
) : (
-
+ <>
+ {/* Auto's runtime-aware switch belongs only on this gated page. */}
+
+
+ >
)}
);
diff --git a/apps/web/src/trpc/commands/deployment-experiments/index.test.ts b/apps/web/src/trpc/commands/deployment-experiments/index.test.ts
index 2972a1dc54..a9c905481e 100644
--- a/apps/web/src/trpc/commands/deployment-experiments/index.test.ts
+++ b/apps/web/src/trpc/commands/deployment-experiments/index.test.ts
@@ -6,7 +6,6 @@ import {
getDeploymentExperimentsCommand,
getDizzyExperimentEnabledCommand,
getNightlyExperimentsCommand,
- getIntegrationToolAutoApprovalsEnabledCommand,
setDeploymentExperimentCommand,
setNightlyExperimentCommand,
} from './index';
@@ -45,7 +44,6 @@ describe('deployment experiment commands', () => {
expect(firstValues).toEqual(secondValues);
expect(firstValues).not.toHaveProperty('results');
- expect(firstValues).not.toHaveProperty('integrationToolAutoApprovals');
});
it('allows only admins to update shared values and leaves user metadata dormant', async () => {
@@ -70,12 +68,6 @@ describe('deployment experiment commands', () => {
enabled: true,
}),
).resolves.toMatchObject({ privateSessions: true });
- await expect(
- setDeploymentExperimentCommand(auth(admin.id, true), {
- id: 'integrationToolAutoApprovals',
- enabled: true,
- }),
- ).rejects.toThrow('Unauthorized');
await expect(
getDeploymentExperimentsCommand(auth(member.id, false)),
).resolves.toMatchObject({ privateSessions: true });
@@ -146,19 +138,9 @@ describe('deployment experiment commands', () => {
await expect(
getDizzyExperimentEnabledCommand(auth(member.id, false, false)),
).rejects.toThrow('Unauthorized');
- await expect(
- getIntegrationToolAutoApprovalsEnabledCommand(
- auth(member.id, false, false),
- ),
- ).rejects.toThrow('Unauthorized');
await expect(
getDizzyExperimentEnabledCommand(auth(member.id, false, true)),
).resolves.toBe(false);
- await expect(
- getIntegrationToolAutoApprovalsEnabledCommand(
- auth(member.id, false, true),
- ),
- ).resolves.toBe(false);
await setNightlyExperimentCommand(auth(admin.id, true, true), {
id: 'dizzy',
@@ -168,15 +150,6 @@ describe('deployment experiment commands', () => {
await expect(
getDizzyExperimentEnabledCommand(auth(member.id, false, true)),
).resolves.toBe(true);
- await setNightlyExperimentCommand(auth(admin.id, true, true), {
- id: 'integrationToolAutoApprovals',
- enabled: true,
- });
- await expect(
- getIntegrationToolAutoApprovalsEnabledCommand(
- auth(member.id, false, true),
- ),
- ).resolves.toBe(true);
await expect(
getNightlyExperimentsCommand(auth(member.id, false, true)),
).rejects.toThrow('Unauthorized');
diff --git a/apps/web/src/trpc/commands/deployment-experiments/integration-tool-auto-approvals.test.ts b/apps/web/src/trpc/commands/deployment-experiments/integration-tool-auto-approvals.test.ts
new file mode 100644
index 0000000000..dbd40795af
--- /dev/null
+++ b/apps/web/src/trpc/commands/deployment-experiments/integration-tool-auto-approvals.test.ts
@@ -0,0 +1,67 @@
+import type { UserAuthSuccess } from '@/types';
+
+import {
+ getDeploymentExperimentsCommand,
+ getIntegrationToolAutoApprovalsEnabledCommand,
+ getNightlyExperimentsCommand,
+ setDeploymentExperimentCommand,
+ setNightlyExperimentCommand,
+} from './index';
+
+function auth(
+ userId: string,
+ isAdmin: boolean,
+ nightlyExperimentsEnabled = false,
+): UserAuthSuccess {
+ return { userId, isAdmin, nightlyExperimentsEnabled } as UserAuthSuccess;
+}
+
+describe('Auto tool approvals nightly experiment', () => {
+ const admin = auth('auto-admin', true, true);
+ const member = auth('auto-member', false, true);
+
+ it('keeps Auto out of customer-preview reads and writes', async () => {
+ await expect(
+ getDeploymentExperimentsCommand(admin),
+ ).resolves.not.toHaveProperty('integrationToolAutoApprovals');
+ await expect(
+ setDeploymentExperimentCommand(admin, {
+ id: 'integrationToolAutoApprovals',
+ enabled: true,
+ }),
+ ).rejects.toThrow('Unauthorized');
+ });
+
+ it('defaults off in Nightly and exposes the runtime value to opted-in members', async () => {
+ await setNightlyExperimentCommand(admin, {
+ id: 'integrationToolAutoApprovals',
+ enabled: false,
+ });
+ await expect(getNightlyExperimentsCommand(admin)).resolves.toMatchObject({
+ integrationToolAutoApprovals: false,
+ });
+ await expect(
+ getIntegrationToolAutoApprovalsEnabledCommand(member),
+ ).resolves.toBe(false);
+ await expect(
+ getIntegrationToolAutoApprovalsEnabledCommand(
+ auth('customer-member', false, false),
+ ),
+ ).rejects.toThrow('Unauthorized');
+
+ try {
+ await setNightlyExperimentCommand(admin, {
+ id: 'integrationToolAutoApprovals',
+ enabled: true,
+ });
+ await expect(
+ getIntegrationToolAutoApprovalsEnabledCommand(member),
+ ).resolves.toBe(true);
+ } finally {
+ await setNightlyExperimentCommand(admin, {
+ id: 'integrationToolAutoApprovals',
+ enabled: false,
+ });
+ }
+ });
+});
diff --git a/packages/feature-flags/src/__tests__/integration-tool-auto-approvals.test.ts b/packages/feature-flags/src/__tests__/integration-tool-auto-approvals.test.ts
new file mode 100644
index 0000000000..5b795d44b1
--- /dev/null
+++ b/packages/feature-flags/src/__tests__/integration-tool-auto-approvals.test.ts
@@ -0,0 +1,35 @@
+import {
+ getBooleanMetadataDescriptorByKey,
+ getDeploymentExperimentAudience,
+ getDeploymentExperimentIdsForAudience,
+ getDeploymentExperimentValues,
+} from '../index';
+
+describe('Auto tool approvals nightly experiment metadata', () => {
+ it('ignores legacy customer-preview opt-ins and defaults the new nightly key off', () => {
+ expect(
+ getDeploymentExperimentValues({
+ integration_tool_auto_approvals_experiment_enabled: true,
+ }).integrationToolAutoApprovals,
+ ).toBe(false);
+ expect(
+ getDeploymentExperimentValues({
+ integration_tool_auto_approvals_nightly_experiment_enabled: true,
+ }).integrationToolAutoApprovals,
+ ).toBe(true);
+ expect(
+ getBooleanMetadataDescriptorByKey(
+ 'integration_tool_auto_approvals_experiment_enabled',
+ ).kind,
+ ).toBe('legacy');
+ });
+
+ it('classifies Auto tool approvals as an internal-nightly experiment', () => {
+ expect(
+ getDeploymentExperimentAudience('integrationToolAutoApprovals'),
+ ).toBe('internal-nightly');
+ expect(getDeploymentExperimentIdsForAudience('internal-nightly')).toContain(
+ 'integrationToolAutoApprovals',
+ );
+ });
+});
diff --git a/packages/feature-flags/src/__tests__/metadata-descriptions.test.ts b/packages/feature-flags/src/__tests__/metadata-descriptions.test.ts
index cae8e6c590..bd2cfe7844 100644
--- a/packages/feature-flags/src/__tests__/metadata-descriptions.test.ts
+++ b/packages/feature-flags/src/__tests__/metadata-descriptions.test.ts
@@ -23,7 +23,6 @@ describe('metadata descriptions', () => {
'composerSuggestions',
'integration_keys_enabled',
'code_mode_integrations_experiment_enabled',
- 'integration_tool_auto_approvals_experiment_enabled',
'integration_tool_approvals_experiment_enabled',
'results_page_enabled',
])('classifies removed experiment metadata %s as legacy', (key) => {
@@ -41,11 +40,6 @@ describe('metadata descriptions', () => {
expect(
getBooleanMetadataDescriptorByKey('anonymous_analytics_enabled').kind,
).toBe('deployment-control');
- expect(
- getBooleanMetadataDescriptorByKey(
- 'integration_tool_auto_approvals_nightly_experiment_enabled',
- ).kind,
- ).toBe('deployment-control');
});
it('enables deployment experiments only from explicit true metadata', () => {
@@ -53,7 +47,6 @@ describe('metadata descriptions', () => {
getDeploymentExperimentValues({
results_page_enabled: true,
private_sessions_experiment_enabled: true,
- integration_tool_auto_approvals_experiment_enabled: true,
integration_keys_enabled: 'true',
}),
).toEqual({
@@ -63,11 +56,6 @@ describe('metadata descriptions', () => {
sessionTaskCommunicationTriage: false,
dizzy: false,
});
- expect(
- getDeploymentExperimentValues({
- integration_tool_auto_approvals_nightly_experiment_enabled: true,
- }).integrationToolAutoApprovals,
- ).toBe(true);
});
it('requires an explicit supported audience for every experiment', () => {
From a6132ec527cc0ba7cfa9c0951f472d7f48103f51 Mon Sep 17 00:00:00 2001
From: "@daniel-lxs" <57051444+daniel-lxs@users.noreply.github.com>
Date: Thu, 24 Sep 2026 19:27:07 +0000
Subject: [PATCH 3/3] [Fix] Defer nightly environment loading for Auto state
---
.../src/server/integration-tool-auto-evaluation.ts | 10 +++++++---
1 file changed, 7 insertions(+), 3 deletions(-)
diff --git a/packages/cloud-agents/src/server/integration-tool-auto-evaluation.ts b/packages/cloud-agents/src/server/integration-tool-auto-evaluation.ts
index b140f361b0..66464a6e52 100644
--- a/packages/cloud-agents/src/server/integration-tool-auto-evaluation.ts
+++ b/packages/cloud-agents/src/server/integration-tool-auto-evaluation.ts
@@ -3,7 +3,6 @@ import {
isDeploymentExperimentEnabled,
recordIntegrationToolShadowEvaluation,
} from '@roomote/db/server';
-import { Env, isEnvFlagEnabled } from '@roomote/env';
import type {
IntegrationToolAutoEvaluation,
IntegrationToolAutoSettings,
@@ -368,11 +367,16 @@ export type IntegrationToolAutoState = {
};
export async function resolveIntegrationToolAutoState(): Promise {
- const [enabled, settings] = await Promise.all([
+ // Some callers import this module only for the Jev requirements; defer Env
+ // initialization until the Auto state is actually resolved.
+ const [enabled, settings, nightlyExperimentsEnabled] = await Promise.all([
isDeploymentExperimentEnabled('integrationToolAutoApprovals'),
getIntegrationToolAutoSettings(),
+ import('@roomote/env').then(({ Env, isEnvFlagEnabled }) =>
+ isEnvFlagEnabled(Env.R_NIGHTLY_EXPERIMENTS_ENABLED),
+ ),
]);
- if (!isEnvFlagEnabled(Env.R_NIGHTLY_EXPERIMENTS_ENABLED) || !enabled) {
+ if (!nightlyExperimentsEnabled || !enabled) {
return { mode: 'off', settings, model: null };
}