fix(cli/session): emit JSON envelope in session delete for non-rich formats #10888
@@ -3,8 +3,6 @@ name: CI
|
||||
on:
|
||||
push:
|
||||
branches: [master, develop]
|
||||
pull_request:
|
||||
branches: [master, develop]
|
||||
|
||||
vars:
|
||||
docker_prefix: "http://harbor.cleverthis.com/docker/"
|
||||
|
||||
@@ -165,7 +165,7 @@ def step_resolve_with_no_config_data(context: Any) -> None:
|
||||
try:
|
||||
resolve_config_files("local/empty-actor", [])
|
||||
context.resolve_exit_code = 0
|
||||
except (SystemExit, click.exceptions.Exit) as exc:
|
||||
except (SystemExit, click.exceptions.Exit, typer.Exit) as exc:
|
||||
context.resolve_exit_code = getattr(
|
||||
exc, "exit_code", getattr(exc, "code", 1)
|
||||
)
|
||||
@@ -209,7 +209,7 @@ def step_resolve_unknown_actor_directly(context: Any) -> None:
|
||||
try:
|
||||
resolve_config_files("nonexistent/actor", [])
|
||||
context.resolve_exit_code = 0
|
||||
except (SystemExit, click.exceptions.Exit) as exc:
|
||||
except (SystemExit, click.exceptions.Exit, typer.Exit) as exc:
|
||||
context.resolve_exit_code = getattr(
|
||||
exc, "exit_code", getattr(exc, "code", 1)
|
||||
)
|
||||
@@ -260,7 +260,7 @@ def step_resolve_with_empty_config_blob(context: Any) -> None:
|
||||
try:
|
||||
resolve_config_files("local/empty-blob-actor", [])
|
||||
context.resolve_exit_code = 0
|
||||
except (SystemExit, click.exceptions.Exit) as exc:
|
||||
except (SystemExit, click.exceptions.Exit, typer.Exit) as exc:
|
||||
context.resolve_exit_code = getattr(
|
||||
exc, "exit_code", getattr(exc, "code", 1)
|
||||
)
|
||||
@@ -356,7 +356,7 @@ def step_resolve_with_unserializable_config_blob(context: Any) -> None:
|
||||
try:
|
||||
resolve_config_files("local/bad-blob-actor", [])
|
||||
context.resolve_exit_code = 0
|
||||
except (SystemExit, click.exceptions.Exit) as exc:
|
||||
except (SystemExit, click.exceptions.Exit, typer.Exit) as exc:
|
||||
context.resolve_exit_code = getattr(
|
||||
exc, "exit_code", getattr(exc, "code", 1)
|
||||
)
|
||||
|
||||
@@ -49,7 +49,7 @@ def step_resolve_with_control_character_name(context: Any) -> None:
|
||||
try:
|
||||
resolve_config_files(actor_name, [])
|
||||
context.resolve_exit_code = 0
|
||||
except (SystemExit, click.exceptions.Exit) as exc:
|
||||
except (SystemExit, click.exceptions.Exit, typer.Exit) as exc:
|
||||
context.resolve_exit_code = getattr(
|
||||
exc, "exit_code", getattr(exc, "code", 1)
|
||||
)
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
"""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
|
||||
@@ -185,7 +185,7 @@ def run(
|
||||
except UnsafeConfigurationError as exc:
|
||||
typer.echo(f"Error: {exc}", err=True)
|
||||
raise typer.Exit(code=1) from exc
|
||||
except click.exceptions.Exit:
|
||||
except (click.exceptions.Exit, typer.Exit):
|
||||
raise
|
||||
except CleverAgentsError as exc:
|
||||
typer.echo(f"Error: {exc}", err=True)
|
||||
|
||||
@@ -159,7 +159,7 @@ def run(
|
||||
except UnsafeConfigurationError as exc:
|
||||
typer.echo(f"Error: {exc}", err=True)
|
||||
raise typer.Exit(code=1) from exc
|
||||
except click.exceptions.Exit:
|
||||
except (click.exceptions.Exit, typer.Exit):
|
||||
raise
|
||||
except CleverAgentsError as exc:
|
||||
typer.echo(f"Error: {exc}", err=True)
|
||||
|
||||
@@ -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,
|
||||
|
HAL9001
commented
Suggestion: Consider passing a Suggestion: Consider passing a `command` argument to `format_output()` for completeness. Currently calling `format_output(data, fmt.value)` — the envelope will have an empty command field. Other commands pass descriptive strings like `"agents session create"`. For consistency, try: `typer.echo(format_output(data, fmt.value, command="agents session delete"))`. This is non-blocking.
|
||||
"storage_freed": "0 KB",
|
||||
|
HAL9001
commented
Suggestion: consider passing a command string to format_output for the envelope. Other commands like create and list_sessions include the command identifier in their envelope calls. Suggestion: consider passing a command string to format_output for the envelope. Other commands like create and list_sessions include the command identifier in their envelope calls.
|
||||
"plans_orphaned": 0,
|
||||
}
|
||||
typer.echo(format_output(data, fmt.value, command="agents session delete"))
|
||||
|
||||
except SessionNotFoundError as exc:
|
||||
console.print(f"[red]Session not found:[/red] {session_id}")
|
||||
|
||||
Reference in New Issue
Block a user
Question: Should we also add a BDD scenario for the error path (e.g., when a SessionNotFoundError is raised with --format json)? This would ensure the envelope behavior is tested for failure paths too. Not blocking, but would improve coverage.