diff --git a/features/steps/tdd_session_delete_format_json_steps.py b/features/steps/tdd_session_delete_format_json_steps.py new file mode 100644 index 000000000..a386c2b73 --- /dev/null +++ b/features/steps/tdd_session_delete_format_json_steps.py @@ -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}" + ) diff --git a/features/tdd_session_delete_format_json.feature b/features/tdd_session_delete_format_json.feature new file mode 100644 index 000000000..abb498028 --- /dev/null +++ b/features/tdd_session_delete_format_json.feature @@ -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 diff --git a/src/cleveragents/cli/commands/session.py b/src/cleveragents/cli/commands/session.py index 99ec2498a..da4a40f80 100644 --- a/src/cleveragents/cli/commands/session.py +++ b/src/cleveragents/cli/commands/session.py @@ -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}")