diff --git a/amplifier_app_cli/commands/routing.py b/amplifier_app_cli/commands/routing.py index f353e4c..2d3b89e 100644 --- a/amplifier_app_cli/commands/routing.py +++ b/amplifier_app_cli/commands/routing.py @@ -277,9 +277,18 @@ def _print_name_stem_note(mismatched: list[tuple[str, str]]) -> None: def _display_path(path: Path) -> str: - """Render a path with ``~`` for the home directory, for readable output.""" + """Render a path with ``~`` for the home directory, for readable output. + + The ``~/`` abbreviation is a POSIX-style spelling, so the remainder is + rendered with forward slashes on every platform (``as_posix``). Without + that, Windows produced the mixed form ``~/.amplifier\\routing\\openai.yaml`` + -- the separator switched mid-path -- in `routing list`/`show` output and + in the JSON ``matrix_file`` field, which the CLI's own help text spells as + ``~/.amplifier/routing/...``. A path outside the home directory is + returned as-is, in its native form, because it is not being abbreviated. + """ try: - return f"~/{path.relative_to(Path.home())}" + return f"~/{path.relative_to(Path.home()).as_posix()}" except ValueError: return str(path) diff --git a/tests/conftest.py b/tests/conftest.py index be57d8f..273fcfc 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -38,3 +38,24 @@ def reset_skill_shortcuts(): CommandProcessor.SKILL_SHORTCUTS.clear() yield CommandProcessor.SKILL_SHORTCUTS.clear() + + +# --------------------------------------------------------------------------- +# Home-directory isolation that actually holds on Windows. +# +# ``Path.home()`` is ``os.path.expanduser("~")``. On POSIX that reads HOME; on +# Windows ``ntpath.expanduser`` reads USERPROFILE (then HOMEDRIVE+HOMEPATH) and +# NEVER consults HOME. So ``monkeypatch.setenv("HOME", tmp_path)`` alone -- the +# idiom every SessionStore-touching test used -- isolated nothing on Windows: +# tests wrote real records into the runner's (or a developer's) actual +# ``~/.amplifier/projects//sessions/``, and two tests asserting that a +# record did NOT exist found one left by a previous test. Set both. +# --------------------------------------------------------------------------- + + +@pytest.fixture +def isolated_home(tmp_path, monkeypatch) -> Path: + """Point ``Path.home()`` at ``tmp_path`` on every platform; returns it.""" + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("USERPROFILE", str(tmp_path)) + return tmp_path diff --git a/tests/test_resume_preserves_provider_promotion.py b/tests/test_resume_preserves_provider_promotion.py index f73f3e3..3a9a53d 100644 --- a/tests/test_resume_preserves_provider_promotion.py +++ b/tests/test_resume_preserves_provider_promotion.py @@ -65,6 +65,12 @@ def anyio_backend(): return "asyncio" +@pytest.fixture(autouse=True) +def _home(isolated_home): + """Every test here reaches SessionStore() -> Path.home(). See conftest.isolated_home.""" + return isolated_home + + # --------------------------------------------------------------------------- # Fixtures modelled on the rc0 capture # 20260901-rebaseline/runs/val-rb-oai-sol-xhigh-s1-01 @@ -260,7 +266,6 @@ async def test_resumed_leg_keeps_its_model_role_promotion( resolution`` was reachable only from spawn. The resumed leg resolved to the settings priority-0 provider (sol), exactly as captured. """ - monkeypatch.setenv("HOME", str(tmp_path)) store = SessionStore() session_id = "test-resume-keeps-promotion" metadata = _base_metadata( @@ -296,7 +301,6 @@ async def test_promotion_survives_with_no_recoverable_preferences( The persisted promotion must survive the credential refresh on its own, and the credential must still be refreshed. """ - monkeypatch.setenv("HOME", str(tmp_path)) store = SessionStore() session_id = "test-resume-fix-a-isolated" store.save(session_id, [], _base_metadata(session_id)) @@ -323,7 +327,6 @@ async def test_explicit_preferences_argument_is_honoured( This is the hop the caller (tool-delegate's resume path) gains: it can now pass the same preferences it passes at spawn. """ - monkeypatch.setenv("HOME", str(tmp_path)) store = SessionStore() session_id = "test-resume-explicit-prefs" store.save(session_id, [], _base_metadata(session_id)) @@ -350,7 +353,6 @@ async def test_preferences_recovered_from_persisted_mount_plan( which is what the rc0 capture observed ("still luna ... simply never consulted again"). """ - monkeypatch.setenv("HOME", str(tmp_path)) store = SessionStore() session_id = "test-resume-prefs-from-config" metadata = _base_metadata(session_id) @@ -373,7 +375,6 @@ async def test_preference_config_is_reasserted_on_resume( Settles rc0 section 4.6 from the other direction: the preference's own ``reasoning_effort`` -- not settings' -- governs the resumed leg. """ - monkeypatch.setenv("HOME", str(tmp_path)) store = SessionStore() session_id = "test-resume-pref-config" metadata = _base_metadata( @@ -405,7 +406,6 @@ async def test_model_role_is_written_into_the_resumed_config( The resumed leg's routing hook resolves roles from config; without this the role the delegate was spawned with never reaches it. """ - monkeypatch.setenv("HOME", str(tmp_path)) store = SessionStore() session_id = "test-resume-model-role" store.save(session_id, [], _base_metadata(session_id)) @@ -423,7 +423,6 @@ async def test_unhonourable_promotion_emits_a_fallback_event( leg still has to run on something -- but it must SAY so, naming the cause and the provider/model it actually landed on. """ - monkeypatch.setenv("HOME", str(tmp_path)) store = SessionStore() session_id = "test-resume-fallback-event" metadata = _base_metadata( @@ -462,7 +461,6 @@ async def test_no_preferences_leaves_the_plan_byte_identical( resumes were affected, because a plan with no promotion has nothing to preserve and nothing to rebuild. """ - monkeypatch.setenv("HOME", str(tmp_path)) store = SessionStore() session_id = "test-resume-no-prefs" metadata = _base_metadata(session_id) diff --git a/tests/test_resume_system_prompt.py b/tests/test_resume_system_prompt.py index 0c43dd3..4300cf7 100644 --- a/tests/test_resume_system_prompt.py +++ b/tests/test_resume_system_prompt.py @@ -47,6 +47,12 @@ def anyio_backend(): return "asyncio" +@pytest.fixture(autouse=True) +def _home(isolated_home): + """Every test here reaches SessionStore() -> Path.home(). See conftest.isolated_home.""" + return isolated_home + + class _FakeContextWithFactory: """Stands in for context-simple: supports the factory-based system prompt.""" @@ -154,7 +160,6 @@ async def test_resume_reregisters_system_prompt_via_factory( FAILS BEFORE THE FIX: resume_sub_session never calls set_system_prompt_factory at all, so fake_context.factory stays None. """ - monkeypatch.setenv("HOME", str(tmp_path)) store = SessionStore() session_id = "test-resume-system-prompt-factory" @@ -188,7 +193,6 @@ async def test_resume_adds_system_message_when_no_factory_support( add_message() system-role message instead (mirrors the spawn path's own hasattr-gated fallback). """ - monkeypatch.setenv("HOME", str(tmp_path)) store = SessionStore() session_id = "test-resume-system-prompt-fallback" @@ -223,7 +227,6 @@ async def test_resume_falls_back_to_merged_config_agents_map( inherit-as-is overlay, or a session saved before agent_overlay existed), fall back to config.agents[].instruction. """ - monkeypatch.setenv("HOME", str(tmp_path)) store = SessionStore() session_id = "test-resume-system-prompt-config-fallback" @@ -248,7 +251,6 @@ async def test_resume_with_no_recoverable_instruction_warns_but_succeeds( succeeds. Today this failure mode is completely silent; the fix must surface it rather than leaving the resumed session mute. """ - monkeypatch.setenv("HOME", str(tmp_path)) store = SessionStore() session_id = "test-resume-system-prompt-missing" diff --git a/tests/test_routing_shadowing.py b/tests/test_routing_shadowing.py index 9da041f..72bfa63 100644 --- a/tests/test_routing_shadowing.py +++ b/tests/test_routing_shadowing.py @@ -301,7 +301,11 @@ def test_json_carries_hooks_routing_provenance_fields(self, tmp_path): assert source["matrix_name"] == "openai" assert source["matrix_source"] == "user" assert source["matrix_shadowed"] is True - assert source["matrix_path"].endswith("/.amplifier/routing/openai.yaml") + # matrix_path is a native absolute path (backslashes on Windows) -- + # compare as a Path, not as a POSIX string. + assert ( + Path(source["matrix_path"]) == tmp_path / ".amplifier/routing/openai.yaml" + ) assert len(source["shadowed_paths"]) == 1 assert "amplifier-bundle-routing-matrix-test" in source["shadowed_paths"][0] diff --git a/tests/test_routing_winner_selection.py b/tests/test_routing_winner_selection.py index 59dfc8f..db0d834 100644 --- a/tests/test_routing_winner_selection.py +++ b/tests/test_routing_winner_selection.py @@ -343,7 +343,12 @@ def test_shadowing_marker_still_names_the_same_winner(self, tmp_path): rows = _rows(tmp_path) source = rows["openai"]["routing_source"] - assert source["matrix_path"].endswith("/.amplifier/routing/openai.yaml") + # matrix_path is a native absolute path (backslashes on Windows) -- + # compare as a Path, not as a POSIX string. matrix_file is the display + # form and IS POSIX-spelled on every platform (see _display_path). + assert ( + Path(source["matrix_path"]) == tmp_path / ".amplifier/routing/openai.yaml" + ) assert rows["openai"]["matrix_file"] == "~/.amplifier/routing/openai.yaml" assert len(source["shadowed_paths"]) == 1 assert "amplifier-bundle-routing-matrix-test" in source["shadowed_paths"][0] diff --git a/tests/test_session_spawner.py b/tests/test_session_spawner.py index a92ff7d..92ce51e 100644 --- a/tests/test_session_spawner.py +++ b/tests/test_session_spawner.py @@ -48,6 +48,12 @@ def anyio_backend(): return "asyncio" +@pytest.fixture(autouse=True) +def _home(isolated_home): + """Every test here reaches SessionStore() -> Path.home(). See conftest.isolated_home.""" + return isolated_home + + class TestGenerateSubSessionId: def _assert_format( self, @@ -200,14 +206,12 @@ class TestResumeErrorHandling: async def test_resume_nonexistent_session_fails(self, tmp_path, monkeypatch): """Test that resuming non-existent session raises FileNotFoundError.""" - monkeypatch.setenv("HOME", str(tmp_path)) with pytest.raises(FileNotFoundError, match="not found.*may have expired"): await resume_sub_session("fake-session-id", "Test instruction") async def test_resume_with_missing_config(self, tmp_path, monkeypatch): """Test that resume fails gracefully when metadata lacks config.""" - monkeypatch.setenv("HOME", str(tmp_path)) # Use default SessionStore (will use HOME/.amplifier/projects/...) store = SessionStore() @@ -230,7 +234,6 @@ async def test_resume_with_missing_config(self, tmp_path, monkeypatch): async def test_resume_with_corrupted_metadata_file(self, tmp_path, monkeypatch): """Test that resume handles corrupted metadata.json gracefully.""" - monkeypatch.setenv("HOME", str(tmp_path)) # Use default SessionStore (will resolve to HOME/.amplifier/projects/...) store = SessionStore() @@ -397,7 +400,6 @@ async def test_resume_registers_session_spawn_capability( """ from unittest.mock import AsyncMock, MagicMock, patch - monkeypatch.setenv("HOME", str(tmp_path)) # Create a valid session to resume store = SessionStore() @@ -469,7 +471,6 @@ async def test_resume_restores_working_dir_capability(self, tmp_path, monkeypatc """Test that resume_sub_session restores session.working_dir from metadata.""" from unittest.mock import AsyncMock, MagicMock, patch - monkeypatch.setenv("HOME", str(tmp_path)) # Create session with working_dir store = SessionStore() @@ -535,7 +536,6 @@ async def test_resume_without_working_dir_uses_cwd_fallback( """ from unittest.mock import AsyncMock, MagicMock, patch - monkeypatch.setenv("HOME", str(tmp_path)) # Create session WITHOUT working_dir store = SessionStore() @@ -613,7 +613,6 @@ async def test_spawn_result_includes_status_and_turn_count( from amplifier_app_cli.session_spawner import spawn_sub_session - monkeypatch.setenv("HOME", str(tmp_path)) # --- parent session mock --- parent_coordinator = MagicMock() @@ -738,7 +737,6 @@ async def test_spawn_result_defaults_when_no_hook_fires( from amplifier_app_cli.session_spawner import spawn_sub_session - monkeypatch.setenv("HOME", str(tmp_path)) # --- parent session mock --- parent_coordinator = MagicMock() @@ -833,7 +831,6 @@ async def test_resume_result_includes_status_and_turn_count( """Test that resume_sub_session returns status and turn_count from orchestrator:complete.""" from unittest.mock import AsyncMock, MagicMock, patch - monkeypatch.setenv("HOME", str(tmp_path)) # Create a valid session to resume store = SessionStore() @@ -1031,7 +1028,6 @@ async def test_session_metadata_injected_into_child_config( from amplifier_app_cli.session_spawner import spawn_sub_session - monkeypatch.setenv("HOME", str(tmp_path)) # Track the config passed to AmplifierSession constructor captured_config: dict = {} @@ -1195,7 +1191,6 @@ async def test_uses_agent_config_prefs_when_caller_passes_none( from amplifier_app_cli.session_spawner import spawn_sub_session - monkeypatch.setenv("HOME", str(tmp_path)) apply_called_with = {} @@ -1314,7 +1309,6 @@ async def test_explicit_prefs_take_precedence_over_agent_config_prefs( from amplifier_app_cli.session_spawner import spawn_sub_session from amplifier_foundation.spawn_utils import ProviderPreference - monkeypatch.setenv("HOME", str(tmp_path)) apply_called_with = {} @@ -1425,7 +1419,6 @@ async def test_no_prefs_no_agent_config_prefs_skips_apply( from amplifier_app_cli.session_spawner import spawn_sub_session - monkeypatch.setenv("HOME", str(tmp_path)) apply_call_count = {"n": 0} @@ -1533,7 +1526,6 @@ async def test_routing_capability_propagated_to_child(self, tmp_path, monkeypatc from amplifier_app_cli.session_spawner import spawn_sub_session - monkeypatch.setenv("HOME", str(tmp_path)) fake_routing = {"matrix": "balanced", "overrides": {"coding": "anthropic"}} @@ -1638,7 +1630,6 @@ async def test_no_routing_capability_on_parent_skips_registration( from amplifier_app_cli.session_spawner import spawn_sub_session - monkeypatch.setenv("HOME", str(tmp_path)) registered_capabilities = {} @@ -1828,7 +1819,6 @@ async def test_system_instruction_mentions_are_expanded(self, tmp_path, monkeypa from amplifier_app_cli.lib.mention_loading.app_resolver import AppMentionResolver from amplifier_app_cli.session_spawner import spawn_sub_session - monkeypatch.setenv("HOME", str(tmp_path)) FIXTURE_CONTENT = "SENTINEL_FIXTURE_SYSTEM_CONTENT_12345" fixture_file = tmp_path / "fixture.md" @@ -1901,7 +1891,6 @@ async def test_delegation_instruction_mentions_are_expanded(self, tmp_path, monk from amplifier_app_cli.lib.mention_loading.app_resolver import AppMentionResolver from amplifier_app_cli.session_spawner import spawn_sub_session - monkeypatch.setenv("HOME", str(tmp_path)) FIXTURE_CONTENT = "SENTINEL_FIXTURE_INSTRUCTION_67890" fixture_file = tmp_path / "task.md" @@ -2093,7 +2082,6 @@ async def _spawn(self, parent_session, child_session, agent_configs, sub_session async def test_factory_registered_when_context_supports_it(self, tmp_path, monkeypatch): """Context exposing set_system_prompt_factory: the factory is registered with the exact system_instruction; no static system message is added.""" - monkeypatch.setenv("HOME", str(tmp_path)) context = _FactoryContext() parent_session, child_session = self._make_sessions(context) @@ -2122,7 +2110,6 @@ async def test_falls_back_to_add_message_without_factory_support( """Context exposing ONLY add_message (no set_system_prompt_factory attribute) falls back to the static system message -- regression guard for pre-fix behavior.""" - monkeypatch.setenv("HOME", str(tmp_path)) context = _StaticOnlyContext() assert not hasattr(context, "set_system_prompt_factory") @@ -2146,7 +2133,6 @@ async def test_falls_back_to_add_message_without_factory_support( async def test_no_instruction_registers_nothing(self, tmp_path, monkeypatch): """No system_instruction on the agent config: neither the factory nor add_message should be invoked for a system message.""" - monkeypatch.setenv("HOME", str(tmp_path)) context = _FactoryContext() parent_session, child_session = self._make_sessions(context) @@ -2171,7 +2157,6 @@ async def test_factory_content_is_mention_expanded(self, tmp_path, monkeypatch): raw pre-expansion instruction string.""" from amplifier_app_cli.lib.mention_loading.app_resolver import AppMentionResolver - monkeypatch.setenv("HOME", str(tmp_path)) FIXTURE_CONTENT = "SENTINEL_FACTORY_EXPANSION_54321" fixture_file = tmp_path / "fixture.md" @@ -2276,7 +2261,6 @@ async def test_resume_instruction_mentions_are_expanded(self, tmp_path, monkeypa from amplifier_app_cli.session_spawner import resume_sub_session - monkeypatch.setenv("HOME", str(tmp_path)) FIXTURE_CONTENT = "SENTINEL_FIXTURE_RESUME_CONTENT_99999" fixture_file = tmp_path / "resume_task.md" diff --git a/tests/test_skills_cli.py b/tests/test_skills_cli.py index dc1cc98..5b58166 100644 --- a/tests/test_skills_cli.py +++ b/tests/test_skills_cli.py @@ -100,6 +100,7 @@ def test_packaged_amplifier_config_discovery_and_invocation( """Discover and invoke amplifier-config from only the packaged skills dir.""" monkeypatch.chdir(tmp_path) monkeypatch.setenv("HOME", str(tmp_path / "isolated-home")) + monkeypatch.setenv("USERPROFILE", str(tmp_path / "isolated-home")) packaged_dir = Path(config.__file__).parent.parent / "data" / "skills" shortcuts = {} diff --git a/tests/test_timedout_session_resumable.py b/tests/test_timedout_session_resumable.py index a4f3aa9..106b45b 100644 --- a/tests/test_timedout_session_resumable.py +++ b/tests/test_timedout_session_resumable.py @@ -52,6 +52,12 @@ def anyio_backend(): return "asyncio" +@pytest.fixture(autouse=True) +def _home(isolated_home): + """Every test here reaches SessionStore() -> Path.home(). See conftest.isolated_home.""" + return isolated_home + + # --------------------------------------------------------------------------- # Fakes # --------------------------------------------------------------------------- @@ -228,7 +234,6 @@ async def test_timed_out_spawn_leaves_a_loadable_session( self, tmp_path, monkeypatch ): """The advertised session_id exists in SessionStore after a timeout.""" - monkeypatch.setenv("HOME", str(tmp_path)) monkeypatch.setenv("AMPLIFIER_SPAWN_CHECKPOINT_INTERVAL_S", "0") hooks = FakeHooks() @@ -271,7 +276,6 @@ async def test_timed_out_session_round_trips_through_resume( resume_sub_session(session_id) and observe the preserved messages restored into the resumed session's context. """ - monkeypatch.setenv("HOME", str(tmp_path)) monkeypatch.setenv("AMPLIFIER_SPAWN_CHECKPOINT_INTERVAL_S", "0") hooks = FakeHooks() @@ -350,8 +354,6 @@ async def test_missing_session_says_not_resumable_and_says_re_delegate( ): from amplifier_app_cli.session_spawner import resume_sub_session - monkeypatch.setenv("HOME", str(tmp_path)) - with pytest.raises(FileNotFoundError) as excinfo: await resume_sub_session("never-existed-session-id", "carry on") @@ -376,7 +378,6 @@ async def test_disabled_checkpointing_lands_on_the_non_resumable_branch( """ from amplifier_app_cli.session_spawner import resume_sub_session - monkeypatch.setenv("HOME", str(tmp_path)) monkeypatch.setenv("AMPLIFIER_SPAWN_CHECKPOINT_INTERVAL_S", "-1") hooks = FakeHooks() @@ -421,7 +422,6 @@ async def test_hanging_get_messages_does_not_delay_the_unwind( accident (e.g. via a short-circuit that skipped the await for an unrelated reason). """ - monkeypatch.setenv("HOME", str(tmp_path)) monkeypatch.setenv("AMPLIFIER_SPAWN_CHECKPOINT_INTERVAL_S", "0") hooks = FakeHooks() @@ -502,7 +502,6 @@ async def test_cleanup_still_runs_and_the_timeout_still_propagates( self, tmp_path, monkeypatch ): """Checkpointing must not swallow the timeout or skip child cleanup.""" - monkeypatch.setenv("HOME", str(tmp_path)) monkeypatch.setenv("AMPLIFIER_SPAWN_CHECKPOINT_INTERVAL_S", "0") hooks = FakeHooks() @@ -538,7 +537,6 @@ async def test_checkpoint_is_wired_to_provider_request_only( whose tool_calls have no matching results; resuming that transcript reproduces "No tool call found for function call output". """ - monkeypatch.setenv("HOME", str(tmp_path)) monkeypatch.setenv("AMPLIFIER_SPAWN_CHECKPOINT_INTERVAL_S", "0") hooks = FakeHooks() @@ -565,7 +563,6 @@ class TestCheckpointIsBestEffort: async def test_failing_checkpoint_does_not_break_the_run( self, tmp_path, monkeypatch ): - monkeypatch.setenv("HOME", str(tmp_path)) monkeypatch.setenv("AMPLIFIER_SPAWN_CHECKPOINT_INTERVAL_S", "0") hooks = FakeHooks() @@ -599,7 +596,6 @@ async def test_missing_hook_registry_still_pre_registers_the_session( self, tmp_path, monkeypatch ): """No hooks -> no mid-run checkpoints, but the id must still resolve.""" - monkeypatch.setenv("HOME", str(tmp_path)) monkeypatch.setenv("AMPLIFIER_SPAWN_CHECKPOINT_INTERVAL_S", "0") context = FakeContext() @@ -624,7 +620,6 @@ async def execute_impl(instruction): class TestThrottleAndEscapeHatch: async def test_interval_throttles_mid_run_checkpoints(self, tmp_path, monkeypatch): """A large interval collapses N provider calls to the one pre-registration.""" - monkeypatch.setenv("HOME", str(tmp_path)) monkeypatch.setenv("AMPLIFIER_SPAWN_CHECKPOINT_INTERVAL_S", "3600") hooks = FakeHooks() @@ -654,7 +649,6 @@ async def test_negative_interval_disables_checkpointing_entirely( self, tmp_path, monkeypatch ): """The documented escape hatch, and its cost, pinned in one test.""" - monkeypatch.setenv("HOME", str(tmp_path)) monkeypatch.setenv("AMPLIFIER_SPAWN_CHECKPOINT_INTERVAL_S", "-1") hooks = FakeHooks()