Spaces:
Sleeping
Sleeping
| """Tests for inference.py — validates exact stdout format and safety guarantees. | |
| Uses a mock OpenAI client so no real API key is needed. | |
| """ | |
| from __future__ import annotations | |
| import re | |
| from typing import Any | |
| from unittest.mock import AsyncMock, MagicMock, patch | |
| import pytest | |
| import inference | |
| def _single_difficulty_for_tests() -> Any: | |
| """Full baseline runs easy+medium+hard; unit tests keep one episode.""" | |
| with patch.object(inference, "EPISODE_DIFFICULTIES", ["easy"]): | |
| yield | |
| # ── mock helpers ───────────────────────────────────────────────────────────── | |
| def _make_mock_client(responses: list[str]) -> AsyncMock: | |
| """Build a mock AsyncOpenAI that returns *responses* in order.""" | |
| client = AsyncMock() | |
| side_effects = [] | |
| for text in responses: | |
| choice = MagicMock() | |
| choice.message.content = text | |
| resp = MagicMock() | |
| resp.choices = [choice] | |
| side_effects.append(resp) | |
| while len(side_effects) < 15: | |
| side_effects.append(side_effects[-1]) | |
| client.chat.completions.create = AsyncMock(side_effect=side_effects) | |
| return client | |
| OPTIMAL_EASY_RESPONSES = [ | |
| '{"action_type":"classify","category":"account_access","priority":"medium"}', | |
| '{"action_type":"route","department":"account"}', | |
| '{"action_type":"resolve","resolution_summary":"Password reset link sent. Access to the account is now restored."}', | |
| ] | |
| # ── format validation ─────────────────────────────────────────────────────── | |
| START_RE = re.compile( | |
| r"^\[START\] task=\S+ env=\S+ model=\S+$" | |
| ) | |
| STEP_RE = re.compile( | |
| r"^\[STEP\] step=\d+ action=.+ reward=-?\d+\.\d{2} done=(?:true|false) error=.+$" | |
| ) | |
| END_RE = re.compile( | |
| r"^\[END\] success=(?:true|false) steps=\d+ score=\d+\.\d{3} rewards=.+$" | |
| ) | |
| def _parse_output(captured: str) -> dict[str, Any]: | |
| lines = [l for l in captured.strip().splitlines() if l.strip()] | |
| assert len(lines) >= 2, f"Expected at least 2 lines, got:\n{captured}" | |
| start_line = lines[0] | |
| end_line = lines[-1] | |
| step_lines = lines[1:-1] | |
| assert START_RE.match(start_line), f"Bad START line: {start_line}" | |
| assert END_RE.match(end_line), f"Bad END line: {end_line}" | |
| for sl in step_lines: | |
| assert STEP_RE.match(sl), f"Bad STEP line: {sl}" | |
| end_parts = end_line.split() | |
| success = end_parts[1].split("=")[1] | |
| steps = int(end_parts[2].split("=")[1]) | |
| score = float(end_parts[3].split("=")[1]) | |
| rewards_str = end_parts[4].split("=")[1] | |
| assert success in ("true", "false") | |
| assert steps == len(step_lines) | |
| if rewards_str: | |
| rewards = [float(r) for r in rewards_str.split(",")] | |
| else: | |
| rewards = [] | |
| return { | |
| "success": success == "true", | |
| "steps": steps, | |
| "score": score, | |
| "rewards": rewards, | |
| "step_lines": step_lines, | |
| } | |
| # ── tests ──────────────────────────────────────────────────────────────────── | |
| async def test_optimal_easy_episode_format(capsys: pytest.CaptureFixture[str]) -> None: | |
| """A full easy episode must produce valid [START]/[STEP]/[END] lines.""" | |
| mock_client = _make_mock_client(OPTIMAL_EASY_RESPONSES) | |
| with patch.object(inference, "_build_client", return_value=mock_client): | |
| await inference.run() | |
| out = capsys.readouterr().out | |
| parsed = _parse_output(out) | |
| assert parsed["success"] is True | |
| assert parsed["steps"] == 3 | |
| assert parsed["score"] > 0.1 | |
| assert len(parsed["rewards"]) == 3 | |
| for r in parsed["rewards"]: | |
| assert -0.25 <= r <= 0.30 | |
| async def test_step_numbering_starts_at_one(capsys: pytest.CaptureFixture[str]) -> None: | |
| """Step numbers must be 1-based.""" | |
| mock_client = _make_mock_client(OPTIMAL_EASY_RESPONSES) | |
| with patch.object(inference, "_build_client", return_value=mock_client): | |
| await inference.run() | |
| out = capsys.readouterr().out | |
| step_nums = [] | |
| for line in out.strip().splitlines(): | |
| m = re.match(r"^\[STEP\] step=(\d+)", line) | |
| if m: | |
| step_nums.append(int(m.group(1))) | |
| assert step_nums == [1, 2, 3] | |
| async def test_model_returns_garbage(capsys: pytest.CaptureFixture[str]) -> None: | |
| """When the model returns non-JSON, inference uses fallback and never crashes.""" | |
| garbage = ["lol not json"] * 12 | |
| mock_client = _make_mock_client(garbage) | |
| with patch.object(inference, "_build_client", return_value=mock_client): | |
| await inference.run() | |
| out = capsys.readouterr().out | |
| parsed = _parse_output(out) | |
| assert parsed["steps"] >= 1 | |
| for sl in parsed["step_lines"]: | |
| assert "error=" in sl | |
| async def test_model_exception(capsys: pytest.CaptureFixture[str]) -> None: | |
| """If the model call raises, inference still emits [END].""" | |
| mock_client = AsyncMock() | |
| mock_client.chat.completions.create = AsyncMock( | |
| side_effect=RuntimeError("API down") | |
| ) | |
| with patch.object(inference, "_build_client", return_value=mock_client): | |
| await inference.run() | |
| out = capsys.readouterr().out | |
| lines = [l for l in out.strip().splitlines() if l.strip()] | |
| assert any(l.startswith("[START]") for l in lines) | |
| assert any(l.startswith("[END]") for l in lines) | |
| async def test_reward_format_two_decimals(capsys: pytest.CaptureFixture[str]) -> None: | |
| """Rewards in STEP lines must have exactly 2 decimal places.""" | |
| mock_client = _make_mock_client(OPTIMAL_EASY_RESPONSES) | |
| with patch.object(inference, "_build_client", return_value=mock_client): | |
| await inference.run() | |
| out = capsys.readouterr().out | |
| for line in out.strip().splitlines(): | |
| if line.startswith("[STEP]"): | |
| match = re.search(r"reward=(-?\d+\.\d+)", line) | |
| assert match is not None | |
| decimals = match.group(1).split(".")[1] | |
| assert len(decimals) == 2, f"Expected 2 decimal places, got: {match.group(1)}" | |
| async def test_score_format_three_decimals(capsys: pytest.CaptureFixture[str]) -> None: | |
| """Score in END line must have exactly 3 decimal places.""" | |
| mock_client = _make_mock_client(OPTIMAL_EASY_RESPONSES) | |
| with patch.object(inference, "_build_client", return_value=mock_client): | |
| await inference.run() | |
| out = capsys.readouterr().out | |
| for line in out.strip().splitlines(): | |
| if line.startswith("[END]"): | |
| match = re.search(r"score=(\d+\.\d+)", line) | |
| assert match is not None | |
| decimals = match.group(1).split(".")[1] | |
| assert len(decimals) == 3, f"Expected 3 decimal places, got: {match.group(1)}" | |
| async def test_booleans_lowercase(capsys: pytest.CaptureFixture[str]) -> None: | |
| """All boolean fields must use lowercase true/false.""" | |
| mock_client = _make_mock_client(OPTIMAL_EASY_RESPONSES) | |
| with patch.object(inference, "_build_client", return_value=mock_client): | |
| await inference.run() | |
| out = capsys.readouterr().out | |
| for line in out.strip().splitlines(): | |
| assert "True" not in line, f"Python-style True found in: {line}" | |
| assert "False" not in line, f"Python-style False found in: {line}" | |
| async def test_end_always_emitted_on_crash(capsys: pytest.CaptureFixture[str]) -> None: | |
| """Even if env.reset() throws, [END] must be emitted.""" | |
| mock_client = _make_mock_client(OPTIMAL_EASY_RESPONSES) | |
| with ( | |
| patch.object(inference, "_build_client", return_value=mock_client), | |
| patch( | |
| "inference.CustomerSupportEnv.reset", | |
| side_effect=RuntimeError("env broken"), | |
| ), | |
| ): | |
| await inference.run() | |
| out = capsys.readouterr().out | |
| lines = [l for l in out.strip().splitlines() if l.strip()] | |
| assert lines[-1].startswith("[END]") | |
| async def test_missing_hf_token_emits_end(capsys: pytest.CaptureFixture[str]) -> None: | |
| """If HF_TOKEN is empty, _build_client raises but [END] is still emitted.""" | |
| with patch.object(inference, "HF_TOKEN", ""): | |
| await inference.run() | |
| out = capsys.readouterr().out | |
| lines = [l for l in out.strip().splitlines() if l.strip()] | |
| assert lines[-1].startswith("[END]") | |
| async def test_action_field_is_pure_json(capsys: pytest.CaptureFixture[str]) -> None: | |
| """The action= field must be unmodified JSON (no underscore replacement).""" | |
| mock_client = _make_mock_client(OPTIMAL_EASY_RESPONSES) | |
| with patch.object(inference, "_build_client", return_value=mock_client): | |
| await inference.run() | |
| out = capsys.readouterr().out | |
| import json as _json | |
| for line in out.strip().splitlines(): | |
| if line.startswith("[STEP]"): | |
| m = re.search(r"action=(.+?) reward=", line) | |
| assert m is not None, f"No action field in: {line}" | |
| action_raw = m.group(1) | |
| parsed = _json.loads(action_raw) | |
| assert "action_type" in parsed | |
| async def test_error_field_unmodified(capsys: pytest.CaptureFixture[str]) -> None: | |
| """Error messages must be output verbatim (no underscore replacement).""" | |
| garbage = ["not json at all"] * 12 | |
| mock_client = _make_mock_client(garbage) | |
| with patch.object(inference, "_build_client", return_value=mock_client): | |
| await inference.run() | |
| out = capsys.readouterr().out | |
| found_error = False | |
| for line in out.strip().splitlines(): | |
| if line.startswith("[STEP]"): | |
| m = re.search(r"error=(.+)$", line) | |
| if m and m.group(1) != "null": | |
| found_error = True | |
| assert "_" not in m.group(1) or " " in m.group(1), ( | |
| f"Error should be unmodified: {m.group(1)}" | |
| ) | |
| assert found_error, "Expected at least one step with an error" | |
| async def test_default_baseline_runs_three_tasks(capsys: pytest.CaptureFixture[str]) -> None: | |
| """With easy+medium+hard, inference emits one [START]/[END] block per task.""" | |
| garbage = ["not json"] * 60 | |
| mock_client = _make_mock_client(garbage) | |
| with patch.object(inference, "EPISODE_DIFFICULTIES", ["easy", "medium", "hard"]): | |
| with patch.object(inference, "_build_client", return_value=mock_client): | |
| await inference.run() | |
| out = capsys.readouterr().out | |
| assert out.count("[START]") == 3 | |
| assert out.count("[END]") == 3 | |
| async def test_success_is_score_based(capsys: pytest.CaptureFixture[str]) -> None: | |
| """success=true iff score > 0.1.""" | |
| mock_client = _make_mock_client(OPTIMAL_EASY_RESPONSES) | |
| with patch.object(inference, "_build_client", return_value=mock_client): | |
| await inference.run() | |
| out = capsys.readouterr().out | |
| parsed = _parse_output(out) | |
| if parsed["score"] > 0.1: | |
| assert parsed["success"] is True | |
| else: | |
| assert parsed["success"] is False | |