Skip to content

Commit 1a12d06

Browse files
committed
fix: omit nulls in agent controls response
1 parent d6a06b9 commit 1a12d06

5 files changed

Lines changed: 76 additions & 121 deletions

File tree

‎server/src/agent_control_server/endpoints/agents.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1367,6 +1367,7 @@ async def remove_agent_control(
13671367
@router.get(
13681368
"/{agent_name}/controls",
13691369
response_model=AgentControlsResponse,
1370+
response_model_exclude_none=True,
13701371
summary="List agent's active controls",
13711372
response_description="List of controls from agent policy and direct associations",
13721373
)

‎server/tests/test_control_compatibility.py‎

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,78 @@ def test_list_agent_controls_returns_canonical_shape_for_legacy_stored_payload(
144144
assert control["condition"]["evaluator"]["name"] == "regex"
145145

146146

147+
def test_list_agent_controls_omits_null_condition_fields_in_response(
148+
client: TestClient,
149+
) -> None:
150+
# Given: an agent assigned a policy whose control uses a composite condition tree
151+
agent_name = _init_agent(client)
152+
policy_id = _create_policy(client)
153+
154+
composite_payload = deepcopy(VALID_CONTROL_PAYLOAD)
155+
composite_payload["condition"] = {
156+
"and": [
157+
{
158+
"not": {
159+
"selector": {"path": "context.channel.scope"},
160+
"evaluator": {
161+
"name": "list",
162+
"config": {
163+
"values": ["slack:direct:U123"],
164+
"logic": "any",
165+
"match_on": "match",
166+
},
167+
},
168+
}
169+
},
170+
{
171+
"or": [
172+
{
173+
"selector": {"path": "name"},
174+
"evaluator": {
175+
"name": "regex",
176+
"config": {"pattern": r"(^|\.)(read|memory_search|memory_get)$"},
177+
},
178+
}
179+
]
180+
},
181+
]
182+
}
183+
184+
control_resp = client.put(
185+
"/api/v1/controls",
186+
json={"name": f"control-{uuid.uuid4()}", "data": composite_payload},
187+
)
188+
assert control_resp.status_code == 200
189+
control_id = control_resp.json()["control_id"]
190+
191+
assoc = client.post(f"/api/v1/policies/{policy_id}/controls/{control_id}")
192+
assert assoc.status_code == 200
193+
assign = client.post(f"/api/v1/agents/{agent_name}/policy/{policy_id}")
194+
assert assign.status_code == 200
195+
196+
# When: listing active controls for the agent
197+
resp = client.get(f"/api/v1/agents/{agent_name}/controls")
198+
199+
# Then: composite nodes omit synthetic null siblings in the response payload
200+
assert resp.status_code == 200
201+
control = resp.json()["controls"][0]["control"]
202+
condition = control["condition"]
203+
204+
assert set(condition.keys()) == {"and"}
205+
assert len(condition["and"]) == 2
206+
207+
first_child = condition["and"][0]
208+
assert set(first_child.keys()) == {"not"}
209+
assert first_child["not"]["selector"]["path"] == "context.channel.scope"
210+
assert first_child["not"]["evaluator"]["name"] == "list"
211+
212+
second_child = condition["and"][1]
213+
assert set(second_child.keys()) == {"or"}
214+
assert len(second_child["or"]) == 1
215+
assert second_child["or"][0]["selector"]["path"] == "name"
216+
assert second_child["or"][0]["evaluator"]["name"] == "regex"
217+
218+
147219
def test_get_control_data_rejects_partial_legacy_stored_payload(
148220
client: TestClient,
149221
) -> None:

‎ui/src/core/page-components/agent-detail/modals/edit-control/edit-control-content.tsx‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ import type {
4242
EditControlMode,
4343
} from './types';
4444
import { useEvaluatorConfigState } from './use-evaluator-config-state';
45-
import { applyApiErrorsToForms, stringifyJsonWithoutNulls } from './utils';
45+
import { applyApiErrorsToForms } from './utils';
4646

4747
const EVALUATOR_CONFIG_HEIGHT = 450;
4848
const JSON_EDITOR_HEIGHT = 520;
@@ -340,7 +340,7 @@ export const EditControlContent = ({
340340
return;
341341
}
342342

343-
setDefinitionJsonText(stringifyJsonWithoutNulls(definition));
343+
setDefinitionJsonText(JSON.stringify(definition, null, 2));
344344
setDefinitionJsonError(null);
345345
setDefinitionValidationError(null);
346346
setDefinitionValidationStatus('idle');
@@ -394,7 +394,7 @@ export const EditControlContent = ({
394394
setEditorMode(initialEditorMode);
395395
setDefinitionJsonText(
396396
initialEditorMode === 'json'
397-
? stringifyJsonWithoutNulls(control.control)
397+
? JSON.stringify(control.control, null, 2)
398398
: ''
399399
);
400400
setDefinitionJsonError(null);

‎ui/src/core/page-components/agent-detail/modals/edit-control/utils.ts‎

Lines changed: 0 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -155,18 +155,3 @@ export function sanitizeControlNamePart(s: string): string {
155155
.replace(/^[-_]+/, '');
156156
return sanitized || 'control';
157157
}
158-
159-
/**
160-
* Stringify JSON for editor display while omitting object properties whose
161-
* values are null. This keeps the editor aligned with persisted control data.
162-
*/
163-
export function stringifyJsonWithoutNulls(value: unknown): string {
164-
return (
165-
JSON.stringify(
166-
value,
167-
(_key: string, currentValue: unknown) =>
168-
currentValue === null ? undefined : currentValue,
169-
2
170-
) ?? ''
171-
);
172-
}

‎ui/tests/agent-detail.spec.ts‎

Lines changed: 0 additions & 103 deletions
Original file line numberDiff line numberDiff line change
@@ -763,109 +763,6 @@ test.describe('Agent Detail Page', () => {
763763
});
764764
});
765765

766-
test('full JSON editor omits null condition fields from display', async ({
767-
mockedPage,
768-
}) => {
769-
const noisyCompositeControl: Control = {
770-
id: 88,
771-
name: 'Public Slack Mode',
772-
control: {
773-
description: 'Composite control with nullable transport fields',
774-
enabled: true,
775-
execution: 'server',
776-
scope: { step_types: ['tool'], stages: ['pre'] },
777-
condition: {
778-
selector: null,
779-
evaluator: null,
780-
and: [
781-
{
782-
selector: null,
783-
evaluator: null,
784-
and: null,
785-
or: null,
786-
not: {
787-
selector: { path: 'context.channel.scope' },
788-
evaluator: {
789-
name: 'list',
790-
config: {
791-
values: ['slack:direct:U123'],
792-
logic: 'any',
793-
match_on: 'match',
794-
},
795-
},
796-
and: null,
797-
or: null,
798-
not: null,
799-
},
800-
},
801-
{
802-
selector: null,
803-
evaluator: null,
804-
and: null,
805-
or: [
806-
{
807-
selector: null,
808-
evaluator: null,
809-
and: null,
810-
or: null,
811-
not: {
812-
selector: { path: 'name' },
813-
evaluator: {
814-
name: 'regex',
815-
config: {
816-
pattern: '(^|\\.)(read|memory_search|memory_get)$',
817-
},
818-
},
819-
and: null,
820-
or: null,
821-
not: null,
822-
},
823-
},
824-
],
825-
not: null,
826-
},
827-
],
828-
or: null,
829-
not: null,
830-
},
831-
action: { decision: 'deny' },
832-
tags: ['slack', 'public'],
833-
},
834-
};
835-
const noisyControls: AgentControlsResponse = {
836-
controls: [noisyCompositeControl],
837-
};
838-
839-
await mockRoutes.agent(mockedPage, {
840-
controls: { data: noisyControls },
841-
});
842-
await mockedPage.route('**/api/v1/controls/88/data', async (route) => {
843-
await route.fulfill({
844-
status: 200,
845-
contentType: 'application/json',
846-
body: JSON.stringify({ data: noisyCompositeControl.control }),
847-
});
848-
});
849-
850-
await mockedPage.goto(
851-
getAgentControlsUrl({ modal: 'edit', controlId: 88 })
852-
);
853-
854-
const modal = mockedPage.getByRole('dialog', { name: 'Edit Control' });
855-
await expect(modal).toBeVisible();
856-
await modal.getByText('Full JSON', { exact: true }).click();
857-
858-
await expect(modal).not.toContainText('"selector": null');
859-
await expect(modal).not.toContainText('"evaluator": null');
860-
await expect(modal).not.toContainText('"and": null');
861-
await expect(modal).not.toContainText('"or": null');
862-
await expect(modal).not.toContainText('"not": null');
863-
await expect(modal).toContainText('"context.channel.scope"');
864-
await expect(modal).toContainText(
865-
'"(^|\\\\.)(read|memory_search|memory_get)$"'
866-
);
867-
});
868-
869766
test('disables Form switch when JSON is invalid', async ({ mockedPage }) => {
870767
await mockedPage.goto(agentUrl);
871768

0 commit comments

Comments
 (0)