Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions amplifier_app_cli/commands/routing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
21 changes: 21 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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/<slug>/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
14 changes: 6 additions & 8 deletions tests/test_resume_preserves_provider_promotion.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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))
Expand All @@ -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))
Expand All @@ -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)
Expand All @@ -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(
Expand Down Expand Up @@ -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))
Expand All @@ -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(
Expand Down Expand Up @@ -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)
Expand Down
10 changes: 6 additions & 4 deletions tests/test_resume_system_prompt.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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[<agent_name>].instruction.
"""
monkeypatch.setenv("HOME", str(tmp_path))

store = SessionStore()
session_id = "test-resume-system-prompt-config-fallback"
Expand All @@ -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"
Expand Down
6 changes: 5 additions & 1 deletion tests/test_routing_shadowing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]

Expand Down
7 changes: 6 additions & 1 deletion tests/test_routing_winner_selection.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
28 changes: 6 additions & 22 deletions tests/test_session_spawner.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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()

Expand All @@ -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()

Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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 = {}
Expand Down Expand Up @@ -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 = {}

Expand Down Expand Up @@ -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 = {}

Expand Down Expand Up @@ -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}

Expand Down Expand Up @@ -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"}}

Expand Down Expand Up @@ -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 = {}

Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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")
Expand All @@ -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)
Expand All @@ -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"
Expand Down Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions tests/test_skills_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {}
Expand Down
Loading
Loading