Skip to content

Commit 72f6426

Browse files
authored
fix(traces): support flat trace structure in ha_get_automation_traces (homeassistant-ai#529)
* fix(traces): support flat trace structure in ha_get_automation_traces * test(e2e): enhance trace test to verify parsing of triggers and actions * fix(traces): restore support for legacy trace structure keys
1 parent 5bd54e9 commit 72f6426

3 files changed

Lines changed: 202 additions & 15 deletions

File tree

src/ha_mcp/tools/tools_traces.py

Lines changed: 58 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -411,11 +411,47 @@ def _format_detailed_trace(
411411
"state": trace.get("state"),
412412
}
413413

414+
raw_trace = trace.get("trace", {})
415+
416+
# Initialize lists
417+
triggers = []
418+
conditions = []
419+
actions = []
420+
421+
# Home Assistant trace data is stored as a flat dict with path keys
422+
# e.g. "trigger/0": [...], "action/0": [...], "action/0/1": [...]
423+
for path, steps in raw_trace.items():
424+
if not isinstance(steps, list):
425+
continue
426+
427+
for step in steps:
428+
# Create a copy to avoid modifying original
429+
step_info = step.copy()
430+
step_info["path"] = path
431+
432+
if path == "trigger" or path.startswith("trigger/"):
433+
triggers.append(step_info)
434+
elif path == "condition" or path.startswith("condition/"):
435+
conditions.append(step_info)
436+
elif path == "action" or path.startswith("action/"):
437+
actions.append(step_info)
438+
439+
# Sort by timestamp (if available) or path to maintain execution order
440+
def sort_key(item):
441+
return (item.get("timestamp", ""), item.get("path", ""))
442+
443+
triggers.sort(key=sort_key)
444+
conditions.sort(key=sort_key)
445+
actions.sort(key=sort_key)
446+
414447
# Extract trigger information
415-
trigger_trace = trace.get("trace", {}).get("trigger", [])
416-
if trigger_trace:
417-
trigger_step = trigger_trace[0]
418-
trigger_vars = trigger_step.get("variables", {}).get("trigger", {})
448+
if triggers:
449+
trigger_step = triggers[0]
450+
trigger_vars = trigger_step.get("changed_variables", {}).get("trigger", {})
451+
# Sometimes variables are in 'variables' key, sometimes 'changed_variables'
452+
if not trigger_vars:
453+
trigger_vars = trigger_step.get("variables", {}).get("trigger", {})
454+
419455
result["trigger"] = {
420456
"platform": trigger_vars.get("platform"),
421457
"description": trigger_vars.get("description"),
@@ -427,26 +463,29 @@ def _format_detailed_trace(
427463
result["trigger"]["from_state"] = trigger_vars.get("from_state", {}).get("state")
428464
if "entity_id" in trigger_vars:
429465
result["trigger"]["entity_id"] = trigger_vars["entity_id"]
466+
467+
# If no trigger info found in traces, try to get it from the top-level trigger field if present
468+
# (some HA versions might populate this)
469+
if "trigger" not in result and "trigger" in trace:
470+
result["trigger"] = {"description": trace["trigger"]}
430471

431472
# Extract condition results
432-
condition_trace = trace.get("trace", {}).get("condition", [])
433-
if condition_trace:
473+
if conditions:
434474
condition_results = []
435-
for cond in condition_trace:
475+
for cond in conditions:
436476
cond_result = {
437477
"result": cond.get("result", {}).get("result"),
478+
"path": cond.get("path"),
438479
}
439-
# Try to get condition type from the path
440-
if "path" in cond:
441-
cond_result["path"] = cond["path"]
480+
if "timestamp" in cond:
481+
cond_result["timestamp"] = cond["timestamp"]
442482
condition_results.append(cond_result)
443483
result["condition_results"] = condition_results
444484

445485
# Extract action trace
446-
action_trace = trace.get("trace", {}).get("action", [])
447-
if action_trace:
486+
if actions:
448487
action_results = []
449-
for action in action_trace:
488+
for action in actions:
450489
action_info: dict[str, Any] = {
451490
"path": action.get("path"),
452491
}
@@ -465,12 +504,17 @@ def _format_detailed_trace(
465504
action_info["error"] = action["error"]
466505

467506
# Extract variables if they contain useful debugging info
468-
variables = action.get("variables", {})
507+
# Check both 'variables' and 'changed_variables'
508+
variables = action.get("variables") or action.get("changed_variables", {})
469509
if variables and "trigger" not in variables: # Skip trigger vars (already shown)
470510
# Only include non-empty variable sets
471511
useful_vars = {k: v for k, v in variables.items() if v is not None}
472512
if useful_vars:
473513
action_info["variables"] = useful_vars
514+
515+
# Add child execution info (for nested scripts/automations)
516+
if "child_id" in action:
517+
action_info["child_id"] = action["child_id"]
474518

475519
action_results.append(action_info)
476520

tests/src/e2e/workflows/automation/test_traces.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,19 @@ async def check_automation_traces():
170170

171171
assert detailed_data.get("success") is True
172172
assert detailed_data.get("run_id") == run_id
173-
logger.info(f"Detailed trace retrieved for run_id: {run_id}")
173+
174+
# Verify detailed content structure (Deep verification)
175+
# This ensures we correctly parsed the flat structure (trigger/0, action/0)
176+
assert "trigger" in detailed_data, "Detailed trace should contain trigger info"
177+
assert "action_trace" in detailed_data, "Detailed trace should contain action_trace"
178+
assert isinstance(detailed_data["action_trace"], list), "action_trace should be a list"
179+
assert len(detailed_data["action_trace"]) > 0, "action_trace should not be empty"
180+
181+
# Check for path property to ensure flat structure parsing worked
182+
first_action = detailed_data["action_trace"][0]
183+
assert "path" in first_action, "Action trace element should contain 'path'"
184+
185+
logger.info(f"Detailed trace verified: Found {len(detailed_data['action_trace'])} actions")
174186

175187
async def test_empty_traces_with_diagnostics(
176188
self, mcp_client, cleanup_tracker, test_data_factory
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
"""Unit tests for tools_traces detailed trace formatting."""
2+
3+
import pytest
4+
from ha_mcp.tools.tools_traces import _format_detailed_trace
5+
6+
class TestFormatDetailedTrace:
7+
"""Test _format_detailed_trace function."""
8+
9+
def test_format_flat_trace_structure(self):
10+
"""Test parsing of Home Assistant's flat path-based trace structure."""
11+
12+
# Data structure as provided by user
13+
trace_data = {
14+
"timestamp": {"start": "2026-01-29T23:05:00.345824+00:00", "finish": "2026-01-29T23:05:00.356669+00:00"},
15+
"state": "stopped",
16+
"trigger": "time",
17+
"trace": {
18+
"trigger/0": [{
19+
"path": "trigger/0",
20+
"timestamp": "2026-01-29T23:05:00.345915+00:00",
21+
"changed_variables": {
22+
"trigger": {
23+
"platform": "time",
24+
"description": "time",
25+
"entity_id": None
26+
}
27+
}
28+
}],
29+
"action/0": [{
30+
"path": "action/0",
31+
"timestamp": "2026-01-29T23:05:00.346301+00:00",
32+
"result": {"params": {"domain": "light", "service": "turn_on"}}
33+
}],
34+
"action/0/0": [{
35+
"path": "action/0/0",
36+
"timestamp": "2026-01-29T23:05:00.347072+00:00",
37+
"child_id": {"domain": "script", "item_id": "set_brightness_chambre", "run_id": "04e0241d"},
38+
"result": {"params": {"domain": "script"}}
39+
}]
40+
},
41+
"config": {
42+
"alias": "Lumières Chambre 18h05",
43+
"mode": "single"
44+
}
45+
}
46+
47+
result = _format_detailed_trace("automation.test", "run_123", trace_data)
48+
49+
assert result["success"] is True
50+
assert result["automation_id"] == "automation.test"
51+
assert result["run_id"] == "run_123"
52+
53+
# Verify Trigger
54+
assert "trigger" in result
55+
assert result["trigger"]["platform"] == "time"
56+
assert result["trigger"]["description"] == "time"
57+
58+
# Verify Actions
59+
assert "action_trace" in result
60+
actions = result["action_trace"]
61+
assert len(actions) == 2
62+
63+
# Sort order should be preserved (action/0 then action/0/0)
64+
assert actions[0]["path"] == "action/0"
65+
assert actions[1]["path"] == "action/0/0"
66+
67+
# Verify content of actions
68+
assert actions[0]["result"]["params"]["service"] == "turn_on"
69+
assert actions[1]["child_id"]["item_id"] == "set_brightness_chambre"
70+
71+
def test_format_legacy_trace_structure(self):
72+
"""Test fallback parsing of potential legacy trace structure (lists)."""
73+
74+
trace_data = {
75+
"timestamp": "2026-01-29T23:05:00",
76+
"state": "stopped",
77+
"trace": {
78+
"trigger": [{
79+
"path": "trigger/0",
80+
"variables": {
81+
"trigger": {
82+
"platform": "state",
83+
"description": "state change"
84+
}
85+
}
86+
}],
87+
"action": [{
88+
"path": "action/0",
89+
"result": {"executed": True}
90+
}]
91+
}
92+
}
93+
94+
result = _format_detailed_trace("automation.legacy", "run_456", trace_data)
95+
96+
assert result["success"] is True
97+
98+
# Verify Trigger
99+
assert result["trigger"]["platform"] == "state"
100+
101+
# Verify Actions
102+
assert len(result["action_trace"]) == 1
103+
assert result["action_trace"][0]["result"]["executed"] is True
104+
105+
def test_format_mixed_variables_location(self):
106+
"""Test that variables are found whether in 'variables' or 'changed_variables'."""
107+
108+
trace_data = {
109+
"trace": {
110+
"trigger/0": [{
111+
"variables": {
112+
"trigger": {"platform": "variables_key"}
113+
}
114+
}],
115+
"trigger/1": [{
116+
"changed_variables": {
117+
"trigger": {"platform": "changed_variables_key"}
118+
}
119+
}]
120+
}
121+
}
122+
123+
# Test finding in 'variables' (legacy/standard)
124+
result1 = _format_detailed_trace("auto.1", "1",
125+
{"trace": {"trigger/0": trace_data["trace"]["trigger/0"]}})
126+
assert result1["trigger"]["platform"] == "variables_key"
127+
128+
# Test finding in 'changed_variables' (new flat format)
129+
result2 = _format_detailed_trace("auto.2", "2",
130+
{"trace": {"trigger/0": trace_data["trace"]["trigger/1"]}})
131+
assert result2["trigger"]["platform"] == "changed_variables_key"

0 commit comments

Comments
 (0)