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
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
566 lines
20 KiB
Python
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}"
|
|
)
|