fix(cli): fix JSON/YAML envelope messages[].text to show actual content #6739

Merged
HAL9000 merged 5 commits from fix/issue-6457-json-envelope-messages-text into master 2026-06-01 00:34:52 +00:00
7 changed files with 314 additions and 44 deletions
+8
View File
@@ -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.
+1
View File
@@ -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 ────────────────────
+36
View File
@@ -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
@@ -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"
)
+47
View File
@@ -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, (
+6 -2
View File
@@ -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
+215 -41
View File
2
@@ -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}")