forked from HAL9000/cleveragents-core
e732c32981
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
369 lines
12 KiB
Python
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}"
|
|
)
|