diff --git a/src/tmq/events.py b/src/tmq/events.py index 4b459ab..ce0a08e 100644 --- a/src/tmq/events.py +++ b/src/tmq/events.py @@ -166,6 +166,69 @@ def append_event( return event_id +# ── Model resolution ────────────────────────────────────────────── + +# Map model → provider from the known fleet configuration. +# See institutional memory: data-driven-model-selection.md. +# Refines tms/lib/tms/events.py:MODEL_TO_PROVIDER (tms#73). +# tmq#7's resolver is more aggressive: known fleet models always +# resolve to their mapped provider, overriding any caller-supplied +# provider (intentional divergence from tms#73, which preserves +# explicit providers even for known models). +MODEL_TO_PROVIDER = { + "deepseek-v4-pro": "deepseek", + "MiniMax-M3": "minimax", + "MiniMax-M3.5": "minimax", + "glm-5.2": "zai", +} + + +def _resolve_default_model() -> tuple[str, str]: + """Resolve the actually-served model from pi's settings file. + + When tmq dispatches pi without --provider/--model flags, the agent + uses the default from ~/.pi/agent/settings.json. We resolve this + at event-write time so the dispatch record carries the real model, + not an empty string. Returns (provider, model) tuple. + """ + import json as _json + + settings_path = os.path.expanduser("~/.pi/agent/settings.json") + try: + with open(settings_path) as f: + settings = _json.load(f) + except (FileNotFoundError, _json.JSONDecodeError, OSError): + return ("", "") + + model = settings.get("defaultModel", "") + if not model: + return ("", "") + + provider = MODEL_TO_PROVIDER.get(model, "unknown") + return (provider, model) + + +def _resolve_dispatch_model(provider: str, model: str) -> tuple[str, str]: + """Resolve event provenance from explicit flags, then pi defaults. + + Refines tms#73: an explicit model determines its provider from the + fleet map, and the mapping is authoritative — a known model always + resolves to its mapped provider, overriding any caller-supplied + provider (tms#73 preserves explicit providers even for known models; + this divergence is intentional so stale defaults can't leak). + For unknown models, the explicit provider (if any) is preserved. + Defaults are consulted only when the invocation supplies no model. + """ + if model: + resolved = MODEL_TO_PROVIDER.get(model) + if resolved: + return (resolved, model) + return (provider or "unknown", model) + + resolved_provider, resolved_model = _resolve_default_model() + return (provider or resolved_provider, resolved_model) + + def log_dispatch( *, repo: str, @@ -185,6 +248,7 @@ def log_dispatch( so the metrics rows distinguish them; aoe_id is the 8-char prefix the bash tool computed via ``aoe session show --json``. """ + provider, model = _resolve_dispatch_model(provider, model) return append_event( event_type="dispatch", repo=repo, @@ -215,6 +279,7 @@ def log_dispatch_failed( """The failure path: tms#39 (cc root refusal), aoe add failed, aoe session start failed, etc. ``reason`` is the actionable hint. """ + provider, model = _resolve_dispatch_model(provider, model) return append_event( event_type="dispatch_failed", repo=repo, diff --git a/tests/test_events.py b/tests/test_events.py new file mode 100644 index 0000000..dabc35e --- /dev/null +++ b/tests/test_events.py @@ -0,0 +1,174 @@ +"""Tests for src/tmq/events.py — provider/model provenance. + +Mirrors tms#73's parameterized tests for _resolve_dispatch_model. +Tests are ported to the bogocat-tmq API surface (log_dispatch / +log_dispatch_failed) but assert the same resolution contract. + +Database isolation: events.py writes to postgres via psycopg2. +Tests patch psycopg2.connect to capture the INSERT SQL + params +so we assert what the row would contain without needing a real DB. +""" + +from unittest.mock import MagicMock, patch + +import pytest + + +# ── Shared fixtures ─────────────────────────────────────────────── + + +def _capture_insert(mock_connect): + """Set up mock psycopg2 so we can capture the INSERT params.""" + mock_conn = MagicMock() + mock_cursor = MagicMock() + mock_conn.__enter__.return_value = mock_conn + mock_conn.cursor.return_value.__enter__.return_value = mock_cursor + mock_connect.return_value = mock_conn + return mock_cursor + + +def _last_insert_params(mock_cursor): + """Return the params tuple from the most recent execute() call.""" + call_args = mock_cursor.execute.call_args + if call_args is None: + return None + return call_args[0][1] # args[0] = (sql, params) + + +# ── Tests ───────────────────────────────────────────────────────── + + +class TestLogDispatchProviderResolution: + """Provider/model provenance for log_dispatch (tms#83).""" + + def test_explicit_model_derives_provider(self): + """A --model without --provider derives provider from the fleet map.""" + with patch("psycopg2.connect") as mock_connect: + cursor = _capture_insert(mock_connect) + from tmq.events import log_dispatch + + log_dispatch( + repo="tms", issue=83, agent="pi", + provider="", model="MiniMax-M3", + dispatch_type="fix", cwd="/tmp/wt", session_name="fix-tms#83", + ) + params = _last_insert_params(cursor) + # provider column (index 7 in INSERT: id,created_at,event_ts,event_type, + # repo,issue,agent,provider,model,...) + assert params[7] == "minimax" + assert params[8] == "MiniMax-M3" + + def test_explicit_provider_and_model_preserved(self): + """Explicit provider + model passed through unchanged.""" + with patch("psycopg2.connect") as mock_connect: + cursor = _capture_insert(mock_connect) + from tmq.events import log_dispatch + + log_dispatch( + repo="tms", issue=83, agent="pi", + provider="custom-p", model="custom-m", + dispatch_type="fix", cwd="/tmp/wt", session_name="fix-tms#83", + ) + params = _last_insert_params(cursor) + assert params[7] == "custom-p" + assert params[8] == "custom-m" + + @pytest.mark.parametrize( + ("model", "expected_provider"), + [ + ("deepseek-v4-pro", "deepseek"), + ("MiniMax-M3", "minimax"), + ("MiniMax-M3.5", "minimax"), + ("glm-5.2", "zai"), + ], + ) + def test_each_known_model_gets_correct_provider(self, model, expected_provider): + """Every fleet model maps to its known provider (tms#73 ports).""" + with patch("psycopg2.connect") as mock_connect: + cursor = _capture_insert(mock_connect) + from tmq.events import log_dispatch + + log_dispatch( + repo="tms", issue=83, agent="pi", + provider="", model=model, + dispatch_type="fix", cwd="/tmp/wt", session_name="fix-tms#83", + ) + params = _last_insert_params(cursor) + assert params[7] == expected_provider + assert params[8] == model + + def test_explicit_model_ignores_default_provider(self): + """An explicit --model must NOT combine with a stale default provider. + + If the caller sets provider='stale-default' (from settings) but no + explicit --model, defaults are consulted. But with an explicit model, + the provider is derived from the model, not the stale default. + """ + with patch("psycopg2.connect") as mock_connect: + cursor = _capture_insert(mock_connect) + from tmq.events import log_dispatch + + # Simulate: settings.pi_provider='stale-default' but args.model='MiniMax-M3' + log_dispatch( + repo="tms", issue=83, agent="pi", + provider="stale-default", model="MiniMax-M3", + dispatch_type="fix", cwd="/tmp/wt", session_name="fix-tms#83", + ) + params = _last_insert_params(cursor) + assert params[7] == "minimax", ( + "explicit model should override stale default provider" + ) + assert params[8] == "MiniMax-M3" + + def test_empty_both_resolves_from_defaults(self): + """Empty provider + model resolves from pi settings defaults.""" + with patch("psycopg2.connect") as mock_connect: + cursor = _capture_insert(mock_connect) + with patch("tmq.events._resolve_default_model", + return_value=("deepseek", "deepseek-v4-pro")): + from tmq.events import log_dispatch + + log_dispatch( + repo="tms", issue=83, agent="pi", + provider="", model="", + dispatch_type="fix", cwd="/tmp/wt", session_name="fix-tms#83", + ) + params = _last_insert_params(cursor) + assert params[7] == "deepseek" + assert params[8] == "deepseek-v4-pro" + + +class TestLogDispatchFailedProviderResolution: + """Provider/model provenance for log_dispatch_failed (tms#83).""" + + def test_explicit_model_derives_provider_on_failure(self): + """dispatch_failed must also resolve provider from model.""" + with patch("psycopg2.connect") as mock_connect: + cursor = _capture_insert(mock_connect) + from tmq.events import log_dispatch_failed + + log_dispatch_failed( + repo="tms", issue=83, agent="pi", + provider="", model="MiniMax-M3.5", + dispatch_type="fix", reason="aoe add failed", + ) + params = _last_insert_params(cursor) + assert params[7] == "minimax" + assert params[8] == "MiniMax-M3.5" + + def test_failed_uses_defaults_when_empty(self): + """Failed dispatch with no model resolves from pi settings.""" + with patch("psycopg2.connect") as mock_connect: + cursor = _capture_insert(mock_connect) + with patch("tmq.events._resolve_default_model", + return_value=("zai", "glm-5.2")): + from tmq.events import log_dispatch_failed + + log_dispatch_failed( + repo="tms", issue=83, agent="pi", + provider="", model="", + dispatch_type="fix", reason="timeout", + ) + params = _last_insert_params(cursor) + assert params[7] == "zai" + assert params[8] == "glm-5.2"