Files
temp/features/steps/session_list_summary_dedup_steps.py
freemo 9210fbce33 refactor(cli): deduplicate session list summary logic in list_sessions
Refactor list_sessions to reuse the summary dict already computed by
_session_list_dict instead of recalculating total_msgs, sorted_sessions,
most_recent, and oldest independently.

The rich table summary panel now reads directly from data["summary"],
ensuring JSON and rich table output are always consistent and the
summary logic lives in exactly one place (_session_list_dict).

Add Behave feature (session_list_summary_dedup.feature) with 6 scenarios
verifying that JSON and rich table summary values are consistent across
all required fields: total, most_recent, oldest, total_messages, storage.

ISSUES CLOSED: #3046
2026-04-05 09:38:43 +00:00

252 lines
8.3 KiB
Python

"""Step definitions for session_list_summary_dedup.feature (issue #3046).
Verifies that the summary values in the JSON output and the rich table output
are consistent — i.e. that the deduplicated ``_session_list_dict`` helper is
the single source of truth for both rendering paths.
"""
from __future__ import annotations
import json
from datetime import UTC, datetime
from unittest.mock import MagicMock, patch
from behave import given, then, when
from behave.runner import Context
from typer.testing import CliRunner
from cleveragents.cli.commands.session import app as session_app
from cleveragents.domain.models.core.session import (
MessageRole,
Session,
SessionMessage,
SessionService,
SessionTokenUsage,
)
_runner = CliRunner()
# Stable ULIDs for deterministic test output
_ULID_NEWER = "01SCVBST000000000000000001"
_ULID_OLDER = "01SCVBST000000000000000002"
# Newer session updated more recently
_DT_NEWER = datetime(2025, 8, 1, 12, 0, 0, tzinfo=UTC)
_DT_OLDER = datetime(2025, 7, 1, 10, 0, 0, tzinfo=UTC)
def _make_session(
session_id: str,
actor_name: str | None,
updated_at: datetime,
message_count: int = 0,
name: str | None = None,
) -> Session:
messages = [
SessionMessage(
message_id=f"01SCVBST0000000000000000{i + 1:02d}",
role=MessageRole.USER,
content=f"msg {i}",
sequence=i,
timestamp=updated_at,
)
for i in range(message_count)
]
return Session(
session_id=session_id,
actor_name=actor_name,
namespace="local",
messages=messages,
linked_plan_ids=[],
token_usage=SessionTokenUsage(),
created_at=updated_at,
updated_at=updated_at,
metadata={},
cost_budget=None,
name=name,
)
def _mock_service_with_sessions() -> MagicMock:
svc = MagicMock(spec=SessionService)
sessions = [
_make_session(
session_id=_ULID_NEWER,
actor_name="openai/gpt-4",
updated_at=_DT_NEWER,
message_count=3,
name="newer-session",
),
_make_session(
session_id=_ULID_OLDER,
actor_name=None,
updated_at=_DT_OLDER,
message_count=2,
name=None,
),
]
svc.list.return_value = sessions
return svc
# ---------------------------------------------------------------------------
# Background
# ---------------------------------------------------------------------------
@given("a session-list-summary mock service with two sessions")
def step_setup_mock_service(context: Context) -> None:
context.sls_svc = _mock_service_with_sessions()
context.sls_json_result = None
context.sls_rich_result = None
# ---------------------------------------------------------------------------
# When
# ---------------------------------------------------------------------------
@when("I invoke session list with JSON format")
def step_invoke_json(context: Context) -> None:
with patch(
"cleveragents.cli.commands.session._get_session_service",
return_value=context.sls_svc,
):
context.sls_json_result = _runner.invoke(
session_app, ["list", "--format", "json"]
)
@when("I invoke session list with rich format")
def step_invoke_rich(context: Context) -> None:
with patch(
"cleveragents.cli.commands.session._get_session_service",
return_value=context.sls_svc,
):
context.sls_rich_result = _runner.invoke(
session_app, ["list", "--format", "rich"]
)
# ---------------------------------------------------------------------------
# Then — cross-format consistency
# ---------------------------------------------------------------------------
def _get_json_summary(context: Context) -> dict:
result = context.sls_json_result
assert result is not None, "JSON result not set — invoke JSON format first"
assert result.exit_code == 0, (
f"JSON command failed (exit {result.exit_code}):\n{result.output}"
)
data = json.loads(result.output)
assert "summary" in data, f"No 'summary' key in JSON output: {data}"
return data["summary"]
def _get_rich_output(context: Context) -> str:
result = context.sls_rich_result
assert result is not None, "Rich result not set — invoke rich format first"
assert result.exit_code == 0, (
f"Rich command failed (exit {result.exit_code}):\n{result.output}"
)
return result.output
@then("the JSON summary total should match the rich table total")
def step_total_matches(context: Context) -> None:
summary = _get_json_summary(context)
rich_output = _get_rich_output(context)
expected_total = str(summary["total"])
assert "Total:" in rich_output, f"'Total:' not found in rich output:\n{rich_output}"
assert expected_total in rich_output, (
f"Expected total '{expected_total}' not found in rich output:\n{rich_output}"
)
@then("the JSON summary total_messages should match the rich table total messages")
def step_total_messages_matches(context: Context) -> None:
summary = _get_json_summary(context)
rich_output = _get_rich_output(context)
expected = str(summary["total_messages"])
assert "Total Messages:" in rich_output, (
f"'Total Messages:' not found in rich output:\n{rich_output}"
)
assert expected in rich_output, (
f"Expected total_messages '{expected}' not found in rich output:\n{rich_output}"
)
@then("the JSON summary most_recent should appear in the rich table summary panel")
def step_most_recent_matches(context: Context) -> None:
summary = _get_json_summary(context)
rich_output = _get_rich_output(context)
most_recent = summary["most_recent"]
assert most_recent is not None, "most_recent is None in JSON summary"
assert most_recent in rich_output, (
f"Expected most_recent '{most_recent}' not found in rich output:\n{rich_output}"
)
@then("the JSON summary oldest should appear in the rich table summary panel")
def step_oldest_matches(context: Context) -> None:
summary = _get_json_summary(context)
rich_output = _get_rich_output(context)
oldest = summary["oldest"]
assert oldest is not None, "oldest is None in JSON summary"
assert oldest in rich_output, (
f"Expected oldest '{oldest}' not found in rich output:\n{rich_output}"
)
# ---------------------------------------------------------------------------
# Then — rich table field presence
# ---------------------------------------------------------------------------
@then('the rich table output contains "{text}"')
def step_rich_contains(context: Context, text: str) -> None:
rich_output = _get_rich_output(context)
assert text in rich_output, f"Expected '{text}' in rich output:\n{rich_output}"
# ---------------------------------------------------------------------------
# Then — JSON summary field presence
# ---------------------------------------------------------------------------
@then("the JSON output has a summary key with total")
def step_json_has_total(context: Context) -> None:
summary = _get_json_summary(context)
assert "total" in summary, f"'total' not in summary: {summary}"
assert isinstance(summary["total"], int), (
f"'total' should be int, got {type(summary['total'])}"
)
@then("the JSON output has a summary key with most_recent")
def step_json_has_most_recent(context: Context) -> None:
summary = _get_json_summary(context)
assert "most_recent" in summary, f"'most_recent' not in summary: {summary}"
@then("the JSON output has a summary key with oldest")
def step_json_has_oldest(context: Context) -> None:
summary = _get_json_summary(context)
assert "oldest" in summary, f"'oldest' not in summary: {summary}"
@then("the JSON output has a summary key with total_messages")
def step_json_has_total_messages(context: Context) -> None:
summary = _get_json_summary(context)
assert "total_messages" in summary, f"'total_messages' not in summary: {summary}"
assert isinstance(summary["total_messages"], int), (
f"'total_messages' should be int, got {type(summary['total_messages'])}"
)
@then("the JSON output has a summary key with storage")
def step_json_has_storage(context: Context) -> None:
summary = _get_json_summary(context)
assert "storage" in summary, f"'storage' not in summary: {summary}"