From 1eb7c357262d3abb28dd2bc56dce097c707b2fdb Mon Sep 17 00:00:00 2001 From: yannickmonney Date: Sat, 11 Apr 2026 02:44:54 +0200 Subject: [PATCH 1/5] feat(platform): wire feature flags resolver and add governance tab Wire the existing resolveFeatureFlags() into the chat pipeline so governance feature flag rules are enforced before agent generation. Add validation for feature_flags config in upsertPolicy, pass maxContextTokens through to context builder, and build a FeatureFlagsEditor admin UI on the governance settings page. - Call resolveFeatureFlags() in startAgentChat after budget check - Enforce webSearch disable (remove web tool, set mode off) - Block file uploads with assistant message when fileUpload is false - Forward maxContextTokens through scheduler to generate_response - Override maxHistoryTokens in all buildStructuredContext calls - Add featureFlagsConfigSchema validation in upsertPolicy mutation - Build FeatureFlagsEditor with scope/target rules table and dialog - Add Feature Controls tab to governance page with i18n tab labels - Add translation keys to en.json and de.json - Add unit tests for resolveFeatureFlags priority chain - Add component tests with accessibility checks - Add Storybook story for FeatureFlagsEditor Closes #1360 --- .../feature-flags-editor.stories.tsx | 26 + .../components/feature-flags-editor.test.tsx | 158 +++++ .../components/feature-flags-editor.tsx | 565 ++++++++++++++++++ .../dashboard/$id/settings/governance.tsx | 22 +- .../__tests__/feature_enforcement.test.ts | 320 ++++++++++ .../platform/convex/governance/mutations.ts | 10 + .../__tests__/start_agent_chat.test.ts | 16 + .../convex/lib/agent_chat/internal_actions.ts | 3 + .../convex/lib/agent_chat/start_agent_chat.ts | 60 +- .../platform/convex/lib/agent_chat/types.ts | 2 + .../lib/agent_response/generate_response.ts | 7 +- .../convex/lib/agent_response/types.ts | 2 + services/platform/messages/de.json | 25 + services/platform/messages/en.json | 21 +- 14 files changed, 1223 insertions(+), 14 deletions(-) create mode 100644 services/platform/app/features/settings/governance/components/feature-flags-editor.stories.tsx create mode 100644 services/platform/app/features/settings/governance/components/feature-flags-editor.test.tsx create mode 100644 services/platform/app/features/settings/governance/components/feature-flags-editor.tsx create mode 100644 services/platform/convex/governance/__tests__/feature_enforcement.test.ts diff --git a/services/platform/app/features/settings/governance/components/feature-flags-editor.stories.tsx b/services/platform/app/features/settings/governance/components/feature-flags-editor.stories.tsx new file mode 100644 index 0000000000..c1b7cdee0d --- /dev/null +++ b/services/platform/app/features/settings/governance/components/feature-flags-editor.stories.tsx @@ -0,0 +1,26 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import { FeatureFlagsEditor } from './feature-flags-editor'; + +const meta: Meta = { + title: 'Settings/Governance/FeatureFlagsEditor', + component: FeatureFlagsEditor, + tags: ['autodocs'], + parameters: { + layout: 'padded', + }, + args: { + organizationId: 'org_test', + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const WithOrganization: Story = { + args: { + organizationId: 'org_demo', + }, +}; diff --git a/services/platform/app/features/settings/governance/components/feature-flags-editor.test.tsx b/services/platform/app/features/settings/governance/components/feature-flags-editor.test.tsx new file mode 100644 index 0000000000..7b05a94db4 --- /dev/null +++ b/services/platform/app/features/settings/governance/components/feature-flags-editor.test.tsx @@ -0,0 +1,158 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +import { checkAccessibility } from '@/test/utils/a11y'; +import { render, screen } from '@/test/utils/render'; + +vi.mock('@/app/hooks/use-organization-id', () => ({ + useOrganizationId: () => 'org_test', +})); + +vi.mock('@/app/hooks/use-ability', () => ({ + useAbility: () => ({ + can: () => true, + cannot: () => false, + }), +})); + +vi.mock('@/app/hooks/use-toast', () => ({ + useToast: () => ({ + toast: vi.fn(), + }), +})); + +vi.mock('../hooks/queries', () => ({ + useGovernancePolicy: vi.fn().mockReturnValue({ + data: null, + isLoading: false, + }), +})); + +vi.mock('../hooks/mutations', () => ({ + useUpsertGovernancePolicy: () => ({ + mutateAsync: vi.fn(), + isPending: false, + }), +})); + +vi.mock('@/app/features/settings/organization/hooks/queries', () => ({ + useMembers: () => ({ + members: [ + { userId: 'user_1', displayName: 'Alice', email: 'alice@test.com' }, + { userId: 'user_2', displayName: 'Bob', email: 'bob@test.com' }, + ], + }), +})); + +vi.mock('@/app/features/settings/teams/hooks/queries', () => ({ + useOrgTeams: () => ({ + teams: [ + { id: 'team_1', name: 'Engineering' }, + { id: 'team_2', name: 'Marketing' }, + ], + }), +})); + +const { useGovernancePolicy } = await import('../hooks/queries'); +const mockedUseGovernancePolicy = vi.mocked(useGovernancePolicy); + +const { FeatureFlagsEditor } = await import('./feature-flags-editor'); + +describe('FeatureFlagsEditor', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockedUseGovernancePolicy.mockReturnValue({ + data: null, + isLoading: false, + } as never); + }); + + it('renders empty state when no rules exist', () => { + render(); + + expect( + screen.getByText(/no feature control rules configured/i), + ).toBeInTheDocument(); + }); + + it('renders rules table when rules exist', () => { + mockedUseGovernancePolicy.mockReturnValue({ + data: { + config: { + enabled: true, + rules: [ + { + scope: 'default', + webSearch: true, + codeExecution: false, + fileUpload: true, + }, + ], + }, + }, + isLoading: false, + } as never); + + render(); + + expect(screen.getByText('default')).toBeInTheDocument(); + expect(screen.getByText('\u2718')).toBeInTheDocument(); + }); + + it('renders add rule button', () => { + render(); + + expect( + screen.getByRole('button', { name: /add rule/i }), + ).toBeInTheDocument(); + }); + + it('renders enabled toggle', () => { + render(); + + expect(screen.getByRole('switch')).toBeInTheDocument(); + }); + + it('returns null while loading', () => { + mockedUseGovernancePolicy.mockReturnValue({ + data: null, + isLoading: true, + } as never); + + const { container } = render(); + + expect(container.innerHTML).toBe(''); + }); + + describe('accessibility', () => { + it('passes axe audit with empty state', async () => { + const { container } = render( + , + ); + await checkAccessibility(container); + }); + + it('passes axe audit with rules table', async () => { + mockedUseGovernancePolicy.mockReturnValue({ + data: { + config: { + enabled: true, + rules: [ + { + scope: 'default', + webSearch: true, + codeExecution: true, + fileUpload: true, + }, + ], + }, + }, + isLoading: false, + } as never); + + const { container } = render( + , + ); + await checkAccessibility(container); + }); + }); +}); diff --git a/services/platform/app/features/settings/governance/components/feature-flags-editor.tsx b/services/platform/app/features/settings/governance/components/feature-flags-editor.tsx new file mode 100644 index 0000000000..8d6d0c015b --- /dev/null +++ b/services/platform/app/features/settings/governance/components/feature-flags-editor.tsx @@ -0,0 +1,565 @@ +'use client'; + +import { Pencil, Plus, Trash2 } from 'lucide-react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; + +import { FormDialog } from '@/app/components/ui/dialog/form-dialog'; +import { Input } from '@/app/components/ui/forms/input'; +import { SearchableSelect } from '@/app/components/ui/forms/searchable-select'; +import { Select } from '@/app/components/ui/forms/select'; +import { Switch } from '@/app/components/ui/forms/switch'; +import { HStack, Stack } from '@/app/components/ui/layout/layout'; +import { PageSection } from '@/app/components/ui/layout/page-section'; +import { Button } from '@/app/components/ui/primitives/button'; +import { Text } from '@/app/components/ui/typography/text'; +import { useMembers } from '@/app/features/settings/organization/hooks/queries'; +import { useOrgTeams } from '@/app/features/settings/teams/hooks/queries'; +import { useAbility } from '@/app/hooks/use-ability'; +import { useToast } from '@/app/hooks/use-toast'; +import { useT } from '@/lib/i18n/client'; +import { + featureFlagsConfigSchema, + type FeatureFlagsConfig, + type FeatureFlagRule, +} from '@/lib/shared/schemas/governance'; +import { isRecord } from '@/lib/utils/type-guards'; + +import { useUpsertGovernancePolicy } from '../hooks/mutations'; +import { useGovernancePolicy } from '../hooks/queries'; + +interface FeatureFlagsEditorProps { + organizationId: string; +} + +const SCOPE_OPTIONS = [ + { value: 'default', label: 'Default' }, + { value: 'user', label: 'User' }, + { value: 'team', label: 'Team' }, + { value: 'role', label: 'Role' }, +]; + +function isScopeValue(v: string): v is FeatureFlagRule['scope'] { + return SCOPE_OPTIONS.some((o) => o.value === v); +} + +const ROLE_OPTIONS = [ + { value: 'admin', label: 'Admin' }, + { value: 'developer', label: 'Developer' }, + { value: 'editor', label: 'Editor' }, + { value: 'member', label: 'Member' }, +]; + +function emptyRule(): FeatureFlagRule { + return { + scope: 'default', + webSearch: true, + codeExecution: true, + fileUpload: true, + }; +} + +function parseFeatureFlagsConfig(policy: unknown): FeatureFlagsConfig { + const config = isRecord(policy) ? policy : {}; + const result = featureFlagsConfigSchema.safeParse(config); + if (result.success) { + return result.data; + } + return { enabled: false, rules: [] }; +} + +interface RuleDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + rule: FeatureFlagRule; + onSave: (rule: FeatureFlagRule) => void; + title: string; + cannotManage: boolean; + memberOptions: { value: string; label: string; description?: string }[]; + teamOptions: { value: string; label: string }[]; +} + +function RuleDialog({ + open, + onOpenChange, + rule: initialRule, + onSave, + title, + cannotManage, + memberOptions, + teamOptions, +}: RuleDialogProps) { + const { t } = useT('governance'); + const [draft, setDraft] = useState(initialRule); + + useEffect(() => { + if (open) { + setDraft(initialRule); + } + }, [open, initialRule]); + + const updateDraft = useCallback((patch: Partial) => { + setDraft((prev) => { + const updated = { ...prev, ...patch }; + if (patch.scope === 'default') { + delete updated.scopeId; + } + return updated; + }); + }, []); + + const handleSubmit = useCallback( + (e: React.FormEvent) => { + e.preventDefault(); + onSave(draft); + onOpenChange(false); + }, + [draft, onSave, onOpenChange], + ); + + return ( + + + +
+ updateDraft({ scopeId: value })} + disabled={cannotManage} + size="sm" + /> +
+ )} + + {draft.scope === 'user' && ( +
+ User + updateDraft({ scopeId: value })} + options={memberOptions} + searchPlaceholder="Search users..." + emptyText="No users found" + aria-label="Select user" + trigger={ + + } + /> +
+ )} + + {draft.scope === 'team' && ( +
+ Team + updateDraft({ scopeId: value })} + options={teamOptions} + searchPlaceholder="Search teams..." + emptyText="No teams found" + aria-label="Select team" + trigger={ + + } + /> +
+ )} +
+ + + updateDraft({ webSearch: checked })} + disabled={cannotManage} + /> + + updateDraft({ codeExecution: checked }) + } + disabled={cannotManage} + /> + updateDraft({ fileUpload: checked })} + disabled={cannotManage} + /> + +
+ + updateDraft({ + maxContextTokens: e.target.value + ? Number(e.target.value) + : undefined, + }) + } + disabled={cannotManage} + size="sm" + placeholder="e.g. 50000" + min={0} + /> + + {t('featureFlags.maxContextTokensHint')} + +
+
+
+
+ ); +} + +export function FeatureFlagsEditor({ + organizationId, +}: FeatureFlagsEditorProps) { + const { t } = useT('governance'); + const { toast } = useToast(); + const ability = useAbility(); + + const { data: policy, isLoading } = useGovernancePolicy( + organizationId, + 'feature_flags', + ); + const upsertMutation = useUpsertGovernancePolicy(); + const { members } = useMembers(organizationId); + const { teams } = useOrgTeams(); + + const memberOptions = useMemo( + () => + (members ?? []).map((m) => ({ + value: m.userId, + label: m.displayName || m.email || m.userId, + description: m.email && m.displayName ? m.email : undefined, + })), + [members], + ); + + const teamOptions = useMemo( + () => + (teams ?? []).map((team) => ({ + value: team.id, + label: team.name || team.id, + })), + [teams], + ); + + const savedConfig = useMemo( + () => parseFeatureFlagsConfig(policy?.config), + [policy], + ); + + const [enabled, setEnabled] = useState(false); + const [rules, setRules] = useState([]); + + const [dialogOpen, setDialogOpen] = useState(false); + const [editingIndex, setEditingIndex] = useState(null); + const [dialogRule, setDialogRule] = useState(emptyRule()); + + useEffect(() => { + setEnabled(savedConfig.enabled); + setRules(savedConfig.rules); + }, [savedConfig]); + + const cannotManage = ability.cannot('write', 'orgSettings'); + + const saveConfig = useCallback( + async (configToSave: { enabled: boolean; rules: FeatureFlagRule[] }) => { + try { + await upsertMutation.mutateAsync({ + organizationId, + policyType: 'feature_flags', + config: configToSave, + }); + toast({ title: t('featureFlags.saved'), variant: 'success' }); + } catch (error: unknown) { + const message = + error instanceof Error ? error.message : t('featureFlags.saveFailed'); + toast({ title: message, variant: 'destructive' }); + } + }, + [organizationId, upsertMutation, toast, t], + ); + + const handleToggleEnabled = useCallback( + (checked: boolean) => { + setEnabled(checked); + void saveConfig({ enabled: checked, rules }); + }, + [saveConfig, rules], + ); + + const removeRule = useCallback( + (index: number) => { + const newRules = rules.filter((_, i) => i !== index); + setRules(newRules); + void saveConfig({ enabled, rules: newRules }); + }, + [rules, enabled, saveConfig], + ); + + const openAddDialog = useCallback(() => { + setEditingIndex(null); + setDialogRule(emptyRule()); + setDialogOpen(true); + }, []); + + const openEditDialog = useCallback( + (index: number) => { + setEditingIndex(index); + setDialogRule(rules[index]); + setDialogOpen(true); + }, + [rules], + ); + + const handleDialogSave = useCallback( + (rule: FeatureFlagRule) => { + let newRules: FeatureFlagRule[]; + if (editingIndex === null) { + newRules = [...rules, rule]; + } else { + newRules = rules.map((r, i) => (i === editingIndex ? rule : r)); + } + setRules(newRules); + void saveConfig({ enabled, rules: newRules }); + }, + [editingIndex, rules, enabled, saveConfig], + ); + + const resolveTarget = useCallback( + (rule: FeatureFlagRule): string => { + switch (rule.scope) { + case 'user': { + if (!rule.scopeId) return '\u2014'; + return ( + memberOptions.find((o) => o.value === rule.scopeId)?.label ?? + rule.scopeId + ); + } + case 'team': { + if (!rule.scopeId) return '\u2014'; + return ( + teamOptions.find((o) => o.value === rule.scopeId)?.label ?? + rule.scopeId + ); + } + case 'role': + return rule.scopeId ?? '\u2014'; + case 'default': + return t('featureFlags.allUsers'); + default: + return '\u2014'; + } + }, + [memberOptions, teamOptions, t], + ); + + if (isLoading) { + return null; + } + + return ( + + } + > + + + {rules.length > 0 ? ( +
+ + + + + + + + + + + + + + + {rules.map((rule, index) => ( + + + + + + + + + + ))} + +
{t('featureFlags.title')}
+ {t('featureFlags.scope')} + + {t('featureFlags.target')} + + {t('featureFlags.webSearch')} + + {t('featureFlags.codeExecution')} + + {t('featureFlags.fileUpload')} + + {t('featureFlags.maxContextTokens')} + + Actions +
{rule.scope}{resolveTarget(rule)} + {rule.webSearch === false ? '\u2718' : '\u2714'} + + {rule.codeExecution === false ? '\u2718' : '\u2714'} + + {rule.fileUpload === false ? '\u2718' : '\u2714'} + + {rule.maxContextTokens != null + ? rule.maxContextTokens.toLocaleString() + : '\u2014'} + + + + + +
+
+ ) : ( + + {t('featureFlags.noRules')} + + )} + + +
+
+ + +
+ ); +} diff --git a/services/platform/app/routes/dashboard/$id/settings/governance.tsx b/services/platform/app/routes/dashboard/$id/settings/governance.tsx index a58f3a0a30..7cb91859d4 100644 --- a/services/platform/app/routes/dashboard/$id/settings/governance.tsx +++ b/services/platform/app/routes/dashboard/$id/settings/governance.tsx @@ -5,6 +5,7 @@ import { z } from 'zod'; import { AccessDenied } from '@/app/components/layout/access-denied'; import { Tabs } from '@/app/components/ui/navigation/tabs'; import { BudgetEditor } from '@/app/features/settings/governance/components/budget-editor'; +import { FeatureFlagsEditor } from '@/app/features/settings/governance/components/feature-flags-editor'; import { PiiConfig } from '@/app/features/settings/governance/components/pii-config'; import { RetentionEditor } from '@/app/features/settings/governance/components/retention-editor'; import { SystemPromptEditor } from '@/app/features/settings/governance/components/system-prompt-editor'; @@ -29,8 +30,8 @@ function GovernanceSettingsPage() { const { id: organizationId } = Route.useParams(); const search = Route.useSearch(); const navigate = useNavigate(); - const { t } = useT('accessDenied'); - const { t: tGov } = useT('governance'); + const { t: tAccessDenied } = useT('accessDenied'); + const { t } = useT('governance'); const ability = useAbility(); const abilityLoading = useAbilityLoading(); @@ -49,12 +50,12 @@ function GovernanceSettingsPage() { () => [ { value: 'system-prompt', - label: tGov('tabs.systemPrompt'), + label: t('tabs.systemPrompt'), content: , }, { value: 'budgets', - label: tGov('tabs.budgets'), + label: t('tabs.budgets'), content: , }, { @@ -62,18 +63,23 @@ function GovernanceSettingsPage() { label: 'Retention', content: , }, + { + value: 'feature-controls', + label: t('tabs.featureControls'), + content: , + }, { value: 'usage', - label: tGov('tabs.usage'), + label: t('tabs.usage'), content: , }, { value: 'pii', - label: tGov('tabs.pii'), + label: t('tabs.pii'), content: , }, ], - [organizationId, tGov], + [organizationId, t], ); if (abilityLoading) { @@ -81,7 +87,7 @@ function GovernanceSettingsPage() { } if (ability.cannot('read', 'orgSettings')) { - return ; + return ; } return ( diff --git a/services/platform/convex/governance/__tests__/feature_enforcement.test.ts b/services/platform/convex/governance/__tests__/feature_enforcement.test.ts new file mode 100644 index 0000000000..768e77605a --- /dev/null +++ b/services/platform/convex/governance/__tests__/feature_enforcement.test.ts @@ -0,0 +1,320 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +import type { + FeatureFlagsConfig, + FeatureFlagRule, +} from '../../../lib/shared/schemas/governance'; + +vi.mock('../helpers', () => ({ + readPolicyConfig: vi.fn(), +})); + +const { readPolicyConfig } = await import('../helpers'); +const mockedReadPolicyConfig = vi.mocked(readPolicyConfig); + +const { resolveFeatureFlags } = await import('../feature_enforcement'); + +function createMockCtx() { + return { + db: { + query: vi.fn(), + }, + auth: { + getUserIdentity: vi.fn(), + }, + } as never; +} + +describe('resolveFeatureFlags', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns all-enabled defaults when no policy exists', async () => { + mockedReadPolicyConfig.mockResolvedValue(null); + const ctx = createMockCtx(); + + const result = await resolveFeatureFlags( + ctx, + 'org_1', + 'user_1', + [], + 'member', + ); + + expect(result).toEqual({ + webSearch: true, + codeExecution: true, + fileUpload: true, + }); + }); + + it('returns all-enabled defaults when policy is disabled', async () => { + const config: FeatureFlagsConfig = { + enabled: false, + rules: [{ scope: 'default', webSearch: false, codeExecution: false }], + }; + mockedReadPolicyConfig.mockResolvedValue(config); + const ctx = createMockCtx(); + + const result = await resolveFeatureFlags( + ctx, + 'org_1', + 'user_1', + [], + 'member', + ); + + expect(result).toEqual({ + webSearch: true, + codeExecution: true, + fileUpload: true, + }); + }); + + it('returns all-enabled defaults when rules array is empty', async () => { + const config: FeatureFlagsConfig = { + enabled: true, + rules: [], + }; + mockedReadPolicyConfig.mockResolvedValue(config); + const ctx = createMockCtx(); + + const result = await resolveFeatureFlags( + ctx, + 'org_1', + 'user_1', + [], + 'member', + ); + + expect(result).toEqual({ + webSearch: true, + codeExecution: true, + fileUpload: true, + }); + }); + + it('applies default-scope rule when no more specific rule matches', async () => { + const config: FeatureFlagsConfig = { + enabled: true, + rules: [ + { + scope: 'default', + webSearch: false, + codeExecution: true, + fileUpload: true, + }, + ], + }; + mockedReadPolicyConfig.mockResolvedValue(config); + const ctx = createMockCtx(); + + const result = await resolveFeatureFlags( + ctx, + 'org_1', + 'user_1', + [], + 'member', + ); + + expect(result.webSearch).toBe(false); + expect(result.codeExecution).toBe(true); + expect(result.fileUpload).toBe(true); + }); + + it('user-scope rule overrides team-scope rule', async () => { + const rules: FeatureFlagRule[] = [ + { scope: 'team', scopeId: 'team_1', webSearch: false }, + { scope: 'user', scopeId: 'user_1', webSearch: true }, + ]; + const config: FeatureFlagsConfig = { enabled: true, rules }; + mockedReadPolicyConfig.mockResolvedValue(config); + const ctx = createMockCtx(); + + const result = await resolveFeatureFlags( + ctx, + 'org_1', + 'user_1', + ['team_1'], + 'member', + ); + + expect(result.webSearch).toBe(true); + }); + + it('team-scope rule overrides role-scope rule', async () => { + const rules: FeatureFlagRule[] = [ + { scope: 'role', scopeId: 'member', webSearch: true }, + { scope: 'team', scopeId: 'team_1', webSearch: false }, + ]; + const config: FeatureFlagsConfig = { enabled: true, rules }; + mockedReadPolicyConfig.mockResolvedValue(config); + const ctx = createMockCtx(); + + const result = await resolveFeatureFlags( + ctx, + 'org_1', + 'user_2', + ['team_1'], + 'member', + ); + + expect(result.webSearch).toBe(false); + }); + + it('role-scope rule overrides default rule', async () => { + const rules: FeatureFlagRule[] = [ + { scope: 'default', webSearch: true }, + { scope: 'role', scopeId: 'member', webSearch: false }, + ]; + const config: FeatureFlagsConfig = { enabled: true, rules }; + mockedReadPolicyConfig.mockResolvedValue(config); + const ctx = createMockCtx(); + + const result = await resolveFeatureFlags( + ctx, + 'org_1', + 'user_1', + [], + 'member', + ); + + expect(result.webSearch).toBe(false); + }); + + it('partial rule preserves defaults for unset fields', async () => { + const rules: FeatureFlagRule[] = [ + { scope: 'user', scopeId: 'user_1', webSearch: false }, + ]; + const config: FeatureFlagsConfig = { enabled: true, rules }; + mockedReadPolicyConfig.mockResolvedValue(config); + const ctx = createMockCtx(); + + const result = await resolveFeatureFlags( + ctx, + 'org_1', + 'user_1', + [], + 'member', + ); + + expect(result.webSearch).toBe(false); + expect(result.codeExecution).toBe(true); + expect(result.fileUpload).toBe(true); + expect(result.maxContextTokens).toBeUndefined(); + }); + + it('passes through maxContextTokens from the matching rule', async () => { + const rules: FeatureFlagRule[] = [ + { scope: 'user', scopeId: 'user_1', maxContextTokens: 50000 }, + ]; + const config: FeatureFlagsConfig = { enabled: true, rules }; + mockedReadPolicyConfig.mockResolvedValue(config); + const ctx = createMockCtx(); + + const result = await resolveFeatureFlags( + ctx, + 'org_1', + 'user_1', + [], + 'member', + ); + + expect(result.maxContextTokens).toBe(50000); + }); + + it('returns defaults when no rules match the user', async () => { + const rules: FeatureFlagRule[] = [ + { scope: 'user', scopeId: 'user_other', webSearch: false }, + { scope: 'team', scopeId: 'team_other', codeExecution: false }, + ]; + const config: FeatureFlagsConfig = { enabled: true, rules }; + mockedReadPolicyConfig.mockResolvedValue(config); + const ctx = createMockCtx(); + + const result = await resolveFeatureFlags( + ctx, + 'org_1', + 'user_1', + [], + 'member', + ); + + expect(result).toEqual({ + webSearch: true, + codeExecution: true, + fileUpload: true, + }); + }); + + it('handles all features disabled for a user', async () => { + const rules: FeatureFlagRule[] = [ + { + scope: 'user', + scopeId: 'user_1', + webSearch: false, + codeExecution: false, + fileUpload: false, + maxContextTokens: 10000, + }, + ]; + const config: FeatureFlagsConfig = { enabled: true, rules }; + mockedReadPolicyConfig.mockResolvedValue(config); + const ctx = createMockCtx(); + + const result = await resolveFeatureFlags( + ctx, + 'org_1', + 'user_1', + [], + 'member', + ); + + expect(result).toEqual({ + webSearch: false, + codeExecution: false, + fileUpload: false, + maxContextTokens: 10000, + }); + }); + + it('matches team rule when user belongs to multiple teams', async () => { + const rules: FeatureFlagRule[] = [ + { scope: 'team', scopeId: 'team_2', webSearch: false }, + ]; + const config: FeatureFlagsConfig = { enabled: true, rules }; + mockedReadPolicyConfig.mockResolvedValue(config); + const ctx = createMockCtx(); + + const result = await resolveFeatureFlags( + ctx, + 'org_1', + 'user_1', + ['team_1', 'team_2', 'team_3'], + 'member', + ); + + expect(result.webSearch).toBe(false); + }); + + it('skips role rule when role is not provided', async () => { + const rules: FeatureFlagRule[] = [ + { scope: 'role', scopeId: 'admin', webSearch: false }, + { scope: 'default', webSearch: true }, + ]; + const config: FeatureFlagsConfig = { enabled: true, rules }; + mockedReadPolicyConfig.mockResolvedValue(config); + const ctx = createMockCtx(); + + const result = await resolveFeatureFlags( + ctx, + 'org_1', + 'user_1', + [], + undefined, + ); + + expect(result.webSearch).toBe(true); + }); +}); diff --git a/services/platform/convex/governance/mutations.ts b/services/platform/convex/governance/mutations.ts index ed8621926d..4b23577eba 100644 --- a/services/platform/convex/governance/mutations.ts +++ b/services/platform/convex/governance/mutations.ts @@ -2,6 +2,7 @@ import { v } from 'convex/values'; import { budgetConfigSchema, + featureFlagsConfigSchema, piiConfigSchema, } from '../../lib/shared/schemas/governance'; import { mutation } from '../_generated/server'; @@ -51,6 +52,15 @@ export const upsertPolicy = mutation({ } } + if (args.policyType === 'feature_flags') { + const parsed = featureFlagsConfigSchema.safeParse(args.config); + if (!parsed.success) { + throw new Error( + `Invalid feature flags configuration: ${parsed.error.message}`, + ); + } + } + const existing = await ctx.db .query('governancePolicies') .withIndex('by_org_policyType', (q) => diff --git a/services/platform/convex/lib/agent_chat/__tests__/start_agent_chat.test.ts b/services/platform/convex/lib/agent_chat/__tests__/start_agent_chat.test.ts index 878cf69577..e8cfae7780 100644 --- a/services/platform/convex/lib/agent_chat/__tests__/start_agent_chat.test.ts +++ b/services/platform/convex/lib/agent_chat/__tests__/start_agent_chat.test.ts @@ -37,6 +37,22 @@ vi.mock('../../debug_log', () => ({ createDebugLog: () => () => {}, })); +vi.mock('../../../governance/budget_enforcement', () => ({ + checkBudget: vi.fn().mockResolvedValue({ allowed: true }), +})); + +vi.mock('../../../governance/feature_enforcement', () => ({ + resolveFeatureFlags: vi.fn().mockResolvedValue({ + webSearch: true, + codeExecution: true, + fileUpload: true, + }), +})); + +vi.mock('../../get_user_teams', () => ({ + getUserTeamIds: vi.fn().mockResolvedValue([]), +})); + vi.mock('../../message_deduplication', () => ({ computeDeduplicationState: () => ({ lastUserMessage: null, diff --git a/services/platform/convex/lib/agent_chat/internal_actions.ts b/services/platform/convex/lib/agent_chat/internal_actions.ts index 0c4289da9c..734e557f43 100644 --- a/services/platform/convex/lib/agent_chat/internal_actions.ts +++ b/services/platform/convex/lib/agent_chat/internal_actions.ts @@ -141,6 +141,7 @@ export const runAgentGeneration = internalAction({ maxSteps: v.optional(v.number()), deadlineMs: v.optional(v.number()), generationParams: v.optional(v.any()), + maxContextTokens: v.optional(v.number()), }, handler: async (ctx, args) => { const actionStartTime = Date.now(); @@ -171,6 +172,7 @@ export const runAgentGeneration = internalAction({ maxSteps, deadlineMs, generationParams, + maxContextTokens, } = args; const agentType = narrowStringUnion( @@ -412,6 +414,7 @@ export const runAgentGeneration = internalAction({ noCacheToolNames: agentConfig.noCacheToolNames, instructions: finalInstructions, toolsSummary, + maxContextTokens, }, { ctx, diff --git a/services/platform/convex/lib/agent_chat/start_agent_chat.ts b/services/platform/convex/lib/agent_chat/start_agent_chat.ts index 4d102c337d..cc70506d1e 100644 --- a/services/platform/convex/lib/agent_chat/start_agent_chat.ts +++ b/services/platform/convex/lib/agent_chat/start_agent_chat.ts @@ -18,11 +18,13 @@ import { components, internal } from '../../_generated/api'; import type { Id } from '../../_generated/dataModel'; import type { MutationCtx } from '../../_generated/server'; import { checkBudget } from '../../governance/budget_enforcement'; +import { resolveFeatureFlags } from '../../governance/feature_enforcement'; import { persistentStreaming } from '../../streaming/helpers'; import type { FileAttachment } from '../attachments'; import type { AgentType } from '../context_management/constants'; import { AGENT_CONTEXT_CONFIGS } from '../context_management/constants'; import { createDebugLog } from '../debug_log'; +import { getUserTeamIds } from '../get_user_teams'; import { computeDeduplicationState, type AgentListMessagesResult, @@ -241,6 +243,61 @@ export async function startAgentChat( } } + // Feature flag enforcement — resolve per-user flags and override agent config + let enforcedConfig = agentConfig; + let governanceMaxContextTokens: number | undefined; + + if (userId) { + const userTeamIds = await getUserTeamIds(ctx, userId); + const featureFlags = await resolveFeatureFlags( + ctx, + organizationId, + userId, + userTeamIds, + ); + + const needsWebSearchOverride = + !featureFlags.webSearch && agentConfig.webSearchMode !== 'off'; + const needsFileUploadBlock = + !featureFlags.fileUpload && attachments && attachments.length > 0; + + if (needsWebSearchOverride || needsFileUploadBlock) { + enforcedConfig = { ...agentConfig }; + } + + if (!featureFlags.webSearch) { + enforcedConfig = { + ...enforcedConfig, + webSearchMode: 'off', + convexToolNames: (enforcedConfig.convexToolNames ?? []).filter( + (t) => t !== 'web', + ), + }; + } + + if (!featureFlags.fileUpload && attachments && attachments.length > 0) { + await saveMessage(ctx, components.agent, { + threadId, + message: { + role: 'assistant', + content: + 'File uploads are disabled for your account by organization policy. Please contact your administrator.', + }, + }); + if (threadMeta) { + await ctx.db.patch(threadMeta._id, { + generationStatus: 'idle' as const, + updatedAt: Date.now(), + }); + } + return { messageAlreadyExists, streamId }; + } + + if (featureFlags.maxContextTokens != null) { + governanceMaxContextTokens = featureFlags.maxContextTokens; + } + } + // Schedule the generic agent action with full configuration debugLog('SCHEDULE_ACTION', { threadId, @@ -252,7 +309,7 @@ export async function startAgentChat( internal.lib.agent_chat.internal_actions.runAgentGeneration, { agentType, - agentConfig, + agentConfig: enforcedConfig, model, provider, debugTag, @@ -271,6 +328,7 @@ export async function startAgentChat( userContext, deadlineMs, generationParams: args.generationParams, + maxContextTokens: governanceMaxContextTokens, }, ); diff --git a/services/platform/convex/lib/agent_chat/types.ts b/services/platform/convex/lib/agent_chat/types.ts index b24fb872c2..c9e90a0c05 100644 --- a/services/platform/convex/lib/agent_chat/types.ts +++ b/services/platform/convex/lib/agent_chat/types.ts @@ -139,6 +139,8 @@ export interface RunAgentGenerationArgs { maxSteps?: number; /** Optional per-request generation parameters from OpenAI compat endpoint */ generationParams?: GenerationParams; + /** Governance-enforced max context tokens (overrides agent config) */ + maxContextTokens?: number; } /** diff --git a/services/platform/convex/lib/agent_response/generate_response.ts b/services/platform/convex/lib/agent_response/generate_response.ts index f8ad11ef6d..494c7e74a0 100644 --- a/services/platform/convex/lib/agent_response/generate_response.ts +++ b/services/platform/convex/lib/agent_response/generate_response.ts @@ -182,6 +182,7 @@ export async function generateAgentResponse( noCacheToolNames, instructions, toolsSummary, + maxContextTokens, } = config; const { ctx, @@ -512,7 +513,7 @@ export async function generateAgentResponse( threadId, additionalContext, parentThreadId, - maxHistoryTokens: agentConfig.maxHistoryTokens, + maxHistoryTokens: maxContextTokens ?? agentConfig.maxHistoryTokens, ragContext: knowledgeContextResult ?? hookData?.ragContext, webContext: webContextResult, }); @@ -940,7 +941,7 @@ export async function generateAgentResponse( threadId, additionalContext, parentThreadId, - maxHistoryTokens: agentConfig.maxHistoryTokens, + maxHistoryTokens: maxContextTokens ?? agentConfig.maxHistoryTokens, ragContext: hookData?.ragContext, }); @@ -1169,7 +1170,7 @@ export async function generateAgentResponse( threadId, additionalContext, parentThreadId, - maxHistoryTokens: agentConfig.maxHistoryTokens, + maxHistoryTokens: maxContextTokens ?? agentConfig.maxHistoryTokens, ragContext: hookData?.ragContext, }); diff --git a/services/platform/convex/lib/agent_response/types.ts b/services/platform/convex/lib/agent_response/types.ts index bb8b900770..dc523c7883 100644 --- a/services/platform/convex/lib/agent_response/types.ts +++ b/services/platform/convex/lib/agent_response/types.ts @@ -47,6 +47,8 @@ export interface GenerateResponseConfig { responseCacheTtlMs?: number; /** Tool names whose invocation should prevent caching the response */ noCacheToolNames?: string[]; + /** Governance-enforced max context tokens (overrides agent config maxHistoryTokens) */ + maxContextTokens?: number; } /** diff --git a/services/platform/messages/de.json b/services/platform/messages/de.json index b684b30bbf..430c11f75c 100644 --- a/services/platform/messages/de.json +++ b/services/platform/messages/de.json @@ -3585,6 +3585,31 @@ "governance": { "title": "Richtlinien", "description": "Konfigurieren Sie organisationsweite Richtlinien und Kontrollen.", + "tabs": { + "systemPrompt": "System-Prompt", + "budgets": "Budgets", + "featureControls": "Feature-Steuerung", + "usage": "Nutzung" + }, + "featureFlags": { + "title": "Feature-Steuerung", + "description": "Funktionen pro Benutzer, Team oder Rolle aktivieren oder deaktivieren.", + "webSearch": "Websuche", + "codeExecution": "Code-Ausführung", + "fileUpload": "Datei-Upload", + "maxContextTokens": "Max. Kontext-Tokens", + "maxContextTokensHint": "Maximale Kontext-Tokens für KI-Antworten. Leer lassen für unbegrenzt.", + "enabled": "Feature-Steuerung aktivieren", + "scope": "Geltungsbereich", + "target": "Ziel", + "allUsers": "Alle Benutzer", + "addRule": "Regel hinzufügen", + "editRule": "Regel bearbeiten", + "deleteRule": "Regel löschen", + "noRules": "Keine Feature-Regeln konfiguriert. Füge eine Regel hinzu, um Funktionen pro Benutzer, Team oder Rolle zu steuern.", + "saved": "Feature-Steuerung gespeichert", + "saveFailed": "Feature-Steuerung konnte nicht gespeichert werden" + }, "systemPrompt": { "title": "System-Prompt", "description": "Legen Sie einen verbindlichen System-Prompt fest, der auf alle Agents angewendet wird und nicht von Benutzern überschrieben werden kann.", diff --git a/services/platform/messages/en.json b/services/platform/messages/en.json index c42726c86e..e739bd1b57 100644 --- a/services/platform/messages/en.json +++ b/services/platform/messages/en.json @@ -3638,6 +3638,12 @@ "governance": { "title": "Governance", "description": "Configure organization-wide policies and controls.", + "tabs": { + "systemPrompt": "System prompt", + "budgets": "Budgets", + "featureControls": "Feature controls", + "usage": "Usage" + }, "systemPrompt": { "title": "System Prompt", "description": "Set a mandatory system prompt that is applied to all agents and cannot be overridden by users.", @@ -3694,12 +3700,23 @@ "saved": "Retention policy saved" }, "featureFlags": { - "title": "Feature Controls", + "title": "Feature controls", "description": "Enable or disable features per user, team, or role.", "webSearch": "Web search", "codeExecution": "Code execution", + "fileUpload": "File upload", "maxContextTokens": "Max context tokens", - "saved": "Feature controls saved" + "maxContextTokensHint": "Maximum number of context tokens for AI responses. Leave empty for no limit.", + "enabled": "Enable feature controls", + "scope": "Scope", + "target": "Target", + "allUsers": "All users", + "addRule": "Add rule", + "editRule": "Edit rule", + "deleteRule": "Delete rule", + "noRules": "No feature control rules configured. Add a rule to manage features per user, team, or role.", + "saved": "Feature controls saved", + "saveFailed": "Failed to save feature controls" }, "pii": { "title": "PII protection", From e83c322914c402d87f7e248dd17eeb38fe80d451 Mon Sep 17 00:00:00 2001 From: yannickmonney Date: Sat, 11 Apr 2026 02:50:06 +0200 Subject: [PATCH 2/5] test(platform): add feature flag enforcement tests for startAgentChat Add tests verifying governance feature flag enforcement: - maxContextTokens forwarded to scheduled generation args - web search disabled removes web tool and sets mode off - file upload disabled blocks request with assistant message - file upload disabled allows request without attachments --- .../__tests__/start_agent_chat.test.ts | 134 ++++++++++++++++++ 1 file changed, 134 insertions(+) diff --git a/services/platform/convex/lib/agent_chat/__tests__/start_agent_chat.test.ts b/services/platform/convex/lib/agent_chat/__tests__/start_agent_chat.test.ts index e8cfae7780..49a661ef15 100644 --- a/services/platform/convex/lib/agent_chat/__tests__/start_agent_chat.test.ts +++ b/services/platform/convex/lib/agent_chat/__tests__/start_agent_chat.test.ts @@ -61,6 +61,10 @@ vi.mock('../../message_deduplication', () => ({ }), })); +const { resolveFeatureFlags } = + await import('../../../governance/feature_enforcement'); +const mockedResolveFeatureFlags = vi.mocked(resolveFeatureFlags); + const { startAgentChat } = await import('../start_agent_chat'); function createMockCtx( @@ -161,3 +165,133 @@ describe('startAgentChat — concurrent generation guard', () => { }); }); }); + +describe('startAgentChat — feature flag enforcement', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockListMessages.mockResolvedValue({ page: [] }); + mockSaveMessage.mockResolvedValue({ messageId: 'msg_1' }); + }); + + it('forwards maxContextTokens to scheduled generation when set', async () => { + mockedResolveFeatureFlags.mockResolvedValueOnce({ + webSearch: true, + codeExecution: true, + fileUpload: true, + maxContextTokens: 4096, + }); + + const ctx = createMockCtx({ + _id: 'meta_1', + generationStatus: 'idle', + }); + + await startAgentChat(createDefaultArgs(ctx)); + + expect(ctx.scheduler.runAfter).toHaveBeenCalledWith( + expect.any(Number), + 'mock-runAgentGeneration', + expect.objectContaining({ maxContextTokens: 4096 }), + ); + }); + + it('removes web tool and sets webSearchMode off when webSearch is disabled', async () => { + mockedResolveFeatureFlags.mockResolvedValueOnce({ + webSearch: false, + codeExecution: true, + fileUpload: true, + }); + + const ctx = createMockCtx({ + _id: 'meta_1', + generationStatus: 'idle', + }); + + const args = { + ...createDefaultArgs(ctx), + agentConfig: { + name: 'test-agent', + instructions: 'test', + maxSteps: 5, + webSearchMode: 'tool' as const, + convexToolNames: ['web', 'rag_search'] as never[], + }, + }; + + await startAgentChat(args); + + expect(ctx.scheduler.runAfter).toHaveBeenCalledWith( + expect.any(Number), + 'mock-runAgentGeneration', + expect.objectContaining({ + agentConfig: expect.objectContaining({ + webSearchMode: 'off', + convexToolNames: ['rag_search'], + }), + }), + ); + }); + + it('blocks file upload with assistant message when fileUpload is disabled', async () => { + mockedResolveFeatureFlags.mockResolvedValueOnce({ + webSearch: true, + codeExecution: true, + fileUpload: false, + }); + + const ctx = createMockCtx({ + _id: 'meta_1', + generationStatus: 'idle', + }); + + const args = { + ...createDefaultArgs(ctx), + attachments: [ + { + fileId: 'file_1' as never, + fileName: 'test.pdf', + fileType: 'application/pdf', + fileSize: 1024, + }, + ], + }; + + const result = await startAgentChat(args); + + expect(result.streamId).toBe('new-stream-id'); + expect(mockSaveMessage).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.objectContaining({ + message: expect.objectContaining({ + role: 'assistant', + content: expect.stringContaining('File uploads are disabled'), + }), + }), + ); + expect(ctx.db.patch).toHaveBeenCalledWith( + 'meta_1', + expect.objectContaining({ + generationStatus: 'idle', + }), + ); + expect(ctx.scheduler.runAfter).not.toHaveBeenCalled(); + }); + + it('allows request without attachments when fileUpload is disabled', async () => { + mockedResolveFeatureFlags.mockResolvedValueOnce({ + webSearch: true, + codeExecution: true, + fileUpload: false, + }); + + const ctx = createMockCtx({ + _id: 'meta_1', + generationStatus: 'idle', + }); + + await startAgentChat(createDefaultArgs(ctx)); + + expect(ctx.scheduler.runAfter).toHaveBeenCalled(); + }); +}); From 917e25f185e11d70ee063cf63c58307538a7ba70 Mon Sep 17 00:00:00 2001 From: yannickmonney Date: Sat, 11 Apr 2026 03:01:42 +0200 Subject: [PATCH 3/5] fix(platform): address CodeRabbit review findings for feature flags Replace all hardcoded English strings in feature-flags-editor with i18n translation keys (scope/role labels, search placeholders, aria-labels, submit text, actions header). Use formatNumber() instead of toLocaleString(). Show loading skeleton with aria-busy instead of returning null. Normalize maxContextTokens once before reuse in generate_response. Remove redundant object spread in start_agent_chat. --- .../components/feature-flags-editor.test.tsx | 4 +- .../components/feature-flags-editor.tsx | 81 ++++++++++++------- .../convex/lib/agent_chat/start_agent_chat.ts | 13 +-- .../lib/agent_response/generate_response.ts | 12 ++- services/platform/messages/en.json | 22 ++++- 5 files changed, 85 insertions(+), 47 deletions(-) diff --git a/services/platform/app/features/settings/governance/components/feature-flags-editor.test.tsx b/services/platform/app/features/settings/governance/components/feature-flags-editor.test.tsx index 7b05a94db4..bd343a250d 100644 --- a/services/platform/app/features/settings/governance/components/feature-flags-editor.test.tsx +++ b/services/platform/app/features/settings/governance/components/feature-flags-editor.test.tsx @@ -112,7 +112,7 @@ describe('FeatureFlagsEditor', () => { expect(screen.getByRole('switch')).toBeInTheDocument(); }); - it('returns null while loading', () => { + it('renders loading skeleton while loading', () => { mockedUseGovernancePolicy.mockReturnValue({ data: null, isLoading: true, @@ -120,7 +120,7 @@ describe('FeatureFlagsEditor', () => { const { container } = render(); - expect(container.innerHTML).toBe(''); + expect(container.querySelector('[aria-busy="true"]')).toBeInTheDocument(); }); describe('accessibility', () => { diff --git a/services/platform/app/features/settings/governance/components/feature-flags-editor.tsx b/services/platform/app/features/settings/governance/components/feature-flags-editor.tsx index 8d6d0c015b..9506ee9b0a 100644 --- a/services/platform/app/features/settings/governance/components/feature-flags-editor.tsx +++ b/services/platform/app/features/settings/governance/components/feature-flags-editor.tsx @@ -4,6 +4,7 @@ import { Pencil, Plus, Trash2 } from 'lucide-react'; import { useCallback, useEffect, useMemo, useState } from 'react'; import { FormDialog } from '@/app/components/ui/dialog/form-dialog'; +import { Skeleton } from '@/app/components/ui/feedback/skeleton'; import { Input } from '@/app/components/ui/forms/input'; import { SearchableSelect } from '@/app/components/ui/forms/searchable-select'; import { Select } from '@/app/components/ui/forms/select'; @@ -22,6 +23,7 @@ import { type FeatureFlagsConfig, type FeatureFlagRule, } from '@/lib/shared/schemas/governance'; +import { formatNumber } from '@/lib/utils/format/number'; import { isRecord } from '@/lib/utils/type-guards'; import { useUpsertGovernancePolicy } from '../hooks/mutations'; @@ -31,23 +33,13 @@ interface FeatureFlagsEditorProps { organizationId: string; } -const SCOPE_OPTIONS = [ - { value: 'default', label: 'Default' }, - { value: 'user', label: 'User' }, - { value: 'team', label: 'Team' }, - { value: 'role', label: 'Role' }, -]; +const SCOPE_VALUES = ['default', 'user', 'team', 'role'] as const; function isScopeValue(v: string): v is FeatureFlagRule['scope'] { - return SCOPE_OPTIONS.some((o) => o.value === v); + return (SCOPE_VALUES as readonly string[]).includes(v); } -const ROLE_OPTIONS = [ - { value: 'admin', label: 'Admin' }, - { value: 'developer', label: 'Developer' }, - { value: 'editor', label: 'Editor' }, - { value: 'member', label: 'Member' }, -]; +const ROLE_VALUES = ['admin', 'developer', 'editor', 'member'] as const; function emptyRule(): FeatureFlagRule { return { @@ -89,8 +81,27 @@ function RuleDialog({ teamOptions, }: RuleDialogProps) { const { t } = useT('governance'); + const { t: tCommon } = useT('common'); const [draft, setDraft] = useState(initialRule); + const scopeOptions = useMemo( + () => + SCOPE_VALUES.map((v) => ({ + value: v, + label: t(`featureFlags.scopeLabels.${v}`), + })), + [t], + ); + + const roleOptions = useMemo( + () => + ROLE_VALUES.map((v) => ({ + value: v, + label: t(`featureFlags.roleLabels.${v}`), + })), + [t], + ); + useEffect(() => { if (open) { setDraft(initialRule); @@ -122,14 +133,14 @@ function RuleDialog({ onOpenChange={onOpenChange} title={title} onSubmit={handleSubmit} - submitText="Confirm" + submitText={tCommon('actions.confirm')} >
updateDraft({ scopeId: value })} disabled={cannotManage} @@ -156,14 +167,16 @@ function RuleDialog({ {draft.scope === 'user' && (
- User + + {t('featureFlags.scopeLabels.user')} + updateDraft({ scopeId: value })} options={memberOptions} - searchPlaceholder="Search users..." - emptyText="No users found" - aria-label="Select user" + searchPlaceholder={t('featureFlags.searchUsers')} + emptyText={t('featureFlags.noUsersFound')} + aria-label={t('featureFlags.selectUser')} trigger={ } @@ -186,14 +199,16 @@ function RuleDialog({ {draft.scope === 'team' && (
- Team + + {t('featureFlags.scopeLabels.team')} + updateDraft({ scopeId: value })} options={teamOptions} - searchPlaceholder="Search teams..." - emptyText="No teams found" - aria-label="Select team" + searchPlaceholder={t('featureFlags.searchTeams')} + emptyText={t('featureFlags.noTeamsFound')} + aria-label={t('featureFlags.selectTeam')} trigger={ } @@ -410,7 +425,13 @@ export function FeatureFlagsEditor({ ); if (isLoading) { - return null; + return ( +
+ + + +
+ ); } return ( @@ -477,7 +498,7 @@ export function FeatureFlagsEditor({ scope="col" className="text-muted-foreground px-3 py-2 text-right font-medium" > - Actions + {t('featureFlags.actions')} @@ -497,7 +518,7 @@ export function FeatureFlagsEditor({ {rule.maxContextTokens != null - ? rule.maxContextTokens.toLocaleString() + ? formatNumber(rule.maxContextTokens) : '\u2014'} diff --git a/services/platform/convex/lib/agent_chat/start_agent_chat.ts b/services/platform/convex/lib/agent_chat/start_agent_chat.ts index cc70506d1e..fadc98993e 100644 --- a/services/platform/convex/lib/agent_chat/start_agent_chat.ts +++ b/services/platform/convex/lib/agent_chat/start_agent_chat.ts @@ -256,20 +256,11 @@ export async function startAgentChat( userTeamIds, ); - const needsWebSearchOverride = - !featureFlags.webSearch && agentConfig.webSearchMode !== 'off'; - const needsFileUploadBlock = - !featureFlags.fileUpload && attachments && attachments.length > 0; - - if (needsWebSearchOverride || needsFileUploadBlock) { - enforcedConfig = { ...agentConfig }; - } - if (!featureFlags.webSearch) { enforcedConfig = { - ...enforcedConfig, + ...agentConfig, webSearchMode: 'off', - convexToolNames: (enforcedConfig.convexToolNames ?? []).filter( + convexToolNames: (agentConfig.convexToolNames ?? []).filter( (t) => t !== 'web', ), }; diff --git a/services/platform/convex/lib/agent_response/generate_response.ts b/services/platform/convex/lib/agent_response/generate_response.ts index 494c7e74a0..38a33846c9 100644 --- a/services/platform/convex/lib/agent_response/generate_response.ts +++ b/services/platform/convex/lib/agent_response/generate_response.ts @@ -508,12 +508,18 @@ export async function generateAgentResponse( // Build structured context (history, RAG, web) // Note: promptMessage is NOT included - it's passed via `prompt` parameter const agentConfig = AGENT_CONTEXT_CONFIGS[agentType]; + const effectiveMaxHistoryTokens = + maxContextTokens != null && + Number.isFinite(maxContextTokens) && + maxContextTokens > 0 + ? Math.floor(maxContextTokens) + : agentConfig.maxHistoryTokens; structuredThreadContext = await buildStructuredContext({ ctx, threadId, additionalContext, parentThreadId, - maxHistoryTokens: maxContextTokens ?? agentConfig.maxHistoryTokens, + maxHistoryTokens: effectiveMaxHistoryTokens, ragContext: knowledgeContextResult ?? hookData?.ragContext, webContext: webContextResult, }); @@ -941,7 +947,7 @@ export async function generateAgentResponse( threadId, additionalContext, parentThreadId, - maxHistoryTokens: maxContextTokens ?? agentConfig.maxHistoryTokens, + maxHistoryTokens: effectiveMaxHistoryTokens, ragContext: hookData?.ragContext, }); @@ -1170,7 +1176,7 @@ export async function generateAgentResponse( threadId, additionalContext, parentThreadId, - maxHistoryTokens: maxContextTokens ?? agentConfig.maxHistoryTokens, + maxHistoryTokens: effectiveMaxHistoryTokens, ragContext: hookData?.ragContext, }); diff --git a/services/platform/messages/en.json b/services/platform/messages/en.json index e739bd1b57..4a93f6bca7 100644 --- a/services/platform/messages/en.json +++ b/services/platform/messages/en.json @@ -3716,7 +3716,27 @@ "deleteRule": "Delete rule", "noRules": "No feature control rules configured. Add a rule to manage features per user, team, or role.", "saved": "Feature controls saved", - "saveFailed": "Failed to save feature controls" + "saveFailed": "Failed to save feature controls", + "actions": "Actions", + "role": "Role", + "scopeLabels": { + "default": "Default", + "user": "User", + "team": "Team", + "role": "Role" + }, + "roleLabels": { + "admin": "Admin", + "developer": "Developer", + "editor": "Editor", + "member": "Member" + }, + "searchUsers": "Search users...", + "noUsersFound": "No users found", + "selectUser": "Select user...", + "searchTeams": "Search teams...", + "noTeamsFound": "No teams found", + "selectTeam": "Select team..." }, "pii": { "title": "PII protection", From d86eaa8f1d19ad39bed073c9d9a7114bf24ac719 Mon Sep 17 00:00:00 2001 From: larryro <371767072@qq.com> Date: Sat, 11 Apr 2026 15:02:57 +0800 Subject: [PATCH 4/5] fix(platform): consolidate duplicate governance i18n keys Remove duplicate governance.tabs and governance.featureFlags entries, merging feature controls tab label into the canonical tabs section. --- services/platform/messages/de.json | 45 +++++++++++------------------- services/platform/messages/en.json | 9 ++---- 2 files changed, 18 insertions(+), 36 deletions(-) diff --git a/services/platform/messages/de.json b/services/platform/messages/de.json index 430c11f75c..8b3d5d40df 100644 --- a/services/platform/messages/de.json +++ b/services/platform/messages/de.json @@ -3585,31 +3585,6 @@ "governance": { "title": "Richtlinien", "description": "Konfigurieren Sie organisationsweite Richtlinien und Kontrollen.", - "tabs": { - "systemPrompt": "System-Prompt", - "budgets": "Budgets", - "featureControls": "Feature-Steuerung", - "usage": "Nutzung" - }, - "featureFlags": { - "title": "Feature-Steuerung", - "description": "Funktionen pro Benutzer, Team oder Rolle aktivieren oder deaktivieren.", - "webSearch": "Websuche", - "codeExecution": "Code-Ausführung", - "fileUpload": "Datei-Upload", - "maxContextTokens": "Max. Kontext-Tokens", - "maxContextTokensHint": "Maximale Kontext-Tokens für KI-Antworten. Leer lassen für unbegrenzt.", - "enabled": "Feature-Steuerung aktivieren", - "scope": "Geltungsbereich", - "target": "Ziel", - "allUsers": "Alle Benutzer", - "addRule": "Regel hinzufügen", - "editRule": "Regel bearbeiten", - "deleteRule": "Regel löschen", - "noRules": "Keine Feature-Regeln konfiguriert. Füge eine Regel hinzu, um Funktionen pro Benutzer, Team oder Rolle zu steuern.", - "saved": "Feature-Steuerung gespeichert", - "saveFailed": "Feature-Steuerung konnte nicht gespeichert werden" - }, "systemPrompt": { "title": "System-Prompt", "description": "Legen Sie einen verbindlichen System-Prompt fest, der auf alle Agents angewendet wird und nicht von Benutzern überschrieben werden kann.", @@ -3665,12 +3640,23 @@ "saved": "Aufbewahrungsrichtlinie gespeichert" }, "featureFlags": { - "title": "Funktionssteuerung", + "title": "Feature-Steuerung", "description": "Funktionen pro Benutzer, Team oder Rolle aktivieren oder deaktivieren.", "webSearch": "Websuche", "codeExecution": "Code-Ausführung", - "maxContextTokens": "Maximale Kontext-Token", - "saved": "Funktionssteuerung gespeichert" + "fileUpload": "Datei-Upload", + "maxContextTokens": "Max. Kontext-Tokens", + "maxContextTokensHint": "Maximale Kontext-Tokens für KI-Antworten. Leer lassen für unbegrenzt.", + "enabled": "Feature-Steuerung aktivieren", + "scope": "Geltungsbereich", + "target": "Ziel", + "allUsers": "Alle Benutzer", + "addRule": "Regel hinzufügen", + "editRule": "Regel bearbeiten", + "deleteRule": "Regel löschen", + "noRules": "Keine Feature-Regeln konfiguriert. Füge eine Regel hinzu, um Funktionen pro Benutzer, Team oder Rolle zu steuern.", + "saved": "Feature-Steuerung gespeichert", + "saveFailed": "Feature-Steuerung konnte nicht gespeichert werden" }, "pii": { "title": "PII-Schutz", @@ -3718,7 +3704,8 @@ "systemPrompt": "System-Prompt", "budgets": "Budgets", "usage": "Nutzung", - "pii": "PII-Schutz" + "pii": "PII-Schutz", + "featureControls": "Feature-Steuerung" } } } diff --git a/services/platform/messages/en.json b/services/platform/messages/en.json index 4a93f6bca7..309a1df70e 100644 --- a/services/platform/messages/en.json +++ b/services/platform/messages/en.json @@ -3638,12 +3638,6 @@ "governance": { "title": "Governance", "description": "Configure organization-wide policies and controls.", - "tabs": { - "systemPrompt": "System prompt", - "budgets": "Budgets", - "featureControls": "Feature controls", - "usage": "Usage" - }, "systemPrompt": { "title": "System Prompt", "description": "Set a mandatory system prompt that is applied to all agents and cannot be overridden by users.", @@ -3784,7 +3778,8 @@ "systemPrompt": "System prompt", "budgets": "Budgets", "usage": "Usage", - "pii": "PII protection" + "pii": "PII protection", + "featureControls": "Feature controls" } }, "prompts": { From b8a22732bd832c4e952be077ca2c7b10181aeb39 Mon Sep 17 00:00:00 2001 From: larryro <371767072@qq.com> Date: Sat, 11 Apr 2026 15:10:04 +0800 Subject: [PATCH 5/5] feat(platform): expose resolved feature flags to frontend and hide upload when disabled Add getMyFeatureFlags query that resolves the current user's feature flags server-side (reactive via Convex subscriptions). Use it in ChatInterface to hide the file upload button, drop zone, and paste handler when fileUpload is disabled by governance policy. Also disable the Confirm button in the rule edit dialog until the user makes a change. --- .../features/chat/components/chat-input.tsx | 30 +++++++++++-------- .../chat/components/chat-interface.tsx | 5 ++++ .../components/feature-flags-editor.tsx | 5 ++++ .../settings/governance/hooks/queries.ts | 6 ++++ .../platform/convex/governance/queries.ts | 30 +++++++++++++++++++ 5 files changed, 63 insertions(+), 13 deletions(-) diff --git a/services/platform/app/features/chat/components/chat-input.tsx b/services/platform/app/features/chat/components/chat-input.tsx index ea4637e41b..3ec30f0ce8 100644 --- a/services/platform/app/features/chat/components/chat-input.tsx +++ b/services/platform/app/features/chat/components/chat-input.tsx @@ -43,6 +43,7 @@ interface ChatInputProps extends Omit< uploadFiles: (files: File[]) => Promise; removeAttachment: (fileId: Id<'_storage'>) => void; clearAttachments: () => FileAttachment[]; + fileUploadDisabled?: boolean; isIndexing?: boolean; indexingStatuses?: Map< Id<'_storage'>, @@ -65,6 +66,7 @@ export function ChatInput({ uploadFiles, removeAttachment, clearAttachments, + fileUploadDisabled = false, isIndexing = false, indexingStatuses, ...restProps @@ -124,7 +126,7 @@ export function ChatInput({ }; const handlePaste = (e: React.ClipboardEvent) => { - if (inputDisabled) return; + if (inputDisabled || fileUploadDisabled) return; const items = e.clipboardData?.items; if (!items) return; @@ -165,7 +167,7 @@ export function ChatInput({ className="relative flex h-full min-h-0 flex-1 flex-col" onFilesSelected={uploadFiles} clickable={false} - disabled={inputDisabled} + disabled={inputDisabled || fileUploadDisabled} > - - - + {!fileUploadDisabled && ( + + + + )} {isArenaMode ? ( diff --git a/services/platform/app/features/chat/components/chat-interface.tsx b/services/platform/app/features/chat/components/chat-interface.tsx index e99f96e1f5..522c98e84e 100644 --- a/services/platform/app/features/chat/components/chat-interface.tsx +++ b/services/platform/app/features/chat/components/chat-interface.tsx @@ -17,6 +17,7 @@ import { api } from '@/convex/_generated/api'; import { useT } from '@/lib/i18n/client'; import { cn } from '@/lib/utils/cn'; +import { useMyFeatureFlags } from '../../settings/governance/hooks/queries'; import { useBranchContext } from '../context/branch-context'; import { useChatLayout } from '../context/chat-layout-context'; import { useEditAndBranch, useForkOwnThread } from '../hooks/mutations'; @@ -144,6 +145,9 @@ export function ChatInterface({ const { isIndexing, statusMap: indexingStatuses } = useFileIndexingStatus(attachments); + const { data: featureFlags } = useMyFeatureFlags(organizationId); + const fileUploadDisabled = featureFlags?.fileUpload === false; + usePersistedAttachments({ userId: user?.userId, threadId, @@ -695,6 +699,7 @@ export function ChatInterface({ uploadFiles={uploadFiles} removeAttachment={removeAttachment} clearAttachments={clearAttachments} + fileUploadDisabled={fileUploadDisabled} isIndexing={isIndexing} indexingStatuses={indexingStatuses} /> diff --git a/services/platform/app/features/settings/governance/components/feature-flags-editor.tsx b/services/platform/app/features/settings/governance/components/feature-flags-editor.tsx index 9506ee9b0a..2411537659 100644 --- a/services/platform/app/features/settings/governance/components/feature-flags-editor.tsx +++ b/services/platform/app/features/settings/governance/components/feature-flags-editor.tsx @@ -108,6 +108,10 @@ function RuleDialog({ } }, [open, initialRule]); + const isDirty = useMemo(() => { + return JSON.stringify(draft) !== JSON.stringify(initialRule); + }, [draft, initialRule]); + const updateDraft = useCallback((patch: Partial) => { setDraft((prev) => { const updated = { ...prev, ...patch }; @@ -134,6 +138,7 @@ function RuleDialog({ title={title} onSubmit={handleSubmit} submitText={tCommon('actions.confirm')} + isDirty={isDirty} > diff --git a/services/platform/app/features/settings/governance/hooks/queries.ts b/services/platform/app/features/settings/governance/hooks/queries.ts index 55ad84f646..187f2ed86c 100644 --- a/services/platform/app/features/settings/governance/hooks/queries.ts +++ b/services/platform/app/features/settings/governance/hooks/queries.ts @@ -20,3 +20,9 @@ export function useGovernancePolicy( policyType, }); } + +export function useMyFeatureFlags(organizationId: string) { + return useConvexQuery(api.governance.queries.getMyFeatureFlags, { + organizationId, + }); +} diff --git a/services/platform/convex/governance/queries.ts b/services/platform/convex/governance/queries.ts index 43831ef392..1f0621e365 100644 --- a/services/platform/convex/governance/queries.ts +++ b/services/platform/convex/governance/queries.ts @@ -2,8 +2,10 @@ import { v } from 'convex/values'; import { query } from '../_generated/server'; import { authComponent } from '../auth'; +import { getUserTeamIds } from '../lib/get_user_teams'; import { getOrganizationMember } from '../lib/rls'; import { isAdmin } from '../lib/rls/helpers/role_helpers'; +import { resolveFeatureFlags } from './feature_enforcement'; import { GOVERNANCE_POLICY_TYPES } from './schema'; const policyTypeValidator = v.union( @@ -152,3 +154,31 @@ export const getUsageSummary = query({ }; }, }); + +export const getMyFeatureFlags = query({ + args: { + organizationId: v.string(), + }, + handler: async (ctx, args) => { + const authUser = await authComponent.getAuthUser(ctx); + if (!authUser) { + throw new Error('Unauthenticated'); + } + + const userId = String(authUser._id); + const member = await getOrganizationMember(ctx, args.organizationId, { + userId, + email: authUser.email, + name: authUser.name, + }); + + const teamIds = await getUserTeamIds(ctx, userId); + return resolveFeatureFlags( + ctx, + args.organizationId, + userId, + teamIds, + member.role, + ); + }, +});