fix(cli/session): emit JSON envelope in session delete for non-rich formats
CI / benchmark-publish (pull_request) Has been skipped
CI / helm (pull_request) Successful in 33s
CI / lint (pull_request) Failing after 58s
CI / build (pull_request) Successful in 41s
CI / push-validation (pull_request) Successful in 30s
CI / quality (pull_request) Successful in 1m9s
CI / typecheck (pull_request) Successful in 1m17s
CI / security (pull_request) Successful in 1m37s
CI / coverage (pull_request) Has been skipped
CI / integration_tests (pull_request) Successful in 3m26s
CI / e2e_tests (pull_request) Successful in 4m2s
CI / unit_tests (pull_request) Successful in 5m36s
CI / docker (pull_request) Has been skipped
CI / status-check (pull_request) Failing after 3s

Replace the bare console.print() Rich markup call in the session delete
command's non-rich branch with a proper format_output() call that emits
a spec-compliant JSON/YAML/plain envelope.

The else branch previously called:
  console.print(f"[green]✓ OK[/green] Session {session_id} deleted")
which emitted raw Rich markup to stdout, breaking any downstream JSON
parser. The fix constructs a data dict with session_id, messages_removed,
storage_freed, and plans_orphaned fields and delegates to format_output().

Also adds BDD feature file and step definitions tagged @tdd_issue_10461
that verify the JSON envelope output and confirm no regression in the
default Rich console output path.

ISSUES CLOSED: #10461
This commit is contained in:
2026-04-28 07:43:06 +00:00
parent ff62e28d16
commit 8fc2b93a2a
3 changed files with 279 additions and 2 deletions
@@ -0,0 +1,216 @@
"""Step definitions for TDD session delete --format json envelope tests.
These tests verify that ``session delete --format json`` emits a spec-compliant
JSON envelope instead of Rich markup text.
Issue #10461 (Bug): session delete --format json outputs Rich markup instead of
JSON envelope.
Issue #10457 (TDD): add failing test for session delete --format json missing envelope.
"""
from __future__ import annotations
import json
from datetime import datetime
from unittest.mock import MagicMock
from behave import given, then, when
from behave.runner import Context
from typer.testing import CliRunner
from ulid import ULID
from cleveragents.cli.commands import session as session_mod
from cleveragents.cli.commands.session import app as session_app
from cleveragents.domain.models.core.session import (
Session,
SessionService,
SessionTokenUsage,
)
_runner = CliRunner()
_SESSION_ID = str(ULID())
def _make_mock_service(session_id: str) -> MagicMock:
"""Create a MagicMock SessionService for delete tests."""
svc = MagicMock(spec=SessionService)
session = Session(
session_id=session_id,
actor_name=None,
namespace="local",
messages=[],
token_usage=SessionTokenUsage(
input_tokens=10,
output_tokens=5,
estimated_cost=0.001,
),
created_at=datetime.now(),
updated_at=datetime.now(),
)
svc.get.return_value = session
svc.delete.return_value = None
return svc
# ---------------------------------------------------------------------------
# Background
# ---------------------------------------------------------------------------
@given("a session delete format mock service")
def step_setup_delete_format_mock_service(context: Context) -> None:
"""Set up a mock session service for delete format tests."""
svc = _make_mock_service(_SESSION_ID)
session_mod._service = svc
context.mock_svc = svc
context.session_id = _SESSION_ID
def cleanup() -> None:
session_mod._service = None
context.add_cleanup(cleanup)
# ---------------------------------------------------------------------------
# When steps
# ---------------------------------------------------------------------------
@when("I invoke session delete with --yes and --format json")
def step_invoke_delete_format_json(context: Context) -> None:
"""Invoke session delete with --yes and --format json."""
context.result = _runner.invoke(
session_app,
["delete", context.session_id, "--yes", "--format", "json"],
)
@when("I invoke session delete with --yes and --format yaml")
def step_invoke_delete_format_yaml(context: Context) -> None:
"""Invoke session delete with --yes and --format yaml."""
context.result = _runner.invoke(
session_app,
["delete", context.session_id, "--yes", "--format", "yaml"],
)
@when("I invoke session delete with --yes and no format flag")
def step_invoke_delete_no_format(context: Context) -> None:
"""Invoke session delete with --yes and default (rich) format."""
context.result = _runner.invoke(
session_app,
["delete", context.session_id, "--yes"],
)
# ---------------------------------------------------------------------------
# Then steps
# ---------------------------------------------------------------------------
@then("the delete format command exits with code 0")
def step_delete_format_exit_0(context: Context) -> None:
"""Assert the command exited with code 0."""
assert context.result.exit_code == 0, (
f"Expected exit code 0, got {context.result.exit_code}.\n"
f"Output:\n{context.result.output}"
)
@then("the delete format output is valid JSON")
def step_delete_format_valid_json(context: Context) -> None:
"""Assert the output is valid JSON."""
try:
context.parsed_json = json.loads(context.result.output)
except json.JSONDecodeError as exc:
raise AssertionError(
f"Output is not valid JSON:\n{context.result.output}"
) from exc
@then('the delete format JSON envelope has key "{key}"')
def step_delete_format_envelope_has_key(context: Context, key: str) -> None:
"""Assert the JSON envelope has the given top-level key."""
parsed = getattr(context, "parsed_json", None)
if parsed is None:
parsed = json.loads(context.result.output)
context.parsed_json = parsed
assert key in parsed, (
f"Expected key '{key}' in JSON envelope, got keys: {list(parsed.keys())}\n"
f"Output:\n{context.result.output}"
)
@then('the delete format JSON data has key "{key}"')
def step_delete_format_data_has_key(context: Context, key: str) -> None:
"""Assert the JSON envelope data field has the given key."""
parsed = getattr(context, "parsed_json", None)
if parsed is None:
parsed = json.loads(context.result.output)
context.parsed_json = parsed
data = parsed.get("data", {})
assert key in data, (
f"Expected key '{key}' in JSON data, got keys: {list(data.keys())}\n"
f"Output:\n{context.result.output}"
)
@then('the delete format JSON envelope status is "{expected_status}"')
def step_delete_format_envelope_status(context: Context, expected_status: str) -> None:
"""Assert the JSON envelope status field matches expected value."""
parsed = getattr(context, "parsed_json", None)
if parsed is None:
parsed = json.loads(context.result.output)
context.parsed_json = parsed
actual_status = parsed.get("status")
assert actual_status == expected_status, (
f"Expected status '{expected_status}', got '{actual_status}'\n"
f"Output:\n{context.result.output}"
)
@then("the delete format JSON envelope exit_code is {expected_code:d}")
def step_delete_format_envelope_exit_code(
context: Context, expected_code: int
) -> None:
"""Assert the JSON envelope exit_code field matches expected value."""
parsed = getattr(context, "parsed_json", None)
if parsed is None:
parsed = json.loads(context.result.output)
context.parsed_json = parsed
actual_code = parsed.get("exit_code")
assert actual_code == expected_code, (
f"Expected exit_code {expected_code}, got {actual_code}\n"
f"Output:\n{context.result.output}"
)
@then('the delete format output contains "{text}"')
def step_delete_format_output_contains(context: Context, text: str) -> None:
"""Assert the output contains the given text."""
assert text in context.result.output, (
f"Expected '{text}' in output:\n{context.result.output}"
)
@then('the delete format output does not contain "{text}"')
def step_delete_format_output_not_contains(context: Context, text: str) -> None:
"""Assert the output does not contain the given text."""
assert text not in context.result.output, (
f"Expected '{text}' NOT in output, but found it:\n{context.result.output}"
)
@then("the delete format JSON data session_id matches the deleted session")
def step_delete_format_data_session_id(context: Context) -> None:
"""Assert the JSON data.session_id matches the session that was deleted."""
parsed = getattr(context, "parsed_json", None)
if parsed is None:
parsed = json.loads(context.result.output)
context.parsed_json = parsed
data = parsed.get("data", {})
actual_id = data.get("session_id")
assert actual_id == context.session_id, (
f"Expected session_id '{context.session_id}', got '{actual_id}'\n"
f"Output:\n{context.result.output}"
)
@@ -0,0 +1,55 @@
@tdd_issue @tdd_issue_10461
Feature: TDD — session delete --format json emits JSON envelope
As a developer using the CLI
I want `agents session delete --format json` to emit a spec-compliant JSON envelope
So that automation scripts can parse machine-readable output from session delete
Background:
Given a session delete format mock service
@tdd_issue @tdd_issue_10461
Scenario: session delete --format json emits a spec-compliant JSON envelope
When I invoke session delete with --yes and --format json
Then the delete format command exits with code 0
And the delete format output is valid JSON
And the delete format JSON envelope has key "command"
And the delete format JSON envelope has key "status"
And the delete format JSON envelope has key "exit_code"
And the delete format JSON envelope has key "data"
And the delete format JSON envelope has key "timing"
And the delete format JSON envelope has key "messages"
And the delete format JSON data has key "session_id"
And the delete format JSON data has key "messages_removed"
And the delete format JSON data has key "storage_freed"
And the delete format JSON data has key "plans_orphaned"
And the delete format JSON envelope status is "ok"
And the delete format JSON envelope exit_code is 0
@tdd_issue @tdd_issue_10461
Scenario: session delete --format json output contains no Rich markup
When I invoke session delete with --yes and --format json
Then the delete format command exits with code 0
And the delete format output does not contain "[green]"
And the delete format output does not contain " OK"
@tdd_issue @tdd_issue_10461
Scenario: session delete --format yaml emits a YAML envelope
When I invoke session delete with --yes and --format yaml
Then the delete format command exits with code 0
And the delete format output contains "status:"
And the delete format output contains "session_id:"
@tdd_issue @tdd_issue_10461
Scenario: session delete without --format continues to render Rich panels
When I invoke session delete with --yes and no format flag
Then the delete format command exits with code 0
And the delete format output contains "Deletion Summary"
And the delete format output contains "Cleanup"
And the delete format output contains "Session deleted"
@tdd_issue @tdd_issue_10461
Scenario: session delete --format json data contains correct session_id
When I invoke session delete with --yes and --format json
Then the delete format command exits with code 0
And the delete format output is valid JSON
And the delete format JSON data session_id matches the deleted session
+8 -2
View File
@@ -521,8 +521,14 @@ def delete(
console.print()
console.print("[green]✓ OK[/green] Session deleted")
else:
# Non-rich formats: simple message
console.print(f"[green]✓ OK[/green] Session {session_id} deleted")
# Non-rich formats: emit spec-compliant JSON/YAML/plain envelope
data: dict[str, Any] = {
"session_id": session_id,
"messages_removed": message_count,
"storage_freed": "0 KB",
"plans_orphaned": 0,
}
typer.echo(format_output(data, fmt.value))
except SessionNotFoundError as exc:
console.print(f"[red]Session not found:[/red] {session_id}")