from __future__ import annotations

from typing import Any
import threading
import pytest
from bootstrap.constants import MAX_TERMINAL_OUTPUT_CHARS
from bootstrap.artifacts import ManifestResult, ManifestStatus
from bootstrap.app_server import AppServerInitializeResponse
import time
from bootstrap.config_loader import UserConfig
from bootstrap.orchestrator import BootstrapConfig, BootstrapOrchestrator
from bootstrap.git_ops import GitConfig
from bootstrap.payload import RepoContext
from bootstrap.payload import BootstrapAlreadyClaimed
from bootstrap.payload import BootstrapPayload


def test_orchestrator_happy_path(monkeypatch, tmp_path):
    """End-to-end orchestration with fakes: no real network or Codex app-server."""

    workspace = tmp_path / "work"
    artifacts = [{"artifact_type": "text", "payload": {"content": "mock artifact"}}]

    # Prepare a dummy config file
    cfg_file = tmp_path / "config.toml"
    cfg_file.write_text("", encoding="utf-8")

    # Telemetry collectors
    heartbeats: list[dict[str, Any]] = []
    logs: list[dict[str, Any]] = []
    posted_artifacts: list[dict[str, Any]] = []
    completions: list[dict[str, Any]] = []
    errors: list[dict[str, Any]] = []

    monkeypatch.setenv("BOOTSTRAP_MOCK_ENGINEER", "1")
    monkeypatch.setattr(
        "bootstrap.orchestrator.load_codex_config",
        lambda path: UserConfig(
            raw={},
            working_dir=workspace,
            sandbox_mode=None,
            approval_policy="unless-allow-listed",
            oss_provider=None,
            writable_roots=(workspace,),
            workspace_instructions="use workspace",
            instructions_source="inline",
            warnings=(),
        ),
    )
    monkeypatch.setattr(
        "bootstrap.orchestrator.fetch_bootstrap_payload",
        lambda server_url, run_id, token: BootstrapPayload(prompt="PROMPT"),
    )

    monkeypatch.setattr(
        "bootstrap.orchestrator.post_heartbeat",
        lambda server_url, run_id, token, summary: heartbeats.append(
            {"summary": summary}
        ),
    )
    monkeypatch.setattr(
        "bootstrap.orchestrator.post_log",
        lambda server_url, run_id, token, level, message, extra: logs.append(
            {"level": level, "message": message, "extra": extra}
        ),
    )
    monkeypatch.setattr(
        "bootstrap.orchestrator.post_artifacts",
        lambda server_url, run_id, token, entries: posted_artifacts.extend(entries),
    )
    monkeypatch.setattr(
        "bootstrap.orchestrator.post_completion",
        lambda server_url, run_id, token, summary: completions.append(
            {"summary": summary}
        ),
    )
    monkeypatch.setattr(
        "bootstrap.orchestrator.post_error",
        lambda server_url, run_id, token, reason, summary=None: errors.append(
            {"reason": reason, "summary": summary}
        ),
    )

    cfg = BootstrapConfig(
        run_id="run-123",
        capability_token="secret",
        config_path=cfg_file,
        server_url="http://server",
        run_root=tmp_path,
    )
    orchestrator = BootstrapOrchestrator(cfg)
    code = orchestrator.run()

    assert code == 0
    assert heartbeats, "expected at least one heartbeat"
    assert logs, "expected log forwarding"
    assert posted_artifacts == artifacts
    assert completions and not errors
    assert any(
        entry["extra"].get("run_event_kind") in {"command_output", "agent_message"}
        for entry in logs
    )


def test_orchestrator_happy_path_uses_app_server(monkeypatch, tmp_path) -> None:
    """Non-mock run wires up Codex app-server and passes the right environment."""

    workspace = tmp_path / "work"
    workspace.mkdir(parents=True, exist_ok=True)
    (workspace / ".git" / "info").mkdir(parents=True, exist_ok=True)
    artifacts = [{"artifact_type": "text", "payload": {"content": "hi"}}]

    # Isolate homedir so tests don't depend on the developer's ~/.codex.
    home = tmp_path / "home"
    home.mkdir(parents=True, exist_ok=True)
    (home / ".codex").mkdir(parents=True, exist_ok=True)
    (home / ".codex" / "auth.json").write_text("{}", encoding="utf-8")
    monkeypatch.setenv("HOME", str(home))

    cfg_file = tmp_path / "config.toml"
    cfg_file.write_text("", encoding="utf-8")

    hb_event = threading.Event()
    heartbeats: list[str] = []
    logs: list[dict[str, Any]] = []
    app_server_events: list[dict[str, Any]] = []
    posted_artifacts: list[dict[str, Any]] = []
    completions: list[str] = []
    errors: list[str] = []

    monkeypatch.setattr(
        "bootstrap.orchestrator.load_codex_config",
        lambda _path: UserConfig(
            raw={},
            working_dir=workspace,
            sandbox_mode=None,
            approval_policy="unless-allow-listed",
            oss_provider=None,
            writable_roots=(workspace,),
            workspace_instructions="use workspace",
            instructions_source="inline",
            warnings=(),
        ),
    )
    monkeypatch.setattr(
        "bootstrap.orchestrator.fetch_bootstrap_payload",
        lambda _server_url, _run_id, _token: BootstrapPayload(prompt="PROMPT"),
    )

    from pathlib import Path

    monkeypatch.setattr(
        "bootstrap.orchestrator.ensure_codex",
        lambda **_kwargs: Path("/fake/codex"),
    )
    monkeypatch.setattr(
        "bootstrap.orchestrator.read_manifest",
        lambda _path: ManifestResult(status=ManifestStatus.VALID, artifacts=artifacts),
    )

    monkeypatch.setattr(
        "bootstrap.orchestrator.poll_run_control",
        lambda *_args, **_kwargs: {},
    )

    def fake_post_heartbeat(*_args, **_kwargs) -> None:
        hb_event.set()
        heartbeats.append("hb")

    monkeypatch.setattr(
        "bootstrap.orchestrator.post_heartbeat",
        fake_post_heartbeat,
    )
    monkeypatch.setattr(
        "bootstrap.orchestrator.post_log",
        lambda _server_url, _run_id, _token, level, message, extra: logs.append(
            {"level": level, "message": message, "extra": extra}
        ),
    )
    monkeypatch.setattr(
        "bootstrap.orchestrator.post_app_server_event",
        lambda _server_url, _run_id, _token, method, params: app_server_events.append(
            {"method": method, "params": params}
        ),
    )
    monkeypatch.setattr(
        "bootstrap.orchestrator.post_artifacts",
        lambda _server_url, _run_id, _token, entries: posted_artifacts.extend(entries),
    )
    monkeypatch.setattr(
        "bootstrap.orchestrator.post_completion",
        lambda *_args, **kwargs: completions.append(str(kwargs.get("summary") or "")),
    )
    monkeypatch.setattr(
        "bootstrap.orchestrator.post_error",
        lambda *_args, **kwargs: errors.append(str(kwargs.get("reason") or "")),
    )

    captured: dict[str, Any] = {}

    class FakeAppServerStdioClient:
        def __init__(self, *, argv, env, cwd, on_notification, request_handler=None):
            captured["argv"] = list(argv)
            captured["env"] = dict(env)
            captured["cwd"] = cwd
            self._on_notification = on_notification

        def initialize(self, *, client_info, experimental_api: bool = False):
            return AppServerInitializeResponse(user_agent="stub/1")

        def request(self, *, method: str, params, timeout_seconds: float = 60):
            if method == "thread/start":
                return {"thread": {"id": "thr-1"}, "model": "gpt-5.3-codex"}
            if method == "turn/start":

                def _emit():
                    hb_event.wait(timeout=1)
                    if self._on_notification is None:
                        return
                    self._on_notification(
                        {
                            "method": "turn/started",
                            "params": {
                                "threadId": "thr-1",
                                "turn": {
                                    "id": "turn-1",
                                    "items": [],
                                    "status": "inProgress",
                                    "error": None,
                                },
                            },
                        }
                    )
                    self._on_notification(
                        {
                            "method": "item/agentMessage/delta",
                            "params": {
                                "threadId": "thr-1",
                                "turnId": "turn-1",
                                "itemId": "msg-1",
                                "delta": "ok",
                            },
                        }
                    )
                    self._on_notification(
                        {
                            "method": "thread/tokenUsage/updated",
                            "params": {
                                "threadId": "thr-1",
                                "turnId": "turn-1",
                                "tokenUsage": {
                                    "total": {
                                        "totalTokens": 67,
                                        "inputTokens": 50,
                                        "cachedInputTokens": 10,
                                        "outputTokens": 5,
                                        "reasoningOutputTokens": 2,
                                    },
                                    "last": {
                                        "totalTokens": 67,
                                        "inputTokens": 50,
                                        "cachedInputTokens": 10,
                                        "outputTokens": 5,
                                        "reasoningOutputTokens": 2,
                                    },
                                    "modelContextWindow": None,
                                },
                            },
                        }
                    )
                    self._on_notification(
                        {
                            "method": "turn/completed",
                            "params": {
                                "threadId": "thr-1",
                                "turn": {
                                    "id": "turn-1",
                                    "items": [],
                                    "status": "completed",
                                    "error": None,
                                },
                            },
                        }
                    )

                threading.Thread(target=_emit, daemon=True).start()
                return {
                    "turn": {
                        "id": "turn-1",
                        "items": [],
                        "status": "inProgress",
                        "error": None,
                    }
                }
            if method == "turn/interrupt":
                captured["interrupted"] = True
                return {}
            raise AssertionError(f"unexpected request {method}")

        def close(self) -> None:
            captured["closed"] = True

    monkeypatch.setattr(
        "bootstrap.orchestrator.AppServerStdioClient", FakeAppServerStdioClient
    )

    cfg = BootstrapConfig(
        run_id="run-123",
        capability_token="secret",
        config_path=cfg_file,
        server_url="http://server",
        run_root=tmp_path,
    )
    orchestrator = BootstrapOrchestrator(cfg)
    code = orchestrator.run()

    assert code == 0
    assert heartbeats
    assert not errors
    assert completions
    assert posted_artifacts == artifacts

    env = captured["env"]
    assert env["FLYWHEEL_RUN_ID"] == "run-123"
    assert env["FLYWHEEL_RUN_TOKEN"] == "secret"
    assert env["FLYWHEEL_SERVER"] == "http://server"
    assert env["FLYWHEEL_WORKSPACE"] == str(workspace.resolve())
    assert "CODEX_HOME" in env
    assert "PATH" in env

    argv = captured["argv"]
    assert argv and argv[0].endswith("codex")
    assert "app-server" in argv

    exclude_path = workspace / ".git" / "info" / "exclude"
    assert exclude_path.exists()
    assert ".codex_home/" in exclude_path.read_text(encoding="utf-8")
    assert any(
        event.get("method") == "item/agentMessage/delta"
        and isinstance(event.get("params"), dict)
        and event["params"].get("delta") == "ok"
        for event in app_server_events
    )


def test_orchestrator_emits_usage_from_app_server(monkeypatch, tmp_path) -> None:
    """Bootstrap should surface Codex token usage via run logs for billing."""

    workspace = tmp_path / "work"
    artifacts = [{"artifact_type": "text", "payload": {"content": "hi"}}]

    # Isolate homedir so tests don't depend on the developer's ~/.codex.
    home = tmp_path / "home"
    home.mkdir(parents=True, exist_ok=True)
    (home / ".codex").mkdir(parents=True, exist_ok=True)
    (home / ".codex" / "auth.json").write_text("{}", encoding="utf-8")
    monkeypatch.setenv("HOME", str(home))

    cfg_file = tmp_path / "config.toml"
    cfg_file.write_text("", encoding="utf-8")

    hb_event = threading.Event()
    logs: list[dict[str, Any]] = []
    app_server_events: list[dict[str, Any]] = []
    posted_artifacts: list[dict[str, Any]] = []
    completions: list[str] = []
    errors: list[str] = []

    monkeypatch.setattr(
        "bootstrap.orchestrator.load_codex_config",
        lambda _path: UserConfig(
            raw={},
            working_dir=workspace,
            sandbox_mode=None,
            approval_policy="unless-allow-listed",
            oss_provider=None,
            writable_roots=(workspace,),
            workspace_instructions="use workspace",
            instructions_source="inline",
            warnings=(),
        ),
    )
    monkeypatch.setattr(
        "bootstrap.orchestrator.fetch_bootstrap_payload",
        lambda _server_url, _run_id, _token: BootstrapPayload(prompt="PROMPT"),
    )

    from pathlib import Path

    monkeypatch.setattr(
        "bootstrap.orchestrator.ensure_codex",
        lambda **_kwargs: Path("/fake/codex"),
    )
    monkeypatch.setattr(
        "bootstrap.orchestrator.read_manifest",
        lambda _path: ManifestResult(status=ManifestStatus.VALID, artifacts=artifacts),
    )

    monkeypatch.setattr(
        "bootstrap.orchestrator.poll_run_control",
        lambda *_args, **_kwargs: {},
    )
    monkeypatch.setattr(
        "bootstrap.orchestrator.post_heartbeat",
        lambda *_args, **_kwargs: hb_event.set(),
    )
    monkeypatch.setattr(
        "bootstrap.orchestrator.post_log",
        lambda _server_url, _run_id, _token, level, message, extra: logs.append(
            {"level": level, "message": message, "extra": extra}
        ),
    )
    monkeypatch.setattr(
        "bootstrap.orchestrator.post_app_server_event",
        lambda _server_url, _run_id, _token, method, params: app_server_events.append(
            {"method": method, "params": params}
        ),
    )
    monkeypatch.setattr(
        "bootstrap.orchestrator.post_artifacts",
        lambda _server_url, _run_id, _token, entries: posted_artifacts.extend(entries),
    )
    monkeypatch.setattr(
        "bootstrap.orchestrator.post_completion",
        lambda *_args, **kwargs: completions.append(str(kwargs.get("summary") or "")),
    )
    monkeypatch.setattr(
        "bootstrap.orchestrator.post_error",
        lambda *_args, **kwargs: errors.append(str(kwargs.get("reason") or "")),
    )

    captured: dict[str, Any] = {}

    class FakeAppServerStdioClient:
        def __init__(self, *, argv, env, cwd, on_notification, request_handler=None):
            captured["env"] = dict(env)
            self._on_notification = on_notification

        def initialize(self, *, client_info, experimental_api: bool = False):
            return AppServerInitializeResponse(user_agent="stub/1")

        def request(self, *, method: str, params, timeout_seconds: float = 60):
            if method == "thread/start":
                return {"thread": {"id": "thr-1"}, "model": "gpt-5.3-codex"}
            if method == "turn/start":

                def _emit():
                    hb_event.wait(timeout=1)
                    if self._on_notification is None:
                        return
                    self._on_notification(
                        {
                            "method": "thread/tokenUsage/updated",
                            "params": {
                                "threadId": "thr-1",
                                "turnId": "turn-1",
                                "tokenUsage": {
                                    "total": {
                                        "totalTokens": 67,
                                        "inputTokens": 50,
                                        "cachedInputTokens": 10,
                                        "outputTokens": 5,
                                        "reasoningOutputTokens": 2,
                                    },
                                    "last": {
                                        "totalTokens": 67,
                                        "inputTokens": 50,
                                        "cachedInputTokens": 10,
                                        "outputTokens": 5,
                                        "reasoningOutputTokens": 2,
                                    },
                                    "modelContextWindow": None,
                                },
                            },
                        }
                    )
                    self._on_notification(
                        {
                            "method": "turn/completed",
                            "params": {
                                "threadId": "thr-1",
                                "turn": {
                                    "id": "turn-1",
                                    "items": [],
                                    "status": "completed",
                                    "error": None,
                                },
                            },
                        }
                    )

                threading.Thread(target=_emit, daemon=True).start()
                return {
                    "turn": {
                        "id": "turn-1",
                        "items": [],
                        "status": "inProgress",
                        "error": None,
                    }
                }
            if method == "turn/interrupt":
                return {}
            raise AssertionError(f"unexpected request {method}")

        def close(self) -> None:
            return None

    monkeypatch.setattr(
        "bootstrap.orchestrator.AppServerStdioClient", FakeAppServerStdioClient
    )

    cfg = BootstrapConfig(
        run_id="run-123",
        capability_token="secret",
        config_path=cfg_file,
        server_url="http://server",
        run_root=tmp_path,
    )
    orchestrator = BootstrapOrchestrator(cfg)
    code = orchestrator.run()

    assert code == 0
    assert posted_artifacts == artifacts
    assert completions and not errors

    usage_logs = [
        entry
        for entry in logs
        if isinstance(entry.get("extra"), dict)
        and isinstance(entry["extra"].get("event"), dict)
        and entry["extra"]["event"].get("type") == "turn.completed"
    ]
    assert usage_logs, "expected a turn.completed usage log for billing"
    usage_event = usage_logs[-1]["extra"]["event"]
    assert usage_event.get("model") == "gpt-5.3-codex"
    assert usage_event.get("usage") == {
        "input_tokens": 50,
        "cached_input_tokens": 10,
        "output_tokens": 5,
        "reasoning_output_tokens": 2,
    }


def test_orchestrator_ignores_backtracking_agent_message_deltas(
    monkeypatch, tmp_path
) -> None:
    """Backtracking deltas must not duplicate already-assembled text.

    Codex app-server occasionally re-emits a shorter prefix of the current text
    (e.g. due to partial replays). The bootstrapper should treat these as
    no-ops so the UI doesn't show repeated words.
    """

    workspace = tmp_path / "work"
    artifacts = [{"artifact_type": "text", "payload": {"content": "hi"}}]

    # Isolate homedir so tests don't depend on the developer's ~/.codex.
    home = tmp_path / "home"
    home.mkdir(parents=True, exist_ok=True)
    (home / ".codex").mkdir(parents=True, exist_ok=True)
    (home / ".codex" / "auth.json").write_text("{}", encoding="utf-8")
    monkeypatch.setenv("HOME", str(home))

    cfg_file = tmp_path / "config.toml"
    cfg_file.write_text("", encoding="utf-8")

    hb_event = threading.Event()
    app_server_events: list[dict[str, Any]] = []

    monkeypatch.setattr(
        "bootstrap.orchestrator.load_codex_config",
        lambda _path: UserConfig(
            raw={},
            working_dir=workspace,
            sandbox_mode=None,
            approval_policy="unless-allow-listed",
            oss_provider=None,
            writable_roots=(workspace,),
            workspace_instructions="use workspace",
            instructions_source="inline",
            warnings=(),
        ),
    )
    monkeypatch.setattr(
        "bootstrap.orchestrator.fetch_bootstrap_payload",
        lambda _server_url, _run_id, _token: BootstrapPayload(prompt="PROMPT"),
    )

    from pathlib import Path

    monkeypatch.setattr(
        "bootstrap.orchestrator.ensure_codex",
        lambda **_kwargs: Path("/fake/codex"),
    )
    monkeypatch.setattr(
        "bootstrap.orchestrator.read_manifest",
        lambda _path: ManifestResult(status=ManifestStatus.VALID, artifacts=artifacts),
    )

    monkeypatch.setattr(
        "bootstrap.orchestrator.poll_run_control",
        lambda *_args, **_kwargs: {},
    )
    monkeypatch.setattr(
        "bootstrap.orchestrator.post_heartbeat",
        lambda *_args, **_kwargs: hb_event.set(),
    )
    monkeypatch.setattr(
        "bootstrap.orchestrator.post_log",
        lambda *_args, **_kwargs: None,
    )
    monkeypatch.setattr(
        "bootstrap.orchestrator.post_app_server_event",
        lambda _server_url, _run_id, _token, method, params: app_server_events.append(
            {"method": method, "params": params}
        ),
    )
    monkeypatch.setattr(
        "bootstrap.orchestrator.post_artifacts",
        lambda *_args, **_kwargs: None,
    )
    monkeypatch.setattr(
        "bootstrap.orchestrator.post_completion",
        lambda *_args, **_kwargs: None,
    )
    monkeypatch.setattr(
        "bootstrap.orchestrator.post_error",
        lambda *_args, **_kwargs: None,
    )

    class FakeAppServerStdioClient:
        def __init__(self, *, argv, env, cwd, on_notification, request_handler=None):
            self._on_notification = on_notification

        def initialize(self, *, client_info, experimental_api: bool = False):
            return AppServerInitializeResponse(user_agent="stub/1")

        def request(self, *, method: str, params, timeout_seconds: float = 60):
            if method == "thread/start":
                return {"thread": {"id": "thr-1"}, "model": "gpt-5.3-codex"}
            if method == "turn/start":

                def _emit() -> None:
                    hb_event.wait(timeout=1)
                    if self._on_notification is None:
                        return
                    self._on_notification(
                        {
                            "method": "turn/started",
                            "params": {
                                "threadId": "thr-1",
                                "turn": {
                                    "id": "turn-1",
                                    "items": [],
                                    "status": "inProgress",
                                    "error": None,
                                },
                            },
                        }
                    )
                    # Cumulative snapshot.
                    self._on_notification(
                        {
                            "method": "item/agentMessage/delta",
                            "params": {
                                "threadId": "thr-1",
                                "turnId": "turn-1",
                                "itemId": "msg-1",
                                "delta": "Bootstrapping the workspace: I will inspect",
                            },
                        }
                    )
                    # Backtracking snapshot: shorter prefix should be ignored.
                    self._on_notification(
                        {
                            "method": "item/agentMessage/delta",
                            "params": {
                                "threadId": "thr-1",
                                "turnId": "turn-1",
                                "itemId": "msg-1",
                                "delta": "Bootstrapping the workspace: I will",
                            },
                        }
                    )
                    # Extend again.
                    self._on_notification(
                        {
                            "method": "item/agentMessage/delta",
                            "params": {
                                "threadId": "thr-1",
                                "turnId": "turn-1",
                                "itemId": "msg-1",
                                "delta": "Bootstrapping the workspace: I will inspect.",
                            },
                        }
                    )
                    self._on_notification(
                        {
                            "method": "turn/completed",
                            "params": {
                                "threadId": "thr-1",
                                "turn": {
                                    "id": "turn-1",
                                    "items": [],
                                    "status": "completed",
                                    "error": None,
                                },
                            },
                        }
                    )

                threading.Thread(target=_emit, daemon=True).start()
                return {
                    "turn": {
                        "id": "turn-1",
                        "items": [],
                        "status": "inProgress",
                        "error": None,
                    }
                }
            if method == "turn/interrupt":
                return {}
            raise AssertionError(f"unexpected request {method}")

        def close(self) -> None:
            return None

    monkeypatch.setattr(
        "bootstrap.orchestrator.AppServerStdioClient", FakeAppServerStdioClient
    )

    cfg = BootstrapConfig(
        run_id="run-123",
        capability_token="secret",
        config_path=cfg_file,
        server_url="http://server",
        run_root=tmp_path,
    )
    orchestrator = BootstrapOrchestrator(cfg)
    code = orchestrator.run()

    assert code == 0

    # Join emitted deltas in order; we expect a clean transcript without repeats.
    emitted = "".join(
        str(event.get("params", {}).get("delta") or "")
        for event in app_server_events
        if event.get("method") == "item/agentMessage/delta"
    )
    assert emitted == "Bootstrapping the workspace: I will inspect."
    assert "Bootstrapping the workspace: I willBootstrapping" not in emitted


def test_orchestrator_bootstrap_conflict_exits_cleanly(monkeypatch, tmp_path) -> None:
    """Bootstrap 409 should exit without posting completion/error."""

    workspace = tmp_path / "work"
    cfg_file = tmp_path / "config.toml"
    cfg_file.write_text("", encoding="utf-8")

    logs: list[dict[str, Any]] = []
    posted_artifacts: list[dict[str, Any]] = []
    completions: list[dict[str, Any]] = []
    errors: list[dict[str, Any]] = []

    monkeypatch.setenv("BOOTSTRAP_MOCK_ENGINEER", "1")
    monkeypatch.setattr(
        "bootstrap.orchestrator.load_codex_config",
        lambda path: UserConfig(
            raw={},
            working_dir=workspace,
            sandbox_mode=None,
            approval_policy="unless-allow-listed",
            oss_provider=None,
            writable_roots=(workspace,),
            workspace_instructions="use workspace",
            instructions_source="inline",
            warnings=(),
        ),
    )
    monkeypatch.setattr(
        "bootstrap.orchestrator.fetch_bootstrap_payload",
        lambda server_url, run_id, token: (_ for _ in ()).throw(
            BootstrapAlreadyClaimed("bootstrap already claimed")
        ),
    )
    monkeypatch.setattr(
        "bootstrap.orchestrator.BootstrapOrchestrator._launch_engineer_and_stream",
        lambda _self: pytest.fail("codex should not launch when bootstrap is claimed"),
    )
    monkeypatch.setattr(
        "bootstrap.orchestrator.post_log",
        lambda server_url, run_id, token, level, message, extra: logs.append(
            {"level": level, "message": message}
        ),
    )
    monkeypatch.setattr(
        "bootstrap.orchestrator.post_artifacts",
        lambda server_url, run_id, token, entries: posted_artifacts.extend(entries),
    )
    monkeypatch.setattr(
        "bootstrap.orchestrator.post_completion",
        lambda server_url, run_id, token, summary: completions.append(
            {"summary": summary}
        ),
    )
    monkeypatch.setattr(
        "bootstrap.orchestrator.post_error",
        lambda server_url, run_id, token, reason, summary=None: errors.append(
            {"reason": reason, "summary": summary}
        ),
    )

    cfg = BootstrapConfig(
        run_id="run-123",
        capability_token="secret",
        config_path=cfg_file,
        server_url="http://server",
        run_root=tmp_path,
    )
    orchestrator = BootstrapOrchestrator(cfg)
    code = orchestrator.run()

    assert code == 0
    assert not posted_artifacts
    assert not completions
    assert not errors
    assert any("bootstrap already claimed" in entry["message"] for entry in logs)


def test_orchestrator_bootstrap_payload_error_posts_failure(
    monkeypatch, tmp_path
) -> None:
    """Unexpected bootstrap failures should still be reported as errors."""

    workspace = tmp_path / "work"
    cfg_file = tmp_path / "config.toml"
    cfg_file.write_text("", encoding="utf-8")

    errors: list[dict[str, Any]] = []

    monkeypatch.setenv("BOOTSTRAP_MOCK_ENGINEER", "1")
    monkeypatch.setattr(
        "bootstrap.orchestrator.load_codex_config",
        lambda path: UserConfig(
            raw={},
            working_dir=workspace,
            sandbox_mode=None,
            approval_policy="unless-allow-listed",
            oss_provider=None,
            writable_roots=(workspace,),
            workspace_instructions="use workspace",
            instructions_source="inline",
            warnings=(),
        ),
    )
    monkeypatch.setattr(
        "bootstrap.orchestrator.fetch_bootstrap_payload",
        lambda *_args, **_kwargs: (_ for _ in ()).throw(RuntimeError("boom")),
    )
    monkeypatch.setattr(
        "bootstrap.orchestrator.post_error",
        lambda server_url, run_id, token, reason, summary=None: errors.append(
            {"reason": reason, "summary": summary}
        ),
    )

    cfg = BootstrapConfig(
        run_id="run-123",
        capability_token="secret",
        config_path=cfg_file,
        server_url="http://server",
        run_root=tmp_path,
    )
    orchestrator = BootstrapOrchestrator(cfg)
    code = orchestrator.run()

    assert code == 1
    assert errors
    assert any("boom" in entry["reason"] for entry in errors)


def test_ensure_workspace_git_info_exclude_entry_is_idempotent(tmp_path) -> None:
    cfg_file = tmp_path / "config.toml"
    cfg_file.write_text("", encoding="utf-8")
    workspace = tmp_path / "work"
    workspace.mkdir(parents=True, exist_ok=True)

    # Create .git/info/exclude with an existing entry
    exclude_path = workspace / ".git" / "info" / "exclude"
    exclude_path.parent.mkdir(parents=True, exist_ok=True)
    exclude_path.write_text(".codex_home/\n", encoding="utf-8")

    orchestrator = BootstrapOrchestrator(
        BootstrapConfig(
            run_id="run-123",
            capability_token="token",
            config_path=cfg_file,
            server_url="http://server",
            run_root=tmp_path,
        )
    )
    orchestrator.workspace = workspace

    orchestrator._ensure_workspace_git_info_exclude_entry(".codex_home/")
    orchestrator._ensure_workspace_git_info_exclude_entry(".codex_home/")

    lines = exclude_path.read_text(encoding="utf-8").splitlines()
    assert lines.count(".codex_home/") == 1


def test_resolve_workspace_rejects_outside_writable(monkeypatch, tmp_path):
    cfg_file = tmp_path / "config.toml"
    cfg_file.write_text("", encoding="utf-8")

    orchestrator = BootstrapOrchestrator(
        BootstrapConfig(
            run_id="run-1",
            capability_token="token",
            config_path=cfg_file,
            server_url="http://server",
            run_root=tmp_path,
        )
    )
    orchestrator.user_config = UserConfig(
        raw={},
        working_dir=tmp_path / "work",
        sandbox_mode="workspace-write",
        approval_policy="unless-allow-listed",
        oss_provider=None,
        writable_roots=(tmp_path / "other",),
        workspace_instructions="instr",
        instructions_source="inline",
        warnings=(),
    )

    with pytest.raises(SystemExit):
        orchestrator._resolve_workspace()


def test_resolve_workspace_expands_run_id_placeholder(tmp_path) -> None:
    cfg_file = tmp_path / "config.toml"
    cfg_file.write_text("", encoding="utf-8")

    orchestrator = BootstrapOrchestrator(
        BootstrapConfig(
            run_id="run-xyz",
            capability_token="token",
            config_path=cfg_file,
            server_url="http://server",
            run_root=tmp_path,
        )
    )

    templated = tmp_path / "runs" / "<run_id>"
    orchestrator.user_config = UserConfig(
        raw={},
        working_dir=templated,
        sandbox_mode="workspace-write",
        approval_policy="unless-allow-listed",
        oss_provider=None,
        writable_roots=(tmp_path,),
        workspace_instructions="instr",
        instructions_source="inline",
        warnings=(),
    )

    orchestrator._resolve_workspace()
    assert orchestrator.workspace == (tmp_path / "runs" / "run-xyz").resolve()


def test_finalize_git_repo_pushes_on_failure(monkeypatch, tmp_path) -> None:
    """Non-zero exit should still commit and push changes."""
    cfg = BootstrapConfig(
        run_id="run-123",
        capability_token="token",
        config_path=tmp_path / "config.toml",
        server_url="http://server",
        run_root=tmp_path,
    )
    orchestrator = BootstrapOrchestrator(cfg)
    orchestrator.git_config = GitConfig(
        workspace=tmp_path,
        repo_context=RepoContext(
            repo_url="https://github.com/testuser/repo",
            repo_owner="testuser",
            repo_name="repo",
            branch_name="flywheel/test",
            base_branch="main",
        ),
        github_token="old-token",
        log_fn=lambda *_: None,
    )

    commit_calls = []
    push_calls = []
    update_calls = []

    artifact_output = tmp_path / "artifacts" / "out.txt"
    artifact_output.parent.mkdir(parents=True, exist_ok=True)
    artifact_output.write_text("artifact\n", encoding="utf-8")
    manifest = tmp_path / "flywheel_artifacts.json"
    manifest.write_text(
        '[{"artifact_type":"text","payload":{"path":"artifacts/out.txt"}}]',
        encoding="utf-8",
    )
    orchestrator.workspace = tmp_path

    def _record_commit(
        config: GitConfig, message: str, *, exclude_paths: tuple[str, ...] = ()
    ) -> bool:
        commit_calls.append((message, tuple(sorted(exclude_paths))))
        return True

    def _record_push(config: GitConfig) -> bool:
        push_calls.append(True)
        return True

    def _record_update_auth(config: GitConfig, token: str) -> bool:
        update_calls.append(token)
        return True

    monkeypatch.setattr("bootstrap.orchestrator.commit_changes", _record_commit)
    monkeypatch.setattr("bootstrap.orchestrator.push_changes", _record_push)
    monkeypatch.setattr(
        "bootstrap.orchestrator.fetch_github_token",
        lambda *args, **kwargs: "new-token",
    )
    monkeypatch.setattr("bootstrap.orchestrator.update_git_auth", _record_update_auth)

    orchestrator._finalize_git_repo(exit_code=2)

    assert commit_calls
    assert "[WIP]" in commit_calls[0][0]
    assert "artifacts/out.txt" in commit_calls[0][1]
    assert "flywheel_artifacts.json" in commit_calls[0][1]
    assert update_calls == ["new-token"]
    assert push_calls


def test_artifact_paths_for_git_exclusion_handles_missing_manifest(tmp_path) -> None:
    """Missing manifest should produce no exclusions."""
    cfg = BootstrapConfig(
        run_id="run-123",
        capability_token="token",
        config_path=tmp_path / "config.toml",
        server_url="http://server",
        run_root=tmp_path,
    )
    orchestrator = BootstrapOrchestrator(cfg)
    orchestrator.workspace = tmp_path

    assert orchestrator._artifact_paths_for_git_exclusion() == ()


def test_load_artifacts_with_content_inlines_json_and_checkpoint(
    monkeypatch, tmp_path
) -> None:
    """Files below threshold should be embedded into artifact payloads."""
    cfg = BootstrapConfig(
        run_id="run-blob",
        capability_token="token",
        config_path=tmp_path / "config.toml",
        server_url="http://server",
        run_root=tmp_path,
    )
    orchestrator = BootstrapOrchestrator(cfg)
    orchestrator.workspace = tmp_path
    orchestrator._artifact_blob_threshold_bytes = 1024

    artifacts_dir = tmp_path / "artifacts"
    artifacts_dir.mkdir(parents=True, exist_ok=True)

    json_file = artifacts_dir / "metrics.json"
    json_file.write_text('{"acc": 0.91, "loss": 0.12}', encoding="utf-8")

    checkpoint_file = artifacts_dir / "model.bin"
    checkpoint_file.write_bytes(b"\x00\x01\x02\x03checkpoint")

    manifest_entries = [
        {"artifact_type": "json", "payload": {"path": "artifacts/metrics.json"}},
        {"artifact_type": "text", "payload": {"path": "artifacts/model.bin"}},
    ]
    monkeypatch.setattr(
        "bootstrap.orchestrator.read_manifest",
        lambda _path: ManifestResult(
            status=ManifestStatus.VALID,
            artifacts=manifest_entries,
        ),
    )

    result, enriched = orchestrator._load_artifacts_with_content(
        tmp_path / "flywheel_artifacts.json"
    )
    assert result.status == ManifestStatus.VALID
    assert len(enriched) == 2

    json_payload = enriched[0]["payload"]
    assert isinstance(json_payload, dict)
    assert json_payload["content"] == {"acc": 0.91, "loss": 0.12}
    assert isinstance(json_payload.get("size_bytes"), int)

    checkpoint_artifact = enriched[1]
    assert checkpoint_artifact["artifact_type"] == "checkpoint"
    checkpoint_payload = checkpoint_artifact["payload"]
    assert isinstance(checkpoint_payload, dict)
    assert isinstance(checkpoint_payload.get("size_bytes"), int)
    assert str(checkpoint_payload.get("data_url", "")).startswith(
        "data:application/octet-stream;base64,"
    )


def test_load_artifacts_with_content_marks_large_file(monkeypatch, tmp_path) -> None:
    """Files above threshold should not be inlined and should carry a size error."""
    cfg = BootstrapConfig(
        run_id="run-blob-large",
        capability_token="token",
        config_path=tmp_path / "config.toml",
        server_url="http://server",
        run_root=tmp_path,
    )
    orchestrator = BootstrapOrchestrator(cfg)
    orchestrator.workspace = tmp_path
    orchestrator._artifact_blob_threshold_bytes = 16

    artifacts_dir = tmp_path / "artifacts"
    artifacts_dir.mkdir(parents=True, exist_ok=True)
    large_text = artifacts_dir / "large.txt"
    large_text.write_text("x" * 128, encoding="utf-8")

    monkeypatch.setattr(
        "bootstrap.orchestrator.read_manifest",
        lambda _path: ManifestResult(
            status=ManifestStatus.VALID,
            artifacts=[
                {"artifact_type": "text", "payload": {"path": "artifacts/large.txt"}}
            ],
        ),
    )

    _, enriched = orchestrator._load_artifacts_with_content(
        tmp_path / "flywheel_artifacts.json"
    )
    payload = enriched[0]["payload"]
    assert isinstance(payload, dict)
    assert "rendering_error" in payload
    assert "max 16" in str(payload["rendering_error"])
    assert "content" not in payload


def test_malformed_manifest_triggers_two_retry_attempts(monkeypatch, tmp_path):
    """When manifest is malformed, the orchestrator retries up to 2 times with feedback."""

    workspace = tmp_path / "work"
    good_artifacts = [{"artifact_type": "text", "payload": {"content": "hi"}}]

    cfg_file = tmp_path / "config.toml"
    cfg_file.write_text("", encoding="utf-8")

    heartbeats: list[dict[str, Any]] = []
    logs: list[dict[str, Any]] = []
    posted_artifacts: list[dict[str, Any]] = []
    completions: list[dict[str, Any]] = []
    errors: list[dict[str, Any]] = []

    monkeypatch.setenv("BOOTSTRAP_MOCK_ENGINEER", "1")
    monkeypatch.setattr(
        "bootstrap.orchestrator.load_codex_config",
        lambda path: UserConfig(
            raw={},
            working_dir=workspace,
            sandbox_mode=None,
            approval_policy="unless-allow-listed",
            oss_provider=None,
            writable_roots=(workspace,),
            workspace_instructions="use workspace",
            instructions_source="inline",
            warnings=(),
        ),
    )
    monkeypatch.setattr(
        "bootstrap.orchestrator.fetch_bootstrap_payload",
        lambda server_url, run_id, token: BootstrapPayload(prompt="PROMPT"),
    )

    calls = {"manifest": 0, "retry": 0}

    def fake_read_manifest(path):
        calls["manifest"] += 1
        # First two reads return malformed, third returns good data
        if calls["manifest"] <= 2:
            return ManifestResult(
                status=ManifestStatus.MALFORMED,
                artifacts=[],
                error="artifact manifest wrapped in dict",
            )
        return ManifestResult(status=ManifestStatus.VALID, artifacts=good_artifacts)

    monkeypatch.setattr("bootstrap.orchestrator.read_manifest", fake_read_manifest)

    def fake_attempt_artifact_retry(self, manifest_path, manifest_result):
        calls["retry"] += 1

    monkeypatch.setattr(
        "bootstrap.orchestrator.BootstrapOrchestrator._attempt_artifact_retry",
        fake_attempt_artifact_retry,
    )

    monkeypatch.setattr("bootstrap.orchestrator.HEARTBEAT_INTERVAL_SECONDS", 0.01)
    monkeypatch.setattr(
        "bootstrap.orchestrator.post_heartbeat",
        lambda server_url, run_id, token, summary: heartbeats.append(
            {"summary": summary}
        ),
    )
    monkeypatch.setattr(
        "bootstrap.orchestrator.post_log",
        lambda server_url, run_id, token, level, message, extra: logs.append(
            {"level": level, "message": message}
        ),
    )
    monkeypatch.setattr(
        "bootstrap.orchestrator.post_artifacts",
        lambda server_url, run_id, token, entries: posted_artifacts.extend(entries),
    )
    monkeypatch.setattr(
        "bootstrap.orchestrator.post_completion",
        lambda server_url, run_id, token, summary: completions.append(
            {"summary": summary}
        ),
    )
    monkeypatch.setattr(
        "bootstrap.orchestrator.post_error",
        lambda server_url, run_id, token, reason, summary=None: errors.append(
            {"reason": reason, "summary": summary}
        ),
    )

    cfg = BootstrapConfig(
        run_id="run-456",
        capability_token="secret",
        config_path=cfg_file,
        server_url="http://server",
        run_root=tmp_path,
    )
    orchestrator = BootstrapOrchestrator(cfg)
    code = orchestrator.run()

    assert code == 0
    # Should have attempted retry twice (max retries = 2)
    assert calls["retry"] == 2
    # manifest read: initial + after 1st retry + after 2nd retry = 3
    assert calls["manifest"] == 3
    assert posted_artifacts == good_artifacts
    assert completions and not errors


def test_malformed_manifest_exhausts_retries_still_completes(monkeypatch, tmp_path):
    """When all retry attempts fail to produce artifacts, run still completes (no crash)."""

    workspace = tmp_path / "work"

    cfg_file = tmp_path / "config.toml"
    cfg_file.write_text("", encoding="utf-8")

    completions: list[dict[str, Any]] = []
    errors: list[dict[str, Any]] = []

    monkeypatch.setenv("BOOTSTRAP_MOCK_ENGINEER", "1")
    monkeypatch.setattr(
        "bootstrap.orchestrator.load_codex_config",
        lambda path: UserConfig(
            raw={},
            working_dir=workspace,
            sandbox_mode=None,
            approval_policy="unless-allow-listed",
            oss_provider=None,
            writable_roots=(workspace,),
            workspace_instructions="use workspace",
            instructions_source="inline",
            warnings=(),
        ),
    )
    monkeypatch.setattr(
        "bootstrap.orchestrator.fetch_bootstrap_payload",
        lambda server_url, run_id, token: BootstrapPayload(prompt="PROMPT"),
    )

    calls = {"retry": 0}

    # Always return malformed (manifest never gets fixed)
    monkeypatch.setattr(
        "bootstrap.orchestrator.read_manifest",
        lambda path: ManifestResult(
            status=ManifestStatus.MALFORMED,
            artifacts=[],
            error="artifact manifest is empty",
        ),
    )

    def fake_attempt_artifact_retry(self, manifest_path, manifest_result):
        calls["retry"] += 1

    monkeypatch.setattr(
        "bootstrap.orchestrator.BootstrapOrchestrator._attempt_artifact_retry",
        fake_attempt_artifact_retry,
    )

    monkeypatch.setattr("bootstrap.orchestrator.HEARTBEAT_INTERVAL_SECONDS", 0.01)
    monkeypatch.setattr(
        "bootstrap.orchestrator.post_heartbeat",
        lambda server_url, run_id, token, summary: None,
    )
    monkeypatch.setattr(
        "bootstrap.orchestrator.post_log",
        lambda server_url, run_id, token, level, message, extra: None,
    )
    monkeypatch.setattr(
        "bootstrap.orchestrator.post_artifacts",
        lambda server_url, run_id, token, entries: None,
    )
    monkeypatch.setattr(
        "bootstrap.orchestrator.post_completion",
        lambda server_url, run_id, token, summary: completions.append(
            {"summary": summary}
        ),
    )
    monkeypatch.setattr(
        "bootstrap.orchestrator.post_error",
        lambda server_url, run_id, token, reason, summary=None: errors.append(
            {"reason": reason, "summary": summary}
        ),
    )

    cfg = BootstrapConfig(
        run_id="run-789",
        capability_token="secret",
        config_path=cfg_file,
        server_url="http://server",
        run_root=tmp_path,
    )
    orchestrator = BootstrapOrchestrator(cfg)
    code = orchestrator.run()

    assert code == 0
    # Exhausted all 2 retry attempts
    assert calls["retry"] == 2
    # Still completes (exit code was 0)
    assert completions and not errors


def test_handle_control_payload_acks_take_over(monkeypatch, tmp_path) -> None:
    cfg_file = tmp_path / "config.toml"
    cfg_file.write_text("", encoding="utf-8")
    cfg = BootstrapConfig(
        run_id="run-1",
        capability_token="token-1",
        config_path=cfg_file,
        server_url="http://server",
        run_root=tmp_path,
    )
    orchestrator = BootstrapOrchestrator(cfg)

    calls: list[dict[str, str]] = []
    logs: list[str] = []

    def fake_ack(server_url, run_id, token, command_id, status, detail):
        calls.append(
            {
                "server_url": server_url,
                "run_id": run_id,
                "token": token,
                "command_id": command_id,
                "status": status,
                "detail": detail,
            }
        )

    monkeypatch.setattr("bootstrap.orchestrator.ack_run_control", fake_ack)
    monkeypatch.setattr(
        orchestrator,
        "_log",
        lambda message, level="info", extra=None: logs.append(message),
    )

    orchestrator._handle_control_payload(
        {"command_id": "cmd-1", "command": "take_over"}
    )

    assert calls == [
        {
            "server_url": "http://server",
            "run_id": "run-1",
            "token": "token-1",
            "command_id": "cmd-1",
            "status": "applied",
            "detail": "take_over acknowledged; interactive mode active",
        }
    ]
    assert logs
    assert orchestrator._get_interaction_mode() == "interactive"


def test_handle_control_payload_take_over_cancels_active_prompt(
    monkeypatch, tmp_path
) -> None:
    cfg_file = tmp_path / "config.toml"
    cfg_file.write_text("", encoding="utf-8")
    cfg = BootstrapConfig(
        run_id="run-terminate",
        capability_token="token-terminate",
        config_path=cfg_file,
        server_url="http://server",
        run_root=tmp_path,
    )
    orchestrator = BootstrapOrchestrator(cfg)

    calls: list[dict[str, str]] = []

    def fake_ack(server_url, run_id, token, command_id, status, detail):
        calls.append(
            {
                "server_url": server_url,
                "run_id": run_id,
                "token": token,
                "command_id": command_id,
                "status": status,
                "detail": detail,
            }
        )

    monkeypatch.setattr("bootstrap.orchestrator.ack_run_control", fake_ack)
    monkeypatch.setattr(orchestrator, "_cancel_active_prompt", lambda: True)
    monkeypatch.setattr(
        orchestrator,
        "_log",
        lambda message, level="info", extra=None: None,
    )

    orchestrator._handle_control_payload(
        {"command_id": "cmd-term", "command": "take_over"}
    )

    assert calls[0]["status"] == "applied"
    assert "autonomous execution interrupted" in calls[0]["detail"]


def test_handle_control_payload_acks_resume_autonomous(monkeypatch, tmp_path) -> None:
    cfg_file = tmp_path / "config.toml"
    cfg_file.write_text("", encoding="utf-8")
    cfg = BootstrapConfig(
        run_id="run-resume",
        capability_token="token-resume",
        config_path=cfg_file,
        server_url="http://server",
        run_root=tmp_path,
    )
    orchestrator = BootstrapOrchestrator(cfg)
    orchestrator._set_interaction_mode("interactive")

    calls: list[dict[str, str]] = []

    def fake_ack(server_url, run_id, token, command_id, status, detail):
        calls.append(
            {
                "server_url": server_url,
                "run_id": run_id,
                "token": token,
                "command_id": command_id,
                "status": status,
                "detail": detail,
            }
        )

    monkeypatch.setattr("bootstrap.orchestrator.ack_run_control", fake_ack)
    monkeypatch.setattr(
        orchestrator,
        "_log",
        lambda message, level="info", extra=None: None,
    )

    orchestrator._handle_control_payload(
        {"command_id": "cmd-resume", "command": "resume_autonomous"}
    )

    assert orchestrator._get_interaction_mode() == "autonomous"
    assert calls == [
        {
            "server_url": "http://server",
            "run_id": "run-resume",
            "token": "token-resume",
            "command_id": "cmd-resume",
            "status": "applied",
            "detail": "resume_autonomous acknowledged; autonomous execution will resume",
        }
    ]


def test_handle_control_payload_acks_unsupported_as_ignored(
    monkeypatch, tmp_path
) -> None:
    cfg_file = tmp_path / "config.toml"
    cfg_file.write_text("", encoding="utf-8")
    cfg = BootstrapConfig(
        run_id="run-2",
        capability_token="token-2",
        config_path=cfg_file,
        server_url="http://server",
        run_root=tmp_path,
    )
    orchestrator = BootstrapOrchestrator(cfg)

    calls: list[dict[str, str]] = []

    def fake_ack(server_url, run_id, token, command_id, status, detail):
        calls.append(
            {
                "server_url": server_url,
                "run_id": run_id,
                "token": token,
                "command_id": command_id,
                "status": status,
                "detail": detail,
            }
        )

    monkeypatch.setattr("bootstrap.orchestrator.ack_run_control", fake_ack)
    monkeypatch.setattr(
        orchestrator,
        "_log",
        lambda message, level="info", extra=None: None,
    )

    orchestrator._handle_control_payload(
        {"command_id": "cmd-2", "command": "some_new_command"}
    )

    assert calls == [
        {
            "server_url": "http://server",
            "run_id": "run-2",
            "token": "token-2",
            "command_id": "cmd-2",
            "status": "ignored",
            "detail": "unsupported command 'some_new_command'",
        }
    ]


def test_handle_control_payload_send_input_from_autonomous_is_applied(
    monkeypatch, tmp_path
) -> None:
    cfg_file = tmp_path / "config.toml"
    cfg_file.write_text("", encoding="utf-8")
    cfg = BootstrapConfig(
        run_id="run-3",
        capability_token="token-3",
        config_path=cfg_file,
        server_url="http://server",
        run_root=tmp_path,
    )
    orchestrator = BootstrapOrchestrator(cfg)

    calls: list[dict[str, str]] = []
    done = threading.Event()

    def fake_ack(server_url, run_id, token, command_id, status, detail):
        calls.append(
            {
                "server_url": server_url,
                "run_id": run_id,
                "token": token,
                "command_id": command_id,
                "status": status,
                "detail": detail,
            }
        )
        done.set()

    monkeypatch.setattr("bootstrap.orchestrator.ack_run_control", fake_ack)
    monkeypatch.setattr(
        orchestrator,
        "_run_prompt_turn",
        lambda _prompt: (True, "prompt applied"),
    )
    monkeypatch.setattr(
        orchestrator, "_steer_active_turn", lambda _text: (False, "no active")
    )
    monkeypatch.setattr(
        orchestrator,
        "_log",
        lambda message, level="info", extra=None: None,
    )

    orchestrator._handle_control_payload(
        {"command_id": "cmd-3", "command": "send_input", "payload": {"text": "ping"}}
    )

    assert done.wait(timeout=2), "expected send_input worker to ACK"

    assert calls == [
        {
            "server_url": "http://server",
            "run_id": "run-3",
            "token": "token-3",
            "command_id": "cmd-3",
            "status": "applied",
            "detail": "send_input: prompt applied",
        }
    ]


def test_should_skip_terminalization_only_for_interrupted_interactive_turn(
    tmp_path,
) -> None:
    cfg_file = tmp_path / "config.toml"
    cfg_file.write_text("", encoding="utf-8")
    cfg = BootstrapConfig(
        run_id="run-loop",
        capability_token="token-loop",
        config_path=cfg_file,
        server_url="http://server",
        run_root=tmp_path,
    )
    orchestrator = BootstrapOrchestrator(cfg)

    orchestrator._set_interaction_mode("interactive")
    assert (
        orchestrator._should_retry_after_turn(
            success=False,
            detail="turn_id=0 status=interrupted",
        )
        is True
    )

    # Interactive mode should not swallow successful turns.
    assert (
        orchestrator._should_retry_after_turn(
            success=True,
            detail="turn_id=0 status=completed",
        )
        is False
    )

    # Non-interactive interrupted turns should not be retried indefinitely.
    orchestrator._set_interaction_mode("autonomous")
    assert (
        orchestrator._should_retry_after_turn(
            success=False,
            detail="turn_id=0 status=interrupted",
        )
        is False
    )


def test_handle_control_payload_send_input_is_acked_by_worker_not_inline(
    monkeypatch, tmp_path
) -> None:
    cfg_file = tmp_path / "config.toml"
    cfg_file.write_text("", encoding="utf-8")
    cfg = BootstrapConfig(
        run_id="run-4",
        capability_token="token-4",
        config_path=cfg_file,
        server_url="http://server",
        run_root=tmp_path,
    )
    orchestrator = BootstrapOrchestrator(cfg)

    calls: list[dict[str, str]] = []
    allow_finish = threading.Event()

    def fake_ack(server_url, run_id, token, command_id, status, detail):
        calls.append(
            {
                "server_url": server_url,
                "run_id": run_id,
                "token": token,
                "command_id": command_id,
                "status": status,
                "detail": detail,
            }
        )

    orchestrator._set_interaction_mode("interactive")
    monkeypatch.setattr("bootstrap.orchestrator.ack_run_control", fake_ack)
    monkeypatch.setattr(
        orchestrator,
        "_run_prompt_turn",
        lambda _text: (allow_finish.wait(timeout=2), "prompt applied"),
    )
    monkeypatch.setattr(
        orchestrator,
        "_log",
        lambda message, level="info", extra=None: None,
    )
    monkeypatch.setattr(
        orchestrator, "_steer_active_turn", lambda _text: (False, "no active")
    )

    orchestrator._handle_control_payload(
        {"command_id": "cmd-4", "command": "send_input", "payload": {"text": "hello"}}
    )

    # Worker should not ACK until the prompt is allowed to complete.
    assert calls == []
    allow_finish.set()
    with orchestrator._interactive_input_lock:
        thread = orchestrator._interactive_input_thread
    assert thread is not None
    thread.join(timeout=2)
    assert calls and calls[0]["status"] == "applied"


def test_handle_app_server_notification_posts_log(monkeypatch, tmp_path) -> None:
    cfg_file = tmp_path / "config.toml"
    cfg_file.write_text("", encoding="utf-8")
    cfg = BootstrapConfig(
        run_id="run-events",
        capability_token="token-events",
        config_path=cfg_file,
        server_url="http://server",
        run_root=tmp_path,
    )
    orchestrator = BootstrapOrchestrator(cfg)

    calls: list[dict[str, Any]] = []

    monkeypatch.setattr(
        "bootstrap.orchestrator.post_app_server_event",
        lambda server_url, run_id, token, method, params: calls.append(
            {"method": method, "params": params}
        ),
    )

    orchestrator._handle_app_server_notification(
        {
            "method": "item/agentMessage/delta",
            "params": {
                "threadId": "thr-1",
                "turnId": "turn-1",
                "itemId": "msg-1",
                "delta": "\u001b[31merror\u001b[0m line",
            },
        }
    )
    orchestrator._flush_delta_buffers()

    assert calls
    assert calls[0]["method"] == "item/agentMessage/delta"
    params = calls[0]["params"]
    assert isinstance(params, dict)
    assert params["threadId"] == "thr-1"
    assert params["turnId"] == "turn-1"
    assert params["itemId"] == "msg-1"
    assert params["delta"] == "error line"


def test_handle_app_server_notification_dedupes_cumulative_deltas(
    monkeypatch, tmp_path
) -> None:
    cfg_file = tmp_path / "config.toml"
    cfg_file.write_text("", encoding="utf-8")
    cfg = BootstrapConfig(
        run_id="run-events",
        capability_token="token-events",
        config_path=cfg_file,
        server_url="http://server",
        run_root=tmp_path,
    )
    orchestrator = BootstrapOrchestrator(cfg)

    calls: list[dict[str, Any]] = []

    monkeypatch.setattr(
        "bootstrap.orchestrator.post_app_server_event",
        lambda server_url, run_id, token, method, params: calls.append(
            {"method": method, "params": params}
        ),
    )

    # Simulate cumulative deltas ("Hello" then "Hello world"). The bootstrapper
    # should forward "Hello" + " world" (not "Hello" + "Hello world").
    for text in ("Hello", "Hello world"):
        orchestrator._handle_app_server_notification(
            {
                "method": "item/agentMessage/delta",
                "params": {
                    "threadId": "thr-1",
                    "turnId": "turn-1",
                    "itemId": "msg-1",
                    "delta": text,
                },
            }
        )

    orchestrator._flush_delta_buffers()

    assert calls
    assert calls[0]["method"] == "item/agentMessage/delta"
    params = calls[0]["params"]
    assert isinstance(params, dict)
    assert params["delta"] == "Hello world"


def test_handle_app_server_notification_dedupes_overlapping_deltas(
    monkeypatch, tmp_path
) -> None:
    cfg_file = tmp_path / "config.toml"
    cfg_file.write_text("", encoding="utf-8")
    cfg = BootstrapConfig(
        run_id="run-events",
        capability_token="token-events",
        config_path=cfg_file,
        server_url="http://server",
        run_root=tmp_path,
    )
    orchestrator = BootstrapOrchestrator(cfg)

    calls: list[dict[str, Any]] = []

    monkeypatch.setattr(
        "bootstrap.orchestrator.post_app_server_event",
        lambda server_url, run_id, token, method, params: calls.append(
            {"method": method, "params": params}
        ),
    )

    # Simulate overlapping deltas (not strictly prefix-cumulative). A naive
    # appender would produce "InspectInspectinging the the ...".
    for text in ("Inspect", "ing the ", "the workspace"):
        orchestrator._handle_app_server_notification(
            {
                "method": "item/agentMessage/delta",
                "params": {
                    "threadId": "thr-1",
                    "turnId": "turn-1",
                    "itemId": "msg-1",
                    "delta": text,
                },
            }
        )

    orchestrator._flush_delta_buffers()

    assert calls
    params = calls[0]["params"]
    assert isinstance(params, dict)
    assert params["delta"] == "Inspecting the workspace"


def test_handle_app_server_notification_delta_stream_key_includes_turn(
    monkeypatch, tmp_path
) -> None:
    cfg_file = tmp_path / "config.toml"
    cfg_file.write_text("", encoding="utf-8")
    cfg = BootstrapConfig(
        run_id="run-events",
        capability_token="token-events",
        config_path=cfg_file,
        server_url="http://server",
        run_root=tmp_path,
    )
    orchestrator = BootstrapOrchestrator(cfg)

    calls: list[dict[str, Any]] = []

    monkeypatch.setattr(
        "bootstrap.orchestrator.post_app_server_event",
        lambda server_url, run_id, token, method, params: calls.append(
            {"method": method, "params": params}
        ),
    )

    # Some app-server deltas omit itemId. Ensure we don't mix deltas across turns.
    orchestrator._handle_app_server_notification(
        {
            "method": "item/agentMessage/delta",
            "params": {"threadId": "thr-1", "turnId": "turn-1", "delta": "Hello"},
        }
    )
    orchestrator._flush_delta_buffers()
    orchestrator._handle_app_server_notification(
        {
            "method": "item/agentMessage/delta",
            "params": {"threadId": "thr-1", "turnId": "turn-2", "delta": "World"},
        }
    )
    orchestrator._flush_delta_buffers()

    assert [c["params"]["delta"] for c in calls] == ["Hello", "World"]


def test_handle_app_server_notification_infers_missing_item_id_from_item_started(
    monkeypatch, tmp_path
) -> None:
    cfg_file = tmp_path / "config.toml"
    cfg_file.write_text("", encoding="utf-8")
    cfg = BootstrapConfig(
        run_id="run-events",
        capability_token="token-events",
        config_path=cfg_file,
        server_url="http://server",
        run_root=tmp_path,
    )
    orchestrator = BootstrapOrchestrator(cfg)

    calls: list[dict[str, Any]] = []

    monkeypatch.setattr(
        "bootstrap.orchestrator.post_app_server_event",
        lambda server_url, run_id, token, method, params: calls.append(
            {"method": method, "params": params}
        ),
    )

    # Codex app-server occasionally omits itemId on delta notifications. If we don't
    # infer it, we split one logical message across two streams and the UI shows
    # duplicated words like "InspectInspectinging...".
    orchestrator._handle_app_server_notification(
        {
            "method": "item/started",
            "params": {
                "threadId": "thr-1",
                "turnId": "turn-1",
                "item": {"id": "msg-1", "type": "agentMessage"},
            },
        }
    )
    # First delta: missing itemId.
    orchestrator._handle_app_server_notification(
        {
            "method": "item/agentMessage/delta",
            "params": {"threadId": "thr-1", "turnId": "turn-1", "delta": "Inspect"},
        }
    )
    # Next delta: itemId present.
    orchestrator._handle_app_server_notification(
        {
            "method": "item/agentMessage/delta",
            "params": {
                "threadId": "thr-1",
                "turnId": "turn-1",
                "itemId": "msg-1",
                "delta": "Inspecting the workspace",
            },
        }
    )
    orchestrator._flush_delta_buffers()

    # We should forward a single clean stream, not two streams that duplicate text.
    forwarded = [c for c in calls if c.get("method") == "item/agentMessage/delta"]
    assert len(forwarded) == 1
    params = forwarded[0]["params"]
    assert isinstance(params, dict)
    assert params.get("itemId") == "msg-1"
    assert params.get("delta") == "Inspecting the workspace"


def test_send_input_is_queued_not_ignored_when_previous_input_running(
    monkeypatch, tmp_path
) -> None:
    cfg_file = tmp_path / "config.toml"
    cfg_file.write_text("", encoding="utf-8")
    cfg = BootstrapConfig(
        run_id="run-control",
        capability_token="token-control",
        config_path=cfg_file,
        server_url="http://server",
        run_root=tmp_path,
    )
    orchestrator = BootstrapOrchestrator(cfg)
    orchestrator._set_interaction_mode("interactive")

    # Avoid real network calls.
    acked: list[tuple[str, str]] = []
    monkeypatch.setattr(
        "bootstrap.orchestrator.ack_run_control",
        lambda _server_url,
        _run_id,
        _token,
        *,
        command_id,
        status,
        detail=None: acked.append((command_id, status)),
    )

    prompts: list[str] = []

    def _fake_run_prompt_turn(text: str) -> tuple[bool, str]:
        prompts.append(text)
        return (True, "ok")

    monkeypatch.setattr(orchestrator, "_run_prompt_turn", _fake_run_prompt_turn)
    # No active turn to steer in this test.
    monkeypatch.setattr(
        orchestrator, "_steer_active_turn", lambda _text: (False, "no active")
    )
    monkeypatch.setattr(orchestrator, "_log", lambda *_args, **_kwargs: None)

    for cmd_id, text in (("cmd-1", "first"), ("cmd-2", "second")):
        orchestrator._handle_control_payload(
            {"command_id": cmd_id, "command": "send_input", "payload": {"text": text}}
        )

    deadline = time.time() + 2
    while time.time() < deadline and len(acked) < 2:
        time.sleep(0.01)

    assert prompts == ["first", "second"]
    assert ("cmd-1", "applied") in acked
    assert ("cmd-2", "applied") in acked


def test_send_input_steers_active_turn_when_available(monkeypatch, tmp_path) -> None:
    cfg_file = tmp_path / "config.toml"
    cfg_file.write_text("", encoding="utf-8")
    cfg = BootstrapConfig(
        run_id="run-control",
        capability_token="token-control",
        config_path=cfg_file,
        server_url="http://server",
        run_root=tmp_path,
    )
    orchestrator = BootstrapOrchestrator(cfg)
    orchestrator._set_interaction_mode("interactive")

    # Pretend we have an active turn.
    orchestrator._thread_id = "thr-1"
    with orchestrator._turn_state_lock:
        orchestrator._active_turn_id = "turn-1"

    class _StubApp:
        def __init__(self) -> None:
            self.calls: list[tuple[str, dict[str, object]]] = []

        def request(
            self, *, method: str, params: dict[str, object], timeout_seconds: float = 60
        ):
            self.calls.append((method, params))
            return {}

    app = _StubApp()
    orchestrator._app = app  # type: ignore[assignment]

    acked: list[tuple[str, str]] = []
    monkeypatch.setattr(
        "bootstrap.orchestrator.ack_run_control",
        lambda _server_url,
        _run_id,
        _token,
        *,
        command_id,
        status,
        detail=None: acked.append((command_id, status)),
    )

    # Ensure we don't start a new turn when steering succeeds.
    monkeypatch.setattr(
        orchestrator, "_run_prompt_turn", lambda _text: (False, "should not run")
    )

    orchestrator._handle_control_payload(
        {"command_id": "cmd-1", "command": "send_input", "payload": {"text": "hi"}}
    )
    # Worker may complete before we read the thread handle; wait for ack instead.
    deadline = time.time() + 2
    while time.time() < deadline and not acked:
        time.sleep(0.01)
    assert acked == [("cmd-1", "applied")]
    assert app.calls
    assert app.calls[0][0] == "turn/steer"


def test_handle_app_server_notification_truncates_long_text(
    monkeypatch, tmp_path
) -> None:
    cfg_file = tmp_path / "config.toml"
    cfg_file.write_text("", encoding="utf-8")
    cfg = BootstrapConfig(
        run_id="run-long",
        capability_token="token-long",
        config_path=cfg_file,
        server_url="http://server",
        run_root=tmp_path,
    )
    orchestrator = BootstrapOrchestrator(cfg)

    calls: list[dict[str, Any]] = []

    monkeypatch.setattr(
        "bootstrap.orchestrator.post_app_server_event",
        lambda *_args, **kwargs: calls.append(
            {"method": kwargs.get("method"), "params": kwargs.get("params")}
        ),
    )

    long_text = "x" * (MAX_TERMINAL_OUTPUT_CHARS + 128)
    orchestrator._handle_app_server_notification(
        {
            "method": "item/agentMessage/delta",
            "params": {
                "threadId": "thr-1",
                "turnId": "turn-1",
                "itemId": "msg-1",
                "delta": long_text,
            },
        }
    )
    orchestrator._flush_delta_buffers()

    assert calls
    params = calls[0]["params"]
    assert isinstance(params, dict)
    message = params.get("delta")
    assert isinstance(message, str)
    assert message.endswith(" [truncated]")
    assert len(message) == MAX_TERMINAL_OUTPUT_CHARS


def test_handle_app_server_item_completed_failed_is_error(
    monkeypatch, tmp_path
) -> None:
    cfg_file = tmp_path / "config.toml"
    cfg_file.write_text("", encoding="utf-8")
    cfg = BootstrapConfig(
        run_id="run-tool",
        capability_token="token-tool",
        config_path=cfg_file,
        server_url="http://server",
        run_root=tmp_path,
    )
    orchestrator = BootstrapOrchestrator(cfg)

    calls: list[dict[str, Any]] = []
    monkeypatch.setattr(
        "bootstrap.orchestrator.post_app_server_event",
        lambda *_args, **kwargs: calls.append(
            {"method": kwargs.get("method"), "params": kwargs.get("params")}
        ),
    )

    orchestrator._handle_app_server_notification(
        {
            "method": "item/completed",
            "params": {
                "threadId": "thr-1",
                "turnId": "turn-1",
                "item": {
                    "type": "commandExecution",
                    "id": "item-1",
                    "command": "false",
                    "cwd": "/tmp",
                    "processId": None,
                    "status": "failed",
                    "commandActions": [],
                    "aggregatedOutput": "oops",
                    "exitCode": 1,
                    "durationMs": 1,
                },
            },
        }
    )

    assert calls and calls[0]["method"] == "item/completed"


def test_send_input_worker_acks_when_done(monkeypatch, tmp_path) -> None:
    cfg_file = tmp_path / "config.toml"
    cfg_file.write_text("", encoding="utf-8")
    cfg = BootstrapConfig(
        run_id="run-input",
        capability_token="token-input",
        config_path=cfg_file,
        server_url="http://server",
        run_root=tmp_path,
    )
    orchestrator = BootstrapOrchestrator(cfg)
    orchestrator._set_interaction_mode("interactive")

    calls: list[dict[str, str]] = []
    done = threading.Event()

    monkeypatch.setattr(
        orchestrator,
        "_run_prompt_turn",
        lambda _prompt: (True, "prompt applied"),
    )
    monkeypatch.setattr(
        orchestrator,
        "_log",
        lambda *_args, **_kwargs: None,
    )

    def fake_ack_run_control(
        _server_url: str,
        _run_id: str,
        _token: str,
        command_id: str,
        status: str,
        detail: str,
    ) -> None:
        calls.append({"command_id": command_id, "status": status, "detail": detail})
        done.set()

    monkeypatch.setattr(
        "bootstrap.orchestrator.ack_run_control",
        fake_ack_run_control,
    )

    orchestrator._handle_control_payload(
        {"command_id": "cmd-1", "command": "send_input", "payload": {"text": "hello"}}
    )
    assert done.wait(timeout=2), "expected send_input worker to ACK"

    assert calls == [
        {
            "command_id": "cmd-1",
            "status": "applied",
            "detail": "send_input: prompt applied",
        }
    ]


def test_control_loop_stops_when_run_terminal(monkeypatch, tmp_path) -> None:
    cfg_file = tmp_path / "config.toml"
    cfg_file.write_text("", encoding="utf-8")
    cfg = BootstrapConfig(
        run_id="run-term",
        capability_token="token-term",
        config_path=cfg_file,
        server_url="http://server",
        run_root=tmp_path,
    )
    orchestrator = BootstrapOrchestrator(cfg)

    calls: list[str] = []
    monkeypatch.setattr(orchestrator, "_log", lambda *_args, **_kwargs: None)

    def _fake_cancel() -> bool:
        calls.append("interrupt")
        return True

    monkeypatch.setattr(orchestrator, "_cancel_active_prompt", _fake_cancel)

    monkeypatch.setattr(
        "bootstrap.orchestrator.poll_run_control",
        lambda *_args, **_kwargs: {"status": "errored", "run_id": "run-term"},
    )

    orchestrator._stop_control.clear()
    t = threading.Thread(target=orchestrator._control_loop, daemon=True)
    t.start()
    t.join(timeout=2)
    assert orchestrator._stop_control.is_set()
    assert "interrupt" in calls
