fix(cli): fix project context show JSON/YAML output fields (#6323)
CI / benchmark-publish (pull_request) Has been skipped
CI / build (pull_request) Successful in 18s
CI / push-validation (pull_request) Successful in 19s
CI / helm (pull_request) Successful in 24s
CI / quality (pull_request) Successful in 49s
CI / lint (pull_request) Failing after 52s
CI / typecheck (pull_request) Successful in 56s
CI / security (pull_request) Successful in 1m14s
CI / coverage (pull_request) Has been skipped
CI / benchmark-regression (pull_request) Has been skipped
CI / e2e_tests (pull_request) Successful in 3m9s
CI / integration_tests (pull_request) Successful in 4m10s
CI / unit_tests (pull_request) Failing after 5m14s
CI / docker (pull_request) Has been skipped
CI / status-check (pull_request) Failing after 1s

ISSUES CLOSED: #6323
This commit is contained in:
2026-04-09 23:15:48 +00:00
parent fc9c730670
commit 4ea2b9dc74
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 from __future__ import annotations
import json import json
import yaml
from io import StringIO from io import StringIO
from typing import Any from typing import Any
from unittest.mock import MagicMock, patch 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, soft_wrap=True,
) )
def _test_format_output(data, format_type): def _test_format_output(data, format_type, **kwargs):
import sys as _sys import sys as _sys
from io import StringIO as _SIO 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 _old = _sys.stdout
_sys.stdout = _b _sys.stdout = _b
try: try:
_r = _fo(data, format_type) _r = _fo(data, format_type, **kwargs)
finally: finally:
_sys.stdout = _old _sys.stdout = _old
return _r or _b.getvalue().rstrip("\n") return _r or _b.getvalue().rstrip("\n")
@@ -329,7 +330,13 @@ def step_ctx_show(context: Any, project: str) -> None:
context_show, 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}"') @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, 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}"') @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 # Then assertions
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@@ -595,3 +613,188 @@ def step_ctx_output_valid_json(context: Any) -> None:
raise AssertionError( raise AssertionError(
f"Output is not valid JSON: {exc}\nOutput: {context.ctx_output!r}" f"Output is not valid JSON: {exc}\nOutput: {context.ctx_output!r}"
) from exc ) 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( def _write_policy(
session_factory: Any, session_factory: Any,
namespaced_name: str, namespaced_name: str,
@@ -794,125 +820,140 @@ def context_show(
policy = _read_policy(session_factory, project) policy = _read_policy(session_factory, project)
acms = _read_acms_config(session_factory, project) acms = _read_acms_config(session_factory, project)
if view is not None: active_view = view or "default"
if view not in VALID_PHASES: if active_view not in VALID_PHASES:
err_console.print( err_console.print(
f"[red]Invalid view '{view}': must be one of {PHASE_NAMES}[/red]" f"[red]Invalid view '{active_view}': must be one of {PHASE_NAMES}[/red]"
) )
raise typer.Exit(1) 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]
if output_format.lower() == OutputFormat.RICH: resolved_view = policy.resolve_view(active_view)
title = f"Context Policy: {project}" include_resources = list(resolved_view.include_resources or [])
if view: exclude_resources = list(resolved_view.exclude_resources or [])
title += f" (phase: {view})" include_paths = list(resolved_view.include_paths or [])
lines: list[str] = [] exclude_paths = list(resolved_view.exclude_paths or [])
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}")
# ACMS pipeline config section hot_limit = int(acms.get("hot_max_tokens") or _DEFAULT_HOT_MAX_TOKENS)
lines.append("") warm_limit = int(acms.get("warm_max_decisions") or _DEFAULT_WARM_MAX_DECISIONS)
lines.append("[bold]ACMS Pipeline Config:[/bold]") cold_limit = int(acms.get("cold_max_decisions") or _DEFAULT_COLD_MAX_DECISIONS)
lines.append( query_limit = acms.get("query_limit")
f" Hot max tokens: {acms.get('hot_max_tokens', _DEFAULT_HOT_MAX_TOKENS)}" 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( console.print(Panel("\n".join(limits_lines), title="Limits", expand=False))
f" Warm max decisions: " console.print(
f"{acms.get('warm_max_decisions', _DEFAULT_WARM_MAX_DECISIONS)}" Panel("\n".join(summarization_lines), title="Summarization", expand=False)
)
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( console.print(
Panel( Panel("\n".join(usage_lines), title="Current Usage", expand=False)
"\n".join(lines),
title=title,
expand=False,
)
) )
else: console.print("[green]✓ OK[/green] Context policy loaded")
console.print(format_output(data, output_format)) 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") @app.command(name="inspect")