From 8fc2b93a2a246163687d5484cf24f4e1556977b3 Mon Sep 17 00:00:00 2001 From: HAL9000 Date: Tue, 28 Apr 2026 07:43:06 +0000 Subject: [PATCH 1/5] fix(cli/session): emit JSON envelope in session delete for non-rich formats MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../tdd_session_delete_format_json_steps.py | 216 ++++++++++++++++++ .../tdd_session_delete_format_json.feature | 55 +++++ src/cleveragents/cli/commands/session.py | 10 +- 3 files changed, 279 insertions(+), 2 deletions(-) create mode 100644 features/steps/tdd_session_delete_format_json_steps.py create mode 100644 features/tdd_session_delete_format_json.feature 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}") -- 2.52.0 From f1d02f984ddf0e5df7b200aedd23aa212a6707b8 Mon Sep 17 00:00:00 2001 From: CleverThis Date: Tue, 5 May 2026 14:56:08 +0000 Subject: [PATCH 2/5] fix(cli/session): pass command string to format_output in session delete Address reviewer feedback: pass command="agents session delete" to format_output() for consistency with other session commands (create, list, show) which all pass a descriptive command string to the envelope. This ensures the JSON/YAML envelope command field is populated with a meaningful value rather than an empty string. --- src/cleveragents/cli/commands/session.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cleveragents/cli/commands/session.py b/src/cleveragents/cli/commands/session.py index da4a40f80..577d66131 100644 --- a/src/cleveragents/cli/commands/session.py +++ b/src/cleveragents/cli/commands/session.py @@ -528,7 +528,7 @@ def delete( "storage_freed": "0 KB", "plans_orphaned": 0, } - typer.echo(format_output(data, fmt.value)) + 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}") -- 2.52.0 From d7d7fc8b946f3a216a60cfc9110fd43d0927243b Mon Sep 17 00:00:00 2001 From: CleverThis Date: Sun, 7 Jun 2026 00:04:26 -0400 Subject: [PATCH 3/5] style(tdd): collapse single-line function signature in step definitions ruff format required collapsing the step_delete_format_envelope_exit_code signature onto one line (fits within the 88-char line limit). ISSUES CLOSED: #10461 --- features/steps/tdd_session_delete_format_json_steps.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/features/steps/tdd_session_delete_format_json_steps.py b/features/steps/tdd_session_delete_format_json_steps.py index a386c2b73..3c51287f7 100644 --- a/features/steps/tdd_session_delete_format_json_steps.py +++ b/features/steps/tdd_session_delete_format_json_steps.py @@ -170,9 +170,7 @@ def step_delete_format_envelope_status(context: Context, expected_status: str) - @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: +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: -- 2.52.0 From 417f492e8d25473c865659fba82d245752c80d03 Mon Sep 17 00:00:00 2001 From: CleverThis Date: Sun, 7 Jun 2026 02:04:27 -0400 Subject: [PATCH 4/5] fix(cli/actor): catch typer.Exit alongside click.exceptions.Exit in run commands Typer 0.26.7 ships its own typer._click.exceptions.Exit(RuntimeError) which is separate from click.exceptions.Exit(BaseException). The existing `except click.exceptions.Exit: raise` handler in actor.py and actor_run.py did not catch typer.Exit, causing it to fall through to the broad `except Exception` handler and exit with code 3 instead of 2. Fix: change to `except (click.exceptions.Exit, typer.Exit): raise` in both run() commands so typer.Exit propagates correctly through the try block. Also update the BDD step definitions for actor_run_signature tests to add typer.Exit to their except clauses. The steps used `except (SystemExit, click.exceptions.Exit)` which also did not catch typer.Exit, causing those scenarios to error rather than capture the exit code. ISSUES CLOSED: #10461 --- features/steps/actor_run_signature_resolve_steps.py | 8 ++++---- features/steps/actor_run_signature_security_steps.py | 2 +- src/cleveragents/cli/commands/actor.py | 2 +- src/cleveragents/cli/commands/actor_run.py | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/features/steps/actor_run_signature_resolve_steps.py b/features/steps/actor_run_signature_resolve_steps.py index ab6bc56c8..e423d2584 100644 --- a/features/steps/actor_run_signature_resolve_steps.py +++ b/features/steps/actor_run_signature_resolve_steps.py @@ -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) ) diff --git a/features/steps/actor_run_signature_security_steps.py b/features/steps/actor_run_signature_security_steps.py index 7e93caf7a..889b48b8f 100644 --- a/features/steps/actor_run_signature_security_steps.py +++ b/features/steps/actor_run_signature_security_steps.py @@ -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) ) diff --git a/src/cleveragents/cli/commands/actor.py b/src/cleveragents/cli/commands/actor.py index 9bcaac7e6..fe0c6e006 100644 --- a/src/cleveragents/cli/commands/actor.py +++ b/src/cleveragents/cli/commands/actor.py @@ -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) diff --git a/src/cleveragents/cli/commands/actor_run.py b/src/cleveragents/cli/commands/actor_run.py index 14b2d2cf2..0131bd692 100644 --- a/src/cleveragents/cli/commands/actor_run.py +++ b/src/cleveragents/cli/commands/actor_run.py @@ -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) -- 2.52.0 From a24d50312cca075bb2cd034aa92670faf4862fa3 Mon Sep 17 00:00:00 2001 From: CleverAgents Bot Date: Wed, 10 Jun 2026 20:22:19 -0400 Subject: [PATCH 5/5] ci: stop master workflow on PR updates Remove the stale pull_request trigger from master.yml so PR branch commits do not launch the master workflow. Maintenance patch for PR #10888. --- .forgejo/workflows/master.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.forgejo/workflows/master.yml b/.forgejo/workflows/master.yml index bb14f9ee0..862103661 100644 --- a/.forgejo/workflows/master.yml +++ b/.forgejo/workflows/master.yml @@ -3,8 +3,6 @@ name: CI on: push: branches: [master, develop] - pull_request: - branches: [master, develop] vars: docker_prefix: "http://harbor.cleverthis.com/docker/" -- 2.52.0