Files
placeholder/features/steps/actor_context_cmds_steps.py
freemo a0df5a4cd0 fix(cli): wrap format_output() in spec-required JSON/YAML envelope across all CLI commands
Implements the spec-required JSON/YAML output envelope for all CLI commands
that use format_output(). The envelope structure is:

  {
    "command": "<command that was run>",
    "status": "ok" | "warn" | "error",
    "exit_code": 0,
    "data": { ... command-specific payload ... },
    "timing": { "duration_ms": 123 },
    "messages": [{ "level": "ok", "text": "..." }]
  }

Changes:
- Add _build_envelope() helper to construct the spec-required envelope
- Add optional command, status, exit_code, messages parameters to format_output()
- Wrap json/yaml output in the envelope; plain/table/rich/color unchanged
- Add timing measurement (duration_ms) to all json/yaml outputs
- Add new BDD feature file (cli_json_envelope.feature) with 14 scenarios
  testing envelope field presence, values, and data payload
- Update 14 existing step files to unwrap the envelope when checking
  specific data keys (backward-compatible via _unwrap_envelope() helper)

Closes #3431
2026-04-05 19:48:40 +00:00

464 lines
15 KiB
Python

# pyright: reportRedeclaration=false
"""Step definitions for actor context remove/export/import commands."""
from __future__ import annotations
import json
import shutil
import tempfile
from pathlib import Path
from typing import Any
from behave import given, then, when
from typer.testing import CliRunner
from cleveragents.cli.commands.actor_context import app as actor_context_app
from cleveragents.reactive.context_manager import ContextManager
_ENVELOPE_KEYS = {"command", "status", "exit_code", "data", "timing", "messages"}
def _unwrap_envelope(parsed: Any) -> Any:
"""Return the ``data`` field if *parsed* is a spec envelope, else *parsed* as-is."""
if isinstance(parsed, dict) and _ENVELOPE_KEYS.issubset(parsed.keys()):
return parsed["data"]
return parsed
# ---------------------------------------------------------------------------
# Background
# ---------------------------------------------------------------------------
@given("a temporary context directory for actor context tests")
def step_temp_context_dir(context):
context.tmp_dir = Path(tempfile.mkdtemp())
context.context_dir = context.tmp_dir / "contexts"
context.context_dir.mkdir(parents=True, exist_ok=True)
context.runner = CliRunner()
context._cleanup_handlers: list = getattr(context, "_cleanup_handlers", [])
context._cleanup_handlers.append(
lambda: shutil.rmtree(context.tmp_dir, ignore_errors=True)
)
# Will store original messages for round-trip checks
context.original_messages = None
context.export_file = None
# ---------------------------------------------------------------------------
# Givens — context creation helpers
# ---------------------------------------------------------------------------
@given('an actor context named "{name}" exists')
def step_context_exists(context, name):
mgr = ContextManager(name, context.context_dir)
mgr.add_message("system", f"Initial message for {name}")
@given('an actor context named "{name}" exists with messages')
def step_context_exists_with_messages(context, name):
mgr = ContextManager(name, context.context_dir)
mgr.add_message("user", "Hello, how are you?")
mgr.add_message("assistant", "I'm doing well, thanks for asking!")
mgr.add_message("user", "Great, let's get started.")
context.original_messages = list(mgr.messages)
@given('a valid context JSON file named "{filename}"')
def step_create_json_file(context, filename):
data = {
"context_name": filename.replace(".json", ""),
"messages": [
{
"role": "user",
"content": "test message",
"timestamp": "2026-01-01T00:00:00",
"metadata": {},
},
],
"metadata": {"context_name": filename.replace(".json", "")},
"state": {},
"global_context": {},
}
context.import_file = context.tmp_dir / filename
context.import_file.write_text(json.dumps(data, indent=2), encoding="utf-8")
@given('a valid context JSON file with context_name "{name}"')
def step_create_json_file_with_name(context, name):
data = {
"context_name": name,
"messages": [
{
"role": "user",
"content": "auto-named test",
"timestamp": "2026-01-01T00:00:00",
"metadata": {},
},
],
"metadata": {"context_name": name},
"state": {},
"global_context": {},
}
context.import_file = context.tmp_dir / f"{name}.json"
context.import_file.write_text(json.dumps(data, indent=2), encoding="utf-8")
# ---------------------------------------------------------------------------
# When — remove
# ---------------------------------------------------------------------------
@when('I run actor context remove "{name}" with --yes')
def step_remove_named_yes(context, name):
context.result = context.runner.invoke(
actor_context_app,
["remove", name, "--yes", "--context-dir", str(context.context_dir)],
)
@when("I run actor context remove --all with --yes")
def step_remove_all_yes(context):
context.result = context.runner.invoke(
actor_context_app,
["remove", "--all", "--yes", "--context-dir", str(context.context_dir)],
)
@when('I run actor context remove "{name}" with --yes and format "{fmt}"')
def step_remove_named_format(context, name, fmt):
context.result = context.runner.invoke(
actor_context_app,
[
"remove",
name,
"--yes",
"--context-dir",
str(context.context_dir),
"--format",
fmt,
],
)
@when("I run actor context remove without name or all")
def step_remove_no_args(context):
context.result = context.runner.invoke(
actor_context_app,
["remove", "--context-dir", str(context.context_dir)],
)
@when('I run actor context remove "{name}" with --all')
def step_remove_name_and_all(context, name):
context.result = context.runner.invoke(
actor_context_app,
["remove", name, "--all", "--context-dir", str(context.context_dir)],
)
# ---------------------------------------------------------------------------
# When — export
# ---------------------------------------------------------------------------
@when('I run actor context export "{name}" to a JSON file')
def step_export_json(context, name):
context.export_file = context.tmp_dir / f"{name}-export.json"
context.result = context.runner.invoke(
actor_context_app,
[
"export",
name,
"--output",
str(context.export_file),
"--context-dir",
str(context.context_dir),
],
)
@when('I run actor context export "{name}" to a YAML file')
def step_export_yaml(context, name):
context.export_file = context.tmp_dir / f"{name}-export.yaml"
context.result = context.runner.invoke(
actor_context_app,
[
"export",
name,
"--output",
str(context.export_file),
"--context-dir",
str(context.context_dir),
],
)
@when('I run actor context export "{name}" to a JSON file with format "{fmt}"')
def step_export_json_format(context, name, fmt):
context.export_file = context.tmp_dir / f"{name}-export.json"
context.result = context.runner.invoke(
actor_context_app,
[
"export",
name,
"--output",
str(context.export_file),
"--context-dir",
str(context.context_dir),
"--format",
fmt,
],
)
# ---------------------------------------------------------------------------
# When — import
# ---------------------------------------------------------------------------
@when('I run actor context import from that file as "{name}"')
def step_import_named(context, name):
context.result = context.runner.invoke(
actor_context_app,
[
"import",
name,
"--input",
str(context.import_file),
"--context-dir",
str(context.context_dir),
],
)
@when("I run actor context import from that file without a name")
def step_import_no_name(context):
context.result = context.runner.invoke(
actor_context_app,
[
"import",
"--input",
str(context.import_file),
"--context-dir",
str(context.context_dir),
],
)
@when('I run actor context import from that file as "{name}" without update')
def step_import_no_update(context, name):
context.result = context.runner.invoke(
actor_context_app,
[
"import",
name,
"--input",
str(context.import_file),
"--context-dir",
str(context.context_dir),
],
)
@when('I run actor context import from that file as "{name}" with --update')
def step_import_with_update(context, name):
context.result = context.runner.invoke(
actor_context_app,
[
"import",
name,
"--input",
str(context.import_file),
"--update",
"--context-dir",
str(context.context_dir),
],
)
# ---------------------------------------------------------------------------
# When — round-trip helpers
# ---------------------------------------------------------------------------
@when('I export the context "{name}" to a JSON file')
def step_roundtrip_export(context, name):
context.export_file = context.tmp_dir / f"{name}-roundtrip.json"
context.result = context.runner.invoke(
actor_context_app,
[
"export",
name,
"--output",
str(context.export_file),
"--context-dir",
str(context.context_dir),
],
)
assert context.result.exit_code == 0, context.result.output
@when('I remove the context "{name}"')
def step_roundtrip_remove(context, name):
context.result = context.runner.invoke(
actor_context_app,
["remove", name, "--yes", "--context-dir", str(context.context_dir)],
)
assert context.result.exit_code == 0, context.result.output
@when('I import the context from that JSON file as "{name}"')
def step_roundtrip_import(context, name):
context.result = context.runner.invoke(
actor_context_app,
[
"import",
name,
"--input",
str(context.export_file),
"--context-dir",
str(context.context_dir),
],
)
assert context.result.exit_code == 0, context.result.output
# ---------------------------------------------------------------------------
# Then — success / failure
# ---------------------------------------------------------------------------
@then("the actor context remove command should succeed")
def step_remove_success(context):
assert context.result.exit_code == 0, (
f"Expected exit 0, got {context.result.exit_code}.\n"
f"stdout: {context.result.output}\n"
f"stderr: {getattr(context.result, 'stderr', '')}"
)
@then("the actor context export command should succeed")
def step_export_success(context):
assert context.result.exit_code == 0, (
f"Expected exit 0, got {context.result.exit_code}.\n"
f"stdout: {context.result.output}\n"
f"stderr: {getattr(context.result, 'stderr', '')}"
)
@then("the actor context import command should succeed")
def step_import_success(context):
assert context.result.exit_code == 0, (
f"Expected exit 0, got {context.result.exit_code}.\n"
f"stdout: {context.result.output}\n"
f"stderr: {getattr(context.result, 'stderr', '')}"
)
@then("the actor context remove command should fail with exit code 1")
def step_remove_fail(context):
assert context.result.exit_code == 1, (
f"Expected exit 1, got {context.result.exit_code}.\n"
f"stdout: {context.result.output}\n"
f"stderr: {getattr(context.result, 'stderr', '')}"
)
@then("the actor context export command should fail with exit code 1")
def step_export_fail(context):
assert context.result.exit_code == 1, (
f"Expected exit 1, got {context.result.exit_code}.\n"
f"stdout: {context.result.output}\n"
f"stderr: {getattr(context.result, 'stderr', '')}"
)
@then("the actor context import command should fail with exit code 1")
def step_import_fail(context):
assert context.result.exit_code == 1, (
f"Expected exit 1, got {context.result.exit_code}.\n"
f"stdout: {context.result.output}\n"
f"stderr: {getattr(context.result, 'stderr', '')}"
)
# ---------------------------------------------------------------------------
# Then — state assertions
# ---------------------------------------------------------------------------
@then('the context "{name}" should no longer exist')
def step_context_gone(context, name):
ctx_path = context.context_dir / name
assert not ctx_path.exists(), f"Context dir {ctx_path} still exists"
@then("no actor contexts should remain")
def step_no_contexts(context):
remaining = [d for d in context.context_dir.iterdir() if d.is_dir()]
assert len(remaining) == 0, (
f"Expected 0 contexts, found {len(remaining)}: {remaining}"
)
@then('the context "{name}" should exist')
def step_context_exists_check(context, name):
mgr = ContextManager(name, context.context_dir)
assert mgr.exists(), f"Context '{name}' does not exist at {mgr.context_dir}"
# ---------------------------------------------------------------------------
# Then — output assertions
# ---------------------------------------------------------------------------
@then('the output should contain valid JSON with key "{key}"')
def step_output_json_key(context, key):
output = context.result.output
parsed = json.loads(output)
data = _unwrap_envelope(parsed)
assert key in data, f"Key '{key}' not found in JSON output: {data.keys()}"
@then("the exported file should exist and contain valid JSON")
def step_exported_json_valid(context):
assert context.export_file.exists(), f"Export file {context.export_file} not found"
data = json.loads(context.export_file.read_text(encoding="utf-8"))
assert isinstance(data, dict)
@then('the exported JSON should contain key "{key}"')
def step_exported_json_key(context, key):
data = json.loads(context.export_file.read_text(encoding="utf-8"))
assert key in data, f"Key '{key}' not found in exported JSON: {data.keys()}"
@then("the exported YAML file should exist and be valid")
def step_exported_yaml_valid(context):
import yaml
assert context.export_file.exists(), f"Export file {context.export_file} not found"
data = yaml.safe_load(context.export_file.read_text(encoding="utf-8"))
assert isinstance(data, dict)
@then("the imported context should have the same messages as the original")
def step_roundtrip_messages(context):
assert context.original_messages is not None, "No original messages saved"
mgr = ContextManager("roundtrip", context.context_dir)
imported = mgr.messages
# Compare content and role, ignoring timestamp differences
assert len(imported) == len(context.original_messages), (
f"Message count mismatch: {len(imported)} vs {len(context.original_messages)}"
)
for orig, imp in zip(context.original_messages, imported, strict=True):
assert orig["role"] == imp["role"], (
f"Role mismatch: {orig['role']} vs {imp['role']}"
)
assert orig["content"] == imp["content"], (
f"Content mismatch: {orig['content']!r} vs {imp['content']!r}"
)