Files
cleveragents-core/features/steps/session_tell_llm_steps.py
T
hurui200320 87a7ce35d7
CI / benchmark-regression (push) Has been skipped
CI / helm (push) Successful in 38s
CI / lint (push) Successful in 1m14s
CI / build (push) Successful in 1m10s
CI / push-validation (push) Successful in 49s
CI / quality (push) Successful in 1m35s
CI / typecheck (push) Successful in 1m39s
CI / security (push) Successful in 1m45s
CI / integration_tests (push) Successful in 3m44s
CI / e2e_tests (push) Successful in 4m29s
CI / unit_tests (push) Successful in 5m10s
CI / docker (push) Successful in 1m55s
CI / coverage (push) Successful in 11m8s
CI / status-check (push) Successful in 3s
CI / benchmark-publish (push) Successful in 1h20m21s
CI / benchmark-publish (pull_request) Has been skipped
CI / benchmark-regression (pull_request) Failing after 1m35s
CI / docker (pull_request) Successful in 1m26s
CI / unit_tests (pull_request) Successful in 6m40s
CI / push-validation (pull_request) Successful in 1m24s
CI / quality (pull_request) Successful in 4m4s
CI / integration_tests (pull_request) Successful in 5m44s
CI / e2e_tests (pull_request) Failing after 6m5s
CI / helm (pull_request) Successful in 1m10s
CI / build (pull_request) Successful in 2m39s
CI / lint (pull_request) Successful in 2m58s
CI / typecheck (pull_request) Successful in 3m56s
CI / security (pull_request) Successful in 3m56s
CI / coverage (pull_request) Successful in 10m52s
CI / status-check (pull_request) Failing after 3s
feat(session): implement real LLM actor invocation in session tell
Replace the M3 stub in `agents session tell` with real orchestrator actor
invocation via `SessionWorkflow.tell()`. The stub echoed a canned
`Acknowledged: ...` response without calling any LLM or actor; this
commit wires up the full pipeline:

Architecture:
- New `SessionWorkflow` (Application layer) orchestrates LLM invocation
  for session tell. Accepts a `ProviderRegistry` and `ToolRegistry`;
  falls back to `FakeListLLM` when no provider is configured.
- `LangChainSessionCaller` implements the `LLMCaller` protocol, building
  history-aware LangChain message lists from the session conversation and
  invoking the LLM via `ToolCallingRuntime.run_tool_loop()`.
- `TellResult` (Pydantic BaseModel) carries the assistant response plus
  token usage (input_tokens, output_tokens, cost_usd, duration_ms).

Domain / service layer:
- `SessionActorNotConfiguredError` added to the session domain model;
  raised when tell is invoked with no actor on the session and no
  `--actor` override. Clear message; CLI exits with code 1.
- `SessionService.get_messages()` abstract method added (+ implementation
  in `PersistentSessionService`) to load ordered message history.

A2A facade:
- `A2aLocalFacade` gains `message/send` and `message/stream` standard
  A2A operation handlers that route to `SessionWorkflow.tell()`.
  Total supported operations count: 42 → 44.

CLI:
- `session tell` command delegates to `_build_session_workflow()`
  (patchable factory) instead of directly calling `SessionService`.
- Non-streaming: `SessionWorkflow.tell()` returns `TellResult`; output
  includes a Usage panel (Rich/Plain) or a `usage` object (JSON/YAML).
- Streaming: `SessionWorkflow.tell_stream()` yields tokens; CLI prints
  them via `console.print` (not raw `sys.stdout.write`).
- Token usage recorded via `SessionService.update_token_usage()` in
  both paths.

Tests:
- New Behave feature `session_tell_llm.feature` (4 scenarios): real LLM
  response persisted; streaming yields tokens; no-actor exits code 1;
  `--actor` override resolves correctly.
- New Robot suite `session_tell_llm.robot` (4 tests): end-to-end with
  stub LLM injected via monkey-patching `_resolve_llm`.
- Updated all existing `session tell` test steps to patch
  `_build_session_workflow` returning a mock `TellResult` so tests
  remain isolated from the LLM layer.
- Updated operation-count assertions (42 → 44) in
  `a2a_cli_facade_integration`, `consolidated_misc`,
  `m6_autonomy_acceptance` feature files and steps.

Cycle 7 (Review ID 8088) fixes:
- Blocker 4: Split `session_workflow.py` (was 801 lines) into two files:
  `session_workflow.py` (467 lines) and `session_caller.py` (318 lines).
  Extracted: `LangChainSessionCaller`, `extract_content`,
  `extract_token_usage`, `estimate_cost`, `estimate_tokens`,
  `history_to_langchain_messages`, and stub classes.
- Blocker 5: Route CLI streaming path through
  `_facade_dispatch("message/stream", ...)` instead of directly
  calling `workflow.tell_stream()`. The facade's
  `_handle_message_stream` falls back to non-streaming with
  `streamed: false` (acceptable for this milestone per the spec).
- Updated streaming test assertion to validate full response presence
  (no longer checks token-by-token word positions, since the facade
  fallback returns a complete message).

ISSUES CLOSED: #5784
2026-05-11 04:39:29 +00:00

566 lines
20 KiB
Python

"""Step definitions for session tell real LLM invocation tests (issue #5784).
Tests verify that ``agents session tell`` routes through
:class:`~cleveragents.application.services.session_workflow.SessionWorkflow`
with a real (mock) :class:`LLMCaller`, persists the response, and records
token usage.
All LLM interactions are handled via a mock ``LLMCaller`` so no real API
keys or network access is required.
"""
from __future__ import annotations
import contextlib
import json
import re
from collections.abc import Iterator
from typing import Any
from unittest.mock import MagicMock, patch
from behave import given, then, when
from behave.runner import Context
from typer.testing import CliRunner
from cleveragents.application.services.session_workflow import SessionWorkflow
from cleveragents.cli.commands.session import app as session_app
from cleveragents.domain.models.core.session import (
MessageRole,
Session,
SessionMessage,
SessionService,
SessionTokenUsage,
)
_ULID = "01KJ1N8YDN05N29P5RZV4SPDVZ"
_STUB_RESPONSE = "Here is what I can help you with."
class _StubResp:
"""Minimal LLM response stub for _StubLLM.invoke()."""
def __init__(self, content: str) -> None:
self.content = content
self.tool_calls: list[Any] = []
self.response_metadata: dict[str, Any] = {
"usage": {"input_tokens": 10, "output_tokens": 5}
}
class _StubChunk:
"""Minimal streaming chunk for _StubLLM.stream()."""
def __init__(self, content: str) -> None:
self.content = content
runner = CliRunner()
class _StubLLM:
"""Minimal LangChain-compatible LLM stub for LangChainSessionCaller tests."""
def __init__(self, response: str = _STUB_RESPONSE) -> None:
self._response = response
self.invoked_messages: list[Any] = []
def invoke(self, messages: Any, **kwargs: Any) -> _StubResp:
self.invoked_messages = list(messages)
return _StubResp(self._response)
def stream(self, messages: Any, **kwargs: Any) -> Iterator[_StubChunk]:
self.invoked_messages = list(messages)
words = self._response.split()
for i, word in enumerate(words):
# Reconstruct spaces between words so the concatenated
# output matches the original response string verbatim.
token = word if i == 0 else " " + word
yield _StubChunk(token)
def _make_session(
session_id: str = _ULID,
actor_name: str | None = None,
) -> Session:
return Session(
session_id=session_id,
actor_name=actor_name,
namespace="local",
messages=[],
token_usage=SessionTokenUsage(),
)
def _make_mock_service(session: Session) -> MagicMock:
"""Build a MagicMock SessionService pre-configured for session tell."""
svc = MagicMock(spec=SessionService)
svc.get.return_value = session
svc.get_messages.return_value = []
svc.append_message.return_value = MagicMock(spec=SessionMessage)
svc.update_token_usage.return_value = None
return svc
# ---------------------------------------------------------------------------
# Background
# ---------------------------------------------------------------------------
@given("a session tell LLM mock environment")
def step_setup_llm_mock_env(context: Context) -> None:
"""Set up runner, stub LLM, and cleanup handlers."""
context.runner = runner
context.stub_llm = _StubLLM()
context.captured_actor_name: str | None = None
context._cleanup_handlers: list[Any] = []
def cleanup() -> None:
for stop in reversed(context._cleanup_handlers):
with contextlib.suppress(Exception):
stop()
context.add_cleanup(cleanup)
# ---------------------------------------------------------------------------
# Given steps
# ---------------------------------------------------------------------------
@given('a session with actor "{actor}" exists')
def step_session_with_actor(context: Context, actor: str) -> None:
session = _make_session(actor_name=actor)
context.session = session
context.svc = _make_mock_service(session)
# Patch _get_session_service instead of module-level _service so tests
# are resilient to changes in the internal caching strategy (m4).
svc_patcher = patch(
"cleveragents.cli.commands.session._get_session_service",
return_value=context.svc,
)
svc_patcher.start()
context._cleanup_handlers.append(svc_patcher.stop)
@given("a session with no actor exists")
def step_session_no_actor(context: Context) -> None:
session = _make_session(actor_name=None)
context.session = session
context.svc = _make_mock_service(session)
svc_patcher = patch(
"cleveragents.cli.commands.session._get_session_service",
return_value=context.svc,
)
svc_patcher.start()
context._cleanup_handlers.append(svc_patcher.stop)
# ---------------------------------------------------------------------------
# When steps
# ---------------------------------------------------------------------------
@when('I invoke session tell with prompt "{prompt}"')
def step_invoke_tell(context: Context, prompt: str) -> None:
"""Invoke tell routing through _facade_dispatch.
The non-streaming CLI path calls :func:`_facade_dispatch`, which in
production delegates to ``A2aLocalFacade``. The test intercepts
``_facade_dispatch`` with a thin adapter that runs the real
``SessionWorkflow.tell()`` (backed by the test's stub LLM) and
returns a dict matching the facade's response envelope. This
keeps the workflow assertions (append_message, token usage) working
while also testing the CLI's facade-routing code path.
"""
context.prompt = prompt
context.invoked_actor_name = None
stub_llm = context.stub_llm
svc = context.svc
def _llm_factory(actor: str) -> Any:
context.invoked_actor_name = actor
return stub_llm
wf = SessionWorkflow(session_service=svc, llm_factory=_llm_factory)
def _mock_dispatch(operation: str, params: dict[str, Any]) -> dict[str, Any]:
if operation == "message/send":
result = wf.tell(
session_id=params["session_id"],
prompt=params["message"],
actor_override=params.get("actor"),
)
return {
"session_id": result.session_id,
"assistant_message": result.assistant_message,
"usage": result.usage_dict(),
}
raise RuntimeError(f"Unknown operation: {operation}")
dispatch_patcher = patch(
"cleveragents.cli.commands.session._facade_dispatch",
side_effect=_mock_dispatch,
)
dispatch_patcher.start()
context._cleanup_handlers.append(dispatch_patcher.stop)
context.result = runner.invoke(
session_app,
["tell", "--session", _ULID, prompt],
)
@when('I invoke session tell with --stream and prompt "{prompt}"')
def step_invoke_tell_stream(context: Context, prompt: str) -> None:
"""Invoke tell --stream with a real SessionWorkflow backed by _StubLLM."""
context.prompt = prompt
stub_llm = context.stub_llm
svc = context.svc
def _workflow_factory() -> SessionWorkflow:
return SessionWorkflow(
session_service=svc,
llm_factory=lambda actor: stub_llm,
)
wf_patcher = patch(
"cleveragents.cli.commands.session._build_session_workflow",
side_effect=_workflow_factory,
)
wf_patcher.start()
context._cleanup_handlers.append(wf_patcher.stop)
context.result = runner.invoke(
session_app,
["tell", "--session", _ULID, "--stream", prompt],
)
@when('I invoke session tell with --actor "{actor}" and prompt "{prompt}"')
def step_invoke_tell_with_actor(context: Context, actor: str, prompt: str) -> None:
"""Invoke tell with --actor override, routing through _facade_dispatch."""
context.prompt = prompt
context.override_actor = actor
stub_llm = context.stub_llm
svc = context.svc
def _llm_factory(actor_name: str) -> Any:
context.invoked_actor_name = actor_name
return stub_llm
wf = SessionWorkflow(session_service=svc, llm_factory=_llm_factory)
def _mock_dispatch(operation: str, params: dict[str, Any]) -> dict[str, Any]:
if operation == "message/send":
result = wf.tell(
session_id=params["session_id"],
prompt=params["message"],
actor_override=params.get("actor"),
)
return {
"session_id": result.session_id,
"assistant_message": result.assistant_message,
"usage": result.usage_dict(),
}
raise RuntimeError(f"Unknown operation: {operation}")
dispatch_patcher = patch(
"cleveragents.cli.commands.session._facade_dispatch",
side_effect=_mock_dispatch,
)
dispatch_patcher.start()
context._cleanup_handlers.append(dispatch_patcher.stop)
context.result = runner.invoke(
session_app,
["tell", "--session", _ULID, "--actor", actor, prompt],
)
@when('I invoke session tell with --format json and prompt "{prompt}"')
def step_invoke_tell_json(context: Context, prompt: str) -> None:
"""Invoke tell --format json, routing through _facade_dispatch."""
context.prompt = prompt
stub_llm = context.stub_llm
svc = context.svc
wf = SessionWorkflow(
session_service=svc,
llm_factory=lambda actor: stub_llm,
)
def _mock_dispatch(operation: str, params: dict[str, Any]) -> dict[str, Any]:
if operation == "message/send":
result = wf.tell(
session_id=params["session_id"],
prompt=params["message"],
actor_override=params.get("actor"),
)
return {
"session_id": result.session_id,
"assistant_message": result.assistant_message,
"usage": result.usage_dict(),
}
raise RuntimeError(f"Unknown operation: {operation}")
dispatch_patcher = patch(
"cleveragents.cli.commands.session._facade_dispatch",
side_effect=_mock_dispatch,
)
dispatch_patcher.start()
context._cleanup_handlers.append(dispatch_patcher.stop)
context.result = runner.invoke(
session_app,
["tell", "--session", _ULID, "--format", "json", prompt],
)
def _mock_dispatch(operation: str, params: dict[str, Any]) -> dict[str, Any]:
if operation == "message/send":
result = wf.tell(
session_id=params["session_id"],
prompt=params["message"],
actor_override=params.get("actor"),
)
return {
"session_id": result.session_id,
"assistant_message": result.assistant_message,
"usage": result.usage_dict(),
}
raise RuntimeError(f"Unknown operation: {operation}")
dispatch_patcher = patch(
"cleveragents.cli.commands.session._facade_dispatch",
side_effect=_mock_dispatch,
)
dispatch_patcher.start()
context._cleanup_handlers.append(dispatch_patcher.stop)
context.result = runner.invoke(
session_app,
["tell", "--session", _ULID, "--format", "json", prompt],
)
@when('I invoke session tell with --stream --format json and prompt "{prompt}"')
def step_invoke_tell_stream_json(context: Context, prompt: str) -> None:
"""Invoke tell --stream --format json with a real SessionWorkflow backed by _StubLLM."""
context.prompt = prompt
stub_llm = context.stub_llm
svc = context.svc
def _workflow_factory() -> SessionWorkflow:
return SessionWorkflow(
session_service=svc,
llm_factory=lambda actor: stub_llm,
)
wf_patcher = patch(
"cleveragents.cli.commands.session._build_session_workflow",
side_effect=_workflow_factory,
)
wf_patcher.start()
context._cleanup_handlers.append(wf_patcher.stop)
context.result = runner.invoke(
session_app,
["tell", "--session", _ULID, "--stream", "--format", "json", prompt],
)
# ---------------------------------------------------------------------------
# Then steps
# ---------------------------------------------------------------------------
@then("the tell command returns the LLM response")
def step_tell_returns_llm_response(context: Context) -> None:
assert context.result.exit_code == 0, (
f"Expected exit code 0, got {context.result.exit_code}.\n"
f"Output:\n{context.result.output}"
)
# The stub LLM response should appear in the output
assert _STUB_RESPONSE in context.result.output, (
f"LLM response not found in output:\n{context.result.output}"
)
@then("the assistant message is persisted to the session")
def step_assistant_message_persisted(context: Context) -> None:
# append_message should be called exactly twice:
# once for the user message and once for the assistant response.
call_count = context.svc.append_message.call_count
assert call_count == 2, (
f"Expected append_message to be called exactly 2 times (user + assistant), "
f"got {call_count}"
)
# The second call should be for the ASSISTANT role
calls = context.svc.append_message.call_args_list
assistant_calls = [
c
for c in calls
if c.kwargs.get("role") == MessageRole.ASSISTANT
or (len(c.args) >= 2 and c.args[1] == MessageRole.ASSISTANT)
]
assert len(assistant_calls) == 1, (
f"Expected exactly 1 ASSISTANT persist, got {len(assistant_calls)}. "
f"Calls: {calls}"
)
# The second call should be for the ASSISTANT role
calls = context.svc.append_message.call_args_list
assistant_calls = [
c
for c in calls
if c.kwargs.get("role") == MessageRole.ASSISTANT
or (len(c.args) >= 2 and c.args[1] == MessageRole.ASSISTANT)
]
assert len(assistant_calls) == 1, (
f"Expected exactly 1 ASSISTANT persist, got {len(assistant_calls)}. "
f"Calls: {calls}"
)
@then("token usage is recorded")
def step_token_usage_recorded(context: Context) -> None:
assert context.svc.update_token_usage.called, (
"update_token_usage was not called — token usage was not recorded"
)
assert context.svc.update_token_usage.call_count == 1, (
f"Expected update_token_usage to be called exactly once, "
f"got {context.svc.update_token_usage.call_count}"
)
# Verify exact token counts match the metadata from the stub LLM (m5).
call_kwargs = context.svc.update_token_usage.call_args.kwargs
assert call_kwargs.get("input_tokens") == 10, (
f"Expected input_tokens=10, got {call_kwargs.get('input_tokens')}"
)
assert call_kwargs.get("output_tokens") == 5, (
f"Expected output_tokens=5, got {call_kwargs.get('output_tokens')}"
)
assert context.svc.update_token_usage.call_count == 1, (
f"Expected update_token_usage to be called exactly once, "
f"got {context.svc.update_token_usage.call_count}"
)
# Verify exact token counts match the metadata from the stub LLM (m5).
call_kwargs = context.svc.update_token_usage.call_args.kwargs
assert call_kwargs.get("input_tokens") == 10, (
f"Expected input_tokens=10, got {call_kwargs.get('input_tokens')}"
)
assert call_kwargs.get("output_tokens") == 5, (
f"Expected output_tokens=5, got {call_kwargs.get('output_tokens')}"
)
@then("the streamed output contains the LLM response tokens")
def step_streamed_output_contains_tokens(context: Context) -> None:
assert context.result.exit_code == 0, (
f"Expected exit code 0, got {context.result.exit_code}.\n"
f"Output:\n{context.result.output}"
)
# Strip ANSI escape sequences for text matching
output_clean = re.sub(r"\x1b\[[0-9;]*m", "", context.result.output)
# When streamed through the A2A facade (Option B: fallback to
# non-streaming), verify the full response is present in the output
# rather than checking token-by-token word positions (C5).
assert _STUB_RESPONSE in output_clean, (
f"Expected full response '{_STUB_RESPONSE}' in streamed output, "
f"but it was not found.\nOutput:\n{context.result.output}"
)
@then("the assistant message is persisted after streaming")
def step_assistant_persisted_after_stream(context: Context) -> None:
call_count = context.svc.append_message.call_count
assert call_count == 2, (
f"Expected append_message to be called exactly 2 times (user + assistant), "
f"got {call_count}"
)
# Verify that the last persisted message is an ASSISTANT role.
calls = context.svc.append_message.call_args_list
assert calls[-1].kwargs.get("role") == MessageRole.ASSISTANT, (
f"Last persisted message role is not ASSISTANT. Calls: {calls}"
)
# Verify that the last persisted message is an ASSISTANT role.
calls = context.svc.append_message.call_args_list
assert calls[-1].kwargs.get("role") == MessageRole.ASSISTANT, (
f"Last persisted message role is not ASSISTANT. Calls: {calls}"
)
@then("the tell command exits with code 1")
def step_tell_exits_code_1(context: Context) -> None:
assert context.result.exit_code == 1, (
f"Expected exit code 1, got {context.result.exit_code}.\n"
f"Output:\n{context.result.output}"
)
@then("the error output mentions actor configuration")
def step_error_mentions_actor(context: Context) -> None:
output = context.result.output.lower()
assert "actor" in output, (
f"Expected 'actor' in error output.\nGot:\n{context.result.output}"
)
@then('the tell command uses the override actor "{actor}"')
def step_tell_uses_override_actor(context: Context, actor: str) -> None:
invoked = getattr(context, "invoked_actor_name", None)
assert invoked is not None, "Actor name was never captured from _resolve_llm"
assert invoked == actor, f"Expected actor '{actor}' to be used, but got '{invoked}'"
@then("the tell output is valid JSON with a data envelope")
def step_tell_output_is_valid_json(context: Context) -> None:
assert context.result.exit_code == 0, (
f"Expected exit code 0, got {context.result.exit_code}.\n"
f"Output:\n{context.result.output}"
)
try:
raw = json.loads(context.result.output.strip())
except json.JSONDecodeError as exc:
raise AssertionError(
f"Output is not valid JSON: {exc}\nOutput:\n{context.result.output}"
) from exc
# format_output wraps data in a spec-required envelope
context.json_output = raw
context.json_data = raw.get("data", {})
assert isinstance(context.json_data, dict), (
f"Expected data envelope to be a dict, got {type(context.json_data)}"
)
@then("the data section contains the session_id")
def step_json_data_contains_session_id(context: Context) -> None:
assert context.json_data.get("session_id") == _ULID, (
f"Expected session_id={_ULID}, got {context.json_data.get('session_id')}"
)
@then("the data section contains a usage object with expected keys")
def step_json_data_contains_usage_object(context: Context) -> None:
usage = context.json_data.get("usage")
assert isinstance(usage, dict), f"Expected usage to be a dict, got {type(usage)}"
expected_keys = {
"input_tokens",
"output_tokens",
"cost_usd",
"duration_ms",
"tool_calls",
}
missing = expected_keys - set(usage.keys())
assert not missing, f"Usage object missing expected keys: {missing}\nUsage: {usage}"
@then("the output contains a Usage panel")
def step_output_contains_usage_panel(context: Context) -> None:
output = context.result.output
# Rich Usage panel is rendered with title="Usage"
assert "Usage" in output, f"Expected Usage panel in output.\nOutput:\n{output}"
assert "Input tokens" in output, (
f"Expected input tokens in Usage panel.\nOutput:\n{output}"
)
assert "Output tokens" in output, (
f"Expected output tokens in Usage panel.\nOutput:\n{output}"
)