From aac9d0610e406bccc10af69a5dfe0c6f9586f87c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Mon, 16 Feb 2026 20:38:01 +0000 Subject: [PATCH] feat: improve agent sort (#231) --- apps/backend/internal/agent/agents/amp.go | 2 +- apps/backend/internal/agent/agents/copilot.go | 2 +- apps/backend/internal/agent/agents/gemini.go | 2 +- .../backend/internal/agent/agents/opencode.go | 2 +- .../internal/agent/registry/registry.go | 16 +- .../internal/agent/registry/registry_test.go | 65 +++++- apps/web/app/actions/agents.ts | 21 +- .../app/settings/agents/[agentId]/page.tsx | 23 +-- apps/web/components/onboarding-dialog.tsx | 135 +++---------- .../settings/profile-form-fields.tsx | 191 ++++++++++-------- apps/web/lib/agent-permissions.test.ts | 109 ++++++++++ apps/web/lib/agent-permissions.ts | 65 ++++++ 12 files changed, 396 insertions(+), 237 deletions(-) create mode 100644 apps/web/lib/agent-permissions.test.ts create mode 100644 apps/web/lib/agent-permissions.ts diff --git a/apps/backend/internal/agent/agents/amp.go b/apps/backend/internal/agent/agents/amp.go index 6db77db75d..6ff9807c9f 100644 --- a/apps/backend/internal/agent/agents/amp.go +++ b/apps/backend/internal/agent/agents/amp.go @@ -49,7 +49,7 @@ func (a *Amp) Description() string { return "Sourcegraph Amp CLI-powered autonomous coding agent using stream-json protocol." } func (a *Amp) Enabled() bool { return true } -func (a *Amp) DisplayOrder() int { return 6 } +func (a *Amp) DisplayOrder() int { return 7 } func (a *Amp) Logo(v LogoVariant) []byte { if v == LogoDark { diff --git a/apps/backend/internal/agent/agents/copilot.go b/apps/backend/internal/agent/agents/copilot.go index 52bc623082..767d328bb0 100644 --- a/apps/backend/internal/agent/agents/copilot.go +++ b/apps/backend/internal/agent/agents/copilot.go @@ -50,7 +50,7 @@ func (a *Copilot) Description() string { return "GitHub Copilot CLI-powered autonomous coding agent using the Copilot SDK protocol." } func (a *Copilot) Enabled() bool { return true } -func (a *Copilot) DisplayOrder() int { return 5 } +func (a *Copilot) DisplayOrder() int { return 6 } func (a *Copilot) Logo(v LogoVariant) []byte { if v == LogoDark { diff --git a/apps/backend/internal/agent/agents/gemini.go b/apps/backend/internal/agent/agents/gemini.go index e8e07b1d26..b217b53903 100644 --- a/apps/backend/internal/agent/agents/gemini.go +++ b/apps/backend/internal/agent/agents/gemini.go @@ -52,7 +52,7 @@ func (a *Gemini) Description() string { return "Google Gemini CLI-powered autonomous coding agent using ACP protocol." } func (a *Gemini) Enabled() bool { return true } -func (a *Gemini) DisplayOrder() int { return 4 } +func (a *Gemini) DisplayOrder() int { return 5 } func (a *Gemini) Logo(v LogoVariant) []byte { if v == LogoDark { diff --git a/apps/backend/internal/agent/agents/opencode.go b/apps/backend/internal/agent/agents/opencode.go index e4f482c9c3..33e6a4d556 100644 --- a/apps/backend/internal/agent/agents/opencode.go +++ b/apps/backend/internal/agent/agents/opencode.go @@ -53,7 +53,7 @@ func (a *OpenCode) Description() string { return "OpenCode CLI-powered autonomous coding agent using REST/SSE protocol." } func (a *OpenCode) Enabled() bool { return true } -func (a *OpenCode) DisplayOrder() int { return 3 } +func (a *OpenCode) DisplayOrder() int { return 4 } func (a *OpenCode) Logo(v LogoVariant) []byte { if v == LogoDark { diff --git a/apps/backend/internal/agent/registry/registry.go b/apps/backend/internal/agent/registry/registry.go index dded77ee5d..5b868dd533 100644 --- a/apps/backend/internal/agent/registry/registry.go +++ b/apps/backend/internal/agent/registry/registry.go @@ -2,6 +2,7 @@ package registry import ( + "cmp" "fmt" "slices" "sync" @@ -91,6 +92,13 @@ func (r *Registry) GetDefault() (agents.Agent, error) { return nil, fmt.Errorf("no default agent type available") } +// sortByDisplayOrder sorts agents by their DisplayOrder in ascending order. +func sortByDisplayOrder(list []agents.Agent) { + slices.SortStableFunc(list, func(a, b agents.Agent) int { + return cmp.Compare(a.DisplayOrder(), b.DisplayOrder()) + }) +} + // List returns all registered agents func (r *Registry) List() []agents.Agent { r.mu.RLock() @@ -100,9 +108,7 @@ func (r *Registry) List() []agents.Agent { for _, ag := range r.agents { result = append(result, ag) } - slices.SortStableFunc(result, func(a, b agents.Agent) int { - return a.DisplayOrder() - b.DisplayOrder() - }) + sortByDisplayOrder(result) return result } @@ -117,9 +123,7 @@ func (r *Registry) ListEnabled() []agents.Agent { result = append(result, ag) } } - slices.SortStableFunc(result, func(a, b agents.Agent) int { - return a.DisplayOrder() - b.DisplayOrder() - }) + sortByDisplayOrder(result) return result } diff --git a/apps/backend/internal/agent/registry/registry_test.go b/apps/backend/internal/agent/registry/registry_test.go index d5ed84a747..0594c00ba5 100644 --- a/apps/backend/internal/agent/registry/registry_test.go +++ b/apps/backend/internal/agent/registry/registry_test.go @@ -12,11 +12,12 @@ import ( // testAgent is a minimal implementation of agents.Agent for testing. type testAgent struct { - id string - name string - description string - enabled bool - runtime *agents.RuntimeConfig + id string + name string + description string + enabled bool + displayOrder int + runtime *agents.RuntimeConfig } func (a *testAgent) ID() string { return a.id } @@ -24,7 +25,7 @@ func (a *testAgent) Name() string { return a.name } func (a *testAgent) DisplayName() string { return a.name } func (a *testAgent) Description() string { return a.description } func (a *testAgent) Enabled() bool { return a.enabled } -func (a *testAgent) DisplayOrder() int { return 0 } +func (a *testAgent) DisplayOrder() int { return a.displayOrder } func (a *testAgent) Logo(agents.LogoVariant) []byte { return nil } func (a *testAgent) IsInstalled(context.Context) (*agents.DiscoveryResult, error) { return nil, agents.ErrNotSupported @@ -250,6 +251,58 @@ func TestRegistry_LoadDefaults(t *testing.T) { } } +func TestRegistry_List_OrderedByDisplayOrder(t *testing.T) { + log := newTestLogger() + reg := NewRegistry(log) + + // Register agents in reverse display order + a3 := validAgentConfig("agent-c", "Agent C") + a3.displayOrder = 30 + a2 := validAgentConfig("agent-b", "Agent B") + a2.displayOrder = 20 + a1 := validAgentConfig("agent-a", "Agent A") + a1.displayOrder = 10 + + _ = reg.Register(a3) + _ = reg.Register(a1) + _ = reg.Register(a2) + + list := reg.List() + if len(list) != 3 { + t.Fatalf("expected 3 agents, got %d", len(list)) + } + if list[0].ID() != "agent-a" || list[1].ID() != "agent-b" || list[2].ID() != "agent-c" { + t.Errorf("expected agents ordered by DisplayOrder [agent-a, agent-b, agent-c], got [%s, %s, %s]", + list[0].ID(), list[1].ID(), list[2].ID()) + } +} + +func TestRegistry_ListEnabled_OrderedByDisplayOrder(t *testing.T) { + log := newTestLogger() + reg := NewRegistry(log) + + a3 := validAgentConfig("agent-c", "Agent C") + a3.displayOrder = 30 + a2 := validAgentConfig("agent-b", "Agent B") + a2.displayOrder = 20 + a2.enabled = false // disabled — should be excluded + a1 := validAgentConfig("agent-a", "Agent A") + a1.displayOrder = 10 + + _ = reg.Register(a3) + _ = reg.Register(a1) + _ = reg.Register(a2) + + list := reg.ListEnabled() + if len(list) != 2 { + t.Fatalf("expected 2 enabled agents, got %d", len(list)) + } + if list[0].ID() != "agent-a" || list[1].ID() != "agent-c" { + t.Errorf("expected enabled agents ordered [agent-a, agent-c], got [%s, %s]", + list[0].ID(), list[1].ID()) + } +} + func TestAgentTypeConfig_ToAPIType(t *testing.T) { ag := validAgentConfig("test-agent", "Test Agent") ag.description = "Test description" diff --git a/apps/web/app/actions/agents.ts b/apps/web/app/actions/agents.ts index 2f86f5ac48..214d847460 100644 --- a/apps/web/app/actions/agents.ts +++ b/apps/web/app/actions/agents.ts @@ -9,6 +9,9 @@ import type { ListAgentsResponse, ListAgentDiscoveryResponse, } from '@/lib/types/http'; +import type { PermissionKey } from '@/lib/agent-permissions'; + +type ProfilePermissions = Record; const { apiBaseUrl } = getBackendConfig(); @@ -45,14 +48,7 @@ export async function listAgentsAction(): Promise { export async function createAgentAction(payload: { name: string; workspace_id?: string | null; - profiles?: Array<{ - name: string; - model: string; - auto_approve: boolean; - dangerously_skip_permissions: boolean; - allow_indexing: boolean; - cli_passthrough: boolean; - }>; + profiles?: Array<{ name: string; model: string; cli_passthrough: boolean } & ProfilePermissions>; }): Promise { return fetchJson(`${apiBaseUrl}/api/v1/agents`, { method: 'POST', @@ -76,14 +72,7 @@ export async function deleteAgentAction(id: string) { export async function createAgentProfileAction( agentId: string, - payload: { - name: string; - model: string; - auto_approve: boolean; - dangerously_skip_permissions: boolean; - allow_indexing: boolean; - cli_passthrough: boolean; - } + payload: { name: string; model: string; cli_passthrough: boolean } & ProfilePermissions ): Promise { return fetchJson(`${apiBaseUrl}/api/v1/agents/${agentId}/profiles`, { method: 'POST', diff --git a/apps/web/app/settings/agents/[agentId]/page.tsx b/apps/web/app/settings/agents/[agentId]/page.tsx index 9a6a4a8368..b76731f5c3 100644 --- a/apps/web/app/settings/agents/[agentId]/page.tsx +++ b/apps/web/app/settings/agents/[agentId]/page.tsx @@ -19,6 +19,7 @@ import { updateAgentProfileMcpConfigAction, } from '@/app/actions/agents'; import type { Agent, AgentDiscovery, AgentProfile, McpServerDef, AvailableAgent, PermissionSetting } from '@/lib/types/http'; +import { buildDefaultPermissions, arePermissionsDirty, permissionsToProfilePatch } from '@/lib/agent-permissions'; import { generateUUID } from '@/lib/utils'; import { useAppStore } from '@/components/state-provider'; import { useAvailableAgents } from '@/hooks/domains/settings/use-available-agents'; @@ -62,9 +63,7 @@ const createDraftProfile = ( name: '', agent_display_name: agentDisplayName, model: defaultModel, - auto_approve: permissionSettings?.auto_approve?.default ?? false, - dangerously_skip_permissions: permissionSettings?.dangerously_skip_permissions?.default ?? false, - allow_indexing: permissionSettings?.allow_indexing?.default ?? false, + ...buildDefaultPermissions(permissionSettings ?? {}), cli_passthrough: false, created_at: new Date().toISOString(), updated_at: new Date().toISOString(), @@ -137,9 +136,7 @@ function AgentSetupForm({ return ( draft.name !== saved.name || draft.model !== saved.model || - draft.auto_approve !== saved.auto_approve || - draft.dangerously_skip_permissions !== saved.dangerously_skip_permissions || - draft.allow_indexing !== saved.allow_indexing || + arePermissionsDirty(draft, saved) || draft.cli_passthrough !== saved.cli_passthrough ); }; @@ -278,9 +275,7 @@ function AgentSetupForm({ profiles: draftAgent.profiles.map((profile) => ({ name: profile.name, model: profile.model, - auto_approve: profile.auto_approve, - dangerously_skip_permissions: profile.dangerously_skip_permissions, - allow_indexing: profile.allow_indexing ?? false, + ...permissionsToProfilePatch(profile), cli_passthrough: profile.cli_passthrough ?? false, })), }); @@ -357,9 +352,7 @@ function AgentSetupForm({ const createdProfile = await createAgentProfileAction(savedAgent.id, { name: profile.name, model: profile.model, - auto_approve: profile.auto_approve, - dangerously_skip_permissions: profile.dangerously_skip_permissions, - allow_indexing: profile.allow_indexing ?? false, + ...permissionsToProfilePatch(profile), cli_passthrough: profile.cli_passthrough ?? false, }); if (profile.mcp_config && profile.mcp_config.dirty && profile.mcp_config.servers.trim()) { @@ -380,9 +373,7 @@ function AgentSetupForm({ const updatedProfile = await updateAgentProfileAction(profile.id, { name: profile.name, model: profile.model, - auto_approve: profile.auto_approve, - dangerously_skip_permissions: profile.dangerously_skip_permissions, - allow_indexing: profile.allow_indexing ?? false, + ...permissionsToProfilePatch(profile), }); nextProfiles.push(updatedProfile); continue; @@ -470,7 +461,7 @@ function AgentSetupForm({ model: profile.model, auto_approve: profile.auto_approve, dangerously_skip_permissions: profile.dangerously_skip_permissions, - allow_indexing: profile.allow_indexing, + allow_indexing: profile.allow_indexing ?? false, cli_passthrough: profile.cli_passthrough, }} onChange={(patch) => handleProfileChange(profile.id, patch)} diff --git a/apps/web/components/onboarding-dialog.tsx b/apps/web/components/onboarding-dialog.tsx index 57b91dc6df..6b2cfbd8d7 100644 --- a/apps/web/components/onboarding-dialog.tsx +++ b/apps/web/components/onboarding-dialog.tsx @@ -22,10 +22,9 @@ import { IconChevronDown, } from '@tabler/icons-react'; import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '@kandev/ui/collapsible'; -import { Switch } from '@kandev/ui/switch'; -import { Label } from '@kandev/ui/label'; import { AgentLogo } from '@/components/agent-logo'; -import { ModelCombobox } from '@/components/settings/model-combobox'; +import { ProfileFormFields, type ProfileFormData } from '@/components/settings/profile-form-fields'; +import { profileToPermissionsMap, permissionsToProfilePatch } from '@/lib/agent-permissions'; import { listAvailableAgents, listWorkflowTemplates } from '@/lib/api'; import { listAgentsAction, updateAgentProfileAction } from '@/app/actions/agents'; import type { AvailableAgent, WorkflowTemplate } from '@/lib/types/http'; @@ -37,9 +36,7 @@ interface OnboardingDialogProps { type AgentSetting = { profileId: string; - model: string; - permissions: Record; - passthrough: boolean; + formData: ProfileFormData; dirty: boolean; }; @@ -99,21 +96,15 @@ export function OnboardingDialog({ open, onComplete }: OnboardingDialogProps) { const dbAgent = saved.find((a) => a.name === aa.name); const profile = dbAgent?.profiles?.[0]; if (profile) { - const perms: Record = {}; - if (aa.permission_settings) { - for (const [key, setting] of Object.entries(aa.permission_settings)) { - if (setting.supported) { - // Map permission key to profile field - const profileValue = getProfilePermissionValue(profile, key); - perms[key] = profileValue ?? setting.default; - } - } - } + const perms = profileToPermissionsMap(profile, aa.permission_settings ?? {}); settings[aa.name] = { profileId: profile.id, - model: profile.model || aa.model_config.default_model, - permissions: perms, - passthrough: profile.cli_passthrough ?? false, + formData: { + name: profile.name, + model: profile.model || aa.model_config.default_model, + cli_passthrough: profile.cli_passthrough ?? false, + ...perms, + }, dirty: false, }; } @@ -134,11 +125,9 @@ export function OnboardingDialog({ open, onComplete }: OnboardingDialogProps) { await Promise.all( dirtyEntries.map(([, s]) => updateAgentProfileAction(s.profileId, { - model: s.model, - auto_approve: s.permissions['auto_approve'] ?? false, - dangerously_skip_permissions: s.permissions['dangerously_skip_permissions'] ?? false, - allow_indexing: s.permissions['allow_indexing'] ?? false, - cli_passthrough: s.passthrough, + model: s.formData.model, + ...permissionsToProfilePatch(s.formData), + cli_passthrough: s.formData.cli_passthrough, }) ) ); @@ -170,12 +159,12 @@ export function OnboardingDialog({ open, onComplete }: OnboardingDialogProps) { setStep(0); }; - const updateSetting = (agentName: string, patch: Partial) => { + const updateSetting = (agentName: string, formPatch: Partial) => { setAgentSettings((prev) => ({ ...prev, [agentName]: { ...prev[agentName], - ...patch, + formData: { ...prev[agentName].formData, ...formPatch }, dirty: true, }, })); @@ -253,23 +242,6 @@ export function OnboardingDialog({ open, onComplete }: OnboardingDialogProps) { ); } -/** Map a permission settings key to the corresponding AgentProfile boolean field value. */ -function getProfilePermissionValue( - profile: { auto_approve?: boolean; dangerously_skip_permissions?: boolean; allow_indexing?: boolean }, - key: string -): boolean | undefined { - switch (key) { - case 'auto_approve': - return profile.auto_approve; - case 'dangerously_skip_permissions': - return profile.dangerously_skip_permissions; - case 'allow_indexing': - return profile.allow_indexing; - default: - return undefined; - } -} - function StepAgents({ availableAgents, agentSettings, @@ -279,7 +251,7 @@ function StepAgents({ availableAgents: AvailableAgent[]; agentSettings: Record; loading: boolean; - onUpdateSetting: (agentName: string, patch: Partial) => void; + onUpdateSetting: (agentName: string, formPatch: Partial) => void; }) { const [openAgent, setOpenAgent] = useState(null); @@ -299,10 +271,10 @@ function StepAgents({
{agents.map((agent) => { const settings = agentSettings[agent.name]; + const currentModel = settings?.formData.model || agent.model_config.default_model; const modelName = - agent.model_config.available_models.find( - (m) => m.id === (settings?.model || agent.model_config.default_model) - )?.name ?? settings?.model ?? agent.model_config.default_model; + agent.model_config.available_models.find((m) => m.id === currentModel)?.name ?? + currentModel; return ( -
- {/* Model selector */} -
- - onUpdateSetting(agent.name, { model: value })} - models={agent.model_config.available_models} - defaultModel={agent.model_config.default_model} +
+ {settings && ( + onUpdateSetting(agent.name, patch)} + modelConfig={agent.model_config} + permissionSettings={agent.permission_settings ?? {}} + passthroughConfig={agent.passthrough_config ?? null} agentName={agent.name} - supportsDynamicModels={agent.model_config.supports_dynamic_models} /> -
- - {/* Permission toggles */} - {agent.permission_settings && - Object.entries(agent.permission_settings).map(([key, perm]) => { - if (!perm.supported) return null; - return ( -
-
- -

- {perm.description} -

-
- - onUpdateSetting(agent.name, { - permissions: { - ...settings?.permissions, - [key]: checked === true, - }, - }) - } - /> -
- ); - })} - - {/* Passthrough toggle */} - {agent.passthrough_config?.supported && ( -
-
- -

- {agent.passthrough_config.description} -

-
- - onUpdateSetting(agent.name, { passthrough: checked === true }) - } - /> -
)}
diff --git a/apps/web/components/settings/profile-form-fields.tsx b/apps/web/components/settings/profile-form-fields.tsx index ad40d75f67..2b1e02c485 100644 --- a/apps/web/components/settings/profile-form-fields.tsx +++ b/apps/web/components/settings/profile-form-fields.tsx @@ -5,16 +5,14 @@ import { Input } from '@kandev/ui/input'; import { Label } from '@kandev/ui/label'; import { Switch } from '@kandev/ui/switch'; import { ModelCombobox } from '@/components/settings/model-combobox'; +import { PERMISSION_KEYS, type PermissionKey } from '@/lib/agent-permissions'; import type { ModelConfig, PermissionSetting, PassthroughConfig } from '@/lib/types/http'; export type ProfileFormData = { name: string; model: string; - auto_approve: boolean; - dangerously_skip_permissions: boolean; - allow_indexing?: boolean; cli_passthrough: boolean; -}; +} & Record; export type ProfileFormFieldsProps = { profile: ProfileFormData; @@ -25,6 +23,8 @@ export type ProfileFormFieldsProps = { agentName: string; onRemove?: () => void; canRemove?: boolean; + variant?: 'default' | 'compact'; + hideNameField?: boolean; }; export function ProfileFormFields({ @@ -36,27 +36,38 @@ export function ProfileFormFields({ agentName, onRemove, canRemove = false, + variant = 'default', + hideNameField = false, }: ProfileFormFieldsProps) { - return ( -
-
-
- - onChange({ name: event.target.value })} - placeholder="Default profile" - /> -
- {canRemove && onRemove && ( - - )} -
+ const isCompact = variant === 'compact'; + const switchSize = isCompact ? ('sm' as const) : ('default' as const); -
- + return ( +
+ {!hideNameField && ( +
+
+ + onChange({ name: event.target.value })} + placeholder="Default profile" + /> +
+ {canRemove && onRemove && ( + + )} +
+ )} + +
+ {isCompact ? ( + + ) : ( + + )} onChange({ model: value })} @@ -68,69 +79,81 @@ export function ProfileFormFields({ />
-
- {permissionSettings.auto_approve?.supported && ( -
-
- -

- {permissionSettings.auto_approve.description} -

-
- onChange({ auto_approve: checked })} - /> -
- )} - - {permissionSettings.dangerously_skip_permissions?.supported && ( -
-
- -

- {permissionSettings.dangerously_skip_permissions.description} -

-
- onChange({ dangerously_skip_permissions: checked })} - /> -
- )} - - {permissionSettings.allow_indexing?.supported && ( -
-
- -

- {permissionSettings.allow_indexing.description} -

-
- onChange({ allow_indexing: checked })} - /> -
- )} - - {passthroughConfig?.supported && ( -
-
-
- + {isCompact ? ( + <> + {PERMISSION_KEYS.map((key) => { + const setting = permissionSettings[key]; + if (!setting?.supported) return null; + return ( +
+
+ +

+ {setting.description} +

+
+ onChange({ [key]: checked })} + />
-

- {passthroughConfig.description} -

+ ); + })} + + {passthroughConfig?.supported && ( +
+
+ +

+ {passthroughConfig.description} +

+
+ onChange({ cli_passthrough: checked })} + />
- onChange({ cli_passthrough: checked })} - /> -
- )} -
+ )} + + ) : ( +
+ {PERMISSION_KEYS.map((key) => { + const setting = permissionSettings[key]; + if (!setting?.supported) return null; + return ( +
+
+ +

+ {setting.description} +

+
+ onChange({ [key]: checked })} + /> +
+ ); + })} + + {passthroughConfig?.supported && ( +
+
+ +

+ {passthroughConfig.description} +

+
+ onChange({ cli_passthrough: checked })} + /> +
+ )} +
+ )}
); } diff --git a/apps/web/lib/agent-permissions.test.ts b/apps/web/lib/agent-permissions.test.ts new file mode 100644 index 0000000000..cad69d9ef7 --- /dev/null +++ b/apps/web/lib/agent-permissions.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from 'vitest'; +import { + profileToPermissionsMap, + permissionsToProfilePatch, + buildDefaultPermissions, + arePermissionsDirty, +} from './agent-permissions'; +import type { PermissionSetting } from '@/lib/types/http'; + +const mockSettings: Record = { + auto_approve: { supported: true, default: true, label: 'Auto approve', description: '' }, + dangerously_skip_permissions: { supported: true, default: false, label: 'Skip perms', description: '' }, + allow_indexing: { supported: true, default: true, label: 'Indexing', description: '' }, +}; + +describe('profileToPermissionsMap', () => { + it('uses profile values when present', () => { + const result = profileToPermissionsMap( + { auto_approve: false, dangerously_skip_permissions: true, allow_indexing: false }, + mockSettings + ); + expect(result).toEqual({ + auto_approve: false, + dangerously_skip_permissions: true, + allow_indexing: false, + }); + }); + + it('falls back to setting defaults for missing keys', () => { + const result = profileToPermissionsMap({}, mockSettings); + expect(result).toEqual({ + auto_approve: true, + dangerously_skip_permissions: false, + allow_indexing: true, + }); + }); + + it('falls back to false when setting is also missing', () => { + const result = profileToPermissionsMap({}, {}); + expect(result).toEqual({ + auto_approve: false, + dangerously_skip_permissions: false, + allow_indexing: false, + }); + }); +}); + +describe('permissionsToProfilePatch', () => { + it('returns all permission keys with values', () => { + const result = permissionsToProfilePatch({ + auto_approve: true, + dangerously_skip_permissions: true, + allow_indexing: true, + }); + expect(result).toEqual({ + auto_approve: true, + dangerously_skip_permissions: true, + allow_indexing: true, + }); + }); + + it('defaults missing keys to false', () => { + const result = permissionsToProfilePatch({}); + expect(result).toEqual({ + auto_approve: false, + dangerously_skip_permissions: false, + allow_indexing: false, + }); + }); +}); + +describe('buildDefaultPermissions', () => { + it('builds defaults from permission settings', () => { + const result = buildDefaultPermissions(mockSettings); + expect(result).toEqual({ + auto_approve: true, + dangerously_skip_permissions: false, + allow_indexing: true, + }); + }); + + it('defaults to false for missing settings', () => { + const result = buildDefaultPermissions({}); + expect(result).toEqual({ + auto_approve: false, + dangerously_skip_permissions: false, + allow_indexing: false, + }); + }); +}); + +describe('arePermissionsDirty', () => { + it('returns false when all permissions match', () => { + const a = { auto_approve: true, dangerously_skip_permissions: false, allow_indexing: true }; + expect(arePermissionsDirty(a, { ...a })).toBe(false); + }); + + it('returns true when any permission differs', () => { + const a = { auto_approve: true, dangerously_skip_permissions: false, allow_indexing: true }; + const b = { auto_approve: true, dangerously_skip_permissions: true, allow_indexing: true }; + expect(arePermissionsDirty(a, b)).toBe(true); + }); + + it('detects difference when one side has undefined', () => { + const a = { auto_approve: true }; + const b = { auto_approve: false }; + expect(arePermissionsDirty(a, b)).toBe(true); + }); +}); diff --git a/apps/web/lib/agent-permissions.ts b/apps/web/lib/agent-permissions.ts new file mode 100644 index 0000000000..8b932e1f4b --- /dev/null +++ b/apps/web/lib/agent-permissions.ts @@ -0,0 +1,65 @@ +import type { AgentProfile, PermissionSetting } from '@/lib/types/http'; + +/** + * Single source of truth for permission keys. + * Adding a key here that doesn't exist on AgentProfile will cause a compile error. + */ +export const PERMISSION_KEYS = [ + 'auto_approve', + 'dangerously_skip_permissions', + 'allow_indexing', +] as const; + +export type PermissionKey = (typeof PERMISSION_KEYS)[number]; + +// Compile-time check: every PermissionKey must be a boolean key on AgentProfile. +// eslint-disable-next-line @typescript-eslint/no-unused-vars +type _AssertKeysExist = { + [K in PermissionKey]: AgentProfile[K] extends boolean ? true : never; +}; + +/** Extract permission booleans from a profile-like object, using backend defaults for missing values. */ +export function profileToPermissionsMap( + profile: Partial>, + permissionSettings: Record +): Record { + const result = {} as Record; + for (const key of PERMISSION_KEYS) { + const setting = permissionSettings[key]; + result[key] = profile[key] ?? setting?.default ?? false; + } + return result; +} + +/** Convert an object containing permission keys to a typed patch for API calls. */ +export function permissionsToProfilePatch( + perms: Partial> +): Pick { + const result = {} as Pick; + for (const key of PERMISSION_KEYS) { + result[key] = perms[key] ?? false; + } + return result; +} + +/** Create default permission values from backend metadata. */ +export function buildDefaultPermissions( + permissionSettings: Record +): Record { + const result = {} as Record; + for (const key of PERMISSION_KEYS) { + result[key] = permissionSettings[key]?.default ?? false; + } + return result; +} + +/** Compare permission fields between two profile-like objects. */ +export function arePermissionsDirty( + draft: Partial>, + saved: Partial> +): boolean { + for (const key of PERMISSION_KEYS) { + if (draft[key] !== saved[key]) return true; + } + return false; +}