feat(cli): add actor context show command (#6369)
CI / benchmark-publish (pull_request) Has been skipped
CI / push-validation (pull_request) Successful in 21s
CI / lint (pull_request) Failing after 35s
CI / helm (pull_request) Successful in 30s
CI / quality (pull_request) Successful in 52s
CI / build (pull_request) Successful in 45s
CI / typecheck (pull_request) Successful in 1m2s
CI / security (pull_request) Successful in 1m2s
CI / coverage (pull_request) Has been skipped
CI / benchmark-regression (pull_request) Has been skipped
CI / e2e_tests (pull_request) Successful in 3m23s
CI / integration_tests (pull_request) Successful in 4m11s
CI / unit_tests (pull_request) Successful in 5m14s
CI / docker (pull_request) Has been skipped
CI / status-check (pull_request) Failing after 2s

ISSUES CLOSED: #6369
This commit is contained in:
2026-04-09 22:20:47 +00:00
parent fc9c730670
commit fb41c1473f
4 changed files with 278 additions and 7 deletions
+17
View File
@@ -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"
@@ -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):
+18 -7
View File
@@ -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)
# ---------------------------------------------------------------------------
@@ -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,
)