diff --git a/features/actor_context_cmds.feature b/features/actor_context_cmds.feature index 6412514b2..6a93bd973 100644 --- a/features/actor_context_cmds.feature +++ b/features/actor_context_cmds.feature @@ -98,3 +98,20 @@ Feature: Actor context remove, export, and import commands And I import the context from that JSON file as "roundtrip" Then the context "roundtrip" should exist And the imported context should have the same messages as the original + + + # ── context show ─────────────────────────────────────────── + + Scenario: Show a context in rich format + Given an actor context named "docs" exists with messages + When I run actor context show "docs" without specifying format + Then the actor context show command should succeed + And the output should include a context summary for "docs" + + Scenario: Show a context in JSON format + Given an actor context named "docs" exists with messages + When I run actor context show "docs" with format "json" + Then the actor context show command should succeed + And the output should contain valid JSON with key "context_summary" + And the JSON payload should list 3 messages + And the JSON payload metadata context_name should be "docs" diff --git a/features/steps/actor_context_cmds_steps.py b/features/steps/actor_context_cmds_steps.py index cd831971a..023523840 100644 --- a/features/steps/actor_context_cmds_steps.py +++ b/features/steps/actor_context_cmds_steps.py @@ -278,6 +278,34 @@ def step_import_with_update(context, name): ) +# --------------------------------------------------------------------------- +# When — show +# --------------------------------------------------------------------------- + + +@when('I run actor context show "{name}" without specifying format') +def step_show_context_default(context, name): + context.result = context.runner.invoke( + actor_context_app, + ["show", name, "--context-dir", str(context.context_dir)], + ) + + +@when('I run actor context show "{name}" with format "{fmt}"') +def step_show_context_format(context, name, fmt): + context.result = context.runner.invoke( + actor_context_app, + [ + "show", + name, + "--context-dir", + str(context.context_dir), + "--format", + fmt, + ], + ) + + # --------------------------------------------------------------------------- # When — round-trip helpers # --------------------------------------------------------------------------- @@ -356,6 +384,14 @@ def step_import_success(context): f"stderr: {getattr(context.result, 'stderr', '')}" ) +@then("the actor context show command should succeed") +def step_show_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): @@ -421,6 +457,36 @@ def step_output_json_key(context, key): data = _unwrap_envelope(parsed) assert key in data, f"Key '{key}' not found in JSON output: {data.keys()}" +@then('the output should include a context summary for "{name}"') +def step_output_contains_summary(context, name): + output = context.result.output + assert "Context Summary" in output, ( + f"Context Summary not found in output:\n{output}" + ) + assert name in output, f"Context name '{name}' not found in output:\n{output}" + + +@then('the JSON payload should list {count:d} messages') +def step_json_message_count(context, count): + parsed = json.loads(context.result.output) + data = _unwrap_envelope(parsed) + messages = data.get("messages") + assert isinstance(messages, list), "messages is not a list" + assert len(messages) == count, ( + f"Expected {count} messages, found {len(messages)}" + ) + + +@then('the JSON payload metadata context_name should be "{name}"') +def step_json_metadata_context_name(context, name): + parsed = json.loads(context.result.output) + data = _unwrap_envelope(parsed) + metadata = data.get("metadata") + assert isinstance(metadata, dict), "metadata is not a dict" + assert ( + metadata.get("context_name") == name + ), f"Expected metadata.context_name={name!r}, got {metadata.get('context_name')!r}" + @then("the exported file should exist and contain valid JSON") def step_exported_json_valid(context): diff --git a/src/cleveragents/cli/commands/actor_context.py b/src/cleveragents/cli/commands/actor_context.py index 948a2ff21..53e023ae8 100644 --- a/src/cleveragents/cli/commands/actor_context.py +++ b/src/cleveragents/cli/commands/actor_context.py @@ -21,6 +21,7 @@ from rich.panel import Panel from cleveragents.cli.formatting import OutputFormat, format_output from cleveragents.reactive.context_manager import ContextManager +from .actor_context_show import register_show_command app = typer.Typer( help="Manage manual contexts for actor runs.", @@ -73,13 +74,13 @@ def _render_output( fmt: str, rich_panels: list[tuple[str, str]] | None = None, ok_message: str = "", + *, + command: str | None = None, + status: str = "ok", + exit_code: int = 0, ) -> None: - """Emit command output in the requested format. + """Emit command output in the requested format.""" - For ``rich`` format the *rich_panels* list of ``(title, body)`` pairs - are rendered via :class:`rich.panel.Panel`. For all other formats the - flat *data* dict is passed through :func:`format_output`. - """ if fmt == OutputFormat.RICH.value and rich_panels: for title, body in rich_panels: console.print(Panel(body, title=title, expand=False)) @@ -87,8 +88,18 @@ def _render_output( console.print(f"[green]✓ OK[/green] {ok_message}") return - # Machine-readable / non-rich - console.print(format_output(data, fmt)) + console.print( + format_output( + data, + fmt, + command=command or "", + status=status, + exit_code=exit_code, + ) + ) + + +register_show_command(app, _render_output, _FORMAT_HELP) # --------------------------------------------------------------------------- diff --git a/src/cleveragents/cli/commands/actor_context_show.py b/src/cleveragents/cli/commands/actor_context_show.py new file mode 100644 index 000000000..b8b7a9f2e --- /dev/null +++ b/src/cleveragents/cli/commands/actor_context_show.py @@ -0,0 +1,177 @@ +from __future__ import annotations + +import json +from datetime import datetime +from pathlib import Path +from typing import Annotated, Any, Callable + +import typer + +from cleveragents.cli.formatting import OutputFormat +from cleveragents.reactive.context_manager import ContextManager + + +def _estimate_tokens_for_paths(paths: list[Path]) -> int: + """Return a coarse token estimate based on file sizes.""" + + total = 0 + for path in paths: + try: + size = path.stat().st_size + except OSError: + continue + tokens = size // 4 + total += tokens if tokens > 0 else 1 + return total + + +def _format_metadata_timestamp(value: str | None) -> str | None: + """Return a human-readable timestamp for Rich output.""" + + if not value: + return None + try: + cleaned = value.replace("Z", "+00:00") if value.endswith("Z") else value + dt = datetime.fromisoformat(cleaned) + except (ValueError, TypeError): + return value + return dt.strftime("%Y-%m-%d %H:%M") + + +def register_show_command( + app: typer.Typer, + render_output: Callable[..., None], + format_help: str, +) -> None: + """Register the `agents actor context show` command on *app*.""" + + format_option = typer.Option( + "rich", + "--format", + "-f", + help=format_help, + ) + + @app.command("show") + def context_show( + name: Annotated[ + str, + typer.Argument(help="Context name to display"), + ], + context_dir: Annotated[ + Path | None, + typer.Option( + "--context-dir", + help="Directory where contexts are stored", + resolve_path=True, + ), + ] = None, + fmt: str = format_option, + ) -> None: + """Show the content and metadata for a named actor context.""" + + base = Path(context_dir) if context_dir else Path.home() / ".cleveragents" / "context" + if not (base / name).exists(): + typer.echo(f"Error: Context '{name}' does not exist.", err=True) + raise typer.Exit(code=1) + + ctx_mgr = ContextManager(name, context_dir) + files = [path for path in ctx_mgr.context_dir.rglob("*") if path.is_file()] + total_size_kb = round(sum(path.stat().st_size for path in files) / 1024, 1) + estimated_tokens = _estimate_tokens_for_paths(files) + + created_at = ctx_mgr.metadata.get("created_at") + created_human = _format_metadata_timestamp(created_at) + + summary: dict[str, Any] = { + "context": name, + "files": len(files), + "total_size_kb": total_size_kb, + "estimated_tokens": estimated_tokens, + "created": created_at, + "last_updated": ctx_mgr.metadata.get("last_updated"), + "message_count": len(ctx_mgr.messages), + } + + data: dict[str, Any] = { + "context_summary": summary, + "messages": ctx_mgr.messages, + "metadata": ctx_mgr.metadata, + "state": ctx_mgr.state, + "global_context": ctx_mgr.global_context, + } + + rich_panels: list[tuple[str, str]] | None = None + if fmt == OutputFormat.RICH.value: + summary_lines = [ + f"[bold]Context:[/bold] {name}", + f"[bold]Files:[/bold] {len(files)}", + f"[bold]Total Size:[/bold] {total_size_kb} KB", + f"[bold]Estimated Tokens:[/bold] ~{estimated_tokens}", + f"[bold]Messages:[/bold] {len(ctx_mgr.messages)}", + ] + if created_human: + summary_lines.append(f"[bold]Created:[/bold] {created_human}") + elif created_at: + summary_lines.append(f"[bold]Created:[/bold] {created_at}") + last_updated = summary.get("last_updated") + last_updated_human = _format_metadata_timestamp(last_updated) + if last_updated_human: + summary_lines.append(f"[bold]Last Updated:[/bold] {last_updated_human}") + elif last_updated: + summary_lines.append(f"[bold]Last Updated:[/bold] {last_updated}") + + metadata_lines = [ + f"[bold]{key}:[/bold] {value}" + for key, value in sorted(ctx_mgr.metadata.items()) + ] + + def _truncate_content(text: str, limit: int = 180) -> str: + return text if len(text) <= limit else text[: limit - 3] + "..." + + message_lines: list[str] = [] + max_messages = 10 + for idx, message in enumerate(ctx_mgr.messages, start=1): + if idx > max_messages: + remaining = len(ctx_mgr.messages) - max_messages + message_lines.append( + f"(and {remaining} more message{'s' if remaining != 1 else ''}...)" + ) + break + role = message.get("role", "unknown") + timestamp = message.get("timestamp", "") + content = _truncate_content(message.get("content", "")) + message_lines.append( + f"[bold]{idx}. {role}[/bold] {timestamp}\n{content}" + ) + + state_text = json.dumps(ctx_mgr.state, indent=2, default=str) + global_context_text = json.dumps( + ctx_mgr.global_context, indent=2, default=str + ) + + rich_panels = [ + ("Context Summary", "\n".join(summary_lines)), + ( + "Metadata", + "\n".join(metadata_lines) if metadata_lines else "(empty)", + ), + ] + + if message_lines: + rich_panels.append(("Messages", "\n\n".join(message_lines))) + + if ctx_mgr.state: + rich_panels.append(("State", state_text)) + + if ctx_mgr.global_context: + rich_panels.append(("Global Context", global_context_text)) + + command_str = f"agents actor context show {name}" + render_output( + data, + fmt, + rich_panels=rich_panels, + ok_message="Context displayed", + command=command_str, + )