Files
placeholder/features/steps/session_cli_uncovered_branches_steps.py
brent.edwards e732c32981 fix(cli): handle missing database in session list command
Register PersistentSessionService in the DI Container so that
'agents session list' (and all other session subcommands) no longer
throw AttributeError due to a missing 'db' provider.

Changes:
- Add _build_session_service() factory and session_service provider to
  Container, with targeted table creation for session/session_messages
  only (avoids bypassing Alembic for the full schema).
- Add auto_commit parameter to SessionRepository and
  SessionMessageRepository; when True each method commits and closes
  its own database session, preventing resource leaks in CLI context.
- Rewrite _get_session_service() to resolve via container.session_service()
  with module-level caching.
- Add (DatabaseError, AttributeError) error handling with logging to all
  seven session subcommands (list, create, show, delete, export, import,
  tell).
- Remove @tdd_expected_fail tags from all session test files so they run
  as proper regression tests.

ISSUES CLOSED: #554, #570, #680
2026-03-12 16:16:41 +00:00

369 lines
12 KiB
Python

"""Step definitions for session CLI uncovered-branch coverage tests."""
from __future__ import annotations
from datetime import datetime
from typing import Any
from unittest.mock import MagicMock, patch
from behave import given, then, when
from typer.testing import CliRunner
from cleveragents.cli.commands.session import app as session_app
from cleveragents.domain.models.core.session import (
MessageRole,
Session,
SessionExportError,
SessionMessage,
SessionNotFoundError,
SessionService,
SessionTokenUsage,
)
runner = CliRunner()
# Valid ULIDs for tests (Crockford base32)
_ULID = "01KJ1N8YDN05N29P5RZV4SPDVZ"
_ULID2 = "01KJ1N8YDN05N29P5RZV4SPDW0"
_NOW = datetime(2025, 6, 15, 12, 0, 0)
def _make_session(
*,
session_id: str = _ULID,
actor_name: str | None = None,
namespace: str = "local",
messages: list[SessionMessage] | None = None,
linked_plan_ids: list[str] | None = None,
token_usage: SessionTokenUsage | None = None,
metadata: dict[str, Any] | None = None,
) -> Session:
"""Build a Session object with sensible defaults."""
return Session(
session_id=session_id,
actor_name=actor_name,
namespace=namespace,
messages=messages or [],
linked_plan_ids=linked_plan_ids or [],
token_usage=token_usage or SessionTokenUsage(),
created_at=_NOW,
updated_at=_NOW,
metadata=metadata or {},
)
def _make_message(
content: str = "hello",
role: MessageRole = MessageRole.USER,
sequence: int = 0,
) -> SessionMessage:
return SessionMessage(
message_id=_ULID2,
role=role,
content=content,
sequence=sequence,
timestamp=_NOW,
)
def _mock_service() -> MagicMock:
"""Create a MagicMock that passes isinstance checks for SessionService."""
svc = MagicMock(spec=SessionService)
return svc
# ---- helpers to patch the module-level _service ---------------------------
def _patch_service(context, svc):
"""Patch session._service and register cleanup."""
patcher = patch("cleveragents.cli.commands.session._service", svc)
patcher.start()
context._cleanup_handlers.append(patcher.stop)
# ===========================================================================
# Scenario: _get_session_service constructs service when _service is None
# ===========================================================================
@given("session cli branch the module-level _service is None")
def step_service_is_none(context):
import cleveragents.cli.commands.session as mod
context._original_service = mod._service
mod._service = None
context._cleanup_handlers.append(
lambda: setattr(mod, "_service", context._original_service)
)
@when("session cli branch I call _get_session_service with mocked container")
def step_call_get_session_service(context):
import cleveragents.cli.commands.session as mod
mock_service_instance = MagicMock()
mock_container = MagicMock()
mock_container.session_service.return_value = mock_service_instance
context._mock_persistent_instance = mock_service_instance
import sys
mock_container_mod = MagicMock()
mock_container_mod.get_container = MagicMock(return_value=mock_container)
with patch.dict(
sys.modules,
{
"cleveragents.application.container": mock_container_mod,
},
):
result = mod._get_session_service()
context._get_service_result = result
@then("session cli branch a PersistentSessionService is returned")
def step_persistent_returned(context):
assert context._get_service_result is context._mock_persistent_instance
# ===========================================================================
# Scenario: _reset_session_service sets _service to None
# ===========================================================================
@given("session cli branch the module-level _service holds a mock")
def step_service_holds_mock(context):
import cleveragents.cli.commands.session as mod
context._original_service = mod._service
mod._service = MagicMock()
context._cleanup_handlers.append(
lambda: setattr(mod, "_service", context._original_service)
)
@when("session cli branch I call _reset_session_service")
def step_call_reset(context):
import cleveragents.cli.commands.session as mod
mod._reset_session_service()
@then("session cli branch the module-level _service is None again")
def step_service_is_none_again(context):
import cleveragents.cli.commands.session as mod
assert mod._service is None
# Restore so after_scenario doesn't break
mod._service = context._original_service
# ===========================================================================
# Scenario: create command catches SessionNotFoundError
# ===========================================================================
@given(
"session cli branch a mock session service that raises SessionNotFoundError on create"
)
def step_service_raises_on_create(context):
svc = _mock_service()
svc.create.side_effect = SessionNotFoundError("actor not found")
_patch_service(context, svc)
@when("session cli branch I invoke the create command")
def step_invoke_create(context):
context.result = runner.invoke(session_app, ["create"])
@then("session cli branch the exit code is 1")
def step_exit_code_1(context):
assert context.result.exit_code == 1, (
f"Expected exit code 1, got {context.result.exit_code}. "
f"Output: {context.result.output}"
)
@then('session cli branch the output contains "{text}"')
def step_output_contains(context, text):
combined = context.result.output
assert text in combined, f"Expected '{text}' in output. Got:\n{combined}"
# ===========================================================================
# Scenario: show command with no messages
# ===========================================================================
@given("session cli branch a mock session service returning a session with no messages")
def step_service_no_messages(context):
session = _make_session(messages=[], linked_plan_ids=[])
svc = _mock_service()
svc.get.return_value = session
_patch_service(context, svc)
context.session_id = _ULID
@when("session cli branch I invoke the show command for that session")
def step_invoke_show_no_msgs(context):
context.result = runner.invoke(session_app, ["show", context.session_id])
@then("session cli branch the exit code is 0")
def step_exit_code_0(context):
assert context.result.exit_code == 0, (
f"Expected exit code 0, got {context.result.exit_code}. "
f"Output: {context.result.output}"
)
@then('session cli branch the output does not contain "{text}"')
def step_output_not_contains(context, text):
combined = context.result.output
assert text not in combined, f"Did not expect '{text}' in output. Got:\n{combined}"
# ===========================================================================
# Scenario: show command with linked plan ids
# ===========================================================================
@given(
"session cli branch a mock session service returning a session with linked plans"
)
def step_service_linked_plans(context):
session = _make_session(
linked_plan_ids=["01PLAN1234567890ABCDEFGHIJ", "01PLAN1234567890ABCDEFGHIK"],
)
svc = _mock_service()
svc.get.return_value = session
_patch_service(context, svc)
context.session_id_linked = _ULID
@when("session cli branch I invoke the show command for the linked-plans session")
def step_invoke_show_linked(context):
context.result = runner.invoke(session_app, ["show", context.session_id_linked])
# ===========================================================================
# Scenario: show command with long message content truncated
# ===========================================================================
@given(
"session cli branch a mock session service returning a session with a long message"
)
def step_service_long_message(context):
long_content = "A" * 100 # longer than 80 chars → triggers truncation
msg = _make_message(content=long_content, sequence=0)
session = _make_session(messages=[msg])
svc = _mock_service()
svc.get.return_value = session
_patch_service(context, svc)
context.session_id_long = _ULID
@when("session cli branch I invoke the show command for the long-message session")
def step_invoke_show_long(context):
context.result = runner.invoke(session_app, ["show", context.session_id_long])
# ===========================================================================
# Scenario: delete command aborted via confirmation prompt
# ===========================================================================
@given("session cli branch a mock session service for delete")
def step_service_for_delete(context):
session = _make_session()
svc = _mock_service()
svc.get.return_value = session
_patch_service(context, svc)
context.session_id_del = _ULID
@when("session cli branch I invoke the delete command without --yes and answer no")
def step_invoke_delete_no(context):
# typer.confirm reads from stdin; CliRunner accepts `input` kwarg
context.result = runner.invoke(
session_app,
["delete", context.session_id_del],
input="n\n",
)
@then("session cli branch the delete is aborted")
def step_delete_aborted(context):
# typer.Abort() sets exit_code to 1
# The output should contain "Aborted"
combined = context.result.output
assert "Aborted" in combined or context.result.exit_code != 0, (
f"Expected abort. exit_code={context.result.exit_code}, output:\n{combined}"
)
# ===========================================================================
# Scenario: export command catches SessionExportError
# ===========================================================================
@given(
"session cli branch a mock session service that raises SessionExportError on export"
)
def step_service_export_error(context):
svc = _mock_service()
svc.export_session.side_effect = SessionExportError("checksum mismatch")
_patch_service(context, svc)
@when("session cli branch I invoke the export command")
def step_invoke_export(context):
context.result = runner.invoke(session_app, ["export", _ULID])
@then("session cli branch the export exit code is 1")
def step_export_exit_1(context):
assert context.result.exit_code == 1, (
f"Expected exit code 1, got {context.result.exit_code}. "
f"Output: {context.result.output}"
)
@then('session cli branch the export output contains "{text}"')
def step_export_output_contains(context, text):
combined = context.result.output
assert text in combined, f"Expected '{text}' in output. Got:\n{combined}"
# ===========================================================================
# Scenario: tell command with stream=True
# ===========================================================================
@given("session cli branch a mock session service for tell")
def step_service_for_tell(context):
svc = _mock_service()
# append_message doesn't need to return anything meaningful for the
# code path under test — the assistant content is computed inline.
svc.append_message.return_value = None
_patch_service(context, svc)
@when("session cli branch I invoke the tell command with --stream")
def step_invoke_tell_stream(context):
context.result = runner.invoke(
session_app,
["tell", "--session", _ULID, "--stream", "Hello world"],
)
@then("session cli branch the streamed output contains the assistant response")
def step_streamed_output(context):
# The assistant content for no actor is: "Acknowledged: Hello world"
assert "Acknowledged" in context.result.output, (
f"Expected 'Acknowledged' in output. Got:\n{context.result.output}"
)