diff --git a/loopx/codex_app_thread_activity.py b/loopx/codex_app_thread_activity.py index f7af51b876..f7e85865dc 100644 --- a/loopx/codex_app_thread_activity.py +++ b/loopx/codex_app_thread_activity.py @@ -64,6 +64,7 @@ def codex_homes(env: Mapping[str, str] | None = None, user_home: Path | None = N ``LOOPX_CODEX_HOMES`` (``os.pathsep``-separated) is used verbatim when set. Otherwise ``CODEX_HOME``, ``~/.codex`` and sibling ``~/.codex-*`` homes are searched, because Codex App installs can each keep their own home. + Preserve distinct path names: callers also use them as profile aliases. Relative entries use the process working directory when their store is read. """ @@ -328,7 +329,14 @@ def _installed_automation_intervals( intervals: dict[tuple[str, str, str], int] = {} ambiguous: set[tuple[str, str, str]] = set() + identities: set[Path] = set() for home in codex_homes() if homes is None else homes: + # Discovery retains logical profile names; read each physical store only + # once here so an alias cannot masquerade as a second installed heartbeat. + identity = home.resolve() + if identity in identities: + continue + identities.add(identity) for path in sorted((home / "automations").glob("*/automation.toml")): try: item = tomllib.loads(path.read_text(encoding="utf-8")) diff --git a/tests/test_chat_codex_defaults.py b/tests/test_chat_codex_defaults.py index ad2eea458a..a31a2af4bc 100644 --- a/tests/test_chat_codex_defaults.py +++ b/tests/test_chat_codex_defaults.py @@ -1,11 +1,21 @@ """Configured Goal Chat defaults use native settings without retargeting work.""" +import os +import subprocess +import sys +from pathlib import Path from types import SimpleNamespace import pytest from loopx.capabilities.machine_configuration.builtins import build_builtin_machine_configuration_registry -from loopx.capabilities.machine_configuration.store import configure_machine_configuration -from loopx.capabilities.native_chat.machine_defaults import CODEX_CHAT_DEFAULTS_SCHEMA_VERSION, normalize_codex_chat_defaults +from loopx.capabilities.configuration_ui import capability_configuration_editor +from loopx.capabilities.machine_configuration.store import configure_machine_configuration, read_machine_configuration +from loopx.capabilities.native_chat.machine_defaults import ( + CODEX_CHAT_DEFAULTS_SCHEMA_VERSION, + codex_profile_home, + codex_profile_options, + normalize_codex_chat_defaults, +) from loopx.chat_runtime import ChatRuntimeController, CodexAppServerAdapter from loopx.chat_store import ChatSessionStore @@ -18,6 +28,76 @@ def configure(root, **fields): return configure_machine_configuration(**kwargs, execute=True, expected_plan_revision=preview["plan_revision"]) +@pytest.mark.parametrize("selection", ["configured", "sibling", "explicit"]) +def test_saved_profile_alias_opens_ordinary_conversation(tmp_path, monkeypatch, request, selection): + """Physical scan dedup must preserve every supported profile's logical name. + + Run the real configuration transaction, editor and conversation path. Only + the final native adapter launch is replaced; no model or live store is used. + """ + home = tmp_path / ".codex" + home.mkdir() + native_config = 'model = "fixture-model"\n' + (home / "config.toml").write_text(native_config, encoding="utf-8") + alias = tmp_path / ".codex-alias" + if os.name == "nt": + created = subprocess.run( + ["cmd.exe", "/d", "/c", "mklink", "/J", str(alias), str(home)], + capture_output=True, + ) + assert created.returncode == 0, created.stderr or created.stdout + request.addfinalizer(alias.rmdir) + else: + alias.symlink_to(home, target_is_directory=True) + monkeypatch.setattr(Path, "home", lambda: tmp_path) + monkeypatch.setenv("LOOPX_CHAT_CODEX_HOME", str(home)) + monkeypatch.delenv("CODEX_HOME", raising=False) + monkeypatch.delenv("LOOPX_CODEX_HOMES", raising=False) + if selection == "configured": + monkeypatch.setenv("CODEX_HOME", str(alias)) + elif selection == "explicit": + monkeypatch.setenv("LOOPX_CODEX_HOMES", os.pathsep.join(map(str, [alias, home]))) + profile = "codex-alias" if selection == "sibling" else "codex" + root = tmp_path / "runtime" + assert configure(root, codex_profile=profile)["status"] == "applied" + stored = read_machine_configuration(root, registry=build_builtin_machine_configuration_registry()) + defaults = stored["namespaces"]["codex_chat"] + assert defaults["codex_profile"] == profile + calls = [] + + def start(**kwargs): + calls.append(kwargs) + return CodexAppServerAdapter(SimpleNamespace( + model="fixture-model", resolved_model="fixture-model", resolved_reasoning_effort=None, + service_tier=None, read_only_review=False, thread_id="fixture-native-thread", + process=SimpleNamespace(poll=lambda: None), close=lambda: None, + )) + + monkeypatch.setattr(CodexAppServerAdapter, "start", start) + # An existing executable satisfies the real readiness probe without running it. + runtime = ChatRuntimeController(store=ChatSessionStore(root), codex_bin=sys.executable) + try: + session, reused = runtime.open_session( + goal_id="fixture", agent_id="codex", work_dir=tmp_path, objective="Inspect.", mode="new", + ) + assert not reused + assert codex_profile_options() == ["codex", "codex-alias"] + editor = capability_configuration_editor("codex_chat") + assert next(field for field in editor["fields"] if field["key"] == "codex_profile")["options"] == [ + "codex", "codex-alias", + ] + assert codex_profile_home(home, defaults) == home.resolve() + assert session["codex_home"] == str(home.resolve()) + assert session["codex_chat_configuration"]["codex_profile"] == profile + assert session["upstream_thread_id"] == "fixture-native-thread" + assert len(calls) == 1 and calls[0]["codex_home"] == home.resolve() + assert runtime.store.load_session(session["session_id"])["codex_home"] == str(home.resolve()) + assert (home / "config.toml").read_text(encoding="utf-8") == native_config + assert read_machine_configuration(root, registry=build_builtin_machine_configuration_registry()) == stored + finally: + runtime.close() + + def runtime_fixture(tmp_path, monkeypatch): base, gpt = tmp_path / ".codex-ds", tmp_path / ".codex-gpt" base.mkdir() @@ -38,6 +118,7 @@ def start(**kwargs): return CodexAppServerAdapter(SimpleNamespace(model=settings.get("model") or "inherited-model", resolved_model=settings.get("model") or "inherited-model", resolved_reasoning_effort=settings.get("reasoning_effort"), service_tier=settings.get("service_tier"), + read_only_review=False, thread_id=f"native-{len(calls)}", process=SimpleNamespace(poll=lambda: None), close=lambda: None)) monkeypatch.setattr(runtime, "_start_adapter", start) diff --git a/tests/test_host_thread_activity.py b/tests/test_host_thread_activity.py index f6bc5cdd1c..6dcfa887a3 100644 --- a/tests/test_host_thread_activity.py +++ b/tests/test_host_thread_activity.py @@ -1,7 +1,9 @@ from __future__ import annotations import json +import os import sqlite3 +import subprocess from datetime import datetime, timedelta, timezone from pathlib import Path from types import SimpleNamespace @@ -502,6 +504,75 @@ def _install_automation( ) +@pytest.mark.parametrize("selection", ["configured", "sibling", "explicit"]) +def test_aliased_homes_preserve_one_installed_heartbeat_cadence( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, request: pytest.FixtureRequest, + selection: str, +) -> None: + home = CodexHome(tmp_path / ".codex") + alias = tmp_path / ".codex-alias" + if os.name == "nt": + created = subprocess.run( + ["cmd.exe", "/d", "/c", "mklink", "/J", str(alias), str(home.root)], + capture_output=True, + ) + assert created.returncode == 0, created.stderr or created.stdout + request.addfinalizer(alias.rmdir) + else: + alias.symlink_to(home.root, target_is_directory=True) + other = CodexHome(tmp_path / ".codex-other") + monkeypatch.setattr(Path, "home", lambda: tmp_path) + monkeypatch.delenv("CODEX_HOME", raising=False) + monkeypatch.delenv("LOOPX_CODEX_HOMES", raising=False) + first = home.root + if selection == "configured": + monkeypatch.setenv("CODEX_HOME", str(alias)) + first = alias + elif selection == "explicit": + monkeypatch.setenv( + "LOOPX_CODEX_HOMES", os.pathsep.join(map(str, [alias, home.root, other.root])) + ) + first = alias + _install_automation( + home, automation_id="one", goal_id="bound", agent_id="a", + rrule="FREQ=MINUTELY;INTERVAL=5", + ) + lane = HostDeliveryScope( + goal_id="bound", agent_id="a", host_surface="codex-app", thread_id="t-open" + ) + reads: list[Path] = [] + read_text = Path.read_text + + def record_read(path: Path, *args: Any, **kwargs: Any) -> str: + if path.name == "automation.toml": + reads.append(path) + return read_text(path, *args, **kwargs) + + monkeypatch.setattr(Path, "read_text", record_read) + + assert codex_delivery_expectations([lane]) == { + lane: HostDeliveryExpectation( + expected_interval_minutes=5, source="codex_app_automation_rrule" + ) + } + assert reads == [first / "automations" / "one" / "automation.toml"] + logical_homes = [home.root, alias, other.root] if selection == "sibling" else [alias, home.root, other.root] + assert codex_homes() == logical_homes + # Explicit reader inputs receive the same physical-store protection. + assert codex_delivery_expectations([lane], homes=logical_homes)[lane].expected_interval_minutes == 5 + # Two distinct automations remain ambiguous even when their home has aliases. + _install_automation( + home, automation_id="two", goal_id="bound", agent_id="a", + rrule="FREQ=MINUTELY;INTERVAL=9", + ) + assert codex_delivery_expectations([lane]) == {} + _install_automation( + home, automation_id="two", goal_id="bound", agent_id="a", + rrule="FREQ=MINUTELY;INTERVAL=9", status="PAUSED", + ) + assert codex_delivery_expectations([lane])[lane].expected_interval_minutes == 5 + + def test_delivery_expectations_resolve_only_installed_active_app_automations( tmp_path: Path, ) -> None: