fix(cli): fix project context show JSON/YAML output fields (#6323)

ISSUES CLOSED: #6323
This commit is contained in:
2026-04-09 23:15:48 +00:00
committed by cleveragents-auto
parent 422fa4d587
commit 3dfdc86e92
3 changed files with 385 additions and 117 deletions
@@ -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"
+207 -4
View File
@@ -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)
+154 -113
View File
@@ -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")