diff --git a/api/routes.py b/api/routes.py index 279b3ca0..7423df08 100644 --- a/api/routes.py +++ b/api/routes.py @@ -18235,6 +18235,53 @@ def _handle_chat_sync(handler, body): ) +def _selected_profile_snapshot_updates( + profile: str | None, + *, + provider, + model, + base_url=None, + no_agent=False, +) -> dict[str, str | None]: + selected_profile = str(profile or "").strip() + if not selected_profile or (provider is not None and model is not None): + return {} + + try: + from api.profiles import profile_env_for_background_worker + from cron.jobs import _compute_provider_model_snapshots + except Exception: + logger.debug("Failed to load cron snapshot repair helpers", exc_info=True) + return {} + + try: + with profile_env_for_background_worker( + selected_profile, + "cron create snapshot", + logger_override=logger, + ): + provider_snapshot, model_snapshot = _compute_provider_model_snapshots( + provider=provider, + model=model, + base_url=base_url, + no_agent=no_agent, + ) + except Exception: + logger.debug( + "Failed to compute selected-profile cron snapshots for %s", + selected_profile, + exc_info=True, + ) + return {} + + updates = {} + if provider is None: + updates["provider_snapshot"] = provider_snapshot + if model is None: + updates["model_snapshot"] = model_snapshot + return updates + + def _handle_cron_create(handler, body): try: require(body, "prompt", "schedule") @@ -18245,18 +18292,27 @@ def _handle_cron_create(handler, body): profile = _normalize_cron_profile_value(body.get("profile")) toast_notifications = body.get("toast_notifications") is not False + requested_model = body.get("model") or None + requested_provider = body.get("provider") or None job = create_job( prompt=body["prompt"], schedule=body["schedule"], name=body.get("name") or None, deliver=body.get("deliver") or "local", skills=body.get("skills") or [], - model=body.get("model") or None, - provider=body.get("provider") or None, + model=requested_model, + provider=requested_provider, ) post_create_updates = {} if profile is not None: post_create_updates["profile"] = profile + post_create_updates.update( + _selected_profile_snapshot_updates( + profile, + provider=requested_provider, + model=requested_model, + ) + ) if not toast_notifications: post_create_updates["toast_notifications"] = False if post_create_updates: diff --git a/tests/test_issue5130_cron_profile_snapshot.py b/tests/test_issue5130_cron_profile_snapshot.py new file mode 100644 index 00000000..55db6b33 --- /dev/null +++ b/tests/test_issue5130_cron_profile_snapshot.py @@ -0,0 +1,358 @@ +"""Regression coverage for issue #5130 cron create-time profile snapshots.""" + +import io +import json +import sys +import types +from contextlib import contextmanager +from pathlib import Path + +import pytest + + +class _JSONHandler: + def __init__(self): + self.status = None + self.headers = {} + self.response_headers = [] + self.wfile = io.BytesIO() + + def send_response(self, status): + self.status = status + + def send_header(self, key, value): + self.response_headers.append((key, value)) + + def end_headers(self): + pass + + +def _payload(handler): + return json.loads(handler.wfile.getvalue().decode("utf-8")) + + +def _install_fake_cron_modules(monkeypatch, cron_jobs): + cron_pkg = types.ModuleType("cron") + cron_pkg.__path__ = [] + monkeypatch.setitem(sys.modules, "cron", cron_pkg) + monkeypatch.setitem(sys.modules, "cron.jobs", cron_jobs) + + +def test_cron_create_recomputes_unpinned_provider_snapshot_under_selected_profile(monkeypatch): + import api.profiles as profiles + import api.routes as routes + + calls = [] + profile_events = [] + created = { + "id": "job5130", + "name": "Selected profile job", + "prompt": "ping", + "schedule": {"kind": "interval", "minutes": 60}, + "provider_snapshot": "openai-api", + } + + cron_jobs = types.ModuleType("cron.jobs") + cron_jobs.JOBS_FILE = Path("C:/owning/cron/jobs.json") + cron_jobs.CRON_DIR = Path("C:/owning/cron") + cron_jobs.OUTPUT_DIR = Path("C:/owning/cron/output") + + def create_job(**kwargs): + calls.append(("create", kwargs)) + return dict(created) + + def update_job(job_id, updates): + calls.append(("update", job_id, updates)) + return {**created, **updates} + + def compute_snapshots(*, provider, model, base_url, no_agent): + calls.append( + ( + "compute", + { + "provider": provider, + "model": model, + "base_url": base_url, + "no_agent": no_agent, + }, + ) + ) + return "openai-codex", "gpt-5.4" + + @contextmanager + def fake_profile_env(profile, purpose, logger_override=None): + profile_events.append(("enter", profile, purpose, logger_override is routes.logger)) + yield + profile_events.append(("exit", profile, purpose)) + + cron_jobs.create_job = create_job + cron_jobs.update_job = update_job + cron_jobs._compute_provider_model_snapshots = compute_snapshots + + monkeypatch.setattr(profiles, "list_profiles_api", lambda: [{"name": "research"}]) + monkeypatch.setattr(profiles, "profile_env_for_background_worker", fake_profile_env) + _install_fake_cron_modules(monkeypatch, cron_jobs) + + handler = _JSONHandler() + routes._handle_cron_create( + handler, + { + "name": "Selected profile job", + "prompt": "ping", + "schedule": "every 60m", + "deliver": "local", + "profile": "research", + "model": "gpt-5.4", + }, + ) + + body = _payload(handler) + assert handler.status == 200 + assert body["ok"] is True + assert calls[0] == ( + "create", + { + "prompt": "ping", + "schedule": "every 60m", + "name": "Selected profile job", + "deliver": "local", + "skills": [], + "model": "gpt-5.4", + "provider": None, + }, + ) + assert calls[1] == ( + "compute", + { + "provider": None, + "model": "gpt-5.4", + "base_url": None, + "no_agent": False, + }, + ) + assert calls[2] == ( + "update", + "job5130", + {"profile": "research", "provider_snapshot": "openai-codex"}, + ) + assert profile_events == [ + ("enter", "research", "cron create snapshot", True), + ("exit", "research", "cron create snapshot"), + ] + + +def test_cron_create_with_blank_profile_keeps_ambient_snapshot_semantics(monkeypatch): + import api.profiles as profiles + import api.routes as routes + + calls = [] + profile_events = [] + created = { + "id": "job5130", + "name": "Ambient job", + "prompt": "ping", + "schedule": {"kind": "interval", "minutes": 60}, + "provider_snapshot": "openai-api", + } + + cron_jobs = types.ModuleType("cron.jobs") + cron_jobs.create_job = lambda **kwargs: calls.append(("create", kwargs)) or dict(created) + cron_jobs.update_job = lambda *args, **kwargs: pytest.fail("blank profile should not update the job") + cron_jobs._compute_provider_model_snapshots = ( + lambda **kwargs: pytest.fail("blank profile should not recompute snapshots") + ) + + @contextmanager + def fake_profile_env(*args, **kwargs): + profile_events.append(("enter", args, kwargs)) + yield + + monkeypatch.setattr(profiles, "list_profiles_api", lambda: [{"name": "research"}]) + monkeypatch.setattr(profiles, "profile_env_for_background_worker", fake_profile_env) + _install_fake_cron_modules(monkeypatch, cron_jobs) + + handler = _JSONHandler() + routes._handle_cron_create( + handler, + { + "name": "Ambient job", + "prompt": "ping", + "schedule": "every 60m", + "deliver": "local", + "profile": "", + "model": "gpt-5.4", + }, + ) + + body = _payload(handler) + assert handler.status == 200 + assert body["ok"] is True + assert body["job"]["provider_snapshot"] == "openai-api" + assert calls == [ + ( + "create", + { + "prompt": "ping", + "schedule": "every 60m", + "name": "Ambient job", + "deliver": "local", + "skills": [], + "model": "gpt-5.4", + "provider": None, + }, + ) + ] + assert profile_events == [] + + +def test_cron_create_with_explicit_provider_and_model_skips_snapshot_override(monkeypatch): + import api.profiles as profiles + import api.routes as routes + + calls = [] + created = { + "id": "job5130", + "name": "Pinned job", + "prompt": "ping", + "schedule": {"kind": "interval", "minutes": 60}, + "provider_snapshot": "openai-codex", + "model_snapshot": "gpt-5.4", + } + + cron_jobs = types.ModuleType("cron.jobs") + cron_jobs.create_job = lambda **kwargs: calls.append(("create", kwargs)) or dict(created) + cron_jobs.update_job = lambda job_id, updates: calls.append(("update", job_id, updates)) or { + **created, + **updates, + } + cron_jobs._compute_provider_model_snapshots = ( + lambda **kwargs: pytest.fail("explicit provider/model should not recompute snapshots") + ) + + @contextmanager + def fake_profile_env(*args, **kwargs): + pytest.fail("explicit provider/model should not enter selected-profile snapshot context") + yield + + monkeypatch.setattr(profiles, "list_profiles_api", lambda: [{"name": "research"}]) + monkeypatch.setattr(profiles, "profile_env_for_background_worker", fake_profile_env) + _install_fake_cron_modules(monkeypatch, cron_jobs) + + handler = _JSONHandler() + routes._handle_cron_create( + handler, + { + "name": "Pinned job", + "prompt": "ping", + "schedule": "every 60m", + "deliver": "local", + "profile": "research", + "provider": "openai-codex", + "model": "gpt-5.4", + }, + ) + + body = _payload(handler) + assert handler.status == 200 + assert body["ok"] is True + assert calls == [ + ( + "create", + { + "prompt": "ping", + "schedule": "every 60m", + "name": "Pinned job", + "deliver": "local", + "skills": [], + "model": "gpt-5.4", + "provider": "openai-codex", + }, + ), + ("update", "job5130", {"profile": "research"}), + ] + + +def test_selected_profile_snapshot_helper_never_repoints_cron_store_globals(monkeypatch): + import api.profiles as profiles + from api.routes import _selected_profile_snapshot_updates + + profile_events = [] + cron_jobs = types.ModuleType("cron.jobs") + cron_jobs.JOBS_FILE = Path("C:/owning/cron/jobs.json") + cron_jobs.CRON_DIR = Path("C:/owning/cron") + cron_jobs.OUTPUT_DIR = Path("C:/owning/cron/output") + original_paths = ( + cron_jobs.JOBS_FILE, + cron_jobs.CRON_DIR, + cron_jobs.OUTPUT_DIR, + ) + + def compute_snapshots(*, provider, model, base_url, no_agent): + profile_events.append( + ( + "compute", + cron_jobs.JOBS_FILE, + cron_jobs.CRON_DIR, + cron_jobs.OUTPUT_DIR, + provider, + model, + base_url, + no_agent, + ) + ) + return "openai-codex", "gpt-5.4" + + @contextmanager + def fake_profile_env(profile, purpose, logger_override=None): + profile_events.append( + ("enter", profile, purpose, cron_jobs.JOBS_FILE, cron_jobs.CRON_DIR, cron_jobs.OUTPUT_DIR) + ) + yield + profile_events.append( + ("exit", profile, purpose, cron_jobs.JOBS_FILE, cron_jobs.CRON_DIR, cron_jobs.OUTPUT_DIR) + ) + + cron_jobs._compute_provider_model_snapshots = compute_snapshots + + monkeypatch.setattr(profiles, "profile_env_for_background_worker", fake_profile_env) + _install_fake_cron_modules(monkeypatch, cron_jobs) + + updates = _selected_profile_snapshot_updates( + "research", + provider=None, + model=None, + ) + + assert updates == { + "provider_snapshot": "openai-codex", + "model_snapshot": "gpt-5.4", + } + assert (cron_jobs.JOBS_FILE, cron_jobs.CRON_DIR, cron_jobs.OUTPUT_DIR) == original_paths + assert profile_events == [ + ( + "enter", + "research", + "cron create snapshot", + original_paths[0], + original_paths[1], + original_paths[2], + ), + ( + "compute", + original_paths[0], + original_paths[1], + original_paths[2], + None, + None, + None, + False, + ), + ( + "exit", + "research", + "cron create snapshot", + original_paths[0], + original_paths[1], + original_paths[2], + ), + ]