Skip to content

Commit 0294818

Browse files
committed
feat(tests): add unit tests for answer_kb_question handler
1 parent 02de6c2 commit 0294818

1 file changed

Lines changed: 230 additions & 0 deletions

File tree

Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
"""Unit tests for :mod:`agents.kb_builder.qa.answer_kb_question` (M3.5, issue #21).
2+
3+
Covers the acceptance criteria that are testable without a live Anthropic
4+
call or live MCP server:
5+
6+
- Returns ``{answer, source, confidence}`` on the happy path.
7+
- Returns the documented fallback dict (no raise) when the KB is missing.
8+
- Returns the documented fallback dict (no raise) when the LLM call fails.
9+
- Performs no DB writes (verified by stubbing — there is no DB call to make).
10+
- Performs no WebSocket broadcasts (verified by absence — no ``ws_manager``
11+
import in the module under test, asserted below).
12+
- Uses ``model_for("chat")`` so a demo-day flip to ``ARIA_MODEL=opus`` does
13+
not silently switch this handler to Opus.
14+
15+
End-to-end validation (real Sonnet, real MCP, real Postgres) is deferred —
16+
the M2.5 integration tests already cover ``get_equipment_kb``, and this
17+
function adds no DB writes of its own.
18+
"""
19+
20+
from __future__ import annotations
21+
22+
from dataclasses import dataclass
23+
from typing import Any
24+
25+
import pytest
26+
from agents.kb_builder import qa
27+
28+
29+
@dataclass
30+
class _ToolResult:
31+
content: str = '{"thresholds": {"vibration": {"nominal": 4.5}}}'
32+
is_error: bool = False
33+
34+
35+
class _FakeMCP:
36+
def __init__(self, result: _ToolResult | None = None) -> None:
37+
self.calls: list[tuple[str, dict[str, Any]]] = []
38+
self._result = result or _ToolResult()
39+
40+
async def call_tool(self, name: str, args: dict[str, Any]) -> _ToolResult:
41+
self.calls.append((name, args))
42+
return self._result
43+
44+
45+
class _FakeAnthropic:
46+
"""Minimal stand-in for the ``anthropic`` async client.
47+
48+
Captures the kwargs of each ``messages.create`` call so tests can assert
49+
on the model used and the prompt shape, then returns a canned ``Message``
50+
that ``parse_json_response`` will accept.
51+
"""
52+
53+
def __init__(self, response_text: str) -> None:
54+
self.calls: list[dict[str, Any]] = []
55+
self._response_text = response_text
56+
self.messages = self._Messages(self)
57+
58+
class _Messages:
59+
def __init__(self, outer: "_FakeAnthropic") -> None:
60+
self._outer = outer
61+
62+
async def create(self, **kwargs: Any) -> Any:
63+
self._outer.calls.append(kwargs)
64+
from anthropic.types import Message, TextBlock, Usage
65+
66+
return Message(
67+
id="msg_test",
68+
type="message",
69+
role="assistant",
70+
model=kwargs.get("model", "claude-sonnet-4-5"),
71+
content=[TextBlock(type="text", text=self._outer._response_text)],
72+
stop_reason="end_turn",
73+
stop_sequence=None,
74+
usage=Usage(input_tokens=10, output_tokens=10),
75+
)
76+
77+
78+
def _patch(
79+
monkeypatch: pytest.MonkeyPatch,
80+
*,
81+
mcp: _FakeMCP,
82+
anthropic_client: _FakeAnthropic | None = None,
83+
) -> None:
84+
monkeypatch.setattr(qa, "mcp_client", mcp)
85+
if anthropic_client is not None:
86+
monkeypatch.setattr(qa, "anthropic", anthropic_client)
87+
88+
89+
# ── happy path ───────────────────────────────────────────────────────────────
90+
91+
92+
@pytest.mark.asyncio
93+
async def test_returns_parsed_json_on_happy_path(monkeypatch):
94+
mcp = _FakeMCP()
95+
fake = _FakeAnthropic(
96+
response_text='{"answer": "120 Nm", "source": "thresholds.bolt_torque", "confidence": 0.9}'
97+
)
98+
_patch(monkeypatch, mcp=mcp, anthropic_client=fake)
99+
100+
out = await qa.answer_kb_question(2, "What is the max bolt torque?")
101+
102+
assert out == {"answer": "120 Nm", "source": "thresholds.bolt_torque", "confidence": 0.9}
103+
assert mcp.calls == [("get_equipment_kb", {"cell_id": 2})]
104+
# Single Anthropic call with the expected wiring
105+
assert len(fake.calls) == 1
106+
call = fake.calls[0]
107+
assert call["model"] == "claude-sonnet-4-5" # always Sonnet — see acceptance #2 / cost guard
108+
assert call["max_tokens"] == 1024
109+
assert "knowledge base" in call["system"].lower()
110+
user_content = call["messages"][0]["content"]
111+
assert "Equipment KB:" in user_content
112+
assert "What is the max bolt torque?" in user_content
113+
114+
115+
@pytest.mark.asyncio
116+
async def test_unknown_answer_is_passed_through_not_hallucinated(monkeypatch):
117+
"""If the LLM says ``unknown``, we forward it verbatim — no second-guessing."""
118+
119+
mcp = _FakeMCP()
120+
fake = _FakeAnthropic(response_text='{"answer": "unknown", "source": null, "confidence": 0.0}')
121+
_patch(monkeypatch, mcp=mcp, anthropic_client=fake)
122+
123+
out = await qa.answer_kb_question(2, "What colour is the casing?")
124+
125+
assert out["answer"] == "unknown"
126+
assert out["source"] is None
127+
assert out["confidence"] == 0.0
128+
129+
130+
# ── KB missing ───────────────────────────────────────────────────────────────
131+
132+
133+
@pytest.mark.asyncio
134+
async def test_returns_kb_unavailable_when_mcp_reports_error(monkeypatch):
135+
mcp = _FakeMCP(_ToolResult(content="cell 99 not found", is_error=True))
136+
# Anthropic must NOT be called when the KB lookup fails — pass a sentinel
137+
# whose .messages.create would raise if called.
138+
sentinel = _FakeAnthropic(response_text="should-not-be-called")
139+
140+
async def _boom(**_: Any) -> Any:
141+
raise AssertionError("anthropic.messages.create must not be invoked when KB is missing")
142+
143+
sentinel.messages.create = _boom # type: ignore[assignment]
144+
_patch(monkeypatch, mcp=mcp, anthropic_client=sentinel)
145+
146+
out = await qa.answer_kb_question(99, "Anything?")
147+
148+
assert out == {
149+
"answer": "KB not available for cell 99",
150+
"source": None,
151+
"confidence": 0.0,
152+
}
153+
assert mcp.calls == [("get_equipment_kb", {"cell_id": 99})]
154+
155+
156+
# ── error fallback ───────────────────────────────────────────────────────────
157+
158+
159+
@pytest.mark.asyncio
160+
async def test_returns_safe_fallback_when_anthropic_raises(monkeypatch):
161+
"""A Sonnet timeout or transport error must NOT bubble up to the caller —
162+
the Investigator tool loop relies on getting a dict back."""
163+
164+
mcp = _FakeMCP()
165+
166+
class _ExplodingAnthropic(_FakeAnthropic):
167+
class _Messages:
168+
async def create(self, **_: Any) -> Any:
169+
raise RuntimeError("simulated transport failure")
170+
171+
def __init__(self) -> None:
172+
self.messages = self._Messages()
173+
174+
_patch(monkeypatch, mcp=mcp, anthropic_client=_ExplodingAnthropic())
175+
176+
out = await qa.answer_kb_question(2, "Anything?")
177+
178+
assert out == {
179+
"answer": "KB query failed — information unavailable",
180+
"source": None,
181+
"confidence": 0.0,
182+
}
183+
184+
185+
@pytest.mark.asyncio
186+
async def test_returns_safe_fallback_when_response_is_not_json(monkeypatch):
187+
"""``parse_json_response`` raising ``ValueError`` must be swallowed too."""
188+
189+
mcp = _FakeMCP()
190+
fake = _FakeAnthropic(response_text="I am sorry, I cannot answer that.")
191+
_patch(monkeypatch, mcp=mcp, anthropic_client=fake)
192+
193+
out = await qa.answer_kb_question(2, "Anything?")
194+
195+
assert out["answer"] == "KB query failed — information unavailable"
196+
assert out["confidence"] == 0.0
197+
198+
199+
# ── contract guard: no WS broadcasts, no DB writes ───────────────────────────
200+
201+
202+
def test_module_does_not_import_ws_manager_or_db():
203+
"""Static guard for the issue's "pure handler" contract.
204+
205+
The M4.6 orchestrator owns ``agent_handoff`` / ``agent_start`` /
206+
``agent_end``. If this module ever starts broadcasting on its own, the
207+
Activity Feed will show duplicates — fail fast at test time.
208+
"""
209+
210+
import ast
211+
import inspect
212+
213+
tree = ast.parse(inspect.getsource(qa))
214+
# Drop module/function/class docstrings so the guard only inspects code.
215+
for node in ast.walk(tree):
216+
if isinstance(node, (ast.Module, ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
217+
if (
218+
node.body
219+
and isinstance(node.body[0], ast.Expr)
220+
and isinstance(node.body[0].value, ast.Constant)
221+
and isinstance(node.body[0].value.value, str)
222+
):
223+
node.body.pop(0)
224+
code_only = ast.unparse(tree)
225+
226+
assert "ws_manager" not in code_only
227+
assert "broadcast" not in code_only
228+
# No direct DB import either — all KB access must go through MCP.
229+
assert "from core import database" not in code_only
230+
assert "import asyncpg" not in code_only

0 commit comments

Comments
 (0)