diff --git a/ui/src/core/components/template-param-form.tsx b/ui/src/core/components/template-param-form.tsx index 00539c9f..22f62a5f 100644 --- a/ui/src/core/components/template-param-form.tsx +++ b/ui/src/core/components/template-param-form.tsx @@ -139,7 +139,7 @@ export function TemplateParamForm({ errors, }: TemplateParamFormProps) { const paramEntries = useMemo( - () => Object.entries(template.parameters), + () => Object.entries(template.parameters ?? {}), [template.parameters] ); diff --git a/ui/src/core/page-components/agent-detail/modals/edit-control/edit-control-content.tsx b/ui/src/core/page-components/agent-detail/modals/edit-control/edit-control-content.tsx index e45ffca9..96fc60c5 100644 --- a/ui/src/core/page-components/agent-detail/modals/edit-control/edit-control-content.tsx +++ b/ui/src/core/page-components/agent-detail/modals/edit-control/edit-control-content.tsx @@ -55,7 +55,7 @@ import { applyApiErrorsToForms } from './utils'; function isTemplateBacked(control: Control): boolean { const def = control.control as Record | undefined; - return def?.template != null && def?.template_values != null; + return def?.template != null; } const EVALUATOR_CONFIG_HEIGHT = 450; diff --git a/ui/src/core/page-components/agent-detail/modals/edit-control/template-edit-content.tsx b/ui/src/core/page-components/agent-detail/modals/edit-control/template-edit-content.tsx index 384913f6..aeeb3e7e 100644 --- a/ui/src/core/page-components/agent-detail/modals/edit-control/template-edit-content.tsx +++ b/ui/src/core/page-components/agent-detail/modals/edit-control/template-edit-content.tsx @@ -45,6 +45,10 @@ type TemplateEditContentProps = { type EditorMode = 'params' | 'json'; +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + /** * Editor for template-backed controls. Shows the parameter form by default, * with a toggle to edit the full template JSON. @@ -58,7 +62,18 @@ export function TemplateEditContent({ // Access template fields via cast — these exist at runtime but aren't in the // generated API types yet. Will be cleaned up after type regeneration. const definitionRaw = control.control as Record; - const template = definitionRaw.template as TemplateControlInput['template']; + const template = useMemo(() => { + const rawControl = control.control as Record; + const templateRaw = isRecord(rawControl.template) + ? rawControl.template + : {}; + return { + ...templateRaw, + parameters: isRecord(templateRaw.parameters) + ? templateRaw.parameters + : {}, + } as TemplateControlInput['template']; + }, [control.control]); const storedValues = definitionRaw.template_values as | Record | undefined; @@ -116,12 +131,12 @@ export function TemplateEditContent({ // Dynamically extract parameter names from the current JSON text so // completions update as the user edits the parameters block. const templateParameterNames = useMemo(() => { - if (editorMode !== 'json') return Object.keys(template.parameters); + if (editorMode !== 'json') return Object.keys(template.parameters ?? {}); try { const parsed = JSON.parse(jsonText) as TemplateControlInput; return Object.keys(parsed?.template?.parameters ?? {}); } catch { - return Object.keys(template.parameters); + return Object.keys(template.parameters ?? {}); } }, [editorMode, jsonText, template.parameters]); diff --git a/ui/tests/control-templates.spec.ts b/ui/tests/control-templates.spec.ts index d374b5b5..cd0c48db 100644 --- a/ui/tests/control-templates.spec.ts +++ b/ui/tests/control-templates.spec.ts @@ -134,6 +134,29 @@ test.describe('Control Templates', () => { ).toBeVisible(); }); + test('opens unrendered template controls without rendered fields', async ({ + mockedPage, + }) => { + await mockRoutes.agent(mockedPage, { + controls: { data: mockData.controlsWithUnrenderedTemplate }, + }); + await mockRoutes.controlRenderTemplate(mockedPage); + + await mockedPage.goto( + getAgentRoute(agentId, { + tab: 'controls', + query: { modal: 'edit', controlId: '11' }, + }) + ); + + const dialog = mockedPage.getByRole('dialog'); + await expect(dialog.getByText('Template Parameters')).toBeVisible(); + await expect( + dialog.getByText('This template has no configurable parameters.') + ).toBeVisible(); + await expect(dialog.getByText('Something went wrong')).not.toBeVisible(); + }); + test('can toggle to Full JSON mode and see template JSON', async ({ mockedPage, }) => { diff --git a/ui/tests/fixtures.ts b/ui/tests/fixtures.ts index 099df7c6..313d651a 100644 --- a/ui/tests/fixtures.ts +++ b/ui/tests/fixtures.ts @@ -188,6 +188,32 @@ const templateBackedControl: Control = { } as Control['control'], }; +const unrenderedTemplateControl = { + id: 11, + name: 'Unrendered Template Guard', + control: { + enabled: false, + template: { + description: 'Template without supplied values', + definition_template: { + description: 'Deny when input matches pattern', + execution: 'server', + scope: { + stages: ['pre'], + }, + condition: { + selector: { path: 'input' }, + evaluator: { + name: 'regex', + config: { pattern: '\\bsecret\\b' }, + }, + }, + action: { decision: 'deny' }, + }, + }, + }, +} as unknown as Control; + const controlsList: Control[] = [ { id: 1, @@ -258,6 +284,11 @@ const controlsWithTemplateList: Control[] = [ templateBackedControl, ]; +const controlsWithUnrenderedTemplateList: Control[] = [ + ...controlsList, + unrenderedTemplateControl, +]; + const controlsResponse: AgentControlsResponse = { controls: controlsList, }; @@ -266,6 +297,10 @@ const controlsWithTemplateResponse: AgentControlsResponse = { controls: controlsWithTemplateList, }; +const controlsWithUnrenderedTemplateResponse: AgentControlsResponse = { + controls: controlsWithUnrenderedTemplateList, +}; + // Control summaries for GET /api/v1/controls (list all controls) const controlSummariesList: (ControlSummary & { used_by_agent?: { agent_name: string } | null; @@ -621,7 +656,9 @@ export const mockData = { agentWithSteps: agentWithStepsResponse, controls: controlsResponse, controlsWithTemplate: controlsWithTemplateResponse, + controlsWithUnrenderedTemplate: controlsWithUnrenderedTemplateResponse, templateControl: templateBackedControl, + unrenderedTemplateControl, listControls: listControlsResponse, templateControlSummary: templateControlSummary, evaluators: evaluatorsResponse,