fix(ui): null-safe trim calls in agent model config (#329)

* fix(ui): null-safe trim calls in agent model config (#319)

Guard against undefined values in .trim() calls when editing agent
model configuration (primary model and fallback array entries).

Closes #319

* test: add unit tests for null-safe trim in agent model config

Tests verify that undefined/null values in model primary and fallback
arrays don't cause trim() TypeError crashes.

* test(e2e): add agent model config edge case tests

Tests verify PUT /api/agents/:id with valid config, empty fallbacks,
empty primary, and whitespace-only fallback entries.
This commit is contained in:
nyk 2026-03-14 14:18:03 +07:00 committed by GitHub
parent acd7ed8ba3
commit 23204612eb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 184 additions and 3 deletions

View File

@ -1443,7 +1443,7 @@ export function ConfigTab({
const updateModelConfig = (updater: (current: { primary?: string; fallbacks?: string[] }) => { primary?: string; fallbacks?: string[] }) => { const updateModelConfig = (updater: (current: { primary?: string; fallbacks?: string[] }) => { primary?: string; fallbacks?: string[] }) => {
setConfig((prev: any) => { setConfig((prev: any) => {
const nextModel = updater({ ...(prev?.model || {}) }) const nextModel = updater({ ...(prev?.model || {}) })
const dedupedFallbacks = [...new Set((nextModel.fallbacks || []).map((value) => value.trim()).filter(Boolean))] const dedupedFallbacks = [...new Set((nextModel.fallbacks || []).map((value) => (value || '').trim()).filter(Boolean))]
return { return {
...prev, ...prev,
model: { model: {
@ -2788,8 +2788,8 @@ export function ModelsTab({ agent }: { agent: Agent }) {
body: JSON.stringify({ body: JSON.stringify({
gateway_config: { gateway_config: {
model: { model: {
primary: primary.trim(), primary: (primary || '').trim(),
fallbacks: fallbacks.filter(f => f.trim()), fallbacks: fallbacks.filter(f => f && f.trim()),
}, },
}, },
write_to_gateway: true, write_to_gateway: true,

View File

@ -0,0 +1,86 @@
import { describe, expect, it } from 'vitest'
/**
* Tests for the null-safe trim patterns used in agent model config editing.
* These mirror the logic in agent-detail-tabs.tsx (updateModelConfig + handleSave).
*/
function dedupFallbacks(fallbacks: (string | undefined | null)[]): string[] {
return [...new Set((fallbacks || []).map((value) => (value || '').trim()).filter(Boolean))]
}
function safePrimary(primary: string | undefined | null): string {
return (primary || '').trim()
}
function filterFallbacks(fallbacks: (string | undefined | null)[]): string[] {
return fallbacks.filter(f => f && f.trim()) as string[]
}
describe('agent model config trim safety', () => {
describe('dedupFallbacks (updateModelConfig pattern)', () => {
it('handles normal string values', () => {
expect(dedupFallbacks(['gpt-4', 'claude-3'])).toEqual(['gpt-4', 'claude-3'])
})
it('handles undefined values without throwing', () => {
expect(dedupFallbacks([undefined, 'gpt-4', undefined])).toEqual(['gpt-4'])
})
it('handles null values without throwing', () => {
expect(dedupFallbacks([null, 'gpt-4'])).toEqual(['gpt-4'])
})
it('filters out empty strings', () => {
expect(dedupFallbacks(['', ' ', 'gpt-4'])).toEqual(['gpt-4'])
})
it('deduplicates models', () => {
expect(dedupFallbacks(['gpt-4', 'gpt-4', 'claude-3'])).toEqual(['gpt-4', 'claude-3'])
})
it('handles empty array', () => {
expect(dedupFallbacks([])).toEqual([])
})
})
describe('safePrimary (handleSave pattern)', () => {
it('trims normal string', () => {
expect(safePrimary(' gpt-4 ')).toBe('gpt-4')
})
it('handles undefined without throwing', () => {
expect(safePrimary(undefined)).toBe('')
})
it('handles null without throwing', () => {
expect(safePrimary(null)).toBe('')
})
it('handles empty string', () => {
expect(safePrimary('')).toBe('')
})
})
describe('filterFallbacks (handleSave pattern)', () => {
it('filters valid values', () => {
expect(filterFallbacks(['gpt-4', 'claude-3'])).toEqual(['gpt-4', 'claude-3'])
})
it('filters out undefined without throwing', () => {
expect(filterFallbacks([undefined, 'gpt-4'])).toEqual(['gpt-4'])
})
it('filters out null without throwing', () => {
expect(filterFallbacks([null, 'gpt-4'])).toEqual(['gpt-4'])
})
it('filters out empty strings', () => {
expect(filterFallbacks(['', 'gpt-4'])).toEqual(['gpt-4'])
})
it('filters out whitespace-only strings', () => {
expect(filterFallbacks([' ', 'gpt-4'])).toEqual(['gpt-4'])
})
})
})

View File

@ -0,0 +1,95 @@
import { test, expect } from '@playwright/test'
import { API_KEY_HEADER, createTestAgent, deleteTestAgent } from './helpers'
/**
* E2E tests for agent model configuration updates.
* Verifies the API handles edge cases like empty/null model values.
*/
test.describe('Agent Model Config', () => {
const cleanup: number[] = []
test.afterEach(async ({ request }) => {
for (const id of cleanup) {
await deleteTestAgent(request, id).catch(() => {})
}
cleanup.length = 0
})
test('PUT with valid model config succeeds', async ({ request }) => {
const { id } = await createTestAgent(request)
cleanup.push(id)
const res = await request.put(`/api/agents/${id}`, {
headers: API_KEY_HEADER,
data: {
gateway_config: {
model: {
primary: 'claude-3-5-sonnet-20241022',
fallbacks: ['gpt-4o'],
},
},
},
})
expect(res.status()).toBe(200)
})
test('PUT with empty fallbacks array succeeds', async ({ request }) => {
const { id } = await createTestAgent(request)
cleanup.push(id)
const res = await request.put(`/api/agents/${id}`, {
headers: API_KEY_HEADER,
data: {
gateway_config: {
model: {
primary: 'claude-3-5-sonnet-20241022',
fallbacks: [],
},
},
},
})
expect(res.status()).toBe(200)
})
test('PUT with empty string primary returns appropriate response', async ({ request }) => {
const { id } = await createTestAgent(request)
cleanup.push(id)
const res = await request.put(`/api/agents/${id}`, {
headers: API_KEY_HEADER,
data: {
gateway_config: {
model: {
primary: '',
fallbacks: [],
},
},
},
})
// Server should accept the update (model config is optional gateway config)
expect([200, 400]).toContain(res.status())
})
test('PUT with whitespace-only fallbacks filters them', async ({ request }) => {
const { id } = await createTestAgent(request)
cleanup.push(id)
const res = await request.put(`/api/agents/${id}`, {
headers: API_KEY_HEADER,
data: {
gateway_config: {
model: {
primary: 'claude-3-5-sonnet-20241022',
fallbacks: [' ', '', 'gpt-4o'],
},
},
},
})
expect(res.status()).toBe(200)
})
})