"""Tests for ctl.* built-in tools.

Phase 5, step 4: ctl_finish, ctl_fail, ctl_request_approval, etc.

Uses real tmp filesystem - no mocking state layer.
"""

import asyncio
import json
from pathlib import Path

import pytest
from click.testing import CliRunner
from returns.result import Failure, Success

from anima.core.execution import ExecutionIntent, resolve_execution_contract
from anima.core.lifecycle import LifecycleEvent
from anima.core.schemas import (
    AgentState,
    AgentStateData,
    ContextMode,
    JobSpec,
    LifecycleType,
    MaxDepthExceededError,
    PersonaSpec,
)
from anima.shell.ctl import (
    ctl_fail,
    ctl_finish,
    ctl_request_approval,
    ctl_spawn_subagent,
    ctl_suspend_until,
)
from anima.shell.ctl_tools import create_ctl_tools
from anima.shell.agent import AgentRunner
from anima.shell.cli import main
from anima.shell.state import (
    read_log,
    read_result,
    read_state,
    transition_state,
    write_contract_snapshot,
    write_state,
)
from tests.fixtures.launch_control_services import (
    make_ctl_spawn_subagent_fixture_cases,
    override_persona_fixture_for_spawn,
)


@pytest.fixture(autouse=True)
def _stub_spawn_launcher(monkeypatch: pytest.MonkeyPatch) -> None:
    """Keep ctl.spawn unit tests hermetic by stubbing subprocess launch."""

    def _launch_stub(**_kwargs: object) -> Success[int]:
        return Success(4242)

    monkeypatch.setattr("anima.shell.ctl_spawn.launch_agent_subprocess", _launch_stub)


@pytest.fixture
def tmp_state_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
    """Create a temporary state directory and set ANIMA_STATE."""
    state_dir = tmp_path / "anima_state"
    state_dir.mkdir()
    monkeypatch.setenv("ANIMA_STATE", str(state_dir))
    return state_dir


@pytest.fixture
def work_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
    """Create a working directory with personas, change cwd to it."""
    # Create work directory structure
    w_dir = tmp_path / "work"
    w_dir.mkdir()
    p_dir = w_dir / "personas"
    p_dir.mkdir()

    # Parent persona (can spawn any)
    parent = PersonaSpec(
        id="parent",
        description="A test persona.",
        prompt="You are a parent agent.",
        model="claude-sonnet-4-20250514",
        can_spawn=True,
    )
    (p_dir / "parent.json").write_text(parent.model_dump_json())

    # Worker persona (can spawn specific)
    worker = PersonaSpec(
        id="worker",
        description="A test persona.",
        prompt="You are a worker agent.",
        model="claude-sonnet-4-20250514",
        can_spawn=["helper"],
    )
    (p_dir / "worker.json").write_text(worker.model_dump_json())

    # Restricted persona (cannot spawn)
    restricted = PersonaSpec(
        id="restricted",
        description="A test persona.",
        prompt="You are a restricted agent.",
        model="claude-sonnet-4-20250514",
        can_spawn=False,
    )
    (p_dir / "restricted.json").write_text(restricted.model_dump_json())

    # Helper persona
    helper = PersonaSpec(
        id="helper",
        description="A test persona.",
        prompt="You are a helper agent.",
        model="claude-sonnet-4-20250514",
    )
    (p_dir / "helper.json").write_text(helper.model_dump_json())

    # Change working directory to work_dir for relative paths
    monkeypatch.chdir(w_dir)

    return w_dir


@pytest.fixture
def personas_dir(work_dir: Path) -> Path:
    """Return personas directory relative to work_dir (uses relative paths)."""
    return work_dir / "personas"


@pytest.fixture
def running_agent(tmp_state_dir: Path, work_dir: Path) -> AgentStateData:
    """Create a running agent state."""
    state = AgentStateData(
        trace_id="tr_test",
        instance_id="inst_running",
        persona_id="test",
        persona_ref="personas/parent.json",  # Relative path, work_dir managed by work_dir fixture
        state=AgentState.running,
        lifecycle=LifecycleType.ephemeral,
        depth=0,
        max_depth=3,
    )
    write_state("inst_running", state)
    return state


@pytest.fixture
def pending_agent(tmp_state_dir: Path, work_dir: Path) -> AgentStateData:
    """Create a pending agent state."""
    state = AgentStateData(
        trace_id="tr_test",
        instance_id="inst_pending",
        persona_id="test",
        persona_ref="personas/parent.json",
        state=AgentState.pending,
        lifecycle=LifecycleType.ephemeral,
    )
    write_state("inst_pending", state)
    return state


@pytest.fixture
def daemon_agent(tmp_state_dir: Path, work_dir: Path) -> AgentStateData:
    """Create a daemon (lifecycle) agent."""
    state = AgentStateData(
        trace_id="tr_test",
        instance_id="inst_daemon",
        persona_id="daemon",
        persona_ref="personas/daemon.json",
        state=AgentState.running,
        lifecycle=LifecycleType.daemon,
        depth=0,
        max_depth=3,
    )
    write_state("inst_daemon", state)
    return state


class TestCtlFinish:
    """Tests for ctl_finish tool."""

    @pytest.mark.asyncio
    async def test_finish_transitions_to_completed(
        self, running_agent: AgentStateData
    ) -> None:
        """ctl_finish transitions running agent to completed state."""
        result = await ctl_finish("inst_running")

        assert isinstance(result, Success)

        # Verify state transition
        state_result = read_state("inst_running")
        assert isinstance(state_result, Success)
        state = state_result.unwrap()
        assert state.state == AgentState.completed
        assert state.result_ref is None

    @pytest.mark.asyncio
    async def test_finish_with_result_ref(self, running_agent: AgentStateData) -> None:
        """ctl_finish stores result_ref."""
        result = await ctl_finish("inst_running", result_ref="results/output.json")

        assert isinstance(result, Success)

        state_result = read_state("inst_running")
        assert isinstance(state_result, Success)
        state = state_result.unwrap()
        assert state.result_ref == "results/output.json"

    @pytest.mark.asyncio
    async def test_finish_creates_result_file(
        self, running_agent: AgentStateData, tmp_state_dir: Path
    ) -> None:
        """ctl_finish creates result.json file."""
        await ctl_finish("inst_running")

        result_file = tmp_state_dir / "agents" / "inst_running" / "result.json"
        assert result_file.exists()

        data = json.loads(result_file.read_text())
        assert data["final_state"] == "completed"

    @pytest.mark.asyncio
    async def test_finish_writes_response_text_to_result(
        self, running_agent: AgentStateData, tmp_state_dir: Path
    ) -> None:
        """ctl_finish persists response_text in result.json when provided."""
        await ctl_finish(
            "inst_running", result_ref=None, response_text="The analysis found 3 issues."
        )

        result_file = tmp_state_dir / "agents" / "inst_running" / "result.json"
        data = json.loads(result_file.read_text())
        assert data["response_text"] == "The analysis found 3 issues."
        assert data["final_state"] == "completed"

    @pytest.mark.asyncio
    async def test_finish_result_response_text_defaults_to_none(
        self, running_agent: AgentStateData, tmp_state_dir: Path
    ) -> None:
        """ctl_finish omitting response_text writes null (backward compat)."""
        await ctl_finish("inst_running")

        result_file = tmp_state_dir / "agents" / "inst_running" / "result.json"
        data = json.loads(result_file.read_text())
        assert data.get("response_text") is None

    @pytest.mark.asyncio
    async def test_finish_logs_event(self, running_agent: AgentStateData) -> None:
        """ctl_finish appends log entry."""
        await ctl_finish("inst_running", result_ref="results/out.json")

        log_result = read_log("inst_running")
        assert isinstance(log_result, Success)
        entries = log_result.unwrap()

        # Find the finish event
        finish_entries = [e for e in entries if e.get("event") == "finished"]
        assert len(finish_entries) >= 1
        assert finish_entries[-1]["details"]["result_ref"] == "results/out.json"

    @pytest.mark.asyncio
    async def test_finish_fails_for_nonexistent_agent(self) -> None:
        """ctl_finish returns Failure for non-existent agent."""
        result = await ctl_finish("inst_nonexistent")

        assert isinstance(result, Failure)
        error = result.failure()
        assert "not found" in str(error).lower()

    @pytest.mark.asyncio
    async def test_finish_fails_for_pending_agent(
        self, pending_agent: AgentStateData
    ) -> None:
        """ctl_finish returns Failure for pending agent (invalid transition)."""
        result = await ctl_finish("inst_pending")

        assert isinstance(result, Failure)
        error = result.failure()
        assert "cannot" in str(error).lower() or "invalid" in str(error).lower()

    @pytest.mark.asyncio
    async def test_finish_ignored_for_daemon(
        self, daemon_agent: AgentStateData
    ) -> None:
        """ctl_finish is ignored for daemon lifecycle agents."""
        result = await ctl_finish("inst_daemon")

        # Returns success but doesn't change state
        assert isinstance(result, Success)

        state_result = read_state("inst_daemon")
        assert isinstance(state_result, Success)
        state = state_result.unwrap()
        # Daemon stays in running state
        assert state.state == AgentState.running

        # Verify log has finish_ignored event
        log_result = read_log("inst_daemon")
        assert isinstance(log_result, Success)
        entries = log_result.unwrap()
        ignored = [e for e in entries if e.get("event") == "finish_ignored"]
        assert len(ignored) >= 1

    @pytest.mark.asyncio
    @pytest.mark.asyncio
    async def test_finish_blocks_with_non_terminal_children(
        self, running_agent: AgentStateData, tmp_state_dir: Path
    ) -> None:
        """ctl_finish rejects when un-awaited children are still running."""
        # Create a running child
        child_state = AgentStateData(
            instance_id="inst_child_running",
            persona_name="child",
            trace_id="tr_child",
            state=AgentState.running,
        )
        write_state("inst_child_running", child_state)

        # Add child to parent's children list
        running_agent.children = ["inst_child_running"]
        write_state("inst_running", running_agent)

        result = await ctl_finish("inst_running")

        assert isinstance(result, Failure)
        assert "un-awaited children" in result.failure().message
        assert "inst_child_running" in result.failure().message

    @pytest.mark.asyncio
    async def test_finish_allows_with_terminal_children(
        self, running_agent: AgentStateData, tmp_state_dir: Path
    ) -> None:
        """ctl_finish succeeds when all children are in terminal state."""
        # Create a completed child
        child_state = AgentStateData(
            instance_id="inst_child_done",
            persona_name="child",
            trace_id="tr_child",
            state=AgentState.completed,
        )
        write_state("inst_child_done", child_state)

        # Add child to parent's children list
        running_agent.children = ["inst_child_done"]
        write_state("inst_running", running_agent)

        result = await ctl_finish("inst_running")

        assert isinstance(result, Success)

    async def test_finish_ignored_for_already_terminal(
        self, tmp_state_dir: Path
    ) -> None:
        """ctl_finish is ignored if agent is already terminal."""
        # Create completed agent
        state = AgentStateData(
            trace_id="tr_test",
            instance_id="inst_completed",
            persona_id="test",
            state=AgentState.completed,
        )
        write_state("inst_completed", state)

        result = await ctl_finish("inst_completed")

        assert isinstance(result, Success)

        # Log should show finish_ignored
        log_result = read_log("inst_completed")
        assert isinstance(log_result, Success)
        entries = log_result.unwrap()
        ignored = [e for e in entries if e.get("event") == "finish_ignored"]
        assert len(ignored) >= 1


class TestCtlFinishWithError:
    """Tests for ctl_finish(error=...) path (merged ctl_fail into ctl_finish)."""

    @pytest.mark.asyncio
    async def test_finish_with_error_transitions_to_failed(
        self, running_agent: AgentStateData
    ) -> None:
        """ctl_finish(error=...) transitions running agent to failed state."""
        result = await ctl_finish("inst_running", error="Something went wrong")

        assert isinstance(result, Success)

        state_result = read_state("inst_running")
        assert isinstance(state_result, Success)
        state = state_result.unwrap()
        assert state.state == AgentState.failed
        assert state.error == "Something went wrong"

    @pytest.mark.asyncio
    async def test_finish_with_error_creates_result_with_error(
        self, running_agent: AgentStateData, tmp_state_dir: Path
    ) -> None:
        """ctl_finish(error=...) creates result.json with error field."""
        await ctl_finish("inst_running", error="Test failure")

        result = read_result("inst_running")
        assert isinstance(result, Success)
        data = result.unwrap()
        assert data.final_state == AgentState.failed
        assert data.error == "Test failure"

    @pytest.mark.asyncio
    async def test_finish_with_error_logs_error(
        self, running_agent: AgentStateData
    ) -> None:
        """ctl_finish(error=...) logs the error message."""
        await ctl_finish("inst_running", error="Critical error")

        log_result = read_log("inst_running")
        assert isinstance(log_result, Success)
        entries = log_result.unwrap()

        failed_entries = [e for e in entries if e.get("event") == "failed"]
        assert len(failed_entries) >= 1
        assert failed_entries[-1]["details"]["error"] == "Critical error"

    @pytest.mark.asyncio
    async def test_finish_with_error_blocks_with_non_terminal_children(
        self, running_agent: AgentStateData, tmp_state_dir: Path
    ) -> None:
        """ctl_finish(error=...) rejects when un-awaited children are still running."""
        child_state = AgentStateData(
            instance_id="inst_child_running",
            persona_name="child",
            trace_id="tr_child",
            state=AgentState.running,
        )
        write_state("inst_child_running", child_state)

        running_agent.children = ["inst_child_running"]
        write_state("inst_running", running_agent)

        result = await ctl_finish("inst_running", error="boom")

        assert isinstance(result, Failure)
        assert "un-awaited children" in result.failure().message

    @pytest.mark.asyncio
    async def test_finish_with_error_fails_for_nonexistent_agent(self) -> None:
        """ctl_finish(error=...) returns Failure for non-existent agent."""
        result = await ctl_finish("inst_nonexistent", error="test")
        assert isinstance(result, Failure)


class TestCtlFail:
    """Tests for ctl_fail tool."""

    @pytest.mark.asyncio
    async def test_fail_transitions_to_failed(
        self, running_agent: AgentStateData
    ) -> None:
        """ctl_fail transitions running agent to failed state."""
        result = await ctl_fail("inst_running", error="Something went wrong")

        assert isinstance(result, Success)

        state_result = read_state("inst_running")
        assert isinstance(state_result, Success)
        state = state_result.unwrap()
        assert state.state == AgentState.failed
        assert state.error == "Something went wrong"

    @pytest.mark.asyncio
    async def test_fail_creates_result_with_error(
        self, running_agent: AgentStateData, tmp_state_dir: Path
    ) -> None:
        """ctl_fail creates result.json with error field."""
        await ctl_fail("inst_running", error="Test failure")

        result = read_result("inst_running")
        assert isinstance(result, Success)
        data = result.unwrap()
        assert data.final_state == AgentState.failed
        assert data.error == "Test failure"

    @pytest.mark.asyncio
    async def test_fail_logs_error(self, running_agent: AgentStateData) -> None:
        """ctl_fail logs the error message."""
        await ctl_fail("inst_running", error="Critical error")

        log_result = read_log("inst_running")
        assert isinstance(log_result, Success)
        entries = log_result.unwrap()

        failed_entries = [e for e in entries if e.get("event") == "failed"]
        assert len(failed_entries) >= 1
        assert failed_entries[-1]["details"]["error"] == "Critical error"

    @pytest.mark.asyncio
    async def test_fail_fails_for_nonexistent_agent(self) -> None:
        """ctl_fail returns Failure for non-existent agent."""
        result = await ctl_fail("inst_nonexistent", error="test")

        assert isinstance(result, Failure)

    @pytest.mark.asyncio
    async def test_fail_fails_for_pending_agent(
        self, pending_agent: AgentStateData
    ) -> None:
        """ctl_fail returns Failure for pending agent (invalid transition)."""
        result = await ctl_fail("inst_pending", error="test")

        assert isinstance(result, Failure)


class TestCtlRequestApproval:
    """Tests for ctl_request_approval tool."""

    @pytest.mark.asyncio
    async def test_request_approval_blocks_until_approved(
        self, running_agent: AgentStateData
    ) -> None:
        """ctl_request_approval blocks and returns approval payload."""
        approval_task = asyncio.create_task(
            ctl_request_approval("inst_running", reason="Need approval for action")
        )

        await asyncio.sleep(0.2)
        assert not approval_task.done()

        # While blocked, state must be awaiting_approval.
        state_result = read_state("inst_running")
        assert isinstance(state_result, Success)
        pending_state = state_result.unwrap()
        assert pending_state.state == AgentState.awaiting_approval

        transition_result = transition_state("inst_running", LifecycleEvent.approve)
        assert isinstance(transition_result, Success)

        result = await asyncio.wait_for(approval_task, timeout=1.0)

        assert isinstance(result, Success)
        assert result.unwrap() == {"approved": True, "note": None}

    @pytest.mark.asyncio
    async def test_request_approval_reject_returns_reason(
        self, running_agent: AgentStateData
    ) -> None:
        """ctl_request_approval returns rejection reason when rejected."""
        approval_task = asyncio.create_task(
            ctl_request_approval("inst_running", reason="Dangerous operation")
        )

        await asyncio.sleep(0.2)
        # Write rejection response before transition (matches real _handle_reject flow)
        from anima.shell.state import write_approval_response

        write_approval_response("inst_running", approved=False, note="Not approved")
        transition_result = transition_state(
            "inst_running",
            LifecycleEvent.reject,
        )
        assert isinstance(transition_result, Success)

        result = await asyncio.wait_for(approval_task, timeout=1.0)
        assert isinstance(result, Success)
        assert result.unwrap() == {"approved": False, "note": "Not approved"}

        log_result = read_log("inst_running")
        assert isinstance(log_result, Success)
        entries = log_result.unwrap()

        approval_entries = [
            e for e in entries if e.get("event") == "approval_requested"
        ]
        assert len(approval_entries) >= 1
        assert approval_entries[-1]["details"]["reason"] == "Dangerous operation"

    @pytest.mark.asyncio
    async def test_request_approval_passes_back_human_note_via_cli(
        self,
        running_agent: AgentStateData,
        tmp_state_dir: Path,
    ) -> None:
        """Integration: ctl_request_approval receives note from anima approve --note."""
        approval_task = asyncio.create_task(
            ctl_request_approval("inst_running", reason="Need sign-off")
        )

        await asyncio.sleep(0.2)
        assert not approval_task.done()

        cli_runner = CliRunner()
        approve_result = await asyncio.to_thread(
            cli_runner.invoke,
            main,
            ["approve", "inst_running", "--note", "Approved by human"],
            env={"ANIMA_STATE": str(tmp_state_dir)},
        )
        assert approve_result.exit_code == 0

        result = await asyncio.wait_for(approval_task, timeout=1.0)
        assert isinstance(result, Success)
        assert result.unwrap() == {
            "approved": True,
            "note": "Approved by human",
        }

    @pytest.mark.asyncio
    async def test_request_approval_preserves_note_when_approve_interleaves_before_wait(
        self,
        running_agent: AgentStateData,
        tmp_state_dir: Path,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        """Approval note survives approve/write interleaving without being cleared.

        Source: approval-mechanism-fix blocker #1 requires the approval note channel
        to remain intact when approve happens between request-state write and wait.
        """
        cli_runner = CliRunner()
        original_write_state = write_state
        interleaved = False

        def intercepted_write_state(
            agent_id: str,
            state_data: AgentStateData,
            event: str = "state_write",
            details: dict | None = None,
        ):
            nonlocal interleaved
            write_result = original_write_state(agent_id, state_data, event, details)
            if event == "approval_requested" and not interleaved:
                interleaved = True
                approve_result = cli_runner.invoke(
                    main,
                    ["approve", agent_id, "--note", "Race-safe note"],
                    env={"ANIMA_STATE": str(tmp_state_dir)},
                )
                assert approve_result.exit_code == 0
            return write_result

        monkeypatch.setattr("anima.shell.ctl.write_state", intercepted_write_state)

        approval_task = asyncio.create_task(
            ctl_request_approval("inst_running", reason="Interleaving approval request")
        )
        result = await asyncio.wait_for(approval_task, timeout=1.0)

        assert interleaved is True
        assert isinstance(result, Success)
        assert result.unwrap() == {
            "approved": True,
            "note": "Race-safe note",
        }

    @pytest.mark.asyncio
    async def test_request_approval_fails_for_nonexistent(self) -> None:
        """ctl_request_approval returns Failure for non-existent agent."""
        result = await ctl_request_approval("inst_nonexistent", reason="test")

        assert isinstance(result, Failure)

    @pytest.mark.asyncio
    async def test_request_approval_fails_for_pending(
        self, pending_agent: AgentStateData
    ) -> None:
        """ctl_request_approval returns Failure for pending agent."""
        result = await ctl_request_approval("inst_pending", reason="test")

        assert isinstance(result, Failure)


class TestCtlSuspend:
    """Tests for ctl_suspend_until tool."""

    @pytest.mark.asyncio
    async def test_suspend_transitions_to_suspended(
        self, running_agent: AgentStateData
    ) -> None:
        """ctl_suspend_until transitions to suspended state."""
        result = await ctl_suspend_until("inst_running")

        assert isinstance(result, Success)

        state_result = read_state("inst_running")
        assert isinstance(state_result, Success)
        state = state_result.unwrap()
        assert state.state == AgentState.suspended

    @pytest.mark.asyncio
    async def test_suspend_with_resume_time(
        self, running_agent: AgentStateData
    ) -> None:
        """ctl_suspend_until stores resume_after timestamp."""
        resume_time = "2026-03-01T12:00:00Z"
        result = await ctl_suspend_until("inst_running", resume_after=resume_time)

        assert isinstance(result, Success)

        state_result = read_state("inst_running")
        assert isinstance(state_result, Success)
        state = state_result.unwrap()
        assert state.resume_after == resume_time

    @pytest.mark.asyncio
    async def test_suspend_logs_event(self, running_agent: AgentStateData) -> None:
        """ctl_suspend_until logs the suspension."""
        await ctl_suspend_until("inst_running", resume_after="2026-03-01T12:00:00Z")

        log_result = read_log("inst_running")
        assert isinstance(log_result, Success)
        entries = log_result.unwrap()

        suspended = [e for e in entries if e.get("event") == "suspended"]
        assert len(suspended) >= 1

    @pytest.mark.asyncio
    async def test_suspend_without_resume_time(
        self, running_agent: AgentStateData
    ) -> None:
        """ctl_suspend_until works without resume_after (manual resume)."""
        result = await ctl_suspend_until("inst_running")

        assert isinstance(result, Success)

        state_result = read_state("inst_running")
        assert isinstance(state_result, Success)
        state = state_result.unwrap()
        assert state.state == AgentState.suspended
        assert state.resume_after is None

    @pytest.mark.asyncio
    async def test_suspend_fails_for_nonexistent(self) -> None:
        """ctl_suspend_until returns Failure for non-existent agent."""
        result = await ctl_suspend_until("inst_nonexistent")

        assert isinstance(result, Failure)


class TestCtlSpawnSubagent:
    """Tests for ctl_spawn_subagent tool."""

    @pytest.mark.asyncio
    async def test_spawn_creates_child_agent(
        self, running_agent: AgentStateData, work_dir: Path, tmp_state_dir: Path
    ) -> None:
        """ctl_spawn_subagent creates a child agent with correct state."""
        # Use relative path for persona_ref
        running_agent.persona_ref = "personas/parent.json"
        write_state("inst_running", running_agent)

        result = await ctl_spawn_subagent(
            "inst_running",
            "personas/helper.json",
            "Help with task",
        )

        assert isinstance(result, Success)
        child_id = result.unwrap()
        assert child_id.startswith("inst_")

        # Verify child state
        # Need to find child by instance_id (agent_id is derived)
        agents_dir = tmp_state_dir / "agents"
        child_dirs = [
            d for d in agents_dir.iterdir() if d.is_dir() and d.name != "inst_running"
        ]
        assert len(child_dirs) == 1

        child_state_result = read_state(child_dirs[0].name)
        assert isinstance(child_state_result, Success)
        child_state = child_state_result.unwrap()
        assert child_state.persona_name == "helper"
        assert child_state.state == AgentState.pending
        assert child_state.parent_id == "inst_running"
        assert child_state.depth == 1

    @pytest.mark.asyncio
    async def test_spawn_inherits_constraints(
        self, running_agent: AgentStateData, work_dir: Path
    ) -> None:
        """ctl_spawn_subagent inherits max_turns, budget, etc."""
        running_agent.persona_ref = "personas/parent.json"
        running_agent.max_turns = 50
        running_agent.budget_total = 10000
        running_agent.max_depth = 5
        write_state("inst_running", running_agent)

        result = await ctl_spawn_subagent(
            "inst_running",
            "personas/helper.json",
            "Task",
        )

        assert isinstance(result, Success)

        # Find and verify child
        from anima.shell.state import list_agents

        agents_result = list_agents()
        assert isinstance(agents_result, Success)
        agents = agents_result.unwrap()
        child = next(a for a in agents if a.parent_id == "inst_running")

        assert child.max_turns == 50
        assert child.budget_total == 10000
        assert child.max_depth == 5

    @pytest.mark.asyncio
    async def test_spawn_inherits_parent_persona_when_ref_is_none(
        self, running_agent: AgentStateData, work_dir: Path
    ) -> None:
        """ctl_spawn_subagent uses parent persona_ref when child persona_ref is None."""
        running_agent.persona_ref = "personas/parent.json"
        write_state("inst_running", running_agent)

        result = await ctl_spawn_subagent(
            "inst_running",
            None,
            "Task",
        )

        assert isinstance(result, Success)

        from anima.shell.state import list_agents

        agents_result = list_agents()
        assert isinstance(agents_result, Success)
        agents = agents_result.unwrap()
        child = next(a for a in agents if a.parent_id == "inst_running")

        assert child.persona_ref == "personas/parent.json"
        assert child.persona_name == "parent"

    @pytest.mark.asyncio
    async def test_spawn_applies_constraint_and_lifecycle_overrides(
        self, running_agent: AgentStateData, work_dir: Path
    ) -> None:
        """ctl_spawn_subagent applies override values when provided."""
        running_agent.persona_ref = "personas/parent.json"
        running_agent.max_turns = 50
        running_agent.budget_total = 10000
        write_state("inst_running", running_agent)

        result = await ctl_spawn_subagent(
            "inst_running",
            "personas/helper.json",
            "Task",
            lifecycle="daemon",
            max_turns=10,
            max_tokens=1000,
        )

        assert isinstance(result, Success)

        from anima.shell.state import list_agents

        agents_result = list_agents()
        assert isinstance(agents_result, Success)
        agents = agents_result.unwrap()
        child = next(a for a in agents if a.parent_id == "inst_running")

        assert child.lifecycle.value == "daemon"
        assert child.max_turns == 10
        assert child.budget_total == 1000

    @pytest.mark.asyncio
    async def test_spawn_rejects_max_turns_override_above_parent_limit(
        self, running_agent: AgentStateData, work_dir: Path
    ) -> None:
        """ctl_spawn_subagent rejects max_turns override above parent max_turns."""
        running_agent.persona_ref = "personas/parent.json"
        running_agent.max_turns = 5
        write_state("inst_running", running_agent)

        result = await ctl_spawn_subagent(
            "inst_running",
            "personas/helper.json",
            "Task",
            max_turns=6,
        )

        assert isinstance(result, Failure)
        assert "max_turns" in str(result.failure()).lower()

    @pytest.mark.asyncio
    async def test_spawn_rejects_max_tokens_override_above_parent_budget(
        self, running_agent: AgentStateData, work_dir: Path
    ) -> None:
        """ctl_spawn_subagent rejects max_tokens override above parent budget."""
        running_agent.persona_ref = "personas/parent.json"
        running_agent.budget_total = 500
        write_state("inst_running", running_agent)

        result = await ctl_spawn_subagent(
            "inst_running",
            "personas/helper.json",
            "Task",
            max_tokens=501,
        )

        assert isinstance(result, Failure)
        assert "max_tokens" in str(result.failure()).lower()

    @pytest.mark.asyncio
    async def test_spawn_updates_parent_children_list(
        self, running_agent: AgentStateData, work_dir: Path
    ) -> None:
        """ctl_spawn_subagent updates parent's children list."""
        running_agent.persona_ref = "personas/parent.json"
        write_state("inst_running", running_agent)

        result = await ctl_spawn_subagent(
            "inst_running",
            "personas/helper.json",
            "Task",
        )

        assert isinstance(result, Success)
        child_id = result.unwrap()

        # Verify parent children list
        parent_result = read_state("inst_running")
        assert isinstance(parent_result, Success)
        parent = parent_result.unwrap()
        assert child_id in parent.children

    @pytest.mark.asyncio
    async def test_spawn_fails_for_nonexistent_parent(self, work_dir: Path) -> None:
        """ctl_spawn_subagent returns Failure for non-existent parent."""
        result = await ctl_spawn_subagent(
            "inst_nonexistent",
            "personas/helper.json",
            "Task",
        )

        assert isinstance(result, Failure)

    @pytest.mark.asyncio
    async def test_spawn_fails_for_parent_without_persona_ref(
        self, running_agent: AgentStateData, work_dir: Path
    ) -> None:
        """ctl_spawn_subagent returns Failure if parent has no persona_ref and child inherits."""
        running_agent.persona_ref = None
        write_state("inst_running", running_agent)

        # Child with no explicit persona_ref — must inherit from parent, but parent has none
        result = await ctl_spawn_subagent(
            "inst_running",
            None,
            "Task",
        )

        assert isinstance(result, Failure)

    @pytest.mark.asyncio
    async def test_spawn_fails_for_can_spawn_false(
        self, tmp_state_dir: Path, work_dir: Path
    ) -> None:
        """ctl_spawn_subagent returns Failure if parent cannot spawn."""
        # Create agent with restricted persona
        state = AgentStateData(
            trace_id="tr_test",
            instance_id="inst_restricted",
            persona_id="restricted",
            persona_ref="personas/restricted.json",
            state=AgentState.running,
        )
        write_state("inst_restricted", state)

        result = await ctl_spawn_subagent(
            "inst_restricted",
            "personas/helper.json",
            "Task",
        )

        assert isinstance(result, Failure)
        error = result.failure()
        assert (
            "can_spawn" in str(error).lower() or "not permitted" in str(error).lower()
        )

    @pytest.mark.asyncio
    async def test_spawn_fails_for_unauthorized_persona(
        self, tmp_state_dir: Path, work_dir: Path
    ) -> None:
        """ctl_spawn_subagent returns Failure if persona not in allowlist."""
        # Worker can only spawn helper, not parent
        state = AgentStateData(
            trace_id="tr_test",
            instance_id="inst_worker",
            persona_id="worker",
            persona_ref="personas/worker.json",
            state=AgentState.running,
        )
        write_state("inst_worker", state)

        result = await ctl_spawn_subagent(
            "inst_worker",
            "personas/parent.json",  # parent not in worker's allowlist
            "Task",
        )

        assert isinstance(result, Failure)
        error = result.failure()
        assert (
            "allowlist" in str(error).lower() or "not permitted" in str(error).lower()
        )

    @pytest.mark.asyncio
    async def test_spawn_allowed_for_authorised_persona(
        self, tmp_state_dir: Path, work_dir: Path
    ) -> None:
        """ctl_spawn_subagent succeeds if persona is in allowlist."""
        # Worker can spawn helper (in allowlist)
        state = AgentStateData(
            trace_id="tr_test",
            instance_id="inst_worker",
            persona_id="worker",
            persona_ref="personas/worker.json",
            state=AgentState.running,
            depth=0,
            max_depth=3,
        )
        write_state("inst_worker", state)

        result = await ctl_spawn_subagent(
            "inst_worker",
            "personas/helper.json",
            "Task",
        )

        assert isinstance(result, Success)

    @pytest.mark.asyncio
    async def test_spawn_fails_max_depth_exceeded(
        self, tmp_state_dir: Path, work_dir: Path
    ) -> None:
        """ctl_spawn_subagent returns Failure if max_depth would be exceeded."""
        state = AgentStateData(
            trace_id="tr_test",
            instance_id="inst_deep",
            persona_id="parent",
            persona_ref="personas/parent.json",
            state=AgentState.running,
            depth=2,
            max_depth=3,  # depth + 1 = 3 >= max_depth
        )
        write_state("inst_deep", state)

        result = await ctl_spawn_subagent(
            "inst_deep",
            "personas/helper.json",
            "Task",
        )

        assert isinstance(result, Failure)
        error = result.failure()
        # Check it's a MaxDepthExceededError
        assert isinstance(error, MaxDepthExceededError) or "depth" in str(error).lower()

    @pytest.mark.asyncio
    async def test_spawn_fails_invalid_persona_path(
        self, running_agent: AgentStateData, tmp_state_dir: Path, work_dir: Path
    ) -> None:
        """ctl_spawn_subagent returns Failure for non-existent persona file."""
        running_agent.persona_ref = "personas/parent.json"
        write_state("inst_running", running_agent)

        result = await ctl_spawn_subagent(
            "inst_running",
            "personas/nonexistent.json",  # Non-existent file
            "Task",
        )

        assert isinstance(result, Failure)
        error = result.failure()
        assert "not found" in str(error).lower()

    @pytest.mark.asyncio
    async def test_spawn_rejects_absolute_path(
        self, running_agent: AgentStateData, personas_dir: Path, tmp_path: Path
    ) -> None:
        """ctl_spawn_subagent rejects absolute paths for security."""
        running_agent.persona_ref = "personas/parent.json"
        write_state("inst_running", running_agent)

        result = await ctl_spawn_subagent(
            "inst_running",
            "/etc/passwd",  # Absolute path
            "Task",
        )

        assert isinstance(result, Failure)
        error = result.failure()
        assert "absolute" in str(error).lower() or "not allowed" in str(error).lower()

    @pytest.mark.asyncio
    async def test_spawn_rejects_path_traversal(
        self, running_agent: AgentStateData, work_dir: Path
    ) -> None:
        """ctl_spawn_subagent rejects path traversal attempts."""
        running_agent.persona_ref = "personas/parent.json"
        write_state("inst_running", running_agent)

        result = await ctl_spawn_subagent(
            "inst_running",
            "../../../etc/passwd",  # Path traversal
            "Task",
        )

        assert isinstance(result, Failure)
        error = result.failure()
        assert "traversal" in str(error).lower() or "not allowed" in str(error).lower()

    @pytest.mark.asyncio
    async def test_spawn_logs_event(
        self, running_agent: AgentStateData, work_dir: Path
    ) -> None:
        """ctl_spawn_subagent logs spawn event."""
        running_agent.persona_ref = "personas/parent.json"
        write_state("inst_running", running_agent)

        result = await ctl_spawn_subagent(
            "inst_running",
            "personas/helper.json",
            "Test task",
        )

        assert isinstance(result, Success)

        log_result = read_log("inst_running")
        assert isinstance(log_result, Success)
        entries = log_result.unwrap()

        spawn_entries = [e for e in entries if e.get("event") == "spawn"]
        assert len(spawn_entries) >= 1
        assert "child_id" in spawn_entries[-1]["details"]

    @pytest.mark.asyncio
    async def test_spawn_persona_ref_file_path_only(
        self, running_agent: AgentStateData, work_dir: Path, tmp_state_dir: Path
    ) -> None:
        """ctl_spawn_subagent persona_ref is file path only (no spec_id resolution).

        This test documents the current behavior where persona_ref must be a
        file path. spec_id resolution requires nervus integration (future work).

        See docs/INTERFACES.md D.4 for semantics clarification.
        """
        running_agent.persona_ref = "personas/parent.json"
        write_state("inst_running", running_agent)

        # Valid: relative file path
        result = await ctl_spawn_subagent(
            "inst_running",
            "personas/helper.json",  # File path
            "Task 1",
        )
        assert isinstance(result, Success)

        # Invalid: spec_id-style reference (not a file path)
        result2 = await ctl_spawn_subagent(
            "inst_running",
            "helper@v1",  # Not a file, doesn't exist as path
            "Task 2",
        )
        assert isinstance(result2, Failure)
        # Should fail with file not found
        assert "not found" in str(result2.failure()).lower()

    @pytest.mark.asyncio
    async def test_spawn_inherits_can_spawn_from_parent_when_no_child_persona(
        self, tmp_state_dir: Path, work_dir: Path
    ) -> None:
        """ctl_spawn_subagent inherits can_spawn from parent when persona_ref=None.

        When child persona_ref is None (inherits parent), the spawn permission
        check should use the parent's can_spawn setting.
        """
        # Parent with can_spawn=True
        state = AgentStateData(
            trace_id="tr_test",
            instance_id="inst_parent",
            persona_id="parent",
            persona_ref="personas/parent.json",
            state=AgentState.running,
            depth=0,
            max_depth=3,
        )
        write_state("inst_parent", state)

        # Spawn with persona_ref=None (inherits parent)
        result = await ctl_spawn_subagent(
            "inst_parent",
            None,  # Inherit parent persona
            "Task",
        )
        assert isinstance(result, Success)

        # Child should have inherited parent's can_spawn
        from anima.shell.state import list_agents

        agents_result = list_agents()
        assert isinstance(agents_result, Success)
        agents = agents_result.unwrap()
        child = next(a for a in agents if a.parent_id == "inst_parent")
        assert child.persona_ref == "personas/parent.json"
        assert child.persona_name == "parent"

    @pytest.mark.asyncio
    async def test_spawn_lifecycle_override_changes_child_lifecycle(
        self, running_agent: AgentStateData, work_dir: Path
    ) -> None:
        """ctl_spawn_subagent applies lifecycle override when specified."""
        running_agent.persona_ref = "personas/parent.json"
        running_agent.lifecycle = LifecycleType.ephemeral
        write_state("inst_running", running_agent)

        # Override to daemon lifecycle
        result = await ctl_spawn_subagent(
            "inst_running",
            "personas/helper.json",
            "Task",
            lifecycle="daemon",
        )

        assert isinstance(result, Success)

        from anima.shell.state import list_agents

        agents_result = list_agents()
        assert isinstance(agents_result, Success)
        agents = agents_result.unwrap()
        child = next(a for a in agents if a.parent_id == "inst_running")

        assert child.lifecycle == LifecycleType.daemon

    @pytest.mark.asyncio
    async def test_spawn_inherits_parent_lifecycle_when_no_override(
        self, running_agent: AgentStateData, work_dir: Path
    ) -> None:
        """ctl_spawn_subagent inherits parent lifecycle when not overridden."""
        running_agent.persona_ref = "personas/parent.json"
        running_agent.lifecycle = LifecycleType.daemon
        write_state("inst_running", running_agent)

        # No lifecycle override
        result = await ctl_spawn_subagent(
            "inst_running",
            "personas/helper.json",
            "Task",
        )

        assert isinstance(result, Success)

        from anima.shell.state import list_agents

        agents_result = list_agents()
        assert isinstance(agents_result, Success)
        agents = agents_result.unwrap()
        child = next(a for a in agents if a.parent_id == "inst_running")

        assert child.lifecycle == LifecycleType.daemon

    @pytest.mark.asyncio
    async def test_spawn_context_mode_shared_forwards_to_child(
        self, running_agent: AgentStateData, work_dir: Path, tmp_state_dir: Path
    ) -> None:
        """ctl_spawn_subagent accepts context_mode='shared' parameter.

        Note: context_mode is stored in JobSpec (not persisted to AgentStateData).
        This test verifies the function accepts the parameter without error.
        The actual context forwarding is handled by the context_bundle mechanism.
        """
        running_agent.persona_ref = "personas/parent.json"
        write_state("inst_running", running_agent)

        result = await ctl_spawn_subagent(
            "inst_running",
            "personas/helper.json",
            "Task",
            context_mode="shared",
        )

        # Verify spawn succeeds with context_mode='shared'
        assert isinstance(result, Success)
        child_id = result.unwrap()

        # Verify child was created
        from anima.shell.state import list_agents

        agents_result = list_agents()
        assert isinstance(agents_result, Success)
        agents = agents_result.unwrap()
        child = next(a for a in agents if a.instance_id == child_id)
        assert child.persona_name == "helper"

    @pytest.mark.asyncio
    async def test_spawn_context_mode_isolated_is_default(
        self, running_agent: AgentStateData, work_dir: Path
    ) -> None:
        """ctl_spawn_subagent defaults to context_mode='isolated'.

        This test verifies the function accepts the default parameter without error.
        """
        running_agent.persona_ref = "personas/parent.json"
        write_state("inst_running", running_agent)

        # No context_mode specified - should default to isolated
        result = await ctl_spawn_subagent(
            "inst_running",
            "personas/helper.json",
            "Task",
        )

        # Verify spawn succeeds
        assert isinstance(result, Success)
        child_id = result.unwrap()

        # Verify child was created
        from anima.shell.state import list_agents

        agents_result = list_agents()
        assert isinstance(agents_result, Success)
        agents = agents_result.unwrap()
        child = next(a for a in agents if a.instance_id == child_id)
        assert child.persona_name == "helper"

    @pytest.mark.asyncio
    async def test_spawn_invalid_context_mode_returns_failure(
        self, running_agent: AgentStateData, work_dir: Path
    ) -> None:
        """ctl_spawn_subagent returns Failure for invalid context_mode.

        Regression test: invalid context_mode should NOT raise uncaught ValueError.
        """
        running_agent.persona_ref = "personas/parent.json"
        write_state("inst_running", running_agent)

        # Invalid context_mode should return Failure, not raise ValueError
        result = await ctl_spawn_subagent(
            "inst_running",
            "personas/helper.json",
            "Task",
            context_mode="invalid_mode",
        )

        assert isinstance(result, Failure)
        error = result.failure()
        assert "context_mode" in str(error).lower()

    @pytest.mark.asyncio
    async def test_spawn_max_tokens_override_within_budget_succeeds(
        self, running_agent: AgentStateData, work_dir: Path
    ) -> None:
        """ctl_spawn_subagent allows max_tokens override within parent budget."""
        running_agent.persona_ref = "personas/parent.json"
        running_agent.budget_total = 1000
        write_state("inst_running", running_agent)

        result = await ctl_spawn_subagent(
            "inst_running",
            "personas/helper.json",
            "Task",
            max_tokens=500,  # Within budget
        )

        assert isinstance(result, Success)

        from anima.shell.state import list_agents

        agents_result = list_agents()
        assert isinstance(agents_result, Success)
        agents = agents_result.unwrap()
        child = next(a for a in agents if a.parent_id == "inst_running")

        assert child.budget_total == 500

    @pytest.mark.asyncio
    async def test_spawn_max_turns_override_within_limit_succeeds(
        self, running_agent: AgentStateData, work_dir: Path
    ) -> None:
        """ctl_spawn_subagent allows max_turns override within parent limit."""
        running_agent.persona_ref = "personas/parent.json"
        running_agent.max_turns = 100
        write_state("inst_running", running_agent)

        result = await ctl_spawn_subagent(
            "inst_running",
            "personas/helper.json",
            "Task",
            max_turns=50,  # Within limit
        )

        assert isinstance(result, Success)

        from anima.shell.state import list_agents

        agents_result = list_agents()
        assert isinstance(agents_result, Success)
        agents = agents_result.unwrap()
        child = next(a for a in agents if a.parent_id == "inst_running")

        assert child.max_turns == 50

    @pytest.mark.asyncio
    async def test_spawn_positional_args_work(
        self, running_agent: AgentStateData, work_dir: Path
    ) -> None:
        """ctl_spawn_subagent works with positional arguments (backward compat)."""
        running_agent.persona_ref = "personas/parent.json"
        write_state("inst_running", running_agent)

        # Positional arguments: instance_id, persona_ref, task
        result = await ctl_spawn_subagent(
            "inst_running",
            "personas/helper.json",
            "Task with positional args",
        )

        assert isinstance(result, Success)
        child_id = result.unwrap()
        assert child_id.startswith("inst_")

        # Verify child was created
        from anima.shell.state import list_agents

        agents_result = list_agents()
        assert isinstance(agents_result, Success)
        agents = agents_result.unwrap()
        child = next(a for a in agents if a.instance_id == child_id)
        assert child.persona_name == "helper"

    @pytest.mark.asyncio
    async def test_spawn_positional_persona_ref_with_keyword_task(
        self, running_agent: AgentStateData, work_dir: Path
    ) -> None:
        """ctl_spawn_subagent works with positional persona_ref and keyword task."""
        running_agent.persona_ref = "personas/parent.json"
        write_state("inst_running", running_agent)

        # Mixed: positional instance_id, persona_ref; keyword task
        result = await ctl_spawn_subagent(
            "inst_running",
            "personas/helper.json",
            task="Task with keyword task arg",
        )

        assert isinstance(result, Success)
        child_id = result.unwrap()
        assert child_id.startswith("inst_")

    @pytest.mark.asyncio
    async def test_spawn_tool_positional_order_persona_ref_then_task(
        self, running_agent: AgentStateData, work_dir: Path
    ) -> None:
        """ctl_spawn_subagent tool wrapper accepts positional (persona_ref, task)."""
        running_agent.persona_ref = "personas/parent.json"
        write_state("inst_running", running_agent)

        runner = AgentRunner(instance_id="inst_running", task="Parent task")
        tools = create_ctl_tools("inst_running", runner)
        spawn_tool = next(tool for tool in tools if tool.name == "ctl_spawn")

        result = await spawn_tool.function(
            "personas/helper.json",
            "Task via positional args",
        )

        assert result.startswith("Spawned subagent: inst_")

    @pytest.mark.asyncio
    async def test_spawn_tool_allows_omitting_persona_ref_with_keyword_task(
        self, running_agent: AgentStateData, work_dir: Path
    ) -> None:
        """Tool wrapper supports persona_ref omission via keyword-only task call."""
        running_agent.persona_ref = "personas/parent.json"
        write_state("inst_running", running_agent)

        runner = AgentRunner(instance_id="inst_running", task="Parent task")
        tools = create_ctl_tools("inst_running", runner)
        spawn_tool = next(tool for tool in tools if tool.name == "ctl_spawn")

        result = await spawn_tool.function(task="Task via omission contract")

        assert result.startswith("Spawned subagent: inst_")

        from anima.shell.state import list_agents

        agents_result = list_agents()
        assert isinstance(agents_result, Success)
        agents = agents_result.unwrap()
        child = next(a for a in agents if a.parent_id == "inst_running")
        assert child.persona_ref == "personas/parent.json"
        assert child.persona_name == "parent"

    @pytest.mark.asyncio
    async def test_spawn_tool_task_first_positional_is_explicit_error(
        self, running_agent: AgentStateData, work_dir: Path
    ) -> None:
        """Task-first positional ordering is rejected to avoid arg ambiguity."""
        running_agent.persona_ref = "personas/parent.json"
        write_state("inst_running", running_agent)

        runner = AgentRunner(instance_id="inst_running", task="Parent task")
        tools = create_ctl_tools("inst_running", runner)
        spawn_tool = next(tool for tool in tools if tool.name == "ctl_spawn")

        result = await spawn_tool.function(
            "Task via task-first positional args",
            "personas/helper.json",
        )

        assert result.startswith("Error:")

    @pytest.mark.asyncio
    async def test_spawn_tool_missing_task_reports_explicit_error(
        self, running_agent: AgentStateData, work_dir: Path
    ) -> None:
        """Wrapper returns explicit contract error when task is omitted."""
        running_agent.persona_ref = "personas/parent.json"
        write_state("inst_running", running_agent)

        runner = AgentRunner(instance_id="inst_running", task="Parent task")
        tools = create_ctl_tools("inst_running", runner)
        spawn_tool = next(tool for tool in tools if tool.name == "ctl_spawn")

        result = await spawn_tool.function()

        assert result == "Error: task is required"


class TestCtlSpawnCanSpawnVariants:
    """Tests for can_spawn variants in ctl_spawn_subagent."""

    @pytest.mark.asyncio
    async def test_spawn_can_spawn_true(
        self, tmp_state_dir: Path, work_dir: Path
    ) -> None:
        """can_spawn=True allows any persona."""
        state = AgentStateData(
            trace_id="tr_test",
            instance_id="inst_parent",
            persona_id="parent",
            persona_ref="personas/parent.json",
            state=AgentState.running,
            depth=0,
            max_depth=3,
        )
        write_state("inst_parent", state)

        # parent has can_spawn=True, should work with any child
        result = await ctl_spawn_subagent(
            "inst_parent",
            "personas/helper.json",
            "Task",
        )
        assert isinstance(result, Success)

    @pytest.mark.asyncio
    async def test_spawn_can_spawn_list_allowlist(
        self, tmp_state_dir: Path, work_dir: Path
    ) -> None:
        """can_spawn=[list] only allows listed personas."""
        state = AgentStateData(
            trace_id="tr_test",
            instance_id="inst_worker",
            persona_id="worker",
            persona_ref="personas/worker.json",
            state=AgentState.running,
            depth=0,
            max_depth=3,
        )
        write_state("inst_worker", state)

        # worker can spawn helper (in allowlist)
        result = await ctl_spawn_subagent(
            "inst_worker",
            "personas/helper.json",
            "Task",
        )
        assert isinstance(result, Success)

    @pytest.mark.asyncio
    async def test_spawn_can_spawn_list_rejects_unlisted(
        self, tmp_state_dir: Path, work_dir: Path
    ) -> None:
        """can_spawn=[list] rejects personas not in list."""
        state = AgentStateData(
            trace_id="tr_test",
            instance_id="inst_worker",
            persona_id="worker",
            persona_ref="personas/worker.json",
            state=AgentState.running,
        )
        write_state("inst_worker", state)

        # worker cannot spawn parent (not in allowlist)
        result = await ctl_spawn_subagent(
            "inst_worker",
            "personas/parent.json",
            "Task",
        )
        assert isinstance(result, Failure)


class TestDaemonGuard:
    """Tests for daemon lifecycle guard behavior."""

    @pytest.mark.asyncio
    async def test_finish_ignored_for_daemon_lifecycle(
        self, daemon_agent: AgentStateData
    ) -> None:
        """Daemon agents ignore ctl_finish calls."""
        result = await ctl_finish("inst_daemon")

        assert isinstance(result, Success)

        # State should remain running
        state_result = read_state("inst_daemon")
        assert isinstance(state_result, Success)
        state = state_result.unwrap()
        assert state.state == AgentState.running  # Not completed

    @pytest.mark.asyncio
    async def test_fail_still_works_for_daemon(
        self, daemon_agent: AgentStateData
    ) -> None:
        """Daemon agents can still be failed."""
        result = await ctl_fail("inst_daemon", error="Daemon crash")

        assert isinstance(result, Success)

        state_result = read_state("inst_daemon")
        assert isinstance(state_result, Success)
        state = state_result.unwrap()
        assert state.state == AgentState.failed

    @pytest.mark.asyncio
    async def test_request_approval_works_for_daemon(
        self, daemon_agent: AgentStateData
    ) -> None:
        """Daemon agents can request approval and resume when approved."""
        approval_task = asyncio.create_task(
            ctl_request_approval("inst_daemon", reason="Need approval")
        )
        await asyncio.sleep(0.2)
        assert not approval_task.done()

        transition_result = transition_state("inst_daemon", LifecycleEvent.approve)
        assert isinstance(transition_result, Success)

        result = await asyncio.wait_for(approval_task, timeout=1.0)

        assert isinstance(result, Success)
        assert result.unwrap() == {"approved": True, "note": None}

        state_result = read_state("inst_daemon")
        assert isinstance(state_result, Success)
        state = state_result.unwrap()
        assert state.state == AgentState.running

    @pytest.mark.asyncio
    async def test_suspend_works_for_daemon(self, daemon_agent: AgentStateData) -> None:
        """Daemon agents can be suspended."""
        result = await ctl_suspend_until("inst_daemon")

        assert isinstance(result, Success)

        state_result = read_state("inst_daemon")
        assert isinstance(state_result, Success)
        state = state_result.unwrap()
        assert state.state == AgentState.suspended


class TestMaxDepthVariants:
    """Tests for max_depth edge cases."""

    @pytest.mark.asyncio
    async def test_spawn_at_max_depth_minus_one(
        self, tmp_state_dir: Path, work_dir: Path
    ) -> None:
        """Spawn succeeds at depth = max_depth - 1."""
        state = AgentStateData(
            trace_id="tr_test",
            instance_id="inst_depth2",
            persona_id="parent",
            persona_ref="personas/parent.json",
            state=AgentState.running,
            depth=1,
            max_depth=3,  # depth + 1 = 2 < max_depth
        )
        write_state("inst_depth2", state)

        result = await ctl_spawn_subagent(
            "inst_depth2",
            "personas/helper.json",
            "Task",
        )

        assert isinstance(result, Success)

    @pytest.mark.asyncio
    async def test_spawn_at_max_depth_fails(
        self, tmp_state_dir: Path, work_dir: Path
    ) -> None:
        """Spawn fails when depth == max_depth - 1 for next child would be >= max_depth.

        The check is: current_depth + 1 >= max_depth
        So if depth=2 and max_depth=3, then 2+1=3 >= 3, fails.
        """
        state = AgentStateData(
            trace_id="tr_test",
            instance_id="inst_at_limit",
            persona_id="parent",
            persona_ref="personas/parent.json",
            state=AgentState.running,
            depth=2,
            max_depth=3,  # depth + 1 = 3 >= max_depth, fails
        )
        write_state("inst_at_limit", state)

        result = await ctl_spawn_subagent(
            "inst_at_limit",
            "personas/helper.json",
            "Task",
        )

        assert isinstance(result, Failure)


class TestCtlSpawnEdgeCaseValidation:
    """Regression tests for edge-case validation.

    These tests verify that edge-case inputs (max_turns=0, max_tokens=0,
    invalid lifecycle) are handled with explicit contract-aligned failure
    instead of uncaught pydantic.ValidationError.

    See: spawn-interface-verify.fix-spawn-interface-edge-case-validation
    """

    @pytest.mark.asyncio
    async def test_spawn_rejects_max_turns_zero(
        self, running_agent: AgentStateData, work_dir: Path
    ) -> None:
        """max_turns=0 is rejected with explicit error, not ValidationError."""
        running_agent.persona_ref = "personas/parent.json"
        write_state("inst_running", running_agent)

        result = await ctl_spawn_subagent(
            "inst_running",
            "personas/helper.json",
            "Task",
            max_turns=0,
        )

        assert isinstance(result, Failure)
        error = result.failure()
        # Should be InvalidInputError, not uncaught pydantic.ValidationError
        assert "max_turns" in str(error).lower()
        assert ">= 1" in str(error)

    @pytest.mark.asyncio
    async def test_spawn_rejects_max_tokens_zero(
        self, running_agent: AgentStateData, work_dir: Path
    ) -> None:
        """max_tokens=0 is rejected with explicit error, not ValidationError."""
        running_agent.persona_ref = "personas/parent.json"
        write_state("inst_running", running_agent)

        result = await ctl_spawn_subagent(
            "inst_running",
            "personas/helper.json",
            "Task",
            max_tokens=0,
        )

        assert isinstance(result, Failure)
        error = result.failure()
        # Should be InvalidInputError, not uncaught pydantic.ValidationError
        assert "max_tokens" in str(error).lower()
        assert ">= 1" in str(error)

    @pytest.mark.asyncio
    async def test_spawn_rejects_invalid_lifecycle(
        self, running_agent: AgentStateData, work_dir: Path
    ) -> None:
        """Invalid lifecycle is rejected with explicit error, not ValidationError."""
        running_agent.persona_ref = "personas/parent.json"
        write_state("inst_running", running_agent)

        result = await ctl_spawn_subagent(
            "inst_running",
            "personas/helper.json",
            "Task",
            lifecycle="not_a_valid_lifecycle",
        )

        assert isinstance(result, Failure)
        error = result.failure()
        # Should be InvalidInputError, not uncaught pydantic.ValidationError
        assert "lifecycle" in str(error).lower()
        assert "invalid" in str(error).lower()

    @pytest.mark.asyncio
    async def test_spawn_rejects_negative_max_turns(
        self, running_agent: AgentStateData, work_dir: Path
    ) -> None:
        """Negative max_turns is rejected with explicit error."""
        running_agent.persona_ref = "personas/parent.json"
        write_state("inst_running", running_agent)

        result = await ctl_spawn_subagent(
            "inst_running",
            "personas/helper.json",
            "Task",
            max_turns=-5,
        )

        assert isinstance(result, Failure)
        error = result.failure()
        assert "max_turns" in str(error).lower()

    @pytest.mark.asyncio
    async def test_spawn_rejects_negative_max_tokens(
        self, running_agent: AgentStateData, work_dir: Path
    ) -> None:
        """Negative max_tokens is rejected with explicit error."""
        running_agent.persona_ref = "personas/parent.json"
        write_state("inst_running", running_agent)

        result = await ctl_spawn_subagent(
            "inst_running",
            "personas/helper.json",
            "Task",
            max_tokens=-100,
        )

        assert isinstance(result, Failure)
        error = result.failure()
        assert "max_tokens" in str(error).lower()


class TestCtlSpawnProvenanceFixtures:
    """Fixture matrix tests for ctl_spawn_subagent provenance branches."""

    def test_spawn_fixture_matrix_includes_all_provenance_sources(self) -> None:
        cases = make_ctl_spawn_subagent_fixture_cases()
        sources = {case.provenance_source for case in cases}

        assert {"file", "inline", "synthesized", "snapshot"}.issubset(sources)

    def test_inline_and_synthesized_fixture_matrix_has_spawn_or_structured_failure(
        self,
    ) -> None:
        cases = make_ctl_spawn_subagent_fixture_cases()
        inline_cases = [case for case in cases if case.provenance_source == "inline"]
        synthesized_cases = [
            case for case in cases if case.provenance_source == "synthesized"
        ]

        assert any(case.outcome.kind == "spawned" for case in inline_cases)
        assert any(case.outcome.kind == "spawnability_error" for case in inline_cases)
        assert any(case.outcome.kind == "spawned" for case in synthesized_cases)
        assert any(
            case.outcome.kind == "spawnability_error" for case in synthesized_cases
        )

    def test_non_spawnable_fixture_outcomes_use_structured_user_reason(self) -> None:
        cases = make_ctl_spawn_subagent_fixture_cases()
        non_spawnable = [
            case for case in cases if case.outcome.kind == "spawnability_error"
        ]

        assert non_spawnable
        for case in non_spawnable:
            reason = case.outcome.reason
            assert reason.code in {
                "inline_persona_requires_materialization",
                "synthesized_persona_requires_materialization",
            }
            assert len(reason.message) > 0
            assert len(reason.user_action or "") > 0
            assert reason.retryable is True

    def test_explicit_child_override_fixture_is_available(self) -> None:
        override = override_persona_fixture_for_spawn()

        assert override.persona_ref == "personas/child-override.json"


class TestCtlSpawnContractProvenance:
    """Regression tests for contract-truth spawnability handling."""

    @pytest.mark.asyncio
    async def test_spawn_uses_contract_spawnability_when_parent_persona_ref_missing(
        self, tmp_state_dir: Path, work_dir: Path
    ) -> None:
        parent = AgentStateData(
            trace_id="tr_contract",
            instance_id="inst_contract_parent",
            persona_id="inline-parent",
            persona_ref=None,
            state=AgentState.running,
            depth=0,
            max_depth=3,
        )
        write_state(parent.instance_id, parent)

        parent_persona = PersonaSpec(
            id="inline-parent",
            description="Inline parent",
            prompt="Inline",
            model="claude-sonnet-4-20250514",
        )
        contract = resolve_execution_contract(
            ExecutionIntent(
                persona_spec=parent_persona,
                job_spec=JobSpec(
                    persona_ref="personas/parent.json",
                    instance_id=parent.instance_id,
                    lifecycle=parent.lifecycle,
                    max_depth=parent.max_depth,
                ),
            ),
            persona_source="inline",
            inline_behavior="reject_spawn",
        )
        snapshot_result = write_contract_snapshot(parent.instance_id, contract)
        assert isinstance(snapshot_result, Success)

        result = await ctl_spawn_subagent(
            parent.instance_id,
            None,
            "child task from contract truth",
        )

        assert isinstance(result, Failure)
        details = getattr(result.failure(), "details", {})
        assert details.get("code") == "inline_persona_requires_materialization"

    @pytest.mark.asyncio
    async def test_spawn_contract_false_wins_over_legacy_persona_ref_presence(
        self, running_agent: AgentStateData, work_dir: Path
    ) -> None:
        running_agent.persona_ref = "personas/parent.json"
        write_state("inst_running", running_agent)

        parent_persona = PersonaSpec(
            id="parent",
            description="Parent",
            prompt="Parent",
            model="claude-sonnet-4-20250514",
        )
        contract = resolve_execution_contract(
            ExecutionIntent(
                persona_spec=parent_persona,
                job_spec=JobSpec(
                    persona_ref="personas/parent.json",
                    instance_id="inst_running",
                    lifecycle=running_agent.lifecycle,
                    max_depth=running_agent.max_depth,
                ),
            ),
            persona_source="inline",
            inline_behavior="reject_spawn",
        )
        snapshot_result = write_contract_snapshot("inst_running", contract)
        assert isinstance(snapshot_result, Success)

        result = await ctl_spawn_subagent("inst_running", None, "child task")

        assert isinstance(result, Failure)
        details = getattr(result.failure(), "details", {})
        assert details.get("code") in {
            "inline_persona_requires_materialization",
            "synthesized_persona_requires_materialization",
        }
