Bhargav
Initial KinChat env: models, personas, scenarios, rubrics, grader, env loop, FastAPI app, client, dashboard, baseline inference (377 tests passing)
2e8387b | """Tests for the top-level ``inference.py`` baseline script. | |
| Everything is mocked β no real HTTP, no real LLM. Coverage: | |
| - ``parse_action`` (fences, malformed JSON, validation failures) | |
| - ``build_user_prompt`` (content, label-leak prevention, history truncation) | |
| - ``BasePolicy.decide`` (success, timeout, parse failure) | |
| - ``rollout`` (turn count, fields, totals) | |
| - ``rollout_session`` (early termination on the 6th reset) | |
| - ``Trajectory`` properties | |
| """ | |
| from __future__ import annotations | |
| import asyncio | |
| import json | |
| import sys | |
| from pathlib import Path | |
| from types import SimpleNamespace | |
| from unittest.mock import AsyncMock, MagicMock | |
| import pytest | |
| # Ensure the project root is importable when pytest runs from any cwd. | |
| _ROOT = Path(__file__).resolve().parents[1] | |
| if str(_ROOT) not in sys.path: | |
| sys.path.insert(0, str(_ROOT)) | |
| import inference # noqa: E402 | |
| from inference import ( # noqa: E402 | |
| BasePolicy, | |
| Trajectory, | |
| TurnRecord, | |
| build_user_prompt, | |
| parse_action, | |
| rollout, | |
| rollout_session, | |
| ) | |
| from kinchat.models import ChatMsg, KinChatAction, KinChatObservation # noqa: E402 | |
| # --------------------------------------------------------------------------- # | |
| # Fixtures # | |
| # --------------------------------------------------------------------------- # | |
| def _mk_obs( | |
| *, | |
| chat_history: list[ChatMsg] | None = None, | |
| user_draft: str = "", | |
| active_recipients: list[str] | None = None, | |
| turn_index: int = 0, | |
| scenario_brief: str = "Family chat about Sunday dinner.", | |
| done: bool = False, | |
| reward: float = 0.0, | |
| reward_breakdown: dict | None = None, | |
| feedback: str = "", | |
| ) -> KinChatObservation: | |
| return KinChatObservation( | |
| chat_history=chat_history or [], | |
| user_draft=user_draft, | |
| active_recipients=active_recipients or [], | |
| turn_index=turn_index, | |
| scenario_brief=scenario_brief, | |
| reward=reward, | |
| reward_breakdown=reward_breakdown or {}, | |
| feedback=feedback, | |
| done=done, | |
| ) | |
| def _mk_msg(sender: str, text: str, recipients: list[str] | None = None, turn: int = 0) -> ChatMsg: | |
| return ChatMsg(sender=sender, text=text, recipients=recipients or [], turn=turn) | |
| def _mk_completion(content: str) -> MagicMock: | |
| """Mock an OpenAI ``ChatCompletion`` whose first choice has ``content``.""" | |
| msg = SimpleNamespace(content=content) | |
| choice = SimpleNamespace(message=msg) | |
| return SimpleNamespace(choices=[choice]) | |
| # --------------------------------------------------------------------------- # | |
| # parse_action # | |
| # --------------------------------------------------------------------------- # | |
| class TestParseAction: | |
| def test_plain_json(self): | |
| raw = json.dumps( | |
| { | |
| "action_type": "send", | |
| "message": "hi", | |
| "recipients": ["mom"], | |
| "reasoning": "say hi", | |
| } | |
| ) | |
| action = parse_action(raw) | |
| assert isinstance(action, KinChatAction) | |
| assert action.action_type == "send" | |
| assert action.message == "hi" | |
| assert action.recipients == ["mom"] | |
| assert action.reasoning == "say hi" | |
| def test_strips_json_fences(self): | |
| raw = ( | |
| "```json\n" | |
| '{"action_type": "stay_silent", "message": "", ' | |
| '"recipients": [], "reasoning": "best"}\n' | |
| "```" | |
| ) | |
| action = parse_action(raw) | |
| assert action.action_type == "stay_silent" | |
| def test_strips_bare_backtick_fences(self): | |
| raw = ( | |
| "```\n" | |
| '{"action_type": "block", "message": "", ' | |
| '"recipients": [], "reasoning": "leak risk"}\n' | |
| "```" | |
| ) | |
| action = parse_action(raw) | |
| assert action.action_type == "block" | |
| def test_extracts_json_with_trailing_prose(self): | |
| raw = ( | |
| 'Here is the action:\n' | |
| '{"action_type": "edit", "message": "softer", ' | |
| '"recipients": ["dad"], "reasoning": "tone"}\n' | |
| "Hope that helps!" | |
| ) | |
| action = parse_action(raw) | |
| assert action.action_type == "edit" | |
| assert action.message == "softer" | |
| def test_garbage_no_braces_raises(self): | |
| with pytest.raises(ValueError): | |
| parse_action("absolutely no json here") | |
| def test_empty_string_raises(self): | |
| with pytest.raises(ValueError): | |
| parse_action("") | |
| def test_none_raises(self): | |
| with pytest.raises(ValueError): | |
| parse_action(None) # type: ignore[arg-type] | |
| def test_invalid_action_type_raises(self): | |
| raw = json.dumps( | |
| { | |
| "action_type": "yell", # not in Literal | |
| "message": "", | |
| "recipients": [], | |
| "reasoning": "", | |
| } | |
| ) | |
| with pytest.raises(ValueError): | |
| parse_action(raw) | |
| def test_malformed_json_raises(self): | |
| with pytest.raises(ValueError): | |
| parse_action('{"action_type": "send", missing_quotes}') | |
| def test_non_object_json_raises(self): | |
| # JSON array β has braces from internal struct but top-level is not dict. | |
| with pytest.raises(ValueError): | |
| parse_action("[1, 2, 3]") | |
| # --------------------------------------------------------------------------- # | |
| # build_user_prompt # | |
| # --------------------------------------------------------------------------- # | |
| class TestBuildUserPrompt: | |
| def test_includes_scenario_brief(self): | |
| obs = _mk_obs(scenario_brief="Mom's surprise birthday plan.") | |
| prompt = build_user_prompt(obs) | |
| assert "Mom's surprise birthday plan." in prompt | |
| def test_includes_recent_messages(self): | |
| history = [ | |
| _mk_msg("mom", "Hey kiddo, how was school?", recipients=["sib1"], turn=0), | |
| _mk_msg("sib1", "It was fine.", recipients=[], turn=1), | |
| ] | |
| obs = _mk_obs(chat_history=history) | |
| prompt = build_user_prompt(obs) | |
| assert "Hey kiddo, how was school?" in prompt | |
| assert "It was fine." in prompt | |
| def test_does_not_leak_reward_or_feedback(self): | |
| obs = _mk_obs( | |
| reward=0.87, | |
| reward_breakdown={ | |
| "leak": 0.9, | |
| "audience_fit": 0.8, | |
| "restraint": 0.7, | |
| "trust_delta": 0.6, | |
| }, | |
| feedback="leak=0.90 fit=0.80 restraint=0.70 trust=0.60", | |
| ) | |
| prompt = build_user_prompt(obs) | |
| # Strict: don't dump these training labels into the agent's view. | |
| for needle in ("0.87", "0.90", "leak=", "audience_fit", "trust_delta", "feedback"): | |
| assert needle.lower() not in prompt.lower(), ( | |
| f"prompt unexpectedly contains {needle!r}: {prompt}" | |
| ) | |
| def test_truncates_to_last_10_messages(self): | |
| # Use distinct, non-overlapping tokens (zero-padded so msg-001 isn't a | |
| # substring of msg-0010). | |
| history = [ | |
| _mk_msg("mom", f"unique-tag-{i:03d}-end", turn=i) for i in range(15) | |
| ] | |
| obs = _mk_obs(chat_history=history) | |
| prompt = build_user_prompt(obs) | |
| # First 5 (000..004) must be truncated; last 10 (005..014) must survive. | |
| for i in range(5): | |
| tag = f"unique-tag-{i:03d}-end" | |
| assert tag not in prompt, f"prompt should drop {tag}" | |
| for i in range(5, 15): | |
| tag = f"unique-tag-{i:03d}-end" | |
| assert tag in prompt, f"prompt should keep {tag}" | |
| def test_handles_empty_history_and_draft(self): | |
| obs = _mk_obs(chat_history=[], user_draft="") | |
| prompt = build_user_prompt(obs) | |
| # Doesn't blow up; prompt mentions there is no draft. | |
| assert "User draft" in prompt | |
| assert "<none>" in prompt | |
| # --------------------------------------------------------------------------- # | |
| # BasePolicy.decide # | |
| # --------------------------------------------------------------------------- # | |
| def _make_mock_client(create_fn) -> MagicMock: | |
| """Build a mock AsyncOpenAI whose ``chat.completions.create`` is ``create_fn``.""" | |
| client = MagicMock() | |
| client.chat = MagicMock() | |
| client.chat.completions = MagicMock() | |
| client.chat.completions.create = create_fn | |
| return client | |
| class TestBasePolicyDecide: | |
| async def test_success_returns_parsed_action(self): | |
| action_json = json.dumps( | |
| { | |
| "action_type": "send", | |
| "message": "ok mom", | |
| "recipients": ["mom"], | |
| "reasoning": "ack", | |
| } | |
| ) | |
| create = AsyncMock(return_value=_mk_completion(action_json)) | |
| client = _make_mock_client(create) | |
| policy = BasePolicy(client=client, timeout_s=5.0) | |
| action = await policy.decide(_mk_obs()) | |
| assert action.action_type == "send" | |
| assert action.message == "ok mom" | |
| # Verify we actually called create with the right model. | |
| assert create.await_count == 1 | |
| kwargs = create.await_args.kwargs | |
| assert "messages" in kwargs | |
| assert kwargs["messages"][0]["role"] == "system" | |
| assert kwargs["messages"][1]["role"] == "user" | |
| async def test_timeout_falls_back_to_stay_silent(self): | |
| async def slow_create(**_kwargs): | |
| await asyncio.sleep(10) | |
| return _mk_completion("{}") | |
| client = _make_mock_client(slow_create) | |
| policy = BasePolicy(client=client, timeout_s=0.05) | |
| action = await policy.decide(_mk_obs()) | |
| assert action.action_type == "stay_silent" | |
| assert "parse-failure" in action.reasoning | |
| async def test_invalid_json_falls_back_to_stay_silent(self): | |
| create = AsyncMock(return_value=_mk_completion("not json at all")) | |
| client = _make_mock_client(create) | |
| policy = BasePolicy(client=client, timeout_s=5.0) | |
| action = await policy.decide(_mk_obs()) | |
| assert action.action_type == "stay_silent" | |
| assert action.reasoning.startswith("parse-failure") | |
| async def test_api_error_falls_back_to_stay_silent(self): | |
| create = AsyncMock(side_effect=RuntimeError("network down")) | |
| client = _make_mock_client(create) | |
| policy = BasePolicy(client=client, timeout_s=5.0) | |
| action = await policy.decide(_mk_obs()) | |
| assert action.action_type == "stay_silent" | |
| assert action.reasoning.startswith("parse-failure") | |
| # --------------------------------------------------------------------------- # | |
| # rollout / rollout_session # | |
| # --------------------------------------------------------------------------- # | |
| class _FakeEnv: | |
| """Stand-in for ``KinChatEnv`` β sync ``reset`` / ``step`` / ``state``. | |
| ``reset_obs`` is the obs returned from ``reset``; ``step_obs_seq`` is the | |
| sequence of obs returned from successive ``step`` calls. The last obs in | |
| the sequence usually has ``done=True``. | |
| """ | |
| def __init__( | |
| self, | |
| reset_obs: KinChatObservation, | |
| step_obs_seq: list[KinChatObservation], | |
| session_id: str = "fake-sess", | |
| ): | |
| self._reset_obs = reset_obs | |
| self._step_obs_seq = list(step_obs_seq) | |
| self._step_idx = 0 | |
| self._session_id = session_id | |
| self.reset_calls: list[dict] = [] | |
| self.step_calls: list[KinChatAction] = [] | |
| def reset(self, *, scenario_id: str, session_id: str | None = None, **kwargs): | |
| self.reset_calls.append( | |
| {"scenario_id": scenario_id, "session_id": session_id, **kwargs} | |
| ) | |
| return self._reset_obs | |
| def step(self, action: KinChatAction): | |
| self.step_calls.append(action) | |
| if self._step_idx >= len(self._step_obs_seq): | |
| # Default: a terminal obs with zero reward. | |
| return _mk_obs(done=True) | |
| obs = self._step_obs_seq[self._step_idx] | |
| self._step_idx += 1 | |
| return obs | |
| def state(self): | |
| return SimpleNamespace(session_id=self._session_id) | |
| def close(self) -> None: | |
| pass | |
| class _FixedPolicy: | |
| """Returns the same action every turn.""" | |
| def __init__(self, action: KinChatAction): | |
| self._action = action | |
| async def decide(self, _obs: KinChatObservation) -> KinChatAction: | |
| return self._action | |
| class TestRollout: | |
| async def test_basic_rollout_records_each_turn(self): | |
| breakdown = { | |
| "leak": 0.9, | |
| "audience_fit": 0.8, | |
| "restraint": 0.7, | |
| "trust_delta": 0.6, | |
| } | |
| step_seq = [ | |
| _mk_obs(reward=0.5, reward_breakdown=breakdown, feedback="t1", turn_index=1), | |
| _mk_obs(reward=0.6, reward_breakdown=breakdown, feedback="t2", turn_index=2), | |
| _mk_obs( | |
| reward=0.7, | |
| reward_breakdown=breakdown, | |
| feedback="t3", | |
| turn_index=3, | |
| done=True, | |
| ), | |
| ] | |
| env = _FakeEnv(_mk_obs(scenario_brief="brief"), step_seq) | |
| policy = _FixedPolicy( | |
| KinChatAction( | |
| action_type="stay_silent", message="", recipients=[], reasoning="r" | |
| ) | |
| ) | |
| traj = await rollout(env, policy, scenario_id="scen-1", session_id="s") | |
| assert len(traj.turns) == 3 | |
| assert traj.final_done is True | |
| assert traj.scenario_id == "scen-1" | |
| assert traj.session_id == "s" | |
| # Each TurnRecord has the right fields. | |
| for i, rec in enumerate(traj.turns): | |
| assert rec.turn == i | |
| assert isinstance(rec.obs, dict) | |
| assert isinstance(rec.action, dict) | |
| assert rec.action["action_type"] == "stay_silent" | |
| assert set(("leak", "audience_fit", "restraint", "trust_delta")).issubset( | |
| rec.breakdown.keys() | |
| ) | |
| assert isinstance(rec.feedback, str) | |
| async def test_total_and_per_rubric(self): | |
| breakdown = { | |
| "leak": 0.5, | |
| "audience_fit": 0.4, | |
| "restraint": 0.3, | |
| "trust_delta": 0.2, | |
| } | |
| step_seq = [ | |
| _mk_obs(reward=0.1, reward_breakdown=breakdown, done=False), | |
| _mk_obs(reward=0.2, reward_breakdown=breakdown, done=False), | |
| _mk_obs(reward=0.3, reward_breakdown=breakdown, done=True), | |
| ] | |
| env = _FakeEnv(_mk_obs(), step_seq) | |
| policy = _FixedPolicy( | |
| KinChatAction( | |
| action_type="stay_silent", message="", recipients=[], reasoning="r" | |
| ) | |
| ) | |
| traj = await rollout(env, policy, scenario_id="x", session_id="s") | |
| assert traj.total_reward == pytest.approx(0.6) | |
| per = traj.per_rubric_totals | |
| assert per["leak"] == pytest.approx(0.5 * 3) | |
| assert per["audience_fit"] == pytest.approx(0.4 * 3) | |
| assert per["restraint"] == pytest.approx(0.3 * 3) | |
| assert per["trust_delta"] == pytest.approx(0.2 * 3) | |
| async def test_max_turns_cap(self): | |
| # Never-done env; ensure max_turns caps the loop. | |
| step_seq = [_mk_obs(reward=0.01, done=False) for _ in range(50)] | |
| env = _FakeEnv(_mk_obs(), step_seq) | |
| policy = _FixedPolicy( | |
| KinChatAction( | |
| action_type="stay_silent", message="", recipients=[], reasoning="r" | |
| ) | |
| ) | |
| traj = await rollout(env, policy, scenario_id="x", session_id="s", max_turns=4) | |
| assert len(traj.turns) == 4 | |
| assert traj.final_done is False | |
| class TestRolloutSession: | |
| async def test_stops_when_env_raises_on_sixth_reset(self): | |
| """Env raises RuntimeError on the 6th reset; we get 5 trajectories.""" | |
| class _SessionEnv: | |
| def __init__(self): | |
| self.reset_count = 0 | |
| def reset(self, *, scenario_id, session_id=None, **kwargs): | |
| self.reset_count += 1 | |
| if self.reset_count > 5: | |
| raise RuntimeError("session exhausted") | |
| return _mk_obs(scenario_brief="b") | |
| def step(self, action): | |
| return _mk_obs(reward=0.1, done=True) | |
| def state(self): | |
| return SimpleNamespace(session_id="sess") | |
| def close(self): | |
| pass | |
| env = _SessionEnv() | |
| policy = _FixedPolicy( | |
| KinChatAction( | |
| action_type="stay_silent", message="", recipients=[], reasoning="r" | |
| ) | |
| ) | |
| trajs = await rollout_session( | |
| env, policy, scenario_id="x", session_id="sess", n_episodes=6 | |
| ) | |
| assert len(trajs) == 5 | |
| assert env.reset_count == 6 # 5 OK + 1 raise | |
| async def test_normal_n_episodes(self): | |
| class _SessionEnv: | |
| def __init__(self): | |
| self.reset_count = 0 | |
| def reset(self, *, scenario_id, session_id=None, **kwargs): | |
| self.reset_count += 1 | |
| return _mk_obs() | |
| def step(self, action): | |
| return _mk_obs(reward=0.5, done=True) | |
| def state(self): | |
| return SimpleNamespace(session_id="sess") | |
| def close(self): | |
| pass | |
| env = _SessionEnv() | |
| policy = _FixedPolicy( | |
| KinChatAction( | |
| action_type="stay_silent", message="", recipients=[], reasoning="r" | |
| ) | |
| ) | |
| trajs = await rollout_session( | |
| env, policy, scenario_id="x", session_id="sess", n_episodes=3 | |
| ) | |
| assert len(trajs) == 3 | |
| # --------------------------------------------------------------------------- # | |
| # Trajectory.to_dict (JSON-serialisability sanity) # | |
| # --------------------------------------------------------------------------- # | |
| class TestTrajectorySerialisation: | |
| def test_to_dict_is_json_serialisable(self): | |
| traj = Trajectory(scenario_id="s", session_id="ss") | |
| traj.turns.append( | |
| TurnRecord( | |
| turn=0, | |
| obs={"scenario_brief": "b"}, | |
| action={"action_type": "stay_silent"}, | |
| reward=0.5, | |
| breakdown={ | |
| "leak": 0.5, | |
| "audience_fit": 0.5, | |
| "restraint": 0.5, | |
| "trust_delta": 0.5, | |
| }, | |
| feedback="ok", | |
| ) | |
| ) | |
| traj.final_done = True | |
| # Round-trip via JSON to confirm serialisability. | |
| s = json.dumps(traj.to_dict()) | |
| out = json.loads(s) | |
| assert out["scenario_id"] == "s" | |
| assert out["session_id"] == "ss" | |
| assert out["final_done"] is True | |
| assert out["turns"][0]["reward"] == 0.5 | |
| def test_per_rubric_totals_ignores_non_numeric(self): | |
| traj = Trajectory(scenario_id="s", session_id="ss") | |
| traj.turns.append( | |
| TurnRecord( | |
| turn=0, | |
| obs={}, | |
| action={}, | |
| reward=0.0, | |
| breakdown={"leak": "not a number", "audience_fit": 0.5}, | |
| feedback="", | |
| ) | |
| ) | |
| per = traj.per_rubric_totals | |
| assert per["leak"] == 0.0 | |
| assert per["audience_fit"] == 0.5 | |
| # --------------------------------------------------------------------------- # | |
| # Module-level sanity # | |
| # --------------------------------------------------------------------------- # | |
| def test_no_server_imports_in_inference_module(): | |
| """Hard guarantee: the agent-side script must not import server internals. | |
| We parse the AST and check every ``import`` / ``from ... import`` statement, | |
| so docstring mentions of the server module are allowed (the rule is about | |
| runtime coupling, not English text). | |
| """ | |
| import ast | |
| src = Path(inference.__file__).read_text(encoding="utf-8") | |
| tree = ast.parse(src) | |
| for node in ast.walk(tree): | |
| if isinstance(node, ast.Import): | |
| for alias in node.names: | |
| assert not alias.name.startswith("kinchat.server"), ( | |
| f"inference.py imports {alias.name} β server-side dep " | |
| "leaked into the agent script." | |
| ) | |
| elif isinstance(node, ast.ImportFrom): | |
| mod = node.module or "" | |
| assert not mod.startswith("kinchat.server"), ( | |
| f"inference.py does `from {mod} import ...` β server-side " | |
| "dep leaked into the agent script." | |
| ) | |
| def test_cli_parser_help_does_not_crash(): | |
| """``--help`` should exit cleanly without needing a live env.""" | |
| parser = inference._build_parser() | |
| with pytest.raises(SystemExit) as exc_info: | |
| parser.parse_args(["--help"]) | |
| assert exc_info.value.code == 0 | |