"""Focused behavioral tests for shell agent runner paths.

Spec sources:
- User task: increase coverage for src/anima/shell/agent.py with meaningful tests.
- docs/INTERFACES.md Section H.4 (timeout and interrupt semantics), referenced by
  AgentRunner docstrings and inline comments.
"""

from __future__ import annotations

import asyncio
from contextlib import asynccontextmanager
from dataclasses import dataclass
from typing import Any, cast

import pytest
from returns.result import Failure, Success

from anima.core.lifecycle import LifecycleEvent
from anima.core.schemas import (
    AgentState,
    AnimaTimeoutError,
    CompactionConfig,
    CompletionPolicy,
    JobSpec,
    LifecycleType,
)
from anima.shell.agent import AgentRunner
from anima.shell.agent_sync import run_agent_sync
from anima.shell.state import (
    create_agent_state,
    read_state,
    read_log,
    transition_state,
)
from anima.shell.mcp_toolset_policy import MAX_TOOL_RESULT_TEXT_CHARS


def _create_running_runner(
    *,
    instance_id: str,
    max_turns: int | None = None,
    lifecycle: LifecycleType = LifecycleType.ephemeral,
) -> AgentRunner:
    """Create an AgentRunner whose persisted state is transitioned to running."""
    job = JobSpec(
        persona_ref="test@test",
        instance_id=instance_id,
        max_turns=max_turns,
        lifecycle=lifecycle,
    )
    created = create_agent_state(instance_id, "test-persona", job)
    assert isinstance(created, Success)
    instance_id = created.unwrap().instance_id
    started = transition_state(instance_id, LifecycleEvent.start)
    assert isinstance(started, Success)
    return AgentRunner(
        instance_id=instance_id,
        task="test task",
        job=job,
        completion_policy=CompletionPolicy.auto,
        max_turns=max_turns,
    )


def _expected_system_reminder(original_message: str) -> str:
    """Return the exact system-reminder wrapper required by spec.

    Spec source: plan step
    ``system-reminder-wrap.replace-plain-text-prefix-with-system-reminder-wrapping``.
    """
    return (
        "<system-reminder>\n"
        "The user sent a new message while you were working:\n"
        f"{original_message}\n\n"
        "IMPORTANT: After completing your current task, you MUST address "
        "the user's message above. Do not ignore it.\n"
        "</system-reminder>"
    )


def test_on_timeout_transitions_state_and_signals_interrupt(tmp_state_dir: Any) -> None:
    """Timeout callback transitions state to failed and interrupts MCP wiring."""
    _ = tmp_state_dir
    runner = _create_running_runner(instance_id="inst_timeout", max_turns=10)

    from tests.helpers.mcp_fakes import FakeMCPServer

    fake_mcp = FakeMCPServer()
    runner.mcp_server = fake_mcp  # type: ignore[assignment]  # justified: minimal protocol in test

    runner._on_timeout(runner.instance_id)

    assert runner._timeout_fired is True
    assert runner._interrupted is True
    assert fake_mcp.get_interrupt_event().is_set() is True

    state_result = read_state(runner.instance_id)
    assert isinstance(state_result, Success)
    state = state_result.unwrap()
    assert state.state == AgentState.failed
    assert state.error is not None
    assert "timed out" in state.error

    log_result = read_log(runner.instance_id)
    assert isinstance(log_result, Success)
    events = [entry.get("event") for entry in log_result.unwrap()]
    assert "timeout" in events


def test_check_interrupt_returns_timeout_failure() -> None:
    """Timeout has priority over normal kill handling (H.4)."""
    runner = AgentRunner(instance_id="inst_timeout_priority", task="task", timeout="3m")
    runner._timeout_fired = True
    runner._interrupted = True

    result = runner._check_interrupt()

    assert isinstance(result, Failure)
    error = result.failure()
    assert isinstance(error, AnimaTimeoutError)
    assert "3m" in str(error)


def test_check_interrupt_kills_agent_and_returns_killed_result(
    tmp_state_dir: Any,
) -> None:
    """Interrupt signal transitions running agent to killed state."""
    _ = tmp_state_dir
    runner = _create_running_runner(instance_id="inst_interrupt", max_turns=5)
    runner._interrupted = True

    result = runner._check_interrupt()

    assert isinstance(result, Success)
    agent_result = result.unwrap()
    assert agent_result.final_state == AgentState.killed

    state_result = read_state(runner.instance_id)
    assert isinstance(state_result, Success)
    assert state_result.unwrap().state == AgentState.killed


def test_handle_model_request_enforces_max_turns(tmp_state_dir: Any) -> None:
    """Max turns now enters grace window instead of immediate failure.

    Spec source: spec-compliance-v2.max-turns-grace
    When max_turns is reached, agent enters grace window to allow ctl_finish.
    """
    _ = tmp_state_dir
    runner = _create_running_runner(instance_id="inst_max_turns", max_turns=1)
    runner.turn_count = 1

    result = runner._handle_model_request()

    # Now enters grace instead of immediate failure
    assert result is None  # No immediate failure
    assert runner.in_grace_window is True
    assert runner.max_turns_exceeded is True

    state_result = read_state(runner.instance_id)
    assert isinstance(state_result, Success)
    # State remains running (not failed) because we're in grace
    assert state_result.unwrap().state == AgentState.running


def test_check_auto_complete_transitions_to_completed(tmp_state_dir: Any) -> None:
    """Auto policy completes after two consecutive no-tool turns."""
    _ = tmp_state_dir
    runner = _create_running_runner(instance_id="inst_auto_complete", max_turns=5)
    runner.no_tool_streak = 2

    result = runner._check_auto_complete()

    assert isinstance(result, Success)
    assert result.unwrap().final_state == AgentState.completed

    state_result = read_state(runner.instance_id)
    assert isinstance(state_result, Success)
    assert state_result.unwrap().state == AgentState.completed


@pytest.mark.asyncio
async def test_poll_pending_message_reads_queue_and_empty_timeout() -> None:
    """Polling returns queue message and None when queue has no item."""
    runner = AgentRunner(instance_id="inst_poll", task="task")

    from tests.helpers.mcp_fakes import FakeMCPServer

    server = FakeMCPServer()
    await server.queue.put("ping")
    runner.mcp_server = server  # type: ignore[assignment]  # justified: minimal protocol in test

    first = await runner._poll_pending_message(timeout_seconds=0.01)
    second = await runner._poll_pending_message(timeout_seconds=0.01)

    assert first == "ping"
    assert second is None


@pytest.mark.asyncio
async def test_poll_pending_message_injects_followup_hint_after_queued_turn() -> None:
    """Queued follow-up gets context hint on next dequeue.

    Spec source: vectl step
    ``agent-loop-resilience.notify-agent-of-pending-messages-after-current-turn``.
    """
    runner = AgentRunner(instance_id="inst_poll_hint", task="task")

    from tests.helpers.mcp_fakes import FakeMCPServer

    server = FakeMCPServer()
    await server.queue.put("second message")
    runner.mcp_server = server  # type: ignore[assignment]  # justified: minimal protocol in test

    runner._queue_followup_hint_if_pending_messages()

    hinted_message = await runner._poll_pending_message(timeout_seconds=0.01)

    assert hinted_message == _expected_system_reminder("second message")


@pytest.mark.asyncio
async def test_poll_pending_message_defensive_yield_when_mcp_server_is_none() -> None:
    """Defensive yield before early return when mcp_server is None.

    Spec source: vectl step
    ``async-poll-hardening.add-defensive-yield-in-poll-pending-message-when-mcp-server-is-none``.
    """
    runner = AgentRunner(instance_id="inst_no_mcp", task="task")
    # mcp_server is None by default in AgentRunner.__init__
    assert runner.mcp_server is None

    # Should yield before returning None, preventing tight spin
    result = await runner._poll_pending_message(timeout_seconds=0.01)
    assert result is None


@pytest.mark.asyncio
async def test_poll_pending_message_defensive_yield_when_queue_is_none() -> None:
    """Defensive yield before early return when queue is None.

    Spec source: vectl step
    ``async-poll-hardening.add-defensive-yield-in-poll-pending-message-when-mcp-server-is-none``.
    """
    runner = AgentRunner(instance_id="inst_no_queue", task="task")

    # Create minimal server with None-returning get_pending_message
    class NullQueueMCPServer:
        """Test double that returns None from get_pending_message."""

        instance_id = "null_queue_server"

        def get_pending_message(self) -> None:
            return None

        async def start(self) -> None:
            pass

        async def stop(self) -> None:
            pass

    runner.mcp_server = NullQueueMCPServer()  # type: ignore[assignment]  # justified: minimal protocol in test

    # Should yield before returning None, preventing tight spin
    result = await runner._poll_pending_message(timeout_seconds=0.01)
    assert result is None


@pytest.mark.asyncio
async def test_run_agent_loop_daemon_does_not_exit_on_empty_poll(
    tmp_state_dir: Any,
) -> None:
    """Daemon loop stays alive through empty polls until explicit finish signal."""
    _ = tmp_state_dir
    from pydantic_ai._agent_graph import ModelRequestNode
    from pydantic_ai.messages import ModelRequest, UserPromptPart

    runner = _create_running_runner(
        instance_id="inst_daemon_interact",
        max_turns=10,
        lifecycle=LifecycleType.daemon,
    )
    runner.completion_policy = CompletionPolicy.explicit

    request = ModelRequest(parts=[UserPromptPart(content="hello")])
    run = _FakeIterRun(
        nodes=[ModelRequestNode(request=request)],
        messages=[],
        usage=_UsageSnapshot(1, 1),
    )
    agent = _FakeAgent(run)

    state_result = read_state(runner.instance_id)
    assert isinstance(state_result, Success)

    poll_calls = 0

    async def _fake_poll(timeout_seconds: float | None = 30.0) -> str | None:
        _ = timeout_seconds
        nonlocal poll_calls
        poll_calls += 1
        if poll_calls == 1:
            return None
        return "follow-up"

    async def _fake_process(
        _agent: Any, _message: str, history: list[Any]
    ) -> Success[Any]:
        done = transition_state(runner.instance_id, LifecycleEvent.finish)
        assert isinstance(done, Success)
        return Success(history)

    runner._poll_pending_message = _fake_poll  # type: ignore[method-assign]  # justified: controlled unit seam
    runner._process_interact_message = _fake_process  # type: ignore[method-assign]  # justified: controlled unit seam

    result = await runner._run_agent_loop(cast(Any, agent), state_result.unwrap())

    assert isinstance(result, Success)
    assert result.unwrap().final_state == AgentState.completed
    assert poll_calls >= 2


@pytest.mark.asyncio
async def test_run_agent_loop_daemon_yields_when_poll_returns_none(
    tmp_state_dir: Any,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """Daemon interact loop yields control while poll repeatedly returns None.

    Spec source: vectl step
    ``async-poll-hardening.add-regression-test-daemon-loop-yields-when-poll-returns-none``.

    Regression contract:
    - daemon loop must not busy-spin when `_poll_pending_message()` returns None
    - loop must invoke an async yield before next poll
    - test fails if that defensive yield is removed
    """
    _ = tmp_state_dir
    from pydantic_ai._agent_graph import ModelRequestNode
    from pydantic_ai.messages import ModelRequest, UserPromptPart

    runner = _create_running_runner(
        instance_id="inst_daemon_yield_none_poll",
        max_turns=10,
        lifecycle=LifecycleType.daemon,
    )
    runner.completion_policy = CompletionPolicy.explicit

    request = ModelRequest(parts=[UserPromptPart(content="hello")])
    run = _FakeIterRun(
        nodes=[ModelRequestNode(request=request)],
        messages=[],
        usage=_UsageSnapshot(1, 1),
    )
    agent = _FakeAgent(run)

    state_result = read_state(runner.instance_id)
    assert isinstance(state_result, Success)

    poll_calls = 0
    yield_calls = 0

    import anima.shell.agent as agent_module

    real_sleep = asyncio.sleep

    async def _tracking_sleep(delay: float) -> None:
        _ = delay
        nonlocal yield_calls
        yield_calls += 1
        await real_sleep(0)

    monkeypatch.setattr(agent_module.asyncio, "sleep", _tracking_sleep)

    async def _none_then_message_poll(
        timeout_seconds: float | None = 30.0,
    ) -> str | None:
        _ = timeout_seconds
        nonlocal poll_calls
        poll_calls += 1
        if poll_calls == 1:
            return None
        return "follow-up"

    async def _process_and_stop(
        _agent: Any,
        _message: str,
        history: list[Any],
    ) -> Success[Any]:
        runner._interrupted = True
        return Success(history)

    runner._poll_pending_message = _none_then_message_poll  # type: ignore[method-assign]  # justified: controlled unit seam
    runner._process_interact_message = _process_and_stop  # type: ignore[method-assign]  # justified: controlled unit seam

    result = await runner._run_agent_loop(cast(Any, agent), state_result.unwrap())

    assert isinstance(result, Success)
    assert result.unwrap().final_state == AgentState.killed
    assert poll_calls >= 2
    assert yield_calls >= 1, (
        "Daemon loop did not yield after poll returned None; "
        "defensive sleep appears to be missing"
    )


@dataclass
class _Usage:
    """Small usage carrier matching pydantic-ai run usage shape."""

    input_tokens: int
    output_tokens: int


def test_track_token_usage_updates_tracker_and_state(tmp_state_dir: Any) -> None:
    """Token usage accounting updates in-memory and persisted state."""
    _ = tmp_state_dir
    runner = _create_running_runner(instance_id="inst_usage", max_turns=5)

    runner._track_token_usage(_Usage(input_tokens=7, output_tokens=11))

    assert runner.token_usage.prompt_tokens == 7
    assert runner.token_usage.completion_tokens == 11
    assert runner.budget_tracker.used == 18

    state_result = read_state(runner.instance_id)
    assert isinstance(state_result, Success)
    persisted_usage = state_result.unwrap().token_usage
    assert persisted_usage.prompt_tokens == 7
    assert persisted_usage.completion_tokens == 11


def test_budget_exhaustion_enters_grace_then_fails_when_grace_consumed(
    tmp_state_dir: Any,
) -> None:
    """Budget handling follows grace-entry then grace-exhausted failure path."""
    _ = tmp_state_dir
    job = JobSpec(
        persona_ref="test@test",
        instance_id="inst_grace",
        max_tokens=10,
        finalize_grace_budget=3,
    )
    created = create_agent_state("inst_grace", "test-persona", job)
    assert isinstance(created, Success)
    instance_id = created.unwrap().instance_id
    started = transition_state(instance_id, LifecycleEvent.start)
    assert isinstance(started, Success)

    runner = AgentRunner(instance_id=instance_id, task="task", job=job)
    runner.budget_tracker.record_usage(10)

    first = runner._check_budget_exhaustion()
    assert first is None
    assert runner.in_grace_window is True

    runner.budget_tracker.record_usage(3)
    second = runner._check_budget_exhaustion()
    assert isinstance(second, Failure)
    assert "Grace budget exhausted" in str(second.failure())

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


def test_budget_exhaustion_without_grace_returns_failed_result(
    tmp_state_dir: Any,
) -> None:
    """No grace budget produces Failure(BudgetExhaustedError) per spec-compliance-v2."""
    _ = tmp_state_dir
    job = JobSpec(
        persona_ref="test@test",
        instance_id="inst_no_grace",
        max_tokens=5,
        finalize_grace_budget=0,
    )
    created = create_agent_state("inst_no_grace", "test-persona", job)
    assert isinstance(created, Success)
    instance_id = created.unwrap().instance_id
    started = transition_state(instance_id, LifecycleEvent.start)
    assert isinstance(started, Success)

    runner = AgentRunner(instance_id=instance_id, task="task", job=job)
    runner.budget_tracker.record_usage(5)

    result = runner._check_budget_exhaustion()
    assert isinstance(result, Failure)
    error = result.failure()
    assert error.code == 310  # BudgetExhaustedError code
    assert "Budget exhausted" in error.message

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


def test_run_agent_sync_maps_success_failure_and_killed_exit_codes(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """Sync wrapper exit codes: completed=0, killed=130, failure=1."""

    async def _success_completed(self: AgentRunner) -> Success[Any]:
        from anima.core.schemas import AgentResult

        return Success(AgentResult(final_state=AgentState.completed))

    async def _success_killed(self: AgentRunner) -> Success[Any]:
        from anima.core.schemas import AgentResult

        return Success(AgentResult(final_state=AgentState.killed))

    async def _failure(self: AgentRunner) -> Failure[Any]:
        from anima.core.schemas import InternalError

        return Failure(InternalError("boom"))

    monkeypatch.setattr(AgentRunner, "run", _success_completed)
    assert run_agent_sync("ag_sync_1", "task") == 0

    monkeypatch.setattr(AgentRunner, "run", _success_killed)
    assert run_agent_sync("ag_sync_2", "task") == 130

    monkeypatch.setattr(AgentRunner, "run", _failure)
    assert run_agent_sync("ag_sync_3", "task") == 1


class _UsageSnapshot:
    """Usage snapshot with attributes consumed by AgentRunner."""

    def __init__(self, input_tokens: int, output_tokens: int) -> None:
        self.input_tokens = input_tokens
        self.output_tokens = output_tokens


class _FakeIterRun:
    """Minimal async run object compatible with AgentRunner loops."""

    def __init__(
        self,
        nodes: list[Any],
        messages: list[Any],
        usage: _UsageSnapshot,
        *,
        ctx: object | None = None,
    ) -> None:
        self._nodes = nodes
        self._messages = messages
        self._usage = usage
        self._index = 0
        self.ctx = ctx

    def __aiter__(self) -> "_FakeIterRun":
        self._index = 0
        return self

    async def __anext__(self) -> Any:
        if self._index >= len(self._nodes):
            raise StopAsyncIteration
        node = self._nodes[self._index]
        self._index += 1
        return node

    def usage(self) -> _UsageSnapshot:
        return self._usage

    def all_messages(self) -> list[Any]:
        return self._messages


class _FakeIterContext:
    """Async context manager returning _FakeIterRun."""

    def __init__(self, run: _FakeIterRun) -> None:
        self._run = run

    async def __aenter__(self) -> _FakeIterRun:
        return self._run

    async def __aexit__(self, exc_type: Any, exc: Any, tb: Any) -> bool:
        return False


class _FakeAgent:
    """Agent facade exposing iter() for AgentRunner internals."""

    def __init__(self, run: _FakeIterRun) -> None:
        self._run = run

    def iter(self, **_: Any) -> _FakeIterContext:
        return _FakeIterContext(self._run)


class _StreamingFakeMCPServer:
    """Minimal fake MCP server that records stream chunks."""

    def __init__(self) -> None:
        from tests.helpers.mcp_fakes import FakeMCPServer

        self._base: FakeMCPServer = FakeMCPServer()
        self.stream_chunks: list[tuple[int, str]] = []

    @property
    def response(self) -> tuple[str, int] | None:
        return self._base.response

    def __getattr__(self, name: str) -> Any:
        return getattr(self._base, name)

    async def enqueue_stream_chunk(self, turn_number: int, chunk: str) -> None:
        self.stream_chunks.append((turn_number, chunk))


class _FakeAgentStream:
    """Agent stream stub exposing stream_text deltas."""

    def __init__(self, deltas: list[str]) -> None:
        self._deltas = deltas

    async def stream_text(
        self, *, delta: bool = False, debounce_by: float | None = 0.1
    ):
        assert delta is True
        _ = debounce_by
        for item in self._deltas:
            yield item


@asynccontextmanager
async def _fake_model_stream(deltas: list[str]):
    yield _FakeAgentStream(deltas)


@pytest.mark.asyncio
async def test_run_agent_loop_completes_and_persists_finish_state(
    tmp_state_dir: Any,
) -> None:
    """Main loop completes and transitions runner state to completed."""
    _ = tmp_state_dir
    from pydantic_ai._agent_graph import ModelRequestNode
    from pydantic_ai.messages import ModelRequest, UserPromptPart

    runner = _create_running_runner(instance_id="inst_loop_complete", max_turns=10)
    runner.completion_policy = CompletionPolicy.explicit

    request = ModelRequest(parts=[UserPromptPart(content="hello")])
    nodes = [ModelRequestNode(request=request)]
    run = _FakeIterRun(nodes=nodes, messages=[], usage=_UsageSnapshot(2, 3))
    agent = _FakeAgent(run)

    state_result = read_state(runner.instance_id)
    assert isinstance(state_result, Success)

    result = await runner._run_agent_loop(cast(Any, agent), state_result.unwrap())

    assert isinstance(result, Success)
    payload = result.unwrap()
    assert payload.final_state == AgentState.completed
    assert payload.token_usage is not None
    assert payload.token_usage.prompt_tokens > 0

    latest = read_state(runner.instance_id)
    assert isinstance(latest, Success)
    assert latest.unwrap().state == AgentState.completed


@pytest.mark.asyncio
async def test_run_agent_loop_drains_phase1_pending_messages_before_interact_poll(
    tmp_state_dir: Any,
) -> None:
    """Phase-1 queued interact message is processed before completion transition.

    Spec source: vectl step
    ``agent-loop-resilience.drain-pending-messages-after-phase-1-completes``.
    """
    _ = tmp_state_dir
    from pydantic_ai._agent_graph import ModelRequestNode
    from pydantic_ai.messages import ModelRequest, UserPromptPart

    runner = _create_running_runner(instance_id="inst_phase1_drain", max_turns=10)
    runner.completion_policy = CompletionPolicy.explicit

    class _Phase1DrainMCP:
        def __init__(self) -> None:
            self.response: tuple[str, int] | None = None
            self.busy: bool = False

        def drain_pending_messages(self) -> list[str]:
            return ["message queued during phase1"]

        def get_pending_message(self) -> None:
            return None

        def set_response(self, text: str, turns: int) -> None:
            self.response = (text, turns)

        def set_busy(self, busy: bool) -> None:
            self.busy = busy

        def get_stop_turn_event(self) -> asyncio.Event:
            return asyncio.Event()

    fake_mcp = _Phase1DrainMCP()
    runner.mcp_server = fake_mcp  # type: ignore[assignment]  # justified: minimal protocol in test

    request = ModelRequest(parts=[UserPromptPart(content="initial task")])
    nodes = [ModelRequestNode(request=request)]
    run = _FakeIterRun(nodes=nodes, messages=[], usage=_UsageSnapshot(1, 1))
    agent = _FakeAgent(run)

    captured_interact_message: str | None = None

    async def _capture_process_interact(
        _agent: Any,
        message: str,
        message_history: list[Any],
    ) -> Success[list[Any]]:
        nonlocal captured_interact_message
        captured_interact_message = message
        return Success(message_history)

    runner._process_interact_message = _capture_process_interact  # type: ignore[method-assign]  # justified: controlled unit seam

    state_result = read_state(runner.instance_id)
    assert isinstance(state_result, Success)

    result = await runner._run_agent_loop(cast(Any, agent), state_result.unwrap())

    assert isinstance(result, Success)
    assert captured_interact_message == _expected_system_reminder(
        "message queued during phase1"
    )


@pytest.mark.asyncio
async def test_process_interact_message_returns_updated_history_and_response(
    tmp_state_dir: Any,
) -> None:
    """Interact continuation sends MCP response and returns updated history."""
    _ = tmp_state_dir
    from pydantic_ai._agent_graph import CallToolsNode, ModelRequestNode
    from pydantic_ai.messages import (
        ModelRequest,
        ModelResponse,
        TextPart,
        ToolReturnPart,
        UserPromptPart,
    )

    runner = _create_running_runner(instance_id="inst_interact", max_turns=10)

    from tests.helpers.mcp_fakes import FakeMCPServer

    fake_mcp = FakeMCPServer()
    runner.mcp_server = fake_mcp  # type: ignore[assignment]  # justified: minimal protocol in test

    request = ModelRequest(parts=[UserPromptPart(content="follow-up")])
    response = ModelResponse(parts=[TextPart(content="all done")])
    nodes = [
        ModelRequestNode(request=request),
        CallToolsNode(model_response=response, tool_call_results={}),
    ]
    history = [response]
    run = _FakeIterRun(nodes=nodes, messages=history, usage=_UsageSnapshot(1, 4))
    agent = _FakeAgent(run)

    result = await runner._process_interact_message(cast(Any, agent), "Need update", [])

    assert isinstance(result, Success)
    assert result.unwrap() == history
    assert fake_mcp.response is not None
    text, turns = fake_mcp.response
    assert text == "all done"
    assert turns >= 1


@pytest.mark.asyncio
async def test_process_interact_message_prunes_prior_tool_outputs_before_reuse(
    tmp_state_dir: Any,
) -> None:
    """Interactive continuation redacts old tool outputs before next model call."""
    _ = tmp_state_dir
    from pydantic_ai._agent_graph import CallToolsNode
    from pydantic_ai.messages import (
        ModelRequest,
        ModelResponse,
        TextPart,
        ToolReturnPart,
        UserPromptPart,
    )

    runner = _create_running_runner(instance_id="inst_interact_prune", max_turns=10)

    from tests.helpers.mcp_fakes import FakeMCPServer

    fake_mcp = FakeMCPServer()
    runner.mcp_server = fake_mcp  # type: ignore[assignment]  # justified: minimal protocol in test

    old_request = ModelRequest(
        parts=[
            UserPromptPart(content="prior user"),
            ToolReturnPart(
                content="X" * 1024,
                tool_name="directory_tree",
                tool_call_id="call_1",
            ),
        ]
    )
    response = ModelResponse(parts=[TextPart(content="all done")])
    nodes = [CallToolsNode(model_response=response, tool_call_results={})]
    run = _FakeIterRun(nodes=nodes, messages=[response], usage=_UsageSnapshot(1, 4))

    captured: dict[str, Any] = {}

    class _CapturingAgent(_FakeAgent):
        def iter(self, **kwargs: Any) -> _FakeIterContext:
            captured.update(kwargs)
            return super().iter(**kwargs)

    agent = _CapturingAgent(run)

    result = await runner._process_interact_message(
        cast(Any, agent), "Need update", [old_request]
    )

    assert isinstance(result, Success)
    reused_history = captured["message_history"]
    assert len(reused_history) == 1
    reused_request = reused_history[0]
    assert isinstance(reused_request, ModelRequest)
    assert isinstance(reused_request.parts[1], ToolReturnPart)
    assert reused_request.parts[1].content == "[Old tool result content cleared]"


@pytest.mark.asyncio
async def test_process_interact_message_applies_budget_assembly_before_iter(
    tmp_state_dir: Any,
) -> None:
    """Pre-model-call history assembly keeps summary/excerpt under resolved budgets."""
    _ = tmp_state_dir
    import json

    from pydantic_ai._agent_graph import CallToolsNode
    from pydantic_ai.messages import (
        ModelRequest,
        ModelResponse,
        TextPart,
        ToolReturnPart,
    )

    runner = _create_running_runner(instance_id="inst_interact_budgeted", max_turns=10)
    runner._tool_output_per_call_cap_bytes = 160
    runner._tool_output_per_turn_cap_bytes = 160

    response = ModelResponse(parts=[TextPart(content="budgeted")])
    nodes = [CallToolsNode(model_response=response, tool_call_results={})]
    run = _FakeIterRun(nodes=nodes, messages=[response], usage=_UsageSnapshot(1, 4))

    captured: dict[str, Any] = {}

    class _CapturingAgent(_FakeAgent):
        def iter(self, **kwargs: Any) -> _FakeIterContext:
            captured.update(kwargs)
            return super().iter(**kwargs)

    agent = _CapturingAgent(run)
    older_history = [
        ModelRequest(
            parts=[
                ToolReturnPart(
                    content={
                        "summary": "old summary",
                        "excerpt": "old excerpt",
                        "raw": "x" * 5000,
                    },
                    tool_name="directory_tree",
                    tool_call_id="call_older",
                )
            ]
        )
    ]

    result = await runner._process_interact_message(
        cast(Any, agent),
        "Need update",
        older_history,
    )

    assert isinstance(result, Success)
    reused_history = captured["message_history"]
    content = reused_history[0].parts[0].content
    assert isinstance(content, dict)
    assert content["summary"] == "old summary"
    assert content["placeholder"] == "[Old tool result content cleared]"
    assert len(json.dumps(content).encode("utf-8")) <= 160


@pytest.mark.asyncio
async def test_process_interact_message_current_turn_result_stays_oversized_without_recursive_failsafe(
    tmp_state_dir: Any,
) -> None:
    """Current-turn oversized tool output can exceed request cap before prune runs."""
    _ = tmp_state_dir
    from pydantic_ai._agent_graph import ModelRequestNode
    from pydantic_ai.messages import (
        ModelRequest,
        ModelResponse,
        TextPart,
        ToolReturnPart,
        UserPromptPart,
    )

    runner = _create_running_runner(
        instance_id="inst_current_turn_oversize", max_turns=10
    )
    prior_output = "p" * (MAX_TOOL_RESULT_TEXT_CHARS + 16)
    oversized_tool_output = "r" * (MAX_TOOL_RESULT_TEXT_CHARS + 512)
    assert (
        len(prior_output) + len(oversized_tool_output) > MAX_TOOL_RESULT_TEXT_CHARS * 2
    )

    prior_history = [
        ModelRequest(
            parts=[
                UserPromptPart(content="prior user"),
                ToolReturnPart(
                    content=prior_output,
                    tool_name="directory_tree",
                    tool_call_id="call_prev",
                ),
            ]
        )
    ]

    current_turn_return = ModelRequest(
        parts=[
            UserPromptPart(content="current user"),
            ToolReturnPart(
                content=oversized_tool_output,
                tool_name="directory_tree",
                tool_call_id="call_current",
            ),
        ]
    )
    response = ModelResponse(parts=[TextPart(content="interact response")])

    run = _FakeIterRun(
        nodes=[
            ModelRequestNode(
                request=ModelRequest(parts=[UserPromptPart(content="continue")])
            )
        ],
        messages=[response, current_turn_return],
        usage=_UsageSnapshot(1, 1),
    )
    captured: dict[str, Any] = {}

    class _CaptureAgent(_FakeAgent):
        def iter(self, **kwargs: Any) -> _FakeIterContext:
            captured["message_history"] = kwargs["message_history"]
            return super().iter(**kwargs)

    agent = _CaptureAgent(run)

    result = await runner._process_interact_message(
        cast(Any, agent), "continue", prior_history
    )

    pruned_history = captured["message_history"]
    first_pruned = pruned_history[0]
    assert first_pruned.parts[1].content == "[Old tool result content cleared]"

    updated_history = result.unwrap()
    current_part: ToolReturnPart | None = None
    for msg in updated_history:
        for part in getattr(msg, "parts", []):
            if isinstance(part, ToolReturnPart) and part.tool_call_id == "call_current":
                current_part = part
                break

    assert current_part is not None
    current_content = cast(str, current_part.content)
    assert isinstance(current_content, str)
    assert len(current_content) == len(oversized_tool_output)
    assert len(current_content) > MAX_TOOL_RESULT_TEXT_CHARS


@pytest.mark.asyncio
async def test_process_interact_message_consumes_stop_turn_request(
    tmp_state_dir: Any,
) -> None:
    """Stop-turn event cancels current interact turn and keeps loop alive."""
    _ = tmp_state_dir
    from pydantic_ai._agent_graph import ModelRequestNode
    from pydantic_ai.messages import ModelRequest, UserPromptPart

    runner = _create_running_runner(instance_id="inst_interact_stop", max_turns=10)

    from tests.helpers.mcp_fakes import FakeMCPServer

    fake_mcp = FakeMCPServer()
    fake_mcp.get_stop_turn_event().set()  # Pre-set the stop event
    runner.mcp_server = fake_mcp  # type: ignore[assignment]  # justified: minimal protocol in test

    request = ModelRequest(parts=[UserPromptPart(content="follow-up")])
    nodes = [ModelRequestNode(request=request)]
    history = [request]
    run = _FakeIterRun(nodes=nodes, messages=history, usage=_UsageSnapshot(0, 0))
    agent = _FakeAgent(run)

    result = await runner._process_interact_message(cast(Any, agent), "Stop now", [])

    assert isinstance(result, Success)
    assert result.unwrap() == history
    assert fake_mcp.response is not None
    text, _turns = fake_mcp.response
    assert text == "[Current turn cancelled by /stop]"
    assert fake_mcp.stop_turn_event.is_set() is False


@pytest.mark.asyncio
async def test_process_interact_message_streams_text_deltas_from_model_request(
    tmp_state_dir: Any,
) -> None:
    """ModelRequestNode stream deltas are forwarded to the MCP stream queue."""
    _ = tmp_state_dir
    from pydantic_ai._agent_graph import ModelRequestNode
    from pydantic_ai.messages import (
        ModelRequest,
        ModelResponse,
        TextPart,
        UserPromptPart,
    )

    runner = _create_running_runner(instance_id="inst_interact_stream", max_turns=10)
    fake_mcp = _StreamingFakeMCPServer()
    runner.mcp_server = fake_mcp  # type: ignore[assignment]  # justified: minimal protocol in test

    request = ModelRequest(parts=[UserPromptPart(content="stream me")])
    response = ModelResponse(parts=[TextPart(content="chunk-one chunk-two")])
    node = ModelRequestNode(request=request)
    node.stream = lambda _ctx: _fake_model_stream(["chunk-one ", "chunk-two"])  # type: ignore[method-assign]  # justified: isolate streaming branch
    run = _FakeIterRun(
        [node],
        [response],
        _UsageSnapshot(1, 6),
        ctx=object(),
    )
    agent = _FakeAgent(run)

    result = await runner._process_interact_message(cast(Any, agent), "Need stream", [])

    assert isinstance(result, Success)
    assert fake_mcp.stream_chunks == [(1, "chunk-one "), (1, "chunk-two")]
    assert fake_mcp.response is not None
    text, _turns = fake_mcp.response
    assert text == "chunk-one chunk-two"


def test_process_model_result_uses_runner_context_limit_for_compaction() -> None:
    """Compaction trigger in main loop uses runner-resolved context limit."""
    runner = AgentRunner(instance_id="inst_context_main", task="task")
    runner._compaction_config = CompactionConfig(auto=True, reserved=0)
    runner._compaction_pending = False

    class _Usage:
        input_tokens = 0
        output_tokens = 0

    class _Run:
        @staticmethod
        def usage() -> Any:
            return _Usage()

        @staticmethod
        def all_messages() -> list[Any]:
            return []

    def _stub_track_token_usage(_usage: Any) -> None:
        runner.token_usage.prompt_tokens = 120
        runner.token_usage.last_request_prompt_tokens = 120

    runner._track_token_usage = _stub_track_token_usage  # type: ignore[method-assign]  # justified: isolate compaction threshold behavior

    _, _ = runner._process_model_result(
        result=object(),
        run=_Run(),
        model_request_node_type=type("_ModelReqNode", (), {}),
        call_tools_node_type=type("_CallToolsNode", (), {}),
    )
    assert runner._compaction_pending is False

    runner._model_context_limit = 100
    runner._compaction_pending = False

    _, _ = runner._process_model_result(
        result=object(),
        run=_Run(),
        model_request_node_type=type("_ModelReqNode", (), {}),
        call_tools_node_type=type("_CallToolsNode", (), {}),
    )
    assert runner._compaction_pending is True


@pytest.mark.asyncio
async def test_process_interact_message_uses_runner_context_limit_for_compaction(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """Compaction trigger in interactive loop uses runner-resolved context limit."""
    import anima.shell.agent as agent_module

    runner = AgentRunner(instance_id="inst_context_interact", task="task")
    runner._compaction_config = CompactionConfig(auto=True, reserved=0)
    runner.token_usage.prompt_tokens = 120
    runner.token_usage.last_request_prompt_tokens = 120

    async def _fake_process_interact_message(
        *_args: Any, **_kwargs: Any
    ) -> tuple[Any, int, int, bool]:
        return Success([]), 0, runner.no_tool_streak, runner.in_grace_window

    monkeypatch.setattr(
        agent_module,
        "process_interact_message",
        _fake_process_interact_message,
    )

    runner._model_context_limit = 200
    runner._compaction_pending = False
    result_high_limit = await runner._process_interact_message(
        cast(Any, object()), "msg", []
    )
    assert isinstance(result_high_limit, Success)
    assert runner._compaction_pending is False

    runner._model_context_limit = 100
    runner._compaction_pending = False
    result_low_limit = await runner._process_interact_message(
        cast(Any, object()), "msg", []
    )
    assert isinstance(result_low_limit, Success)
    assert runner._compaction_pending is True


@pytest.mark.asyncio
async def test_run_pending_compaction_logs_failure_and_keeps_loop_alive(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """Compaction exceptions are logged and original history is preserved."""
    import anima.shell.agent as agent_module

    runner = AgentRunner(instance_id="inst_compaction_failure", task="task")
    runner._compaction_pending = True

    logged: list[tuple[str, str, dict[str, Any]]] = []

    async def _boom_run_compaction(*_args: Any, **_kwargs: Any) -> Any:
        raise RuntimeError("compaction exploded")

    def _capture_append_log(
        instance_id: str,
        event: str,
        details: dict[str, Any],
    ) -> Success[None]:
        logged.append((instance_id, event, details))
        return Success(None)

    monkeypatch.setattr(agent_module, "run_compaction", _boom_run_compaction)
    monkeypatch.setattr(agent_module, "append_log", _capture_append_log)

    original_history = [cast(Any, object())]
    result_history = await runner._run_pending_compaction(
        cast(Any, object()),
        original_history,
    )

    assert result_history == original_history
    assert runner._compaction_pending is False
    assert logged and logged[0][1] == "compaction_failed"


@pytest.mark.asyncio
async def test_apply_persona_switch_stores_resolved_persona_id_in_state(
    tmp_state_dir: Any,
) -> None:
    """After persona switch, persona_ref in state must be the resolved persona ID.

    Spec source: vectl step persona-foundation.state-persona-ref.
    When _resolve_deferred_persona completes, state.persona_ref should be the
    resolved persona's actual ID, not the spec_id or bootstrap-xxx placeholder.
    """
    _ = tmp_state_dir
    runner = _create_running_runner(instance_id="inst_persona_ref")

    from anima.core.schemas.models import PersonaSpec

    resolved_persona = PersonaSpec(
        id="resolved-python-senior-abc123",
        description="Resolved senior Python persona.",
        prompt="You are a senior Python engineer.",
        model="test-model",
    )

    # Mock _create_runtime_agent to return a fake agent without real model setup
    sentinel_agent = object()

    async def _fake_create_runtime_agent(
        persona: PersonaSpec, global_config: Any
    ) -> Success[Any]:
        return Success(sentinel_agent)

    runner._create_runtime_agent = _fake_create_runtime_agent  # type: ignore[assignment]  # justified: minimal protocol in test

    new_agent = await runner._apply_persona_switch(
        None,
        resolved_persona,
        resolved_persona.id,
    )

    assert new_agent is sentinel_agent

    state_result = read_state(runner.instance_id)
    assert isinstance(state_result, Success)
    state = state_result.unwrap()
    assert state.persona_ref == "resolved-python-senior-abc123"
    assert state.persona_name == "resolved-python-senior-abc123"


@pytest.mark.asyncio
async def test_resolve_deferred_persona_passes_resolved_id_not_spec_id(
    tmp_state_dir: Any,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """_resolve_deferred_persona passes resolved persona.id as persona_ref.

    Spec source: vectl step persona-foundation.state-persona-ref.
    Previously, spec_id (e.g. 'python-senior') was passed to _apply_persona_switch
    instead of the resolved persona's actual ID. This test captures the regression.
    """
    import anima.shell.agent as agent_module

    _ = tmp_state_dir
    runner = _create_running_runner(instance_id="inst_deferred_ref")

    from anima.core.schemas.models import PersonaSpec

    resolved_persona = PersonaSpec(
        id="resolved-python-senior-abc123",
        description="Resolved senior Python persona.",
        prompt="You are a senior Python engineer.",
        model="test-model",
    )

    # Track what persona_ref is passed to _apply_persona_switch
    captured_refs: list[str] = []
    sentinel_agent = object()

    original_apply = runner._apply_persona_switch

    async def _tracking_apply(
        current_agent: Any,
        new_persona: PersonaSpec,
        new_persona_ref: str,
    ) -> Any:
        captured_refs.append(new_persona_ref)
        return await original_apply(current_agent, new_persona, new_persona_ref)

    async def _fake_create_runtime_agent(
        persona: PersonaSpec, global_config: Any
    ) -> Success[Any]:
        return Success(sentinel_agent)

    runner._apply_persona_switch = _tracking_apply  # type: ignore[assignment]  # justified: tracking wrapper in test
    runner._create_runtime_agent = _fake_create_runtime_agent  # type: ignore[assignment]  # justified: minimal protocol in test

    # Mock resolve_persona_via_larva to return our resolved persona
    async def _fake_resolve(spec_id: str, mcp_servers: Any) -> Success[PersonaSpec]:
        return Success(resolved_persona)

    monkeypatch.setattr(
        "anima.shell.larva_persona_resolver.resolve_persona_via_larva",
        _fake_resolve,
    )
    monkeypatch.setattr(agent_module, "append_log", lambda *a, **kw: Success(None))

    bootstrap_persona = PersonaSpec(
        id="bootstrap-python-senior",
        description="Bootstrap placeholder persona.",
        prompt="Bootstrap placeholder.",
        model="test-model",
    )

    result = await runner._resolve_deferred_persona(bootstrap_persona, None)
    assert isinstance(result, Success)

    # The key assertion: persona_ref passed to _apply_persona_switch must be
    # the resolved persona's ID, NOT the spec_id 'python-senior'
    assert len(captured_refs) == 1
    assert captured_refs[0] == "resolved-python-senior-abc123"
    assert captured_refs[0] != "python-senior"
