fix(cli): align output envelope with specification structure #1187

Merged
freemo merged 1 commits from feature/m4-output-envelope into master 2026-03-30 18:06:36 +00:00
6 changed files with 29 additions and 16 deletions
@@ -279,6 +279,7 @@ def step_given_mock_snapshot(context: Context) -> None:
context.mock_snapshot = StructuredOutput(
command="test",
session_id="ses-000001",
status="ok",
elements=[],
exit_code=0,
metadata={},
+1 -1
View File
@@ -552,7 +552,7 @@ def step_json_valid(context: Context) -> None:
@then('the json output contains element type "{etype}"')
def step_json_element_type(context: Context, etype: str) -> None:
data = context.json_data
elements = data.get("elements", [])
elements = data.get("data", [])
types = [e.get("type") for e in elements]
assert etype in types, f"Expected element type {etype!r} in {types}"
+9 -5
View File
@@ -60,8 +60,10 @@ def test_json_output() -> None:
table.close()
output = strategy.get_output()
data = json.loads(output)
assert "elements" in data
assert len(data["elements"]) == 2
assert "data" in data
assert len(data["data"]) == 2
assert "status" in data
assert data["status"] == "ok"
print("output-rendering-json-output-ok")
@@ -74,7 +76,9 @@ def test_yaml_output() -> None:
panel.close()
output = strategy.get_output()
data = yaml.safe_load(output)
assert "elements" in data
assert "data" in data
assert "status" in data
assert data["status"] == "ok"
print("output-rendering-yaml-output-ok")
@@ -271,7 +275,7 @@ def test_json_all_elements() -> None:
session.action_hint(["cmd"])
output = strategy.get_output()
data = json.loads(output)
types = [e["type"] for e in data["elements"]]
types = [e["type"] for e in data["data"]]
expected = [
"panel",
"table",
@@ -327,7 +331,7 @@ def test_yaml_all_elements() -> None:
session.action_hint(["cmd"])
output = strategy.get_output()
data = yaml.safe_load(output)
types = [e["type"] for e in data["elements"]]
types = [e["type"] for e in data["data"]]
expected = [
"panel",
"table",
+3 -5
View File
@@ -65,11 +65,9 @@ yet needed in the current milestone.
The spec does not define an element count limit. A soft limit of
10,000 is enforced as a DoS guard.
**SD-13 (JSON/YAML envelope structure)**
The spec defines ``status``/``data`` wrapper, ``messages``, and
``timing`` fields in the serialised envelope. This implementation
serialises a flat ``StructuredOutput`` dict with ``command``,
``session_id``, ``exit_code``, ``elements``, and ``metadata``.
**SD-13 (JSON/YAML envelope structure IMPLEMENTED)**
The spec's envelope fields (``status``, ``data``, ``messages``,
``timing``) are now emitted. Implemented per M4 fix (#884).
**SD-14 (NO_COLOR env var IMPLEMENTED)**
``NO_COLOR`` (https://no-color.org/) is now respected: when set,
+7 -5
View File
@@ -275,16 +275,18 @@ def _snapshot_to_dict(snapshot: StructuredOutput) -> dict[str, Any]:
P1-2 fix (Luis review): includes the ``timing`` field when present,
matching the spec's JSON envelope structure (§27022).
M4 fix: renamed ``elements`` ``data``, ``metadata`` ``messages``,
and added ``status`` field to match the specification envelope.
"""
result: dict[str, Any] = {
"command": snapshot.command,
"session_id": snapshot.session_id,
"status": snapshot.status,
"exit_code": snapshot.exit_code,
"elements": [_element_to_dict(e) for e in snapshot.elements],
"metadata": snapshot.metadata,
"data": [_element_to_dict(e) for e in snapshot.elements],
"timing": snapshot.timing,
"messages": snapshot.metadata,
}
if snapshot.timing is not None:
result["timing"] = snapshot.timing
return result
+8
View File
@@ -72,6 +72,7 @@ class StructuredOutput(BaseModel):
command: str
session_id: str
status: str = "ok"
elements: list[ElementSnapshot] = Field(default_factory=list)
exit_code: int = 0
timing: dict[str, Any] | None = None
@@ -424,12 +425,17 @@ class OutputSession:
P1-2 fix (Luis review): includes ``timing`` when available so
that accumulate strategies (JSON/YAML) emit the timing field.
M4 fix: derives ``status`` from ``exit_code`` ``"ok"`` when
zero, ``"error"`` otherwise.
"""
with self._lock:
elements = [h.element_copy() for h in self._handles.values()]
status = "error" if self._exit_code != 0 else "ok"
snap = StructuredOutput(
command=self.command,
session_id=self.session_id,
status=status,
elements=list(elements),
exit_code=self._exit_code,
metadata=dict(self._metadata),
@@ -445,9 +451,11 @@ class OutputSession:
# Already closed — return snapshot with cached timing
# (R2-C2 fix: previously timing was None on double-close).
elements = [h.element_copy() for h in self._handles.values()]
status = "error" if self._exit_code != 0 else "ok"
snap = StructuredOutput(
command=self.command,
session_id=self.session_id,
status=status,
elements=list(elements),
exit_code=self._exit_code,
metadata=dict(self._metadata),