From 2a1febdeef2b26e21508a413d05596651438c449 Mon Sep 17 00:00:00 2001 From: Ocean Bennett <204957658+undergroundrap@users.noreply.github.com> Date: Fri, 5 Jun 2026 07:13:13 -0400 Subject: [PATCH] fix(actions): scope scheduled model resolution to owner (#2773) --- src/builtin_actions.py | 14 +- tests/test_builtin_actions_owner_scope.py | 154 ++++++++++++++++++++++ 2 files changed, 161 insertions(+), 7 deletions(-) create mode 100644 tests/test_builtin_actions_owner_scope.py diff --git a/src/builtin_actions.py b/src/builtin_actions.py index d532603a6..b1687000f 100644 --- a/src/builtin_actions.py +++ b/src/builtin_actions.py @@ -593,9 +593,9 @@ async def action_classify_events(owner: str, **kwargs) -> Tuple[str, bool]: if not events: return "No upcoming events to classify", True - llm_url, llm_model, llm_headers = resolve_endpoint("utility") + llm_url, llm_model, llm_headers = resolve_endpoint("utility", owner=owner) if not llm_url: - llm_url, llm_model, llm_headers = resolve_endpoint("default") + llm_url, llm_model, llm_headers = resolve_endpoint("default", owner=owner) llm_available = bool(llm_url and llm_model) # Pull user memories so the LLM has personal context (relationships, @@ -867,9 +867,9 @@ async def action_learn_sender_signatures(owner: str, **kwargs) -> Tuple[str, boo if not eligible: return "All sender sigs already cached (or no eligible senders)", True - url, model, headers = resolve_endpoint("utility") + url, model, headers = resolve_endpoint("utility", owner=owner) if not url or not model: - url, model, headers = resolve_endpoint("default") + url, model, headers = resolve_endpoint("default", owner=owner) if not url or not model: return "No LLM endpoint available", False @@ -1480,12 +1480,12 @@ async def action_check_email_urgency(owner: str, **kwargs) -> Tuple[str, bool]: # ── 1. Resolve LLM candidates (utility primary + utility fallbacks; fall # through to default chat as a last resort). - url, model, headers = resolve_endpoint("utility") + url, model, headers = resolve_endpoint("utility", owner=owner) if not url or not model: - url, model, headers = resolve_endpoint("default") + url, model, headers = resolve_endpoint("default", owner=owner) if not url or not model: return "No LLM endpoint available", False - candidates = [(url, model, headers)] + resolve_utility_fallback_candidates() + candidates = [(url, model, headers)] + resolve_utility_fallback_candidates(owner=owner) # ── 2. Enumerate enabled accounts. Match this task's owner AND fall # back to the legacy "unowned account whose imap_user / from_address diff --git a/tests/test_builtin_actions_owner_scope.py b/tests/test_builtin_actions_owner_scope.py new file mode 100644 index 000000000..446aba86d --- /dev/null +++ b/tests/test_builtin_actions_owner_scope.py @@ -0,0 +1,154 @@ +"""Regression tests for owner-scoped model resolution in scheduled actions.""" + +from datetime import datetime +from types import SimpleNamespace + +import pytest + + +class _Column: + def __eq__(self, _other): + return True + + def __ne__(self, _other): + return True + + def __ge__(self, _other): + return True + + def __le__(self, _other): + return True + + +class _Query: + def __init__(self, rows): + self._rows = rows + + def filter(self, *_args, **_kwargs): + return self + + def limit(self, _limit): + return self + + def all(self): + return list(self._rows) + + +class _Db: + def __init__(self, rows_by_model): + self._rows_by_model = rows_by_model + self.commits = 0 + self.closed = False + + def query(self, model): + return _Query(self._rows_by_model.get(model, [])) + + def commit(self): + self.commits += 1 + + def close(self): + self.closed = True + + +def _resolver_spy(monkeypatch, utility_result=("", "", {}), default_result=("http://llm", "model", {})): + from src import endpoint_resolver + + calls = [] + fallback_calls = [] + + def fake_resolve(kind, *args, **kwargs): + calls.append((kind, kwargs.get("owner"))) + return utility_result if kind == "utility" else default_result + + def fake_fallbacks(*args, **kwargs): + fallback_calls.append(kwargs.get("owner")) + return [] + + monkeypatch.setattr(endpoint_resolver, "resolve_endpoint", fake_resolve) + monkeypatch.setattr(endpoint_resolver, "resolve_utility_fallback_candidates", fake_fallbacks) + return calls, fallback_calls + + +@pytest.mark.asyncio +async def test_classify_events_resolves_llm_for_task_owner(monkeypatch): + from core import database + from src.builtin_actions import action_classify_events + + class FakeCalendarEvent: + dtstart = _Column() + status = _Column() + + event = SimpleNamespace( + summary="Demo presentation", + event_type="work", + importance="high", + color=None, + dtstart=datetime(2026, 1, 1, 9, 0, 0), + location="", + ) + db = _Db({FakeCalendarEvent: [event]}) + calls, _fallback_calls = _resolver_spy(monkeypatch, utility_result=("http://llm", "model", {})) + + monkeypatch.setattr(database, "CalendarEvent", FakeCalendarEvent) + monkeypatch.setattr(database, "SessionLocal", lambda: db) + + message, ok = await action_classify_events("alice") + + assert ok is True + assert "Scanned 1 upcoming event" in message + assert calls == [("utility", "alice")] + assert db.closed is True + + +@pytest.mark.asyncio +async def test_learn_sender_signatures_resolves_llm_for_task_owner(monkeypatch): + from routes import email_helpers + from src.builtin_actions import action_learn_sender_signatures + + class FakeImap: + def select(self, *_args, **_kwargs): + return "OK", [] + + def search(self, *_args, **_kwargs): + return "OK", [b"1 2 3"] + + def fetch(self, _uid, _query): + return "OK", [(None, b"From: Writer \r\n\r\n")] + + def logout(self): + return None + + calls, _fallback_calls = _resolver_spy(monkeypatch, utility_result=("", "", {}), default_result=("", "", {})) + monkeypatch.setattr(email_helpers, "_imap_connect", lambda _account_id=None: FakeImap()) + + message, ok = await action_learn_sender_signatures("alice") + + assert ok is False + assert message == "No LLM endpoint available" + assert calls == [("utility", "alice"), ("default", "alice")] + + +@pytest.mark.asyncio +async def test_check_email_urgency_resolves_llm_candidates_for_task_owner(monkeypatch, tmp_path): + from core import database + from src.builtin_actions import TaskNoop, action_check_email_urgency + + class FakeEmailAccount: + enabled = _Column() + owner = _Column() + imap_user = _Column() + from_address = _Column() + + db = _Db({FakeEmailAccount: []}) + calls, fallback_calls = _resolver_spy(monkeypatch, utility_result=("http://llm", "model", {})) + + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(database, "EmailAccount", FakeEmailAccount) + monkeypatch.setattr(database, "SessionLocal", lambda: db) + + with pytest.raises(TaskNoop, match="no email accounts configured"): + await action_check_email_urgency("alice") + + assert calls == [("utility", "alice")] + assert fallback_calls == ["alice"] + assert db.closed is True