Skip to content

Commit 0aa2f3c

Browse files
authored
fix(server): Omit null fields in control JSON editor (#157)
## Summary This updates the control JSON editor so `null` object fields are not rendered in Full JSON mode. ## What changed - Added a recursive UI-side normalizer that removes object properties whose values are `null` - Used that normalized shape when seeding the edit-control modal state - Used null-free stringification when switching from form mode to Full JSON mode - Added a regression test covering a composite condition tree with nullable transport fields ## Why Controls are not persisted with these `null` keys in the database, but the UI was rendering the nullable transport shape returned by the API. That made Full JSON mode noisy with entries like `"or": null` and `"selector": null`. ## Validation - `pnpm exec eslint src/core/page-components/agent-detail/modals/edit-control/edit-control-content.tsx src/core/page-components/agent-detail/modals/edit-control/utils.ts tests/agent-detail.spec.ts` ## Notes - Full Playwright verification is currently blocked locally by unrelated UI environment issues, including a missing `motion/react` module when starting a fresh dev server. - `make ui-typecheck` also currently fails elsewhere in the UI for unrelated type errors.
1 parent 439150f commit 0aa2f3c

2 files changed

Lines changed: 73 additions & 0 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:

0 commit comments

Comments
 (0)