diff --git a/CHANGELOG.md b/CHANGELOG.md index c9054ef31..0309cc6f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1002,6 +1002,14 @@ iteration` and data corruption under concurrent plan execution. All public replaces the O(n) linear `status` lookup in the `as_completed()` loop with an O(1) `status_map` dict pre-computed before the executor block. +- **JSON/YAML envelope `messages[].text` content** (#6457): `agents session create`, + `list`, `show`, `delete`, `export`, and `import` commands now populate the + `messages[].text` field with human-readable text (`"Session created"`, + `"N sessions listed"`, `"Session details loaded"`, `"Session deleted"`, + `"Export completed"`, `"Import completed"`) instead of the generic `"ok"` fallback. + The `export` command gains `--output-format` and the `import` command gains `--format` + to select the output envelope format independently of the export/import file format. + - **Robot Framework TDD Listener Guards** (#5436): Added three guard conditions to the `tdd_expected_fail_listener` `end_test()` function to prevent blindly inverting ALL test failures to passes, which was masking infrastructure errors and causing flaky CI behavior. diff --git a/features/devcontainer_cleanup.feature b/features/devcontainer_cleanup.feature index 56a36dddd..8a455d11b 100644 --- a/features/devcontainer_cleanup.feature +++ b/features/devcontainer_cleanup.feature @@ -143,6 +143,7 @@ Feature: Devcontainer Session Cleanup and CLI Commands When I invoke CLI resource stop "local/ctr-stop" Then the CLI exit code should be 0 And the CLI output should contain "Stopped" + And the CLI stop mock should have been called with "01TESTLIFECYCLE0000000370" # ── F6-r6: CLI handler-level error paths ──────────────────── diff --git a/features/session_cli.feature b/features/session_cli.feature index 215771676..901283243 100644 --- a/features/session_cli.feature +++ b/features/session_cli.feature @@ -21,6 +21,7 @@ Feature: Session CLI commands When I run session CLI create with --format json Then the session CLI create should succeed And the session CLI output should be valid JSON + And the session CLI JSON envelope message should be "Session created" # List command tests Scenario: List sessions when empty @@ -28,6 +29,12 @@ Feature: Session CLI commands When I run session CLI list Then the session CLI output should contain "No sessions found" + Scenario: List sessions with JSON format when empty + Given there are no mocked sessions + When I run session CLI list with --format json + Then the session CLI output should be valid JSON + And the session CLI JSON envelope message should be "0 sessions listed" + Scenario: List sessions with populated data Given there are mocked existing sessions When I run session CLI list @@ -38,6 +45,7 @@ Feature: Session CLI commands When I run session CLI list with --format json Then the session CLI output should be valid JSON And the session CLI JSON should contain "sessions" + And the session CLI JSON envelope message should be "2 sessions listed" Scenario: List sessions JSON matches the documented contract Given there are mocked existing sessions @@ -85,6 +93,7 @@ Feature: Session CLI commands Given there is a mocked session with messages When I run session CLI show with --format json Then the session CLI output should be valid JSON + And the session CLI JSON envelope message should be "Session details loaded" Scenario: Show session JSON includes token usage counts Given there is a mocked session with messages @@ -103,6 +112,19 @@ Feature: Session CLI commands Then the session CLI delete should succeed And the session CLI output should contain "deleted" + Scenario: Delete session with JSON format emits envelope + Given there is a mocked session to delete + When I run session CLI delete with --yes and --format json + Then the session CLI delete should succeed + And the session CLI output should be valid JSON + And the session CLI JSON envelope message should be "Session deleted" + + Scenario: Delete session with --format color emits Rich output + Given there is a mocked session to delete + When I run session CLI delete with --yes and --format color + Then the session CLI delete should succeed + And the session CLI output should contain "deleted" + Scenario: Delete non-existent session When I run session CLI delete with a non-existent ID Then the session CLI should exit with error @@ -154,6 +176,13 @@ Feature: Session CLI commands Then the session CLI export should succeed And the session CLI output should contain "Export completed" + Scenario: Export session with JSON output format emits envelope + Given there is a mocked session for export + When I run session CLI export with --output-format json + Then the session CLI export should succeed + And the session CLI output should be valid JSON + And the session CLI JSON envelope message should be "Export completed" + Scenario: Export non-existent session When I run session CLI export with a non-existent session ID Then the session CLI should exit with error @@ -193,6 +222,13 @@ Feature: Session CLI commands Then the session CLI should exit with error And the session CLI output should contain "Import error" + Scenario: Import session with JSON format emits envelope + Given there is a valid session export file + When I run session CLI import with the export file and --format json + Then the session CLI import should succeed + And the session CLI output should be valid JSON + And the session CLI JSON envelope message should be "Import completed" + Scenario: Import from invalid JSON file Given there is an invalid JSON file When I run session CLI import with the invalid JSON file diff --git a/features/steps/session_cli_mcp_simple_steps.py b/features/steps/session_cli_mcp_simple_steps.py index 26e7dbed9..c5bc749e5 100644 --- a/features/steps/session_cli_mcp_simple_steps.py +++ b/features/steps/session_cli_mcp_simple_steps.py @@ -50,7 +50,7 @@ def step_contains_getLogger(context: Any) -> None: if hasattr(context, "create_source") else context.list_source ) - assert 'logging.getLogger("cleveragents.mcp")' in source, ( + assert "logging.getLogger(_MCP_LOGGER_NAME)" in source, ( "Source should contain logging.getLogger call" ) diff --git a/features/steps/session_cli_steps.py b/features/steps/session_cli_steps.py index 81cd3decb..e3321f176 100644 --- a/features/steps/session_cli_steps.py +++ b/features/steps/session_cli_steps.py @@ -421,6 +421,20 @@ def step_delete_yes(context: Context) -> None: ) +@when("I run session CLI delete with --yes and --format json") +def step_delete_yes_json(context: Context) -> None: + context.result = context.runner.invoke( + session_app, ["delete", context.session_id, "--yes", "--format", "json"] + ) + + +@when("I run session CLI delete with --yes and --format color") +def step_delete_yes_color(context: Context) -> None: + context.result = context.runner.invoke( + session_app, ["delete", context.session_id, "--yes", "--format", "color"] + ) + + @when("I run session CLI delete with a non-existent ID") def step_delete_nonexistent(context: Context) -> None: context.mock_service.get.side_effect = SessionNotFoundError("Session not found") @@ -463,6 +477,13 @@ def step_export_stdout(context: Context) -> None: context.result = context.runner.invoke(session_app, ["export", context.session_id]) +@when("I run session CLI export with --output-format json") +def step_export_output_format_json(context: Context) -> None: + context.result = context.runner.invoke( + session_app, ["export", context.session_id, "--output-format", "json"] + ) + + @when("I run session CLI export with --output to a temp file") def step_export_to_file(context: Context) -> None: fd, path = tempfile.mkstemp(suffix=".json") @@ -566,6 +587,13 @@ def step_import_valid(context: Context) -> None: ) +@when("I run session CLI import with the export file and --format json") +def step_import_valid_json(context: Context) -> None: + context.result = context.runner.invoke( + session_app, ["import", "--input", context.import_path, "--format", "json"] + ) + + @when("I run session CLI import with a non-existent file") def step_import_nonexistent(context: Context) -> None: context.result = context.runner.invoke( @@ -756,6 +784,25 @@ def _assert_token_usage_counts_are_ints(token_usage: dict[str, Any]) -> None: assert output_tokens != "***REDACTED***", "output_tokens should not be redacted" +@then('the session CLI JSON envelope message should be "{expected}"') +def step_json_envelope_message(context: Context, expected: str) -> None: + parsed = json.loads(context.result.output) + assert isinstance(parsed, dict), ( + f"Expected envelope dict, got {type(parsed)}: {context.result.output}" + ) + messages = parsed.get("messages") + assert isinstance(messages, list), ( + f"Expected 'messages' list, got {type(messages)}: {messages}" + ) + assert messages, "Envelope messages should not be empty" + first = messages[0] + assert isinstance(first, dict), f"Message entry should be dict, got {type(first)}" + actual = first.get("text") + assert actual == expected, ( + f"Expected message text '{expected}', got '{actual}' in {messages}" + ) + + @then("the session CLI should exit with error") def step_exit_with_error(context: Context) -> None: assert context.result.exit_code != 0, ( diff --git a/src/cleveragents/cli/commands/resource.py b/src/cleveragents/cli/commands/resource.py index 0c57fae46..e92d41073 100644 --- a/src/cleveragents/cli/commands/resource.py +++ b/src/cleveragents/cli/commands/resource.py @@ -1461,8 +1461,12 @@ def resource_remove( # B2 fix: separate type sets — stop works on any container, but rebuild # requires devcontainer-instance since it invokes ``devcontainer up``. # Issue #2588 fix: container-instance is now stoppable — stop_container uses -# docker stop with the container_id from the lifecycle tracker, which works -# for both container-instance and devcontainer-instance resources. +# docker stop with the container_id from the lifecycle tracker, covering +# both container-instance and devcontainer-instance resources. +# Spec regression guard: docs/specification.md requires both resource types +# to support ``agents resource stop``; keep rebuild limited to +# devcontainer-instance while allowing the broader stop surface mandated by +# the CLI contract. _STOPPABLE_TYPES = frozenset({"devcontainer-instance", "container-instance"}) _REBUILDABLE_TYPES = frozenset({"devcontainer-instance"}) # F5-r6 fix: moved from inside resource_rebuild() to module level to avoid diff --git a/src/cleveragents/cli/commands/session.py b/src/cleveragents/cli/commands/session.py index b8e241f74..f65c51a5c 100644 --- a/src/cleveragents/cli/commands/session.py +++ b/src/cleveragents/cli/commands/session.py @@ -63,6 +63,20 @@ _MCP_LOGGER_NAME = "cleveragents.mcp" # multiple CLI commands execute concurrently (e.g., in parallel test runners). _mcp_logger_lock = threading.Lock() + +def _command_label(subcommand: str, *tokens: str) -> str: + """Build a CLI command string for envelope metadata.""" + parts: list[str] = ["agents", "session", subcommand] + parts.extend(token for token in tokens if token) + return " ".join(parts) + + +def _session_list_message(count: int) -> str: + """Return the human-readable message for session list results.""" + suffix = "session" if count == 1 else "sessions" + return f"{count} {suffix} listed" + + # --------------------------------------------------------------------------- # Module-level service and workflow accessors (patchable in tests) # --------------------------------------------------------------------------- @@ -325,7 +339,7 @@ def create( """ # Suppress MCP daemon logger during JSON/YAML output to prevent health check # messages from interfering with structured output. - mcp_logger = logging.getLogger("cleveragents.mcp") + mcp_logger = logging.getLogger(_MCP_LOGGER_NAME) orig_level = mcp_logger.level if fmt not in (OutputFormat.RICH.value, OutputFormat.COLOR.value): with _mcp_logger_lock: @@ -355,7 +369,18 @@ def create( data = _session_summary_dict(session) if fmt not in (OutputFormat.RICH.value, OutputFormat.COLOR.value): - typer.echo(format_output(dict(data), fmt)) + extra_tokens: list[str] = [] + if actor: + extra_tokens.extend(["--actor", actor]) + extra_tokens.extend(["--format", fmt]) + typer.echo( + format_output( + dict(data), + fmt, + command=_command_label("create", *extra_tokens), + messages=[{"level": "ok", "text": "Session created"}], + ) + ) return details = ( @@ -431,7 +456,7 @@ def list_sessions( """ # Suppress MCP daemon logger during JSON/YAML output to prevent health check # messages from interfering with structured output. - mcp_logger = logging.getLogger("cleveragents.mcp") + mcp_logger = logging.getLogger(_MCP_LOGGER_NAME) orig_level = mcp_logger.level if fmt not in (OutputFormat.RICH.value, OutputFormat.COLOR.value): with _mcp_logger_lock: @@ -456,7 +481,19 @@ def list_sessions( # For machine-readable formats, always emit a structured empty list # so that callers parsing JSON/YAML receive valid output. if fmt not in (OutputFormat.RICH.value, OutputFormat.COLOR.value): - typer.echo(format_output({"sessions": [], "total": 0}, fmt)) + typer.echo( + format_output( + {"sessions": [], "total": 0}, + fmt, + command=_command_label("list", "--format", fmt), + messages=[ + { + "level": "ok", + "text": _session_list_message(0), + } + ], + ) + ) return console.print("[yellow]No sessions found.[/yellow]") console.print("Create one with 'agents session create'") @@ -465,7 +502,19 @@ def list_sessions( data = _session_list_dict(sessions) if fmt not in (OutputFormat.RICH.value, OutputFormat.COLOR.value): - typer.echo(format_output(data, fmt)) + typer.echo( + format_output( + data, + fmt, + command=_command_label("list", "--format", fmt), + messages=[ + { + "level": "ok", + "text": _session_list_message(len(sessions)), + } + ], + ) + ) return # Rich table @@ -531,7 +580,19 @@ def show( data = session.as_cli_dict() if fmt not in (OutputFormat.RICH.value, OutputFormat.COLOR.value): - typer.echo(format_output(dict(data), fmt)) + typer.echo( + format_output( + dict(data), + fmt, + command=_command_label( + "show", + session_id, + "--format", + fmt, + ), + messages=[{"level": "ok", "text": "Session details loaded"}], + ) + ) return # Session summary panel — field order per spec: ID, Actor, Messages, @@ -656,9 +717,21 @@ def delete( service.delete(session_id) - # Rich output: render Deletion Summary and Cleanup panels - if fmt == OutputFormat.RICH: - # Deletion Summary panel + if fmt not in (OutputFormat.RICH, OutputFormat.COLOR): + # Machine-readable formats: emit a structured JSON/YAML envelope per spec + # (docs/specification.md §"agents session delete" line ~1959) + typer.echo( + format_output( + {"session_id": session_id}, + fmt.value, + command=_command_label( + "delete", session_id, "--yes", "--format", fmt.value + ), + messages=[{"level": "ok", "text": "Session deleted"}], + ) + ) + else: + # Rich/Color output: render Deletion Summary and Cleanup panels summary_table = Table.grid(padding=(0, 1)) summary_table.add_column(style="cyan bold", justify="left") summary_table.add_column(style="white", justify="left") @@ -685,9 +758,6 @@ def delete( console.print(Panel(cleanup_table, title="Cleanup", border_style="blue")) 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") except SessionNotFoundError as exc: console.print(f"[red]Session not found:[/red] {session_id}") @@ -722,6 +792,14 @@ def export_session( help="Export format: json (default) or md (Markdown transcript)", ), ] = "json", + output_fmt: Annotated[ + str, + typer.Option( + "--output-format", + "-f", + help=_FORMAT_HELP, + ), + ] = "rich", ) -> None: """Export a session as JSON or Markdown. @@ -781,17 +859,75 @@ def export_session( # Create parent directories if needed output.parent.mkdir(parents=True, exist_ok=True) output.write_text(content, encoding="utf-8") - else: + elif output_fmt in (OutputFormat.RICH.value, OutputFormat.COLOR.value): + # Only echo raw content to stdout when using rich output format; + # for machine-readable formats the envelope wraps the metadata instead. typer.echo(content) - # Render Rich panels for both file and stdout export paths - _render_export_panels( - session_id=session_id, - output=output, - content=content, - export_data=json_data, - fmt=fmt, - ) + if output_fmt not in (OutputFormat.RICH.value, OutputFormat.COLOR.value): + # Non-rich output formats: emit a structured JSON/YAML envelope per spec + # (docs/specification.md §"agents session export" line ~2085) + size_bytes = len(content.encode("utf-8")) + if size_bytes < 1024: + size_str = f"{size_bytes} B" + elif size_bytes < 1024 * 1024: + size_str = f"{size_bytes // 1024} KB" + else: + size_str = f"{size_bytes // (1024 * 1024)} MB" + output_display = str(output) if output is not None else "(stdout)" + format_display = "JSON" if fmt == "json" else "Markdown" + message_count = len(json_data.get("messages", [])) + plan_refs = len(json_data.get("linked_plan_ids", [])) + metadata_keys = len(json_data.get("metadata", {})) + actor_config = "included" if json_data.get("actor_name") else "none" + schema_version = json_data.get("schema_version", "v1") + checksum_raw = json_data.get("checksum", "") + checksum_display = ( + f"sha256:{checksum_raw[:4]}...{checksum_raw[-4:]}" + if len(checksum_raw) >= 8 + else checksum_raw or "n/a" + ) + envelope_data = { + "session_export": { + "session": session_id, + "output": output_display, + "messages": message_count, + "size": size_str, + "format": format_display, + }, + "contents": { + "messages": message_count, + "plan_references": plan_refs, + "metadata_keys": metadata_keys, + "actor_config": actor_config, + "schema_version": schema_version, + }, + "integrity": { + "checksum": checksum_display, + "encrypted": False, + }, + } + extra_tokens: list[str] = [] + if output is not None: + extra_tokens.extend(["--output", str(output)]) + extra_tokens.extend(["--output-format", output_fmt]) + typer.echo( + format_output( + envelope_data, + output_fmt, + command=_command_label("export", session_id, *extra_tokens), + messages=[{"level": "ok", "text": "Export completed"}], + ) + ) + else: + # Render Rich panels for both file and stdout export paths + _render_export_panels( + session_id=session_id, + output=output, + content=content, + export_data=json_data, + fmt=fmt, + ) except SessionNotFoundError as exc: console.print(f"[red]Session not found:[/red] {session_id}") @@ -891,6 +1027,10 @@ def import_session( Path, typer.Option("--input", "-i", help="Input JSON file path"), ], + output_fmt: Annotated[ + str, + typer.Option("--format", "-f", help=_FORMAT_HELP), + ] = "rich", ) -> None: """Import a session from a JSON file. @@ -917,29 +1057,63 @@ def import_session( actor_name = data.get("actor_name") session = service.import_session(data) - # Session Import panel - session_details = ( - f"[bold]Input:[/bold] {input_file}\n" - f"[bold]Session ID:[/bold] {session.session_id}\n" - f"[bold]Messages:[/bold] {session.message_count}\n" - f"[bold]Schema:[/bold] {schema_version}" - ) - console.print(Panel(session_details, title="Session Import", expand=False)) + if output_fmt not in (OutputFormat.RICH.value, OutputFormat.COLOR.value): + # Non-rich output formats: emit a structured JSON/YAML envelope per spec + # (docs/specification.md §"agents session import" line ~2205) + actor_ref_status = "resolved" if actor_name else "none" + envelope_data = { + "session_import": { + "input": str(input_file), + "session_id": session.session_id, + "messages": session.message_count, + "schema": schema_version, + }, + "validation": { + "checksum": "verified", + "schema": "compatible", + "actor_ref": actor_ref_status, + }, + "merge": { + "existing": "none", + "strategy": "create new", + }, + } + typer.echo( + format_output( + envelope_data, + output_fmt, + command=_command_label( + "import", "--input", str(input_file), "--format", output_fmt + ), + messages=[{"level": "ok", "text": "Import completed"}], + ) + ) + else: + # Session Import panel + session_details = ( + f"[bold]Input:[/bold] {input_file}\n" + f"[bold]Session ID:[/bold] {session.session_id}\n" + f"[bold]Messages:[/bold] {session.message_count}\n" + f"[bold]Schema:[/bold] {schema_version}" + ) + console.print(Panel(session_details, title="Session Import", expand=False)) - # Validation panel - actor_ref_status = "resolved" if actor_name else "none" - validation_details = ( - f"[bold]Checksum:[/bold] verified\n" - f"[bold]Schema:[/bold] compatible\n" - f"[bold]Actor Ref:[/bold] {actor_ref_status}" - ) - console.print(Panel(validation_details, title="Validation", expand=False)) + # Validation panel + actor_ref_status = "resolved" if actor_name else "none" + validation_details = ( + f"[bold]Checksum:[/bold] verified\n" + f"[bold]Schema:[/bold] compatible\n" + f"[bold]Actor Ref:[/bold] {actor_ref_status}" + ) + console.print(Panel(validation_details, title="Validation", expand=False)) - # Merge panel - merge_details = "[bold]Existing:[/bold] none\n[bold]Strategy:[/bold] create new" - console.print(Panel(merge_details, title="Merge", expand=False)) + # Merge panel + merge_details = ( + "[bold]Existing:[/bold] none\n[bold]Strategy:[/bold] create new" + ) + console.print(Panel(merge_details, title="Merge", expand=False)) - console.print("[green]✓ OK[/green] Import completed") + console.print("[green]✓ OK[/green] Import completed") except SessionImportError as exc: console.print(f"[red]Import error:[/red] {exc}")