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
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:
@@ -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
|
||||
@@ -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}")
|
||||
|
||||
Reference in New Issue
Block a user