forked from HAL9000/cleveragents-core
a808c395f9
Add 53 new .feature files and corresponding step definition files targeting uncovered lines identified in build/coverage.xml. Fix AmbiguousStep conflicts in 7 pre-existing step files by disambiguating step text. New tests cover: ACP clients/facade, actor CLI/config, application container, ACMS service/strategies, async worker, automation profile CLI, autonomy guardrail, bridge, change model, config CLI/service, context service, cross-plan correction, database models, decision service, decomposition clustering/service, discovery handler, langchain chat provider, langgraph nodes, materializers, multi-project service, plan apply/CLI/lifecycle/model/ preflight/resume/service, PostgreSQL analyzer, project CLI/context CLI, provider registry, reactive application/route, repositories, resolver handler, resource registry service, resume model, retry patterns, sandbox protocol, server CLI, skill CLI/service, skills registry, subplan execution/service, system CLI, UKO loader, UoW, and YAML template engine. Closes #645
320 lines
11 KiB
Python
320 lines
11 KiB
Python
"""Step definitions targeting uncovered lines in langchain_chat_provider.py.
|
|
|
|
Targeted uncovered lines:
|
|
36-38 — _openai_callback discovery from langchain.callbacks module
|
|
302-303 — _resolve_token_cost TypeError/ValueError fallback
|
|
345-346 — _extract_event StopIteration guard for empty dict
|
|
376-377 — _estimate_token_usage when estimator() raises
|
|
385-386 — _estimate_token_usage when int(result) raises
|
|
450 — _extract_error_message returning None for empty list/tuple/set
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import types
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
from behave import given, then, when
|
|
|
|
from cleveragents.domain.models.core import Context, Plan, Project
|
|
from cleveragents.providers.llm.langchain_chat_provider import LangChainChatProvider
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Background
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("a fresh LangChain chat provider with mocked dependencies")
|
|
def step_fresh_provider(context):
|
|
"""Set up a clean LangChainChatProvider with controllable fakes."""
|
|
context.llm_instance = MagicMock()
|
|
context.llm_instance.__class__ = type(
|
|
"FakeLLM", (), {"__module__": "langchain_community.llms"}
|
|
)
|
|
context.llm_instance.get_num_tokens = MagicMock(return_value=0)
|
|
|
|
def fake_llm_factory(model_id: str):
|
|
return context.llm_instance
|
|
|
|
context.provider = LangChainChatProvider(
|
|
name="boost-test",
|
|
model_id="boost-model",
|
|
llm_factory=fake_llm_factory,
|
|
max_retries=1,
|
|
supports_streaming=False,
|
|
)
|
|
|
|
context.project = MagicMock(spec=Project)
|
|
context.plan = MagicMock(spec=Plan)
|
|
context.plan.prompt = "Test prompt"
|
|
context.contexts = [MagicMock(spec=Context)]
|
|
context.contexts[0].content = "test content"
|
|
|
|
context.progress_updates = []
|
|
|
|
def progress_cb(pct: int) -> None:
|
|
context.progress_updates.append(pct)
|
|
|
|
context.progress_callback = progress_cb
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Lines 302-303: _resolve_token_cost — TypeError/ValueError when float() fails
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@when("the usage tracker reports a non-numeric cost value")
|
|
def step_non_numeric_cost(context):
|
|
tracker = MagicMock()
|
|
tracker.total_cost = object() # not convertible to float
|
|
context.token_cost_result = context.provider._resolve_token_cost(tracker)
|
|
|
|
|
|
@then("resolving token cost should return None")
|
|
def step_token_cost_is_none(context):
|
|
assert context.token_cost_result is None, (
|
|
f"Expected None, got {context.token_cost_result!r}"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Lines 345-346: _extract_event — StopIteration for empty dict
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@when("an empty event dictionary is passed to extract_event")
|
|
def step_empty_event_dict(context):
|
|
context.node_name, context.payload = context.provider._extract_event({})
|
|
|
|
|
|
@then('the extracted node name should be "__unknown__" with an empty payload')
|
|
def step_assert_unknown_event(context):
|
|
assert context.node_name == "__unknown__", (
|
|
f"Expected '__unknown__', got {context.node_name!r}"
|
|
)
|
|
assert context.payload == {}, (
|
|
f"Expected empty dict payload, got {context.payload!r}"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Lines 376-377: _estimate_token_usage — estimator raises exception
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@when("the LLM token estimator raises an exception during estimation")
|
|
def step_estimator_raises(context):
|
|
context.llm_instance.get_num_tokens = MagicMock(
|
|
side_effect=RuntimeError("tokenizer crashed")
|
|
)
|
|
context.estimated_tokens = context.provider._estimate_token_usage(
|
|
context.llm_instance, context.plan, context.contexts
|
|
)
|
|
|
|
|
|
@then("the estimated token count should be zero")
|
|
def step_assert_zero_tokens(context):
|
|
assert context.estimated_tokens == 0, (
|
|
f"Expected 0, got {context.estimated_tokens!r}"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Lines 385-386: _estimate_token_usage — int() conversion fails
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@when("the LLM token estimator returns a value that cannot be cast to int")
|
|
def step_estimator_returns_non_int(context):
|
|
# Return an object whose __int__ raises TypeError
|
|
class BadInt:
|
|
def __int__(self):
|
|
raise TypeError("cannot convert")
|
|
|
|
def __str__(self):
|
|
return "BadInt()"
|
|
|
|
context.llm_instance.get_num_tokens = MagicMock(return_value=BadInt())
|
|
context.estimated_tokens = context.provider._estimate_token_usage(
|
|
context.llm_instance, context.plan, context.contexts
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Line 450: _extract_error_message — returns None for empty list/tuple/set
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@when("the error value is an empty list")
|
|
def step_error_empty_list(context):
|
|
context.extracted_error = context.provider._extract_error_message([])
|
|
|
|
|
|
@when("the error value is an empty tuple")
|
|
def step_error_empty_tuple(context):
|
|
context.extracted_error = context.provider._extract_error_message(())
|
|
|
|
|
|
@when("the error value is an empty set")
|
|
def step_error_empty_set(context):
|
|
context.extracted_error = context.provider._extract_error_message(set())
|
|
|
|
|
|
@when("the error value is a list containing only None entries")
|
|
def step_error_list_of_nones(context):
|
|
context.extracted_error = context.provider._extract_error_message([None, None])
|
|
|
|
|
|
@then("the extracted error message should be None")
|
|
def step_assert_extracted_error_none(context):
|
|
assert context.extracted_error is None, (
|
|
f"Expected None, got {context.extracted_error!r}"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Lines 36-38: _openai_callback discovery when module is available
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@when("the langchain callbacks module exposes get_openai_callback")
|
|
def step_callback_discovery_success(context):
|
|
"""Simulate the module-level callback discovery logic (lines 30-38)."""
|
|
fake_callback = MagicMock()
|
|
fake_module = types.ModuleType("langchain.callbacks")
|
|
fake_module.get_openai_callback = fake_callback # type: ignore[attr-defined]
|
|
|
|
# Re-execute the discovery logic that runs at module level
|
|
discovered = None
|
|
try:
|
|
callbacks_module = fake_module
|
|
except Exception:
|
|
pass
|
|
else:
|
|
cb = getattr(callbacks_module, "get_openai_callback", None)
|
|
if callable(cb):
|
|
discovered = cb
|
|
|
|
context.discovered_callback = discovered
|
|
context.expected_callback = fake_callback
|
|
|
|
|
|
@then("the callback discovery logic should bind the callable")
|
|
def step_assert_callback_bound(context):
|
|
assert context.discovered_callback is context.expected_callback, (
|
|
"Expected the discovered callback to be the fake callable"
|
|
)
|
|
|
|
|
|
@when("the langchain callbacks module exposes a non-callable get_openai_callback")
|
|
def step_callback_discovery_non_callable(context):
|
|
"""Simulate the discovery logic when the attribute is not callable."""
|
|
fake_module = types.ModuleType("langchain.callbacks")
|
|
fake_module.get_openai_callback = "not-a-callable" # type: ignore[attr-defined]
|
|
|
|
discovered = None
|
|
try:
|
|
callbacks_module = fake_module
|
|
except Exception:
|
|
pass
|
|
else:
|
|
cb = getattr(callbacks_module, "get_openai_callback", None)
|
|
if callable(cb):
|
|
discovered = cb
|
|
|
|
context.discovered_callback = discovered
|
|
|
|
|
|
@then("the callback discovery logic should not bind any callable")
|
|
def step_assert_callback_not_bound(context):
|
|
assert context.discovered_callback is None, (
|
|
f"Expected None, got {context.discovered_callback!r}"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Lines 302-303 variant: tracker with no total_cost attribute at all
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@when("the usage tracker has no total_cost attribute")
|
|
def step_tracker_no_cost_attr(context):
|
|
tracker = MagicMock(spec=[]) # empty spec → no attributes
|
|
context.token_cost_result = context.provider._resolve_token_cost(tracker)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Token estimation with multiple contexts (lines 368-372 coverage boost)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@when("the LLM token estimator counts tokens for plan and multiple contexts")
|
|
def step_estimator_with_contexts(context):
|
|
call_args_capture = []
|
|
|
|
def fake_get_num_tokens(text: str) -> int:
|
|
call_args_capture.append(text)
|
|
return len(text.split())
|
|
|
|
context.llm_instance.get_num_tokens = fake_get_num_tokens
|
|
context.plan.prompt = "Build a widget"
|
|
|
|
ctx1 = MagicMock(spec=Context)
|
|
ctx1.content = "First context content"
|
|
ctx2 = MagicMock(spec=Context)
|
|
ctx2.content = "Second context content"
|
|
ctx3 = MagicMock(spec=Context)
|
|
ctx3.content = "" # empty content should be skipped
|
|
|
|
context.contexts = [ctx1, ctx2, ctx3]
|
|
context.estimated_tokens = context.provider._estimate_token_usage(
|
|
context.llm_instance, context.plan, context.contexts
|
|
)
|
|
context.estimator_call_args = call_args_capture
|
|
|
|
|
|
@then("the estimated token count should reflect the combined prompt text")
|
|
def step_assert_combined_tokens(context):
|
|
assert context.estimated_tokens > 0, (
|
|
f"Expected positive token count, got {context.estimated_tokens}"
|
|
)
|
|
# Verify the prompt text included both non-empty contexts
|
|
call_text = context.estimator_call_args[0]
|
|
assert "First context content" in call_text
|
|
assert "Second context content" in call_text
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Generate changes exception path (lines 132-143)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@when("generate_changes is called and the workflow raises an exception")
|
|
def step_generate_raises_exception(context):
|
|
with patch(
|
|
"cleveragents.providers.llm.langchain_chat_provider.PlanGenerationGraph"
|
|
) as graph_cls:
|
|
mock_graph = graph_cls.return_value
|
|
mock_graph.invoke.side_effect = RuntimeError("workflow exploded")
|
|
context.response = context.provider.generate_changes(
|
|
context.project,
|
|
context.plan,
|
|
context.contexts,
|
|
progress_callback=context.progress_callback,
|
|
)
|
|
|
|
|
|
@then("the response should contain the exception message with zero changes")
|
|
def step_assert_error_response(context):
|
|
assert context.response is not None
|
|
assert context.response.changes == []
|
|
assert context.response.error_message == "workflow exploded"
|
|
assert context.response.model_used == "boost-model"
|
|
|
|
|
|
@then("progress should be reported as complete despite the failure")
|
|
def step_assert_progress_complete_on_failure(context):
|
|
assert context.progress_updates[-1] == 100, (
|
|
f"Expected last progress to be 100, got {context.progress_updates[-1]}"
|
|
)
|