srr84/agent-data-layer
0
1"""Unit tests for M5/A9 answer_composer: the LLM job-2 nl_text-ONLY call site.2 3compose_nl() must (1) return the model's nl_text str verbatim and (2) CATCH the adapter's4ModelUnavailableError at the C-AS boundary, converting it to a typed ModelUnavailable5(stage=compose_nl) so no exception escapes the request path (INV-A6). It holds no fact: the6returned str is display-only (D-AI-2).7 8```text9spec traceability (audit map — safe to skip)10LLD §1 (M-answer_composer: compose_nl for nl_text ONLY; returns str), §3.1, §711TechSpec §4 (ModelUnavailable stage=compose_nl), §10 (no exception escapes)12Charter D-AI-1, D-AI-2 (return tainted), INV-A613```14"""15 16from __future__ import annotations17 18from typing import Sequence, Union19 20from agent_data_layer.answer_composer.answer_composer import compose_nl21from agent_data_layer.contracts.envelopes import ToolResult22from agent_data_layer.contracts.errors import ModelUnavailable, OutOfCoverage23from agent_data_layer.contracts.tools import PriceResult, ReturnSchema, ToolCall, ToolSpec24from agent_data_layer.local_model_adapter.local_model_adapter import ModelUnavailableError25 26_RESULT: ToolResult[ReturnSchema] = ToolResult(27 asserted_value=PriceResult(value=199), provenance=frozenset(), observed_at=None28)29 30 31class _FakeComposeProvider:32 """A provider whose compose_nl returns a canned sentence."""33 34 def __init__(self, sentence: str) -> None:35 self._sentence = sentence36 37 def select_tool(38 self, question: str, tool_specs: Sequence[ToolSpec]39 ) -> Union[ToolCall, OutOfCoverage]:40 return OutOfCoverage(reason="unused")41 42 def compose_nl(self, result: ToolResult[object]) -> str:43 return self._sentence44 45 46class _DownComposeProvider:47 """A provider whose compose_nl raises ModelUnavailableError (model-down on job-2)."""48 49 def select_tool(50 self, question: str, tool_specs: Sequence[ToolSpec]51 ) -> Union[ToolCall, OutOfCoverage]:52 return OutOfCoverage(reason="unused")53 54 def compose_nl(self, result: ToolResult[object]) -> str:55 raise ModelUnavailableError(56 ModelUnavailable(stage="compose_nl", detail="http_status=503")57 )58 59 60def test_compose_returns_nl_text_verbatim() -> None:61 """The model's nl_text is returned verbatim (display-only)."""62 out = compose_nl(_RESULT, _FakeComposeProvider("The price is $1.99."))63 assert out == "The price is $1.99."64 65 66def test_compose_catches_model_down_as_typed_error() -> None:67 """A compose-stage failure is caught -> typed ModelUnavailable(stage=compose_nl) (INV-A6)."""68 out = compose_nl(_RESULT, _DownComposeProvider())69 assert isinstance(out, ModelUnavailable)70 assert out.stage == "compose_nl"71 assert out.detail == "http_status=503"72 73 74def test_compose_never_raises_on_model_down() -> None:75 """No exception escapes the request path on a compose-stage model failure."""76 try:77 compose_nl(_RESULT, _DownComposeProvider())78 except Exception as exc: # noqa: BLE00179 raise AssertionError(f"answer_composer leaked an exception: {exc!r}")80 