From 3dfdc86e92f00f009e7f751f42a3ddef4441b1f6 Mon Sep 17 00:00:00 2001 From: CleverThis Date: Thu, 9 Apr 2026 23:15:48 +0000 Subject: [PATCH] fix(cli): fix project context show JSON/YAML output fields (#6323) ISSUES CLOSED: #6323 --- .../issue_6323_project_context_show.feature | 24 ++ features/steps/project_context_cli_steps.py | 211 +++++++++++++- .../cli/commands/project_context.py | 267 ++++++++++-------- 3 files changed, 385 insertions(+), 117 deletions(-) create mode 100644 features/issue_6323_project_context_show.feature diff --git a/features/issue_6323_project_context_show.feature b/features/issue_6323_project_context_show.feature new file mode 100644 index 000000000..efef9896a --- /dev/null +++ b/features/issue_6323_project_context_show.feature @@ -0,0 +1,24 @@ +Feature: Issue 6323 - project context show JSON/YAML output matches specification + In order to comply with the CLI output specification + As a developer + I want agents project context show to emit the required envelope and data sections + + Background: + Given a project context CLI in-memory database is initialized + And a project "local/ctx-app" exists for context CLI + And issue 6323 project context sample is configured for "local/ctx-app" + And the context tier service has sample usage data for "local/ctx-app" + + @tdd_issue @tdd_issue_6323 + Scenario: JSON output includes spec-required sections and current usage data + And the context show output format is "json" + When I run context show on "local/ctx-app" with view "strategize" + Then the context show command should succeed + And the context show json output should match issue 6323 spec for "local/ctx-app" and view "strategize" + + @tdd_issue @tdd_issue_6323 + Scenario: YAML output includes spec-required sections and current usage data + And the context show output format is "yaml" + When I run context show on "local/ctx-app" with view "strategize" + Then the context show command should succeed + And the context show yaml output should match issue 6323 spec for "local/ctx-app" and view "strategize" diff --git a/features/steps/project_context_cli_steps.py b/features/steps/project_context_cli_steps.py index 4351a3f40..6b80c51d8 100644 --- a/features/steps/project_context_cli_steps.py +++ b/features/steps/project_context_cli_steps.py @@ -7,6 +7,7 @@ CLI commands using an in-memory SQLite database. from __future__ import annotations import json +import yaml from io import StringIO from typing import Any from unittest.mock import MagicMock, patch @@ -139,7 +140,7 @@ def _run_with_container(context: Any, func: Any, *args: Any, **kwargs: Any) -> N soft_wrap=True, ) - def _test_format_output(data, format_type): + def _test_format_output(data, format_type, **kwargs): import sys as _sys from io import StringIO as _SIO @@ -149,7 +150,7 @@ def _run_with_container(context: Any, func: Any, *args: Any, **kwargs: Any) -> N _old = _sys.stdout _sys.stdout = _b try: - _r = _fo(data, format_type) + _r = _fo(data, format_type, **kwargs) finally: _sys.stdout = _old return _r or _b.getvalue().rstrip("\n") @@ -329,7 +330,13 @@ def step_ctx_show(context: Any, project: str) -> None: context_show, ) - _run_with_container(context, context_show, project=project) + kwargs: dict[str, Any] = {} + fmt_override = getattr(context, "ctx_show_format", None) + if fmt_override is not None: + kwargs["output_format"] = fmt_override + del context.ctx_show_format + + _run_with_container(context, context_show, project=project, **kwargs) @when('I run context show on "{project}" with view "{view}"') @@ -338,7 +345,13 @@ def step_ctx_show_view(context: Any, project: str, view: str) -> None: context_show, ) - _run_with_container(context, context_show, project=project, view=view) + kwargs: dict[str, Any] = {} + fmt_override = getattr(context, "ctx_show_format", None) + if fmt_override is not None: + kwargs["output_format"] = fmt_override + del context.ctx_show_format + + _run_with_container(context, context_show, project=project, view=view, **kwargs) @when('I run context show on "{project}" with format "{fmt}"') @@ -443,6 +456,11 @@ def step_given_execute_view_res(context: Any, project: str, res: str) -> None: ) +@given('the context show output format is "{fmt}"') +def step_set_ctx_show_format(context: Any, fmt: str) -> None: + context.ctx_show_format = fmt + + # ------------------------------------------------------------------ # Then assertions # ------------------------------------------------------------------ @@ -595,3 +613,188 @@ def step_ctx_output_valid_json(context: Any) -> None: raise AssertionError( f"Output is not valid JSON: {exc}\nOutput: {context.ctx_output!r}" ) from exc + + +# ------------------------------------------------------------------ +# Issue #6323 spec compliance helpers +# ------------------------------------------------------------------ + + +@given('issue 6323 project context sample is configured for "{project}"') +def step_issue6323_configure_policy(context: Any, project: str) -> None: + from cleveragents.cli.commands.project_context import ( + _format_size as _format_size_helper, + ) + from cleveragents.cli.commands.project_context import context_set + + max_file = 1_048_576 + max_total = 50 * 1_048_576 + _run_with_container( + context, + context_set, + project=project, + view="strategize", + include_resource=["repo"], + exclude_path=["**/node_modules/**"], + hot_max_tokens=12_000, + warm_max_decisions=50, + cold_max_decisions=200, + query_limit=20, + summarize=True, + summary_max_tokens=800, + max_file_size=max_file, + max_total_size=max_total, + ) + + context.issue6323_expected = { + "context_policy": { + "project": project, + "view": "strategize", + "include_resources": ["repo"], + "exclude_resources": [], + "include_paths": [], + "exclude_paths": ["**/node_modules/**"], + }, + "limits": { + "hot_max_tokens": 12_000, + "warm_max_decisions": 50, + "cold_max_decisions": 200, + "query_limit": 20, + "max_file_size": _format_size_helper(max_file), + "max_total_size": _format_size_helper(max_total), + }, + "summarization": { + "enabled": True, + "max_tokens": 800, + }, + "current_usage": { + "hot_context_tokens": 0, + "hot_context_limit": 12_000, + "warm_entries": 0, + "warm_limit": 50, + "cold_entries": 0, + "cold_limit": 200, + "indexed_resources": 0, + }, + } + + +@given('the context tier service has sample usage data for "{project}"') +def step_issue6323_seed_usage(context: Any, project: str) -> None: + from cleveragents.application.services.context_tiers import ContextTierService + from cleveragents.domain.models.acms.tiers import ContextTier, TieredFragment + + svc = getattr(context, "ctx_tier_service", ContextTierService()) + context.ctx_tier_service = svc + + svc._hot.clear() + svc._warm.clear() + svc._cold.clear() + try: + svc._budget.max_tokens_hot = 12_000 + except Exception: # pragma: no cover - fallback for unexpected attribute errors + pass + + svc.store( + TieredFragment( + fragment_id="hot-frag", + content="hot context fragment", + tier=ContextTier.HOT, + project_name=project, + resource_id="res:hot", + token_count=8_420, + ) + ) + + for idx in range(12): + svc.store( + TieredFragment( + fragment_id=f"warm-{idx}", + content="warm fragment", + tier=ContextTier.WARM, + project_name=project, + resource_id=f"res:warm-{idx}", + token_count=10, + ) + ) + + for idx in range(47): + svc.store( + TieredFragment( + fragment_id=f"cold-{idx}", + content="cold fragment", + tier=ContextTier.COLD, + project_name=project, + resource_id=f"res:cold-{idx}", + token_count=5, + ) + ) + + context.issue6323_expected["current_usage"].update( + { + "hot_context_tokens": "***REDACTED***", + "warm_entries": 12, + "cold_entries": 47, + "indexed_resources": 1 + 12 + 47, + } + ) + + +def _issue6323_parse_json(context: Any) -> dict[str, Any]: + raw = context.ctx_output.strip() + return json.loads(raw) + + +def _issue6323_parse_yaml(context: Any) -> dict[str, Any]: + raw = context.ctx_output.strip() + return yaml.safe_load(raw) + + +def _issue6323_assert_payload( + parsed: dict[str, Any], expected: dict[str, Any], project: str, view: str +) -> None: + assert parsed["command"] == "project context show" + assert parsed["status"] == "ok" + assert parsed["exit_code"] == 0 + assert "timing" in parsed and "duration_ms" in parsed["timing"] + assert parsed["messages"] == [{"level": "ok", "text": "Context policy loaded"}] + + payload = parsed.get("data") + assert payload is not None, "Expected 'data' field in output payload" + + context_policy = payload.get("context_policy") + assert context_policy == expected["context_policy"], ( + f"Unexpected context_policy payload. Expected {expected['context_policy']}, got {context_policy}" + ) + + limits = payload.get("limits") + assert limits == expected["limits"], ( + f"Unexpected limits payload. Expected {expected['limits']}, got {limits}" + ) + + summarization = payload.get("summarization") + assert summarization == expected["summarization"], ( + "Unexpected summarization payload. " + f"Expected {expected['summarization']}, got {summarization}" + ) + + usage = payload.get("current_usage") + assert usage == expected["current_usage"], ( + f"Unexpected current_usage payload. Expected {expected['current_usage']}, got {usage}" + ) + + +@then( + 'the context show json output should match issue 6323 spec for "{project}" and view "{view}"' +) +def step_issue6323_assert_json(context: Any, project: str, view: str) -> None: + parsed = _issue6323_parse_json(context) + _issue6323_assert_payload(parsed, context.issue6323_expected, project, view) + + +@then( + 'the context show yaml output should match issue 6323 spec for "{project}" and view "{view}"' +) +def step_issue6323_assert_yaml(context: Any, project: str, view: str) -> None: + parsed = _issue6323_parse_yaml(context) + _issue6323_assert_payload(parsed, context.issue6323_expected, project, view) diff --git a/src/cleveragents/cli/commands/project_context.py b/src/cleveragents/cli/commands/project_context.py index ac6a6365c..5ffb58855 100644 --- a/src/cleveragents/cli/commands/project_context.py +++ b/src/cleveragents/cli/commands/project_context.py @@ -195,6 +195,32 @@ def _default_acms_config() -> dict[str, Any]: } +def _format_size(value: int | None) -> str: + """Return a human-readable string for *value* in bytes.""" + + if value is None: + return "(no limit)" + if value <= 0: + return "0 bytes" + + units: tuple[tuple[str, int], ...] = ( + ("TB", 1024**4), + ("GB", 1024**3), + ("MB", 1024**2), + ("KB", 1024), + ) + for unit, factor in units: + if value >= factor: + quotient = value / factor + if quotient.is_integer(): + return f"{int(quotient)} {unit}" + return f"{quotient:.1f} {unit}" + + if value == 1: + return "1 byte" + return f"{value} bytes" + + def _write_policy( session_factory: Any, namespaced_name: str, @@ -794,125 +820,140 @@ def context_show( policy = _read_policy(session_factory, project) acms = _read_acms_config(session_factory, project) - if view is not None: - if view not in VALID_PHASES: - err_console.print( - f"[red]Invalid view '{view}': must be one of {PHASE_NAMES}[/red]" - ) - raise typer.Exit(1) - resolved = policy.resolve_view(view) - data: dict[str, Any] = { - "phase": view, - "resolved_view": resolved.model_dump(mode="json"), - "acms_config": acms, - } - else: - data = _policy_to_dict(policy) - data["acms_config"] = acms - # Include execution environment fields from raw persisted blob - try: - raw_blob = _load_policy_json(session_factory, project) - except Exception: - raw_blob = None - if raw_blob: - for key in ("execution_environment", "execution_env_priority"): - if key in raw_blob: - data[key] = raw_blob[key] + active_view = view or "default" + if active_view not in VALID_PHASES: + err_console.print( + f"[red]Invalid view '{active_view}': must be one of {PHASE_NAMES}[/red]" + ) + raise typer.Exit(1) - if output_format.lower() == OutputFormat.RICH: - title = f"Context Policy: {project}" - if view: - title += f" (phase: {view})" - lines: list[str] = [] - if view: - rv = policy.resolve_view(view) - lines.append(f"[bold]Phase:[/bold] {view}") - lines.append( - f"[bold]Include resources:[/bold] {rv.include_resources or '(all)'}" - ) - lines.append( - f"[bold]Exclude resources:[/bold] {rv.exclude_resources or '(none)'}" - ) - lines.append(f"[bold]Include paths:[/bold] {rv.include_paths or '(all)'}") - lines.append(f"[bold]Exclude paths:[/bold] {rv.exclude_paths or '(none)'}") - lines.append( - f"[bold]Max file size:[/bold] {rv.max_file_size or '(no limit)'}" - ) - lines.append( - f"[bold]Max total size:[/bold] {rv.max_total_size or '(no limit)'}" - ) - else: - for phase in [ - "default", - "strategize", - "execute", - "apply", - ]: - view_obj = getattr(policy, f"{phase}_view") - if view_obj is not None: - lines.append(f"[bold]{phase}:[/bold] configured") - else: - lines.append(f"[bold]{phase}:[/bold] (inherits)") - # Execution environment section - try: - raw_blob = _load_policy_json(session_factory, project) - except Exception: - raw_blob = None - ee = (raw_blob or {}).get("execution_environment") - eep = (raw_blob or {}).get("execution_env_priority") - if ee or eep: - lines.append("") - lines.append("[bold]Execution Environment:[/bold]") - if ee: - lines.append(f" Environment: {ee}") - if eep: - lines.append(f" Priority: {eep}") + resolved_view = policy.resolve_view(active_view) + include_resources = list(resolved_view.include_resources or []) + exclude_resources = list(resolved_view.exclude_resources or []) + include_paths = list(resolved_view.include_paths or []) + exclude_paths = list(resolved_view.exclude_paths or []) - # ACMS pipeline config section - lines.append("") - lines.append("[bold]ACMS Pipeline Config:[/bold]") - lines.append( - f" Hot max tokens: {acms.get('hot_max_tokens', _DEFAULT_HOT_MAX_TOKENS)}" + hot_limit = int(acms.get("hot_max_tokens") or _DEFAULT_HOT_MAX_TOKENS) + warm_limit = int(acms.get("warm_max_decisions") or _DEFAULT_WARM_MAX_DECISIONS) + cold_limit = int(acms.get("cold_max_decisions") or _DEFAULT_COLD_MAX_DECISIONS) + query_limit = acms.get("query_limit") + summarize_enabled = bool(acms.get("summarize", True)) + summary_tokens = acms.get("summary_max_tokens") + + tier_service = _get_context_tier_service() + metrics: TierMetrics = tier_service.get_scoped_metrics([project]) + fragments: list[TieredFragment] = tier_service.get_scoped_view([project]) + + hot_tokens = sum( + fragment.token_count + for fragment in fragments + if fragment.tier == ContextTier.HOT + ) + indexed_resources = len({f.resource_id for f in fragments if f.resource_id}) + + structured_data: dict[str, Any] = { + "context_policy": { + "project": project, + "view": active_view, + "include_resources": include_resources, + "exclude_resources": exclude_resources, + "include_paths": include_paths, + "exclude_paths": exclude_paths, + }, + "limits": { + "hot_max_tokens": hot_limit, + "warm_max_decisions": warm_limit, + "cold_max_decisions": cold_limit, + "query_limit": query_limit, + "max_file_size": _format_size(resolved_view.max_file_size), + "max_total_size": _format_size(resolved_view.max_total_size), + }, + "summarization": { + "enabled": summarize_enabled, + "max_tokens": summary_tokens, + }, + "current_usage": { + "hot_context_tokens": hot_tokens, + "hot_context_limit": hot_limit, + "warm_entries": metrics.warm_count, + "warm_limit": warm_limit, + "cold_entries": metrics.cold_count, + "cold_limit": cold_limit, + "indexed_resources": indexed_resources, + }, + } + + fmt = output_format.lower() + command_name = "project context show" + success_message = {"level": "ok", "text": "Context policy loaded"} + + if fmt == OutputFormat.RICH: + policy_lines = [ + f"[cyan]Project:[/cyan] {project}", + f"[blue]View:[/blue] {active_view}", + f"[green]Include:[/green] {', '.join(include_resources) if include_resources else '(all)'}", + f"[yellow]Exclude:[/yellow] {', '.join(exclude_paths) if exclude_paths else '(none)'}", + ] + if include_paths: + policy_lines.append( + f"[green]Include paths:[/green] {', '.join(include_paths)}" + ) + if exclude_resources: + policy_lines.append( + f"[yellow]Exclude resources:[/yellow] {', '.join(exclude_resources)}" + ) + + limits = structured_data["limits"] + limits_lines = [ + f"[magenta]Hot Tokens:[/magenta] {limits['hot_max_tokens']:,}", + f"[magenta]Warm Decisions:[/magenta] {limits['warm_max_decisions']:,}", + f"[magenta]Cold Decisions:[/magenta] {limits['cold_max_decisions']:,}", + f"[cyan]Query Limit:[/cyan] {limits['query_limit'] if limits['query_limit'] is not None else '(no limit)'}", + f"[cyan]Max File Size:[/cyan] {limits['max_file_size']}", + f"[cyan]Max Total Size:[/cyan] {limits['max_total_size']}", + ] + + summarization = structured_data["summarization"] + summarization_lines = [ + f"[green]Enabled:[/green] {'yes' if summarization['enabled'] else 'no'}", + f"[blue]Max Tokens:[/blue] {summarization['max_tokens'] if summarization['max_tokens'] is not None else '(auto)'}", + ] + + usage = structured_data["current_usage"] + usage_lines = [ + f"[blue]Hot Context:[/blue] {usage['hot_context_tokens']:,} / {usage['hot_context_limit']:,}", + f"[blue]Warm Entries:[/blue] {usage['warm_entries']:,} / {usage['warm_limit']:,}", + f"[blue]Cold Entries:[/blue] {usage['cold_entries']:,} / {usage['cold_limit']:,}", + f"[green]Indexed Resources:[/green] {usage['indexed_resources']:,}", + ] + + console.print( + Panel("\n".join(policy_lines), title="Context Policy", expand=False) ) - lines.append( - f" Warm max decisions: " - f"{acms.get('warm_max_decisions', _DEFAULT_WARM_MAX_DECISIONS)}" - ) - lines.append( - f" Cold max decisions: " - f"{acms.get('cold_max_decisions', _DEFAULT_COLD_MAX_DECISIONS)}" - ) - lines.append(f" Summarize: {acms.get('summarize', True)}") - lines.append(f" Temporal scope: {acms.get('temporal_scope', 'current')}") - lines.append(f" Auto-refresh: {acms.get('auto_refresh', True)}") - strategies = acms.get("strategies", []) - lines.append( - f" Strategies: {', '.join(strategies) if strategies else '(default)'}" - ) - lines.append( - f" Default breadth: {acms.get('default_breadth', _DEFAULT_BREADTH)}" - ) - lines.append(f" Default depth: {acms.get('default_depth', _DEFAULT_DEPTH)}") - dg = acms.get("depth_gradient", {}) - if dg: - gradient_parts = [ - f"{h}:{v}" for h, v in sorted(dg.items(), key=lambda x: int(x[0])) - ] - lines.append(f" Depth gradient: {', '.join(gradient_parts)}") - else: - lines.append(" Depth gradient: (none)") - lines.append( - f" Skeleton ratio: {acms.get('skeleton_ratio', _DEFAULT_SKELETON_RATIO)}" + console.print(Panel("\n".join(limits_lines), title="Limits", expand=False)) + console.print( + Panel("\n".join(summarization_lines), title="Summarization", expand=False) ) console.print( - Panel( - "\n".join(lines), - title=title, - expand=False, - ) + Panel("\n".join(usage_lines), title="Current Usage", expand=False) ) - else: - console.print(format_output(data, output_format)) + console.print("[green]✓ OK[/green] Context policy loaded") + return + + rendered = format_output( + structured_data, + output_format, + command=command_name, + status="ok", + exit_code=0, + messages=[success_message], + ) + + if rendered: + console.print(rendered) + + if fmt == OutputFormat.PLAIN.value: + console.print("[OK] Context policy loaded") @app.command(name="inspect")