Files
temp/features/steps/actor_context_cmds_steps.py
T
freemo 297823c291 feat(cli): add actor context remove, export, and import commands
Implement three new actor context subcommands as specified in the v3
CLI specification:

- `agents actor context remove [--yes|-y] (--all|-a|<NAME>)`:
  Remove a named context or all contexts with interactive confirmation.
  Supports --all for bulk removal and --yes to skip prompts.

- `agents actor context export (--output|-o) <FILE> <NAME>`:
  Export a named context to JSON or YAML (detected from file extension).
  Includes sha256 integrity checksum in output metadata.

- `agents actor context import [--update] (--input|-i) <FILE> [<NAME>]`:
  Import a context from JSON or YAML. Name is inferred from file metadata
  if omitted. Refuses to overwrite existing contexts unless --update is
  specified.

All commands:
- Support 6 output formats (json, yaml, plain, table, rich, color) via
  the shared format_output framework
- Use ContextManager for persistence (messages, metadata, state,
  global_context)
- Follow existing error handling patterns (typer.Exit for user errors)
- Are wired as `agents actor context` subcommand group via add_typer

New files:
- src/cleveragents/cli/commands/actor_context.py (command module)
- features/actor_context_cmds.feature (Behave BDD scenarios)
- features/steps/actor_context_cmds_steps.py (BDD step definitions)
- robot/actor_context_export_import.robot (Robot integration test)

ISSUES CLOSED: #869
2026-03-30 19:35:23 +00:00

452 lines
14 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 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
# ---------------------------------------------------------------------------
# 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
data = json.loads(output)
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}"
)