"""Tests for structured tool-event progress metadata emitted by AgentLoop."""

import asyncio
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock

import pytest

from nanobot.agent.context import TranscriptInput
from nanobot.agent.hooks import create_file_edit_activity_hook
from nanobot.agent.loop import AgentLoop
from nanobot.agent.tools.context import current_request_context
from nanobot.agent.tools.filesystem import WriteFileTool
from nanobot.bus.events import InboundMessage
from nanobot.bus.outbound_events import (
    GoalStatusEvent,
    ProgressEvent,
    SessionUpdatedEvent,
    StreamDeltaEvent,
    StreamedResponseEvent,
    StreamEndEvent,
    TurnEndEvent,
)
from nanobot.bus.queue import MessageBus
from nanobot.providers.base import LLMResponse, ToolCallRequest
from nanobot.providers.factory import ProviderSnapshot
from nanobot.session.webui_turns import WebuiTurnCoordinator, WebuiTurnRoutePolicy
from nanobot.utils.progress_events import (
    invoke_file_edit_progress,
    on_progress_accepts_file_edit_events,
)
from nanobot.webui.metadata import (
    WEBSOCKET_TURN_OWNER_METADATA_KEY,
    WEBUI_TURN_METADATA_KEY,
)


def _make_loop(tmp_path: Path) -> AgentLoop:
    bus = MessageBus()
    provider = MagicMock()
    provider.get_default_model.return_value = "test-model"
    return AgentLoop(
        bus=bus,
        provider=provider,
        workspace=tmp_path,
        model="test-model",
        hook_factories=[create_file_edit_activity_hook],
    )


def _attach_webui_runtime_events(loop: AgentLoop, bus: MessageBus) -> None:
    loop.turn_delivery_factory.route_policy = WebuiTurnRoutePolicy(loop.sessions)
    coordinator = WebuiTurnCoordinator(
        bus=bus,
        sessions=loop.sessions,
        schedule_background=lambda coro: loop.schedule_background(coro),
    )
    coordinator.subscribe(loop.runtime_events)


class TestToolEventProgress:
    """_run_agent_loop emits structured tool_events via on_progress."""

    @pytest.mark.asyncio
    async def test_start_and_finish_events_emitted(self, tmp_path: Path) -> None:
        loop = _make_loop(tmp_path)
        tool_call = ToolCallRequest(id="call1", name="custom_tool", arguments={"path": "foo.txt"})
        calls = iter([
            LLMResponse(content="Visible", tool_calls=[tool_call]),
            LLMResponse(content="Done", tool_calls=[]),
        ])
        loop.provider.chat_with_retry = AsyncMock(side_effect=lambda *a, **kw: next(calls))
        loop.tools.get_definitions = MagicMock(return_value=[])
        loop.tools.prepare_call = MagicMock(return_value=(None, {"path": "foo.txt"}, None))
        loop.tools.execute = AsyncMock(return_value="ok")

        progress: list[tuple[str, bool, list[dict] | None]] = []

        async def on_progress(
            content: str,
            *,
            tool_hint: bool = False,
            tool_events: list[dict] | None = None,
        ) -> None:
            progress.append((content, tool_hint, tool_events))

        result = await loop._run_agent_loop(
            TranscriptInput(history=[], current_message=None),
            runtime=loop.llm_runtime(),
            on_progress=on_progress,
        )

        assert result.final_content == "Done"
        assert progress == [
            ("Visible", False, None),
            (
                'custom_tool("foo.txt")',
                True,
                [{
                    "version": 1,
                    "phase": "start",
                    "call_id": "call1",
                    "name": "custom_tool",
                    "arguments": {"path": "foo.txt"},
                    "result": None,
                    "error": None,
                    "files": [],
                    "embeds": [],
                }],
            ),
            (
                "",
                False,
                [{
                    "version": 1,
                    "phase": "end",
                    "call_id": "call1",
                    "name": "custom_tool",
                    "arguments": {"path": "foo.txt"},
                    "result": "ok",
                    "error": None,
                    "files": [],
                    "embeds": [],
                }],
            ),
        ]

    @pytest.mark.asyncio
    async def test_write_file_emits_file_edit_progress(self, tmp_path: Path) -> None:
        loop = _make_loop(tmp_path)
        target = tmp_path / "foo.txt"
        target.write_text("old\n", encoding="utf-8")
        tool_call = ToolCallRequest(
            id="call-write",
            name="write_file",
            arguments={"path": "foo.txt", "content": "new\nextra\n"},
        )
        calls = iter([
            LLMResponse(content="", tool_calls=[tool_call]),
            LLMResponse(content="Done", tool_calls=[]),
        ])
        loop.provider.chat_with_retry = AsyncMock(side_effect=lambda *a, **kw: next(calls))
        loop.tools.get_definitions = MagicMock(return_value=[])
        tool = WriteFileTool(workspace=tmp_path)
        loop.tools.prepare_call = MagicMock(
            return_value=(tool, {"path": "foo.txt", "content": "new\nextra\n"}, None),
        )
        file_events: list[dict] = []

        async def on_progress(
            content: str,
            *,
            tool_hint: bool = False,
            tool_events: list[dict] | None = None,
            file_edit_events: list[dict] | None = None,
        ) -> None:
            if file_edit_events:
                file_events.extend(file_edit_events)

        result = await loop._run_agent_loop(
            TranscriptInput(history=[], current_message=None),
            runtime=loop.llm_runtime(),
            on_progress=on_progress,
        )

        assert result.final_content == "Done"
        assert [event["phase"] for event in file_events] == ["start", "end"]
        assert file_events[0] == {
            "version": 1,
            "call_id": "call-write",
            "tool": "write_file",
            "path": "foo.txt",
            "absolute_path": (tmp_path / "foo.txt").resolve().as_posix(),
            "phase": "start",
            "added": 0,
            "deleted": 0,
            "approximate": True,
            "status": "editing",
        }
        assert file_events[1]["status"] == "done"
        assert file_events[1]["approximate"] is False
        assert (file_events[1]["added"], file_events[1]["deleted"]) == (2, 1)
        assert file_events[1]["diff"]["format"] == "unified"

    @pytest.mark.asyncio
    async def test_file_edit_snapshot_skipped_when_progress_callback_cannot_emit_file_edits(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        loop = _make_loop(tmp_path)
        target = tmp_path / "foo.txt"
        target.write_text("old\n", encoding="utf-8")
        prepare_file_edit_trackers = MagicMock()

        class ObservableWriteTool:
            name = "write_file"

            async def execute(self, path: str, content: str) -> str:
                target.write_text(content, encoding="utf-8")
                return "ok"

        tool = ObservableWriteTool()
        tool_call = ToolCallRequest(
            id="call-write",
            name="write_file",
            arguments={"path": "foo.txt", "content": "new\n"},
        )
        calls = iter([
            LLMResponse(content="", tool_calls=[tool_call]),
            LLMResponse(content="Done", tool_calls=[]),
        ])
        loop.provider.chat_with_retry = AsyncMock(side_effect=lambda *a, **kw: next(calls))
        loop.tools.get_definitions = MagicMock(return_value=[])
        loop.tools.prepare_call = MagicMock(
            return_value=(tool, {"path": "foo.txt", "content": "new\n"}, None),
        )

        async def on_progress(
            content: str,
            *,
            tool_hint: bool = False,
            tool_events: list[dict] | None = None,
        ) -> None:
            pass

        monkeypatch.setattr(
            "nanobot.agent.hooks.file_edit_activity.prepare_file_edit_trackers",
            prepare_file_edit_trackers,
        )

        result = await loop._run_agent_loop(
            TranscriptInput(history=[], current_message=None),
            runtime=loop.llm_runtime(),
            on_progress=on_progress,
        )

        assert result.final_content == "Done"
        assert target.read_text(encoding="utf-8") == "new\n"
        prepare_file_edit_trackers.assert_not_called()

    @pytest.mark.asyncio
    async def test_exec_does_not_emit_file_edit_progress(self, tmp_path: Path) -> None:
        loop = _make_loop(tmp_path)
        tool_call = ToolCallRequest(
            id="call-exec",
            name="exec",
            arguments={"command": "printf hi > foo.txt"},
        )
        calls = iter([
            LLMResponse(content="", tool_calls=[tool_call]),
            LLMResponse(content="Done", tool_calls=[]),
        ])
        loop.provider.chat_with_retry = AsyncMock(side_effect=lambda *a, **kw: next(calls))
        loop.tools.get_definitions = MagicMock(return_value=[])
        loop.tools.prepare_call = MagicMock(
            return_value=(None, {"command": "printf hi > foo.txt"}, None),
        )
        loop.tools.execute = AsyncMock(return_value="ok")
        file_events: list[dict] = []

        async def on_progress(
            content: str,
            *,
            tool_hint: bool = False,
            tool_events: list[dict] | None = None,
            file_edit_events: list[dict] | None = None,
        ) -> None:
            if file_edit_events:
                file_events.extend(file_edit_events)

        await loop._run_agent_loop(
            TranscriptInput(history=[], current_message=None),
            runtime=loop.llm_runtime(),
            on_progress=on_progress,
        )

        assert file_events == []

    @pytest.mark.asyncio
    async def test_bus_progress_forwards_tool_events_to_outbound_metadata(self, tmp_path: Path) -> None:
        """When run() handles a bus message, _tool_events lands in OutboundMessage metadata."""
        bus = MessageBus()
        provider = MagicMock()
        provider.get_default_model.return_value = "test-model"
        loop = AgentLoop(bus=bus, provider=provider, workspace=tmp_path, model="test-model")

        tool_call = ToolCallRequest(id="tc1", name="exec", arguments={"command": "ls"})
        calls = iter([
            LLMResponse(content="", tool_calls=[tool_call]),
            LLMResponse(content="Done", tool_calls=[]),
        ])
        loop.provider.chat_with_retry = AsyncMock(side_effect=lambda *a, **kw: next(calls))
        loop.tools.get_definitions = MagicMock(return_value=[])
        loop.tools.prepare_call = MagicMock(return_value=(None, {"command": "ls"}, None))
        loop.tools.execute = AsyncMock(return_value="file.txt")

        msg = InboundMessage(
            channel="telegram",
            sender_id="u1",
            chat_id="chat1",
            content="run ls",
        )
        await loop._dispatch(msg)

        # Drain all outbound messages and find the one carrying tool events.
        outbound = []
        while bus.outbound_size > 0:
            outbound.append(await bus.consume_outbound())

        tool_event_msgs = [
            m
            for m in outbound
            if isinstance(m.event, ProgressEvent) and m.event.tool_events
        ]
        assert tool_event_msgs, "expected at least one outbound message with tool events"

        start_msgs = [
            m
            for m in tool_event_msgs
            if isinstance(m.event, ProgressEvent)
            and m.event.tool_events
            and m.event.tool_events[0]["phase"] == "start"
        ]
        finish_msgs = [
            m
            for m in tool_event_msgs
            if isinstance(m.event, ProgressEvent)
            and m.event.tool_events
            and m.event.tool_events[0]["phase"] in ("end", "error")
        ]
        assert start_msgs, "expected a start-phase tool event"
        assert finish_msgs, "expected a finish-phase tool event"

        assert isinstance(start_msgs[0].event, ProgressEvent)
        assert start_msgs[0].event.tool_events is not None
        start = start_msgs[0].event.tool_events[0]
        assert start["name"] == "exec"
        assert start["call_id"] == "tc1"
        assert start["result"] is None

        assert isinstance(finish_msgs[0].event, ProgressEvent)
        assert finish_msgs[0].event.tool_events is not None
        finish = finish_msgs[0].event.tool_events[0]
        assert finish["phase"] == "end"
        assert finish["result"] == "file.txt"

    @pytest.mark.asyncio
    async def test_bus_progress_forwards_file_edit_events_without_channel_branch(self, tmp_path: Path) -> None:
        bus = MessageBus()
        provider = MagicMock()
        provider.get_default_model.return_value = "test-model"
        loop = AgentLoop(bus=bus, provider=provider, workspace=tmp_path, model="test-model")
        edit_events = [{
            "call_id": "call-write",
            "tool": "write_file",
            "path": "foo.txt",
            "phase": "start",
            "added": 1,
            "deleted": 0,
            "approximate": True,
            "status": "editing",
        }]

        msg = InboundMessage(
            channel="telegram",
            sender_id="u1",
            chat_id="chat1",
            content="edit",
        )
        progress = loop.turn_delivery_factory.create(msg, msg.session_key).progress_callback()
        assert progress is not None
        assert on_progress_accepts_file_edit_events(progress) is True
        await invoke_file_edit_progress(progress, edit_events)
        outbound = await bus.consume_outbound()
        assert outbound.channel == "telegram"
        assert isinstance(outbound.event, ProgressEvent)
        assert outbound.event.file_edit_events == edit_events

    @pytest.mark.asyncio
    async def test_goal_turn_keeps_file_edit_progress_for_webui(self, tmp_path: Path) -> None:
        """The /goal command rewrites the prompt but must not bypass WebUI file-edit progress."""
        bus = MessageBus()
        provider = MagicMock()
        provider.get_default_model.return_value = "test-model"
        call_count = 0

        async def chat_stream_with_retry(**kwargs):
            nonlocal call_count
            call_count += 1
            if call_count == 1:
                return LLMResponse(
                    content=None,
                    tool_calls=[
                        ToolCallRequest(
                            id="call-goal-write",
                            name="write_file",
                            arguments={
                                "path": "goal.txt",
                                "content": "one\ntwo\nthree\n",
                            },
                        )
                    ],
                    usage=None,
                )
            return LLMResponse(content="Done", tool_calls=[], usage=None)

        provider.chat_stream_with_retry = chat_stream_with_retry
        provider.chat_with_retry = AsyncMock()
        loop = AgentLoop(
            bus=bus,
            provider=provider,
            workspace=tmp_path,
            model="test-model",
            hook_factories=[create_file_edit_activity_hook],
        )
        tool = WriteFileTool(workspace=tmp_path)
        loop.tools.get_definitions = MagicMock(return_value=[
            {"type": "function", "function": {"name": "write_file"}},
        ])
        loop.tools.prepare_call = MagicMock(
            return_value=(
                tool,
                {"path": "goal.txt", "content": "one\ntwo\nthree\n"},
                None,
            ),
        )
        loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False)  # type: ignore[method-assign]

        await loop._dispatch(InboundMessage(
            channel="websocket",
            sender_id="u1",
            chat_id="chat1",
            content="/goal create goal file",
            metadata={"_wants_stream": True},
        ))

        outbound = []
        while bus.outbound_size > 0:
            outbound.append(await bus.consume_outbound())

        edit_events = [
            event
            for msg in outbound
            if isinstance(msg.event, ProgressEvent)
            for event in msg.event.file_edit_events or []
        ]
        assert any(
            event["status"] == "editing"
            and event["approximate"]
            and event["added"] == 0
            for event in edit_events
        )
        assert any(
            event["status"] == "done"
            and not event["approximate"]
            and event["added"] == 3
            and event.get("diff", {}).get("format") == "unified"
            for event in edit_events
        )
        provider.chat_with_retry.assert_not_awaited()

    @pytest.mark.asyncio
    async def test_non_streaming_channel_does_not_publish_codex_progress_deltas(
        self,
        tmp_path: Path,
    ) -> None:
        """Non-streaming channels should get one final reply, not token progress spam."""
        bus = MessageBus()
        provider = MagicMock()
        provider.get_default_model.return_value = "openai-codex/gpt-5.5"
        provider.chat_with_retry = AsyncMock(return_value=LLMResponse(content="Hello", tool_calls=[]))
        provider.chat_stream_with_retry = AsyncMock()
        loop = AgentLoop(bus=bus, provider=provider, workspace=tmp_path, model="openai-codex/gpt-5.5")
        loop.tools.get_definitions = MagicMock(return_value=[])
        loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False)  # type: ignore[method-assign]

        await loop._dispatch(InboundMessage(
            channel="whatsapp",
            sender_id="u1",
            chat_id="chat1",
            content="say hello",
        ))

        outbound = []
        while bus.outbound_size > 0:
            outbound.append(await bus.consume_outbound())

        assert [m.content for m in outbound] == ["Hello"]
        assert not any(isinstance(m.event, ProgressEvent) for m in outbound)
        assert not any(isinstance(m.event, StreamedResponseEvent) for m in outbound)
        provider.chat_stream_with_retry.assert_not_awaited()
        provider.chat_with_retry.assert_awaited_once()

    @pytest.mark.asyncio
    async def test_streaming_channel_streams_provider_deltas_for_codex_style_provider(
        self,
        tmp_path: Path,
    ) -> None:
        """Streaming channels still receive provider deltas through stream events."""
        bus = MessageBus()
        provider = MagicMock()
        provider.get_default_model.return_value = "openai-codex/gpt-5.5"

        async def chat_stream_with_retry(*, on_content_delta, **kwargs):
            await on_content_delta("Hel")
            await on_content_delta("lo")
            return LLMResponse(content="Hello", tool_calls=[])

        provider.chat_stream_with_retry = chat_stream_with_retry
        provider.chat_with_retry = AsyncMock()
        loop = AgentLoop(bus=bus, provider=provider, workspace=tmp_path, model="openai-codex/gpt-5.5")
        _attach_webui_runtime_events(loop, bus)
        loop.tools.get_definitions = MagicMock(return_value=[])
        loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False)  # type: ignore[method-assign]

        await loop._dispatch(InboundMessage(
            channel="websocket",
            sender_id="u1",
            chat_id="chat1",
            content="say hello",
            metadata={"_wants_stream": True},
        ))

        outbound = []
        while bus.outbound_size > 0:
            outbound.append(await bus.consume_outbound())

        deltas = [m for m in outbound if isinstance(m.event, StreamDeltaEvent)]
        stream_end = [m for m in outbound if isinstance(m.event, StreamEndEvent)]
        final = [
            m for m in outbound
            if not isinstance(m.event, StreamDeltaEvent | StreamEndEvent)
            and not isinstance(m.event, TurnEndEvent | GoalStatusEvent)
        ]

        assert [m.content for m in deltas] == ["Hel", "lo"]
        assert len(stream_end) == 1
        assert final[-1].content == "Hello"
        assert isinstance(final[-1].event, StreamedResponseEvent)
        turn_end_msgs = [m for m in outbound if isinstance(m.event, TurnEndEvent)]
        assert len(turn_end_msgs) == 1
        assert turn_end_msgs[0].content == ""
        provider.chat_with_retry.assert_not_awaited()

    @pytest.mark.asyncio
    async def test_length_recovery_keeps_one_user_visible_stream(
        self,
        tmp_path: Path,
    ) -> None:
        bus = MessageBus()
        provider = MagicMock()
        provider.get_default_model.return_value = "test-model"
        responses = iter([
            LLMResponse(content="first-", finish_reason="length"),
            LLMResponse(content="second", finish_reason="stop"),
        ])

        async def chat_stream_with_retry(*, on_content_delta, **kwargs):
            response = next(responses)
            await on_content_delta(response.content or "")
            return response

        provider.chat_stream_with_retry = chat_stream_with_retry
        provider.chat_with_retry = AsyncMock()
        loop = AgentLoop(bus=bus, provider=provider, workspace=tmp_path, model="test-model")
        _attach_webui_runtime_events(loop, bus)
        loop.tools.get_definitions = MagicMock(return_value=[])
        loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False)  # type: ignore[method-assign]

        await loop._dispatch(InboundMessage(
            channel="websocket",
            sender_id="u1",
            chat_id="chat1",
            content="give a long answer",
            metadata={"_wants_stream": True},
        ))

        outbound = []
        while bus.outbound_size > 0:
            outbound.append(await bus.consume_outbound())

        deltas = [m.event for m in outbound if isinstance(m.event, StreamDeltaEvent)]
        endings = [m.event for m in outbound if isinstance(m.event, StreamEndEvent)]

        assert [event.content for event in deltas] == ["first-", "second"]
        assert [event.resuming for event in endings] == [True, False]
        assert [event.merge_next for event in endings] == [True, False]
        assert {event.stream_id for event in [*deltas, *endings]} == {deltas[0].stream_id}

    @pytest.mark.asyncio
    async def test_length_recovery_streams_non_delta_terminal_segment(
        self,
        tmp_path: Path,
    ) -> None:
        bus = MessageBus()
        provider = MagicMock()
        provider.get_default_model.return_value = "test-model"
        call_count = 0

        async def chat_stream_with_retry(*, on_content_delta, **kwargs):
            nonlocal call_count
            call_count += 1
            if call_count == 1:
                await on_content_delta("first-")
                return LLMResponse(content="first-", finish_reason="length")
            return LLMResponse(content="second", finish_reason="stop")

        provider.chat_stream_with_retry = chat_stream_with_retry
        provider.chat_with_retry = AsyncMock()
        loop = AgentLoop(bus=bus, provider=provider, workspace=tmp_path, model="test-model")
        _attach_webui_runtime_events(loop, bus)
        loop.tools.get_definitions = MagicMock(return_value=[])
        loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False)  # type: ignore[method-assign]

        await loop._dispatch(InboundMessage(
            channel="websocket",
            sender_id="u1",
            chat_id="chat1",
            content="give a long answer",
            metadata={"_wants_stream": True},
        ))

        outbound = []
        while bus.outbound_size > 0:
            outbound.append(await bus.consume_outbound())

        deltas = [m.event for m in outbound if isinstance(m.event, StreamDeltaEvent)]
        endings = [m.event for m in outbound if isinstance(m.event, StreamEndEvent)]
        final = [m for m in outbound if m.content == "first-second"]

        assert [event.content for event in deltas] == ["first-", "second"]
        assert [event.merge_next for event in endings] == [True, False]
        assert len(final) == 1
        assert isinstance(final[0].event, StreamedResponseEvent)

    @pytest.mark.asyncio
    async def test_length_recovery_at_max_iterations_streams_only_missing_tail(
        self,
        tmp_path: Path,
    ) -> None:
        bus = MessageBus()
        provider = MagicMock()
        provider.get_default_model.return_value = "test-model"

        async def chat_stream_with_retry(*, on_content_delta, **kwargs):
            await on_content_delta("partial")
            return LLMResponse(content="partial", finish_reason="length")

        provider.chat_stream_with_retry = chat_stream_with_retry
        provider.chat_with_retry = AsyncMock(
            return_value=LLMResponse(content="summary", finish_reason="stop")
        )
        loop = AgentLoop(bus=bus, provider=provider, workspace=tmp_path, model="test-model")
        _attach_webui_runtime_events(loop, bus)
        loop.max_iterations = 1
        loop.tools.get_definitions = MagicMock(return_value=[])
        loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False)  # type: ignore[method-assign]

        await loop._dispatch(InboundMessage(
            channel="websocket",
            sender_id="u1",
            chat_id="chat1",
            content="give a long answer",
            metadata={"_wants_stream": True},
        ))

        outbound = []
        while bus.outbound_size > 0:
            outbound.append(await bus.consume_outbound())

        deltas = [m.event for m in outbound if isinstance(m.event, StreamDeltaEvent)]
        endings = [m.event for m in outbound if isinstance(m.event, StreamEndEvent)]
        final = [m for m in outbound if isinstance(m.event, StreamedResponseEvent)]

        assert [event.content for event in deltas] == ["partial", "\n\nsummary"]
        assert [event.merge_next for event in endings] == [True, False]
        assert {event.stream_id for event in [*deltas, *endings]} == {deltas[0].stream_id}
        assert [message.content for message in final] == ["partial\n\nsummary"]

    @pytest.mark.asyncio
    async def test_cancelled_length_recovery_closes_merged_stream(
        self,
        tmp_path: Path,
    ) -> None:
        bus = MessageBus()
        provider = MagicMock()
        provider.get_default_model.return_value = "test-model"
        loop = AgentLoop(bus=bus, provider=provider, workspace=tmp_path, model="test-model")

        async def cancel_after_merge(
            _msg: InboundMessage,
            *,
            on_stream,
            on_stream_end,
            **_kwargs,
        ):
            assert on_stream is not None
            assert on_stream_end is not None
            await on_stream("partial")
            await on_stream_end(resuming=True, merge_next=True)
            raise asyncio.CancelledError

        loop._process_message = cancel_after_merge  # type: ignore[method-assign]

        with pytest.raises(asyncio.CancelledError):
            await loop._dispatch(InboundMessage(
                channel="websocket",
                sender_id="u1",
                chat_id="chat1",
                content="give a long answer",
                metadata={"_wants_stream": True},
            ))

        outbound = []
        while bus.outbound_size > 0:
            outbound.append(await bus.consume_outbound())

        endings = [m.event for m in outbound if isinstance(m.event, StreamEndEvent)]
        assert [(event.resuming, event.merge_next) for event in endings] == [
            (True, True),
            (False, False),
        ]
        assert endings[0].stream_id == endings[1].stream_id

    @pytest.mark.asyncio
    async def test_non_streamed_finalization_is_delivered_as_regular_message(
        self,
        tmp_path: Path,
    ) -> None:
        """A no-tools finalization must not be dropped after empty stream retries."""
        bus = MessageBus()
        provider = MagicMock()
        provider.get_default_model.return_value = "openai-codex/gpt-5.5"
        provider.chat_stream_with_retry = AsyncMock(side_effect=[
            LLMResponse(content=None, tool_calls=[]),
            LLMResponse(content=None, tool_calls=[]),
        ])
        provider.chat_with_retry = AsyncMock(
            return_value=LLMResponse(content="final answer", tool_calls=[]),
        )
        loop = AgentLoop(
            bus=bus,
            provider=provider,
            workspace=tmp_path,
            model="openai-codex/gpt-5.5",
        )
        _attach_webui_runtime_events(loop, bus)
        loop.tools.get_definitions = MagicMock(return_value=[])
        loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False)  # type: ignore[method-assign]

        await loop._dispatch(InboundMessage(
            channel="websocket",
            sender_id="u1",
            chat_id="chat1",
            content="say hello",
            metadata={"_wants_stream": True},
        ))

        outbound = []
        while bus.outbound_size > 0:
            outbound.append(await bus.consume_outbound())

        assert not any(isinstance(message.event, StreamDeltaEvent) for message in outbound)
        assert len([
            message for message in outbound if isinstance(message.event, StreamEndEvent)
        ]) == 3
        final = [message for message in outbound if message.content == "final answer"]
        assert len(final) == 1
        assert final[0].event is None
        provider.chat_stream_with_retry.assert_awaited()
        provider.chat_with_retry.assert_awaited_once()

    @pytest.mark.asyncio
    async def test_independent_late_subagent_result_gets_complete_webui_turn(
        self,
        tmp_path: Path,
    ) -> None:
        bus = MessageBus()
        provider = MagicMock()
        provider.get_default_model.return_value = "openai-codex/gpt-5.5"
        first_request_started = asyncio.Event()
        release_first_request = asyncio.Event()
        requests: list[list[dict]] = []
        request_contexts = []
        tool_call = ToolCallRequest(id="call1", name="custom_tool", arguments={})
        responses = iter([
            LLMResponse(content="Checking", tool_calls=[tool_call]),
            LLMResponse(content="The late result is ready", tool_calls=[]),
        ])

        async def chat_stream_with_retry(*, messages, on_content_delta, **kwargs):
            requests.append([dict(message) for message in messages])
            response = next(responses)
            if len(requests) == 1:
                first_request_started.set()
                await release_first_request.wait()
            await on_content_delta(response.content or "")
            return response

        provider.chat_stream_with_retry = chat_stream_with_retry
        provider.chat_with_retry = AsyncMock()
        loop = AgentLoop(
            bus=bus,
            provider=provider,
            workspace=tmp_path,
            model="openai-codex/gpt-5.5",
        )
        _attach_webui_runtime_events(loop, bus)
        loop.tools.get_definitions = MagicMock(return_value=[])
        loop.tools.prepare_call = MagicMock(return_value=(None, {}, None))

        async def execute_tool(*args, **kwargs):
            request_contexts.append(current_request_context())
            return "ok"

        loop.tools.execute = execute_tool
        loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(  # type: ignore[method-assign]
            return_value=False
        )

        session_key = "websocket:chat-a"
        session = loop.sessions.get_or_create(session_key)
        session.add_message("user", "Run this in the background")
        session.metadata.update({"webui": True, "title": "Existing title"})
        loop.sessions.save(session)
        dispatch = asyncio.create_task(loop._dispatch(InboundMessage(
            channel="system",
            sender_id="subagent",
            chat_id=session_key,
            content="Background research completed",
            session_key_override=session_key,
            metadata={
                "injected_event": "subagent_result",
                "subagent_task_id": "sub-1",
            },
        )))

        await asyncio.wait_for(first_request_started.wait(), timeout=1)
        await loop._pending_queues[session_key].put(InboundMessage(
            channel="websocket",
            sender_id="user",
            chat_id="chat-a",
            content="Can you include the key detail?",
            session_key_override=session_key,
        ))
        release_first_request.set()
        await asyncio.wait_for(dispatch, timeout=2)

        outbound = []
        while bus.outbound_size > 0:
            outbound.append(await bus.consume_outbound())

        assert len(requests) == 2
        assert requests[0][-1]["role"] == "user"
        assert requests[0][-1]["content"].endswith("Background research completed")
        assert any(
            message.get("role") == "user"
            and message.get("content") == "Can you include the key detail?"
            for message in requests[1]
        )
        assert len(request_contexts) == 1
        request_ctx = request_contexts[0]
        assert request_ctx is not None
        assert request_ctx.metadata == {
            "injected_event": "subagent_result",
            "subagent_task_id": "sub-1",
        }
        statuses = [
            message.event.status
            for message in outbound
            if isinstance(message.event, GoalStatusEvent)
        ]
        assert statuses == ["running", "idle"]
        assert [
            message.content
            for message in outbound
            if isinstance(message.event, StreamDeltaEvent)
        ] == ["Checking", "The late result is ready"]
        assert any(isinstance(message.event, ProgressEvent) for message in outbound)
        assert len([
            message for message in outbound if isinstance(message.event, TurnEndEvent)
        ]) == 1
        assert len([
            message for message in outbound
            if isinstance(message.event, StreamedResponseEvent)
        ]) == 1
        visible_events = [
            message
            for message in outbound
            if isinstance(
                message.event,
                GoalStatusEvent
                | ProgressEvent
                | StreamDeltaEvent
                | StreamEndEvent
                | StreamedResponseEvent
                | TurnEndEvent,
            )
        ]
        assert visible_events
        turn_ids = {
            message.metadata.get(WEBUI_TURN_METADATA_KEY)
            for message in visible_events
        }
        assert len(turn_ids) == 1
        turn_id = turn_ids.pop()
        assert isinstance(turn_id, str)
        assert turn_id.startswith("subagent:")
        owners = {
            message.metadata.get(WEBSOCKET_TURN_OWNER_METADATA_KEY)
            for message in visible_events
        }
        assert len(owners) == 1
        assert isinstance(owners.pop(), str)
        assert all(
            (message.channel, message.chat_id) == ("websocket", "chat-a")
            and message.metadata.get("webui") is True
            and message.metadata.get("_wants_stream") is True
            and set(message.metadata) <= {
                "webui",
                "_wants_stream",
                WEBSOCKET_TURN_OWNER_METADATA_KEY,
                WEBUI_TURN_METADATA_KEY,
                "latency_ms",
            }
            for message in visible_events
        )
        provider.chat_with_retry.assert_not_awaited()

    @pytest.mark.asyncio
    async def test_stream_timeout_recovery_continues_in_new_segment(
        self,
        tmp_path: Path,
    ) -> None:
        """Recovered streaming output should use a new stream segment."""
        bus = MessageBus()
        provider = MagicMock()
        provider.get_default_model.return_value = "openai-codex/gpt-5.5"

        async def chat_stream_with_retry(*, on_content_delta, on_stream_recover, **kwargs):
            await on_content_delta("partial")
            await on_stream_recover()
            await on_content_delta("full retry response")
            return LLMResponse(content="full retry response", tool_calls=[])

        provider.chat_stream_with_retry = chat_stream_with_retry
        provider.chat_with_retry = AsyncMock()
        loop = AgentLoop(bus=bus, provider=provider, workspace=tmp_path, model="openai-codex/gpt-5.5")
        _attach_webui_runtime_events(loop, bus)
        loop.tools.get_definitions = MagicMock(return_value=[])
        loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False)  # type: ignore[method-assign]

        await loop._dispatch(InboundMessage(
            channel="websocket",
            sender_id="u1",
            chat_id="chat1",
            content="say hello",
            metadata={"_wants_stream": True},
        ))

        outbound = []
        while bus.outbound_size > 0:
            outbound.append(await bus.consume_outbound())

        deltas = [m for m in outbound if isinstance(m.event, StreamDeltaEvent)]
        stream_end = [m for m in outbound if isinstance(m.event, StreamEndEvent)]
        final = [
            m for m in outbound
            if not isinstance(m.event, StreamDeltaEvent | StreamEndEvent)
            and not isinstance(m.event, TurnEndEvent | GoalStatusEvent)
        ]

        assert [m.content for m in deltas] == ["partial", "full retry response"]
        assert [m.event.resuming for m in stream_end if isinstance(m.event, StreamEndEvent)] == [
            True,
            False,
        ]
        assert isinstance(deltas[0].event, StreamDeltaEvent)
        assert isinstance(deltas[1].event, StreamDeltaEvent)
        assert isinstance(stream_end[0].event, StreamEndEvent)
        assert isinstance(stream_end[1].event, StreamEndEvent)
        assert deltas[0].event.stream_id == stream_end[0].event.stream_id
        assert deltas[1].event.stream_id == stream_end[1].event.stream_id
        assert deltas[0].event.stream_id != deltas[1].event.stream_id
        assert final[-1].content == "full retry response"
        assert isinstance(final[-1].event, StreamedResponseEvent)
        provider.chat_with_retry.assert_not_awaited()

    @pytest.mark.asyncio
    async def test_streamed_content_is_not_repeated_before_tool_execution(
        self,
        tmp_path: Path,
    ) -> None:
        """If content was already streamed, tool setup should not repeat it."""
        loop = _make_loop(tmp_path)
        tool_call = ToolCallRequest(id="call1", name="custom_tool", arguments={"path": "foo.txt"})
        calls = iter([
            LLMResponse(content="I will inspect it.", tool_calls=[tool_call]),
            LLMResponse(content="Done", tool_calls=[]),
        ])

        async def chat_stream_with_retry(*, on_content_delta, **kwargs):
            response = next(calls)
            if response.tool_calls:
                await on_content_delta("I will ")
                await on_content_delta("inspect it.")
            return response

        loop.provider.chat_stream_with_retry = chat_stream_with_retry
        loop.provider.chat_with_retry = AsyncMock()
        loop.tools.get_definitions = MagicMock(return_value=[])
        loop.tools.prepare_call = MagicMock(return_value=(None, {"path": "foo.txt"}, None))
        loop.tools.execute = AsyncMock(return_value="ok")

        streamed: list[str] = []
        progress: list[tuple[str, bool, list[dict] | None]] = []

        async def on_stream(delta: str) -> None:
            streamed.append(delta)

        async def on_progress(
            content: str,
            *,
            tool_hint: bool = False,
            tool_events: list[dict] | None = None,
        ) -> None:
            progress.append((content, tool_hint, tool_events))

        result = await loop._run_agent_loop(
            TranscriptInput(history=[], current_message=None),
            runtime=loop.llm_runtime(),
            on_progress=on_progress,
            on_stream=on_stream,
        )

        assert result.final_content == "Done"
        assert streamed == ["I will", " inspect it."]
        assert progress[0][0] == 'custom_tool("foo.txt")'
        assert all(item[0] != "I will inspect it." for item in progress)

    @pytest.mark.asyncio
    async def test_websocket_dispatch_publishes_final_turn_end_marker(self, tmp_path: Path) -> None:
        bus = MessageBus()
        provider = MagicMock()
        provider.get_default_model.return_value = "test-model"
        provider.chat_with_retry = AsyncMock(return_value=LLMResponse(content="Done", tool_calls=[]))
        loop = AgentLoop(bus=bus, provider=provider, workspace=tmp_path, model="test-model")
        _attach_webui_runtime_events(loop, bus)
        loop.tools.get_definitions = MagicMock(return_value=[])
        loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False)  # type: ignore[method-assign]

        await loop._dispatch(InboundMessage(
            channel="websocket",
            sender_id="u1",
            chat_id="chat1",
            content="say hello",
        ))

        outbound = []
        while bus.outbound_size > 0:
            outbound.append(await bus.consume_outbound())

        done_msgs = [m for m in outbound if m.content == "Done"]
        assert len(done_msgs) == 1
        assert not isinstance(done_msgs[0].event, TurnEndEvent)

        turn_end_msgs = [m for m in outbound if isinstance(m.event, TurnEndEvent)]
        assert len(turn_end_msgs) == 1
        assert turn_end_msgs[0].content == ""
        assert turn_end_msgs[0].chat_id == "chat1"
        assert outbound.index(done_msgs[0]) < outbound.index(turn_end_msgs[0])

    @pytest.mark.asyncio
    async def test_websocket_dispatch_publishes_turn_end_after_error(
        self,
        tmp_path: Path,
    ) -> None:
        bus = MessageBus()
        provider = MagicMock()
        provider.get_default_model.return_value = "test-model"
        loop = AgentLoop(bus=bus, provider=provider, workspace=tmp_path, model="test-model")
        _attach_webui_runtime_events(loop, bus)

        async def raise_from_turn(*_args, **_kwargs):
            raise RuntimeError("boom")

        loop._process_message = raise_from_turn  # type: ignore[method-assign]

        await loop._dispatch(InboundMessage(
            channel="websocket",
            sender_id="u1",
            chat_id="chat1",
            content="say hello",
        ))

        outbound = []
        while bus.outbound_size > 0:
            outbound.append(await bus.consume_outbound())

        error_msgs = [m for m in outbound if m.content == "Sorry, I encountered an error."]
        turn_end_msgs = [m for m in outbound if isinstance(m.event, TurnEndEvent)]
        statuses = [m for m in outbound if isinstance(m.event, GoalStatusEvent)]

        assert len(error_msgs) == 1
        assert len(turn_end_msgs) == 1
        assert turn_end_msgs[0].content == ""
        assert turn_end_msgs[0].chat_id == "chat1"
        assert [m.event.status for m in statuses if isinstance(m.event, GoalStatusEvent)] == ["idle"]
        assert outbound.index(error_msgs[0]) < outbound.index(turn_end_msgs[0])
        assert outbound.index(turn_end_msgs[0]) < outbound.index(statuses[-1])

    @pytest.mark.asyncio
    async def test_webui_title_generation_runs_after_turn_end(self, tmp_path: Path) -> None:
        bus = MessageBus()
        provider = MagicMock()
        provider.get_default_model.return_value = "test-model"
        title_started = asyncio.Event()
        release_title = asyncio.Event()
        calls = 0

        async def chat_with_retry(*_args: object, **_kwargs: object) -> LLMResponse:
            nonlocal calls
            calls += 1
            if calls == 1:
                return LLMResponse(content="Done", tool_calls=[])
            title_started.set()
            await release_title.wait()
            return LLMResponse(content="Generated title", tool_calls=[])

        provider.chat_with_retry = AsyncMock(side_effect=chat_with_retry)
        loop = AgentLoop(bus=bus, provider=provider, workspace=tmp_path, model="test-model")
        _attach_webui_runtime_events(loop, bus)
        loop.tools.get_definitions = MagicMock(return_value=[])
        loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False)  # type: ignore[method-assign]

        await asyncio.wait_for(loop._dispatch(InboundMessage(
            channel="websocket",
            sender_id="u1",
            chat_id="chat1",
            content="say hello",
            metadata={"webui": True},
        )), timeout=0.5)

        outbound: list = []
        for _ in range(12):
            outbound.append(await asyncio.wait_for(bus.consume_outbound(), timeout=0.5))
            if isinstance(outbound[-1].event, TurnEndEvent):
                break
        else:
            raise AssertionError("turn-end event not found")

        done_with_body = [m for m in outbound if m.content == "Done"]
        assert len(done_with_body) == 1
        assert isinstance(outbound[-1].event, TurnEndEvent)

        await asyncio.wait_for(title_started.wait(), timeout=0.5)
        release_title.set()
        session_updated = None
        for _ in range(10):
            candidate = await asyncio.wait_for(bus.consume_outbound(), timeout=0.5)
            if isinstance(candidate.event, SessionUpdatedEvent):
                session_updated = candidate
                break
        assert session_updated is not None

        assert isinstance(session_updated.event, SessionUpdatedEvent)
        assert session_updated.event.scope == "metadata"
        assert provider.chat_with_retry.await_count == 2

    @pytest.mark.asyncio
    async def test_webui_title_generation_uses_turn_model_snapshot(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        bus = MessageBus()
        provider = MagicMock()
        provider.get_default_model.return_value = "test-model"
        provider.chat_with_retry = AsyncMock(return_value=LLMResponse(content="Done", tool_calls=[]))
        loop = AgentLoop(bus=bus, provider=provider, workspace=tmp_path, model="test-model")
        _attach_webui_runtime_events(loop, bus)
        loop.tools.get_definitions = MagicMock(return_value=[])
        loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False)  # type: ignore[method-assign]

        captured: dict[str, object] = {}

        async def fake_title_after_turn(**kwargs: object) -> bool:
            captured.update(kwargs)
            return False

        monkeypatch.setattr(
            "nanobot.session.webui_turns.maybe_generate_webui_title_after_turn",
            fake_title_after_turn,
        )
        scheduled_title: list[object] = []

        def schedule_background(coro: object) -> None:
            name = getattr(coro, "__qualname__", "")
            if "_generate_title_and_notify" in name:
                scheduled_title.append(coro)
            elif hasattr(coro, "close"):
                coro.close()

        loop.schedule_background = schedule_background  # type: ignore[method-assign]

        await loop._dispatch(InboundMessage(
            channel="websocket",
            sender_id="u1",
            chat_id="chat1",
            content="say hello",
            metadata={"webui": True},
        ))

        assert len(scheduled_title) == 1
        next_provider = MagicMock()
        next_provider.generation = loop.llm_runtime().generation
        loop.runtime_resolver.adopt_snapshot(ProviderSnapshot(
            provider=next_provider,
            model="switched-after-turn",
            context_window_tokens=loop.context_window_tokens,
            signature=("switched-after-turn",),
        ))

        await scheduled_title[0]  # type: ignore[misc]

        assert captured["provider"] is provider
        assert captured["model"] == "test-model"

    @pytest.mark.asyncio
    async def test_webui_command_turn_does_not_schedule_title_generation(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        bus = MessageBus()
        provider = MagicMock()
        provider.get_default_model.return_value = "test-model"
        provider.chat_with_retry = AsyncMock(return_value=LLMResponse(content="Done", tool_calls=[]))
        loop = AgentLoop(bus=bus, provider=provider, workspace=tmp_path, model="test-model")
        _attach_webui_runtime_events(loop, bus)

        async def fake_title_after_turn(**_kwargs: object) -> bool:
            raise AssertionError("command-only turns should not generate titles")

        monkeypatch.setattr(
            "nanobot.session.webui_turns.maybe_generate_webui_title_after_turn",
            fake_title_after_turn,
        )
        scheduled: list[object] = []
        loop.schedule_background = scheduled.append  # type: ignore[method-assign]

        await loop._dispatch(InboundMessage(
            channel="websocket",
            sender_id="u1",
            chat_id="chat1",
            content="/model",
            metadata={"webui": True},
        ))

        assert scheduled == []

    @pytest.mark.asyncio
    async def test_non_websocket_dispatch_does_not_publish_turn_end_marker(self, tmp_path: Path) -> None:
        bus = MessageBus()
        provider = MagicMock()
        provider.get_default_model.return_value = "test-model"
        provider.chat_with_retry = AsyncMock(return_value=LLMResponse(content="Done", tool_calls=[]))
        loop = AgentLoop(bus=bus, provider=provider, workspace=tmp_path, model="test-model")
        loop.tools.get_definitions = MagicMock(return_value=[])
        loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False)  # type: ignore[method-assign]

        await loop._dispatch(InboundMessage(
            channel="slack",
            sender_id="u1",
            chat_id="chat1",
            content="say hello",
        ))

        outbound = []
        while bus.outbound_size > 0:
            outbound.append(await bus.consume_outbound())

        assert len(outbound) == 1
        assert outbound[0].content == "Done"
        assert not isinstance(outbound[0].event, TurnEndEvent)
