@open-mercato/ai-assistant 0.6.1-develop.3246.1.dbef9d7392 → 0.6.1-develop.3256.1.fe3dec2464

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +82 -18
  3. package/dist/modules/ai_assistant/__integration__/TC-AI-RUNTIME-OVERRIDES-006-model-picker.spec.js +370 -0
  4. package/dist/modules/ai_assistant/__integration__/TC-AI-RUNTIME-OVERRIDES-006-model-picker.spec.js.map +7 -0
  5. package/dist/modules/ai_assistant/api/ai/agents/[agentId]/models/route.js +194 -0
  6. package/dist/modules/ai_assistant/api/ai/agents/[agentId]/models/route.js.map +7 -0
  7. package/dist/modules/ai_assistant/api/ai/agents/route.js +4 -0
  8. package/dist/modules/ai_assistant/api/ai/agents/route.js.map +2 -2
  9. package/dist/modules/ai_assistant/api/ai/chat/route.js +169 -5
  10. package/dist/modules/ai_assistant/api/ai/chat/route.js.map +2 -2
  11. package/dist/modules/ai_assistant/api/route/route.js +38 -19
  12. package/dist/modules/ai_assistant/api/route/route.js.map +3 -3
  13. package/dist/modules/ai_assistant/api/settings/allowlist/route.js +195 -0
  14. package/dist/modules/ai_assistant/api/settings/allowlist/route.js.map +7 -0
  15. package/dist/modules/ai_assistant/api/settings/route.js +537 -22
  16. package/dist/modules/ai_assistant/api/settings/route.js.map +3 -3
  17. package/dist/modules/ai_assistant/backend/config/ai-assistant/agents/AiAgentSettingsPageClient.js +701 -147
  18. package/dist/modules/ai_assistant/backend/config/ai-assistant/agents/AiAgentSettingsPageClient.js.map +2 -2
  19. package/dist/modules/ai_assistant/backend/config/ai-assistant/allowlist/AiTenantAllowlistPageClient.js +338 -0
  20. package/dist/modules/ai_assistant/backend/config/ai-assistant/allowlist/AiTenantAllowlistPageClient.js.map +7 -0
  21. package/dist/modules/ai_assistant/backend/config/ai-assistant/allowlist/page.js +10 -0
  22. package/dist/modules/ai_assistant/backend/config/ai-assistant/allowlist/page.js.map +7 -0
  23. package/dist/modules/ai_assistant/backend/config/ai-assistant/allowlist/page.meta.js +25 -0
  24. package/dist/modules/ai_assistant/backend/config/ai-assistant/allowlist/page.meta.js.map +7 -0
  25. package/dist/modules/ai_assistant/backend/config/ai-assistant/legacy/page.js +1 -1
  26. package/dist/modules/ai_assistant/backend/config/ai-assistant/legacy/page.js.map +2 -2
  27. package/dist/modules/ai_assistant/backend/config/ai-assistant/playground/AiPlaygroundPageClient.js +75 -26
  28. package/dist/modules/ai_assistant/backend/config/ai-assistant/playground/AiPlaygroundPageClient.js.map +2 -2
  29. package/dist/modules/ai_assistant/backend/config/ai-assistant/settings/page.js +10 -0
  30. package/dist/modules/ai_assistant/backend/config/ai-assistant/settings/page.js.map +7 -0
  31. package/dist/modules/ai_assistant/backend/config/ai-assistant/settings/page.meta.js +25 -0
  32. package/dist/modules/ai_assistant/backend/config/ai-assistant/settings/page.meta.js.map +7 -0
  33. package/dist/modules/ai_assistant/components/AiAssistantSettingsPageClient.js +503 -168
  34. package/dist/modules/ai_assistant/components/AiAssistantSettingsPageClient.js.map +2 -2
  35. package/dist/modules/ai_assistant/data/entities/AiAgentRuntimeOverride.js +5 -0
  36. package/dist/modules/ai_assistant/data/entities/AiAgentRuntimeOverride.js.map +7 -0
  37. package/dist/modules/ai_assistant/data/entities/AiTenantModelAllowlist.js +5 -0
  38. package/dist/modules/ai_assistant/data/entities/AiTenantModelAllowlist.js.map +7 -0
  39. package/dist/modules/ai_assistant/data/entities.js +123 -1
  40. package/dist/modules/ai_assistant/data/entities.js.map +2 -2
  41. package/dist/modules/ai_assistant/data/repositories/AiAgentRuntimeOverrideRepository.js +157 -0
  42. package/dist/modules/ai_assistant/data/repositories/AiAgentRuntimeOverrideRepository.js.map +7 -0
  43. package/dist/modules/ai_assistant/data/repositories/AiTenantModelAllowlistRepository.js +77 -0
  44. package/dist/modules/ai_assistant/data/repositories/AiTenantModelAllowlistRepository.js.map +7 -0
  45. package/dist/modules/ai_assistant/frontend/components/AiAssistantSettingsPageClient.js +1 -1
  46. package/dist/modules/ai_assistant/frontend/components/AiAssistantSettingsPageClient.js.map +2 -2
  47. package/dist/modules/ai_assistant/i18n/de.json +90 -1
  48. package/dist/modules/ai_assistant/i18n/en.json +90 -1
  49. package/dist/modules/ai_assistant/i18n/es.json +90 -1
  50. package/dist/modules/ai_assistant/i18n/pl.json +90 -1
  51. package/dist/modules/ai_assistant/lib/agent-registry.js +17 -1
  52. package/dist/modules/ai_assistant/lib/agent-registry.js.map +2 -2
  53. package/dist/modules/ai_assistant/lib/agent-runtime.js +133 -36
  54. package/dist/modules/ai_assistant/lib/agent-runtime.js.map +2 -2
  55. package/dist/modules/ai_assistant/lib/ai-agent-definition.js.map +2 -2
  56. package/dist/modules/ai_assistant/lib/baseurl-allowlist.js +29 -0
  57. package/dist/modules/ai_assistant/lib/baseurl-allowlist.js.map +7 -0
  58. package/dist/modules/ai_assistant/lib/llm-adapters/anthropic.js +4 -1
  59. package/dist/modules/ai_assistant/lib/llm-adapters/anthropic.js.map +2 -2
  60. package/dist/modules/ai_assistant/lib/llm-adapters/google.js +4 -1
  61. package/dist/modules/ai_assistant/lib/llm-adapters/google.js.map +2 -2
  62. package/dist/modules/ai_assistant/lib/model-allowlist.js +211 -0
  63. package/dist/modules/ai_assistant/lib/model-allowlist.js.map +7 -0
  64. package/dist/modules/ai_assistant/lib/model-factory.js +203 -31
  65. package/dist/modules/ai_assistant/lib/model-factory.js.map +2 -2
  66. package/dist/modules/ai_assistant/lib/openai-compatible-presets.js +32 -1
  67. package/dist/modules/ai_assistant/lib/openai-compatible-presets.js.map +2 -2
  68. package/dist/modules/ai_assistant/migrations/Migration20260508140000.js +18 -0
  69. package/dist/modules/ai_assistant/migrations/Migration20260508140000.js.map +7 -0
  70. package/dist/modules/ai_assistant/migrations/Migration20260512090000.js +16 -0
  71. package/dist/modules/ai_assistant/migrations/Migration20260512090000.js.map +7 -0
  72. package/dist/modules/ai_assistant/migrations/Migration20260512130000.js +15 -0
  73. package/dist/modules/ai_assistant/migrations/Migration20260512130000.js.map +7 -0
  74. package/generated/entities/ai_agent_runtime_override/index.ts +13 -0
  75. package/generated/entities/ai_tenant_model_allowlist/index.ts +9 -0
  76. package/generated/entities.ids.generated.ts +2 -0
  77. package/generated/entity-fields-registry.ts +26 -0
  78. package/jest.config.cjs +2 -0
  79. package/package.json +4 -4
  80. package/src/modules/ai_assistant/__integration__/TC-AI-RUNTIME-OVERRIDES-006-model-picker.spec.ts +477 -0
  81. package/src/modules/ai_assistant/__tests__/settings-page-logic.test.ts +116 -0
  82. package/src/modules/ai_assistant/api/ai/agents/[agentId]/models/__tests__/route.test.ts +240 -0
  83. package/src/modules/ai_assistant/api/ai/agents/[agentId]/models/route.ts +251 -0
  84. package/src/modules/ai_assistant/api/ai/agents/route.ts +4 -0
  85. package/src/modules/ai_assistant/api/ai/chat/__tests__/route.test.ts +273 -0
  86. package/src/modules/ai_assistant/api/ai/chat/route.ts +211 -2
  87. package/src/modules/ai_assistant/api/route/route.ts +49 -25
  88. package/src/modules/ai_assistant/api/settings/__tests__/route.test.ts +408 -0
  89. package/src/modules/ai_assistant/api/settings/allowlist/route.ts +221 -0
  90. package/src/modules/ai_assistant/api/settings/route.ts +721 -27
  91. package/src/modules/ai_assistant/backend/config/ai-assistant/agents/AiAgentSettingsPageClient.tsx +858 -177
  92. package/src/modules/ai_assistant/backend/config/ai-assistant/allowlist/AiTenantAllowlistPageClient.tsx +458 -0
  93. package/src/modules/ai_assistant/backend/config/ai-assistant/allowlist/page.meta.ts +23 -0
  94. package/src/modules/ai_assistant/backend/config/ai-assistant/allowlist/page.tsx +12 -0
  95. package/src/modules/ai_assistant/backend/config/ai-assistant/legacy/page.tsx +1 -1
  96. package/src/modules/ai_assistant/backend/config/ai-assistant/playground/AiPlaygroundPageClient.tsx +89 -12
  97. package/src/modules/ai_assistant/backend/config/ai-assistant/settings/page.meta.ts +23 -0
  98. package/src/modules/ai_assistant/backend/config/ai-assistant/settings/page.tsx +18 -0
  99. package/src/modules/ai_assistant/components/AiAssistantSettingsPageClient.tsx +617 -209
  100. package/src/modules/ai_assistant/data/entities/AiAgentRuntimeOverride.ts +7 -0
  101. package/src/modules/ai_assistant/data/entities/AiTenantModelAllowlist.ts +2 -0
  102. package/src/modules/ai_assistant/data/entities.ts +164 -0
  103. package/src/modules/ai_assistant/data/repositories/AiAgentRuntimeOverrideRepository.ts +227 -0
  104. package/src/modules/ai_assistant/data/repositories/AiTenantModelAllowlistRepository.ts +132 -0
  105. package/src/modules/ai_assistant/data/repositories/__tests__/AiAgentRuntimeOverrideRepository.test.ts +337 -0
  106. package/src/modules/ai_assistant/data/repositories/__tests__/AiTenantModelAllowlistRepository.test.ts +181 -0
  107. package/src/modules/ai_assistant/frontend/components/AiAssistantSettingsPageClient.tsx +1 -1
  108. package/src/modules/ai_assistant/i18n/de.json +90 -1
  109. package/src/modules/ai_assistant/i18n/en.json +90 -1
  110. package/src/modules/ai_assistant/i18n/es.json +90 -1
  111. package/src/modules/ai_assistant/i18n/pl.json +90 -1
  112. package/src/modules/ai_assistant/lib/__tests__/agent-runtime-phase4a.test.ts +396 -0
  113. package/src/modules/ai_assistant/lib/__tests__/agent-runtime.test.ts +60 -6
  114. package/src/modules/ai_assistant/lib/__tests__/ai-api-operation-runner.test.ts +4 -2
  115. package/src/modules/ai_assistant/lib/__tests__/baseurl-allowlist.test.ts +75 -0
  116. package/src/modules/ai_assistant/lib/__tests__/llm-adapters-anthropic.test.ts +18 -0
  117. package/src/modules/ai_assistant/lib/__tests__/llm-adapters-google.test.ts +18 -0
  118. package/src/modules/ai_assistant/lib/__tests__/llm-adapters-openai.test.ts +150 -4
  119. package/src/modules/ai_assistant/lib/__tests__/model-allowlist.test.ts +290 -0
  120. package/src/modules/ai_assistant/lib/__tests__/model-factory.test.ts +634 -0
  121. package/src/modules/ai_assistant/lib/agent-registry.ts +20 -1
  122. package/src/modules/ai_assistant/lib/agent-runtime.ts +220 -44
  123. package/src/modules/ai_assistant/lib/ai-agent-definition.ts +48 -0
  124. package/src/modules/ai_assistant/lib/baseurl-allowlist.ts +64 -0
  125. package/src/modules/ai_assistant/lib/llm-adapters/anthropic.ts +11 -1
  126. package/src/modules/ai_assistant/lib/llm-adapters/google.ts +4 -1
  127. package/src/modules/ai_assistant/lib/model-allowlist.ts +407 -0
  128. package/src/modules/ai_assistant/lib/model-factory.ts +486 -58
  129. package/src/modules/ai_assistant/lib/openai-compatible-presets.ts +44 -0
  130. package/src/modules/ai_assistant/migrations/.snapshot-open-mercato.json +704 -235
  131. package/src/modules/ai_assistant/migrations/Migration20260508140000.ts +18 -0
  132. package/src/modules/ai_assistant/migrations/Migration20260512090000.ts +16 -0
  133. package/src/modules/ai_assistant/migrations/Migration20260512130000.ts +13 -0
@@ -24,8 +24,17 @@ import { useT } from '@open-mercato/shared/lib/i18n/context'
24
24
  import { Alert, AlertDescription, AlertTitle } from '@open-mercato/ui/primitives/alert'
25
25
  import { Badge } from '@open-mercato/ui/primitives/badge'
26
26
  import { Button } from '@open-mercato/ui/primitives/button'
27
+ import { Checkbox } from '@open-mercato/ui/primitives/checkbox'
27
28
  import { IconButton } from '@open-mercato/ui/primitives/icon-button'
28
29
  import { Label } from '@open-mercato/ui/primitives/label'
30
+ import { Radio, RadioGroup } from '@open-mercato/ui/primitives/radio'
31
+ import {
32
+ Select,
33
+ SelectContent,
34
+ SelectItem,
35
+ SelectTrigger,
36
+ SelectValue,
37
+ } from '@open-mercato/ui/primitives/select'
29
38
  import { StatusBadge, type StatusMap } from '@open-mercato/ui/primitives/status-badge'
30
39
  import { Switch } from '@open-mercato/ui/primitives/switch'
31
40
  import { Textarea } from '@open-mercato/ui/primitives/textarea'
@@ -37,11 +46,13 @@ import {
37
46
  } from '@open-mercato/ui/primitives/tooltip'
38
47
  import { EmptyState } from '@open-mercato/ui/backend/EmptyState'
39
48
  import { apiCall, apiCallOrThrow } from '@open-mercato/ui/backend/utils/apiCall'
49
+ import { flash } from '@open-mercato/ui/backend/FlashMessages'
50
+ import { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'
40
51
  import { useAiShortcuts } from '@open-mercato/ui/ai'
41
52
 
42
- // Step 4.6: the <select>-based agent picker is deliberately duplicated between the
43
- // playground and this settings page. Duplicated markup is under the 50-line
44
- // threshold, so extraction stays deferred per the Step 4.6 brief.
53
+ // The agent picker is deliberately duplicated between the playground and this
54
+ // settings page. Duplicated markup is under the 50-line threshold, so extraction
55
+ // stays deferred per the Step 4.6 brief.
45
56
 
46
57
  type AgentTool = {
47
58
  name: string
@@ -57,6 +68,10 @@ type AgentSettings = {
57
68
  description: string
58
69
  systemPrompt: string
59
70
  executionMode: 'chat' | 'object'
71
+ defaultProvider: string | null
72
+ defaultModel: string | null
73
+ defaultBaseUrl: string | null
74
+ allowRuntimeModelOverride: boolean
60
75
  mutationPolicy: string
61
76
  readOnly: boolean
62
77
  maxSteps: number | null
@@ -72,6 +87,58 @@ type AgentsResponse = {
72
87
  total: number
73
88
  }
74
89
 
90
+ type ProviderConfig = {
91
+ id: string
92
+ name: string
93
+ defaultModel: string
94
+ configured: boolean
95
+ defaultModels: Array<{ id: string; name: string }>
96
+ }
97
+
98
+ type AgentResolution = {
99
+ agentId: string
100
+ moduleId: string
101
+ allowRuntimeModelOverride: boolean
102
+ codeDefaultProviderId: string | null
103
+ codeDefaultModelId: string | null
104
+ override: {
105
+ providerId: string | null
106
+ modelId: string | null
107
+ baseURL: string | null
108
+ updatedAt: string
109
+ } | null
110
+ runtimeOverrideAllowlist: {
111
+ env: TenantAllowlist | null
112
+ tenant: TenantAllowlist | null
113
+ effective: EffectiveAllowlist
114
+ envVarNames: {
115
+ providers: string
116
+ modelsByProvider: Record<string, string>
117
+ }
118
+ }
119
+ providerId: string
120
+ modelId: string
121
+ baseURL: string | null
122
+ source: string
123
+ }
124
+
125
+ type TenantAllowlist = {
126
+ allowedProviders: string[] | null
127
+ allowedModelsByProvider: Record<string, string[]>
128
+ }
129
+
130
+ type EffectiveAllowlist = {
131
+ providers: string[] | null
132
+ modelsByProvider: Record<string, string[]>
133
+ hasRestrictions: boolean
134
+ tenantOverridesActive: boolean
135
+ }
136
+
137
+ type RuntimeSettingsResponse = {
138
+ availableProviders: ProviderConfig[]
139
+ agents: AgentResolution[]
140
+ }
141
+
75
142
  const PROMPT_SECTION_IDS = [
76
143
  'role',
77
144
  'scope',
@@ -112,6 +179,16 @@ async function fetchAgents(): Promise<AgentsResponse> {
112
179
  return result
113
180
  }
114
181
 
182
+ async function fetchRuntimeSettings(): Promise<RuntimeSettingsResponse> {
183
+ const { result, status } = await apiCallOrThrow<RuntimeSettingsResponse>(
184
+ '/api/ai_assistant/settings',
185
+ { method: 'GET', credentials: 'include' },
186
+ { errorMessage: 'Failed to load runtime settings' },
187
+ )
188
+ if (!result) throw new Error(`Failed to load runtime settings (${status})`)
189
+ return result
190
+ }
191
+
115
192
  type OverrideVersion = {
116
193
  id: string
117
194
  agentId: string
@@ -258,8 +335,8 @@ function PromptSectionEditor({
258
335
  className="flex flex-col gap-2 rounded-md border border-border bg-muted/20 p-3"
259
336
  data-ai-agent-prompt-section={sectionId}
260
337
  >
261
- <div className="flex items-center justify-between gap-3">
262
- <div className="flex flex-col">
338
+ <div className="flex flex-wrap items-start justify-between gap-3">
339
+ <div className="flex min-w-0 flex-col">
263
340
  <span className="text-overline font-semibold uppercase tracking-wider text-muted-foreground">
264
341
  {sectionLabel}
265
342
  </span>
@@ -320,7 +397,7 @@ function ToolRow({ tool }: { tool: AgentTool }) {
320
397
  const t = useT()
321
398
  return (
322
399
  <div
323
- className="flex items-center justify-between gap-3 rounded-md border border-border bg-background px-3 py-2"
400
+ className="flex flex-wrap items-center justify-between gap-3 rounded-md border border-border bg-background px-3 py-2"
324
401
  data-ai-agent-tool-row={tool.name}
325
402
  >
326
403
  <div className="flex items-start gap-2 min-w-0">
@@ -330,7 +407,7 @@ function ToolRow({ tool }: { tool: AgentTool }) {
330
407
  <span className="truncate text-xs font-mono text-muted-foreground">{tool.name}</span>
331
408
  </div>
332
409
  </div>
333
- <div className="flex items-center gap-2 flex-shrink-0">
410
+ <div className="flex flex-wrap items-center gap-2 flex-shrink-0">
334
411
  {tool.isMutation ? (
335
412
  <StatusBadge variant="warning" dot>
336
413
  {t('ai_assistant.agents.tools.mutationBadge', 'Mutation')}
@@ -425,6 +502,12 @@ function MutationPolicySection({ agent }: { agent: AgentSettings }) {
425
502
  | { kind: 'success'; message: string }
426
503
  | { kind: 'error'; message: string }
427
504
  >({ kind: 'idle' })
505
+ const { runMutation: runSavePolicyMutation } = useGuardedMutation({
506
+ contextId: `ai-agent-mutation-policy-save-${agent.id}`,
507
+ })
508
+ const { runMutation: runClearPolicyMutation } = useGuardedMutation({
509
+ contextId: `ai-agent-mutation-policy-clear-${agent.id}`,
510
+ })
428
511
 
429
512
  React.useEffect(() => {
430
513
  setSelected(currentOverride?.mutationPolicy ?? null)
@@ -445,93 +528,94 @@ function MutationPolicySection({ agent }: { agent: AgentSettings }) {
445
528
  setIsSaving(true)
446
529
  setState({ kind: 'idle' })
447
530
  try {
448
- const { ok, status, result } = await apiCall<{
449
- ok?: boolean
450
- error?: string
451
- code?: string
452
- codeDeclared?: MutationPolicy
453
- requested?: MutationPolicy
454
- }>(
455
- `/api/ai_assistant/ai/agents/${encodeURIComponent(agent.id)}/mutation-policy`,
456
- {
457
- method: 'POST',
458
- headers: { 'content-type': 'application/json' },
459
- credentials: 'include',
460
- body: JSON.stringify({ mutationPolicy: selected }),
531
+ await runSavePolicyMutation({
532
+ operation: async () => {
533
+ const { ok, status, result } = await apiCall<{
534
+ ok?: boolean
535
+ error?: string
536
+ code?: string
537
+ codeDeclared?: MutationPolicy
538
+ requested?: MutationPolicy
539
+ }>(
540
+ `/api/ai_assistant/ai/agents/${encodeURIComponent(agent.id)}/mutation-policy`,
541
+ {
542
+ method: 'POST',
543
+ headers: { 'content-type': 'application/json' },
544
+ credentials: 'include',
545
+ body: JSON.stringify({ mutationPolicy: selected }),
546
+ },
547
+ )
548
+ const payload = result ?? {}
549
+ if (!ok) {
550
+ const message =
551
+ payload.code === 'escalation_not_allowed'
552
+ ? (payload.error ??
553
+ t(
554
+ 'ai_assistant.agents.mutation_policy.errors.escalationNotAllowed',
555
+ 'Cannot upgrade beyond the agent\'s declared policy — this is a code-level change.',
556
+ ))
557
+ : (payload.error ??
558
+ `Failed to save mutation policy (${status}).`)
559
+ throw new Error(message)
560
+ }
461
561
  },
462
- )
463
- const payload = result ?? {}
464
- if (!ok) {
465
- const message =
466
- payload.code === 'escalation_not_allowed'
467
- ? (payload.error ??
468
- t(
469
- 'ai_assistant.agents.mutation_policy.errors.escalationNotAllowed',
470
- 'Cannot upgrade beyond the agent\'s declared policy — this is a code-level change.',
471
- ))
472
- : (payload.error ??
473
- `Failed to save mutation policy (${status}).`)
474
- setState({ kind: 'error', message })
475
- return
476
- }
477
- setState({
478
- kind: 'success',
479
- message: t(
480
- 'ai_assistant.agents.mutation_policy.savedMessage',
481
- 'Mutation policy override saved.',
482
- ),
562
+ context: {},
483
563
  })
564
+ const successMessage = t(
565
+ 'ai_assistant.agents.mutation_policy.savedMessage',
566
+ 'Mutation policy override saved.',
567
+ )
568
+ setState({ kind: 'success', message: successMessage })
569
+ flash(successMessage, 'success')
484
570
  await queryClient.invalidateQueries({
485
571
  queryKey: ['ai_assistant', 'agent_settings', 'mutation_policy', agent.id],
486
572
  })
487
573
  } catch (err) {
488
- setState({
489
- kind: 'error',
490
- message: err instanceof Error ? err.message : String(err),
491
- })
574
+ const message = err instanceof Error ? err.message : String(err)
575
+ setState({ kind: 'error', message })
576
+ flash(message, 'error')
492
577
  } finally {
493
578
  setIsSaving(false)
494
579
  }
495
- }, [agent.id, isSaving, queryClient, selected, t])
580
+ }, [agent.id, isSaving, queryClient, runSavePolicyMutation, selected, t])
496
581
 
497
582
  const clear = React.useCallback(async () => {
498
583
  if (isClearing) return
499
584
  setIsClearing(true)
500
585
  setState({ kind: 'idle' })
501
586
  try {
502
- const { ok, status, result } = await apiCall<{
503
- ok?: boolean
504
- error?: string
505
- }>(
506
- `/api/ai_assistant/ai/agents/${encodeURIComponent(agent.id)}/mutation-policy`,
507
- {
508
- method: 'DELETE',
509
- credentials: 'include',
587
+ await runClearPolicyMutation({
588
+ operation: async () => {
589
+ const { ok, status, result } = await apiCall<{
590
+ ok?: boolean
591
+ error?: string
592
+ }>(
593
+ `/api/ai_assistant/ai/agents/${encodeURIComponent(agent.id)}/mutation-policy`,
594
+ {
595
+ method: 'DELETE',
596
+ credentials: 'include',
597
+ },
598
+ )
599
+ const payload = result ?? {}
600
+ if (!ok) {
601
+ throw new Error(payload.error ?? `Failed to clear override (${status}).`)
602
+ }
510
603
  },
511
- )
512
- const payload = result ?? {}
513
- if (!ok) {
514
- setState({
515
- kind: 'error',
516
- message: payload.error ?? `Failed to clear override (${status}).`,
517
- })
518
- return
519
- }
520
- setState({
521
- kind: 'success',
522
- message: t(
523
- 'ai_assistant.agents.mutation_policy.clearedMessage',
524
- 'Mutation policy override cleared; agent is using its code-declared policy.',
525
- ),
604
+ context: {},
526
605
  })
606
+ const successMessage = t(
607
+ 'ai_assistant.agents.mutation_policy.clearedMessage',
608
+ 'Mutation policy override cleared; agent is using its code-declared policy.',
609
+ )
610
+ setState({ kind: 'success', message: successMessage })
611
+ flash(successMessage, 'success')
527
612
  await queryClient.invalidateQueries({
528
613
  queryKey: ['ai_assistant', 'agent_settings', 'mutation_policy', agent.id],
529
614
  })
530
615
  } catch (err) {
531
- setState({
532
- kind: 'error',
533
- message: err instanceof Error ? err.message : String(err),
534
- })
616
+ const message = err instanceof Error ? err.message : String(err)
617
+ setState({ kind: 'error', message })
618
+ flash(message, 'error')
535
619
  } finally {
536
620
  setIsClearing(false)
537
621
  }
@@ -542,10 +626,10 @@ function MutationPolicySection({ agent }: { agent: AgentSettings }) {
542
626
  className="rounded-lg border border-border bg-background p-4"
543
627
  data-ai-agent-mutation-policy={agent.id}
544
628
  >
545
- <header className="flex items-center justify-between gap-3 border-b border-border pb-3">
546
- <div className="flex items-center gap-2">
629
+ <header className="flex flex-wrap items-start justify-between gap-3 border-b border-border pb-3">
630
+ <div className="flex min-w-0 items-start gap-2">
547
631
  <ShieldAlert className="size-4 text-muted-foreground" aria-hidden />
548
- <div>
632
+ <div className="min-w-0">
549
633
  <h3 className="text-sm font-semibold">
550
634
  {t('ai_assistant.agents.mutation_policy.title', 'Mutation policy')}
551
635
  </h3>
@@ -560,6 +644,7 @@ function MutationPolicySection({ agent }: { agent: AgentSettings }) {
560
644
  <StatusBadge
561
645
  variant={mutationPolicyStatusMap[effectivePolicy] ?? 'neutral'}
562
646
  dot
647
+ className="max-w-full whitespace-normal break-all"
563
648
  data-ai-agent-mutation-policy-effective
564
649
  >
565
650
  {effectivePolicy}
@@ -567,15 +652,16 @@ function MutationPolicySection({ agent }: { agent: AgentSettings }) {
567
652
  </header>
568
653
 
569
654
  <div className="mt-3 flex flex-col gap-3">
570
- <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
571
- <div>
655
+ <div className="grid grid-cols-[repeat(auto-fit,minmax(min(100%,12rem),1fr))] gap-3">
656
+ <div className="min-w-0">
572
657
  <span className="text-overline font-semibold uppercase tracking-wider text-muted-foreground">
573
658
  {t('ai_assistant.agents.mutation_policy.codeDeclared', 'Code-declared')}
574
659
  </span>
575
- <div className="mt-1 flex items-center gap-2">
660
+ <div className="mt-1 flex flex-wrap items-center gap-2">
576
661
  <StatusBadge
577
662
  variant={mutationPolicyStatusMap[codeDeclared] ?? 'neutral'}
578
663
  dot
664
+ className="max-w-full whitespace-normal break-all"
579
665
  data-ai-agent-mutation-policy-code-declared
580
666
  >
581
667
  {codeDeclared}
@@ -589,7 +675,7 @@ function MutationPolicySection({ agent }: { agent: AgentSettings }) {
589
675
  </span>
590
676
  </div>
591
677
  </div>
592
- <div>
678
+ <div className="min-w-0">
593
679
  <span className="text-overline font-semibold uppercase tracking-wider text-muted-foreground">
594
680
  {t('ai_assistant.agents.mutation_policy.tenantOverride', 'Tenant override')}
595
681
  </span>
@@ -598,6 +684,7 @@ function MutationPolicySection({ agent }: { agent: AgentSettings }) {
598
684
  <StatusBadge
599
685
  variant={mutationPolicyStatusMap[currentOverride.mutationPolicy] ?? 'neutral'}
600
686
  dot
687
+ className="max-w-full whitespace-normal break-all"
601
688
  data-ai-agent-mutation-policy-override-current
602
689
  >
603
690
  {currentOverride.mutationPolicy}
@@ -654,9 +741,12 @@ function MutationPolicySection({ agent }: { agent: AgentSettings }) {
654
741
  </AlertDescription>
655
742
  </Alert>
656
743
  ) : (
657
- <div
744
+ <RadioGroup
745
+ value={selected ?? ''}
746
+ onValueChange={(value) => {
747
+ setSelected(value as MutationPolicy)
748
+ }}
658
749
  className="flex flex-col gap-2"
659
- role="radiogroup"
660
750
  aria-label={t(
661
751
  'ai_assistant.agents.mutation_policy.pickerLabel',
662
752
  'Mutation policy override',
@@ -670,27 +760,24 @@ function MutationPolicySection({ agent }: { agent: AgentSettings }) {
670
760
  return (
671
761
  <Tooltip key={option}>
672
762
  <TooltipTrigger asChild>
673
- <label
763
+ <div
674
764
  className={`flex items-start gap-3 rounded-md border px-3 py-2 text-sm cursor-pointer transition-colors ${
675
765
  wouldEscalate
676
766
  ? 'border-border bg-muted/30 cursor-not-allowed opacity-60'
677
767
  : isSelected
678
- ? 'border-primary bg-primary/5'
768
+ ? 'border-accent-indigo bg-accent-indigo/5'
679
769
  : 'border-border bg-background hover:bg-muted/40'
680
770
  }`}
771
+ onClick={() => {
772
+ if (wouldEscalate) return
773
+ setSelected(option)
774
+ }}
681
775
  data-ai-agent-mutation-policy-option={option}
682
776
  data-ai-agent-mutation-policy-option-disabled={wouldEscalate ? 'true' : 'false'}
683
777
  >
684
- <input
685
- type="radio"
686
- name={`mutation-policy-${agent.id}`}
778
+ <Radio
687
779
  value={option}
688
- checked={isSelected}
689
780
  disabled={wouldEscalate}
690
- onChange={() => {
691
- if (wouldEscalate) return
692
- setSelected(option)
693
- }}
694
781
  className="mt-0.5"
695
782
  aria-disabled={wouldEscalate}
696
783
  />
@@ -713,7 +800,7 @@ function MutationPolicySection({ agent }: { agent: AgentSettings }) {
713
800
  )}
714
801
  </span>
715
802
  </div>
716
- </label>
803
+ </div>
717
804
  </TooltipTrigger>
718
805
  {wouldEscalate ? (
719
806
  <TooltipContent>
@@ -726,7 +813,7 @@ function MutationPolicySection({ agent }: { agent: AgentSettings }) {
726
813
  </Tooltip>
727
814
  )
728
815
  })}
729
- </div>
816
+ </RadioGroup>
730
817
  )}
731
818
 
732
819
  {state.kind === 'success' ? (
@@ -751,7 +838,7 @@ function MutationPolicySection({ agent }: { agent: AgentSettings }) {
751
838
  </Alert>
752
839
  ) : null}
753
840
 
754
- <div className="flex items-center justify-end gap-2">
841
+ <div className="flex flex-wrap items-center justify-end gap-2">
755
842
  <Button
756
843
  type="button"
757
844
  size="sm"
@@ -787,6 +874,580 @@ function MutationPolicySection({ agent }: { agent: AgentSettings }) {
787
874
  )
788
875
  }
789
876
 
877
+ function AgentModelOverrideSection({ agent }: { agent: AgentSettings }) {
878
+ const t = useT()
879
+ const queryClient = useQueryClient()
880
+ const settingsQuery = useQuery<RuntimeSettingsResponse>({
881
+ queryKey: ['ai_assistant', 'agent_settings', 'runtime_settings'],
882
+ queryFn: fetchRuntimeSettings,
883
+ retry: false,
884
+ })
885
+
886
+ const agentResolution = settingsQuery.data?.agents.find((entry) => entry.agentId === agent.id) ?? null
887
+ const configuredProviders = React.useMemo(
888
+ () => (settingsQuery.data?.availableProviders ?? []).filter((provider) => provider.configured),
889
+ [settingsQuery.data?.availableProviders],
890
+ )
891
+
892
+ const [selectedProviderId, setSelectedProviderId] = React.useState('')
893
+ const [selectedModelId, setSelectedModelId] = React.useState('')
894
+ const [allowedProviders, setAllowedProviders] = React.useState<string[] | null>(null)
895
+ const [allowedModelsByProvider, setAllowedModelsByProvider] = React.useState<Record<string, string[]>>({})
896
+ const [allowlistDirty, setAllowlistDirty] = React.useState(false)
897
+ const [isSaving, setIsSaving] = React.useState(false)
898
+ const [isClearing, setIsClearing] = React.useState(false)
899
+ const [isSavingAllowlist, setIsSavingAllowlist] = React.useState(false)
900
+ const [state, setState] = React.useState<
901
+ | { kind: 'idle' }
902
+ | { kind: 'success'; message: string }
903
+ | { kind: 'error'; message: string }
904
+ >({ kind: 'idle' })
905
+ const { runMutation: runSaveModelOverrideMutation } = useGuardedMutation({
906
+ contextId: `ai-agent-model-override-save-${agent.id}`,
907
+ })
908
+ const { runMutation: runClearModelOverrideMutation } = useGuardedMutation({
909
+ contextId: `ai-agent-model-override-clear-${agent.id}`,
910
+ })
911
+ const { runMutation: runSaveModelAllowlistMutation } = useGuardedMutation({
912
+ contextId: `ai-agent-model-override-allowlist-${agent.id}`,
913
+ })
914
+
915
+ React.useEffect(() => {
916
+ const override = agentResolution?.override
917
+ setSelectedProviderId(override?.providerId ?? '')
918
+ setSelectedModelId(override?.modelId ?? '')
919
+ setAllowedProviders(agentResolution?.runtimeOverrideAllowlist.tenant?.allowedProviders ?? null)
920
+ setAllowedModelsByProvider({
921
+ ...(agentResolution?.runtimeOverrideAllowlist.tenant?.allowedModelsByProvider ?? {}),
922
+ })
923
+ setAllowlistDirty(false)
924
+ setState({ kind: 'idle' })
925
+ }, [
926
+ agent.id,
927
+ agentResolution?.override?.modelId,
928
+ agentResolution?.override?.providerId,
929
+ agentResolution?.runtimeOverrideAllowlist.tenant,
930
+ ])
931
+
932
+ const selectedProvider = configuredProviders.find((provider) => provider.id === selectedProviderId)
933
+ const isProviderAllowedForPicker = React.useCallback(
934
+ (providerId: string) => {
935
+ if (allowedProviders === null) return true
936
+ return allowedProviders.includes(providerId)
937
+ },
938
+ [allowedProviders],
939
+ )
940
+ const isModelAllowedForPicker = React.useCallback(
941
+ (providerId: string, modelId: string) => {
942
+ const list = allowedModelsByProvider[providerId]
943
+ if (list === undefined) return true
944
+ return list.includes(modelId)
945
+ },
946
+ [allowedModelsByProvider],
947
+ )
948
+ const hasChange =
949
+ selectedProviderId.length > 0 &&
950
+ selectedModelId.length > 0 &&
951
+ (
952
+ selectedProviderId !== (agentResolution?.override?.providerId ?? '') ||
953
+ selectedModelId !== (agentResolution?.override?.modelId ?? '')
954
+ )
955
+
956
+ const save = React.useCallback(async () => {
957
+ if (isSaving || !selectedProviderId || !selectedModelId) return
958
+ setIsSaving(true)
959
+ setState({ kind: 'idle' })
960
+ try {
961
+ await runSaveModelOverrideMutation({
962
+ operation: async () => {
963
+ const { ok, status, result } = await apiCall<{ error?: string; code?: string }>(
964
+ '/api/ai_assistant/settings',
965
+ {
966
+ method: 'PUT',
967
+ headers: { 'content-type': 'application/json' },
968
+ credentials: 'include',
969
+ body: JSON.stringify({
970
+ agentId: agent.id,
971
+ providerId: selectedProviderId,
972
+ modelId: selectedModelId,
973
+ }),
974
+ },
975
+ )
976
+ if (!ok) {
977
+ throw new Error(result?.error ?? `Failed to save model override (${status}).`)
978
+ }
979
+ },
980
+ context: {},
981
+ })
982
+ const successMessage = t('ai_assistant.agents.model_override.saved', 'Model override saved.')
983
+ setState({ kind: 'success', message: successMessage })
984
+ flash(successMessage, 'success')
985
+ await queryClient.invalidateQueries({
986
+ queryKey: ['ai_assistant', 'agent_settings', 'runtime_settings'],
987
+ })
988
+ } catch (err) {
989
+ const message = err instanceof Error ? err.message : String(err)
990
+ setState({ kind: 'error', message })
991
+ flash(message, 'error')
992
+ } finally {
993
+ setIsSaving(false)
994
+ }
995
+ }, [agent.id, isSaving, queryClient, runSaveModelOverrideMutation, selectedModelId, selectedProviderId, t])
996
+
997
+ const clear = React.useCallback(async () => {
998
+ if (isClearing) return
999
+ setIsClearing(true)
1000
+ setState({ kind: 'idle' })
1001
+ try {
1002
+ await runClearModelOverrideMutation({
1003
+ operation: async () => {
1004
+ const { ok, status, result } = await apiCall<{ error?: string }>(
1005
+ '/api/ai_assistant/settings',
1006
+ {
1007
+ method: 'DELETE',
1008
+ headers: { 'content-type': 'application/json' },
1009
+ credentials: 'include',
1010
+ body: JSON.stringify({ agentId: agent.id }),
1011
+ },
1012
+ )
1013
+ if (!ok) {
1014
+ throw new Error(result?.error ?? `Failed to clear model override (${status}).`)
1015
+ }
1016
+ },
1017
+ context: {},
1018
+ })
1019
+ setSelectedProviderId('')
1020
+ setSelectedModelId('')
1021
+ const successMessage = t(
1022
+ 'ai_assistant.agents.model_override.cleared',
1023
+ 'Model override cleared; the agent is using the normal resolution chain.',
1024
+ )
1025
+ setState({ kind: 'success', message: successMessage })
1026
+ flash(successMessage, 'success')
1027
+ await queryClient.invalidateQueries({
1028
+ queryKey: ['ai_assistant', 'agent_settings', 'runtime_settings'],
1029
+ })
1030
+ } catch (err) {
1031
+ const message = err instanceof Error ? err.message : String(err)
1032
+ setState({ kind: 'error', message })
1033
+ flash(message, 'error')
1034
+ } finally {
1035
+ setIsClearing(false)
1036
+ }
1037
+ }, [agent.id, isClearing, queryClient, runClearModelOverrideMutation, t])
1038
+
1039
+ const toggleAllowedProvider = React.useCallback(
1040
+ (providerId: string, next: boolean) => {
1041
+ setAllowlistDirty(true)
1042
+ setState({ kind: 'idle' })
1043
+ setAllowedProviders((current) => {
1044
+ if (next) {
1045
+ return current === null ? [providerId] : Array.from(new Set([...current, providerId]))
1046
+ }
1047
+ const baseline = current === null
1048
+ ? configuredProviders.map((provider) => provider.id)
1049
+ : current
1050
+ return baseline.filter((id) => id !== providerId)
1051
+ })
1052
+ },
1053
+ [configuredProviders],
1054
+ )
1055
+
1056
+ const toggleAllowedModel = React.useCallback(
1057
+ (providerId: string, modelId: string, next: boolean) => {
1058
+ setAllowlistDirty(true)
1059
+ setState({ kind: 'idle' })
1060
+ const provider = configuredProviders.find((entry) => entry.id === providerId)
1061
+ const allModelIds = provider?.defaultModels.map((model) => model.id) ?? []
1062
+ setAllowedModelsByProvider((current) => {
1063
+ const existing = current[providerId]
1064
+ const baseline = existing === undefined ? allModelIds : existing
1065
+ const nextModels = next
1066
+ ? Array.from(new Set([...baseline, modelId]))
1067
+ : baseline.filter((id) => id !== modelId)
1068
+ return { ...current, [providerId]: nextModels }
1069
+ })
1070
+ },
1071
+ [configuredProviders],
1072
+ )
1073
+
1074
+ const resetAllowlistDraft = React.useCallback(() => {
1075
+ setAllowedProviders(null)
1076
+ setAllowedModelsByProvider({})
1077
+ setAllowlistDirty(true)
1078
+ setState({ kind: 'idle' })
1079
+ }, [])
1080
+
1081
+ const saveAllowlist = React.useCallback(async () => {
1082
+ if (isSavingAllowlist) return
1083
+ setIsSavingAllowlist(true)
1084
+ setState({ kind: 'idle' })
1085
+ try {
1086
+ await runSaveModelAllowlistMutation({
1087
+ operation: async () => {
1088
+ const { ok, status, result } = await apiCall<{ error?: string }>(
1089
+ '/api/ai_assistant/settings',
1090
+ {
1091
+ method: 'PUT',
1092
+ headers: { 'content-type': 'application/json' },
1093
+ credentials: 'include',
1094
+ body: JSON.stringify({
1095
+ agentId: agent.id,
1096
+ allowedOverrideProviders: allowedProviders,
1097
+ allowedOverrideModelsByProvider: allowedModelsByProvider,
1098
+ }),
1099
+ },
1100
+ )
1101
+ if (!ok) {
1102
+ throw new Error(result?.error ?? `Failed to save chat override allowlist (${status}).`)
1103
+ }
1104
+ },
1105
+ context: {},
1106
+ })
1107
+ setAllowlistDirty(false)
1108
+ const successMessage = t(
1109
+ 'ai_assistant.agents.model_override.allowlistSaved',
1110
+ 'Chat override choices saved.',
1111
+ )
1112
+ setState({ kind: 'success', message: successMessage })
1113
+ flash(successMessage, 'success')
1114
+ await queryClient.invalidateQueries({
1115
+ queryKey: ['ai_assistant', 'agent_settings', 'runtime_settings'],
1116
+ })
1117
+ } catch (err) {
1118
+ const message = err instanceof Error ? err.message : String(err)
1119
+ setState({ kind: 'error', message })
1120
+ flash(message, 'error')
1121
+ } finally {
1122
+ setIsSavingAllowlist(false)
1123
+ }
1124
+ }, [agent.id, allowedModelsByProvider, allowedProviders, isSavingAllowlist, queryClient, runSaveModelAllowlistMutation, t])
1125
+
1126
+ const busy = isSaving || isClearing || isSavingAllowlist
1127
+
1128
+ return (
1129
+ <section
1130
+ className="rounded-lg border border-border bg-background p-4"
1131
+ data-ai-agent-model-override={agent.id}
1132
+ >
1133
+ <header className="flex flex-wrap items-start justify-between gap-3 border-b border-border pb-3">
1134
+ <div className="flex min-w-0 items-start gap-2">
1135
+ <Bot className="size-4 text-muted-foreground" aria-hidden />
1136
+ <div className="min-w-0">
1137
+ <h3 className="text-sm font-semibold">
1138
+ {t('ai_assistant.agents.model_override.title', 'Provider and model')}
1139
+ </h3>
1140
+ <p className="text-xs text-muted-foreground">
1141
+ {t(
1142
+ 'ai_assistant.agents.model_override.subtitle',
1143
+ 'Override the default provider and model for this tenant and agent.',
1144
+ )}
1145
+ </p>
1146
+ </div>
1147
+ </div>
1148
+ {agentResolution ? (
1149
+ <StatusBadge
1150
+ variant="info"
1151
+ dot
1152
+ className="max-w-full whitespace-normal break-all"
1153
+ data-ai-agent-model-override-effective
1154
+ >
1155
+ {agentResolution.providerId} / {agentResolution.modelId}
1156
+ </StatusBadge>
1157
+ ) : null}
1158
+ </header>
1159
+
1160
+ <div className="mt-3 flex flex-col gap-3">
1161
+ <div className="grid grid-cols-[repeat(auto-fit,minmax(min(100%,12rem),1fr))] gap-3">
1162
+ <div className="min-w-0">
1163
+ <span className="text-overline font-semibold uppercase tracking-wider text-muted-foreground">
1164
+ {t('ai_assistant.agents.model_override.codeDefault', 'Code-declared default')}
1165
+ </span>
1166
+ <p className="mt-1 break-all font-mono text-xs">
1167
+ {agent.defaultProvider ?? t('ai_assistant.agents.model_override.anyProvider', 'first configured')}
1168
+ {' / '}
1169
+ {agent.defaultModel ?? t('ai_assistant.agents.model_override.providerDefault', 'provider default')}
1170
+ </p>
1171
+ </div>
1172
+ <div className="min-w-0">
1173
+ <span className="text-overline font-semibold uppercase tracking-wider text-muted-foreground">
1174
+ {t('ai_assistant.agents.model_override.tenantOverride', 'Tenant override')}
1175
+ </span>
1176
+ <p className="mt-1 break-all font-mono text-xs text-muted-foreground">
1177
+ {agentResolution?.override
1178
+ ? `${agentResolution.override.providerId ?? '—'} / ${agentResolution.override.modelId ?? '—'}`
1179
+ : t('ai_assistant.agents.model_override.noOverride', 'No per-agent override')}
1180
+ </p>
1181
+ </div>
1182
+ </div>
1183
+
1184
+ {settingsQuery.isLoading ? (
1185
+ <SettingsLoading
1186
+ message={t(
1187
+ 'ai_assistant.agents.model_override.loading',
1188
+ 'Loading provider catalog...',
1189
+ )}
1190
+ />
1191
+ ) : settingsQuery.isError ? (
1192
+ <Alert variant="destructive" data-ai-agent-model-override-load-error>
1193
+ <AlertCircle className="size-4" aria-hidden />
1194
+ <AlertTitle>
1195
+ {t(
1196
+ 'ai_assistant.agents.model_override.loadErrorTitle',
1197
+ 'Failed to load provider catalog',
1198
+ )}
1199
+ </AlertTitle>
1200
+ <AlertDescription>
1201
+ {settingsQuery.error instanceof Error
1202
+ ? settingsQuery.error.message
1203
+ : String(settingsQuery.error)}
1204
+ </AlertDescription>
1205
+ </Alert>
1206
+ ) : (
1207
+ <div className="grid grid-cols-[repeat(auto-fit,minmax(min(100%,12rem),1fr))] gap-3">
1208
+ <div className="flex min-w-0 flex-col gap-1">
1209
+ <Label htmlFor={`ai-agent-model-provider-${agent.id}`} className="text-xs">
1210
+ {t('ai_assistant.agents.model_override.provider', 'Provider')}
1211
+ </Label>
1212
+ <Select
1213
+ value={selectedProviderId}
1214
+ onValueChange={(value) => {
1215
+ setSelectedProviderId(value)
1216
+ setSelectedModelId('')
1217
+ setState({ kind: 'idle' })
1218
+ }}
1219
+ disabled={busy || configuredProviders.length === 0}
1220
+ >
1221
+ <SelectTrigger
1222
+ id={`ai-agent-model-provider-${agent.id}`}
1223
+ className="w-full min-w-0"
1224
+ data-ai-agent-model-provider-select
1225
+ >
1226
+ <SelectValue
1227
+ placeholder={t(
1228
+ 'ai_assistant.agents.model_override.selectProvider',
1229
+ 'Select provider',
1230
+ )}
1231
+ />
1232
+ </SelectTrigger>
1233
+ <SelectContent>
1234
+ {configuredProviders.map((provider) => (
1235
+ <SelectItem key={provider.id} value={provider.id}>
1236
+ {provider.name}
1237
+ </SelectItem>
1238
+ ))}
1239
+ </SelectContent>
1240
+ </Select>
1241
+ </div>
1242
+ <div className="flex min-w-0 flex-col gap-1">
1243
+ <Label htmlFor={`ai-agent-model-model-${agent.id}`} className="text-xs">
1244
+ {t('ai_assistant.agents.model_override.model', 'Model')}
1245
+ </Label>
1246
+ <Select
1247
+ value={selectedModelId}
1248
+ onValueChange={(value) => {
1249
+ setSelectedModelId(value)
1250
+ setState({ kind: 'idle' })
1251
+ }}
1252
+ disabled={busy || !selectedProvider}
1253
+ >
1254
+ <SelectTrigger
1255
+ id={`ai-agent-model-model-${agent.id}`}
1256
+ className="w-full min-w-0"
1257
+ data-ai-agent-model-select
1258
+ >
1259
+ <SelectValue
1260
+ placeholder={t(
1261
+ 'ai_assistant.agents.model_override.selectModel',
1262
+ 'Select model',
1263
+ )}
1264
+ />
1265
+ </SelectTrigger>
1266
+ <SelectContent>
1267
+ {(selectedProvider?.defaultModels ?? []).map((model) => (
1268
+ <SelectItem key={model.id} value={model.id}>
1269
+ {model.name}
1270
+ </SelectItem>
1271
+ ))}
1272
+ </SelectContent>
1273
+ </Select>
1274
+ </div>
1275
+ </div>
1276
+ )}
1277
+
1278
+ {agent.allowRuntimeModelOverride ? (
1279
+ <div className="rounded-md border border-border bg-muted/20 p-3" data-ai-agent-model-picker-allowlist>
1280
+ <div className="flex flex-wrap items-start justify-between gap-3">
1281
+ <div className="min-w-0">
1282
+ <h4 className="text-sm font-semibold">
1283
+ {t(
1284
+ 'ai_assistant.agents.model_override.allowlistTitle',
1285
+ 'Chat override choices',
1286
+ )}
1287
+ </h4>
1288
+ <p className="text-xs text-muted-foreground">
1289
+ {t(
1290
+ 'ai_assistant.agents.model_override.allowlistHelp',
1291
+ 'Limit which provider/model overrides users can pick in the chat footer for this agent.',
1292
+ )}
1293
+ </p>
1294
+ {agentResolution?.runtimeOverrideAllowlist.env ? (
1295
+ <p className="mt-1 text-xs text-muted-foreground">
1296
+ <code className="font-mono text-xs">
1297
+ {agentResolution.runtimeOverrideAllowlist.envVarNames.providers}
1298
+ </code>
1299
+ {' '}
1300
+ {t(
1301
+ 'ai_assistant.agents.model_override.envAlsoNarrows',
1302
+ 'also narrows this list from env.',
1303
+ )}
1304
+ </p>
1305
+ ) : null}
1306
+ </div>
1307
+ <StatusBadge
1308
+ variant={agentResolution?.runtimeOverrideAllowlist.tenant ? 'info' : 'neutral'}
1309
+ dot
1310
+ >
1311
+ {agentResolution?.runtimeOverrideAllowlist.tenant
1312
+ ? t('ai_assistant.agents.model_override.allowlistCustom', 'custom')
1313
+ : t('ai_assistant.agents.model_override.allowlistInherited', 'inherited')}
1314
+ </StatusBadge>
1315
+ </div>
1316
+
1317
+ <div className="mt-3 flex flex-col gap-3">
1318
+ {configuredProviders.length === 0 ? (
1319
+ <p className="text-xs text-muted-foreground">
1320
+ {t(
1321
+ 'ai_assistant.agents.model_override.allowlistEmpty',
1322
+ 'No configured providers are available for chat overrides.',
1323
+ )}
1324
+ </p>
1325
+ ) : (
1326
+ configuredProviders.map((provider) => {
1327
+ const providerEnabled = isProviderAllowedForPicker(provider.id)
1328
+ return (
1329
+ <div key={provider.id} className="rounded-md border border-border bg-background p-3">
1330
+ <div className="flex items-center gap-2">
1331
+ <Checkbox
1332
+ id={`ai-agent-picker-provider-${agent.id}-${provider.id}`}
1333
+ checked={providerEnabled}
1334
+ onCheckedChange={(value) =>
1335
+ toggleAllowedProvider(provider.id, value === true)
1336
+ }
1337
+ />
1338
+ <Label
1339
+ htmlFor={`ai-agent-picker-provider-${agent.id}-${provider.id}`}
1340
+ className="text-sm font-medium"
1341
+ >
1342
+ {provider.name}
1343
+ </Label>
1344
+ </div>
1345
+ {providerEnabled ? (
1346
+ <div className="mt-2 grid grid-cols-[repeat(auto-fit,minmax(min(100%,12rem),1fr))] gap-2">
1347
+ {provider.defaultModels.map((model) => (
1348
+ <label
1349
+ key={model.id}
1350
+ className="flex min-w-0 items-center gap-2 text-xs"
1351
+ >
1352
+ <Checkbox
1353
+ checked={isModelAllowedForPicker(provider.id, model.id)}
1354
+ onCheckedChange={(value) =>
1355
+ toggleAllowedModel(provider.id, model.id, value === true)
1356
+ }
1357
+ />
1358
+ <span className="truncate font-mono">{model.id}</span>
1359
+ {model.id === provider.defaultModel ? (
1360
+ <Badge variant="outline" className="text-overline">
1361
+ {t('ai_assistant.agents.model_override.defaultBadge', 'default')}
1362
+ </Badge>
1363
+ ) : null}
1364
+ </label>
1365
+ ))}
1366
+ </div>
1367
+ ) : null}
1368
+ </div>
1369
+ )
1370
+ })
1371
+ )}
1372
+ </div>
1373
+
1374
+ <div className="mt-3 flex flex-wrap items-center justify-end gap-2">
1375
+ <Button
1376
+ type="button"
1377
+ size="sm"
1378
+ variant="outline"
1379
+ onClick={resetAllowlistDraft}
1380
+ disabled={busy}
1381
+ >
1382
+ {t('ai_assistant.agents.model_override.allowlistReset', 'Inherit')}
1383
+ </Button>
1384
+ <Button
1385
+ type="button"
1386
+ size="sm"
1387
+ onClick={() => void saveAllowlist()}
1388
+ disabled={busy || !allowlistDirty}
1389
+ data-ai-agent-model-allowlist-save
1390
+ >
1391
+ {isSavingAllowlist ? (
1392
+ <Loader2 className="size-4 animate-spin" aria-hidden />
1393
+ ) : (
1394
+ <Save className="size-4" aria-hidden />
1395
+ )}
1396
+ <span>{t('ai_assistant.agents.model_override.allowlistSave', 'Save choices')}</span>
1397
+ </Button>
1398
+ </div>
1399
+ </div>
1400
+ ) : null}
1401
+
1402
+ {state.kind === 'success' ? (
1403
+ <Alert variant="success" data-ai-agent-model-override-state="success">
1404
+ <CheckCircle2 className="size-4" aria-hidden />
1405
+ <AlertDescription>{state.message}</AlertDescription>
1406
+ </Alert>
1407
+ ) : null}
1408
+ {state.kind === 'error' ? (
1409
+ <Alert variant="destructive" data-ai-agent-model-override-state="error">
1410
+ <AlertCircle className="size-4" aria-hidden />
1411
+ <AlertDescription>{state.message}</AlertDescription>
1412
+ </Alert>
1413
+ ) : null}
1414
+
1415
+ <div className="flex flex-wrap items-center justify-end gap-2">
1416
+ <Button
1417
+ type="button"
1418
+ size="sm"
1419
+ variant="outline"
1420
+ onClick={() => void clear()}
1421
+ disabled={busy || !agentResolution?.override}
1422
+ data-ai-agent-model-clear
1423
+ >
1424
+ {isClearing ? (
1425
+ <Loader2 className="size-4 animate-spin" aria-hidden />
1426
+ ) : (
1427
+ <Trash2 className="size-4" aria-hidden />
1428
+ )}
1429
+ <span>{t('ai_assistant.agents.model_override.clear', 'Clear override')}</span>
1430
+ </Button>
1431
+ <Button
1432
+ type="button"
1433
+ size="sm"
1434
+ onClick={() => void save()}
1435
+ disabled={busy || !hasChange}
1436
+ data-ai-agent-model-save
1437
+ >
1438
+ {isSaving ? (
1439
+ <Loader2 className="size-4 animate-spin" aria-hidden />
1440
+ ) : (
1441
+ <Save className="size-4" aria-hidden />
1442
+ )}
1443
+ <span>{t('ai_assistant.agents.model_override.save', 'Save override')}</span>
1444
+ </Button>
1445
+ </div>
1446
+ </div>
1447
+ </section>
1448
+ )
1449
+ }
1450
+
790
1451
  function AgentDetailPanel({ agent }: { agent: AgentSettings }) {
791
1452
  const t = useT()
792
1453
  const queryClient = useQueryClient()
@@ -816,6 +1477,9 @@ function AgentDetailPanel({ agent }: { agent: AgentSettings }) {
816
1477
  | { kind: 'success'; message: string; version: number }
817
1478
  | { kind: 'error'; message: string }
818
1479
  >({ kind: 'idle' })
1480
+ const { runMutation: runSavePromptOverrideMutation } = useGuardedMutation({
1481
+ contextId: `ai-agent-prompt-override-save-${agent.id}`,
1482
+ })
819
1483
 
820
1484
  const overrideQuery = useQuery<OverrideResponse>({
821
1485
  queryKey: ['ai_assistant', 'agent_settings', 'override', agent.id],
@@ -901,94 +1565,102 @@ function AgentDetailPanel({ agent }: { agent: AgentSettings }) {
901
1565
  setIsSaving(true)
902
1566
  setSaveState({ kind: 'idle' })
903
1567
  try {
904
- const { ok, status, result } = await apiCall<{
905
- ok?: boolean
906
- pending?: boolean
907
- version?: number
908
- updatedAt?: string
909
- message?: string
910
- error?: string
911
- code?: string
912
- reservedKeys?: string[]
913
- }>(
914
- `/api/ai_assistant/ai/agents/${encodeURIComponent(agent.id)}/prompt-override`,
915
- {
916
- method: 'POST',
917
- headers: { 'content-type': 'application/json' },
918
- credentials: 'include',
919
- body: JSON.stringify({
920
- // Send both keys so a pre-Step-5.3 server still accepts the payload.
921
- sections: activeOverrides,
922
- overrides: activeOverrides,
923
- }),
1568
+ const payload = await runSavePromptOverrideMutation({
1569
+ operation: async () => {
1570
+ const { ok, status, result } = await apiCall<{
1571
+ ok?: boolean
1572
+ pending?: boolean
1573
+ version?: number
1574
+ updatedAt?: string
1575
+ message?: string
1576
+ error?: string
1577
+ code?: string
1578
+ reservedKeys?: string[]
1579
+ }>(
1580
+ `/api/ai_assistant/ai/agents/${encodeURIComponent(agent.id)}/prompt-override`,
1581
+ {
1582
+ method: 'POST',
1583
+ headers: { 'content-type': 'application/json' },
1584
+ credentials: 'include',
1585
+ body: JSON.stringify({
1586
+ // Send both keys so a pre-Step-5.3 server still accepts the payload.
1587
+ sections: activeOverrides,
1588
+ overrides: activeOverrides,
1589
+ }),
1590
+ },
1591
+ )
1592
+ const body = result ?? {}
1593
+ if (!ok) {
1594
+ const message =
1595
+ body.code === 'reserved_key'
1596
+ ? t(
1597
+ 'ai_assistant.agents.override.errors.reservedKey',
1598
+ 'Prompt overrides cannot modify policy fields (mutationPolicy, readOnly, allowedTools, acceptedMediaTypes). Remove those sections and retry.',
1599
+ )
1600
+ : (body.error ?? `Failed to save overrides (${status}).`)
1601
+ throw new Error(message)
1602
+ }
1603
+ return body
924
1604
  },
925
- )
926
- const payload = result ?? {}
927
- if (!ok) {
928
- const message =
929
- payload.code === 'reserved_key'
930
- ? t(
931
- 'ai_assistant.agents.override.errors.reservedKey',
932
- 'Prompt overrides cannot modify policy fields (mutationPolicy, readOnly, allowedTools, acceptedMediaTypes). Remove those sections and retry.',
933
- )
934
- : (payload.error ?? `Failed to save overrides (${status}).`)
935
- setSaveState({ kind: 'error', message })
936
- return
937
- }
1605
+ context: {},
1606
+ })
938
1607
  if (payload.ok === true && typeof payload.version === 'number') {
1608
+ const successMessage = t(
1609
+ 'ai_assistant.agents.override.savedMessage',
1610
+ 'Prompt override saved.',
1611
+ )
939
1612
  setSaveState({
940
1613
  kind: 'success',
941
1614
  version: payload.version,
942
- message: t(
943
- 'ai_assistant.agents.override.savedMessage',
944
- 'Prompt override saved.',
945
- ),
1615
+ message: successMessage,
946
1616
  })
1617
+ flash(successMessage, 'success')
947
1618
  await queryClient.invalidateQueries({
948
1619
  queryKey: ['ai_assistant', 'agent_settings', 'override', agent.id],
949
1620
  })
950
1621
  return
951
1622
  }
952
1623
  // Legacy placeholder response: surfaces the Step-4.5 wording for BC.
1624
+ const legacyMessage =
1625
+ payload.message ??
1626
+ t(
1627
+ 'ai_assistant.agents.prompt.pendingMessage',
1628
+ 'Prompt overrides accepted.',
1629
+ )
953
1630
  setSaveState({
954
1631
  kind: 'success',
955
1632
  version: 0,
956
- message:
957
- payload.message ??
958
- t(
959
- 'ai_assistant.agents.prompt.pendingMessage',
960
- 'Prompt overrides accepted.',
961
- ),
1633
+ message: legacyMessage,
962
1634
  })
1635
+ flash(legacyMessage, 'success')
963
1636
  } catch (err) {
964
- setSaveState({
965
- kind: 'error',
966
- message: err instanceof Error ? err.message : String(err),
967
- })
1637
+ const message = err instanceof Error ? err.message : String(err)
1638
+ setSaveState({ kind: 'error', message })
1639
+ flash(message, 'error')
968
1640
  } finally {
969
1641
  setIsSaving(false)
970
1642
  }
971
- }, [activeOverrides, agent.id, isSaving, queryClient, t])
1643
+ }, [activeOverrides, agent.id, isSaving, queryClient, runSavePromptOverrideMutation, t])
972
1644
 
973
1645
  return (
974
1646
  <div className="flex flex-col gap-4" data-ai-agent-detail={agent.id}>
975
1647
  <section className="rounded-lg border border-border bg-background p-4">
976
1648
  <h2 className="text-xl font-semibold">{agent.label}</h2>
977
1649
  <p className="mt-1 text-sm text-muted-foreground">{agent.description}</p>
978
- <dl className="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4 text-sm">
979
- <div>
1650
+ <dl className="mt-4 grid grid-cols-[repeat(auto-fit,minmax(min(100%,9rem),1fr))] gap-3 text-sm">
1651
+ <div className="min-w-0">
980
1652
  <dt className="text-overline font-semibold uppercase tracking-wider text-muted-foreground">
981
1653
  {t('ai_assistant.agents.meta.module', 'Module')}
982
1654
  </dt>
983
- <dd className="mt-1 font-mono text-xs">{agent.moduleId}</dd>
1655
+ <dd className="mt-1 break-all font-mono text-xs">{agent.moduleId}</dd>
984
1656
  </div>
985
- <div>
1657
+ <div className="min-w-0">
986
1658
  <dt className="text-overline font-semibold uppercase tracking-wider text-muted-foreground">
987
1659
  {t('ai_assistant.agents.meta.id', 'Agent id')}
988
1660
  </dt>
989
- <dd className="mt-1 font-mono text-xs">{agent.id}</dd>
1661
+ <dd className="mt-1 break-all font-mono text-xs">{agent.id}</dd>
990
1662
  </div>
991
- <div>
1663
+ <div className="min-w-0">
992
1664
  <dt className="text-overline font-semibold uppercase tracking-wider text-muted-foreground">
993
1665
  {t('ai_assistant.agents.meta.executionMode', 'Execution mode')}
994
1666
  </dt>
@@ -998,7 +1670,7 @@ function AgentDetailPanel({ agent }: { agent: AgentSettings }) {
998
1670
  </StatusBadge>
999
1671
  </dd>
1000
1672
  </div>
1001
- <div>
1673
+ <div className="min-w-0">
1002
1674
  <dt className="text-overline font-semibold uppercase tracking-wider text-muted-foreground">
1003
1675
  {t('ai_assistant.agents.meta.mutationPolicy', 'Mutation policy')}
1004
1676
  </dt>
@@ -1006,12 +1678,13 @@ function AgentDetailPanel({ agent }: { agent: AgentSettings }) {
1006
1678
  <StatusBadge
1007
1679
  variant={mutationPolicyStatusMap[agent.mutationPolicy] ?? 'neutral'}
1008
1680
  dot
1681
+ className="max-w-full whitespace-normal break-all"
1009
1682
  >
1010
1683
  {agent.mutationPolicy}
1011
1684
  </StatusBadge>
1012
1685
  </dd>
1013
1686
  </div>
1014
- <div>
1687
+ <div className="min-w-0">
1015
1688
  <dt className="text-overline font-semibold uppercase tracking-wider text-muted-foreground">
1016
1689
  {t('ai_assistant.agents.meta.readOnly', 'Read-only')}
1017
1690
  </dt>
@@ -1021,7 +1694,7 @@ function AgentDetailPanel({ agent }: { agent: AgentSettings }) {
1021
1694
  : t('ai_assistant.agents.meta.readOnlyNo', 'No')}
1022
1695
  </dd>
1023
1696
  </div>
1024
- <div>
1697
+ <div className="min-w-0">
1025
1698
  <dt className="text-overline font-semibold uppercase tracking-wider text-muted-foreground">
1026
1699
  {t('ai_assistant.agents.meta.maxSteps', 'Max steps')}
1027
1700
  </dt>
@@ -1032,14 +1705,16 @@ function AgentDetailPanel({ agent }: { agent: AgentSettings }) {
1032
1705
  </dl>
1033
1706
  </section>
1034
1707
 
1708
+ <AgentModelOverrideSection agent={agent} />
1709
+
1035
1710
  <MutationPolicySection agent={agent} />
1036
1711
 
1037
1712
  <section
1038
1713
  className="rounded-lg border border-border bg-background p-4"
1039
1714
  data-ai-agent-prompt-editor={agent.id}
1040
1715
  >
1041
- <header className="flex items-center justify-between gap-3 border-b border-border pb-3">
1042
- <div>
1716
+ <header className="flex flex-wrap items-start justify-between gap-3 border-b border-border pb-3">
1717
+ <div className="min-w-0">
1043
1718
  <h3 className="text-sm font-semibold">
1044
1719
  {t('ai_assistant.agents.prompt.title', 'Prompt sections')}
1045
1720
  </h3>
@@ -1152,8 +1827,8 @@ function AgentDetailPanel({ agent }: { agent: AgentSettings }) {
1152
1827
  className="rounded-lg border border-border bg-background p-4"
1153
1828
  data-ai-agent-tools-list={agent.id}
1154
1829
  >
1155
- <header className="flex items-center justify-between gap-3 border-b border-border pb-3">
1156
- <div>
1830
+ <header className="flex flex-wrap items-start justify-between gap-3 border-b border-border pb-3">
1831
+ <div className="min-w-0">
1157
1832
  <h3 className="text-sm font-semibold">
1158
1833
  {t('ai_assistant.agents.tools.title', 'Allowed tools')}
1159
1834
  </h3>
@@ -1186,10 +1861,10 @@ function AgentDetailPanel({ agent }: { agent: AgentSettings }) {
1186
1861
  className="rounded-lg border border-border bg-background p-4"
1187
1862
  data-ai-agent-override-history={agent.id}
1188
1863
  >
1189
- <header className="flex items-center justify-between gap-3 border-b border-border pb-3">
1190
- <div className="flex items-center gap-2">
1864
+ <header className="flex flex-wrap items-start justify-between gap-3 border-b border-border pb-3">
1865
+ <div className="flex min-w-0 items-start gap-2">
1191
1866
  <History className="size-4 text-muted-foreground" aria-hidden />
1192
- <div>
1867
+ <div className="min-w-0">
1193
1868
  <h3 className="text-sm font-semibold">
1194
1869
  {t('ai_assistant.agents.override.history.title', 'Prompt override history')}
1195
1870
  </h3>
@@ -1244,7 +1919,7 @@ function AgentDetailPanel({ agent }: { agent: AgentSettings }) {
1244
1919
  (overrideQuery.data?.versions ?? []).slice(0, 5).map((entry) => (
1245
1920
  <div
1246
1921
  key={entry.id}
1247
- className="flex items-center justify-between gap-3 rounded-md border border-border bg-background px-3 py-2"
1922
+ className="flex flex-wrap items-center justify-between gap-3 rounded-md border border-border bg-background px-3 py-2"
1248
1923
  data-ai-agent-override-history-row={entry.version}
1249
1924
  >
1250
1925
  <div className="flex flex-col min-w-0">
@@ -1269,8 +1944,8 @@ function AgentDetailPanel({ agent }: { agent: AgentSettings }) {
1269
1944
  className="rounded-lg border border-border bg-background p-4"
1270
1945
  data-ai-agent-attachments={agent.id}
1271
1946
  >
1272
- <header className="flex items-center justify-between gap-3 border-b border-border pb-3">
1273
- <div>
1947
+ <header className="flex flex-wrap items-start justify-between gap-3 border-b border-border pb-3">
1948
+ <div className="min-w-0">
1274
1949
  <h3 className="text-sm font-semibold">
1275
1950
  {t('ai_assistant.agents.attachments.title', 'Attachment policy')}
1276
1951
  </h3>
@@ -1357,8 +2032,8 @@ export function AiAgentSettingsPageClient() {
1357
2032
 
1358
2033
  return (
1359
2034
  <TooltipProvider delayDuration={200}>
1360
- <div className="flex flex-col gap-4" data-ai-agent-settings>
1361
- <header className="flex flex-col gap-1">
2035
+ <div className="flex min-w-0 flex-col gap-4" data-ai-agent-settings>
2036
+ <header className="flex min-w-0 flex-col gap-1">
1362
2037
  <h1 className="text-2xl font-bold tracking-tight">
1363
2038
  {t('ai_assistant.agents.title', 'AI Agents')}
1364
2039
  </h1>
@@ -1374,24 +2049,30 @@ export function AiAgentSettingsPageClient() {
1374
2049
  className="flex flex-col gap-3 rounded-lg border border-border bg-background p-3"
1375
2050
  data-ai-agent-settings-picker-wrap
1376
2051
  >
1377
- <div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
1378
- <div className="flex flex-col gap-2 sm:flex-1">
2052
+ <div className="flex flex-wrap items-end justify-between gap-3">
2053
+ <div className="flex min-w-[min(100%,16rem)] flex-1 flex-col gap-2">
1379
2054
  <Label htmlFor="ai-agent-settings-picker">
1380
2055
  {t('ai_assistant.agents.agentPickerLabel', 'Agent')}
1381
2056
  </Label>
1382
- <select
1383
- id="ai-agent-settings-picker"
1384
- data-ai-agent-settings-picker
1385
- className="h-9 rounded-md border border-input bg-background px-3 text-sm"
2057
+ <Select
1386
2058
  value={selectedAgentId ?? ''}
1387
- onChange={(event) => setSelectedAgentId(event.target.value)}
2059
+ onValueChange={(value) => setSelectedAgentId(value)}
1388
2060
  >
1389
- {agents.map((agent) => (
1390
- <option key={agent.id} value={agent.id}>
1391
- {agent.label} ({agent.id})
1392
- </option>
1393
- ))}
1394
- </select>
2061
+ <SelectTrigger
2062
+ id="ai-agent-settings-picker"
2063
+ data-ai-agent-settings-picker
2064
+ className="w-full min-w-0"
2065
+ >
2066
+ <SelectValue />
2067
+ </SelectTrigger>
2068
+ <SelectContent>
2069
+ {agents.map((agent) => (
2070
+ <SelectItem key={agent.id} value={agent.id}>
2071
+ {agent.label} ({agent.id})
2072
+ </SelectItem>
2073
+ ))}
2074
+ </SelectContent>
2075
+ </Select>
1395
2076
  </div>
1396
2077
  <div className="flex items-center gap-2 sm:flex-shrink-0">
1397
2078
  <IconButton