"""Segment-aware mixed tool-batch dispatch.

A model response containing several parallel-safe reads plus one unsafe
tool used to lose ALL concurrency: `_should_parallelize_tool_batch` was
all-or-nothing, so one barrier call forced the entire batch onto the
sequential path.  `_plan_tool_batch_segments` now splits the batch into
ordered segments — maximal contiguous runs of parallel-safe calls execute
concurrently, barrier calls sequentially — while preserving:

  * model tool-result ordering (one result per call, in emission order),
  * side-effect boundaries (no call starts before an earlier barrier ends).
"""

import json
import threading
import uuid
from types import SimpleNamespace
from unittest.mock import MagicMock, patch

import pytest

from run_agent import AIAgent
from agent.tool_dispatch_helpers import (
    _plan_tool_batch_segments,
    _should_parallelize_tool_batch,
)
from agent.prompt_builder import STEER_MARKER_OPEN
from tools.budget_config import BudgetConfig
from tools.tool_result_storage import PERSISTED_OUTPUT_TAG


def _assert_budget_replaced(content: str) -> None:
    """The oversized result must have been replaced by budget enforcement.

    With an active sandbox env (or host-side spillover) the replacement is a
    ``<persisted-output>`` preview+path block; when persistence is impossible
    it falls back to inline truncation. Either way the raw oversized payload
    must be gone — that is the behavior these tests pin, not which
    replacement shape was used.
    """
    assert PERSISTED_OUTPUT_TAG in content or "Truncated:" in content, content[:200]
    assert "L" * 1_000 not in content


def _tc(name="web_search", arguments="{}", call_id=None):
    return SimpleNamespace(
        id=call_id or f"call_{uuid.uuid4().hex[:8]}",
        type="function",
        function=SimpleNamespace(name=name, arguments=arguments),
    )


def _kinds(segments):
    return [kind for kind, _ in segments]


def _flatten_ids(segments):
    return [tc.id for _, calls in segments for tc in calls]


# ---------------------------------------------------------------------------
# Planner unit tests
# ---------------------------------------------------------------------------


class TestPlanToolBatchSegments:
    def test_all_safe_batch_is_single_parallel_segment(self):
        calls = [_tc("web_search"), _tc("read_file", '{"path":"a.py"}'), _tc("web_extract")]
        segments = _plan_tool_batch_segments(calls)
        assert _kinds(segments) == ["parallel"]
        assert _flatten_ids(segments) == [c.id for c in calls]

    def test_three_safe_reads_plus_trailing_unsafe_keeps_reads_parallel(self):
        """The headline case: 3 safe reads + 1 unsafe tool must NOT go fully sequential."""
        calls = [
            _tc("web_search", call_id="r1"),
            _tc("web_search", call_id="r2"),
            _tc("read_file", '{"path":"a.py"}', call_id="r3"),
            _tc("terminal", '{"command":"echo hi"}', call_id="b1"),
        ]
        segments = _plan_tool_batch_segments(calls)
        assert _kinds(segments) == ["parallel", "sequential"]
        assert [tc.id for tc in segments[0][1]] == ["r1", "r2", "r3"]
        assert [tc.id for tc in segments[1][1]] == ["b1"]

    def test_barrier_in_middle_splits_runs_and_preserves_order(self):
        calls = [
            _tc("web_search", call_id="r1"),
            _tc("web_search", call_id="r2"),
            _tc("terminal", '{"command":"make"}', call_id="b1"),
            _tc("web_search", call_id="r3"),
            _tc("web_search", call_id="r4"),
        ]
        segments = _plan_tool_batch_segments(calls)
        assert _kinds(segments) == ["parallel", "sequential", "parallel"]
        assert _flatten_ids(segments) == ["r1", "r2", "b1", "r3", "r4"]

    def test_single_safe_call_after_barrier_is_demoted_and_merged(self):
        # parallel run of 1 gains nothing — demote to sequential and merge
        # with the adjacent barrier segment.
        calls = [
            _tc("web_search", call_id="r1"),
            _tc("web_search", call_id="r2"),
            _tc("terminal", '{"command":"make"}', call_id="b1"),
            _tc("web_search", call_id="r3"),
        ]
        segments = _plan_tool_batch_segments(calls)
        assert _kinds(segments) == ["parallel", "sequential"]
        assert [tc.id for tc in segments[1][1]] == ["b1", "r3"]


    def test_never_parallel_tool_is_a_barrier(self):
        calls = [
            _tc("web_search", call_id="r1"),
            _tc("web_search", call_id="r2"),
            _tc("clarify", '{"question":"?"}', call_id="c1"),
        ]
        segments = _plan_tool_batch_segments(calls)
        assert _kinds(segments) == ["parallel", "sequential"]
        assert [tc.id for tc in segments[1][1]] == ["c1"]



    def test_overlapping_paths_split_across_segments(self, tmp_path, monkeypatch):
        monkeypatch.chdir(tmp_path)
        calls = [
            _tc("read_file", '{"path":"a.py"}', call_id="w1"),
            _tc("web_search", call_id="r1"),
            _tc("write_file", '{"path":"a.py","content":"x"}', call_id="w2"),
            _tc("web_search", call_id="r2"),
        ]
        segments = _plan_tool_batch_segments(calls)
        # w2 conflicts with w1 → closes the first run; w2+r2 form the second.
        assert _kinds(segments) == ["parallel", "parallel"]
        assert [tc.id for tc in segments[0][1]] == ["w1", "r1"]
        assert [tc.id for tc in segments[1][1]] == ["w2", "r2"]
        # Order and completeness preserved.
        assert _flatten_ids(segments) == ["w1", "r1", "w2", "r2"]

    def test_v4a_decoy_path_does_not_parallelize_with_real_target(self, tmp_path):
        """mode=patch scopes via V4A headers, not a decoy path= argument.

        A patch that claims path=dummy.txt but updates real.py must not share
        a parallel segment with write_file/read_file on real.py.
        """
        patch_body = (
            "*** Begin Patch\n"
            "*** Update File: real.py\n"
            "@@\n"
            "-old\n"
            "+new\n"
            "*** End Patch\n"
        )
        patch_args = json.dumps({
            "mode": "patch",
            "path": "dummy.txt",
            "patch": patch_body,
        })
        calls = [
            _tc("patch", patch_args, call_id="p1"),
            _tc("write_file", '{"path":"real.py","content":"x"}', call_id="w1"),
        ]
        segments = _plan_tool_batch_segments(calls, execution_cwd=tmp_path)
        assert _flatten_ids(segments) == ["p1", "w1"]
        # Overlap on real.py must prevent a single parallel segment.
        assert not (
            len(segments) == 1
            and segments[0][0] == "parallel"
            and [tc.id for tc in segments[0][1]] == ["p1", "w1"]
        )
        # Solo runs demote to sequential and may merge; either shape is safe.
        if len(segments) == 1:
            assert segments[0][0] == "sequential"
        else:
            assert [tc.id for tc in segments[0][1]] == ["p1"]
            assert [tc.id for tc in segments[1][1]] == ["w1"]

    def test_v4a_multi_file_reserves_all_header_targets(self, tmp_path):
        """Multi-file V4A must reserve every Update/Add/Delete/Move target."""
        patch_body = (
            "*** Begin Patch\n"
            "*** Update File: a.py\n"
            "@@\n-a\n+b\n"
            "*** Add File: b.py\n"
            "+fresh\n"
            "*** End Patch\n"
        )
        # Honest path= only names a.py — b.py still must be reserved.
        patch_args = json.dumps({
            "mode": "patch",
            "path": "a.py",
            "patch": patch_body,
        })
        calls = [
            _tc("patch", patch_args, call_id="p1"),
            _tc("read_file", '{"path":"b.py"}', call_id="r1"),
        ]
        segments = _plan_tool_batch_segments(calls, execution_cwd=tmp_path)
        assert _flatten_ids(segments) == ["p1", "r1"]
        assert not (
            len(segments) == 1
            and segments[0][0] == "parallel"
            and [tc.id for tc in segments[0][1]] == ["p1", "r1"]
        )
        if len(segments) == 1:
            assert segments[0][0] == "sequential"
        else:
            assert [tc.id for tc in segments[0][1]] == ["p1"]
            assert [tc.id for tc in segments[1][1]] == ["r1"]

    def test_v4a_without_path_arg_still_scopes_from_headers(self, tmp_path):
        """mode=patch with no path= must still parallel-scope from V4A headers."""
        patch_body = (
            "*** Begin Patch\n"
            "*** Update File: real.py\n"
            "@@\n-old\n+new\n"
            "*** End Patch\n"
        )
        patch_args = json.dumps({"mode": "patch", "patch": patch_body})
        calls = [
            _tc("patch", patch_args, call_id="p1"),
            _tc("write_file", '{"path":"other.py","content":"x"}', call_id="w1"),
            _tc("read_file", '{"path":"real.py"}', call_id="r1"),
        ]
        segments = _plan_tool_batch_segments(calls, execution_cwd=tmp_path)
        # p1+w1 are disjoint → can share a parallel run; r1 overlaps real.py → new run.
        assert _flatten_ids(segments) == ["p1", "w1", "r1"]
        assert [tc.id for tc in segments[0][1]] == ["p1", "w1"]
        assert segments[0][0] == "parallel"
        assert [tc.id for tc in segments[1][1]] == ["r1"]

    def test_path_scoped_tool_without_path_is_a_barrier(self):
        calls = [
            _tc("read_file", "{}", call_id="nopath"),
            _tc("web_search", call_id="r1"),
            _tc("web_search", call_id="r2"),
        ]
        segments = _plan_tool_batch_segments(calls)
        assert _kinds(segments) == ["sequential", "parallel"]

    def test_flattened_segments_always_preserve_emission_order(self):
        calls = [
            _tc("terminal", '{"command":"x"}', call_id="b1"),
            _tc("web_search", call_id="r1"),
            _tc("clarify", '{"question":"?"}', call_id="c1"),
            _tc("read_file", '{"path":"a.py"}', call_id="r2"),
            _tc("read_file", '{"path":"b.py"}', call_id="r3"),
        ]
        segments = _plan_tool_batch_segments(calls)
        assert _flatten_ids(segments) == ["b1", "r1", "c1", "r2", "r3"]


class TestReaderWriterPathRoles:
    """Reader/writer reservation semantics on path-scoped tools.

    The originating bug: ``search_files`` was in ``_PARALLEL_SAFE_TOOLS``
    with no path reservation, so ``patch(path=X)`` + ``search_files(path=dir(X))``
    landed in ONE parallel segment and the search could observe pre-patch
    file content (stale-read race).  Fix: ``search_files`` reserves its
    search root as a READER; overlap conflicts only when a WRITER is on
    either side.
    """

    def test_search_files_after_patch_same_subtree_splits(self, tmp_path, monkeypatch):
        """The exact smoke-test race: patch a file, search its directory."""
        monkeypatch.chdir(tmp_path)
        calls = [
            _tc("patch", '{"path":"scratch/sample.txt","old_string":"a","new_string":"patched"}', call_id="w1"),
            _tc("search_files", '{"pattern":"patched","path":"scratch"}', call_id="s1"),
        ]
        segments = _plan_tool_batch_segments(calls, execution_cwd=tmp_path)
        # Both calls survive, but never in the same PARALLEL segment.
        # (A shared *sequential* segment is fine — sequential is ordered.)
        assert _flatten_ids(segments) == ["w1", "s1"]
        for kind, seg_calls in segments:
            ids = [tc.id for tc in seg_calls]
            assert not (kind == "parallel" and {"w1", "s1"} <= set(ids)), (
                "write and dependent search must not share a parallel segment"
            )

    def test_search_files_default_root_conflicts_with_write_into_cwd(self, tmp_path, monkeypatch):
        """search_files with NO path arg reserves the cwd — a write anywhere
        under the cwd must not share its segment."""
        monkeypatch.chdir(tmp_path)
        calls = [
            _tc("write_file", '{"path":"out/notes.txt","content":"x"}', call_id="w1"),
            _tc("search_files", '{"pattern":"notes"}', call_id="s1"),
        ]
        segments = _plan_tool_batch_segments(calls, execution_cwd=tmp_path)
        for kind, seg_calls in segments:
            ids = [tc.id for tc in seg_calls]
            assert not (kind == "parallel" and {"w1", "s1"} <= set(ids))

    def test_reader_reader_same_file_stays_parallel(self, tmp_path, monkeypatch):
        """Two reads of the same file commute — the old planner needlessly
        split them; they must now share one parallel segment."""
        monkeypatch.chdir(tmp_path)
        calls = [
            _tc("read_file", '{"path":"a.py"}', call_id="r1"),
            _tc("read_file", '{"path":"a.py"}', call_id="r2"),
        ]
        segments = _plan_tool_batch_segments(calls, execution_cwd=tmp_path)
        assert _kinds(segments) == ["parallel"]
        assert [tc.id for tc in segments[0][1]] == ["r1", "r2"]

    def test_read_file_and_search_files_overlapping_stay_parallel(self, tmp_path, monkeypatch):
        monkeypatch.chdir(tmp_path)
        calls = [
            _tc("read_file", '{"path":"src/a.py"}', call_id="r1"),
            _tc("search_files", '{"pattern":"foo","path":"src"}', call_id="s1"),
        ]
        segments = _plan_tool_batch_segments(calls, execution_cwd=tmp_path)
        assert _kinds(segments) == ["parallel"]

    def test_search_files_disjoint_from_write_stays_parallel(self, tmp_path, monkeypatch):
        """A search rooted outside the written subtree has no conflict."""
        monkeypatch.chdir(tmp_path)
        calls = [
            _tc("write_file", '{"path":"src/a.py","content":"x"}', call_id="w1"),
            _tc("search_files", '{"pattern":"foo","path":"docs"}', call_id="s1"),
        ]
        segments = _plan_tool_batch_segments(calls, execution_cwd=tmp_path)
        assert _kinds(segments) == ["parallel"]
        assert [tc.id for tc in segments[0][1]] == ["w1", "s1"]

    def test_writer_writer_same_path_still_splits(self, tmp_path, monkeypatch):
        monkeypatch.chdir(tmp_path)
        calls = [
            _tc("write_file", '{"path":"a.py","content":"1"}', call_id="w1"),
            _tc("write_file", '{"path":"a.py","content":"2"}', call_id="w2"),
        ]
        segments = _plan_tool_batch_segments(calls, execution_cwd=tmp_path)
        for kind, seg_calls in segments:
            ids = [tc.id for tc in seg_calls]
            assert not (kind == "parallel" and {"w1", "w2"} <= set(ids))

    def test_read_then_write_same_file_still_splits(self, tmp_path, monkeypatch):
        """Reader followed by writer on the same path keeps the pre-existing
        split (write must not clobber a file mid-read)."""
        monkeypatch.chdir(tmp_path)
        calls = [
            _tc("read_file", '{"path":"a.py"}', call_id="r1"),
            _tc("write_file", '{"path":"a.py","content":"x"}', call_id="w1"),
        ]
        segments = _plan_tool_batch_segments(calls, execution_cwd=tmp_path)
        for kind, seg_calls in segments:
            ids = [tc.id for tc in seg_calls]
            assert not (kind == "parallel" and {"r1", "w1"} <= set(ids))


class TestShouldParallelizeBackwardCompat:
    """The boolean gate is now a view over the planner — same answers as before."""

    def test_single_call_is_sequential(self):
        assert not _should_parallelize_tool_batch([_tc("web_search")])





# ---------------------------------------------------------------------------
# Dispatcher integration
# ---------------------------------------------------------------------------


def _make_tool_defs(*names: str) -> list:
    return [
        {
            "type": "function",
            "function": {
                "name": n,
                "description": f"{n} tool",
                "parameters": {"type": "object", "properties": {}},
            },
        }
        for n in names
    ]


@pytest.fixture()
def agent():
    with (
        patch(
            "model_tools.get_tool_definitions",
            return_value=_make_tool_defs("web_search", "terminal"),
        ),
        patch("model_tools.check_toolset_requirements", return_value={}),
        patch("agent.process_bootstrap.OpenAI"),
    ):
        a = AIAgent(
            api_key="test-key-1234567890",
            base_url="https://openrouter.ai/api/v1",
            quiet_mode=True,
            skip_context_files=True,
            skip_memory=True,
        )
        a.client = MagicMock()
        return a


class TestSegmentedDispatchIntegration:
    def test_mixed_batch_runs_safe_prefix_concurrently_and_barrier_after(self, agent):
        """Two web_search calls must overlap in time; terminal must start only
        after both finish; results land in the model's emission order."""
        calls = [
            _tc("web_search", '{"query":"a"}', call_id="s1"),
            _tc("web_search", '{"query":"b"}', call_id="s2"),
            _tc("terminal", '{"command":"echo done"}', call_id="t1"),
        ]
        msg = SimpleNamespace(content="", tool_calls=calls)
        messages = []

        rendezvous = threading.Barrier(2, timeout=10)
        events = []
        events_lock = threading.Lock()

        def fake_handle(name, args, task_id, **kwargs):
            with events_lock:
                events.append(("start", name, kwargs["tool_call_id"]))
            if name == "web_search":
                # Both searches must be in flight at once to pass this
                # barrier — proves genuine concurrency for the safe prefix.
                rendezvous.wait()
            with events_lock:
                events.append(("end", name, kwargs["tool_call_id"]))
            return json.dumps({"ok": name})

        with patch("model_tools.handle_function_call", side_effect=fake_handle):
            agent._execute_tool_calls(msg, messages, "task-1")

        # One result per call, in emission order.
        assert [m["tool_call_id"] for m in messages] == ["s1", "s2", "t1"]
        assert all(m["role"] == "tool" for m in messages)

        # The barrier (terminal) started only after BOTH searches ended.
        terminal_start = events.index(("start", "terminal", "t1"))
        search_ends = [
            i for i, e in enumerate(events) if e[0] == "end" and e[1] == "web_search"
        ]
        assert len(search_ends) == 2
        assert all(i < terminal_start for i in search_ends)

    def test_mixed_batch_preserves_order_with_barrier_in_middle(self, agent):
        calls = [
            _tc("web_search", '{"query":"a"}', call_id="s1"),
            _tc("web_search", '{"query":"b"}', call_id="s2"),
            _tc("terminal", '{"command":"touch x"}', call_id="t1"),
            _tc("web_search", '{"query":"c"}', call_id="s3"),
            _tc("web_search", '{"query":"d"}', call_id="s4"),
        ]
        msg = SimpleNamespace(content="", tool_calls=calls)
        messages = []
        executed = []
        lock = threading.Lock()

        def fake_handle(name, args, task_id, **kwargs):
            with lock:
                executed.append(kwargs["tool_call_id"])
            return json.dumps({"ok": True})

        with patch("model_tools.handle_function_call", side_effect=fake_handle):
            agent._execute_tool_calls(msg, messages, "task-1")

        assert [m["tool_call_id"] for m in messages] == ["s1", "s2", "t1", "s3", "s4"]
        # Barrier ordering: t1 executed after {s1,s2} and before {s3,s4}.
        t1_pos = executed.index("t1")
        assert {"s1", "s2"} == set(executed[:t1_pos])
        assert {"s3", "s4"} == set(executed[t1_pos + 1:])




    def test_interrupt_during_barrier_drains_later_segments(self, agent):
        """Interrupt raised while the barrier tool runs: the trailing parallel
        segment must be drained with cancelled results — one per call —
        without executing."""
        calls = [
            _tc("web_search", '{"query":"a"}', call_id="s1"),
            _tc("web_search", '{"query":"b"}', call_id="s2"),
            _tc("terminal", '{"command":"long"}', call_id="t1"),
            _tc("web_search", '{"query":"c"}', call_id="s3"),
            _tc("web_search", '{"query":"d"}', call_id="s4"),
        ]
        msg = SimpleNamespace(content="", tool_calls=calls)
        messages = []
        executed = []
        lock = threading.Lock()

        def fake_handle(name, args, task_id, **kwargs):
            with lock:
                executed.append(kwargs["tool_call_id"])
            if kwargs["tool_call_id"] == "t1":
                agent._interrupt_requested = True
            return json.dumps({"ok": True})

        with patch("model_tools.handle_function_call", side_effect=fake_handle):
            agent._execute_tool_calls(msg, messages, "task-1")

        # Every call still gets exactly one result, in order.
        assert [m["tool_call_id"] for m in messages] == ["s1", "s2", "t1", "s3", "s4"]
        # s3/s4 were never executed.
        assert "s3" not in executed and "s4" not in executed
        for m in messages[-2:]:
            assert "cancelled" in m["content"] or "skipped" in m["content"]

    def test_steer_lands_exactly_once_in_mixed_batch(self, agent):
        """The whole-batch finalizer drains steer once, so the marker cannot
        be duplicated by segment boundaries."""
        calls = [
            _tc("web_search", '{"query":"a"}', call_id="s1"),
            _tc("web_search", '{"query":"b"}', call_id="s2"),
            _tc("terminal", '{"command":"echo hi"}', call_id="t1"),
        ]
        msg = SimpleNamespace(content="", tool_calls=calls)
        messages = []

        def fake_handle(name, args, task_id, **kwargs):
            return json.dumps({"ok": True})

        agent.steer("focus on the tests")
        with patch("model_tools.handle_function_call", side_effect=fake_handle):
            agent._execute_tool_calls(msg, messages, "task-1")

        contents = [m["content"] for m in messages]
        hits = [c for c in contents if "focus on the tests" in c]
        assert len(hits) == 1

    @pytest.mark.parametrize(
        ("calls", "expected_segment_kinds"),
        [
            (
                [
                    _tc("web_search", '{"query":"large"}', call_id="parallel-large"),
                    _tc("web_search", '{"query":"small"}', call_id="parallel-small"),
                ],
                ["parallel"],
            ),
            (
                [
                    _tc("terminal", '{"command":"large"}', call_id="sequential-large"),
                    _tc("terminal", '{"command":"small"}', call_id="sequential-small"),
                ],
                ["sequential"],
            ),
            (
                [
                    _tc("web_search", '{"query":"large"}', call_id="mixed-large"),
                    _tc("web_search", '{"query":"small"}', call_id="mixed-search-small"),
                    _tc("terminal", '{"command":"small"}', call_id="mixed-terminal-small"),
                ],
                ["parallel", "sequential"],
            ),
            (
                [
                    _tc("web_search", '{"query":"small"}', call_id="mixed-search-first-small"),
                    _tc("web_search", '{"query":"small"}', call_id="mixed-search-second-small"),
                    _tc("terminal", '{"command":"large"}', call_id="mixed-terminal-large"),
                ],
                ["parallel", "sequential"],
            ),
        ],
        ids=["parallel", "sequential", "mixed-parallel-large", "mixed-sequential-large"],
    )
    def test_steer_survives_turn_budget_in_every_dispatch_path(
        self, agent, calls, expected_segment_kinds
    ):
        """A steer must be appended after aggregate budgeting in direct
        concurrent, direct sequential, and segmented mixed batches.

        The large result forces ``enforce_turn_budget()`` to replace it.
        Before the fix, the per-tool drain consumed the steer first, so that
        replacement silently discarded the canonical marker.
        """
        messages = []
        msg = SimpleNamespace(content="", tool_calls=calls)
        budget = BudgetConfig(
            default_result_size=10_000,
            turn_budget=48,
            preview_size=16,
        )

        assert _kinds(_plan_tool_batch_segments(calls)) == expected_segment_kinds

        def fake_handle(name, args, task_id, **kwargs):
            if kwargs["tool_call_id"].endswith("large"):
                assert agent.steer("preserve this steer after budget enforcement")
                return "L" * 1_000
            return "small"

        with (
            patch("model_tools.handle_function_call", side_effect=fake_handle),
            patch("agent.tool_executor._budget_for_agent", return_value=budget),
        ):
            agent._execute_tool_calls(msg, messages, "task-1")

        large_result_index = next(i for i, call in enumerate(calls) if call.id.endswith("large"))
        _assert_budget_replaced(messages[large_result_index]["content"])
        steer_messages = [m for m in messages if STEER_MARKER_OPEN in m["content"]]
        assert steer_messages == [messages[-1]]
        assert "preserve this steer after budget enforcement" in steer_messages[0]["content"]

    def test_steer_survives_turn_budget_after_malformed_arguments(self, agent):
        """Malformed arguments still reach the shared post-budget finalizer.

        The parser error itself can exceed a constrained turn budget.  A steer
        queued before that malformed sequential call must therefore remain
        pending until after the error result is replaced by the budget preview.
        """
        calls = [_tc("terminal", "{not json", call_id="malformed")]
        messages = []
        msg = SimpleNamespace(content="", tool_calls=calls)
        budget = BudgetConfig(
            default_result_size=10_000,
            turn_budget=48,
            preview_size=16,
        )

        assert _kinds(_plan_tool_batch_segments(calls)) == ["sequential"]
        assert agent.steer("preserve malformed-call steer after budget enforcement")

        with patch("agent.tool_executor._budget_for_agent", return_value=budget):
            agent._execute_tool_calls(msg, messages, "task-1")

        assert len(messages) == 2
        _assert_budget_replaced(messages[0]["content"])
        assert STEER_MARKER_OPEN not in messages[0]["content"]   # tool row untouched
        assert messages[1]["role"] == "user"                      # steer = new user msg
        assert messages[1]["content"].count(STEER_MARKER_OPEN) == 1
        assert "preserve malformed-call steer after budget enforcement" in messages[1]["content"]


class TestPathCanonicalization:
    """Regression tests for _canonical_path / _extract_parallel_scope_path fixes.

    Verifies that symlink aliases, relative/absolute cwd mismatches, and
    (on Windows) case-insensitive aliases are never placed in the same
    parallel segment.
    """

    def test_relative_and_absolute_same_target_use_separate_segments(self, tmp_path):
        """A relative path resolved against execution_cwd and an absolute path
        pointing to the same file must be detected as overlapping."""
        from agent.tool_dispatch_helpers import (
            _canonical_path,
            _paths_overlap,
        )

        target = tmp_path / "config.json"
        target.touch()

        abs_path = _canonical_path(str(target))
        rel_path = _canonical_path("config.json", execution_cwd=tmp_path)

        assert _paths_overlap(abs_path, rel_path), (
            "Absolute and relative paths pointing to the same file must overlap"
        )

    @pytest.mark.require_symlinks
    def test_symlink_aliases_are_not_parallelized(self, tmp_path):
        """A symlink alias and the real path must be detected as overlapping
        so they are never placed in the same parallel segment."""
        from agent.tool_dispatch_helpers import (
            _canonical_path,
            _paths_overlap,
        )

        real_dir = tmp_path / "real"
        real_dir.mkdir()
        target = real_dir / "config.json"
        target.touch()

        alias_dir = tmp_path / "alias"
        alias_dir.symlink_to(real_dir)

        real_path = _canonical_path(str(target))
        alias_path = _canonical_path(str(alias_dir / "config.json"))

        assert _paths_overlap(real_path, alias_path), (
            "Symlink alias and real path must overlap — "
            "they must not be parallelized"
        )

    def test_execution_cwd_used_over_process_cwd(self, tmp_path, monkeypatch):
        """_extract_parallel_scope_path must use execution_cwd, not
        process cwd, when resolving relative paths."""
        from agent.tool_dispatch_helpers import (
            _extract_parallel_scope_path,
            _paths_overlap,
        )

        exec_cwd = tmp_path / "sub"
        exec_cwd.mkdir()
        (exec_cwd / "x.txt").touch()

        # Point process cwd somewhere else entirely.
        monkeypatch.chdir(tmp_path)

        # With execution_cwd supplied the relative path resolves under exec_cwd.
        path_with_cwd = _extract_parallel_scope_path(
            "write_file", {"path": "x.txt"}, execution_cwd=exec_cwd
        )
        # The absolute path under exec_cwd must match.
        path_absolute = _extract_parallel_scope_path(
            "write_file", {"path": str(exec_cwd / "x.txt")}
        )

        assert path_with_cwd is not None
        assert path_absolute is not None
        assert _paths_overlap(path_with_cwd, path_absolute), (
            "execution_cwd-relative path and absolute path must overlap; "
            "process cwd must not be used when execution_cwd is provided"
        )


    # ``platforms("windows")`` rather than ``skipif(sys.platform != "win32")``: the
    # Windows CI job greps for the marker to decide which files to import, so
    # a bare skipif leaves this running on no host at all.
    @pytest.mark.platforms("windows")
    def test_case_insensitive_paths_overlap_windows(self, tmp_path):
        """On Windows, FILE.txt and file.txt are the same file — they must
        be detected as overlapping after normcase() canonicalisation."""
        from agent.tool_dispatch_helpers import _canonical_path, _paths_overlap

        upper = _canonical_path(str(tmp_path / "FILE.txt"), execution_cwd=tmp_path)
        lower = _canonical_path(str(tmp_path / "file.txt"), execution_cwd=tmp_path)

        assert _paths_overlap(upper, lower), (
            "Case-insensitive aliases must overlap on Windows"
        )
