feat: improve agent sort (#231)

This commit is contained in:
Carlos Florêncio
2026-02-16 20:38:01 +00:00
committed by GitHub
parent 35f8d02a4d
commit aac9d0610e
12 changed files with 396 additions and 237 deletions
+1 -1
View File
@@ -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 {
@@ -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 {
+1 -1
View File
@@ -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 {
@@ -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 {
@@ -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
}
@@ -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"
+5 -16
View File
@@ -9,6 +9,9 @@ import type {
ListAgentsResponse,
ListAgentDiscoveryResponse,
} from '@/lib/types/http';
import type { PermissionKey } from '@/lib/agent-permissions';
type ProfilePermissions = Record<PermissionKey, boolean>;
const { apiBaseUrl } = getBackendConfig();
@@ -45,14 +48,7 @@ export async function listAgentsAction(): Promise<ListAgentsResponse> {
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<Agent> {
return fetchJson<Agent>(`${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<AgentProfile> {
return fetchJson<AgentProfile>(`${apiBaseUrl}/api/v1/agents/${agentId}/profiles`, {
method: 'POST',
@@ -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)}
+30 -105
View File
@@ -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<string, boolean>;
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<string, boolean> = {};
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<AgentSetting>) => {
const updateSetting = (agentName: string, formPatch: Partial<ProfileFormData>) => {
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<string, AgentSetting>;
loading: boolean;
onUpdateSetting: (agentName: string, patch: Partial<AgentSetting>) => void;
onUpdateSetting: (agentName: string, formPatch: Partial<ProfileFormData>) => void;
}) {
const [openAgent, setOpenAgent] = useState<string | null>(null);
@@ -299,10 +271,10 @@ function StepAgents({
<div className="grid gap-2 max-h-[320px] overflow-y-auto pr-1">
{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 (
<Collapsible
@@ -313,7 +285,7 @@ function StepAgents({
<CollapsibleTrigger asChild>
<button
type="button"
className="flex w-full items-center gap-3 rounded-lg border p-3 text-left cursor-pointer hover:bg-muted/50 transition-colors group"
className="flex w-full items-center gap-3 rounded-lg border p-2 text-left cursor-pointer hover:bg-muted/50 transition-colors group"
>
<AgentLogo agentName={agent.name} size={28} />
<div className="flex-1 min-w-0">
@@ -330,65 +302,18 @@ function StepAgents({
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="border border-t-0 rounded-b-lg px-3 pb-3 pt-2 space-y-3">
{/* Model selector */}
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground">Model</Label>
<ModelCombobox
value={settings?.model || agent.model_config.default_model}
onChange={(value) => onUpdateSetting(agent.name, { model: value })}
models={agent.model_config.available_models}
defaultModel={agent.model_config.default_model}
<div className="border border-t-0 rounded-b-lg px-3 pb-3 pt-2">
{settings && (
<ProfileFormFields
variant="compact"
hideNameField
profile={settings.formData}
onChange={(patch) => 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}
/>
</div>
{/* Permission toggles */}
{agent.permission_settings &&
Object.entries(agent.permission_settings).map(([key, perm]) => {
if (!perm.supported) return null;
return (
<div key={key} className="flex items-center justify-between gap-2">
<div className="space-y-0.5">
<Label className="text-xs">{perm.label}</Label>
<p className="text-[10px] text-muted-foreground leading-tight">
{perm.description}
</p>
</div>
<Switch
size="sm"
checked={settings?.permissions[key] ?? perm.default}
onCheckedChange={(checked) =>
onUpdateSetting(agent.name, {
permissions: {
...settings?.permissions,
[key]: checked === true,
},
})
}
/>
</div>
);
})}
{/* Passthrough toggle */}
{agent.passthrough_config?.supported && (
<div className="flex items-center justify-between gap-2">
<div className="space-y-0.5">
<Label className="text-xs">{agent.passthrough_config.label}</Label>
<p className="text-[10px] text-muted-foreground leading-tight">
{agent.passthrough_config.description}
</p>
</div>
<Switch
size="sm"
checked={settings?.passthrough ?? false}
onCheckedChange={(checked) =>
onUpdateSetting(agent.name, { passthrough: checked === true })
}
/>
</div>
)}
</div>
</CollapsibleContent>
@@ -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<PermissionKey, boolean>;
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 (
<div className="space-y-4">
<div className="flex items-center justify-between gap-4">
<div className="flex-1 space-y-2">
<Label>Profile name</Label>
<Input
value={profile.name}
onChange={(event) => onChange({ name: event.target.value })}
placeholder="Default profile"
/>
</div>
{canRemove && onRemove && (
<Button size="sm" variant="ghost" onClick={onRemove}>
Remove
</Button>
)}
</div>
const isCompact = variant === 'compact';
const switchSize = isCompact ? ('sm' as const) : ('default' as const);
<div className="space-y-2">
<Label>Model</Label>
return (
<div className={isCompact ? 'space-y-3' : 'space-y-4'}>
{!hideNameField && (
<div className="flex items-center justify-between gap-4">
<div className="flex-1 space-y-2">
<Label>Profile name</Label>
<Input
value={profile.name}
onChange={(event) => onChange({ name: event.target.value })}
placeholder="Default profile"
/>
</div>
{canRemove && onRemove && (
<Button size="sm" variant="ghost" onClick={onRemove}>
Remove
</Button>
)}
</div>
)}
<div className={isCompact ? 'space-y-1.5' : 'space-y-2'}>
{isCompact ? (
<Label className="text-xs text-muted-foreground">Model</Label>
) : (
<Label>Model</Label>
)}
<ModelCombobox
value={profile.model || modelConfig.default_model}
onChange={(value) => onChange({ model: value })}
@@ -68,69 +79,81 @@ export function ProfileFormFields({
/>
</div>
<div className="grid gap-4 md:grid-cols-2">
{permissionSettings.auto_approve?.supported && (
<div className="flex items-center justify-between rounded-md border p-3">
<div className="space-y-1">
<Label>{permissionSettings.auto_approve.label}</Label>
<p className="text-xs text-muted-foreground">
{permissionSettings.auto_approve.description}
</p>
</div>
<Switch
checked={profile.auto_approve}
onCheckedChange={(checked) => onChange({ auto_approve: checked })}
/>
</div>
)}
{permissionSettings.dangerously_skip_permissions?.supported && (
<div className="flex items-center justify-between rounded-md border p-3">
<div className="space-y-1">
<Label>{permissionSettings.dangerously_skip_permissions.label}</Label>
<p className="text-xs text-muted-foreground">
{permissionSettings.dangerously_skip_permissions.description}
</p>
</div>
<Switch
checked={profile.dangerously_skip_permissions}
onCheckedChange={(checked) => onChange({ dangerously_skip_permissions: checked })}
/>
</div>
)}
{permissionSettings.allow_indexing?.supported && (
<div className="flex items-center justify-between rounded-md border p-3">
<div className="space-y-1">
<Label>{permissionSettings.allow_indexing.label}</Label>
<p className="text-xs text-muted-foreground">
{permissionSettings.allow_indexing.description}
</p>
</div>
<Switch
checked={profile.allow_indexing ?? false}
onCheckedChange={(checked) => onChange({ allow_indexing: checked })}
/>
</div>
)}
{passthroughConfig?.supported && (
<div className="flex items-center justify-between rounded-md border p-3">
<div className="space-y-1">
<div className="flex items-center gap-2">
<Label>{passthroughConfig.label}</Label>
{isCompact ? (
<>
{PERMISSION_KEYS.map((key) => {
const setting = permissionSettings[key];
if (!setting?.supported) return null;
return (
<div key={key} className="flex items-center justify-between gap-2">
<div className="space-y-0.5">
<Label className="text-xs">{setting.label}</Label>
<p className="text-[10px] text-muted-foreground leading-tight">
{setting.description}
</p>
</div>
<Switch
size={switchSize}
checked={profile[key]}
onCheckedChange={(checked) => onChange({ [key]: checked })}
/>
</div>
<p className="text-xs text-muted-foreground">
{passthroughConfig.description}
</p>
);
})}
{passthroughConfig?.supported && (
<div className="flex items-center justify-between gap-2">
<div className="space-y-0.5">
<Label className="text-xs">{passthroughConfig.label}</Label>
<p className="text-[10px] text-muted-foreground leading-tight">
{passthroughConfig.description}
</p>
</div>
<Switch
size={switchSize}
checked={profile.cli_passthrough}
onCheckedChange={(checked) => onChange({ cli_passthrough: checked })}
/>
</div>
<Switch
checked={profile.cli_passthrough}
onCheckedChange={(checked) => onChange({ cli_passthrough: checked })}
/>
</div>
)}
</div>
)}
</>
) : (
<div className="grid gap-4 md:grid-cols-2">
{PERMISSION_KEYS.map((key) => {
const setting = permissionSettings[key];
if (!setting?.supported) return null;
return (
<div key={key} className="flex items-center justify-between rounded-md border p-3">
<div className="space-y-1">
<Label>{setting.label}</Label>
<p className="text-xs text-muted-foreground">
{setting.description}
</p>
</div>
<Switch
checked={profile[key]}
onCheckedChange={(checked) => onChange({ [key]: checked })}
/>
</div>
);
})}
{passthroughConfig?.supported && (
<div className="flex items-center justify-between rounded-md border p-3">
<div className="space-y-1">
<Label>{passthroughConfig.label}</Label>
<p className="text-xs text-muted-foreground">
{passthroughConfig.description}
</p>
</div>
<Switch
checked={profile.cli_passthrough}
onCheckedChange={(checked) => onChange({ cli_passthrough: checked })}
/>
</div>
)}
</div>
)}
</div>
);
}
+109
View File
@@ -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<string, PermissionSetting> = {
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);
});
});
+65
View File
@@ -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<Pick<AgentProfile, PermissionKey>>,
permissionSettings: Record<string, PermissionSetting>
): Record<PermissionKey, boolean> {
const result = {} as Record<PermissionKey, boolean>;
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<Record<PermissionKey, boolean>>
): Pick<AgentProfile, PermissionKey> {
const result = {} as Pick<AgentProfile, PermissionKey>;
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<string, PermissionSetting>
): Record<PermissionKey, boolean> {
const result = {} as Record<PermissionKey, boolean>;
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<Pick<AgentProfile, PermissionKey>>,
saved: Partial<Pick<AgentProfile, PermissionKey>>
): boolean {
for (const key of PERMISSION_KEYS) {
if (draft[key] !== saved[key]) return true;
}
return false;
}