Compare commits

...

1 Commits

Author SHA1 Message Date
HAL9000 07f04451b1 fix(cli): skip _build_envelope when data already spec-enveloped (#10982)
The plan execute and plan apply commands pass pre-built spec envelope
dicts (from _execute_output_dict() / _apply_output_dict()) to format_output().

Previously format_output() always called _build_envelope() for JSON/YAML
output, resulting in double-wrapping:

  { "command": "...", "data": { "command": "plan execute", ... } }

This fix adds _is_spec_envelope() to detect pre-existing spec envelopes
and serializes them directly without re-wrapping.

Co-Authored-By: task-implementor <hal9000@cleverthis.com>
2026-05-09 10:41:28 +00:00
6 changed files with 242 additions and 13 deletions
+8
View File
@@ -7,6 +7,14 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
### Fixed
-**plan apply --format json output wrapped in spec-required JSON envelope** (PR #9817):
The ``agents plan apply --format json`` command now produces a properly structured
JSON envelope with all required fields: ``command`` ("plan apply"), ``status``,
``exit_code``, ``data``, ``timing``, and ``messages``. Previously the output used
raw plan data without the spec-required envelope wrapper, making it inconsistent
with ``agents plan execute --format json`` and other API endpoints that follow
the Output Rendering Framework specification (§Output Rendering Framework).
- **Cross-actor subgraph cycle detection reads actor_ref field** (#1431): Fixed
`_detect_subgraph_cycles()`, `_map_node()`, and the `compile_actor()` main loop
in `src/cleveragents/actor/compiler.py` to read `actor_ref` from the top-level
+1
View File
@@ -31,3 +31,4 @@ Below are some of the specific details of various contributions.
* HAL 9000 has contributed comprehensive milestone documentation for v3.6.0 (Advanced Concepts & Deferred Features) and v3.7.0 (TUI Implementation) (PR #9903): split into sub-documents covering context strategies, LLM backends, resource types, A2A rename, container tool execution, scope chain resolution, cost/safety budgets, E2E workflow tests, code review examples, plugin architecture, TUI layout, persona system, reference/command input, session management, configuration, and TuiMaterializer integration.
* HAL 9000 has contributed the LLMTraceRepository data-integrity fix (PR #8185 / issue #7505): replaced the unconditional `session.commit()` in `LLMTraceRepository.save()` with a dual-path implementation that respects the UnitOfWork pattern — flushing only when an external session is provided, and flushing + committing + closing when operating standalone. This eliminates premature transaction commits, loss of rollback capability, and a docstring/implementation mismatch.
* HAL 9000 has contributed the ACMS Index Data Model and File Traversal Engine (PR #9664 / issue #9579): foundational data structures for indexed context entries with hot/warm/cold/archive storage tier classification, tag system, and a timeout-safe chunked file traversal engine for large projects with 10,000+ files.
* HAL 9000 has contributed the `plan apply --format json` spec-required JSON envelope fix (PR #9817): added `_apply_output_dict()` function to build the structured JSON envelope with all required fields (`command`, `status`, `exit_code`, `data`, `timing`, `messages`) for ``agents plan apply --format json``, matching the pattern used by ``plan execute``. Includes BDD test coverage for envelope structure verification.
+11
View File
@@ -112,6 +112,17 @@ Feature: Plan CLI coverage boost
Then the plan coverage command should succeed
And the plan coverage output should contain "plan_id"
@tdd_issue @tdd_issue_9817
Scenario: apply_plan JSON output has spec-required envelope structure
Given a plan lifecycle CLI runner for coverage
And a mocked lifecycle service for plan coverage commands
And the service has a complete execute plan for apply
When I invoke apply with "--format" "json" and plan id
Then the plan coverage command should succeed
And the apply JSON output has the spec-required envelope fields
And the apply JSON output data has plan_id field
And the apply JSON output data has namespaced_name field
# ---- list_plans regex and state/processing_state filtering ----
Scenario: list_plans filters by regex pattern
@@ -698,3 +698,70 @@ def step_execute_json_progress_list(context) -> None:
)
assert "label" in step, f"Expected 'label' key in progress step: {step}"
assert "status" in step, f"Expected 'status' key in progress step: {step}"
# --------------------------------------------------------------------------
# apply_plan JSON envelope step definitions (PR #9817)
# --------------------------------------------------------------------------
@then("the apply JSON output has the spec-required envelope fields")
def step_apply_json_envelope_fields(context) -> None:
"""Verify the apply JSON output has the spec-required top-level envelope."""
import json
output = _output(context)
# The output may have a trailing newline; strip it
parsed = json.loads(output.strip())
required_fields = {"command", "status", "exit_code", "data", "timing", "messages"}
missing = required_fields - set(parsed.keys())
assert not missing, (
f"Missing envelope fields {missing} in apply JSON output:\n{output}"
)
assert parsed["command"] == "plan apply", (
f"Expected command='plan apply', got '{parsed['command']}'"
)
assert parsed["status"] == "ok", f"Expected status='ok', got '{parsed['status']}'"
assert parsed["exit_code"] == 0, (
f"Expected exit_code=0, got '{parsed['exit_code']}'"
)
assert isinstance(parsed["data"], dict), (
f"Expected data to be a dict, got {type(parsed['data'])}"
)
assert isinstance(parsed["messages"], list), (
f"Expected messages to be a list, got {type(parsed['messages'])}"
)
# Verify messages have level and text keys
for msg in parsed["messages"]:
assert "level" in msg, f"Message missing 'level' key: {msg}"
assert "text" in msg, f"Message missing 'text' key: {msg}"
@then("the apply JSON output data has plan_id field")
def step_apply_json_data_plan_id(context) -> None:
"""Verify the apply JSON output data contains plan_id."""
import json
output = _output(context)
parsed = json.loads(output.strip())
data = parsed["data"]
assert "plan_id" in data, f"Expected 'plan_id' key in apply JSON data: {data}"
assert isinstance(data["plan_id"], str), (
f"Expected plan_id to be str, got {type(data['plan_id'])}: {data['plan_id']}"
)
@then("the apply JSON output data has namespaced_name field")
def step_apply_json_data_namespaced_name(context) -> None:
"""Verify the apply JSON output data contains namespaced_name."""
import json
output = _output(context)
parsed = json.loads(output.strip())
data = parsed["data"]
assert "namespaced_name" in data, (
f"Expected 'namespaced_name' key in apply JSON data: {data}"
)
assert isinstance(data["namespaced_name"], str), (
f"Expected namespaced_name to be str: {data['namespaced_name']}"
)
+113 -3
View File
@@ -477,6 +477,106 @@ def _execute_output_dict(
}
def _apply_output_dict(
plan: Any,
started_at: datetime | None = None,
duration_ms: int | None = None,
) -> dict[str, object]:
"""Build the spec-required apply output envelope.
Returns the structured JSON envelope for ``agents plan apply --format json``
as defined in the specification §agents plan apply.
The envelope structure is::
{
"command": "plan apply",
"status": "ok",
"exit_code": 0,
"data": {
"plan_id": "...",
"namespaced_name": "...",
"phase": "apply",
"processing_state": "applied",
"project_links": [...]
},
"timing": {"duration_ms": ...},
"messages": [{"level": "ok", "text": "Plan applied"}]
}
Args:
plan: The ``Plan`` domain model after successful application.
started_at: Optional wall-clock start time for timing output.
duration_ms: Optional computed elapsed time in milliseconds.
Returns:
A JSON-serialisable dict matching the spec-required envelope.
"""
from cleveragents.domain.models.core.plan import Plan as LifecyclePlan
if not isinstance(plan, LifecyclePlan):
# Legacy plan fallback — return minimal envelope
return {
"command": "plan apply",
"status": "ok",
"exit_code": 0,
"data": {"plan": str(plan)},
"timing": {},
"messages": [{"level": "ok", "text": "Plan applied"}],
}
plan_id = plan.identity.plan_id
# ── Project links ────────────────────────────────────────────────────────
project_links: list[dict[str, object]] = [
{
"project_name": link.project_name,
**({"alias": link.alias} if link.alias else {}),
**({"read_only": True} if link.read_only else {}),
}
for link in plan.project_links
]
# ── Data payload ─────────────────────────────────────────────────────────
data: dict[str, object] = {
"plan_id": plan_id,
"namespaced_name": str(plan.namespaced_name),
"phase": plan.phase.value,
"processing_state": plan.processing_state.value,
"state": plan.processing_state.value if plan.state else "unknown",
"project_links": project_links,
"arguments": plan.arguments,
"automation_profile": (
plan.automation_profile.profile_name
if plan.automation_profile
else None
),
"action_name": plan.action_name,
"description": plan.description,
"definition_of_done": plan.definition_of_done,
}
if plan.is_terminal:
data["is_terminal"] = True
if plan.error_message:
data["error_message"] = plan.error_message
# ── Timing ───────────────────────────────────────────────────────────────
timing: dict[str, object] = {}
if started_at is not None:
timing["started"] = started_at.isoformat()
if duration_ms is not None:
timing["duration_ms"] = duration_ms
return {
"command": "plan apply",
"status": "ok",
"exit_code": 0,
"data": data,
"timing": timing,
"messages": [{"level": "ok", "text": "Plan applied"}],
}
def _get_current_project() -> Project:
"""Get the current project or exit with error.
@@ -2181,6 +2281,9 @@ def lifecycle_apply_plan(
try:
service = _get_lifecycle_service()
# Track wall-clock start time for timing output
apply_wall_start = datetime.now()
if not plan_id:
# Try to find plans eligible for apply: Execute/complete or
# Apply/queued (auto_progress may have already advanced).
@@ -2270,9 +2373,16 @@ def lifecycle_apply_plan(
):
plan = service._complete_apply_if_queued(plan_id)
if fmt != OutputFormat.RICH.value:
data = _plan_spec_dict(plan)
console.print(format_output(data, fmt))
if fmt != OutputFormat.RICH.value:
apply_elapsed_ms = int(
(datetime.now() - apply_wall_start).total_seconds() * 1000
)
envelope = _apply_output_dict(
plan,
started_at=apply_wall_start,
duration_ms=apply_elapsed_ms,
)
console.print(format_output(envelope, fmt))
else:
title = "Plan Applied" if plan.is_terminal else "Plan Applying"
_print_lifecycle_plan(plan, title=title)
+42 -10
View File
@@ -163,6 +163,24 @@ def _redact_data(
_VALID_STATUSES: frozenset[str] = frozenset({"ok", "warn", "error"})
def _is_spec_envelope(data: Any) -> bool:
"""Return True if *data* is already a spec-required output envelope.
This is used to avoid double-wrapping when callers (e.g. plan execute /
apply) pass a fully-formed envelope dict directly to ``format_output()``
instead of raw command-specific data.
A pre-enveloped dict has exactly the keys produced by :func:`_build_envelope`:
``command``, ``status``, ``exit_code``, ``data``, ``timing``, ``messages``.
"""
if not isinstance(data, dict):
return False
envelope_keys = frozenset(
("command", "status", "exit_code", "data", "timing", "messages")
)
return envelope_keys.issubset(data.keys())
def _build_envelope(
data: dict[str, Any] | list[dict[str, Any]],
command: str,
@@ -251,8 +269,10 @@ def format_output(
For machine-readable formats (json, yaml) the output is wrapped in
the spec-required envelope (``command``, ``status``, ``exit_code``,
``data``, ``timing``, ``messages``) before serialisation. Plain
format renders the raw data without an envelope.
``data``, ``timing``, ``messages``) before serialisation unless
*data* already has that structure, in which case it is serialized
directly to avoid double-wrapping. Plain format renders the raw
data without an envelope.
For machine-readable formats (json, yaml, plain) the output is
written directly to ``sys.stdout`` to avoid Rich ``console.print``
@@ -291,6 +311,12 @@ def format_output(
# Machine-readable formats: write directly to stdout to avoid
# Rich console.print() line-wrapping artefacts.
# Detect pre-enveloped data to avoid double-wrapping.
# ``plan execute`` and ``plan apply`` pass already-built spec envelopes
# (from ``_execute_output_dict()``, ``_apply_output_dict()``) which would
# otherwise be re-wrapped by ``_build_envelope()`` below.
is_enveloped = _is_spec_envelope(safe_data)
if fmt in (
OutputFormat.JSON.value,
OutputFormat.YAML.value,
@@ -298,16 +324,22 @@ def format_output(
):
if fmt == OutputFormat.JSON.value:
duration_ms = int((time.monotonic() - t_start) * 1000)
envelope = _build_envelope(
safe_data, command, status, exit_code, duration_ms, messages
)
rendered = _format_json(envelope)
if is_enveloped:
rendered = _format_json(safe_data)
else:
envelope = _build_envelope(
safe_data, command, status, exit_code, duration_ms, messages
)
rendered = _format_json(envelope)
elif fmt == OutputFormat.YAML.value:
duration_ms = int((time.monotonic() - t_start) * 1000)
envelope = _build_envelope(
safe_data, command, status, exit_code, duration_ms, messages
)
rendered = _format_yaml(envelope)
if is_enveloped:
rendered = _format_yaml(safe_data)
else:
envelope = _build_envelope(
safe_data, command, status, exit_code, duration_ms, messages
)
rendered = _format_yaml(envelope)
else:
rendered = _format_plain(safe_data)
sys.stdout.write(rendered + "\n")