fix(cli): fix JSON/YAML envelope messages[].text for delete/export/import

Extend the JSON/YAML envelope messages[].text fix to cover the remaining
three session subcommands that were still producing plain Rich output
instead of structured envelopes for non-rich format paths:

- session delete: route non-rich formats through format_output() with
  messages=[{"level": "ok", "text": "Session deleted"}]
- session export: add --output-format/-f option; emit structured envelope
  with session_export/contents/integrity data and "Export completed" message
- session import: add --format/-f option; emit structured envelope with
  session_import/validation/merge data and "Import completed" message

Also add BDD scenarios to features/session_cli.feature for each new
envelope path, add corresponding step definitions, add CHANGELOG entry
under [Unreleased] Fixed, and assign milestone v3.2.0 to the PR.

ISSUES CLOSED: #6457
This commit is contained in:
2026-05-05 19:09:27 +00:00
committed by drew
parent f8946e1147
commit b1bfaf032e
4 changed files with 195 additions and 31 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.
+21
View File
@@ -112,6 +112,13 @@ 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 non-existent session
When I run session CLI delete with a non-existent ID
Then the session CLI should exit with error
@@ -163,6 +170,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
@@ -202,6 +216,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
+21
View File
@@ -421,6 +421,13 @@ 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 a non-existent ID")
def step_delete_nonexistent(context: Context) -> None:
context.mock_service.get.side_effect = SessionNotFoundError("Session not found")
@@ -463,6 +470,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 +580,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(
+145 -31
View File
@@ -747,8 +747,18 @@ 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 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"}],
)
)
except SessionNotFoundError as exc:
console.print(f"[red]Session not found:[/red] {session_id}")
@@ -783,6 +793,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.
@@ -842,17 +860,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}")
@@ -952,6 +1028,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.
@@ -978,29 +1058,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}")