Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion ui/src/core/components/template-param-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ export function TemplateParamForm({
errors,
}: TemplateParamFormProps) {
const paramEntries = useMemo(
() => Object.entries(template.parameters),
() => Object.entries(template.parameters ?? {}),
[template.parameters]
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ import { applyApiErrorsToForms } from './utils';

function isTemplateBacked(control: Control): boolean {
const def = control.control as Record<string, unknown> | undefined;
return def?.template != null && def?.template_values != null;
return def?.template != null;
}

const EVALUATOR_CONFIG_HEIGHT = 450;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ type TemplateEditContentProps = {

type EditorMode = 'params' | 'json';

function isRecord(value: unknown): value is Record<string, unknown> {
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.
Expand All @@ -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<string, unknown>;
const template = definitionRaw.template as TemplateControlInput['template'];
const template = useMemo(() => {
const rawControl = control.control as Record<string, unknown>;
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<string, TemplateValue>
| undefined;
Expand Down Expand Up @@ -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]);

Expand Down
23 changes: 23 additions & 0 deletions ui/tests/control-templates.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}) => {
Expand Down
37 changes: 37 additions & 0 deletions ui/tests/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -258,6 +284,11 @@ const controlsWithTemplateList: Control[] = [
templateBackedControl,
];

const controlsWithUnrenderedTemplateList: Control[] = [
...controlsList,
unrenderedTemplateControl,
];

const controlsResponse: AgentControlsResponse = {
controls: controlsList,
};
Expand All @@ -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;
Expand Down Expand Up @@ -621,7 +656,9 @@ export const mockData = {
agentWithSteps: agentWithStepsResponse,
controls: controlsResponse,
controlsWithTemplate: controlsWithTemplateResponse,
controlsWithUnrenderedTemplate: controlsWithUnrenderedTemplateResponse,
templateControl: templateBackedControl,
unrenderedTemplateControl,
listControls: listControlsResponse,
templateControlSummary: templateControlSummary,
evaluators: evaluatorsResponse,
Expand Down
Loading