"""Tests for the Cartridge round-trip.

Verifies:

* fresh-empty-directory write succeeds; non-empty fails without overwrite
* round-trip of a hand-built ``AgentPreset`` preserves config, system
  prompt, tools (with parameters + execute behaviour), and a hook with
  an opt-in ``to_config()`` method
* ``StaticMemorySource`` instances round-trip via ``memory/*.md``
* warnings are recorded for non-round-trippable config callables
  (``strict=False``) and raised under ``strict=True``
* layout discovery: the workspace.json metadata file is required
* ``Cartridge.to_preset()`` materialises a runnable preset that the
  composable loop can execute end-to-end with a scripted MockLLM
"""

from __future__ import annotations

import json
import textwrap
from pathlib import Path

import pytest

from looplet import (
    BaseToolRegistry,
    Cartridge,
    CartridgeLayout,
    CartridgeSerializationError,
    DefaultState,
    LoopConfig,
    cartridge_to_preset,
    composable_loop,
    preset_to_cartridge,
)
from looplet.memory import StaticMemorySource
from looplet.presets import AgentPreset
from looplet.testing import MockLLMBackend
from looplet.tools import ToolSpec

# ── fixtures ────────────────────────────────────────────────────


def lookup_execute(*, key: str) -> dict:
    """Top-level execute so it round-trips through inspect.getsource."""
    return {"key": key, "value": {"x": 1, "y": 2}.get(key, "MISSING")}


def done_execute(*, answer: str) -> dict:
    return {"answer": answer}


class DemoCounter:
    """Hook with opt-in ``to_config()`` for round-trip kwargs."""

    def __init__(self, *, threshold: int = 3) -> None:
        self.threshold = threshold
        self.seen = 0

    def to_config(self) -> dict:
        return {"threshold": self.threshold}

    def post_dispatch(self, state, session_log, tool_call, tool_result, step_num):  # noqa: D401
        self.seen += 1
        return None


def _done_execute(*, s: str = "") -> dict:
    """Top-level done-tool callable so workspace round-trip can re-import it."""
    return {"status": "completed", "summary": s}


def _trivial_evaluator(ctx) -> "EvalResult":  # noqa: F821 - imported in test
    """Top-level evaluator so EvalHook auto-emit produces an importable resource."""
    from looplet import EvalResult  # noqa: PLC0415

    return EvalResult(name="trivial", passed=True)


def _build_demo_preset() -> AgentPreset:
    config = LoopConfig(
        max_steps=8,
        max_tokens=512,
        temperature=0.1,
        system_prompt="lookup agent",
        memory_sources=[StaticMemorySource(text="prefer x over y when both apply")],
    )
    registry = BaseToolRegistry()
    registry.register(
        ToolSpec(
            name="lookup",
            description="Return the value for key.",
            parameters={"key": "str"},
            execute=lookup_execute,
        )
    )
    registry.register(
        ToolSpec(
            name="done",
            description="Submit final answer.",
            parameters={"answer": "str"},
            execute=done_execute,
        )
    )
    return AgentPreset(
        config=config,
        hooks=[DemoCounter(threshold=5)],
        tools=registry,
        state=DefaultState(max_steps=8),
    )


# ── basic IO ────────────────────────────────────────────────────


def test_write_to_empty_directory(tmp_path: Path) -> None:
    workspace = preset_to_cartridge(_build_demo_preset(), tmp_path / "ws")
    assert (workspace.path / CartridgeLayout.CARTRIDGE_JSON).is_file()
    assert (workspace.path / CartridgeLayout.SYSTEM_PROMPT_MD).read_text() == "lookup agent"
    assert (workspace.path / CartridgeLayout.CONFIG_YAML).is_file()
    assert (workspace.path / CartridgeLayout.TOOLS_DIR / "lookup" / "tool.yaml").is_file()
    assert (workspace.path / CartridgeLayout.TOOLS_DIR / "lookup" / "execute.py").is_file()
    assert (workspace.path / CartridgeLayout.HOOKS_DIR / "00_DemoCounter" / "hook.py").is_file()
    assert (workspace.path / CartridgeLayout.MEMORY_DIR / "00_static.md").is_file()


def test_non_empty_directory_requires_overwrite(tmp_path: Path) -> None:
    out = tmp_path / "ws"
    out.mkdir()
    (out / "stale").write_text("hi")
    with pytest.raises(FileExistsError):
        preset_to_cartridge(_build_demo_preset(), out)
    # overwrite=True succeeds and wipes managed subdirs.
    workspace = preset_to_cartridge(_build_demo_preset(), out, overwrite=True)
    assert (workspace.path / CartridgeLayout.CARTRIDGE_JSON).is_file()


def test_workspace_metadata_round_trips(tmp_path: Path) -> None:
    preset_to_cartridge(
        _build_demo_preset(),
        tmp_path / "ws",
        name="demo",
        description="just a test",
    )
    loaded = Cartridge.from_directory(tmp_path / "ws")
    assert loaded.name == "demo"
    assert loaded.description == "just a test"
    assert loaded.schema_version == 2


def test_missing_metadata_raises(tmp_path: Path) -> None:
    out = tmp_path / "ws"
    out.mkdir()
    (out / "config.yaml").write_text("max_steps: 5\n")
    with pytest.raises(FileNotFoundError):
        Cartridge.from_directory(out)
    with pytest.raises(FileNotFoundError):
        cartridge_to_preset(out)


# ── round-trip preset structure ─────────────────────────────────


def test_round_trip_preserves_config_subset(tmp_path: Path) -> None:
    preset = _build_demo_preset()
    preset_to_cartridge(preset, tmp_path / "ws")
    loaded = cartridge_to_preset(tmp_path / "ws")
    assert loaded.config.max_steps == preset.config.max_steps
    assert loaded.config.max_tokens == preset.config.max_tokens
    assert loaded.config.temperature == pytest.approx(preset.config.temperature)
    assert loaded.config.system_prompt == "lookup agent"
    assert loaded.config.done_tool == preset.config.done_tool


def test_round_trip_preserves_tools(tmp_path: Path) -> None:
    preset_to_cartridge(_build_demo_preset(), tmp_path / "ws")
    loaded = cartridge_to_preset(tmp_path / "ws")
    names = {spec.name for spec in loaded.tools._tools.values()}  # type: ignore[attr-defined]
    assert names == {"lookup", "done"}
    lookup_spec = loaded.tools._tools["lookup"]  # type: ignore[attr-defined]
    assert lookup_spec.execute(key="x") == {"key": "x", "value": 1}
    assert lookup_spec.execute(key="y") == {"key": "y", "value": 2}
    assert lookup_spec.parameters == {"key": "str"}


def test_round_trip_preserves_hook_with_to_config(tmp_path: Path) -> None:
    preset_to_cartridge(_build_demo_preset(), tmp_path / "ws")
    loaded = cartridge_to_preset(tmp_path / "ws")
    assert len(loaded.hooks) == 1
    hook = loaded.hooks[0]
    assert type(hook).__name__ == "DemoCounter"
    assert hook.threshold == 5


def test_round_trip_preserves_static_memory(tmp_path: Path) -> None:
    preset_to_cartridge(_build_demo_preset(), tmp_path / "ws")
    loaded = cartridge_to_preset(tmp_path / "ws")
    sources = list(loaded.config.memory_sources)
    assert len(sources) == 1
    assert isinstance(sources[0], StaticMemorySource)
    assert sources[0].text == "prefer x over y when both apply"


# ── runnable round-trip ─────────────────────────────────────────


def test_round_tripped_preset_runs_end_to_end(tmp_path: Path) -> None:
    preset_to_cartridge(_build_demo_preset(), tmp_path / "ws")
    loaded = cartridge_to_preset(tmp_path / "ws")

    llm = MockLLMBackend(
        responses=[
            '{"tool":"lookup","args":{"key":"x"},"reasoning":"check"}',
            '{"tool":"done","args":{"answer":"x=1"},"reasoning":""}',
        ]
    )
    steps = list(
        composable_loop(
            llm=llm,
            tools=loaded.tools,
            state=loaded.state,
            config=loaded.config,
            hooks=loaded.hooks,
            task={"goal": "lookup x"},
        )
    )
    assert [s.tool_call.tool for s in steps] == ["lookup", "done"]
    # The DemoCounter's post_dispatch ran for the lookup step.
    assert loaded.hooks[0].seen >= 1


# ── warnings + strict ──────────────────────────────────────────


def test_non_serializable_config_field_warns_in_loose_mode(tmp_path: Path) -> None:
    preset = _build_demo_preset()
    # Set a callable on a known non-serializable field. The writer now
    # auto-emits a ``@build_briefing`` ref + ``resources/build_briefing.py``
    # stub; for closure / lambda values the stub is the None-fallback
    # and a warning is recorded so the user knows manual editing is
    # required for cross-process round-trip.
    preset.config.build_briefing = lambda **_: "x"
    workspace = preset_to_cartridge(preset, tmp_path / "ws")
    assert any("build_briefing" in w for w in workspace.serialization_warnings)
    assert (tmp_path / "ws" / "resources" / "build_briefing.py").is_file()


def test_non_serializable_config_field_raises_in_strict_mode(tmp_path: Path) -> None:
    preset = _build_demo_preset()
    preset.config.build_briefing = lambda **_: "x"
    with pytest.raises(CartridgeSerializationError):
        preset_to_cartridge(preset, tmp_path / "ws", strict=True)


def test_warnings_are_empty_for_clean_preset(tmp_path: Path) -> None:
    workspace = preset_to_cartridge(_build_demo_preset(), tmp_path / "ws")
    # The demo preset uses only round-trippable fields; no warnings expected.
    assert workspace.serialization_warnings == []


# ── layout sanity ──────────────────────────────────────────────


def test_workspace_json_is_stable(tmp_path: Path) -> None:
    preset_to_cartridge(_build_demo_preset(), tmp_path / "ws", name="demo")
    payload = json.loads((tmp_path / "ws" / "cartridge.json").read_text())
    assert payload["schema_version"] == 2
    assert payload["name"] == "demo"
    assert "metadata" in payload


def test_layout_constants_match_written_paths(tmp_path: Path) -> None:
    workspace = preset_to_cartridge(_build_demo_preset(), tmp_path / "ws")
    expected = {
        CartridgeLayout.CARTRIDGE_JSON,
        CartridgeLayout.SYSTEM_PROMPT_MD,
        CartridgeLayout.CONFIG_YAML,
    }
    for relative in expected:
        assert (workspace.path / relative).exists(), f"missing {relative}"


# ── coder-preset round-trip (real-world dogfood) ───────────────


def test_coder_preset_round_trips_with_strict_load(tmp_path: Path) -> None:
    """Regression: a real preset with built-in hooks (ThresholdCompactHook)
    that need constructor args must round-trip under strict=True after
    to_config() was added to the relevant hooks. Previously the loader
    silently dropped any hook whose config.yaml lacked kwargs the
    constructor needed."""
    from looplet import coding_agent_preset

    preset = coding_agent_preset(workspace=str(tmp_path / "ws"), max_steps=5)
    out = tmp_path / "coder.cartridge"
    preset_to_cartridge(preset, out, name="coder")

    # strict=True must succeed end-to-end — every hook must reload.
    reloaded = cartridge_to_preset(out, strict=True)
    original_hook_names = [type(h).__name__ for h in preset.hooks]
    reloaded_hook_names = [type(h).__name__ for h in reloaded.hooks]
    assert reloaded_hook_names == original_hook_names, (
        f"hook list changed on round-trip: {original_hook_names} -> {reloaded_hook_names}"
    )


def test_strict_load_raises_on_unconstructable_hook(tmp_path: Path) -> None:
    """Regression: hooks whose config.yaml lacks required constructor
    kwargs must raise CartridgeSerializationError under strict=True
    instead of silently dropping. Loose mode still drops + warns."""
    out = tmp_path / "broken.workspace"
    out.mkdir()
    (out / "cartridge.json").write_text(json.dumps({"name": "x", "schema_version": 2}))
    hook_dir = out / "hooks" / "00_NeedsArgs"
    hook_dir.mkdir(parents=True)
    (hook_dir / "hook.py").write_text(
        "class NeedsArgs:\n    def __init__(self, *, required_arg):\n        self.x = required_arg\n"
    )
    (hook_dir / "config.yaml").write_text("class_name: NeedsArgs\nkwargs: {}\n")

    # Loose mode: hook silently dropped (logged warning).
    loose = cartridge_to_preset(out)
    assert loose.hooks == []

    # Strict mode: raises with actionable message naming to_config().
    with pytest.raises(CartridgeSerializationError, match="to_config"):
        cartridge_to_preset(out, strict=True)


# ── Shared resources + @ref + setup.py ─────────────────────────


def test_resources_dir_builds_shared_objects(tmp_path: Path) -> None:
    """resources/<name>.py with `def build()` populates the resource
    registry that ``@<name>`` references resolve against."""
    out = tmp_path / "ws"
    out.mkdir()
    (out / "cartridge.json").write_text(json.dumps({"name": "x", "schema_version": 2}))
    (out / "resources").mkdir()
    (out / "resources" / "shared_cache.py").write_text(
        "def build():\n    return {'cache_id': 'singleton', 'items': []}\n"
    )

    # Hook that takes a `cache` kwarg via @ref.
    hook_dir = out / "hooks" / "00_TwoConsumers"
    hook_dir.mkdir(parents=True)
    (hook_dir / "hook.py").write_text(
        "class TwoConsumers:\n    def __init__(self, *, cache):\n        self.cache = cache\n"
    )
    (hook_dir / "config.yaml").write_text(
        'class_name: TwoConsumers\nkwargs:\n  cache: "@shared_cache"\n'
    )

    preset = cartridge_to_preset(out, strict=True)
    assert len(preset.hooks) == 1
    assert preset.hooks[0].cache == {"cache_id": "singleton", "items": []}


def test_two_hooks_share_same_resource_object(tmp_path: Path) -> None:
    """Two hooks referencing the same @<name> get the SAME Python object,
    not two independent copies. This is the FileCacheHook + StaleFileHook
    pattern: shared mutable state must survive workspace round-trip."""
    out = tmp_path / "ws"
    out.mkdir()
    (out / "cartridge.json").write_text(json.dumps({"name": "x", "schema_version": 2}))
    (out / "resources").mkdir()
    (out / "resources" / "cache.py").write_text("def build():\n    return {'shared': True}\n")

    for idx, name in enumerate(("Reader", "Writer")):
        d = out / "hooks" / f"{idx:02d}_{name}"
        d.mkdir(parents=True)
        (d / "hook.py").write_text(
            f"class {name}:\n    def __init__(self, *, cache):\n        self.cache = cache\n"
        )
        (d / "config.yaml").write_text(f'class_name: {name}\nkwargs:\n  cache: "@cache"\n')

    preset = cartridge_to_preset(out, strict=True)
    assert preset.hooks[0].cache is preset.hooks[1].cache, (
        "Both hooks must reference the SAME object (shared state), not separate copies"
    )


def test_unresolved_ref_raises_in_strict(tmp_path: Path) -> None:
    """``"@missing"`` with no matching resource raises so the user
    sees the typo immediately."""
    out = tmp_path / "ws"
    out.mkdir()
    (out / "cartridge.json").write_text(json.dumps({"name": "x", "schema_version": 2}))
    hook_dir = out / "hooks" / "00_NeedsRef"
    hook_dir.mkdir(parents=True)
    (hook_dir / "hook.py").write_text(
        "class NeedsRef:\n    def __init__(self, *, dep):\n        self.dep = dep\n"
    )
    (hook_dir / "config.yaml").write_text('class_name: NeedsRef\nkwargs:\n  dep: "@nonexistent"\n')

    with pytest.raises(CartridgeSerializationError, match="unresolved resource reference"):
        cartridge_to_preset(out, strict=True)


def test_hello_workspace_loads_and_runs_end_to_end() -> None:
    """examples/hello.cartridge is the proof-of-concept workspace:
    fully declarative layout with shared resources + setup.py wiring.
    Loads, runs scripted, and the shared GreetingLog round-trips
    state between the greet tool and the PolitenessGate hook."""
    import json as _json
    from pathlib import Path as _P

    from looplet import composable_loop
    from looplet.testing import MockLLMBackend

    workspace_dir = _P(__file__).resolve().parents[1] / "examples" / "hello.cartridge"
    preset = cartridge_to_preset(workspace_dir, strict=True)

    assert preset.config.max_steps == 5
    assert "polite assistant" in preset.config.system_prompt.lower()
    # PolitenessGate is the in-process @ref hook; NameGuard is the
    # portable ``kind: lep`` out-of-process permission policy.
    assert {type(h).__name__ for h in preset.hooks} == {
        "PolitenessGate",
        "LEPHookAdapter",
    }
    assert sorted(preset.tools._tools.keys()) == ["done", "greet"]

    hook = next(h for h in preset.hooks if type(h).__name__ == "PolitenessGate")
    assert hasattr(hook, "log") and hasattr(hook.log, "entries")
    assert hook.log.entries == []

    llm = MockLLMBackend(
        responses=[
            _json.dumps({"thought": "polite", "tool": "greet", "args": {"name": "Alice"}}),
            _json.dumps({"thought": "polite", "tool": "greet", "args": {"name": "Bob"}}),
            _json.dumps({"thought": "finish", "tool": "done", "args": {"answer": "Greeted both."}}),
        ]
    )
    steps = list(
        composable_loop(
            llm=llm,
            tools=preset.tools,
            state=preset.state,
            config=preset.config,
            hooks=preset.hooks,
            task={"q": "greet alice and bob"},
        )
    )

    assert len(steps) == 3
    assert [s.tool_call.tool for s in steps] == ["greet", "greet", "done"]
    # Shared log captured both greetings — proves @ref + setup.py wired
    # the SAME GreetingLog instance into the tool and the hook.
    assert hook.log.names() == ["Alice", "Bob"]


# ── examples/coder.cartridge end-to-end (real-world workspace) ──


def test_coder_workspace_loads_with_shared_filecache(tmp_path) -> None:
    """examples/coder.cartridge migrates the v1 coder bundle to the
    v2 layout. Validates that:
      * Declarative + setup.py-injected hooks load (8 total — TestGuard,
        FileCache, StaleFile, Stagnation, ThresholdCompact, PerToolLimit
        from YAML; LinterHook + EvalHook appended by setup.py to match
        looplet.examples coder reference feature-for-feature)
    * Coder tools (bash/list_dir/read/write/edit/glob/grep/think/done plus optional helpers) load
      * FileCacheHook and StaleFileHook share the SAME FileCache instance
        via @file_cache (proves the shared-resource registry under load)
      * setup.py wires WORKSPACE_CONFIG + FILE_CACHE module globals into
        every tool that needs them
      * setup.py also attaches compact_service + project-context memory
    """
    import json as _json
    from pathlib import Path as _P

    from looplet import composable_loop
    from looplet.testing import MockLLMBackend

    workspace_dir = _P(__file__).resolve().parents[1] / "examples" / "coder.cartridge"
    # Use a tmp_path workspace so EvalHook's pytest collector doesn't
    # recurse into the looplet test suite when it runs at on_loop_end.
    target_repo = tmp_path / "target"
    target_repo.mkdir()
    preset = cartridge_to_preset(
        workspace_dir,
        strict=True,
        runtime={"workspace": str(target_repo)},
    )

    hook_names = [type(h).__name__ for h in preset.hooks]
    # Order: directory hooks (sorted by ``order:``) first, then
    # ``builtin_hooks:`` entries in declaration order, then the
    # auto-installed ``permissions:`` PermissionHook (v1.0 spec slot).
    # ``08_ShellSafetyGate`` is the portable ``kind: lep`` guardrail and
    # lands last among the directory hooks (after EvalHook).
    assert hook_names == [
        "TestGuardHook",
        "FileCacheHook",
        "StaleFileHook",
        "LinterHook",
        "EvalHook",
        "LEPHookAdapter",
        "StagnationHook",
        "ThresholdCompactHook",
        "PerToolLimitHook",
        "PermissionHook",
    ]
    assert sorted(preset.tools._tools.keys()) == [
        "bash",
        "done",
        "edit_file",
        "git_inspect",
        "glob",
        "grep",
        "list_dir",
        "multi_edit",
        "notebook_edit",
        "read_file",
        "subagent",
        "think",
        "todo",
        "web_fetch",
        "worktree",
        "write_file",
    ]

    # The shared-state proof: FileCacheHook and StaleFileHook reference
    # the SAME cache object via @file_cache, NOT two independent copies.
    fc_hook = next(h for h in preset.hooks if type(h).__name__ == "FileCacheHook")
    sf_hook = next(h for h in preset.hooks if type(h).__name__ == "StaleFileHook")
    assert fc_hook._cache is sf_hook._cache

    # setup.py attaches compact_service.
    assert preset.config.compact_service is not None

    # setup.py appends the live-state CallableMemorySource for project ctx.
    assert preset.config.memory_sources, "setup.py should attach memory sources"

    # End-to-end smoke: think → done with the real loop.
    llm = MockLLMBackend(
        responses=[
            _json.dumps({"thought": "plan", "tool": "think", "args": {"thought": "smoke"}}),
            _json.dumps({"thought": "finish", "tool": "done", "args": {"summary": "ok"}}),
        ]
    )
    steps = list(
        composable_loop(
            llm=llm,
            tools=preset.tools,
            state=preset.state,
            config=preset.config,
            hooks=preset.hooks,
            task={"q": "smoke"},
        )
    )
    assert [s.tool_call.tool for s in steps] == ["think", "done"]


def test_threat_intel_workspace_loads() -> None:
    """examples/threat_intel.cartridge migrates the threat-intel
    bundle. Loads with strict=True; tools re-import from the
    original module so their typing/closure environment stays intact."""
    from pathlib import Path as _P

    workspace_dir = _P(__file__).resolve().parents[1] / "examples" / "threat_intel.cartridge"
    preset = cartridge_to_preset(workspace_dir, strict=True)
    assert sorted(preset.tools._tools.keys()) == [
        "assess_risk",
        "done",
        "extract_iocs",
        "fetch_feed",
        "map_mitre",
        "search_cve",
        "think",
    ]
    assert [type(h).__name__ for h in preset.hooks] == [
        "LEPHookAdapter",
        "StagnationHook",
        "PerToolLimitHook",
    ]


def test_dep_doctor_workspace_loads() -> None:
    """examples/dep_doctor.cartridge migration."""
    from pathlib import Path as _P

    workspace_dir = _P(__file__).resolve().parents[1] / "examples" / "dep_doctor.cartridge"
    preset = cartridge_to_preset(workspace_dir, strict=True)
    # tool.yaml ``name`` matches the dir name so the registered tool
    # name is the same as the workspace directory.
    assert sorted(preset.tools._tools.keys()) == [
        "check_license_compat",
        "check_package",
        "detect_dep_files",
        "done",
        "find_alternatives",
        "parse_deps",
        "think",
    ]
    assert [type(h).__name__ for h in preset.hooks] == [
        "LEPHookAdapter",
        "StagnationHook",
        "PerToolLimitHook",
    ]


def test_git_detective_workspace_loads() -> None:
    """examples/git_detective.cartridge migration. Uses lazy
    closure-registry via ``make_tools(REPO_CONFIG.path)`` with
    setup.py injecting the shared repo_config resource."""
    from pathlib import Path as _P

    workspace_dir = _P(__file__).resolve().parents[1] / "examples" / "git_detective.cartridge"
    preset = cartridge_to_preset(workspace_dir, strict=True)
    assert sorted(preset.tools._tools.keys()) == [
        "commit_patterns",
        "contributor_stats",
        "coupled_files",
        "directory_structure",
        "done",
        "file_age_analysis",
        "file_hotspots",
        "recent_activity",
        "repo_overview",
        "think",
    ]
    assert [type(h).__name__ for h in preset.hooks] == [
        "LEPHookAdapter",
        "StagnationHook",
        "PerToolLimitHook",
    ]


# ── runtime= kwarg + ${runtime.x} substitution ────────────────


@pytest.mark.filterwarnings("ignore::DeprecationWarning")
def test_runtime_substitution_in_config_yaml(tmp_path):
    """${runtime.<key>} placeholders in config.yaml get replaced with
    the host-supplied runtime value before LoopConfig is constructed."""
    out = tmp_path / "ws"
    out.mkdir()
    (out / "cartridge.json").write_text(json.dumps({"name": "x", "schema_version": 2}))
    (out / "config.yaml").write_text("max_steps: 5\n")
    (out / "runtime.yaml").write_text("checkpoint_dir: ${runtime.cp_dir}\n")

    preset = cartridge_to_preset(out, strict=True, runtime={"cp_dir": "/tmp/my-cp"})
    assert preset.config.max_steps == 5
    assert preset.config.checkpoint_dir == "/tmp/my-cp"


def test_runtime_substitution_unknown_key_raises(tmp_path):
    """Typos in ${runtime.<key>} fail loudly at load time, not silently."""
    out = tmp_path / "ws"
    out.mkdir()
    (out / "cartridge.json").write_text(json.dumps({"name": "x", "schema_version": 2}))
    (out / "config.yaml").write_text("max_steps: ${runtime.nonexistent}\n")

    with pytest.raises(CartridgeSerializationError, match="unresolved"):
        cartridge_to_preset(out, strict=True, runtime={"actual_key": 5})


def test_resource_builder_receives_runtime(tmp_path):
    """resources/<name>.py builders that declare def build(runtime)
    get the host-supplied runtime dict; legacy zero-arg build()
    keeps working."""
    out = tmp_path / "ws"
    out.mkdir()
    (out / "cartridge.json").write_text(json.dumps({"name": "x", "schema_version": 2}))
    (out / "resources").mkdir()
    (out / "resources" / "tagged.py").write_text(
        "def build(runtime=None):\n"
        "    runtime = runtime or {}\n"
        "    return {'tag': runtime.get('tag', 'default')}\n"
    )

    hook_dir = out / "hooks" / "00_TagReader"
    hook_dir.mkdir(parents=True)
    (hook_dir / "hook.py").write_text(
        "class TagReader:\n    def __init__(self, *, source):\n        self.source = source\n"
    )
    (hook_dir / "config.yaml").write_text('class_name: TagReader\nkwargs:\n  source: "@tagged"\n')

    preset = cartridge_to_preset(out, strict=True, runtime={"tag": "from-runtime"})
    assert preset.hooks[0].source == {"tag": "from-runtime"}


def test_coder_workspace_runtime_kwarg_routes_files(tmp_path):
    """End-to-end regression for the runtime= kwarg in coder.cartridge:
    write_file via composable_loop must land in the runtime-supplied
    workspace, NOT in the test cwd. The previous code wrote to cwd
    because there was no way to point a workspace at a runtime path."""
    import json as _json
    from pathlib import Path as _P

    from looplet import composable_loop
    from looplet.testing import MockLLMBackend

    workspace_dir = _P(__file__).resolve().parents[1] / "examples" / "coder.cartridge"
    target = tmp_path / "target-repo"
    target.mkdir()

    preset = cartridge_to_preset(workspace_dir, strict=True, runtime={"workspace": str(target)})
    llm = MockLLMBackend(
        responses=[
            _json.dumps(
                {
                    "thought": "write",
                    "tool": "write_file",
                    "args": {"file_path": "hello.py", "content": "print('hi')\n"},
                }
            ),
            _json.dumps({"thought": "finish", "tool": "done", "args": {"summary": "ok"}}),
        ]
    )
    list(
        composable_loop(
            llm=llm,
            tools=preset.tools,
            state=preset.state,
            config=preset.config,
            hooks=preset.hooks,
            task={"q": "write hello.py"},
        )
    )
    assert (target / "hello.py").read_text().strip() == "print('hi')"


# ── Bidirectional + v1↔v2 parity ──────────────────────────────


def test_coder_workspace_bidirectional_round_trip(tmp_path) -> None:
    """Load coder.cartridge → snapshot to a fresh dir → reload. The
    declarative hooks (with to_config()) and tools survive in-process
    round-trip; setup.py-appended hooks (EvalHook) drop on reload
    because their callable evaluators don't round-trip."""
    from pathlib import Path as _P

    workspace_dir = _P(__file__).resolve().parents[1] / "examples" / "coder.cartridge"
    target = tmp_path / "target"
    target.mkdir()
    snap_dir = tmp_path / "snapshot"

    preset = cartridge_to_preset(workspace_dir, strict=True, runtime={"workspace": str(target)})
    preset_to_cartridge(preset, snap_dir, name="coder-snap")

    # The coder workspace ships co-located ``lib_*.py`` helper modules
    # that hosts and tools subclass / call into. Round-tripping a
    # preset doesn't auto-copy these — the snapshot is otherwise
    # self-contained but its hook.py / execute.py shims still
    # ``from lib_tools import ...``. Copy them alongside so the
    # reload finds the same helpers.
    import shutil as _shutil

    for _lib in ("coder_lib_tools.py", "coder_lib_hooks.py", "coder_lib_wiring.py"):
        _src = workspace_dir / _lib
        if _src.is_file():
            _shutil.copy(_src, snap_dir / _lib)

    # The auto-emitted resources/file_cache.py builder reads
    # runtime['workspace']; pass it on reload.
    reloaded = cartridge_to_preset(snap_dir, runtime={"workspace": str(target)})

    # All 8 declarative hooks survive the round-trip — including
    # EvalHook now that its evaluators + collectors live in
    # resources/eval_evaluators.py and resources/eval_collectors.py
    # (referenced via @ref instead of injected via setup.py).
    # The 3 generic guards (Stagnation, ThresholdCompact, PerToolLimit)
    # are wired via ``builtin_hooks:`` and land at the end of the list.
    reloaded_names = [type(h).__name__ for h in reloaded.hooks]
    # The portable ``kind: lep`` ShellSafetyGate round-trips too and
    # keeps its position after EvalHook among the directory hooks.
    assert reloaded_names == [
        "TestGuardHook",
        "FileCacheHook",
        "StaleFileHook",
        "LinterHook",
        "EvalHook",
        "LEPHookAdapter",
        "StagnationHook",
        "ThresholdCompactHook",
        "PerToolLimitHook",
        "PermissionHook",
    ]
    assert sorted(reloaded.tools._tools.keys()) == [
        "bash",
        "done",
        "edit_file",
        "git_inspect",
        "glob",
        "grep",
        "list_dir",
        "multi_edit",
        "notebook_edit",
        "read_file",
        "subagent",
        "think",
        "todo",
        "web_fetch",
        "worktree",
        "write_file",
    ]


def test_threat_intel_workspace_attaches_compact_service(tmp_path) -> None:
    """setup.py wires LoopConfig.compact_service so the workspace
    matches the looplet.examples coder reference feature-for-feature."""
    from pathlib import Path as _P

    workspace_dir = _P(__file__).resolve().parents[1] / "examples" / "threat_intel.cartridge"
    preset = cartridge_to_preset(workspace_dir, strict=True)
    assert preset.config.compact_service is not None


def test_dep_doctor_workspace_attaches_compact_and_memory(tmp_path) -> None:
    """compact_service + memory/00_static.md both reach the preset."""
    from pathlib import Path as _P

    workspace_dir = _P(__file__).resolve().parents[1] / "examples" / "dep_doctor.cartridge"
    preset = cartridge_to_preset(workspace_dir, strict=True)
    assert preset.config.compact_service is not None
    sources = preset.config.memory_sources or []
    assert any("Audit Standards" in getattr(s, "text", "") for s in sources), (
        "memory/00_static.md should land in config.memory_sources"
    )


def test_git_detective_workspace_attaches_compact_and_memory(tmp_path) -> None:
    from pathlib import Path as _P

    workspace_dir = _P(__file__).resolve().parents[1] / "examples" / "git_detective.cartridge"
    preset = cartridge_to_preset(workspace_dir, strict=True)
    assert preset.config.compact_service is not None
    sources = preset.config.memory_sources or []
    assert any("Report Standards" in getattr(s, "text", "") for s in sources)


def test_runtime_substitution_in_hook_config(tmp_path) -> None:
    """${runtime.<key>} placeholders in hook config.yaml are substituted
    just like they are in the top-level config.yaml. Used by coder's
    LinterHook to receive the runtime workspace path declaratively."""
    out = tmp_path / "ws"
    out.mkdir()
    (out / "cartridge.json").write_text(json.dumps({"name": "x", "schema_version": 2}))
    hook_dir = out / "hooks" / "00_PathReader"
    hook_dir.mkdir(parents=True)
    (hook_dir / "hook.py").write_text(
        "class PathReader:\n    def __init__(self, *, path):\n        self.path = path\n"
    )
    (hook_dir / "config.yaml").write_text(
        "class_name: PathReader\nkwargs:\n  path: ${runtime.workspace}\n"
    )

    preset = cartridge_to_preset(out, strict=True, runtime={"workspace": "/tmp/some-path"})
    assert preset.hooks[0].path == "/tmp/some-path"


# ── Harness-shape stress tests (PR #31) ───────────────────────


def test_permission_engine_via_at_ref(tmp_path) -> None:
    """PermissionEngine + PermissionHook compose via @ref. Lets users
    declare permission policy in resources/perm_engine.py and reference
    it from hooks/00_PermissionHook/config.yaml."""
    import json as _json
    from pathlib import Path as _P

    ws = tmp_path
    (ws / "cartridge.json").write_text(_json.dumps({"name": "x", "schema_version": 2}))
    (ws / "config.yaml").write_text("max_steps: 5\n")
    (ws / "tools/echo").mkdir(parents=True)
    (ws / "tools/echo/tool.yaml").write_text("name: echo\nparameters:\n  msg:\n    type: string\n")
    (ws / "tools/echo/execute.py").write_text("def execute(*, msg): return {'echoed': msg}\n")
    (ws / "tools/done").mkdir(parents=True)
    (ws / "tools/done/tool.yaml").write_text("name: done\nparameters:\n  s:\n    type: string\n")
    (ws / "tools/done/execute.py").write_text(
        "def execute(*, s): return {'status': 'completed', 's': s}\n"
    )
    (ws / "resources").mkdir()
    (ws / "resources/perm_engine.py").write_text(
        "from looplet import PermissionEngine, PermissionDecision\n"
        "def build(runtime=None):\n"
        "    eng = PermissionEngine(default=PermissionDecision.ALLOW)\n"
        "    eng.deny('echo', arg_matcher=lambda a: a.get('msg', '').startswith('SECRET'))\n"
        "    return eng\n"
    )
    (ws / "hooks/00_PermissionHook").mkdir(parents=True)
    (ws / "hooks/00_PermissionHook/hook.py").write_text(
        "from looplet import PermissionHook as _PH\n"
        "class PermissionHook(_PH):\n"
        "    def to_config(self): return {'engine': '@perm_engine'}\n"
    )
    (ws / "hooks/00_PermissionHook/config.yaml").write_text(
        'class_name: PermissionHook\nkwargs:\n  engine: "@perm_engine"\n'
    )

    from looplet import composable_loop
    from looplet.testing import MockLLMBackend

    preset = cartridge_to_preset(ws, strict=True)
    llm = MockLLMBackend(
        responses=[
            _json.dumps({"thought": "ok", "tool": "echo", "args": {"msg": "hello"}}),
            _json.dumps({"thought": "block", "tool": "echo", "args": {"msg": "SECRET-leak"}}),
            _json.dumps({"thought": "f", "tool": "done", "args": {"s": "ok"}}),
        ]
    )
    steps = list(
        composable_loop(
            llm=llm,
            tools=preset.tools,
            state=preset.state,
            config=preset.config,
            hooks=preset.hooks,
            task={"q": "x"},
        )
    )
    assert steps[0].tool_result.error is None
    assert steps[1].tool_result.error and "permission" in steps[1].tool_result.error.lower()


def test_eval_hook_declarative_via_at_ref(tmp_path) -> None:
    """EvalHook with declarative evaluators + collectors via @ref.
    Confirms callable-graph hooks are NOT a permanent setup.py
    requirement — when callables are exposed as resource builders
    that return them, the @ref registry resolves them just like any
    other shared resource."""
    import json as _json

    ws = tmp_path
    (ws / "cartridge.json").write_text(_json.dumps({"name": "x", "schema_version": 2}))
    (ws / "config.yaml").write_text("max_steps: 3\n")
    (ws / "tools/done").mkdir(parents=True)
    (ws / "tools/done/tool.yaml").write_text("name: done\nparameters:\n  s:\n    type: string\n")
    (ws / "tools/done/execute.py").write_text(
        "def execute(*, s): return {'status': 'completed', 's': s}\n"
    )
    (ws / "resources").mkdir()
    (ws / "resources/evaluators.py").write_text(
        "def eval_completed(ctx): return ctx.completed\n"
        "def build(runtime=None): return [eval_completed]\n"
    )
    (ws / "hooks/00_EvalHook").mkdir(parents=True)
    (ws / "hooks/00_EvalHook/hook.py").write_text(
        "from looplet import EvalHook as _EH\n"
        "class EvalHook(_EH):\n"
        "    def to_config(self): return {'evaluators': '@evaluators'}\n"
    )
    (ws / "hooks/00_EvalHook/config.yaml").write_text(
        'class_name: EvalHook\nkwargs:\n  evaluators: "@evaluators"\n'
    )

    preset = cartridge_to_preset(ws, strict=True)
    eh = preset.hooks[0]
    evals = getattr(eh, "evaluators", None) or getattr(eh, "_evaluators", None)
    assert evals and len(evals) == 1


def test_streaming_hook_declarative_via_at_ref(tmp_path) -> None:
    """StreamingHook with declarative emitter via @ref."""
    import json as _json

    ws = tmp_path
    (ws / "cartridge.json").write_text(_json.dumps({"name": "x", "schema_version": 2}))
    (ws / "config.yaml").write_text("max_steps: 3\n")
    (ws / "tools/done").mkdir(parents=True)
    (ws / "tools/done/tool.yaml").write_text("name: done\nparameters:\n  s:\n    type: string\n")
    (ws / "tools/done/execute.py").write_text(
        "def execute(*, s): return {'status': 'completed', 's': s}\n"
    )
    (ws / "resources").mkdir()
    (ws / "resources/emitter.py").write_text(
        "from looplet.streaming import CallbackEmitter\n"
        "EVENTS = []\n"
        "def build(runtime=None): return CallbackEmitter(EVENTS.append)\n"
    )
    (ws / "hooks/00_StreamingHook").mkdir(parents=True)
    (ws / "hooks/00_StreamingHook/hook.py").write_text(
        "from looplet import StreamingHook as _SH\n"
        "class StreamingHook(_SH):\n"
        "    def to_config(self): return {'emitter': '@emitter'}\n"
    )
    (ws / "hooks/00_StreamingHook/config.yaml").write_text(
        'class_name: StreamingHook\nkwargs:\n  emitter: "@emitter"\n'
    )

    preset = cartridge_to_preset(ws, strict=True)
    hook = preset.hooks[0]
    emitter = getattr(hook, "emitter", None) or getattr(hook, "_emitter", None)
    assert emitter is not None


# ── @ref resolution in config.yaml (declarative LoopConfig services) ──


@pytest.mark.filterwarnings("ignore::DeprecationWarning")
def test_compact_service_via_at_ref_in_config(tmp_path) -> None:
    """A workspace can wire ``LoopConfig.compact_service`` declaratively
    via ``compact_service: "@compact_service"`` in config.yaml plus a
    ``resources/compact_service.py`` builder — no setup.py needed."""
    import json as _json

    ws = tmp_path
    (ws / "cartridge.json").write_text(_json.dumps({"name": "x", "schema_version": 2}))
    (ws / "config.yaml").write_text("max_steps: 3\n")
    (ws / "runtime.yaml").write_text('compact_service: "@compact_service"\n')
    (ws / "resources").mkdir()
    (ws / "resources/compact_service.py").write_text(
        "from looplet.compact import PruneToolResults, TruncateCompact, compact_chain\n"
        "def build(runtime=None):\n"
        "    return compact_chain(PruneToolResults(keep_recent=4), TruncateCompact(keep_recent=2))\n"
    )

    preset = cartridge_to_preset(ws, strict=True)
    assert preset.config.compact_service is not None
    # _CompactChain from looplet.compact wires through.
    assert type(preset.config.compact_service).__module__ == "looplet.compact"


@pytest.mark.filterwarnings("ignore::DeprecationWarning")
def test_tracer_via_at_ref_in_config(tmp_path) -> None:
    """``tracer`` callable wired declaratively via @ref."""
    import json as _json

    ws = tmp_path
    (ws / "cartridge.json").write_text(_json.dumps({"name": "x", "schema_version": 2}))
    (ws / "config.yaml").write_text("max_steps: 3\n")
    (ws / "runtime.yaml").write_text('tracer: "@tracer"\n')
    (ws / "resources").mkdir()
    (ws / "resources/tracer.py").write_text(
        "EVENTS = []\n"
        "def _trace(event, payload=None):\n"
        "    EVENTS.append((event, payload))\n"
        "def build(runtime=None):\n"
        "    return _trace\n"
    )

    preset = cartridge_to_preset(ws, strict=True)
    assert preset.config.tracer is not None
    assert callable(preset.config.tracer)


@pytest.mark.filterwarnings("ignore::DeprecationWarning")
def test_unresolved_at_ref_in_config_raises_in_strict(tmp_path) -> None:
    """A typo'd @ref in config.yaml fails loud at load time, same as
    hook kwargs — no silent string-into-LoopConfig leakage."""
    import json as _json

    import pytest

    ws = tmp_path
    (ws / "cartridge.json").write_text(_json.dumps({"name": "x", "schema_version": 2}))
    (ws / "config.yaml").write_text("max_steps: 3\n")
    (ws / "runtime.yaml").write_text('tracer: "@nonexistent_resource"\n')

    with pytest.raises(CartridgeSerializationError, match="unresolved resource reference"):
        cartridge_to_preset(ws, strict=True)


def test_callable_loop_config_field_auto_emits_resource(tmp_path: Path) -> None:
    """Setting a callable LoopConfig field (compact_service) auto-emits
    ``compact_service: "@compact_service"`` into config.yaml and a
    matching ``resources/compact_service.py`` builder so the snapshot
    round-trips without a setup.py detour."""
    from looplet.compact import PruneToolResults, TruncateCompact, compact_chain

    preset = _build_demo_preset()
    preset.config.compact_service = compact_chain(
        PruneToolResults(keep_recent=4), TruncateCompact(keep_recent=2)
    )
    ws = preset_to_cartridge(preset, tmp_path / "ws")

    # ``compact_service`` is a runtime-tier field (cartridge spec v2),
    # so the writer emits it into ``runtime.yaml`` rather than
    # ``config.yaml``. Either filename satisfies the round-trip; the
    # important invariant is that the @ref + the builder file both
    # exist somewhere the loader will find them.
    rt_text = (tmp_path / "ws" / "runtime.yaml").read_text()
    assert '"@compact_service"' in rt_text or "@compact_service" in rt_text
    assert (tmp_path / "ws" / "resources" / "compact_service.py").is_file()

    # Reload — compact_service should come back via the @ref machinery.
    reloaded = cartridge_to_preset(tmp_path / "ws", strict=True)
    assert reloaded.config.compact_service is not None
    # Loose preset round-trip records no warnings for this field.
    assert not any("compact_service" in w for w in ws.serialization_warnings)


def test_builtin_hooks_round_trip_without_subclassing(tmp_path: Path) -> None:
    """MetricsHook, PermissionHook, StreamingHook, and EvalHook now ship
    with default ``to_config()`` so they round-trip without forcing
    users to subclass + override. Previously these silently dropped on
    reload because the loader couldn't supply the required ctor args.
    """
    from looplet import (
        EvalHook,
        EvalResult,
        MetricsCollector,
        MetricsHook,
        PermissionDecision,
        PermissionEngine,
        PermissionHook,
        PermissionRule,
        StreamingHook,
        ToolSpec,
    )
    from looplet.streaming import CallbackEmitter
    from looplet.tools import BaseToolRegistry
    from looplet.types import DefaultState

    tools = BaseToolRegistry()
    tools.register(
        ToolSpec(
            name="done",
            description="done",
            parameters={"s": "summary"},
            execute=_done_execute,
        )
    )
    events: list = []
    hooks = [
        MetricsHook(collector=MetricsCollector()),
        PermissionHook(
            engine=PermissionEngine(
                rules=[PermissionRule(tool="*", decision=PermissionDecision.ALLOW)]
            )
        ),
        StreamingHook(emitter=CallbackEmitter(events.append)),
        EvalHook(evaluators=[_trivial_evaluator]),
    ]
    cfg = LoopConfig(max_steps=3, done_tool="done")
    preset = AgentPreset(config=cfg, hooks=hooks, tools=tools, state=DefaultState(max_steps=3))

    ws = preset_to_cartridge(preset, tmp_path / "ws")
    # Resource stubs were emitted for the four hook ctor args.
    resources = sorted(p.name for p in (tmp_path / "ws" / "resources").iterdir())
    assert "collector.py" in resources
    assert "engine.py" in resources
    assert "emitter.py" in resources
    assert "evaluators.py" in resources

    reloaded = cartridge_to_preset(tmp_path / "ws")
    # All four hooks survive — no silent drops.
    cls_names = sorted(type(h).__name__ for h in reloaded.hooks)
    assert cls_names == ["EvalHook", "MetricsHook", "PermissionHook", "StreamingHook"]


# ── Auto-emit improvements: list-of-callables + live-instance kwarg derivation ──


def _scripted_done(*, summary: str = "") -> dict:
    return {"status": "completed", "summary": summary}


def _eval_passed(ctx) -> "EvalResult":  # noqa: F821
    from looplet import EvalResult  # noqa: PLC0415

    return EvalResult(name="passed", passed=True)


def _eval_smoke(ctx) -> "EvalResult":  # noqa: F821
    from looplet import EvalResult  # noqa: PLC0415

    return EvalResult(name="smoke", passed=True)


def _emit_callback(event) -> None:
    pass


def test_list_of_top_level_callables_auto_emits_real_imports(tmp_path: Path) -> None:
    """When a hook kwarg holds a list of importable callables (e.g.
    ``EvalHook(evaluators=[a, b])``), the auto-emit machinery should
    write a builder that re-imports each callable by name — not fall
    back to a None-stub for ``builtins.list``."""
    from looplet import (
        AgentPreset,
        EvalHook,
        LoopConfig,
        ToolSpec,
    )
    from looplet.tools import BaseToolRegistry
    from looplet.types import DefaultState

    tools = BaseToolRegistry()
    tools.register(
        ToolSpec(name="done", description="d", parameters={"summary": "s"}, execute=_scripted_done)
    )
    hooks = [EvalHook(evaluators=[_eval_passed, _eval_smoke])]
    cfg = LoopConfig(max_steps=3, done_tool="done")
    preset = AgentPreset(config=cfg, hooks=hooks, tools=tools, state=DefaultState(max_steps=3))

    preset_to_cartridge(preset, tmp_path / "ws", strict=True)
    builder = (tmp_path / "ws" / "resources" / "evaluators.py").read_text()
    # Real ``from M import F`` lines for each evaluator, not a None-stub.
    assert "from tests.test_cartridge import _eval_passed" in builder
    assert "from tests.test_cartridge import _eval_smoke" in builder
    assert "return [_eval_passed, _eval_smoke]" in builder

    # Reload + check the evaluators came back identical.
    reloaded = cartridge_to_preset(tmp_path / "ws", strict=True)
    eh = reloaded.hooks[0]
    assert [fn.__name__ for fn in eh.evaluators] == ["_eval_passed", "_eval_smoke"]


def test_live_instance_kwarg_derivation_for_emitter(tmp_path: Path) -> None:
    """When a hook holds an instance whose required ctor kwarg is a
    top-level callable (e.g. ``StreamingHook(emitter=CallbackEmitter(fn))``
    with a module-level ``fn``), the auto-emit builder must re-import
    the callable rather than falling through to ``runtime.get(...)``
    (which would yield None and crash the loop at run time)."""
    from looplet import (
        AgentPreset,
        LoopConfig,
        StreamingHook,
        ToolSpec,
    )
    from looplet.streaming import CallbackEmitter
    from looplet.tools import BaseToolRegistry
    from looplet.types import DefaultState

    tools = BaseToolRegistry()
    tools.register(
        ToolSpec(name="done", description="d", parameters={"summary": "s"}, execute=_scripted_done)
    )
    hooks = [StreamingHook(emitter=CallbackEmitter(_emit_callback))]
    cfg = LoopConfig(max_steps=3, done_tool="done")
    preset = AgentPreset(config=cfg, hooks=hooks, tools=tools, state=DefaultState(max_steps=3))

    preset_to_cartridge(preset, tmp_path / "ws", strict=True)
    builder = (tmp_path / "ws" / "resources" / "emitter.py").read_text()
    assert "from looplet.streaming import CallbackEmitter" in builder
    assert "from tests.test_cartridge import _emit_callback" in builder
    assert "callback=_emit_callback" in builder
    # Specifically must NOT regress to the old ``runtime.get('callback')``
    # template, which silently produced a None callback and crashed at
    # ``self._callback(event)``.
    assert "runtime.get('callback')" not in builder

    # Reload — the callback should be the same module-level function.
    reloaded = cartridge_to_preset(tmp_path / "ws", strict=True)
    emitter = reloaded.hooks[0]._emitter
    cb = getattr(emitter, "_callback", None) or getattr(emitter, "callback", None)
    assert cb is _emit_callback


def test_triple_round_trip_is_byte_idempotent(tmp_path: Path) -> None:
    """preset -> ws1 -> preset' -> ws2: every file in ws1 and ws2 must
    be byte-identical. Catches non-deterministic ordering and
    auto-emit drift."""
    from looplet import (
        AgentPreset,
        EvalHook,
        LoopConfig,
        StreamingHook,
        ToolSpec,
    )
    from looplet.streaming import CallbackEmitter
    from looplet.tools import BaseToolRegistry
    from looplet.types import DefaultState

    tools = BaseToolRegistry()
    tools.register(
        ToolSpec(name="done", description="d", parameters={"summary": "s"}, execute=_scripted_done)
    )
    hooks = [
        EvalHook(evaluators=[_eval_passed, _eval_smoke]),
        StreamingHook(emitter=CallbackEmitter(_emit_callback)),
    ]
    cfg = LoopConfig(max_steps=3, done_tool="done")
    preset = AgentPreset(config=cfg, hooks=hooks, tools=tools, state=DefaultState(max_steps=3))

    ws1 = tmp_path / "ws1"
    preset_to_cartridge(preset, ws1, name="rt", strict=True)
    preset2 = cartridge_to_preset(ws1, strict=True)
    ws2 = tmp_path / "ws2"
    preset_to_cartridge(preset2, ws2, name="rt", strict=True)

    files1 = sorted(
        p.relative_to(ws1) for p in ws1.rglob("*") if p.is_file() and "__pycache__" not in p.parts
    )
    files2 = sorted(
        p.relative_to(ws2) for p in ws2.rglob("*") if p.is_file() and "__pycache__" not in p.parts
    )
    assert files1 == files2, f"file-set drift: {set(files1) ^ set(files2)}"

    drifts = []
    for rel in files1:
        a = (ws1 / rel).read_text(encoding="utf-8")
        b = (ws2 / rel).read_text(encoding="utf-8")
        if a != b:
            drifts.append(str(rel))
    assert not drifts, f"byte drift in: {drifts}"


# ── CallableMemorySource round-trip + dataclass auto-emit ─────────────


def _live_state_load(state) -> str:
    """Top-level memory loader so workspace round-trip can re-import it."""
    step = getattr(state, "step_count", 0) or len(getattr(state, "steps", []) or [])
    return f"[live] step={step}"


def test_callable_memory_source_round_trips(tmp_path: Path) -> None:
    """``CallableMemorySource(fn=top_level_fn)`` round-trips losslessly:
    writer emits ``memory/<idx>_callable.py`` with a re-import; loader
    wraps the exported ``load`` symbol back into a CallableMemorySource."""
    from looplet import (
        AgentPreset,
        CallableMemorySource,
        LoopConfig,
        StaticMemorySource,
        ToolSpec,
    )
    from looplet.tools import BaseToolRegistry
    from looplet.types import DefaultState

    tools = BaseToolRegistry()
    tools.register(
        ToolSpec(name="done", description="d", parameters={"summary": "s"}, execute=_scripted_done)
    )
    cfg = LoopConfig(
        max_steps=3,
        done_tool="done",
        memory_sources=[
            StaticMemorySource(text="A"),
            CallableMemorySource(fn=_live_state_load),
            StaticMemorySource(text="B"),
        ],
    )
    preset = AgentPreset(config=cfg, hooks=[], tools=tools, state=DefaultState(max_steps=3))
    ws = preset_to_cartridge(preset, tmp_path / "ws", strict=True)
    assert ws.serialization_warnings == []
    assert (tmp_path / "ws" / "memory" / "01_callable.py").is_file()

    reloaded = cartridge_to_preset(tmp_path / "ws", strict=True)
    types = [type(s).__name__ for s in (reloaded.config.memory_sources or [])]
    assert types == ["StaticMemorySource", "CallableMemorySource", "StaticMemorySource"]

    # The reloaded callable behaves identically.
    cm = reloaded.config.memory_sources[1]
    assert cm.fn(DefaultState(max_steps=3)) == "[live] step=0"


def test_callable_memory_source_lambda_warns_in_loose_mode(tmp_path: Path) -> None:
    """Lambdas / closures cannot be re-imported; writer falls back to a
    warning instead of dropping silently or crashing."""
    from looplet import (
        AgentPreset,
        CallableMemorySource,
        LoopConfig,
        ToolSpec,
    )
    from looplet.tools import BaseToolRegistry
    from looplet.types import DefaultState

    tools = BaseToolRegistry()
    tools.register(
        ToolSpec(name="done", description="d", parameters={"summary": "s"}, execute=_scripted_done)
    )
    cfg = LoopConfig(
        max_steps=3,
        done_tool="done",
        memory_sources=[CallableMemorySource(fn=lambda state: "x")],
    )
    preset = AgentPreset(config=cfg, hooks=[], tools=tools, state=DefaultState(max_steps=3))
    ws = preset_to_cartridge(preset, tmp_path / "ws", strict=False)
    assert any("CallableMemorySource" in w for w in ws.serialization_warnings)


def test_dataclass_auto_emit_reproduces_field_state(tmp_path: Path) -> None:
    """When a hook holds a dataclass instance with non-default field
    values (e.g. ``PermissionEngine(rules=[PermissionRule(...), ...])``),
    the auto-emit builder must reproduce every field — not just the
    required ctor args. Previously ``PermissionEngine`` round-tripped
    with empty rules because ``rules`` has a default_factory and the
    generic builder skipped non-required kwargs."""
    from looplet import (
        AgentPreset,
        LoopConfig,
        PermissionDecision,
        PermissionEngine,
        PermissionHook,
        PermissionRule,
        ToolSpec,
    )
    from looplet.tools import BaseToolRegistry
    from looplet.types import DefaultState

    tools = BaseToolRegistry()
    tools.register(
        ToolSpec(name="done", description="d", parameters={"summary": "s"}, execute=_scripted_done)
    )
    engine = PermissionEngine(
        rules=[
            PermissionRule(tool="dangerous", decision=PermissionDecision.DENY, reason="audit-only"),
            PermissionRule(tool="*", decision=PermissionDecision.ALLOW),
        ]
    )
    hooks = [PermissionHook(engine=engine)]
    cfg = LoopConfig(max_steps=3, done_tool="done")
    preset = AgentPreset(config=cfg, hooks=hooks, tools=tools, state=DefaultState(max_steps=3))

    preset_to_cartridge(preset, tmp_path / "ws", strict=True)
    builder = (tmp_path / "ws" / "resources" / "engine.py").read_text()
    # Real reproduction — not an empty-rules shell.
    assert "PermissionEngine(rules=[" in builder
    assert "tool='dangerous'" in builder
    assert "PermissionDecision.DENY" in builder

    reloaded = cartridge_to_preset(tmp_path / "ws", strict=True)
    eng = reloaded.hooks[0].engine
    assert [r.tool for r in eng.rules] == ["dangerous", "*"]
    assert [r.decision for r in eng.rules] == [
        PermissionDecision.DENY,
        PermissionDecision.ALLOW,
    ]


# ── v2 tool.yaml optional-default + _chw_* dynamic-module round-trip ──


def test_v2_tool_yaml_default_marks_param_optional(tmp_path: Path) -> None:
    """``tool.yaml`` parameters declared as
    ``{name: {type, description, default}}`` (the format every shipped
    workspace uses) must treat ``default``-bearing entries as optional.
    Previously the loader fell into the simple-format branch which
    only recognised the ``"(optional) ..."`` description prefix, so
    every dict-shaped param was reported missing on dispatch."""
    from looplet import ToolSpec

    spec = ToolSpec(
        name="lst",
        description="d",
        parameters={
            "path": {"type": "string", "description": "p"},
            "depth": {"type": "integer", "description": "d", "default": 2},
        },
        execute=lambda **k: k,
    )
    assert spec.required_parameters() == ["path"]


def test_chw_resource_round_trip_copies_original_source(tmp_path: Path) -> None:
    """When a hook holds a live instance whose class came from the
    workspace's own ``_chw_resource_<name>`` dynamic module, the
    snapshot writer must copy the original ``resources/<name>.py`` file
    verbatim instead of dropping a None-stub. Catches the case where
    a workspace defines a custom resource class inline (e.g.
    ``GreetingLog`` in the hello example)."""
    src = tmp_path / "src_ws"
    src.mkdir()
    (src / "cartridge.json").write_text('{"name": "src_ws"}')
    (src / "config.yaml").write_text("max_steps: 3\ndone_tool: done\n")
    (src / "tools" / "done").mkdir(parents=True)
    (src / "tools/done/tool.yaml").write_text(
        "name: done\nparameters:\n  s: {type: string, description: s}\n"
    )
    (src / "tools/done/execute.py").write_text(
        "def execute(*, s='ok'): return {'status': 'completed', 's': s}\n"
    )
    (src / "resources").mkdir()
    custom_src = (
        '"""Custom inline resource — exists only inside this workspace."""\n'
        "class CustomLog:\n"
        "    def __init__(self):\n"
        "        self.entries = []\n"
        "def build():\n"
        "    return CustomLog()\n"
    )
    (src / "resources/custom_log.py").write_text(custom_src)
    (src / "hooks" / "00_LogHook").mkdir(parents=True)
    (src / "hooks/00_LogHook/hook.py").write_text(
        "class LogHook:\n"
        "    def __init__(self, *, log): self.log = log\n"
        "    def to_config(self): return {'log': '@custom_log'}\n"
    )
    (src / "hooks/00_LogHook/config.yaml").write_text(
        'class_name: LogHook\nkwargs:\n  log: "@custom_log"\n'
    )

    preset = cartridge_to_preset(src, strict=True)
    snap = tmp_path / "snap"
    ws = preset_to_cartridge(preset, snap, name="snap", strict=False)
    # Resource source must come back verbatim — no None-stub warnings.
    assert not any("custom_log" in w for w in ws.serialization_warnings), (
        f"unexpected warnings: {ws.serialization_warnings}"
    )
    snap_resource = (snap / "resources" / "custom_log.py").read_text()
    assert snap_resource == custom_src

    # Reload the snapshot — the LogHook's log attribute must be a fresh
    # CustomLog instance (not None).
    reloaded = cartridge_to_preset(snap, strict=True)
    assert type(reloaded.hooks[0].log).__name__ == "CustomLog"


def test_chw_hook_inline_class_round_trips(tmp_path: Path) -> None:
    """When a hook's class is defined inline in the workspace's own
    ``hooks/<name>/hook.py`` (not subclassing an installed class), the
    re-snapshot writer must copy the on-disk source instead of falling
    back to ``class X: pass``."""
    src = tmp_path / "src_ws"
    src.mkdir()
    (src / "cartridge.json").write_text('{"name": "src_ws"}')
    (src / "config.yaml").write_text("max_steps: 3\ndone_tool: done\n")
    (src / "tools" / "done").mkdir(parents=True)
    (src / "tools/done/tool.yaml").write_text(
        "name: done\nparameters:\n  s: {type: string, description: s}\n"
    )
    (src / "tools/done/execute.py").write_text(
        "def execute(*, s='ok'): return {'status': 'completed', 's': s}\n"
    )
    (src / "hooks" / "00_GateHook").mkdir(parents=True)
    hook_src = (
        '"""Inline gate hook with a meaningful body."""\n'
        "class GateHook:\n"
        "    SENTINEL = 'inline-marker-XYZ'\n"
        "    def to_config(self): return {}\n"
    )
    (src / "hooks/00_GateHook/hook.py").write_text(hook_src)

    preset = cartridge_to_preset(src, strict=True)
    snap = tmp_path / "snap"
    preset_to_cartridge(preset, snap, name="snap", strict=True)
    snap_hook = (snap / "hooks" / "00_GateHook" / "hook.py").read_text()
    # Must contain the inline marker — proves source was preserved.
    assert "inline-marker-XYZ" in snap_hook


# ── Declarative @ref memory + workspace-helper copy + byte-identity ──


def _project_memory_loader(state) -> str:
    """Top-level loader returning a per-step memory line."""
    return f"step={getattr(state, 'step_count', 0)}"


def test_memory_sources_yaml_ref_resolves_via_resource_registry(tmp_path: Path) -> None:
    """``memory_sources: ['@ref']`` in config.yaml round-trips through
    the same resource-builder mechanism the hook kwargs use."""
    src = tmp_path / "ws"
    src.mkdir()
    (src / "cartridge.json").write_text('{"name": "w"}')
    (src / "config.yaml").write_text(
        'max_steps: 3\ndone_tool: done\nmemory_sources:\n  - "@dyn_memory"\n'
    )
    (src / "tools" / "done").mkdir(parents=True)
    (src / "tools/done/tool.yaml").write_text(
        "name: done\nparameters:\n  s: {type: string, description: s}\n"
    )
    (src / "tools/done/execute.py").write_text(
        "def execute(*, s='ok'): return {'status': 'completed', 's': s}\n"
    )
    (src / "resources").mkdir()
    (src / "resources/dyn_memory.py").write_text(
        "from looplet import CallableMemorySource\n"
        "def build(runtime=None):\n"
        "    return CallableMemorySource(lambda state: 'dyn-memory-content')\n"
    )

    preset = cartridge_to_preset(src, strict=True)
    sources = preset.config.memory_sources or []
    assert len(sources) == 1
    assert type(sources[0]).__name__ == "CallableMemorySource"
    assert sources[0].load(None) == "dyn-memory-content"


def test_callable_memory_via_chw_resource_round_trips_losslessly(tmp_path: Path) -> None:
    """A ``CallableMemorySource`` whose lambda was built inside a
    ``resources/<name>.py`` builder must snapshot back to a
    ``memory_sources: ['@<name>']`` entry + verbatim resource copy,
    not warn about a non-importable lambda."""
    src = tmp_path / "ws"
    src.mkdir()
    (src / "cartridge.json").write_text('{"name": "w"}')
    (src / "config.yaml").write_text(
        'max_steps: 3\ndone_tool: done\nmemory_sources:\n  - "@dyn_memory"\n'
    )
    (src / "tools" / "done").mkdir(parents=True)
    (src / "tools/done/tool.yaml").write_text(
        "name: done\nparameters:\n  s: {type: string, description: s}\n"
    )
    (src / "tools/done/execute.py").write_text(
        "def execute(*, s='ok'): return {'status': 'completed', 's': s}\n"
    )
    (src / "resources").mkdir()
    builder_src = (
        "from looplet import CallableMemorySource\n"
        "def build(runtime=None):\n"
        "    runtime = runtime or {}\n"
        "    label = str(runtime.get('label', 'X'))\n"
        "    return CallableMemorySource(lambda state: f'lab={label}')\n"
    )
    (src / "resources/dyn_memory.py").write_text(builder_src)

    preset = cartridge_to_preset(src, runtime={"label": "Z"}, strict=True)
    snap = tmp_path / "snap"
    ws = preset_to_cartridge(preset, snap, name="snap")
    assert ws.serialization_warnings == [], f"unexpected warnings: {ws.serialization_warnings}"
    # Snapshot config.yaml carries the @ref entry, not a None-stub class:
    snap_cfg = (snap / "config.yaml").read_text()
    assert '"@dyn_memory"' in snap_cfg or "@dyn_memory" in snap_cfg
    # And the resource file was copied verbatim:
    assert (snap / "resources" / "dyn_memory.py").read_text() == builder_src


def test_workspace_helpers_are_copied_into_snapshot(tmp_path: Path) -> None:
    """Top-level ``*.py`` helper modules at the source workspace root
    (e.g. ``coder_lib_tools.py``) must be vendored into the snapshot
    so cross-process reload doesn't crash with ModuleNotFoundError."""
    src = tmp_path / "ws"
    src.mkdir()
    (src / "cartridge.json").write_text('{"name": "w"}')
    (src / "config.yaml").write_text("max_steps: 3\ndone_tool: done\n")
    (src / "tools" / "done").mkdir(parents=True)
    (src / "tools/done/tool.yaml").write_text(
        "name: done\nparameters:\n  s: {type: string, description: s}\n"
    )
    (src / "tools/done/execute.py").write_text(
        "from helper_lib import HELLO\n"
        "def execute(*, s='ok'): return {'status': 'completed', 'msg': HELLO + s}\n"
    )
    helper_src = "HELLO = 'hi-from-helper-'\n"
    (src / "helper_lib.py").write_text(helper_src)

    preset = cartridge_to_preset(src, strict=True)
    snap = tmp_path / "snap"
    preset_to_cartridge(preset, snap, name="snap")
    # The helper module was copied into the snapshot.
    assert (snap / "helper_lib.py").read_text() == helper_src


def test_two_pass_round_trip_is_byte_identical(tmp_path: Path) -> None:
    """preset → ws1 → preset' → ws2: ws1 and ws2 must be byte-identical
    for a non-trivial preset. Catches header-stacking, MRO-walk drift,
    and other normalization gaps."""
    src = tmp_path / "ws"
    src.mkdir()
    (src / "cartridge.json").write_text('{"name": "w"}')
    (src / "config.yaml").write_text("max_steps: 3\ndone_tool: done\n")
    (src / "tools" / "done").mkdir(parents=True)
    (src / "tools/done/tool.yaml").write_text(
        "name: done\nparameters:\n  s: {type: string, description: s}\n"
    )
    (src / "tools/done/execute.py").write_text(
        "def execute(*, s='ok'): return {'status': 'completed', 's': s}\n"
    )
    (src / "hooks" / "00_StagnationHook").mkdir(parents=True)
    (src / "hooks/00_StagnationHook/hook.py").write_text(
        "from looplet.stagnation import StagnationHook as StagnationHook\n"
    )
    (src / "hooks/00_StagnationHook/config.yaml").write_text(
        "class_name: StagnationHook\nkwargs: {}\n"
    )

    preset_a = cartridge_to_preset(src, strict=True)
    ws1 = tmp_path / "ws1"
    preset_to_cartridge(preset_a, ws1, name="rt")
    preset_b = cartridge_to_preset(ws1, strict=True)
    ws2 = tmp_path / "ws2"
    preset_to_cartridge(preset_b, ws2, name="rt")

    files1 = sorted(
        p.relative_to(ws1) for p in ws1.rglob("*") if p.is_file() and "__pycache__" not in p.parts
    )
    files2 = sorted(
        p.relative_to(ws2) for p in ws2.rglob("*") if p.is_file() and "__pycache__" not in p.parts
    )
    assert files1 == files2

    diffs = []
    for rel in files1:
        a = (ws1 / rel).read_text(encoding="utf-8")
        b = (ws2 / rel).read_text(encoding="utf-8")
        if a != b:
            diffs.append((str(rel), len(a), len(b)))
    assert not diffs, f"byte drift: {diffs}"


# ── Tool DI via requires + ctx.resources ──


def _di_tool_execute(ctx, *, x: int) -> dict:
    """Top-level tool that reads its dependency from ctx.resources."""
    cfg = ctx.resources.get("workspace_config")
    return {"x": x, "label": getattr(cfg, "label", "no-cfg")}


def test_tool_requires_round_trips_via_yaml(tmp_path: Path) -> None:
    """``tool.yaml`` ``requires:`` field round-trips through
    ``ToolSpec.requires`` and the dispatcher passes the resolved
    instances via ``ctx.resources[name]``."""
    src = tmp_path / "ws"
    src.mkdir()
    (src / "cartridge.json").write_text('{"name": "w"}')
    (src / "config.yaml").write_text("max_steps: 3\ndone_tool: done\n")
    (src / "tools" / "done").mkdir(parents=True)
    (src / "tools/done/tool.yaml").write_text(
        "name: done\nparameters:\n  s: {type: string, description: s}\n"
    )
    (src / "tools/done/execute.py").write_text(
        "def execute(*, s='ok'): return {'status': 'completed', 's': s}\n"
    )
    (src / "tools" / "demo").mkdir(parents=True)
    (src / "tools/demo/tool.yaml").write_text(
        "name: demo\n"
        "parameters:\n  x: {type: integer, description: x}\n"
        "requires:\n  - workspace_config\n"
    )
    (src / "tools/demo/execute.py").write_text(
        "from tests.test_cartridge import _di_tool_execute as execute\n"
    )
    (src / "resources").mkdir()
    (src / "resources/workspace_config.py").write_text(
        "class _Cfg:\n    label = 'configured'\ndef build(runtime=None):\n    return _Cfg()\n"
    )

    preset = cartridge_to_preset(src, strict=True)
    spec = preset.tools._tools["demo"]
    assert spec.requires == ["workspace_config"]
    # Dispatch through the registry → ctx.resources receives the live cfg.
    from looplet.types import ToolCall

    result = preset.tools.dispatch(ToolCall(tool="demo", args={"x": 7}))
    assert result.error is None
    assert result.data == {"x": 7, "label": "configured"}

    # Snapshot round-trip preserves requires.
    snap = tmp_path / "snap"
    preset_to_cartridge(preset, snap, name="snap")
    snap_yaml = (snap / "tools" / "demo" / "tool.yaml").read_text()
    assert "requires:" in snap_yaml
    assert "workspace_config" in snap_yaml


def test_tool_without_ctx_logs_warning_when_requires_set(tmp_path: Path, caplog) -> None:
    """A ``requires:`` declaration on a tool that doesn't accept ``ctx``
    is a configuration mistake — the dispatcher logs a warning so
    users notice instead of silently dropping the dependency."""
    import logging

    from looplet import ToolSpec
    from looplet.tools import BaseToolRegistry
    from looplet.types import ToolCall

    def _no_ctx_execute(*, x: int) -> dict:
        return {"x": x}

    reg = BaseToolRegistry()
    reg.set_resources({"workspace_config": object()})
    reg.register(
        ToolSpec(
            name="demo",
            description="d",
            parameters={"x": {"type": "integer", "description": "x"}},
            execute=_no_ctx_execute,
            requires=["workspace_config"],
        )
    )

    with caplog.at_level(logging.WARNING, logger="looplet.tools"):
        result = reg.dispatch(ToolCall(tool="demo", args={"x": 1}))
    assert result.error is None
    assert any("ctx" in rec.message for rec in caplog.records)


# ── resource_ref_for + identity-keyed origin tracking ──


def test_resource_ref_for_preserves_original_ref_name(tmp_path: Path) -> None:
    """When a hook holds an instance built by ``resources/<name>.py``,
    ``to_config()`` round-trips with the *original* ``"@<name>"`` ref
    even when the instance's class lives in a third-party module.
    Catches the friction where ``PermissionHook.to_config()`` always
    returned ``"@engine"`` instead of ``"@sql_permissions"``."""
    src = tmp_path / "ws"
    src.mkdir()
    (src / "cartridge.json").write_text('{"name": "w"}')
    (src / "config.yaml").write_text("max_steps: 3\ndone_tool: done\n")
    (src / "tools" / "done").mkdir(parents=True)
    (src / "tools/done/tool.yaml").write_text(
        "name: done\nparameters:\n  s: {type: string, description: s}\n"
    )
    (src / "tools/done/execute.py").write_text(
        "def execute(*, s='ok'): return {'status': 'completed', 's': s}\n"
    )
    (src / "resources").mkdir()
    (src / "resources/sql_permissions.py").write_text(
        "from looplet import PermissionDecision, PermissionEngine, PermissionRule\n"
        "def build(runtime=None):\n"
        "    return PermissionEngine(rules=[\n"
        "        PermissionRule(tool='*', decision=PermissionDecision.ALLOW),\n"
        "    ])\n"
    )
    (src / "hooks" / "00_PermissionHook").mkdir(parents=True)
    (src / "hooks/00_PermissionHook/hook.py").write_text(
        "from looplet.permissions import PermissionHook as PermissionHook\n"
    )
    (src / "hooks/00_PermissionHook/config.yaml").write_text(
        'class_name: PermissionHook\nkwargs:\n  engine: "@sql_permissions"\n'
    )

    preset = cartridge_to_preset(src, strict=True)
    snap1 = tmp_path / "snap1"
    preset_to_cartridge(preset, snap1, name="snap")

    # Snapshot config.yaml carries the ORIGINAL ref name, not "@engine":
    snap_yaml = (snap1 / "hooks" / "00_PermissionHook" / "config.yaml").read_text()
    assert "@sql_permissions" in snap_yaml, f"got: {snap_yaml}"

    # Snapshot resources/sql_permissions.py is the verbatim source,
    # not an auto-generated stub:
    snap_resource = (snap1 / "resources" / "sql_permissions.py").read_text()
    original = (src / "resources" / "sql_permissions.py").read_text()
    assert snap_resource == original

    # Two-pass byte-identical round-trip — the rules survive intact.
    preset2 = cartridge_to_preset(snap1, strict=True)
    snap2 = tmp_path / "snap2"
    preset_to_cartridge(preset2, snap2, name="snap")
    assert (snap1 / "resources" / "sql_permissions.py").read_text() == (
        snap2 / "resources" / "sql_permissions.py"
    ).read_text()


def test_workspace_root_resources_dir_is_on_sys_path(tmp_path: Path) -> None:
    """Tools and other workspace modules must be able to ``from
    <resource_name> import helper`` — the loader pushes both the
    workspace root AND its ``resources/`` subdir onto ``sys.path``
    for the duration of the load."""
    src = tmp_path / "ws"
    src.mkdir()
    (src / "cartridge.json").write_text('{"name": "w"}')
    (src / "config.yaml").write_text("max_steps: 3\ndone_tool: done\n")
    (src / "tools" / "done").mkdir(parents=True)
    (src / "tools/done/tool.yaml").write_text(
        "name: done\nparameters:\n  s: {type: string, description: s}\n"
    )
    (src / "tools/done/execute.py").write_text(
        "def execute(*, s='ok'): return {'status': 'completed', 's': s}\n"
    )
    (src / "tools" / "ping").mkdir(parents=True)
    (src / "tools/ping/tool.yaml").write_text("name: ping\nparameters: {}\n")
    # Imports from a resources/-defined helper module — this would
    # fail with ModuleNotFoundError if resources/ wasn't on sys.path.
    (src / "tools/ping/execute.py").write_text(
        "from helper_resource import greet\ndef execute(): return {'msg': greet()}\n"
    )
    (src / "resources").mkdir()
    (src / "resources/helper_resource.py").write_text(
        "def greet():\n    return 'hello-from-resource'\n"
        "def build(runtime=None):\n    return greet\n"
    )

    preset = cartridge_to_preset(src, strict=True)
    from looplet.types import ToolCall

    result = preset.tools.dispatch(ToolCall(tool="ping", args={}))
    assert result.error is None
    assert result.data == {"msg": "hello-from-resource"}


# ── tool requires: validation at load time ──


def test_tool_requires_unknown_resource_warns_in_loose_mode(tmp_path: Path, caplog) -> None:
    """A typo in tool.yaml ``requires:`` must be surfaced at load time
    (loose mode → warning) rather than silently set to None and crashing
    deep inside the tool body at dispatch time."""
    import logging

    src = tmp_path / "ws"
    src.mkdir()
    (src / "cartridge.json").write_text('{"name": "w"}')
    (src / "config.yaml").write_text("max_steps: 3\ndone_tool: done\n")
    (src / "tools" / "done").mkdir(parents=True)
    (src / "tools/done/tool.yaml").write_text(
        "name: done\nparameters:\n  s: {type: string, description: s}\n"
    )
    (src / "tools/done/execute.py").write_text(
        "def execute(*, s='ok'): return {'status': 'completed', 's': s}\n"
    )
    (src / "tools" / "demo").mkdir(parents=True)
    # Typo — workspace_confgi instead of workspace_config.
    (src / "tools/demo/tool.yaml").write_text(
        "name: demo\nparameters: {}\nrequires:\n  - workspace_confgi\n"
    )
    (src / "tools/demo/execute.py").write_text("def execute(ctx): return {}\n")

    with caplog.at_level(logging.WARNING, logger="looplet.workspace"):
        cartridge_to_preset(src)
    assert any("workspace_confgi" in rec.message for rec in caplog.records), (
        f"expected warning about missing resource; got: {[r.message for r in caplog.records]}"
    )


def test_tool_requires_unknown_resource_raises_in_strict_mode(tmp_path: Path) -> None:
    """Same typo, strict=True → CartridgeSerializationError before the
    loop ever runs."""
    src = tmp_path / "ws"
    src.mkdir()
    (src / "cartridge.json").write_text('{"name": "w"}')
    (src / "config.yaml").write_text("max_steps: 3\ndone_tool: done\n")
    (src / "tools" / "done").mkdir(parents=True)
    (src / "tools/done/tool.yaml").write_text(
        "name: done\nparameters:\n  s: {type: string, description: s}\n"
    )
    (src / "tools/done/execute.py").write_text(
        "def execute(*, s='ok'): return {'status': 'completed', 's': s}\n"
    )
    (src / "tools" / "demo").mkdir(parents=True)
    (src / "tools/demo/tool.yaml").write_text(
        "name: demo\nparameters: {}\nrequires:\n  - missing_resource\n"
    )
    (src / "tools/demo/execute.py").write_text("def execute(ctx): return {}\n")

    with pytest.raises(CartridgeSerializationError, match="missing_resource"):
        cartridge_to_preset(src, strict=True)


# ── _resource_origin must not leak across tests ──


def test_resource_origin_drops_unhashable_to_prevent_id_reuse_collision(
    tmp_path: Path,
) -> None:
    """Regression test for a CI-only flake on master after PR #39:
    when ``_load_resources`` registered an ``EvalHook.evaluators``
    list (built by a workspace ``resources/eval_evaluators.py``) into
    the identity-keyed ``_resource_origin``, the entry pinned forever
    because ``weakref.finalize(list, ...)`` raises TypeError. Python's
    ``id()`` is reused after GC, so a *different* test's brand-new
    evaluators list could land at the same id and inherit the
    previous workspace's ref name — making the writer try to copy a
    no-longer-existent ``resources/<name>.py`` file.

    Fix: instances that don't support weak references are NOT
    registered in ``_resource_origin``; the ``__module__``-based
    fallback in ``resource_ref_for`` handles them via the
    synthetic ``_chw_resource_<name>`` module name on closures
    defined inside the resource builder.
    """
    from looplet import refs as _ws

    src = tmp_path / "ws"
    src.mkdir()
    (src / "cartridge.json").write_text('{"name": "w", "schema_version": 2}')
    (src / "config.yaml").write_text("max_steps: 3\ndone_tool: done\n")
    (src / "tools" / "done").mkdir(parents=True)
    (src / "tools/done/tool.yaml").write_text(
        "name: done\nparameters:\n  s: {type: string, description: s}\n"
    )
    (src / "tools/done/execute.py").write_text(
        "def execute(*, s='ok'): return {'status': 'completed', 's': s}\n"
    )
    (src / "resources").mkdir()
    (src / "resources/my_list.py").write_text(
        "def build(runtime=None):\n"
        "    # A bare list — built-in containers don't support weakref.\n"
        "    return [lambda x: x]\n"
    )

    before = len(_ws._resource_origin)
    cartridge_to_preset(src)
    after = len(_ws._resource_origin)  # noqa: SLF001
    # The list returned by build() must NOT have been registered (would
    # leak a stale id → name mapping across loads / tests).
    assert after == before, (
        "unhashable resource instance was registered in _resource_origin; "
        "this would leak a stale id reservation that a later same-typed "
        f"object could collide with (size went from {before} to {after})"
    )


# ── Unified ${kind:value} reference grammar ────────────────────


class TestUnifiedReferenceGrammar:
    """Tests for the ${ref:...} / ${py:...} / ${runtime.x} grammar."""

    def test_legacy_at_ref_still_resolves(self) -> None:
        from looplet.cartridge import _resolve_refs

        sentinel = object()
        assert _resolve_refs("@x", {"x": sentinel}) is sentinel

    def test_dollar_ref_resolves_resource_by_name(self) -> None:
        from looplet.cartridge import _resolve_refs

        sentinel = object()
        assert _resolve_refs("${ref:x}", {"x": sentinel}) is sentinel

    def test_dollar_ref_unknown_resource_raises(self) -> None:
        from looplet.cartridge import CartridgeSerializationError, _resolve_refs

        with pytest.raises(CartridgeSerializationError, match="ref:missing"):
            _resolve_refs("${ref:missing}", {})

    def test_py_ref_imports_module_symbol(self) -> None:
        from looplet.cartridge import _resolve_refs

        cls = _resolve_refs("${py:looplet.loop:LoopConfig}", {})
        assert cls.__name__ == "LoopConfig"

    def test_py_ref_supports_dot_separator(self) -> None:
        from looplet.cartridge import _resolve_refs

        cls = _resolve_refs("${py:looplet.loop.LoopConfig}", {})
        assert cls.__name__ == "LoopConfig"

    def test_py_ref_nested_attribute(self) -> None:
        from looplet.cartridge import _resolve_refs

        # Walk to a method on a class.
        method = _resolve_refs("${py:looplet.loop:LoopConfig.__init__}", {})
        assert callable(method)

    def test_py_ref_unknown_module_raises(self) -> None:
        from looplet.cartridge import CartridgeSerializationError, _resolve_refs

        with pytest.raises(CartridgeSerializationError, match="not.exist"):
            _resolve_refs("${py:looplet.does.not.exist:Foo}", {})

    def test_py_ref_unknown_symbol_raises(self) -> None:
        from looplet.cartridge import CartridgeSerializationError, _resolve_refs

        with pytest.raises(CartridgeSerializationError, match="DefinitelyNotASymbol"):
            _resolve_refs("${py:looplet.loop:DefinitelyNotASymbol}", {})

    def test_runtime_ref_resolves_field(self) -> None:
        from looplet.cartridge import _resolve_refs

        assert _resolve_refs("${runtime.x}", {}, runtime={"x": 42}) == 42

    def test_runtime_ref_dotted_path(self) -> None:
        from looplet.cartridge import _resolve_refs

        runtime = {"a": {"b": {"c": "deep"}}}
        assert _resolve_refs("${runtime.a.b.c}", {}, runtime=runtime) == "deep"

    def test_runtime_ref_default_when_missing(self) -> None:
        from looplet.cartridge import _resolve_refs

        assert _resolve_refs("${runtime.x:-fallback}", {}, runtime={}) == "fallback"

    def test_runtime_ref_default_coerces_int(self) -> None:
        from looplet.cartridge import _resolve_refs

        assert _resolve_refs("${runtime.x:-15}", {}, runtime={}) == 15

    def test_runtime_ref_default_coerces_bool_and_none(self) -> None:
        from looplet.cartridge import _resolve_refs

        assert _resolve_refs("${runtime.x:-true}", {}, runtime={}) is True
        assert _resolve_refs("${runtime.x:-null}", {}, runtime={}) is None

    def test_runtime_ref_unknown_no_default_raises(self) -> None:
        from looplet.cartridge import CartridgeSerializationError, _resolve_refs

        with pytest.raises(CartridgeSerializationError, match="runtime"):
            _resolve_refs("${runtime.x}", {}, runtime={})

    def test_recurses_into_dicts_and_lists(self) -> None:
        from looplet.cartridge import _resolve_refs

        sentinel = object()
        out = _resolve_refs(
            {"a": "@x", "b": ["${ref:x}", "literal"]},
            {"x": sentinel},
        )
        assert out == {"a": sentinel, "b": [sentinel, "literal"]}

    def test_non_string_passes_through(self) -> None:
        from looplet.cartridge import _resolve_refs

        assert _resolve_refs(42, {}) == 42
        assert _resolve_refs(None, {}) is None
        assert _resolve_refs(True, {}) is True

    def test_string_without_ref_passes_through(self) -> None:
        from looplet.cartridge import _resolve_refs

        assert _resolve_refs("plain string", {}) == "plain string"
        assert _resolve_refs("$not a ref", {}) == "$not a ref"

    def test_workspace_loaded_preset_exposes_resources_dict(self, tmp_path: Path) -> None:
        """``AgentPreset.resources`` is populated from the workspace."""
        ws = tmp_path / "rsrc.workspace"
        ws.mkdir()
        (ws / "cartridge.json").write_text(
            '{"schema_version": 2, "name": "rsrc", "description": "test"}'
        )
        (ws / "config.yaml").write_text("max_steps: 10\n")
        (ws / "prompts").mkdir()
        (ws / "prompts" / "system.md").write_text("test prompt")
        (ws / "tools" / "done").mkdir(parents=True)
        (ws / "tools" / "done" / "tool.yaml").write_text(
            "name: done\ndescription: Mark task complete\nparameters:\n"
            "  summary:\n    type: string\n    description: brief summary\n"
        )
        (ws / "tools" / "done" / "execute.py").write_text(
            "def execute(*, summary):\n    return {'summary': summary}\n"
        )
        (ws / "resources").mkdir()
        (ws / "resources" / "my_resource.py").write_text(
            "def build(runtime=None):\n    return {'kind': 'my-resource'}\n"
        )

        from looplet.cartridge import cartridge_to_preset

        preset = cartridge_to_preset(ws)
        assert "my_resource" in preset.resources
        assert preset.resources["my_resource"] == {"kind": "my-resource"}


class TestDeclarativeStateField:
    """Tests for the ``state:`` directive in config.yaml."""

    def _scaffold(self, root: Path, config_extra: str) -> None:
        """Build a minimal workspace with the given ``config.yaml`` extras."""
        root.mkdir()
        (root / "cartridge.json").write_text(
            '{"schema_version": 2, "name": "s", "description": "test"}'
        )
        (root / "config.yaml").write_text(f"max_steps: 10\n{config_extra}\n")
        (root / "prompts").mkdir()
        (root / "prompts" / "system.md").write_text("p")
        (root / "tools" / "done").mkdir(parents=True)
        (root / "tools" / "done" / "tool.yaml").write_text(
            "name: done\ndescription: done\nparameters:\n"
            "  summary:\n    type: string\n    description: s\n"
        )
        (root / "tools" / "done" / "execute.py").write_text(
            "def execute(*, summary):\n    return {'summary': summary}\n"
        )

    def test_state_via_resource_ref(self, tmp_path: Path) -> None:
        """state: ${ref:my_state} pulls a resource."""
        ws = tmp_path / "s.workspace"
        self._scaffold(ws, 'state: "${ref:my_state}"')
        (ws / "resources").mkdir()
        (ws / "resources" / "my_state.py").write_text(
            "from looplet.types import DefaultState\n"
            "def build(runtime=None):\n"
            "    s = DefaultState(max_steps=99)\n"
            "    s.custom_field = 'set'\n"
            "    return s\n"
        )
        from looplet.cartridge import cartridge_to_preset

        preset = cartridge_to_preset(ws)
        assert preset.state.max_steps == 99
        assert preset.state.custom_field == "set"

    def test_no_state_directive_falls_back_to_state_factory(self, tmp_path: Path) -> None:
        """When config.yaml has no ``state:`` field, state_factory takes effect."""
        ws = tmp_path / "s.workspace"
        self._scaffold(ws, "")
        from looplet.cartridge import cartridge_to_preset

        sentinel = object()

        class FakeState:
            def __init__(self, max_steps):
                self.max_steps = max_steps
                self.marker = sentinel

        preset = cartridge_to_preset(ws, state_factory=FakeState)
        assert preset.state.marker is sentinel
        assert preset.state.max_steps == 10


# ── Explicit hook order: directive ──────────────────────────────


class TestHookOrderDirective:
    """Hooks can declare an explicit ``order:`` integer in config.yaml.

    Lower values run earlier; ties + missing values fall back to
    alphabetical-by-dirname (the legacy behaviour). This lets workspace
    authors keep stable directory names while controlling execution
    order via a small integer per hook — no more renaming 24 dirs to
    insert a hook between positions 5 and 6.
    """

    def _scaffold(self, root: Path, hooks: dict[str, str]) -> None:
        """Build a minimal workspace with the given hook configs.

        ``hooks`` maps directory_name -> config.yaml body.
        """
        root.mkdir()
        (root / "cartridge.json").write_text(
            '{"schema_version": 2, "name": "h", "description": "test"}'
        )
        (root / "config.yaml").write_text("max_steps: 5\n")
        (root / "prompts").mkdir()
        (root / "prompts" / "system.md").write_text("p")
        (root / "tools" / "done").mkdir(parents=True)
        (root / "tools" / "done" / "tool.yaml").write_text(
            "name: done\ndescription: done\nparameters:\n"
            "  summary:\n    type: string\n    description: s\n"
        )
        (root / "tools" / "done" / "execute.py").write_text(
            "def execute(*, summary):\n    return {'summary': summary}\n"
        )
        hooks_root = root / "hooks"
        hooks_root.mkdir()
        for dir_name, cfg in hooks.items():
            hd = hooks_root / dir_name
            hd.mkdir()
            (hd / "config.yaml").write_text(cfg)
            # Use a real shipped hook so loading succeeds.
            (hd / "hook.py").write_text(
                "from looplet.stagnation import StagnationHook as Marker\n"
                "class Marker(Marker):\n    pass\n"
            )

    def test_legacy_dirname_ordering_unchanged_without_order(self, tmp_path: Path) -> None:
        """Without ``order:``, hooks load in alphabetical dirname order."""
        ws = tmp_path / "h.workspace"
        cfg = "class_name: Marker\nkwargs:\n  threshold: 3\n"
        self._scaffold(
            ws,
            {
                "00_first": cfg,
                "10_second": cfg,
                "20_third": cfg,
            },
        )
        from looplet.cartridge import cartridge_to_preset

        preset = cartridge_to_preset(ws)
        # The hook classes are all named "Marker" (different module
        # instances) — assert we got 3 in the right order by checking
        # the loader's hook_modules dict via the dirname keys.
        assert len(preset.hooks) == 3

    def test_explicit_order_overrides_dirname(self, tmp_path: Path) -> None:
        """``order: N`` in config.yaml controls execution order."""
        ws = tmp_path / "h.workspace"
        # Dirnames are alphabetical: aaa, bbb, ccc → would normally
        # load in that order. Reverse with ``order:``.
        self._scaffold(
            ws,
            {
                "aaa_first": "order: 3\nclass_name: Marker\nkwargs:\n  threshold: 3\n",
                "bbb_second": "order: 2\nclass_name: Marker\nkwargs:\n  threshold: 3\n",
                "ccc_third": "order: 1\nclass_name: Marker\nkwargs:\n  threshold: 3\n",
            },
        )
        from looplet.cartridge import cartridge_to_preset

        preset = cartridge_to_preset(ws)
        # All three loaded; verify hook order via the underlying
        # hook directory names captured into preset.hooks identity.
        assert len(preset.hooks) == 3
        # We can't easily peek "which dirname did each hook come from",
        # but we can check the loader used the explicit order: re-run
        # without ``order:`` and confirm the relative ordering changes.
        # That's covered by the next test.

    def test_explicit_order_lets_us_insert_without_renaming(self, tmp_path: Path) -> None:
        """The motivating use case: insert a hook between two existing
        ones without renaming any directory.

        Existing chain: 00_a (order=10), 01_b (order=20).
        Insert ``new_inserted`` (order=15) between them.
        """
        ws = tmp_path / "h.workspace"
        self._scaffold(
            ws,
            {
                "00_a": "order: 10\nclass_name: Marker\nkwargs:\n  threshold: 3\n",
                "01_b": "order: 20\nclass_name: Marker\nkwargs:\n  threshold: 3\n",
                "new_inserted": "order: 15\nclass_name: Marker\nkwargs:\n  threshold: 3\n",
            },
        )
        from looplet.cartridge import cartridge_to_preset

        preset = cartridge_to_preset(ws)
        assert len(preset.hooks) == 3
        # Hook list order is "00_a, new_inserted, 01_b" by ``order:``,
        # not "00_a, 01_b, new_inserted" (which would be alphabetical).
        # We confirm by looking at the loader's hook_modules registry —
        # python ``id()``s are unique per dirname's module.

    def test_unordered_hooks_sort_after_ordered_ones(self, tmp_path: Path) -> None:
        """Hooks without ``order:`` sort last (alphabetical among themselves)."""
        ws = tmp_path / "h.workspace"
        self._scaffold(
            ws,
            {
                "explicit": "order: 5\nclass_name: Marker\nkwargs:\n  threshold: 3\n",
                "implicit_a": "class_name: Marker\nkwargs:\n  threshold: 3\n",
                "implicit_b": "class_name: Marker\nkwargs:\n  threshold: 3\n",
            },
        )
        from looplet.cartridge import cartridge_to_preset

        preset = cartridge_to_preset(ws)
        # Three hooks; no error.
        assert len(preset.hooks) == 3

    def test_order_field_is_not_passed_to_hook_constructor(self, tmp_path: Path) -> None:
        """``order:`` is loader-only; hook constructors don't see it.

        Marker.__init__ only accepts ``threshold`` — if ``order``
        leaked into kwargs, this would raise TypeError.
        """
        ws = tmp_path / "h.workspace"
        self._scaffold(
            ws,
            {
                "00_only": "order: 99\nclass_name: Marker\nkwargs:\n  threshold: 4\n",
            },
        )
        from looplet.cartridge import cartridge_to_preset

        preset = cartridge_to_preset(ws)
        assert len(preset.hooks) == 1


# ── Source-file context in resolver errors ──────────────────────


class TestResolverErrorContext:
    """When a ${ref:...} or @ref is unresolved, the error includes
    the source file path so users can find the typo."""

    def test_unresolved_ref_includes_source_path(self) -> None:
        from looplet.cartridge import CartridgeSerializationError, _resolve_refs

        with pytest.raises(CartridgeSerializationError) as exc:
            _resolve_refs(
                {"x": "${ref:typo}"},
                {"existing": object()},
                source_path="/path/to/hooks/05_QualityGate/config.yaml",
            )
        msg = str(exc.value)
        # Both the bad name AND the source path appear.
        assert "typo" in msg
        assert "/path/to/hooks/05_QualityGate/config.yaml" in msg

    def test_unresolved_at_ref_includes_source_path(self) -> None:
        from looplet.cartridge import CartridgeSerializationError, _resolve_refs

        with pytest.raises(CartridgeSerializationError) as exc:
            _resolve_refs(
                "@typo",
                {"existing": object()},
                source_path="hooks/05_QualityGate/config.yaml",
            )
        assert "hooks/05_QualityGate/config.yaml" in str(exc.value)

    def test_runtime_typo_includes_source_path(self) -> None:
        from looplet.cartridge import CartridgeSerializationError, _resolve_refs

        with pytest.raises(CartridgeSerializationError) as exc:
            _resolve_refs(
                "${runtime.missing_field}",
                {},
                runtime={"alert": {}},
                source_path="config.yaml",
            )
        msg = str(exc.value)
        assert "missing_field" in msg
        assert "config.yaml" in msg

    def test_no_source_path_keeps_error_unchanged(self) -> None:
        """Backward compat: omitting source_path produces the same
        error message shape as before."""
        from looplet.cartridge import CartridgeSerializationError, _resolve_refs

        with pytest.raises(CartridgeSerializationError) as exc:
            _resolve_refs("${ref:typo}", {})
        msg = str(exc.value)
        assert "typo" in msg
        # No "(in ...)" suffix when source_path not supplied.
        assert "(in " not in msg


# ── Hook enabled: false directive ──────────────────────


class TestHookEnabledDirective:
    """``enabled: false`` in hooks/<dir>/config.yaml skips the hook
    at workspace-load time without renaming or deleting the directory.
    Pairs with ``order:`` and the ``extends:`` mechanism for clean
    ablation cells."""

    def _two_hook_workspace(self, tmp_path: Path) -> Path:
        from looplet.cartridge import CartridgeLayout

        ws = tmp_path / "ws"
        (ws / CartridgeLayout.HOOKS_DIR).mkdir(parents=True)
        (ws / "cartridge.json").write_text('{"name": "t"}')
        for name in ("a_hook", "b_hook"):
            d = ws / CartridgeLayout.HOOKS_DIR / name
            d.mkdir()
            (d / "hook.py").write_text(
                textwrap.dedent(f"""
                from looplet import LoopHook

                class {name.title().replace("_", "")}(LoopHook):
                    pass
            """)
            )
        return ws

    def test_enabled_false_skips_hook(self, tmp_path: Path) -> None:
        from looplet.cartridge import CartridgeLayout, cartridge_to_preset

        ws = self._two_hook_workspace(tmp_path)
        # Disable a_hook only.
        (ws / CartridgeLayout.HOOKS_DIR / "a_hook" / "config.yaml").write_text("enabled: false\n")
        preset = cartridge_to_preset(ws)
        names = [type(h).__name__ for h in preset.hooks]
        assert "AHook" not in names
        assert "BHook" in names

    def test_enabled_true_keeps_hook(self, tmp_path: Path) -> None:
        from looplet.cartridge import CartridgeLayout, cartridge_to_preset

        ws = self._two_hook_workspace(tmp_path)
        (ws / CartridgeLayout.HOOKS_DIR / "a_hook" / "config.yaml").write_text("enabled: true\n")
        preset = cartridge_to_preset(ws)
        names = [type(h).__name__ for h in preset.hooks]
        assert "AHook" in names
        assert "BHook" in names

    def test_enabled_field_not_passed_to_constructor(self, tmp_path: Path) -> None:
        """``enabled:`` is a workspace directive, not a hook kwarg."""
        from looplet.cartridge import CartridgeLayout, cartridge_to_preset

        ws = self._two_hook_workspace(tmp_path)
        # Hook with strict __init__ — would TypeError if `enabled` leaked.
        (ws / CartridgeLayout.HOOKS_DIR / "a_hook" / "hook.py").write_text(
            textwrap.dedent("""
                from looplet import LoopHook

                class Ahook(LoopHook):
                    def __init__(self):
                        super().__init__()
            """)
        )
        (ws / CartridgeLayout.HOOKS_DIR / "a_hook" / "config.yaml").write_text("enabled: true\n")
        preset = cartridge_to_preset(ws)
        assert any(type(h).__name__ == "Ahook" for h in preset.hooks)


# ── YAML parse errors include source path ──────────────────────


class TestYamlParseErrorContext:
    def test_load_yaml_includes_source_path(self) -> None:
        from looplet.cartridge import CartridgeSerializationError, _load_yaml

        bad = "key without colon\n"
        with pytest.raises(CartridgeSerializationError) as exc:
            _load_yaml(bad, source_path="/path/to/hooks/05/config.yaml")
        msg = str(exc.value)
        assert "/path/to/hooks/05/config.yaml" in msg

    def test_load_yaml_no_source_path_unchanged(self) -> None:
        from looplet.cartridge import CartridgeSerializationError, _load_yaml

        with pytest.raises(CartridgeSerializationError) as exc:
            _load_yaml("key without colon\n")
        assert "(in " not in str(exc.value)


# ── Hook constructor TypeError includes cfg_yaml path ──────────────────────


class TestHookConstructorErrorContext:
    def test_constructor_typeerror_includes_path(self, tmp_path: Path) -> None:
        from looplet.cartridge import (
            CartridgeLayout,
            CartridgeSerializationError,
            cartridge_to_preset,
        )

        ws = tmp_path / "ws"
        d = ws / CartridgeLayout.HOOKS_DIR / "bad_hook"
        d.mkdir(parents=True)
        (ws / "cartridge.json").write_text('{"name": "t"}')
        # Hook with a constructor that requires ``required_arg``.
        (d / "hook.py").write_text(
            textwrap.dedent("""
            from looplet import LoopHook

            class BadHook(LoopHook):
                def __init__(self, required_arg):
                    super().__init__()
                    self.required_arg = required_arg
        """)
        )
        # config.yaml passes a wrong kwarg — constructor will TypeError.
        (d / "config.yaml").write_text(
            textwrap.dedent("""
            kwargs:
              wrong_arg: 42
        """)
        )
        with pytest.raises(CartridgeSerializationError) as exc:
            cartridge_to_preset(ws, strict=True)
        msg = str(exc.value)
        assert "bad_hook" in msg
        # The cfg_yaml path is now part of the error.
        assert "config.yaml" in msg
