"""Tests for LiveWorkerAdapter — real worker dispatch, no silent fallback."""

from __future__ import annotations

from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch

import pytest

from overdrive.runtime.domain.models import RunRecord, Task
from overdrive.runtime.orchestrator.live_worker_adapter import (
    LiveWorkerAdapter,
    _EARLY_COMPLETE_STEPS,
    _SETTINGS_PROMPT_STEPS,
    _detect_required_verify_commands,
    _filter_npm_commands_by_scripts,
    _inject_required_verify_commands,
    _extract_json,
    _extract_json_value,
    _is_no_action_needed,
    _normalize_planning_text,
    _sanitize_stderr,
    _step_category,
    build_step_prompt,
    detect_project_languages,
)
from overdrive.runtime.orchestrator.worker_adapter import StepResult
from overdrive.runtime.orchestrator.environment_preflight import (
    EnvironmentIssue,
    EnvironmentPreflightResult,
)
from overdrive.runtime.storage.container import Container
from overdrive.workers.config import WorkerProviderSpec
from overdrive.workers.run import WorkerRunResult


@pytest.fixture()
def container(tmp_path: Path) -> Container:
    return Container(tmp_path)


@pytest.fixture()
def adapter(container: Container) -> LiveWorkerAdapter:
    return LiveWorkerAdapter(container)


def _make_task(**kwargs) -> Task:
    defaults = dict(title="Test task", description="Do the thing", task_type="feature")
    defaults.update(kwargs)
    return Task(**defaults)


def _make_run_result(
    *,
    exit_code: int = 0,
    timed_out: bool = False,
    no_heartbeat: bool = False,
    response_text: str = "",
    human_blocking_issues: list[dict[str, str]] | None = None,
) -> WorkerRunResult:
    return WorkerRunResult(
        provider="test",
        prompt_path="/tmp/claude/prompt.txt",
        stdout_path="/tmp/claude/stdout.log",
        stderr_path="/tmp/claude/stderr.log",
        start_time="2025-01-01T00:00:00Z",
        end_time="2025-01-01T00:01:00Z",
        runtime_seconds=60,
        exit_code=exit_code,
        timed_out=timed_out,
        no_heartbeat=no_heartbeat,
        response_text=response_text,
        human_blocking_issues=list(human_blocking_issues or []),
    )


_CODEX_SPEC = WorkerProviderSpec(name="codex", type="codex", command="codex")
_CLAUDE_SPEC = WorkerProviderSpec(name="claude", type="claude", command="claude -p")
_OLLAMA_SPEC = WorkerProviderSpec(
    name="local", type="ollama", endpoint="http://localhost:11434", model="llama3"
)


# ---------------------------------------------------------------------------
# 1. Error when no worker is available
# ---------------------------------------------------------------------------


def test_error_when_no_worker_available(adapter: LiveWorkerAdapter) -> None:
    """When worker is not available, return error — never silently succeed."""
    task = _make_task()

    with patch(
        "overdrive.runtime.orchestrator.live_worker_adapter.test_worker",
        return_value=(False, "Executable not found in PATH: codex"),
    ):
        result = adapter.run_step(task=task, step="implement", attempt=1)

    assert result.status == "error"
    assert "not available" in (result.summary or "").lower()


def test_error_when_worker_cannot_be_resolved(adapter: LiveWorkerAdapter) -> None:
    """When worker resolution raises, return error with details."""
    task = _make_task()

    with patch(
        "overdrive.runtime.orchestrator.live_worker_adapter.get_workers_runtime_config",
        side_effect=ValueError("No workers section in config"),
    ):
        result = adapter.run_step(task=task, step="implement", attempt=1)

    assert result.status == "error"
    assert "cannot resolve" in (result.summary or "").lower()


# ---------------------------------------------------------------------------
# 2. Codex success maps to ok
# ---------------------------------------------------------------------------


def test_codex_success_maps_to_ok(adapter: LiveWorkerAdapter) -> None:
    run_result = _make_run_result(exit_code=0)

    with (
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.get_workers_runtime_config"
        ),
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.resolve_worker_for_step",
            return_value=_CODEX_SPEC,
        ),
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.test_worker",
            return_value=(True, "ok"),
        ),
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.run_environment_preflight",
            return_value=EnvironmentPreflightResult(ok=True, issues=()),
        ),
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.run_worker",
            return_value=run_result,
        ),
    ):
        result = adapter.run_step(task=_make_task(), step="implement", attempt=1)

    assert result.status == "ok"


# ---------------------------------------------------------------------------
# 3. Codex model selection prefers task override over runtime default
# ---------------------------------------------------------------------------


def test_codex_model_prefers_task_override(adapter: LiveWorkerAdapter) -> None:
    run_result = _make_run_result(exit_code=0)
    runtime = SimpleNamespace(default_model="gpt-5-codex-default")
    task = _make_task(worker_model="gpt-5-codex-task")

    with (
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.get_workers_runtime_config",
            return_value=runtime,
        ),
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.resolve_worker_for_step",
            return_value=_CODEX_SPEC,
        ),
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.test_worker",
            return_value=(True, "ok"),
        ),
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.run_worker",
            return_value=run_result,
        ) as run_worker_mock,
    ):
        result = adapter.run_step(task=task, step="implement", attempt=1)

    assert result.status == "ok"
    assert run_worker_mock.call_args.kwargs["spec"].model == "gpt-5-codex-task"


# ---------------------------------------------------------------------------
# 4. Codex model falls back to runtime default when task override absent
# ---------------------------------------------------------------------------


def test_codex_model_falls_back_to_runtime_default(adapter: LiveWorkerAdapter) -> None:
    run_result = _make_run_result(exit_code=0)
    runtime = SimpleNamespace(default_model="gpt-5-codex-default")

    with (
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.get_workers_runtime_config",
            return_value=runtime,
        ),
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.resolve_worker_for_step",
            return_value=_CODEX_SPEC,
        ),
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.test_worker",
            return_value=(True, "ok"),
        ),
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.run_worker",
            return_value=run_result,
        ) as run_worker_mock,
    ):
        result = adapter.run_step(task=_make_task(), step="implement", attempt=1)

    assert result.status == "ok"
    assert run_worker_mock.call_args.kwargs["spec"].model == "gpt-5-codex-default"


# ---------------------------------------------------------------------------
# 5. Prompt construction uses a shared path for Claude workers
# ---------------------------------------------------------------------------


def test_claude_uses_shared_prompt_construction(adapter: LiveWorkerAdapter) -> None:
    run_result = _make_run_result(exit_code=0)

    with (
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.get_workers_runtime_config"
        ),
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.resolve_worker_for_step",
            return_value=_CLAUDE_SPEC,
        ),
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.test_worker",
            return_value=(True, "ok"),
        ),
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.build_step_prompt",
            return_value="prompt",
        ) as build_prompt_mock,
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.run_worker",
            return_value=run_result,
        ),
    ):
        result = adapter.run_step(task=_make_task(), step="implement", attempt=1)

    assert result.status == "ok"
    assert "project_languages" in build_prompt_mock.call_args.kwargs


def test_environment_preflight_failure_short_circuits_before_worker_run(adapter: LiveWorkerAdapter) -> None:
    task = _make_task()
    failed_preflight = EnvironmentPreflightResult(
        ok=False,
        required_capabilities=("docker",),
        issues=(
            EnvironmentIssue(
                code="docker_unavailable",
                summary="Docker daemon/socket is unavailable in the worker execution environment.",
                capability="docker",
                recoverable=False,
            ),
        ),
    )

    with (
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.get_workers_runtime_config",
        ),
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.resolve_worker_for_step",
            return_value=_CODEX_SPEC,
        ),
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.test_worker",
            return_value=(True, "ok"),
        ),
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.run_environment_preflight",
            return_value=failed_preflight,
        ),
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.run_worker",
        ) as run_worker_mock,
    ):
        result = adapter.run_step(task=task, step="implement", attempt=1)

    assert result.status == "error"
    assert "environment preflight failed" in str(result.summary or "").lower()
    run_worker_mock.assert_not_called()


def test_capability_fallback_selects_provider_with_required_capabilities(
    adapter: LiveWorkerAdapter,
) -> None:
    run_result = _make_run_result(exit_code=0)
    runtime = SimpleNamespace(
        default_model="",
        providers={
            "codex": _CODEX_SPEC,
            "claude-capable": WorkerProviderSpec(
                name="claude-capable",
                type="claude",
                command="claude -p",
                capabilities=("docker",),
            ),
        },
    )

    with (
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.get_workers_runtime_config",
            return_value=runtime,
        ),
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.resolve_worker_for_step",
            return_value=_CODEX_SPEC,
        ),
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.required_capabilities_for_step",
            return_value=("docker",),
        ),
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.test_worker",
            return_value=(True, "ok"),
        ),
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.run_environment_preflight",
            return_value=EnvironmentPreflightResult(ok=True, issues=(), required_capabilities=("docker",)),
        ),
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.run_worker",
            return_value=run_result,
        ) as run_worker_mock,
    ):
        result = adapter.run_step(task=_make_task(), step="implement", attempt=1)

    assert result.status == "ok"
    assert run_worker_mock.call_args.kwargs["spec"].name == "claude-capable"


# ---------------------------------------------------------------------------
# 6. Step timeout can be overridden per task metadata
# ---------------------------------------------------------------------------


def test_timeout_override_from_task_metadata(adapter: LiveWorkerAdapter) -> None:
    run_result = _make_run_result(exit_code=0)
    task = _make_task(metadata={"step_timeouts": {"implement": 42}})

    with (
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.get_workers_runtime_config"
        ),
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.resolve_worker_for_step",
            return_value=_CODEX_SPEC,
        ),
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.test_worker",
            return_value=(True, "ok"),
        ),
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.run_worker",
            return_value=run_result,
        ) as run_worker_mock,
    ):
        result = adapter.run_step(task=task, step="implement", attempt=1)

    assert result.status == "ok"
    assert run_worker_mock.call_args.kwargs["timeout_seconds"] == 42


# ---------------------------------------------------------------------------
# 7. Template timeout is used when no metadata override exists
# ---------------------------------------------------------------------------


def test_timeout_defaults_to_no_timeout(adapter: LiveWorkerAdapter) -> None:
    """When no global config override is set, steps default to 0 (no timeout)."""
    run_result = _make_run_result(exit_code=0)
    task = _make_task(task_type="bug")

    with (
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.get_workers_runtime_config"
        ),
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.resolve_worker_for_step",
            return_value=_CODEX_SPEC,
        ),
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.test_worker",
            return_value=(True, "ok"),
        ),
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.run_worker",
            return_value=run_result,
        ) as run_worker_mock,
    ):
        result = adapter.run_step(task=task, step="diagnose", attempt=1)

    assert result.status == "ok"
    assert run_worker_mock.call_args.kwargs["timeout_seconds"] == 0


def test_timeout_defaults_from_orchestrator_settings_when_step_has_no_template(
    container: Container, adapter: LiveWorkerAdapter
) -> None:
    run_result = _make_run_result(exit_code=0)
    cfg = container.config.load()
    orchestrator_cfg = dict(cfg.get("orchestrator") or {})
    orchestrator_cfg["step_timeout_seconds"] = 321
    cfg["orchestrator"] = orchestrator_cfg
    container.config.save(cfg)

    with (
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.get_workers_runtime_config"
        ),
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.resolve_worker_for_step",
            return_value=_CODEX_SPEC,
        ),
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.test_worker",
            return_value=(True, "ok"),
        ),
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.run_worker",
            return_value=run_result,
        ) as run_worker_mock,
    ):
        result = adapter.run_step(task=_make_task(), step="plan_refine", attempt=1)

    assert result.status == "ok"
    assert run_worker_mock.call_args.kwargs["timeout_seconds"] == 321


def test_global_timeout_applies_to_template_steps_and_aliases(
    container: Container, adapter: LiveWorkerAdapter
) -> None:
    """Global step_timeout_seconds should override template defaults for both
    regular pipeline steps and aliased synthetic steps like implement_fix."""
    run_result = _make_run_result(exit_code=0)
    cfg = container.config.load()
    orchestrator_cfg = dict(cfg.get("orchestrator") or {})
    orchestrator_cfg["step_timeout_seconds"] = 6000
    cfg["orchestrator"] = orchestrator_cfg
    container.config.save(cfg)

    task = _make_task(task_type="bug")

    with (
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.get_workers_runtime_config"
        ),
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.resolve_worker_for_step",
            return_value=_CODEX_SPEC,
        ),
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.test_worker",
            return_value=(True, "ok"),
        ),
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.run_worker",
            return_value=run_result,
        ) as run_worker_mock,
    ):
        # implement_fix aliases to implement — should still get global 6000s
        result = adapter.run_step(task=task, step="implement_fix", attempt=1)

    assert result.status == "ok"
    assert run_worker_mock.call_args.kwargs["timeout_seconds"] == 6000


def test_heartbeat_defaults_are_forwarded(adapter: LiveWorkerAdapter) -> None:
    run_result = _make_run_result(exit_code=0)

    with (
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.get_workers_runtime_config"
        ),
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.resolve_worker_for_step",
            return_value=_CODEX_SPEC,
        ),
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.test_worker",
            return_value=(True, "ok"),
        ),
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.run_worker",
            return_value=run_result,
        ) as run_worker_mock,
    ):
        result = adapter.run_step(task=_make_task(), step="implement", attempt=1)

    assert result.status == "ok"
    assert run_worker_mock.call_args.kwargs["heartbeat_seconds"] == 60
    # No global config set, so implement step uses built-in per-step default of 300s
    assert run_worker_mock.call_args.kwargs["heartbeat_grace_seconds"] == 300


def test_heartbeat_settings_from_workers_config(container: Container, adapter: LiveWorkerAdapter) -> None:
    run_result = _make_run_result(exit_code=0)
    cfg = container.config.load()
    cfg["workers"] = {"heartbeat_seconds": 75, "heartbeat_grace_seconds": 190}
    container.config.save(cfg)

    with (
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.get_workers_runtime_config"
        ),
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.resolve_worker_for_step",
            return_value=_CODEX_SPEC,
        ),
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.test_worker",
            return_value=(True, "ok"),
        ),
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.run_worker",
            return_value=run_result,
        ) as run_worker_mock,
    ):
        # Use "plan" step which has no per-step default, so global config applies
        result = adapter.run_step(task=_make_task(), step="plan", attempt=1)

    assert result.status == "ok"
    assert run_worker_mock.call_args.kwargs["heartbeat_seconds"] == 75
    assert run_worker_mock.call_args.kwargs["heartbeat_grace_seconds"] == 190


# ---------------------------------------------------------------------------
# 8. Codex failure maps to error
# ---------------------------------------------------------------------------


def test_codex_failure_maps_to_error(adapter: LiveWorkerAdapter) -> None:
    run_result = _make_run_result(exit_code=1)

    with (
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.get_workers_runtime_config"
        ),
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.resolve_worker_for_step",
            return_value=_CODEX_SPEC,
        ),
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.test_worker",
            return_value=(True, "ok"),
        ),
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.run_environment_preflight",
            return_value=EnvironmentPreflightResult(ok=True, issues=()),
        ),
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.run_worker",
            return_value=run_result,
        ),
    ):
        result = adapter.run_step(task=_make_task(), step="implement", attempt=1)

    assert result.status == "error"
    assert "code 1" in (result.summary or "")


# ---------------------------------------------------------------------------
# 9. Codex timeout maps to error
# ---------------------------------------------------------------------------


def test_codex_timeout_maps_to_error(adapter: LiveWorkerAdapter) -> None:
    run_result = _make_run_result(exit_code=124, timed_out=True)

    with (
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.get_workers_runtime_config"
        ),
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.resolve_worker_for_step",
            return_value=_CODEX_SPEC,
        ),
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.test_worker",
            return_value=(True, "ok"),
        ),
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.run_environment_preflight",
            return_value=EnvironmentPreflightResult(ok=True, issues=()),
        ),
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.run_worker",
            return_value=run_result,
        ),
    ):
        result = adapter.run_step(task=_make_task(), step="implement", attempt=1)

    assert result.status == "error"
    assert "timed out" in (result.summary or "").lower()


# ---------------------------------------------------------------------------
# 10. Codex no-heartbeat maps to explicit stall error
# ---------------------------------------------------------------------------


def test_codex_no_heartbeat_maps_to_stalled_error(adapter: LiveWorkerAdapter) -> None:
    run_result = _make_run_result(exit_code=1, no_heartbeat=True)

    with (
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.get_workers_runtime_config"
        ),
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.resolve_worker_for_step",
            return_value=_CODEX_SPEC,
        ),
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.test_worker",
            return_value=(True, "ok"),
        ),
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.run_worker",
            return_value=run_result,
        ),
    ):
        result = adapter.run_step(task=_make_task(), step="implement", attempt=1)

    assert result.status == "error"
    assert "stalled" in (result.summary or "").lower()


# ---------------------------------------------------------------------------
# 8. Human-blocking issues map to dedicated status
# ---------------------------------------------------------------------------


def test_human_blocking_issues_map_to_human_blocked(adapter: LiveWorkerAdapter) -> None:
    run_result = _make_run_result(
        exit_code=0,
        human_blocking_issues=[{"summary": "Need production API token"}],
    )

    with (
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.get_workers_runtime_config"
        ),
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.resolve_worker_for_step",
            return_value=_CODEX_SPEC,
        ),
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.test_worker",
            return_value=(True, "ok"),
        ),
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.run_worker",
            return_value=run_result,
        ),
    ):
        result = adapter.run_step(task=_make_task(), step="implement", attempt=1)

    assert result.status == "human_blocked"
    assert result.human_blocking_issues is not None
    assert result.human_blocking_issues[0]["summary"] == "Need production API token"
    assert "human intervention required" in (result.summary or "").lower()


# ---------------------------------------------------------------------------
# 9. Ollama review parses findings
# ---------------------------------------------------------------------------


def test_ollama_review_parses_findings(adapter: LiveWorkerAdapter) -> None:
    response_json = '{"findings": [{"severity": "high", "category": "security", "summary": "SQL injection"}]}'
    run_result = _make_run_result(exit_code=0, response_text=response_json)

    with (
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.get_workers_runtime_config"
        ),
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.resolve_worker_for_step",
            return_value=_OLLAMA_SPEC,
        ),
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.test_worker",
            return_value=(True, "ok"),
        ),
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.run_worker",
            return_value=run_result,
        ),
    ):
        result = adapter.run_step(task=_make_task(), step="review", attempt=1)

    assert result.status == "ok"
    assert result.findings is not None
    assert len(result.findings) == 1
    assert result.findings[0]["severity"] == "high"
    assert result.findings[0]["summary"] == "SQL injection"
    assert result.findings[0]["category"] == "security"


def test_ollama_review_normalizes_findings(adapter: LiveWorkerAdapter) -> None:
    response_json = (
        '{"findings": ['
        '{"severity": "info", "category": "style", "summary": "Looks great"},'
        '{"severity": "urgent", "category": "bug", "summary": "Breaks output", "line": "0"}'
        ']}'
    )
    run_result = _make_run_result(exit_code=0, response_text=response_json)

    with (
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.get_workers_runtime_config"
        ),
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.resolve_worker_for_step",
            return_value=_OLLAMA_SPEC,
        ),
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.test_worker",
            return_value=(True, "ok"),
        ),
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.run_worker",
            return_value=run_result,
        ),
    ):
        result = adapter.run_step(task=_make_task(), step="review", attempt=1)

    assert result.status == "ok"
    assert result.findings is not None
    assert len(result.findings) == 1
    finding = result.findings[0]
    assert finding["severity"] == "medium"  # unknown -> medium
    assert finding["category"] == "other"   # unknown -> other
    assert finding["line"] is None          # non-positive -> None
    assert finding["status"] == "open"      # default


# ---------------------------------------------------------------------------
# 10. Ollama generate_tasks parses tasks
# ---------------------------------------------------------------------------


def test_ollama_generate_tasks_parses_tasks(adapter: LiveWorkerAdapter) -> None:
    response_json = '{"tasks": [{"id":"t1","title": "Add tests", "description": "Write unit tests", "task_type": "feature", "priority": "P1", "depends_on": []}]}'
    run_result = _make_run_result(exit_code=0, response_text=response_json)

    with (
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.get_workers_runtime_config"
        ),
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.resolve_worker_for_step",
            return_value=_OLLAMA_SPEC,
        ),
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.test_worker",
            return_value=(True, "ok"),
        ),
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.run_worker",
            return_value=run_result,
        ),
    ):
        result = adapter.run_step(task=_make_task(), step="generate_tasks", attempt=1)

    assert result.status == "ok"
    assert result.generated_tasks is not None
    assert len(result.generated_tasks) == 1
    assert result.generated_tasks[0]["title"] == "Add tests"
    assert result.generated_tasks[0]["id"] == "t1"


# ---------------------------------------------------------------------------
# 11. Ollama implement extracts summary
# ---------------------------------------------------------------------------


def test_ollama_implement_extracts_summary(adapter: LiveWorkerAdapter) -> None:
    response_json = '{"patch": "--- a/file.py\\n+++ b/file.py", "summary": "Added error handling"}'
    run_result = _make_run_result(exit_code=0, response_text=response_json)

    with (
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.get_workers_runtime_config"
        ),
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.resolve_worker_for_step",
            return_value=_OLLAMA_SPEC,
        ),
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.test_worker",
            return_value=(True, "ok"),
        ),
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.run_worker",
            return_value=run_result,
        ),
    ):
        result = adapter.run_step(task=_make_task(), step="implement", attempt=1)

    assert result.status == "ok"
    assert result.summary == "Added error handling"


# ---------------------------------------------------------------------------
# 8. Prompt includes task context
# ---------------------------------------------------------------------------


def test_prompt_includes_task_context() -> None:
    task = _make_task(title="Fix login bug", description="Users cannot log in", task_type="bugfix")
    prompt = build_step_prompt(task=task, step="implement", attempt=1)

    assert "Fix login bug" in prompt
    assert "Users cannot log in" in prompt
    assert "bugfix" in prompt


# ---------------------------------------------------------------------------
# 8b. implement prompt keeps task context minimal
# ---------------------------------------------------------------------------


def test_implement_prompt_omits_priority_step_attempt() -> None:
    task = _make_task()
    prompt = build_step_prompt(task=task, step="implement", attempt=2)

    assert "Priority:" not in prompt
    assert "Step:" not in prompt
    assert "Attempt:" not in prompt


def test_review_prompt_omits_priority_and_step() -> None:
    task = _make_task()
    prompt = build_step_prompt(task=task, step="review", attempt=2)

    assert "Priority:" not in prompt
    assert "Step:" not in prompt
    assert "Attempt: 2" in prompt


def test_verify_prompt_omits_priority_and_step() -> None:
    task = _make_task()
    prompt = build_step_prompt(task=task, step="verify", attempt=2)

    assert "Priority:" not in prompt
    assert "Step:" not in prompt
    assert "Attempt: 2" in prompt


def test_diagnose_prompt_omits_priority_and_step() -> None:
    task = _make_task(task_type="bug")
    prompt = build_step_prompt(task=task, step="diagnose", attempt=1)

    assert "Priority:" not in prompt
    assert "Step:" not in prompt


def test_generate_tasks_prompt_omits_priority_and_step() -> None:
    task = _make_task(task_type="initiative_plan")
    prompt = build_step_prompt(task=task, step="generate_tasks", attempt=1)

    assert "Priority:" not in prompt
    assert "Step:" not in prompt


def test_docs_implement_uses_dedicated_docs_prompt() -> None:
    task = _make_task(task_type="docs")
    prompt = build_step_prompt(task=task, step="implement", attempt=1)

    assert "Implement documentation changes completely and accurately" in prompt
    assert "Implement the task completely and safely" not in prompt


def test_feature_implement_keeps_default_implement_prompt() -> None:
    task = _make_task(task_type="feature")
    prompt = build_step_prompt(task=task, step="implement", attempt=1)

    assert "Implement the task completely and safely" in prompt
    assert "Implement documentation changes completely and accurately" not in prompt


def test_build_step_prompt_applies_step_override() -> None:
    task = _make_task(task_type="feature")
    prompt = build_step_prompt(
        task=task,
        step="implement",
        attempt=1,
        prompt_overrides={"implement": "Custom implementation prompt override."},
    )

    assert "Custom implementation prompt override." in prompt
    assert "Implement the task completely and safely" not in prompt


def test_build_step_prompt_ignores_unrelated_override() -> None:
    task = _make_task(task_type="feature")
    prompt = build_step_prompt(
        task=task,
        step="implement",
        attempt=1,
        prompt_overrides={"verify": "Only for verify."},
    )

    assert "Only for verify." not in prompt
    assert "Implement the task completely and safely" in prompt


def test_build_step_prompt_appends_step_injection() -> None:
    task = _make_task(task_type="feature")
    prompt = build_step_prompt(
        task=task,
        step="implement",
        attempt=1,
        prompt_injections={"implement": "Preserve public API compatibility."},
    )

    assert "## Project-configured step injection" in prompt
    assert "Preserve public API compatibility." in prompt
    assert "Implement the task completely and safely" in prompt


def test_build_step_prompt_merge_retry_context() -> None:
    task = _make_task(
        task_type="feature",
        metadata={
            "merge_conflict_files": {
                "auth.py": "<<<<<<< HEAD\nold\n=======\nnew\n>>>>>>> task-xyz",
            },
            "merge_conflict_attempt": 2,
            "merge_conflict_max_attempts": 3,
            "merge_conflict_previous_error": "Worker reported success but unresolved merge entries remain.",
        },
    )
    prompt = build_step_prompt(task=task, step="resolve_merge", attempt=2)

    assert "## Retry context" in prompt
    assert "Attempt: 2 of 3" in prompt
    assert "Previous attempt error:" in prompt
    assert "unresolved merge entries remain" in prompt
    assert "remove all conflict markers" in prompt.lower()


def test_implement_prompt_omits_review_history() -> None:
    task = _make_task(metadata={
        "review_history": [
            {"attempt": 1, "decision": "changes_requested", "findings": [{"severity": "high", "summary": "x", "status": "open"}]}
        ]
    })
    prompt = build_step_prompt(task=task, step="implement", attempt=1)

    assert "## Previous review cycle history" not in prompt


# ---------------------------------------------------------------------------
# 9. implement_fix prompt includes remediation payload
# ---------------------------------------------------------------------------


def test_prompt_fix_includes_review_findings_injection() -> None:
    findings = [
        {"severity": "high", "summary": "Missing null check", "file": "auth.py", "line": 42}
    ]
    task = _make_task(metadata={"review_findings": findings})
    prompt = build_step_prompt(task=task, step="implement_fix", attempt=2)

    assert "## Issues to fix" in prompt
    assert "### Review findings to address" in prompt
    assert "Missing null check" in prompt
    assert "auth.py" in prompt
    assert "Priority:" not in prompt
    assert "Step:" not in prompt
    assert "Attempt: 2" in prompt


# ---------------------------------------------------------------------------
# JSON extraction helpers
# ---------------------------------------------------------------------------


def test_extract_json_plain() -> None:
    assert _extract_json('{"status": "ok"}') == {"status": "ok"}


def test_extract_json_with_markdown_fences() -> None:
    text = "```json\n{\"status\": \"ok\"}\n```"
    assert _extract_json(text) == {"status": "ok"}


def test_extract_json_with_surrounding_text() -> None:
    text = "Here is the result: {\"status\": \"ok\"} -- done"
    assert _extract_json(text) == {"status": "ok"}


def test_extract_json_invalid_returns_none() -> None:
    assert _extract_json("no json here") is None


def test_extract_json_value_array_from_markdown_fence() -> None:
    text = "```json\n[{\"title\":\"A\"}]\n```"
    parsed = _extract_json_value(text)
    assert isinstance(parsed, list)
    assert parsed[0]["title"] == "A"


def test_normalize_planning_text_strips_wrapper_and_followup() -> None:
    text = (
        "The plan has been prepared. Here's a summary of the key refinement.\n\n"
        "## Refined Plan\n"
        "- Keep timers as absolute timestamps.\n"
        "- Recompute on visibility change.\n\n"
        "---\n\n"
        "Should I write the full PLAN.md to the repo?"
    )
    cleaned = _normalize_planning_text(text)
    assert cleaned.startswith("## Refined Plan")
    assert "The plan has been prepared" not in cleaned
    assert "Should I write the full PLAN.md" not in cleaned


# ---------------------------------------------------------------------------
# Prompt adds JSON schema suffix for structured-critical steps across providers
# ---------------------------------------------------------------------------


def test_prompt_review_includes_json_schema_contract() -> None:
    task = _make_task()
    prompt = build_step_prompt(task=task, step="review", attempt=1)
    assert "Respond with valid JSON" in prompt
    assert "findings" in prompt


def test_prompt_review_includes_json_schema_contract_stable() -> None:
    task = _make_task()
    prompt = build_step_prompt(task=task, step="review", attempt=1)
    assert "Respond with valid JSON" in prompt


def test_prompt_planning_includes_shared_output_format() -> None:
    task = _make_task()
    prompt = build_step_prompt(task=task, step="plan_refine", attempt=1)
    assert "Output format (use these exact section headings)" in prompt
    assert "## Objective" in prompt
    assert "## Verification Plan" in prompt
    assert "Return the full rewritten plan, not a delta summary." in prompt


def test_initiative_plan_uses_dedicated_prompt_without_impl_plan_format() -> None:
    task = _make_task(task_type="initiative_plan")
    prompt = build_step_prompt(task=task, step="initiative_plan", attempt=1)
    assert "Create an initiative-level delivery plan" in prompt
    assert "## Initiative Objective" in prompt
    assert "## Task Decomposition Guidance" in prompt
    assert "## Implementation Phases" not in prompt


def test_initiative_plan_does_not_inject_analyze_output() -> None:
    task = _make_task(
        task_type="initiative_plan",
        metadata={"step_outputs": {"analyze": "Evidence: three auth codepaths and duplicated policy checks."}},
    )
    prompt = build_step_prompt(task=task, step="initiative_plan", attempt=1)
    assert "## Output from prior" not in prompt
    assert "three auth codepaths" not in prompt


def test_initiative_plan_refine_uses_dedicated_prompt_and_fields() -> None:
    task = _make_task(
        task_type="initiative_plan",
        metadata={
            "initiative_plan_refine_base": "Base initiative plan body",
            "initiative_plan_refine_feedback": "Reorder workstreams by risk",
            "initiative_plan_refine_instructions": "Keep scope unchanged",
        },
    )
    prompt = build_step_prompt(task=task, step="initiative_plan_refine", attempt=1)
    assert "Refine the existing initiative plan" in prompt
    assert "## Base initiative plan" in prompt
    assert "Base initiative plan body" in prompt
    assert "Reorder workstreams by risk" in prompt


def test_prompt_ollama_diagnose_includes_diagnosis_schema() -> None:
    task = _make_task(task_type="bug")
    prompt = build_step_prompt(task=task, step="diagnose", attempt=1)
    assert "Respond with valid JSON" not in prompt


def test_prompt_ollama_initiative_plan_includes_schema_override() -> None:
    task = _make_task(task_type="initiative_plan")
    prompt = build_step_prompt(task=task, step="initiative_plan", attempt=1)
    assert "Respond with valid JSON" not in prompt


# ---------------------------------------------------------------------------
# Worker execution exception returns error
# ---------------------------------------------------------------------------


def test_worker_exception_returns_error(adapter: LiveWorkerAdapter) -> None:
    """When run_worker raises, return error — don't silently succeed."""
    with (
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.get_workers_runtime_config"
        ),
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.resolve_worker_for_step",
            return_value=_CODEX_SPEC,
        ),
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.test_worker",
            return_value=(True, "ok"),
        ),
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.run_worker",
            side_effect=RuntimeError("Codex crashed"),
        ),
    ):
        result = adapter.run_step(task=_make_task(), step="implement", attempt=1)

    assert result.status == "error"
    assert "Codex crashed" in (result.summary or "")


# ---------------------------------------------------------------------------
# Ollama verify step maps pass/fail
# ---------------------------------------------------------------------------


def test_ollama_verify_pass(adapter: LiveWorkerAdapter) -> None:
    response_json = '{"status": "pass", "summary": "All tests passed"}'
    run_result = _make_run_result(exit_code=0, response_text=response_json)

    with (
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.get_workers_runtime_config"
        ),
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.resolve_worker_for_step",
            return_value=_OLLAMA_SPEC,
        ),
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.test_worker",
            return_value=(True, "ok"),
        ),
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.run_worker",
            return_value=run_result,
        ),
    ):
        result = adapter.run_step(task=_make_task(), step="verify", attempt=1)

    assert result.status == "ok"
    assert result.summary == "All tests passed"


def test_ollama_verify_fail(adapter: LiveWorkerAdapter) -> None:
    response_json = '{"status": "fail", "summary": "3 tests failed"}'
    run_result = _make_run_result(exit_code=0, response_text=response_json)

    with (
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.get_workers_runtime_config"
        ),
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.resolve_worker_for_step",
            return_value=_OLLAMA_SPEC,
        ),
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.test_worker",
            return_value=(True, "ok"),
        ),
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.run_worker",
            return_value=run_result,
        ),
    ):
        result = adapter.run_step(task=_make_task(), step="verify", attempt=1)

    assert result.status == "error"
    assert result.summary == "3 tests failed"


def test_verify_formatter_includes_reason_code_on_fail(adapter: LiveWorkerAdapter) -> None:
    raw_result = _make_run_result(exit_code=0, response_text="pytest output")
    fmt_result = _make_run_result(
        exit_code=0,
        response_text='{"status":"fail","reason_code":"type_error","summary":"mypy failed"}',
    )

    with (
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.get_workers_runtime_config"
        ),
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.resolve_worker_for_step",
            return_value=_CODEX_SPEC,
        ),
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.test_worker",
            return_value=(True, "ok"),
        ),
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.run_worker",
            side_effect=[raw_result, fmt_result],
        ),
    ):
        result = adapter.run_step(task=_make_task(), step="verify", attempt=1)

    assert result.status == "error"
    assert result.summary is not None
    assert "mypy failed" in result.summary
    assert "reason_code=type_error" in result.summary


def test_verify_formatter_environment_sets_note_with_reason(adapter: LiveWorkerAdapter) -> None:
    task = _make_task()
    raw_result = _make_run_result(exit_code=0, response_text="pytest output")
    fmt_result = _make_run_result(
        exit_code=0,
        response_text='{"status":"environment","reason_code":"permission_denied","summary":"Semaphore blocked"}',
    )

    with (
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.get_workers_runtime_config"
        ),
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.resolve_worker_for_step",
            return_value=_CODEX_SPEC,
        ),
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.test_worker",
            return_value=(True, "ok"),
        ),
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.run_worker",
            side_effect=[raw_result, fmt_result],
        ),
    ):
        result = adapter.run_step(task=task, step="verify", attempt=1)

    assert result.status == "ok"
    assert result.summary is not None
    assert "Semaphore blocked" in result.summary
    assert "reason_code=permission_denied" in result.summary
    assert task.metadata.get("verify_environment_note") == result.summary
    assert task.metadata.get("verify_reason_code") == "permission_denied"


def test_verify_environment_note_normalizes_missing_frontend_toolchain(
    adapter: LiveWorkerAdapter,
    container: Container,
) -> None:
    frontend = container.project_dir / "frontend"
    frontend.mkdir(parents=True, exist_ok=True)
    (frontend / "package.json").write_text('{"name":"frontend","scripts":{"build":"vite build"}}', encoding="utf-8")
    task = _make_task(description="Build gate: tsc --noEmit && vite build passes.")
    raw_result = _make_run_result(exit_code=0, response_text="verify output")
    fmt_result = _make_run_result(
        exit_code=0,
        response_text='{"status":"environment","reason_code":"tool_missing","summary":"npx tsc --noEmit && npx vite build failed"}',
    )

    with (
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.get_workers_runtime_config"
        ),
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.resolve_worker_for_step",
            return_value=_CODEX_SPEC,
        ),
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.test_worker",
            return_value=(True, "ok"),
        ),
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.run_worker",
            side_effect=[raw_result, fmt_result],
        ),
    ):
        result = adapter.run_step(task=task, step="verify", attempt=1)

    assert result.status == "ok"
    assert result.summary is not None
    assert "frontend toolchain" in result.summary.lower()
    assert "frontend/node_modules" in result.summary
    assert "env_kind=frontend_toolchain_missing" in result.summary
    assert task.metadata.get("verify_environment_kind") == "frontend_toolchain_missing"


def test_verify_json_environment_path_does_not_raise_and_sets_env_kind(
    adapter: LiveWorkerAdapter,
    container: Container,
) -> None:
    frontend = container.project_dir / "frontend"
    frontend.mkdir(parents=True, exist_ok=True)
    (frontend / "package.json").write_text('{"name":"frontend","scripts":{"build":"vite build"}}', encoding="utf-8")
    task = _make_task(description="Build gate: tsc --noEmit && vite build passes.")
    run_result = _make_run_result(
        exit_code=0,
        response_text='{"status":"environment","reason_code":"tool_missing","summary":"npx tsc --noEmit && npx vite build failed"}',
    )

    with (
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.get_workers_runtime_config"
        ),
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.resolve_worker_for_step",
            return_value=_CODEX_SPEC,
        ),
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.test_worker",
            return_value=(True, "ok"),
        ),
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.run_worker",
            return_value=run_result,
        ),
    ):
        result = adapter.run_step(task=task, step="verify", attempt=1)

    assert result.status == "ok"
    assert result.summary is not None
    assert "frontend/node_modules" in result.summary
    assert "env_kind=frontend_toolchain_missing" in result.summary
    assert task.metadata.get("verify_reason_code") == "tool_missing"
    assert task.metadata.get("verify_environment_kind") == "frontend_toolchain_missing"


def test_verify_environment_note_normalizes_prisma_missing_database_url(
    adapter: LiveWorkerAdapter,
    container: Container,
) -> None:
    prisma_dir = container.project_dir / "prisma"
    prisma_dir.mkdir(parents=True, exist_ok=True)
    (prisma_dir / "schema.prisma").write_text(
        'datasource db { provider = "postgresql" url = env("DATABASE_URL") }',
        encoding="utf-8",
    )
    (container.project_dir / "package.json").write_text(
        '{"name":"app","devDependencies":{"prisma":"^6.5.0"}}',
        encoding="utf-8",
    )
    task = _make_task(description="Run prisma migrate dev --name init and prisma validate.")
    run_result = _make_run_result(
        exit_code=0,
        response_text=(
            '{"status":"environment","reason_code":"infrastructure",'
            '"summary":"prisma validate failed: Environment variable not found: DATABASE_URL"}'
        ),
    )

    with (
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.get_workers_runtime_config"
        ),
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.resolve_worker_for_step",
            return_value=_CODEX_SPEC,
        ),
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.test_worker",
            return_value=(True, "ok"),
        ),
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.run_environment_preflight",
            return_value=EnvironmentPreflightResult(ok=True, issues=()),
        ),
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.run_worker",
            return_value=run_result,
        ),
    ):
        result = adapter.run_step(task=task, step="verify", attempt=1)

    assert result.status == "ok"
    assert result.summary is not None
    assert "database_url" in result.summary.lower()
    assert "env_kind=prisma_env_missing" in result.summary
    assert task.metadata.get("verify_environment_kind") == "prisma_env_missing"
    assert task.metadata.get("verify_reason_code") == "config_missing"


def test_verify_environment_note_normalizes_prisma_missing_local_cli(
    adapter: LiveWorkerAdapter,
    container: Container,
) -> None:
    cfg = container.config.load()
    workers_cfg = dict(cfg.get("workers") or {})
    workers_cfg["environment"] = {
        "auto_prepare": False,
        "max_auto_retries": 3,
        "capability_fallback": True,
        "required_capabilities_by_step": {},
    }
    cfg["workers"] = workers_cfg
    container.config.save(cfg)

    prisma_dir = container.project_dir / "prisma"
    prisma_dir.mkdir(parents=True, exist_ok=True)
    (prisma_dir / "schema.prisma").write_text('generator client { provider = "prisma-client-js" }', encoding="utf-8")
    (container.project_dir / "package.json").write_text(
        '{"name":"app","devDependencies":{"prisma":"^6.5.0"}}',
        encoding="utf-8",
    )
    task = _make_task(description="Validate Prisma migration workflow in verify.")
    run_result = _make_run_result(
        exit_code=0,
        response_text=(
            '{"status":"environment","reason_code":"infrastructure",'
            '"summary":"npx prisma migrate dev failed; getaddrinfo ENOTFOUND registry.npmjs.org"}'
        ),
    )

    with (
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.get_workers_runtime_config"
        ),
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.resolve_worker_for_step",
            return_value=_CODEX_SPEC,
        ),
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.test_worker",
            return_value=(True, "ok"),
        ),
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.run_environment_preflight",
            return_value=EnvironmentPreflightResult(ok=True, issues=()),
        ),
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.run_worker",
            return_value=run_result,
        ),
    ):
        result = adapter.run_step(task=task, step="verify", attempt=1)

    assert result.status == "ok"
    assert result.summary is not None
    assert "local prisma cli is missing" in result.summary.lower()
    assert "env_kind=prisma_cli_missing" in result.summary
    assert task.metadata.get("verify_environment_kind") == "prisma_cli_missing"
    assert task.metadata.get("verify_reason_code") == "tool_missing"


def test_task_generation_parses_top_level_array(adapter: LiveWorkerAdapter) -> None:
    response = "```json\n[{\"title\":\"Task A\",\"task_type\":\"feature\",\"priority\":\"P1\"}]\n```"
    run_result = _make_run_result(exit_code=0, response_text=response)

    with (
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.get_workers_runtime_config"
        ),
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.resolve_worker_for_step",
            return_value=_CODEX_SPEC,
        ),
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.test_worker",
            return_value=(True, "ok"),
        ),
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.run_worker",
            return_value=run_result,
        ),
    ):
        result = adapter.run_step(task=_make_task(), step="generate_tasks", attempt=1)

    assert result.status == "ok"
    assert isinstance(result.generated_tasks, list)
    assert result.generated_tasks[0]["title"] == "Task A"


# ---------------------------------------------------------------------------
# Preamble and guardrails in prompts
# ---------------------------------------------------------------------------


@pytest.mark.parametrize("step", ["plan", "implement", "review", "verify", "report"])
def test_prompt_includes_preamble(step: str) -> None:
    task = _make_task()
    prompt = build_step_prompt(task=task, step=step, attempt=1)
    assert "autonomous coding agent" in prompt
    assert "coordinator" in prompt
    assert "human-blocking issue" in prompt


@pytest.mark.parametrize("step", ["plan", "implement", "review", "verify", "report"])
def test_prompt_includes_guardrails(step: str) -> None:
    task = _make_task()
    prompt = build_step_prompt(task=task, step=step, attempt=1)
    assert "Do NOT commit" in prompt
    assert ".overdrive/" in prompt
    assert "suppress" in prompt.lower()


# ---------------------------------------------------------------------------
# Expanded category instructions
# ---------------------------------------------------------------------------


def test_planning_prompt_has_planning_rules() -> None:
    task = _make_task()
    prompt = build_step_prompt(task=task, step="plan", attempt=1)
    assert "independently testable" in prompt
    assert "does not modify" in prompt.lower()


def test_implementation_prompt_has_impl_rules() -> None:
    task = _make_task()
    prompt = build_step_prompt(task=task, step="implement", attempt=1)
    assert "Complete the step end-to-end" in prompt
    assert "source of truth" in prompt


def test_review_prompt_has_severity_guidance() -> None:
    task = _make_task()
    prompt = build_step_prompt(task=task, step="review", attempt=1)
    assert "severity" in prompt.lower()
    assert "acceptance criterion" in prompt.lower()
    assert "do not speculate" in prompt.lower()


def test_verification_prompt_has_testing_rules() -> None:
    task = _make_task()
    prompt = build_step_prompt(task=task, step="verify", attempt=1)
    assert "test" in prompt.lower()
    assert "Do not fabricate results" in prompt
    assert "Do not silently skip checks" in prompt


def test_profile_prompt_has_baseline_rules() -> None:
    task = _make_task(task_type="performance")
    prompt = build_step_prompt(task=task, step="profile", attempt=1)
    assert "performance baseline" in prompt.lower()
    assert "## Baseline Measurements" in prompt
    assert "## Benchmarking Inputs" in prompt


def test_summarize_prompt_is_distinct_from_report_prompt() -> None:
    task = _make_task()
    summarize_prompt = build_step_prompt(task=task, step="summarize", attempt=1)
    report_prompt = build_step_prompt(task=task, step="report", attempt=1)
    assert "concise delivery summary" in summarize_prompt
    assert "decision-ready report" in report_prompt


# ---------------------------------------------------------------------------
# Dependency analysis prompt includes preamble and guardrails
# ---------------------------------------------------------------------------


def test_dep_analysis_prompt_includes_preamble_and_guardrails() -> None:
    task = _make_task(
        metadata={
            "candidate_tasks": [
                {"id": "t1", "title": "Add auth", "task_type": "feature"},
            ],
        },
    )
    prompt = build_step_prompt(task=task, step="analyze_deps", attempt=1)
    assert "autonomous coding agent" in prompt
    assert "Do NOT commit" in prompt
    assert "If uncertain, omit the edge" in prompt
    assert "false negatives over false positives" in prompt
    assert "confidence (`high` or `medium`)" in prompt
    assert '"confidence": "high|medium"' in prompt
    assert '"evidence": "artifact reference"' in prompt
    prompt_ollama = build_step_prompt(task=task, step="analyze_deps", attempt=1)
    assert '"confidence": "high|medium"' in prompt_ollama
    assert '"evidence": "artifact reference"' in prompt_ollama


# ---------------------------------------------------------------------------
# Language standards injection
# ---------------------------------------------------------------------------


def test_implementation_prompt_includes_python_standards() -> None:
    task = _make_task()
    prompt = build_step_prompt(
        task=task, step="implement", attempt=1,
        project_languages=["python"],
    )
    assert "Style guidelines" in prompt
    assert "Python defaults" in prompt


def test_review_prompt_includes_typescript_standards() -> None:
    task = _make_task()
    prompt = build_step_prompt(
        task=task, step="review", attempt=1,
        project_languages=["typescript"],
    )
    assert "Style guidelines" in prompt
    assert "TypeScript defaults" in prompt


def test_prompt_no_language_when_none() -> None:
    task = _make_task()
    prompt = build_step_prompt(
        task=task, step="implement", attempt=1, project_languages=None,
    )
    assert "Python defaults" not in prompt


def test_language_not_injected_for_planning() -> None:
    task = _make_task()
    prompt = build_step_prompt(
        task=task, step="plan", attempt=1,
        project_languages=["python"],
    )
    assert "Style guidelines" not in prompt


def test_mixed_language_prompt_includes_all_standards() -> None:
    """Full-stack repo: both Python and TypeScript defaults are injected."""
    task = _make_task()
    prompt = build_step_prompt(
        task=task, step="implement", attempt=1,
        project_languages=["python", "typescript"],
    )
    assert "Python defaults" in prompt
    assert "TypeScript defaults" in prompt


# ---------------------------------------------------------------------------
# detect_project_languages
# ---------------------------------------------------------------------------


def test_detect_languages_python(tmp_path: Path) -> None:
    (tmp_path / "pyproject.toml").write_text("[build-system]")
    assert detect_project_languages(tmp_path) == ["python"]


def test_detect_languages_typescript(tmp_path: Path) -> None:
    (tmp_path / "tsconfig.json").write_text("{}")
    assert detect_project_languages(tmp_path) == ["typescript"]


def test_detect_languages_go(tmp_path: Path) -> None:
    (tmp_path / "go.mod").write_text("module example")
    assert detect_project_languages(tmp_path) == ["go"]


def test_detect_languages_rust(tmp_path: Path) -> None:
    (tmp_path / "Cargo.toml").write_text("[package]")
    assert detect_project_languages(tmp_path) == ["rust"]


def test_detect_languages_none(tmp_path: Path) -> None:
    assert detect_project_languages(tmp_path) == []


def test_detect_languages_mixed_python_typescript(tmp_path: Path) -> None:
    """Full-stack repo with both pyproject.toml and tsconfig.json."""
    (tmp_path / "pyproject.toml").write_text("[build-system]")
    (tmp_path / "tsconfig.json").write_text("{}")
    (tmp_path / "package.json").write_text("{}")
    langs = detect_project_languages(tmp_path)
    assert "python" in langs
    assert "typescript" in langs
    # JavaScript is subsumed by TypeScript
    assert "javascript" not in langs


def test_detect_languages_deduplicates(tmp_path: Path) -> None:
    """Both pyproject.toml and setup.py map to python — no duplicates."""
    (tmp_path / "pyproject.toml").write_text("[build-system]")
    (tmp_path / "setup.py").write_text("from setuptools import setup")
    assert detect_project_languages(tmp_path) == ["python"]


def test_detect_languages_typescript_subsumes_javascript(tmp_path: Path) -> None:
    """When tsconfig.json is present, package.json's 'javascript' is dropped."""
    (tmp_path / "tsconfig.json").write_text("{}")
    (tmp_path / "package.json").write_text("{}")
    assert detect_project_languages(tmp_path) == ["typescript"]


def test_detect_languages_javascript_alone(tmp_path: Path) -> None:
    """package.json without tsconfig.json → javascript."""
    (tmp_path / "package.json").write_text("{}")
    assert detect_project_languages(tmp_path) == ["javascript"]


# ---------------------------------------------------------------------------
# Project commands injection
# ---------------------------------------------------------------------------


def test_verification_prompt_includes_project_commands() -> None:
    task = _make_task()
    prompt = build_step_prompt(
        task=task, step="verify", attempt=1,
        project_languages=["python"],
        project_commands={"python": {"test": ".venv/bin/pytest -n auto", "lint": ".venv/bin/ruff check ."}},
    )
    assert "## Project commands" in prompt
    assert ".venv/bin/pytest -n auto" in prompt
    assert ".venv/bin/ruff check ." in prompt


def test_verification_prompt_includes_required_prisma_gates_when_requested() -> None:
    task = _make_task()
    prompt = build_step_prompt(
        task=task,
        step="verify",
        attempt=1,
        project_languages=["typescript"],
        project_commands={"typescript": {"test": "npm test"}},
        require_prisma_verify=True,
    )
    assert "## Required Prisma verification gates" in prompt
    assert "./node_modules/.bin/prisma validate" in prompt
    assert "./node_modules/.bin/prisma migrate deploy" in prompt


def test_verification_prompt_includes_required_verify_gates_when_requested() -> None:
    task = _make_task()
    prompt = build_step_prompt(
        task=task,
        step="verify",
        attempt=1,
        project_languages=["typescript"],
        project_commands={"typescript": {"test": "npm test"}},
        required_verify_commands=["npm --prefix frontend run build", "npm --prefix frontend run typecheck"],
    )
    assert "## Required verification gates" in prompt
    assert "npm --prefix frontend run build" in prompt
    assert "npm --prefix frontend run typecheck" in prompt


def test_detect_required_verify_commands_uses_workspace_scripts(tmp_path: Path) -> None:
    frontend = tmp_path / "frontend"
    (frontend / "src").mkdir(parents=True)
    (frontend / "package.json").write_text(
        '{"name":"frontend","scripts":{"build":"vite build","typecheck":"tsc --noEmit"}}',
        encoding="utf-8",
    )
    commands = _detect_required_verify_commands(
        tmp_path,
        changed_paths=["frontend/src/use-bar-queries.ts"],
    )
    assert commands == [
        "npm --prefix frontend run build",
        "npm --prefix frontend run typecheck",
    ]


def test_detect_required_verify_commands_empty_without_scripts(tmp_path: Path) -> None:
    frontend = tmp_path / "frontend"
    (frontend / "src").mkdir(parents=True)
    (frontend / "package.json").write_text('{"name":"frontend"}', encoding="utf-8")
    commands = _detect_required_verify_commands(
        tmp_path,
        changed_paths=["frontend/src/use-bar-queries.ts"],
    )
    assert commands == []


def test_inject_required_verify_commands_appends_to_typecheck() -> None:
    merged = _inject_required_verify_commands(
        {"typescript": {"typecheck": "npx tsc --noEmit"}},
        ["typescript"],
        ["npm --prefix frontend run build"],
    )
    assert (
        merged["typescript"]["typecheck"]
        == "npx tsc --noEmit && npm --prefix frontend run build"
    )


def test_implementation_prompt_includes_project_commands() -> None:
    task = _make_task()
    prompt = build_step_prompt(
        task=task, step="implement", attempt=1,
        project_languages=["python"],
        project_commands={"python": {"test": "pytest", "lint": "ruff check ."}},
    )
    assert "## Project commands" in prompt
    assert "pytest" in prompt


def test_prompt_no_commands_when_none() -> None:
    task = _make_task()
    prompt = build_step_prompt(
        task=task, step="verify", attempt=1,
        project_languages=["python"],
        project_commands=None,
    )
    assert "Project commands" not in prompt


def test_prompt_partial_commands() -> None:
    """Only python test set → only test line appears, no lint/typecheck/format."""
    task = _make_task()
    prompt = build_step_prompt(
        task=task, step="verify", attempt=1,
        project_languages=["python"],
        project_commands={"python": {"test": "pytest -x"}},
    )
    assert "## Project commands" in prompt
    assert "Test:" in prompt
    assert "Lint:" not in prompt


def test_multi_language_commands() -> None:
    task = _make_task()
    prompt = build_step_prompt(
        task=task, step="verify", attempt=1,
        project_languages=["python", "typescript"],
        project_commands={
            "python": {"test": "pytest", "lint": "ruff check ."},
            "typescript": {"test": "npm test", "lint": "npx eslint ."},
        },
    )
    assert "### Python" in prompt
    assert "### TypeScript" in prompt
    assert "pytest" in prompt
    assert "npm test" in prompt


def test_commands_filtered_by_detected_languages() -> None:
    """Config has python+go but only python detected → only python shown."""
    task = _make_task()
    prompt = build_step_prompt(
        task=task, step="verify", attempt=1,
        project_languages=["python"],
        project_commands={
            "python": {"test": "pytest"},
            "go": {"test": "go test ./..."},
        },
    )
    assert "pytest" in prompt
    assert "go test" not in prompt


def test_commands_not_injected_for_planning() -> None:
    task = _make_task()
    prompt = build_step_prompt(
        task=task, step="plan", attempt=1,
        project_languages=["python"],
        project_commands={"python": {"test": "pytest"}},
    )
    assert "Project commands" not in prompt


def test_single_language_omits_subheading() -> None:
    """Single language uses flat list (no ### Python heading)."""
    task = _make_task()
    prompt = build_step_prompt(
        task=task, step="verify", attempt=1,
        project_languages=["python"],
        project_commands={"python": {"test": "pytest", "lint": "ruff check ."}},
    )
    assert "## Project commands" in prompt
    assert "### Python" not in prompt


def test_commands_not_injected_for_review() -> None:
    """Review step gets style guidelines but not project commands."""
    task = _make_task()
    prompt = build_step_prompt(
        task=task, step="review", attempt=1,
        project_languages=["python"],
        project_commands={"python": {"test": "pytest"}},
    )
    assert "Style guidelines" in prompt
    assert "Project commands" not in prompt


def test_commands_empty_strings_skipped() -> None:
    """Empty or whitespace-only command values are not rendered."""
    task = _make_task()
    prompt = build_step_prompt(
        task=task, step="verify", attempt=1,
        project_languages=["python"],
        project_commands={"python": {"test": "", "lint": "   ", "typecheck": "mypy ."}},
    )
    assert "## Project commands" in prompt
    assert "Typecheck:" in prompt
    assert "Test:" not in prompt
    assert "Lint:" not in prompt


def test_multi_language_display_names_use_correct_casing() -> None:
    """TypeScript/JavaScript get proper casing, not .title() casing."""
    task = _make_task()
    prompt = build_step_prompt(
        task=task, step="verify", attempt=1,
        project_languages=["typescript", "javascript"],
        project_commands={
            "typescript": {"test": "npm test"},
            "javascript": {"lint": "eslint ."},
        },
    )
    assert "### TypeScript" in prompt
    assert "### JavaScript" in prompt


def test_commands_non_string_values_skipped() -> None:
    """Non-string command values in config are silently skipped."""
    task = _make_task()
    prompt = build_step_prompt(
        task=task, step="verify", attempt=1,
        project_languages=["python"],
        project_commands={"python": {"test": 42, "lint": "ruff check ."}},  # type: ignore[dict-item]
    )
    assert "## Project commands" in prompt
    assert "Lint:" in prompt
    assert "Test:" not in prompt


def test_commands_non_dict_language_entry_skipped() -> None:
    """A non-dict language entry in project_commands is skipped."""
    task = _make_task()
    prompt = build_step_prompt(
        task=task, step="verify", attempt=1,
        project_languages=["python", "go"],
        project_commands={"python": {"test": "pytest"}, "go": "not a dict"},  # type: ignore[dict-item]
    )
    assert "## Project commands" in prompt
    assert "pytest" in prompt
    # "go" entry silently skipped — only one language block, so no subheading
    assert "### Go" not in prompt


def test_run_step_reads_project_commands_from_config(container: Container, adapter: LiveWorkerAdapter) -> None:
    """run_step reads project.commands from config and passes them to the prompt."""
    # Write project commands to config
    cfg = container.config.load()
    cfg["project"] = {"commands": {"python": {"test": ".venv/bin/pytest -x"}}}
    container.config.save(cfg)

    # Create a pyproject.toml so python is detected
    (container.project_dir / "pyproject.toml").write_text("[build-system]")

    captured_prompt = {}
    run_result = _make_run_result(exit_code=0)

    def _capture_run_worker(**kwargs):
        captured_prompt["text"] = kwargs["prompt"]
        return run_result

    with (
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.get_workers_runtime_config"
        ),
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.resolve_worker_for_step",
            return_value=_CODEX_SPEC,
        ),
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.test_worker",
            return_value=(True, "ok"),
        ),
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.run_environment_preflight",
            return_value=EnvironmentPreflightResult(ok=True, issues=()),
        ),
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.run_worker",
            side_effect=_capture_run_worker,
        ),
    ):
        result = adapter.run_step(task=_make_task(), step="verify", attempt=1)

    assert result.status == "ok"
    prompt = captured_prompt["text"]
    assert "## Project commands" in prompt
    assert ".venv/bin/pytest -x" in prompt


def test_run_step_verify_with_prisma_schema_injects_prisma_command_hints(
    container: Container,
    adapter: LiveWorkerAdapter,
) -> None:
    (container.project_dir / "tsconfig.json").write_text("{}", encoding="utf-8")
    prisma_dir = container.project_dir / "prisma"
    prisma_dir.mkdir(parents=True, exist_ok=True)
    (prisma_dir / "schema.prisma").write_text(
        "generator client { provider = \"prisma-client-js\" }",
        encoding="utf-8",
    )

    captured_prompt: dict[str, str] = {}
    run_result = _make_run_result(exit_code=0)

    def _capture_run_worker(**kwargs):
        captured_prompt["text"] = kwargs["prompt"]
        return run_result

    with (
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.get_workers_runtime_config"
        ),
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.resolve_worker_for_step",
            return_value=_CODEX_SPEC,
        ),
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.test_worker",
            return_value=(True, "ok"),
        ),
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.run_environment_preflight",
            return_value=EnvironmentPreflightResult(ok=True, issues=()),
        ),
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.run_worker",
            side_effect=_capture_run_worker,
        ),
    ):
        result = adapter.run_step(task=_make_task(), step="verify", attempt=1)

    assert result.status == "ok"
    assert "## Required Prisma verification gates" in captured_prompt["text"]
    assert "./node_modules/.bin/prisma migrate deploy" in captured_prompt["text"]


def test_run_step_verify_includes_required_workspace_gates(
    container: Container,
    adapter: LiveWorkerAdapter,
) -> None:
    (container.project_dir / "tsconfig.json").write_text("{}", encoding="utf-8")
    captured_prompt: dict[str, str] = {}
    run_result = _make_run_result(exit_code=0)

    def _capture_run_worker(**kwargs):
        captured_prompt["text"] = kwargs["prompt"]
        return run_result

    with (
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.get_workers_runtime_config"
        ),
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.resolve_worker_for_step",
            return_value=_CODEX_SPEC,
        ),
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.test_worker",
            return_value=(True, "ok"),
        ),
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter._detect_required_verify_commands",
            return_value=["npm --prefix frontend run build"],
        ),
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.run_worker",
            side_effect=_capture_run_worker,
        ),
    ):
        result = adapter.run_step(task=_make_task(), step="verify", attempt=1)

    assert result.status == "ok"
    assert "## Required verification gates" in captured_prompt["text"]
    assert "npm --prefix frontend run build" in captured_prompt["text"]


def test_run_step_reads_prompt_overrides_from_config(container: Container, adapter: LiveWorkerAdapter) -> None:
    """run_step applies project.prompt_overrides from runtime settings."""
    cfg = container.config.load()
    cfg["project"] = {"prompt_overrides": {"verify": "Custom verify injection."}}
    container.config.save(cfg)

    captured_prompt = {}
    run_result = _make_run_result(exit_code=0)

    def _capture_run_worker(**kwargs):
        captured_prompt["text"] = kwargs["prompt"]
        return run_result

    with (
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.get_workers_runtime_config"
        ),
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.resolve_worker_for_step",
            return_value=_CODEX_SPEC,
        ),
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.test_worker",
            return_value=(True, "ok"),
        ),
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.run_worker",
            side_effect=_capture_run_worker,
        ),
    ):
        result = adapter.run_step(task=_make_task(), step="verify", attempt=1)

    assert result.status == "ok"
    assert "Custom verify injection." in captured_prompt["text"]


def test_run_step_reads_prompt_injections_from_config(container: Container, adapter: LiveWorkerAdapter) -> None:
    """run_step appends project.prompt_injections from runtime settings."""
    cfg = container.config.load()
    cfg["project"] = {"prompt_injections": {"verify": "Prefer quick smoke checks first."}}
    container.config.save(cfg)

    captured_prompt = {}
    run_result = _make_run_result(exit_code=0)

    def _capture_run_worker(**kwargs):
        captured_prompt["text"] = kwargs["prompt"]
        return run_result

    with (
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.get_workers_runtime_config"
        ),
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.resolve_worker_for_step",
            return_value=_CODEX_SPEC,
        ),
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.test_worker",
            return_value=(True, "ok"),
        ),
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.run_worker",
            side_effect=_capture_run_worker,
        ),
    ):
        result = adapter.run_step(task=_make_task(), step="verify", attempt=1)

    assert result.status == "ok"
    assert "## Project-configured step injection" in captured_prompt["text"]
    assert "Prefer quick smoke checks first." in captured_prompt["text"]


# ---------------------------------------------------------------------------
# Step output injection in build_step_prompt
# ---------------------------------------------------------------------------


class TestStepOutputInjection:
    """Tests for prior step output injection into build_step_prompt."""

    def test_plan_output_not_injected_for_implementation(self) -> None:
        """Test that plan output not injected for implementation."""
        task = _make_task(metadata={"step_outputs": {"plan": "Use adapter pattern."}})
        prompt = build_step_prompt(task=task, step="implement", attempt=1)
        assert "## Output from prior 'plan' step" not in prompt
        assert "Use adapter pattern." not in prompt

    def test_analyze_and_plan_not_injected_for_implementation(self) -> None:
        """Test that analyze and plan not injected for implementation."""
        task = _make_task(metadata={
            "step_outputs": {"analyze": "Found 3 modules.", "plan": "Refactor X."}
        })
        prompt = build_step_prompt(task=task, step="implement", attempt=1)
        assert "## Output from prior 'plan' step" not in prompt
        assert "## Output from prior 'analyze' step" not in prompt
        assert "Found 3 modules." not in prompt
        assert "Refactor X." not in prompt

    def test_verify_output_injected_for_review(self) -> None:
        """Test that verify output injected for review."""
        task = _make_task(metadata={"step_outputs": {"verify": "All tests pass."}})
        prompt = build_step_prompt(task=task, step="review", attempt=1)
        assert "## Output from prior 'verify' step" in prompt
        assert "All tests pass." in prompt

    def test_all_outputs_injected_for_reporting(self) -> None:
        """Test that all outputs injected for reporting."""
        task = _make_task(metadata={
            "step_outputs": {"gather": "Data collected.", "analyze": "Results look good."}
        })
        prompt = build_step_prompt(task=task, step="report", attempt=1)
        assert "## Output from prior 'gather' step" in prompt
        assert "## Output from prior 'analyze' step" in prompt

    def test_security_report_injects_only_scan_outputs(self) -> None:
        """Test that security report injects only scan outputs."""
        task = _make_task(
            task_type="security",
            metadata={
                "step_outputs": {
                    "scan_deps": "dep vuln",
                    "scan_code": "code vuln",
                    "analyze": "irrelevant synthesis",
                }
            },
        )
        prompt = build_step_prompt(task=task, step="report", attempt=1)
        assert "## Output from prior 'scan_deps' step" in prompt
        assert "## Output from prior 'scan_code' step" in prompt
        assert "## Output from prior 'analyze' step" not in prompt

    def test_plan_injected_for_task_generation(self) -> None:
        """Test that plan injected for task generation."""
        task = _make_task(metadata={"step_outputs": {"plan": "Split into 3 subtasks."}})
        prompt = build_step_prompt(task=task, step="generate_tasks", attempt=1)
        assert "## Output from prior 'plan' step" in prompt
        assert "Split into 3 subtasks." in prompt

    def test_initiative_plan_injected_for_task_generation(self) -> None:
        """Test that initiative plan injected for task generation."""
        task = _make_task(
            task_type="initiative_plan",
            metadata={"step_outputs": {"initiative_plan": "Sequence workstreams by platform risk."}},
        )
        prompt = build_step_prompt(task=task, step="generate_tasks", attempt=1)
        assert "## Output from prior 'initiative_plan' step" in prompt
        assert "Sequence workstreams by platform risk." in prompt
        assert "## Output from prior 'plan' step" not in prompt
        assert "## Output from prior 'analyze' step" not in prompt

    def test_security_generate_tasks_uses_report_and_scan_outputs(self) -> None:
        """Test that security generate tasks uses report and scan outputs."""
        task = _make_task(
            task_type="security",
            metadata={
                "step_outputs": {
                    "report": "Top risk is vulnerable openssl chain.",
                    "scan_deps": "openssl CVE",
                    "scan_code": "unsafe TLS config",
                    "plan": "stale inline plan text should be ignored",
                }
            },
        )
        prompt = build_step_prompt(task=task, step="generate_tasks", attempt=1)
        assert "## Output from prior 'report' step" in prompt
        assert "## Output from prior 'scan_deps' step" in prompt
        assert "## Output from prior 'scan_code' step" in prompt
        assert "## Output from prior 'plan' step" not in prompt
        assert "## Remediation task generation focus" in prompt

    def test_no_prior_output_injected_for_diagnose(self) -> None:
        """Diagnose is the first step in bug_fix; no prior outputs are injected."""
        task = _make_task(
            task_type="bug",
            metadata={"step_outputs": {"plan": "Old plan text"}},
        )
        prompt = build_step_prompt(task=task, step="diagnose", attempt=1)
        assert "## Output from prior" not in prompt

    def test_no_injection_for_verification(self) -> None:
        """Test that no injection for verification."""
        task = _make_task(metadata={"step_outputs": {"plan": "Some plan."}})
        prompt = build_step_prompt(task=task, step="verify", attempt=1)
        assert "## Output from prior" not in prompt

    def test_no_injection_for_planning(self) -> None:
        """Test that no injection for planning."""
        task = _make_task(metadata={"step_outputs": {"gather": "Info gathered."}})
        prompt = build_step_prompt(task=task, step="plan", attempt=1)
        assert "## Output from prior" not in prompt

    def test_no_analyze_injection_for_plan(self) -> None:
        """Test that no analyze injection for plan."""
        task = _make_task(metadata={"step_outputs": {"analyze": "Analysis summary."}})
        prompt = build_step_prompt(task=task, step="plan", attempt=1)
        assert "## Output from prior" not in prompt
        assert "Analysis summary." not in prompt

    def test_no_injection_for_initiative_plan(self) -> None:
        """Test that no injection for initiative plan."""
        task = _make_task(
            task_type="initiative_plan",
            metadata={"step_outputs": {"analyze": "Analysis text."}},
        )
        prompt = build_step_prompt(task=task, step="initiative_plan", attempt=1)
        assert "## Output from prior" not in prompt
        assert "Analysis text." not in prompt

    def test_empty_values_skipped(self) -> None:
        """Test that empty values skipped."""
        task = _make_task(metadata={"step_outputs": {"plan": "", "analyze": "   "}})
        prompt = build_step_prompt(task=task, step="implement", attempt=1)
        assert "## Output from prior" not in prompt

    def test_missing_step_outputs_no_crash(self) -> None:
        """Test that missing step outputs no crash."""
        task = _make_task(metadata={})
        prompt = build_step_prompt(task=task, step="implement", attempt=1)
        assert "## Output from prior" not in prompt

    def test_coexists_with_review_findings(self) -> None:
        """Test that coexists with review findings."""
        task = _make_task(metadata={
            "step_outputs": {"plan": "Fix the bug."},
            "review_findings": [{"severity": "high", "summary": "Missing null check"}],
        })
        prompt = build_step_prompt(task=task, step="implement_fix", attempt=1)
        assert "## Output from prior 'plan' step" not in prompt
        assert "Fix the bug." not in prompt
        assert "Missing null check" in prompt

    def test_fix_includes_verify_failure_and_output(self) -> None:
        """Test that fix includes verify failure and output."""
        task = _make_task(metadata={
            "verify_failure": "pytest failed",
            "verify_reason_code": "assertion_failure",
            "verify_output": "E   AssertionError: expected 1",
        })
        prompt = build_step_prompt(task=task, step="implement_fix", attempt=1)
        assert "## Issues to fix" in prompt
        assert "### Verification failure" in prompt
        assert "pytest failed" in prompt
        assert "### Verification reason code" in prompt
        assert "assertion_failure" in prompt
        assert "### Verification output (test/lint/typecheck logs)" in prompt
        assert "AssertionError" in prompt

    def test_fix_includes_retry_and_requested_guidance(self) -> None:
        """Test that fix includes previous error and unified human guidance."""
        task = _make_task(metadata={
            "retry_guidance": {"previous_error": "timed out", "guidance": "narrow scope"},
            "active_human_guidance": {
                "id": "hg-1",
                "source": "review_request_changes",
                "guidance": "add edge-case test",
                "created_at": "2026-01-01T00:00:00+00:00",
                "target_step": "implement",
                "fallback_step": "implement_fix",
                "consumed": False,
            },
        })
        prompt = build_step_prompt(task=task, step="implement_fix", attempt=2)
        assert "## Issues to fix" in prompt
        assert "### Previous attempt error" in prompt
        assert "timed out" in prompt
        assert "## Human guidance" in prompt
        assert "Source: review request changes." in prompt
        assert "edge-case test" in prompt

    def test_fix_prompt_includes_restricted_scope_contract(self) -> None:
        """Restricted scope contract should be injected into implement_fix prompts."""
        task = _make_task(
            metadata={
                "scope_contract": {
                    "mode": "restricted",
                    "allowed_globs": ["strategy_miner/ibkr/**", "tests/test_ibkr_smoke.py"],
                    "forbidden_globs": ["strategy_miner/pipeline/**"],
                    "baseline_ref": "abc123",
                }
            }
        )
        prompt = build_step_prompt(task=task, step="implement_fix", attempt=1)
        assert "## Scope contract" in prompt
        assert "strategy_miner/ibkr/**" in prompt
        assert "strategy_miner/pipeline/**" in prompt
        assert "Baseline ref: `abc123`" in prompt

    def test_human_guidance_injects_for_target_plan_step(self) -> None:
        """Guidance targeted at plan should appear in plan prompt."""
        task = _make_task(metadata={
            "active_human_guidance": {
                "id": "hg-plan",
                "source": "gate_request_changes",
                "guidance": "Tighten plan scope and split migration.",
                "created_at": "2026-01-01T00:00:00+00:00",
                "target_step": "plan",
                "fallback_step": "implement_fix",
                "gate": "before_implement",
                "consumed": False,
            }
        })
        prompt = build_step_prompt(task=task, step="plan", attempt=1)
        assert "## Human guidance" in prompt
        assert "Tighten plan scope and split migration." in prompt
        assert "Gate: before_implement" in prompt

    def test_human_guidance_injects_for_target_generate_tasks_step(self) -> None:
        """Guidance targeted at generate_tasks should appear there."""
        task = _make_task(
            task_type="plan_only",
            metadata={
                "active_human_guidance": {
                    "id": "hg-gen",
                    "source": "gate_request_changes",
                    "guidance": "Generate fewer but larger implementation tasks.",
                    "created_at": "2026-01-01T00:00:00+00:00",
                    "target_step": "generate_tasks",
                    "fallback_step": None,
                    "consumed": False,
                }
            },
        )
        prompt = build_step_prompt(task=task, step="generate_tasks", attempt=1)
        assert "## Human guidance" in prompt
        assert "Generate fewer but larger implementation tasks." in prompt

    def test_human_guidance_not_injected_after_consumption(self) -> None:
        """Consumed guidance should not be injected again."""
        task = _make_task(metadata={
            "active_human_guidance": {
                "id": "hg-consumed",
                "source": "retry",
                "guidance": "Retry with smaller refactor chunks.",
                "created_at": "2026-01-01T00:00:00+00:00",
                "target_step": "implement",
                "fallback_step": "implement_fix",
                "consumed": True,
                "consumed_at": "2026-01-01T00:01:00+00:00",
                "consumed_step": "implement",
            }
        })
        prompt = build_step_prompt(task=task, step="implement", attempt=2)
        assert "## Human guidance" not in prompt
        assert "Retry with smaller refactor chunks." not in prompt

    def test_legacy_requested_changes_promoted_into_active_guidance(self) -> None:
        """Legacy requested_changes should still inject via active guidance promotion."""
        task = _make_task(metadata={
            "requested_changes": {
                "ts": "2026-01-01T00:00:00+00:00",
                "guidance": "Start with test scaffolding before code changes.",
            },
            "retry_from_step": "implement",
        })
        prompt = build_step_prompt(task=task, step="implement", attempt=1)
        assert "## Human guidance" in prompt
        assert "Start with test scaffolding before code changes." in prompt
        active = task.metadata.get("active_human_guidance") if isinstance(task.metadata, dict) else None
        assert isinstance(active, dict)
        assert active.get("source") == "review_request_changes"


# ---------------------------------------------------------------------------
# Workdoc prompt instructions
# ---------------------------------------------------------------------------


class TestWorkdocPromptInstructions:
    """Tests for step-specific workdoc guidance in worker prompts."""
    def test_prompt_includes_workdoc_instructions_for_plan(self) -> None:
        """Test that prompt includes workdoc instructions for plan."""
        task = _make_task()
        prompt = build_step_prompt(task=task, step="plan", attempt=1)
        assert "## Working Document" in prompt
        assert "## Plan" in prompt
        assert ".workdoc.md" in prompt

    def test_prompt_includes_workdoc_instructions_for_analyze(self) -> None:
        """Test that prompt includes workdoc instructions for analyze."""
        task = _make_task()
        prompt = build_step_prompt(task=task, step="analyze", attempt=1)
        assert "## Working Document" in prompt
        assert "## Analysis" in prompt
        assert ".workdoc.md" in prompt
        assert "## Objective Interpreted" in prompt
        assert "## Implementation Phases" not in prompt

    def test_prompt_includes_workdoc_instructions_for_diagnose(self) -> None:
        """Test that prompt includes workdoc instructions for diagnose."""
        task = _make_task(task_type="bug")
        prompt = build_step_prompt(task=task, step="diagnose", attempt=1)
        assert "## Working Document" in prompt
        assert "## Analysis" in prompt
        assert ".workdoc.md" in prompt
        assert "## Root Cause Analysis" in prompt

    def test_prompt_includes_workdoc_instructions_for_implement(self) -> None:
        """Test that prompt includes workdoc instructions for implement."""
        task = _make_task()
        prompt = build_step_prompt(task=task, step="implement", attempt=1)
        assert "## Working Document" in prompt
        assert "## Implementation Log" in prompt
        assert ".workdoc.md" in prompt

    def test_prompt_includes_workdoc_instructions_for_prototype(self) -> None:
        """Test that prompt includes workdoc instructions for prototype."""
        task = _make_task(task_type="spike")
        prompt = build_step_prompt(task=task, step="prototype", attempt=1)
        assert "## Working Document" in prompt
        assert "## Implementation Log" in prompt
        assert "prototype notes" in prompt
        assert "hypotheses tested" in prompt
        assert "Build a timeboxed prototype" in prompt

    def test_prompt_includes_workdoc_instructions_for_profile(self) -> None:
        """Test that prompt includes workdoc instructions for profile."""
        task = _make_task(task_type="performance")
        prompt = build_step_prompt(task=task, step="profile", attempt=1)
        assert "## Working Document" in prompt
        assert "Do NOT modify `.workdoc.md`" in prompt
        assert "## Profiling Baseline" in prompt

    def test_prompt_includes_workdoc_instructions_for_verify(self) -> None:
        """Test that prompt includes workdoc instructions for verify."""
        task = _make_task()
        prompt = build_step_prompt(task=task, step="verify", attempt=1)
        assert "## Working Document" in prompt
        assert "Do NOT modify `.workdoc.md`" in prompt

    def test_prompt_includes_workdoc_instructions_for_implement_fix(self) -> None:
        """Test that prompt includes workdoc instructions for implement fix."""
        task = _make_task()
        prompt = build_step_prompt(task=task, step="implement_fix", attempt=1)
        assert "## Working Document" in prompt
        assert "## Fix Log" in prompt
        assert "Do NOT modify `.workdoc.md`" in prompt

    def test_prompt_includes_workdoc_instructions_for_review(self) -> None:
        """Test that prompt includes workdoc instructions for review."""
        task = _make_task()
        prompt = build_step_prompt(task=task, step="review", attempt=1)
        assert "## Working Document" in prompt
        assert "Do NOT modify" in prompt

    def test_prompt_includes_workdoc_instructions_for_report(self) -> None:
        """Test that prompt includes workdoc instructions for report."""
        task = _make_task()
        prompt = build_step_prompt(task=task, step="report", attempt=1)
        assert "## Working Document" in prompt
        assert "Do NOT modify `.workdoc.md`" in prompt
        assert "## Final Report" in prompt

    def test_prompt_no_workdoc_for_scan_deps(self) -> None:
        """Test that prompt no workdoc for scan deps."""
        task = _make_task()
        prompt = build_step_prompt(task=task, step="scan_deps", attempt=1)
        assert "## Working Document" not in prompt

    def test_scan_prompts_are_step_specific(self) -> None:
        """Test that scan prompts are step specific."""
        task = _make_task(task_type="security")
        deps_prompt = build_step_prompt(task=task, step="scan_deps", attempt=1)
        code_prompt = build_step_prompt(task=task, step="scan_code", attempt=1)
        assert "dependency security scan" in deps_prompt
        assert "code-level security scan" in code_prompt

    def test_pipeline_classify_prompt_includes_allowed_pipelines(self) -> None:
        """Test that pipeline classify prompt includes allowed pipelines."""
        task = _make_task(
            metadata={"classification_allowed_pipelines": ["feature", "docs"]},
        )
        prompt = build_step_prompt(task=task, step="pipeline_classify", attempt=1)
        assert "Allowed pipelines" in prompt
        assert "`feature`" in prompt
        assert "`docs`" in prompt

    def test_prompt_no_workdoc_for_plan_refine(self) -> None:
        """plan_refine doesn't go through _run_non_review_step so should not get workdoc instructions."""
        task = _make_task()
        prompt = build_step_prompt(task=task, step="plan_refine", attempt=1)
        assert "## Working Document" not in prompt

    def test_prompt_no_workdoc_for_initiative_plan_refine(self) -> None:
        """initiative_plan_refine also bypasses workdoc sync path."""
        task = _make_task(task_type="initiative_plan")
        prompt = build_step_prompt(task=task, step="initiative_plan_refine", attempt=1)
        assert "## Working Document" not in prompt


def test_pipeline_classify_maps_json_summary(adapter: LiveWorkerAdapter) -> None:
    run_result = _make_run_result(
        response_text='{"pipeline_id":"docs","confidence":"high","reason":"Documentation-only request."}',
    )

    with (
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.get_workers_runtime_config"
        ),
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.resolve_worker_for_step",
            return_value=_CODEX_SPEC,
        ),
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.test_worker",
            return_value=(True, ""),
        ),
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.run_worker",
            return_value=run_result,
        ),
    ):
        result = adapter.run_step(task=_make_task(), step="pipeline_classify", attempt=1)

    assert result.status == "ok"
    assert result.summary is not None
    assert '"pipeline_id": "docs"' in result.summary


def test_generate_run_summary_prompt_prefers_workdoc_snapshot(
    adapter: LiveWorkerAdapter, container: Container
) -> None:
    canonical = container.state_root / "workdocs" / "task-1.md"
    canonical.parent.mkdir(parents=True, exist_ok=True)
    canonical.write_text("# Plan\n- Add feature X\n\n## Implementation Log\n- Implemented API", encoding="utf-8")

    task = _make_task(id="task-1", metadata={"workdoc_path": str(canonical)})
    run = RunRecord(task_id=task.id, status="done")
    run.steps = [{"step": "verify", "status": "ok", "summary": "All tests passed"}]

    captured_prompt: dict[str, str] = {}
    fmt_result = _make_run_result(response_text='{"summary":"Final summary"}')

    def _capture_run_worker(**kwargs):
        captured_prompt["text"] = kwargs["prompt"]
        return fmt_result

    with (
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.get_workers_runtime_config"
        ),
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.resolve_worker_for_step",
            return_value=_CODEX_SPEC,
        ),
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.test_worker",
            return_value=(True, "ok"),
        ),
        patch(
            "overdrive.runtime.orchestrator.live_worker_adapter.run_worker",
            side_effect=_capture_run_worker,
        ),
    ):
        summary = adapter.generate_run_summary(task=task, run=run, project_dir=container.project_dir)

    assert summary == "Final summary"
    prompt = captured_prompt["text"]
    assert "Run status: done" in prompt
    assert "## Workdoc snapshot" in prompt
    assert "Implemented API" in prompt
    assert "Execution log" in prompt


# ------------------------------------------------------------------
# _filter_npm_commands_by_scripts
# ------------------------------------------------------------------


def test_filter_npm_commands_removes_missing_script(tmp_path: Path) -> None:
    """npm test is removed when package.json has no test script."""
    (tmp_path / "package.json").write_text('{"scripts": {"build": "tsc"}}')
    commands = {"test": "npm test", "lint": "npx eslint . 2>/dev/null || true"}
    filtered = _filter_npm_commands_by_scripts(commands, tmp_path)
    assert "test" not in filtered
    # eslint via npx is not an npm script reference — should be kept.
    assert "lint" in filtered


def test_filter_npm_commands_keeps_existing_script(tmp_path: Path) -> None:
    """npm test is kept when package.json has the test script."""
    (tmp_path / "package.json").write_text('{"scripts": {"test": "vitest run"}}')
    commands = {"test": "npm test", "lint": "npx eslint . 2>/dev/null || true"}
    filtered = _filter_npm_commands_by_scripts(commands, tmp_path)
    assert "test" in filtered
    assert "lint" in filtered


def test_filter_npm_commands_no_package_json(tmp_path: Path) -> None:
    """All npm commands removed when package.json is missing."""
    commands = {"test": "npm test"}
    filtered = _filter_npm_commands_by_scripts(commands, tmp_path)
    assert "test" not in filtered


def test_filter_npm_commands_preserves_non_npm(tmp_path: Path) -> None:
    """Non-npm commands are always preserved."""
    (tmp_path / "package.json").write_text('{"scripts": {}}')
    commands = {"test": "pytest", "typecheck": "mypy ."}
    filtered = _filter_npm_commands_by_scripts(commands, tmp_path)
    assert filtered == commands


def test_filter_npm_commands_npm_run_variant(tmp_path: Path) -> None:
    """npm run <script> is correctly parsed."""
    (tmp_path / "package.json").write_text('{"scripts": {"lint": "eslint ."}}')
    commands = {"lint": "npm run lint", "test": "npm run test"}
    filtered = _filter_npm_commands_by_scripts(commands, tmp_path)
    assert "lint" in filtered
    assert "test" not in filtered


def test_filter_npm_commands_keeps_compound_with_npm_fallback(tmp_path: Path) -> None:
    """Compound commands where npm is a fallback (after ||) are kept."""
    (tmp_path / "package.json").write_text('{"scripts": {}}')
    commands = {"test": "npx vitest run 2>/dev/null || npm test"}
    filtered = _filter_npm_commands_by_scripts(commands, tmp_path)
    # npm test is a fallback after ||, not the primary command — should be kept.
    assert "test" in filtered


# ---------------------------------------------------------------------------
# Comment-aware PR review steps
# ---------------------------------------------------------------------------


class TestCommentAwareStepCategorization:
    """Phase 2: Step categorization for new comment-aware steps."""

    def test_pr_review_comment_is_planning(self) -> None:
        assert _step_category("pr_review_comment") == "planning"

    def test_pr_review_summarize_is_reporting(self) -> None:
        assert _step_category("pr_review_summarize") == "reporting"

    def test_pr_review_fix_respond_is_planning(self) -> None:
        assert _step_category("pr_review_fix_respond") == "planning"


class TestCommentAwarePromptMapping:
    """Phase 3: Prompt template mapping for new comment-aware steps."""

    def test_pr_review_comment_prompt(self) -> None:
        prompt = build_step_prompt(
            task=_make_task(task_type="pr_review_comment"),
            step="pr_review_comment",
            attempt=1,
            project_languages=[],
        )
        assert prompt  # non-empty

    def test_pr_review_summarize_prompt(self) -> None:
        prompt = build_step_prompt(
            task=_make_task(task_type="pr_review_summarize"),
            step="pr_review_summarize",
            attempt=1,
            project_languages=[],
        )
        assert prompt

    def test_pr_review_fix_respond_prompt(self) -> None:
        prompt = build_step_prompt(
            task=_make_task(task_type="pr_review_fix_respond"),
            step="pr_review_fix_respond",
            attempt=1,
            project_languages=[],
        )
        assert prompt


class TestCommentAwareSettingsPromptSteps:
    """Phase 3: All three new steps appear in _SETTINGS_PROMPT_STEPS."""

    def test_pr_review_comment_in_settings(self) -> None:
        assert "pr_review_comment" in _SETTINGS_PROMPT_STEPS

    def test_pr_review_summarize_in_settings(self) -> None:
        assert "pr_review_summarize" in _SETTINGS_PROMPT_STEPS

    def test_pr_review_fix_respond_in_settings(self) -> None:
        assert "pr_review_fix_respond" in _SETTINGS_PROMPT_STEPS


class TestCommentContextInjection:
    """Phase 4: Comment and PR context injection in build_step_prompt()."""

    def test_pr_context_injected_for_pr_review_comment(self) -> None:
        task = _make_task(
            task_type="pr_review_comment",
            metadata={
                "source_description": "Fix widget alignment",
                "source_diff": "--- a/file.py\n+++ b/file.py\n@@ -1 +1 @@\n-old\n+new",
                "source_stat": " file.py | 2 +-",
            },
        )
        prompt = build_step_prompt(task=task, step="pr_review_comment", attempt=1, project_languages=[])
        assert "PR/MR description" in prompt
        assert "Fix widget alignment" in prompt
        assert "Diff stat" in prompt
        assert "PR/MR diff" in prompt

    def test_comment_context_injected_for_pr_review_comment(self) -> None:
        task = _make_task(
            task_type="pr_review_comment",
            metadata={
                "source_comments_formatted": "- @alice: Please fix the typo on line 42\n- @bob: LGTM",
            },
        )
        prompt = build_step_prompt(task=task, step="pr_review_comment", attempt=1, project_languages=[])
        assert "## Existing review comments" in prompt
        assert "@alice: Please fix the typo on line 42" in prompt

    def test_comment_context_injected_for_pr_review_summarize(self) -> None:
        task = _make_task(
            task_type="pr_review_summarize",
            metadata={
                "source_comments_formatted": "- @reviewer: Need tests",
            },
        )
        prompt = build_step_prompt(task=task, step="pr_review_summarize", attempt=1, project_languages=[])
        assert "## Existing review comments" in prompt
        assert "@reviewer: Need tests" in prompt

    def test_comment_context_injected_for_pr_review_fix_respond(self) -> None:
        task = _make_task(
            task_type="pr_review_fix_respond",
            metadata={
                "source_comments_formatted": "- @lead: Refactor this method",
            },
        )
        prompt = build_step_prompt(task=task, step="pr_review_fix_respond", attempt=1, project_languages=[])
        assert "## Existing review comments" in prompt
        assert "@lead: Refactor this method" in prompt

    def test_comment_context_omitted_when_empty(self) -> None:
        task = _make_task(
            task_type="pr_review_comment",
            metadata={"source_comments_formatted": ""},
        )
        prompt = build_step_prompt(task=task, step="pr_review_comment", attempt=1, project_languages=[])
        assert "## Existing review comments" not in prompt

    def test_comment_context_omitted_when_missing(self) -> None:
        task = _make_task(
            task_type="pr_review_comment",
            metadata={},
        )
        prompt = build_step_prompt(task=task, step="pr_review_comment", attempt=1, project_languages=[])
        assert "## Existing review comments" not in prompt

    def test_comment_context_not_injected_for_pr_review(self) -> None:
        """The original pr_review step should NOT get comment context."""
        task = _make_task(
            task_type="pr_review",
            metadata={
                "source_comments_formatted": "- @alice: some comment",
            },
        )
        prompt = build_step_prompt(task=task, step="pr_review", attempt=1, project_languages=[])
        assert "## Existing review comments" not in prompt


class TestCommentAwareEarlyCompletion:
    """Phase 6: Early completion support for new steps."""

    def test_pr_review_comment_no_issues_found(self) -> None:
        assert _is_no_action_needed("pr_review_comment", "No issues found") is True

    def test_pr_review_summarize_no_issues_found(self) -> None:
        assert _is_no_action_needed("pr_review_summarize", "No issues found") is True

    def test_pr_review_fix_respond_no_early_completion(self) -> None:
        assert _is_no_action_needed("pr_review_fix_respond", "No issues found") is False

    def test_pr_review_comment_in_early_complete_steps(self) -> None:
        assert "pr_review_comment" in _EARLY_COMPLETE_STEPS

    def test_pr_review_summarize_in_early_complete_steps(self) -> None:
        assert "pr_review_summarize" in _EARLY_COMPLETE_STEPS

    def test_pr_review_fix_respond_not_in_early_complete_steps(self) -> None:
        assert "pr_review_fix_respond" not in _EARLY_COMPLETE_STEPS


class TestStepResultCommentActions:
    """Phase 1: StepResult comment_actions field."""

    def test_comment_actions_default_none(self) -> None:
        result = StepResult()
        assert result.comment_actions is None

    def test_comment_actions_populated(self) -> None:
        actions = [{"path": "a.py", "line": 1, "body": "fix", "severity": "high"}]
        result = StepResult(comment_actions=actions)
        assert result.comment_actions == actions
        assert len(result.comment_actions) == 1
        assert result.comment_actions[0]["path"] == "a.py"


class TestCommentAwareMapResult:
    """Phase 5: pr_review_comment JSON parsing in _map_result."""

    def test_pr_review_comment_json_parsed_directly(self, adapter: LiveWorkerAdapter) -> None:
        """When pr_review_comment returns JSON with comments field, parse directly."""
        json_output = '{"comments": [{"path": "x.py", "line": 10, "body": "Fix this", "severity": "high"}], "summary": "One issue"}'
        result = _make_run_result(response_text=json_output)

        with patch("overdrive.runtime.orchestrator.live_worker_adapter.resolve_worker_for_step", return_value=_CODEX_SPEC), \
             patch("overdrive.runtime.orchestrator.live_worker_adapter.get_workers_runtime_config") as mock_cfg, \
             patch("overdrive.runtime.orchestrator.live_worker_adapter.test_worker", return_value=(True, "")), \
             patch("overdrive.runtime.orchestrator.live_worker_adapter.run_worker", return_value=result), \
             patch("overdrive.runtime.orchestrator.live_worker_adapter.run_environment_preflight") as mock_pf:
            mock_cfg.return_value = SimpleNamespace(providers={"codex": _CODEX_SPEC}, default_model="")
            mock_pf.return_value = EnvironmentPreflightResult(ok=True, issues=[], required_capabilities=set(), attempted_remediation=False, remediation_log="")
            task = _make_task(task_type="pr_review_comment")
            step_result = adapter.run_step(task=task, step="pr_review_comment", attempt=1)
        assert step_result.status == "ok"
        assert step_result.comment_actions is not None
        assert len(step_result.comment_actions) == 1
        assert step_result.comment_actions[0]["path"] == "x.py"
        assert step_result.summary == "One issue"

    def test_pr_review_comment_freeform_triggers_formatter(self, adapter: LiveWorkerAdapter) -> None:
        """When pr_review_comment returns freeform text, the formatter is invoked."""
        freeform_output = "I found a bug in file.py at line 5: the variable is unused."
        formatter_json = '{"comments": [{"path": "file.py", "line": 5, "body": "unused variable", "severity": "medium"}], "summary": "One finding"}'
        primary_result = _make_run_result(response_text=freeform_output)
        formatter_result = _make_run_result(response_text=formatter_json)

        call_count = 0

        def mock_run_worker(**kwargs):
            nonlocal call_count
            call_count += 1
            if call_count == 1:
                return primary_result
            return formatter_result

        with patch("overdrive.runtime.orchestrator.live_worker_adapter.resolve_worker_for_step", return_value=_CODEX_SPEC), \
             patch("overdrive.runtime.orchestrator.live_worker_adapter.get_workers_runtime_config") as mock_cfg, \
             patch("overdrive.runtime.orchestrator.live_worker_adapter.test_worker", return_value=(True, "")), \
             patch("overdrive.runtime.orchestrator.live_worker_adapter.run_worker", side_effect=mock_run_worker), \
             patch("overdrive.runtime.orchestrator.live_worker_adapter.run_environment_preflight") as mock_pf:
            mock_cfg.return_value = SimpleNamespace(providers={"codex": _CODEX_SPEC}, default_model="")
            mock_pf.return_value = EnvironmentPreflightResult(ok=True, issues=[], required_capabilities=set(), attempted_remediation=False, remediation_log="")
            task = _make_task(task_type="pr_review_comment")
            step_result = adapter.run_step(task=task, step="pr_review_comment", attempt=1)
        assert call_count == 2  # primary + formatter
        assert step_result.status == "ok"
        assert step_result.comment_actions is not None
        assert len(step_result.comment_actions) == 1
        assert step_result.comment_actions[0]["path"] == "file.py"


# ---------------------------------------------------------------------------
# _sanitize_stderr
# ---------------------------------------------------------------------------

class TestSanitizeStderr:
    def test_takes_tail_past_startup_noise(self) -> None:
        raw = (
            "- Do NOT suppress or down-rank review findings.\n"
            "- Prefer fixing issues over escalating; escalate only when truly stuck.\n"
            "- Be explicit about risks, uncertainty, and assumptions.\n"
            "mcp startup: no servers\n"
            "ERROR: You've hit your usage limit.\n"
        )
        result = _sanitize_stderr(raw)
        assert "ERROR: You've hit your usage limit." in result

    def test_strips_tool_error_xml_tags(self) -> None:
        raw = "Tool error: <tool_use_error>File has been modified</tool_use_error>"
        result = _sanitize_stderr(raw)
        assert "<tool_use_error>" not in result
        assert "File has been modified" in result

    def test_preserves_meaningful_errors(self) -> None:
        raw = "ERROR: compilation failed\nsrc/main.rs:12: expected `;`"
        result = _sanitize_stderr(raw)
        assert "compilation failed" in result
        assert "expected `;`" in result

    def test_respects_max_length(self) -> None:
        raw = "ERROR: " + "x" * 600
        result = _sanitize_stderr(raw, max_length=100)
        assert len(result) <= 100

    def test_empty_input(self) -> None:
        assert _sanitize_stderr("") == ""
        assert _sanitize_stderr("   \n  ") == ""
