from __future__ import annotations

import asyncio
import io
import json
import os
import re
import shutil
import sys
import tarfile
import tempfile
import uuid
from collections.abc import Sequence
from pathlib import Path
from typing import Any, Literal, TypedDict, cast

import pytest
from openai.types.responses.response_output_item import LocalShellCall, LocalShellCallAction
from openai.types.responses.response_reasoning_item import ResponseReasoningItem, Summary

import agents.sandbox.runtime_agent_preparation as runtime_agent_preparation_module
from agents import Agent, AgentHooks, LocalShellTool, RunHooks, Runner, function_tool
from agents.exceptions import InputGuardrailTripwireTriggered, UserError
from agents.guardrail import GuardrailFunctionOutput, InputGuardrail, OutputGuardrail
from agents.items import ModelResponse, ToolCallOutputItem, TResponseInputItem
from agents.model_settings import ModelSettings
from agents.prompts import GenerateDynamicPromptData, Prompt
from agents.run import CallModelData, ModelInputData, RunConfig
from agents.run_context import AgentHookContext, RunContextWrapper
from agents.run_state import RunState, _build_agent_identity_map
from agents.sandbox import (
    FileMode,
    Group,
    Manifest,
    Permissions,
    SandboxAgent,
    SandboxArchiveLimits,
    SandboxConcurrencyLimits,
    SandboxPathGrant,
    SandboxRunConfig,
    User,
)
from agents.sandbox.capabilities import (
    Capability,
    Compaction,
    Filesystem,
    Memory,
    Shell,
    StaticCompactionPolicy,
)
from agents.sandbox.entries import (
    BaseEntry,
    File,
    InContainerMountStrategy,
    MountpointMountPattern,
    S3Mount,
)
from agents.sandbox.errors import (
    ExecNonZeroError,
    ExecTransportError,
    InvalidManifestPathError,
    WorkspaceArchiveWriteError,
)
from agents.sandbox.files import EntryKind, FileEntry
from agents.sandbox.materialization import MaterializedFile
from agents.sandbox.remote_mount_policy import (
    REMOTE_MOUNT_POLICY,
)
from agents.sandbox.runtime import SandboxRuntime
from agents.sandbox.runtime_agent_preparation import get_default_sandbox_instructions
from agents.sandbox.runtime_session_manager import SandboxRuntimeSessionManager
from agents.sandbox.sandboxes import unix_local as unix_local_module
from agents.sandbox.sandboxes.unix_local import (
    UnixLocalSandboxClient,
    UnixLocalSandboxSession,
    UnixLocalSandboxSessionState,
)
from agents.sandbox.session.base_sandbox_session import BaseSandboxSession
from agents.sandbox.session.dependencies import Dependencies
from agents.sandbox.session.runtime_helpers import RuntimeHelperScript
from agents.sandbox.session.sandbox_client import BaseSandboxClient
from agents.sandbox.session.sandbox_session import SandboxSession
from agents.sandbox.session.sandbox_session_state import SandboxSessionState
from agents.sandbox.snapshot import LocalSnapshotSpec, NoopSnapshot, SnapshotBase
from agents.sandbox.types import ExecResult
from agents.stream_events import RunItemStreamEvent
from agents.tool import Tool
from agents.tracing import trace
from tests.fake_model import FakeModel
from tests.test_responses import (
    get_final_output_message,
    get_function_tool,
    get_function_tool_call,
    get_handoff_tool_call,
)
from tests.testing_processor import fetch_normalized_spans
from tests.utils.factories import TestSessionState
from tests.utils.simple_session import SimpleListSession


class _FakeSession(BaseSandboxSession):
    def __init__(
        self,
        manifest: Manifest,
        *,
        start_gate: asyncio.Event | None = None,
    ) -> None:
        self.state = TestSessionState(
            manifest=manifest,
            snapshot=NoopSnapshot(id=str(uuid.uuid4())),
        )
        self._start_gate = start_gate
        self._running = False
        self.start_calls = 0
        self.stop_calls = 0
        self.shutdown_calls = 0
        self.close_dependency_calls = 0
        self.archive_limit_values: list[SandboxArchiveLimits | None] = []
        self.concurrency_limit_values: list[SandboxConcurrencyLimits] = []

    def _set_concurrency_limits(self, limits: SandboxConcurrencyLimits) -> None:
        super()._set_concurrency_limits(limits)
        self.concurrency_limit_values.append(limits)

    def _set_archive_limits(self, limits: SandboxArchiveLimits | None) -> None:
        super()._set_archive_limits(limits)
        self.archive_limit_values.append(limits)

    async def start(self) -> None:
        self.start_calls += 1
        if self._start_gate is not None:
            await self._start_gate.wait()
        self._running = True

    async def stop(self) -> None:
        self.stop_calls += 1
        self._running = False

    async def shutdown(self) -> None:
        self.shutdown_calls += 1

    async def running(self) -> bool:
        return self._running

    async def read(self, path: Path, *, user: object = None) -> io.BytesIO:
        _ = (path, user)
        raise AssertionError("read() should not be called in these tests")

    async def write(self, path: Path, data: io.IOBase, *, user: object = None) -> None:
        _ = (path, data, user)
        raise AssertionError("write() should not be called in these tests")

    async def _exec_internal(
        self,
        *command: str | Path,
        timeout: float | None = None,
    ) -> ExecResult:
        _ = (command, timeout)
        raise AssertionError("exec() should not be called in these tests")

    async def persist_workspace(self) -> io.IOBase:
        return io.BytesIO()

    async def hydrate_workspace(self, data: io.IOBase) -> None:
        _ = data

    async def _aclose_dependencies(self) -> None:
        self.close_dependency_calls += 1
        await super()._aclose_dependencies()


class _FailingStopSession(_FakeSession):
    async def stop(self) -> None:
        await super().stop()
        raise RuntimeError("stop failed")


class _LiveSessionDeltaRecorder(_FakeSession):
    def __init__(self, manifest: Manifest, *, fail_entry_batch_times: int = 0) -> None:
        super().__init__(manifest)
        self.apply_manifest_calls = 0
        self.applied_entry_batches: list[list[tuple[Path, BaseEntry]]] = []
        self._fail_entry_batch_times = fail_entry_batch_times

    async def apply_manifest(self, *, only_ephemeral: bool = False):
        _ = only_ephemeral
        self.apply_manifest_calls += 1
        raise AssertionError("apply_manifest() should not be used for running injected sessions")

    async def _apply_entry_batch(
        self,
        entries: Sequence[tuple[Path, BaseEntry]],
        *,
        base_dir: Path,
    ) -> list[MaterializedFile]:
        _ = base_dir
        self.applied_entry_batches.append(
            [(dest, artifact.model_copy(deep=True)) for dest, artifact in entries]
        )
        if self._fail_entry_batch_times > 0:
            self._fail_entry_batch_times -= 1
            raise RuntimeError("delta apply failed")
        return []


class _PathGuardingSession(_FakeSession):
    def __init__(self, manifest: Manifest) -> None:
        super().__init__(manifest)
        self.normalized_paths: list[Path] = []

    async def _validate_path_access(self, path: Path | str, *, for_write: bool = False) -> Path:
        _ = for_write
        normalized = Path(path)
        self.normalized_paths.append(normalized)
        raise InvalidManifestPathError(rel=normalized, reason="escape_root")


class _LocalShellExecSession(_FakeSession):
    async def _exec_internal(
        self,
        *command: str | Path,
        timeout: float | None = None,
    ) -> ExecResult:
        process = await asyncio.create_subprocess_exec(
            *(str(part) for part in command),
            stdout=asyncio.subprocess.PIPE,
            stderr=asyncio.subprocess.PIPE,
        )
        try:
            stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=timeout)
        except TimeoutError:
            process.kill()
            await process.communicate()
            raise
        return ExecResult(
            stdout=stdout or b"",
            stderr=stderr or b"",
            exit_code=process.returncode or 0,
        )


class _EmptyRemoteRealpathSession(_FakeSession):
    def __init__(self, manifest: Manifest) -> None:
        super().__init__(manifest)
        self.exec_commands: list[tuple[str, ...]] = []

    async def _ensure_runtime_helper_installed(self, helper: RuntimeHelperScript) -> Path:
        _ = helper
        return Path("/tmp/resolve_workspace_path")

    async def _exec_internal(
        self,
        *command: str | Path,
        timeout: float | None = None,
    ) -> ExecResult:
        _ = timeout
        self.exec_commands.append(tuple(str(part) for part in command))
        return ExecResult(stdout=b"", stderr=b"", exit_code=0)


class _BlockingStopSession(_FakeSession):
    def __init__(self, manifest: Manifest, stop_gate: asyncio.Event) -> None:
        super().__init__(manifest)
        self._stop_gate = stop_gate

    async def stop(self) -> None:
        await super().stop()
        await self._stop_gate.wait()


class _MarkerSnapshot(SnapshotBase):
    __test__ = False
    type: Literal["marker"] = "marker"
    marker: str = "initial"

    async def persist(self, data: io.IOBase, *, dependencies: Dependencies | None = None) -> None:
        _ = (data, dependencies)

    async def restore(self, *, dependencies: Dependencies | None = None) -> io.IOBase:
        _ = dependencies
        return io.BytesIO()

    async def restorable(self, *, dependencies: Dependencies | None = None) -> bool:
        _ = dependencies
        return False


class _PersistingStopSession(_BlockingStopSession):
    def __init__(self, manifest: Manifest, stop_gate: asyncio.Event) -> None:
        super().__init__(manifest, stop_gate)
        self.state.snapshot = _MarkerSnapshot(id="marker")

    async def stop(self) -> None:
        self.stop_calls += 1
        self._running = False
        await self._stop_gate.wait()
        snapshot = cast(_MarkerSnapshot, self.state.snapshot)
        self.state.snapshot = snapshot.model_copy(update={"marker": "persisted"})


class _ProvisioningFailureSession(_FakeSession):
    async def _exec_internal(
        self,
        *command: str | Path,
        timeout: float | None = None,
    ) -> ExecResult:
        _ = timeout
        cmd = [str(part) for part in command]
        if cmd[:2] == ["mkdir", "-p"]:
            return ExecResult(stdout=b"", stderr=b"", exit_code=0)
        if cmd and cmd[0] in {"groupadd", "useradd"}:
            return ExecResult(
                stdout=f"attempted {cmd[0]}".encode(),
                stderr=f"missing {cmd[0]}".encode(),
                exit_code=1,
            )
        return ExecResult(stdout=b"", stderr=b"", exit_code=0)


class _RestorableSnapshot(SnapshotBase):
    __test__ = False
    type: Literal["restorable"] = "restorable"

    async def persist(self, data: io.IOBase, *, dependencies: Dependencies | None = None) -> None:
        _ = (data, dependencies)

    async def restore(self, *, dependencies: Dependencies | None = None) -> io.IOBase:
        _ = dependencies
        return io.BytesIO(b"snapshot")

    async def restorable(self, *, dependencies: Dependencies | None = None) -> bool:
        _ = dependencies
        return True


class _RestorableProvisioningFailureSession(_ProvisioningFailureSession):
    def __init__(self, manifest: Manifest, *, provision_on_resume: bool = True) -> None:
        super().__init__(manifest)
        self.state.snapshot = _RestorableSnapshot(id="resume")
        self.cleared_workspace_root = False
        self.hydrate_calls = 0
        self._set_start_state_preserved(False, system=not provision_on_resume)

    async def start(self) -> None:
        self.start_calls += 1
        self._running = True
        await BaseSandboxSession.start(self)

    async def hydrate_workspace(self, data: io.IOBase) -> None:
        _ = data
        self.hydrate_calls += 1

    async def _clear_workspace_root_on_resume(self) -> None:
        self.cleared_workspace_root = True


@pytest.mark.asyncio
async def test_sandbox_session_aclose_runs_public_cleanup_lifecycle() -> None:
    inner = _FakeSession(Manifest())
    session = SandboxSession(inner)

    await session.aclose()

    assert inner.stop_calls == 1
    assert inner.shutdown_calls == 1
    assert inner.close_dependency_calls == 1


@pytest.mark.asyncio
async def test_sandbox_session_aclose_closes_dependencies_when_stop_fails() -> None:
    inner = _FailingStopSession(Manifest())
    session = SandboxSession(inner)

    with pytest.raises(RuntimeError, match="stop failed"):
        await session.aclose()

    assert inner.stop_calls == 1
    assert inner.shutdown_calls == 0
    assert inner.close_dependency_calls == 1


@pytest.mark.asyncio
async def test_sandbox_session_routes_helper_path_checks_to_inner_session() -> None:
    inner = _PathGuardingSession(Manifest(root="/workspace"))
    session = SandboxSession(inner)

    with pytest.raises(InvalidManifestPathError, match="must not escape root"):
        await session.ls("link")
    with pytest.raises(InvalidManifestPathError, match="must not escape root"):
        await session.mkdir("link/nested", parents=True)
    with pytest.raises(InvalidManifestPathError, match="must not escape root"):
        await session.rm("link/file.txt")
    with pytest.raises(InvalidManifestPathError, match="must not escape root"):
        await session.extract(
            "bundle.tar",
            io.BytesIO(b"ignored"),
            compression_scheme="tar",
        )

    assert inner.normalized_paths == [
        Path("link"),
        Path("link/nested"),
        Path("link/file.txt"),
        Path("bundle.tar"),
    ]


@pytest.mark.asyncio
async def test_remote_realpath_guard_fails_closed_on_symlink_cycle(tmp_path: Path) -> None:
    workspace_root = tmp_path / "workspace"
    workspace_root.mkdir()
    (workspace_root / "loop").symlink_to("loop")

    session = _LocalShellExecSession(Manifest(root=str(workspace_root)))

    with pytest.raises(ExecNonZeroError, match="symlink resolution depth exceeded"):
        await asyncio.wait_for(
            session._validate_remote_path_access("loop"),  # noqa: SLF001
            timeout=1,
        )


@pytest.mark.asyncio
async def test_remote_realpath_empty_success_output_is_transport_error() -> None:
    session = _EmptyRemoteRealpathSession(Manifest(root="/workspace"))

    with pytest.raises(ExecTransportError) as exc_info:
        await session._validate_remote_path_access("file.txt")  # noqa: SLF001

    assert exc_info.value.context == {
        "command": ("resolve_workspace_path", "/workspace", "/workspace/file.txt", "0"),
        "command_str": "resolve_workspace_path /workspace /workspace/file.txt 0",
        "reason": "empty_stdout",
        "exit_code": 0,
        "stdout": "",
        "stderr": "",
    }
    assert session.exec_commands == [
        ("/tmp/resolve_workspace_path", "/workspace", "/workspace/file.txt", "0")
    ]


@pytest.mark.asyncio
async def test_runtime_helper_install_replaces_tampered_executable(tmp_path: Path) -> None:
    install_path = tmp_path / "runtime-helpers" / "helper"
    helper = RuntimeHelperScript(
        name="test-helper",
        content="#!/bin/sh\nprintf 'expected\\n'",
        install_path=install_path,
    )
    session = _LocalShellExecSession(Manifest(root=str(tmp_path / "workspace")))

    command = helper.install_command()
    assert command[:2] == ("sh", "-c")

    initial = await session._exec_internal(*command)  # noqa: SLF001
    assert initial.ok()
    assert install_path.read_text().rstrip("\n") == helper.content

    install_path.chmod(0o755)
    install_path.write_text("#!/bin/sh\nprintf 'tampered\\n'")
    install_path.chmod(0o755)

    repaired = await session._exec_internal(*helper.install_command())  # noqa: SLF001
    assert repaired.ok()
    assert install_path.read_text().rstrip("\n") == helper.content


@pytest.mark.asyncio
async def test_runtime_helper_reinstalls_when_cached_binary_is_missing(tmp_path: Path) -> None:
    install_path = tmp_path / "runtime-helpers" / "helper"
    helper = RuntimeHelperScript(
        name="test-helper",
        content="#!/bin/sh\nprintf 'expected\\n'",
        install_path=install_path,
    )
    session = _LocalShellExecSession(Manifest(root=str(tmp_path / "workspace")))

    installed_path = await session._ensure_runtime_helper_installed(helper)  # noqa: SLF001
    assert installed_path == install_path
    assert install_path.exists()

    install_path.unlink()
    assert not install_path.exists()

    repaired_path = await session._ensure_runtime_helper_installed(helper)  # noqa: SLF001
    assert repaired_path == install_path
    assert install_path.exists()
    assert install_path.read_text().rstrip("\n") == helper.content


def _extract_user_text(item: dict[str, object]) -> str:
    content = item["content"]
    if isinstance(content, str):
        return content
    if isinstance(content, list):
        first = content[0]
        if isinstance(first, dict):
            return str(first.get("text", ""))
    raise AssertionError(f"Unexpected content payload: {content!r}")


def _tripwire_input_guardrail(
    _context: RunContextWrapper[Any],
    _agent: Agent[Any],
    _input: str | list[TResponseInputItem],
) -> GuardrailFunctionOutput:
    return GuardrailFunctionOutput(output_info=None, tripwire_triggered=True)


def _get_reasoning_item() -> ResponseReasoningItem:
    return ResponseReasoningItem(
        id="rid",
        type="reasoning",
        summary=[Summary(text="thinking", type="summary_text")],
    )


class _CreateKwargs(TypedDict):
    snapshot: object | None
    manifest: Manifest | None
    options: dict[str, str]


class _FakeClient(BaseSandboxClient[dict[str, str]]):
    backend_id = "fake"

    def __init__(self, session: _FakeSession) -> None:
        self.inner_session = session
        self.session = self._wrap_session(session)
        self.create_kwargs: _CreateKwargs | None = None
        self.resume_state: SandboxSessionState | None = None
        self.delete_calls = 0

    async def create(
        self,
        *,
        snapshot: object | None = None,
        manifest: Manifest | None = None,
        options: dict[str, str],
    ) -> SandboxSession:
        base_manifest = manifest if manifest is not None else self.inner_session.state.manifest
        self.create_kwargs = {
            "snapshot": snapshot,
            "manifest": base_manifest,
            "options": options,
        }
        if self.create_kwargs["manifest"] is not None:
            self.inner_session.state.manifest = self.create_kwargs["manifest"]
        return self.session

    async def delete(self, session: SandboxSession) -> SandboxSession:
        self.delete_calls += 1
        return session

    async def resume(
        self,
        state: SandboxSessionState,
    ) -> SandboxSession:
        self.resume_state = state
        self.inner_session.state = self.resume_state
        return self.session

    def deserialize_session_state(self, payload: dict[str, object]) -> SandboxSessionState:
        return SandboxSessionState.model_validate(payload)


class _ManifestSessionClient(BaseSandboxClient[None]):
    backend_id = "manifest"
    supports_default_options = True

    def __init__(self) -> None:
        self.created_manifests: list[Manifest | None] = []

    async def create(
        self,
        *,
        snapshot: object | None = None,
        manifest: Manifest | None = None,
        options: None = None,
    ) -> SandboxSession:
        _ = (snapshot, options)
        self.created_manifests.append(manifest)
        assert manifest is not None
        session = _FakeSession(manifest)
        return self._wrap_session(session)

    async def delete(self, session: SandboxSession) -> SandboxSession:
        return session

    async def resume(
        self,
        state: SandboxSessionState,
    ) -> SandboxSession:
        return self._wrap_session(_FakeSession(state.manifest))

    def deserialize_session_state(self, payload: dict[str, object]) -> SandboxSessionState:
        return SandboxSessionState.model_validate(payload)


class _RecordingCapability(Capability):
    type: str = "recording"
    bound_session: BaseSandboxSession | None = None
    instruction_text: str | None = None
    provided_tools: list[Any]

    def __init__(
        self,
        *,
        instruction_text: str | None = None,
        provided_tools: list[Any] | None = None,
    ) -> None:
        super().__init__(
            type="recording",
            **cast(
                Any,
                {
                    "bound_session": None,
                    "instruction_text": instruction_text,
                    "provided_tools": list(provided_tools or []),
                },
            ),
        )

    def bind(self, session: BaseSandboxSession) -> None:
        self.bound_session = session

    def tools(self) -> list[Tool]:
        return cast(list[Tool], list(self.provided_tools))

    async def instructions(self, manifest: Manifest) -> str | None:
        _ = manifest
        return self.instruction_text


class _NestedStateCapability(Capability):
    type: str = "nested-state"
    state: dict[str, list[str]]

    def __init__(self) -> None:
        super().__init__(type="nested-state", **cast(Any, {"state": {"seen": []}}))


class _NestedObjectState:
    def __init__(self) -> None:
        self.seen: list[str] = []


class _NestedObjectCapability(Capability):
    type: str = "nested-object-state"
    state: _NestedObjectState

    def __init__(self) -> None:
        super().__init__(
            type="nested-object-state",
            **cast(Any, {"state": _NestedObjectState()}),
        )


class _AwaitableSessionCapability(Capability):
    type: str = "awaitable-session"
    bound_session: BaseSandboxSession | None = None
    release_gate: asyncio.Event
    first_instruction_started: asyncio.Event
    second_instruction_started: asyncio.Event

    def __init__(
        self,
        *,
        release_gate: asyncio.Event,
        first_instruction_started: asyncio.Event,
        second_instruction_started: asyncio.Event,
    ) -> None:
        super().__init__(
            type="awaitable-session",
            **cast(
                Any,
                {
                    "bound_session": None,
                    "release_gate": release_gate,
                    "first_instruction_started": first_instruction_started,
                    "second_instruction_started": second_instruction_started,
                },
            ),
        )

    def bind(self, session: BaseSandboxSession) -> None:
        self.bound_session = session

    async def instructions(self, manifest: Manifest) -> str | None:
        _ = manifest
        assert self.bound_session is not None
        readme = self.bound_session.state.manifest.entries["README.md"]
        assert isinstance(readme, File)
        readme_text = readme.content.decode()
        if readme_text == "Session one instructions.":
            self.first_instruction_started.set()
        elif readme_text == "Session two instructions.":
            self.second_instruction_started.set()
        await self.release_gate.wait()
        return readme_text


class _ManifestInstructionsCapability(Capability):
    type: str = "manifest-instructions"
    bound_session: BaseSandboxSession | None = None

    def __init__(self) -> None:
        super().__init__(type="manifest-instructions", **cast(Any, {"bound_session": None}))

    def bind(self, session: BaseSandboxSession) -> None:
        self.bound_session = session

    async def instructions(self, manifest: Manifest) -> str | None:
        _ = manifest
        assert self.bound_session is not None
        readme = self.bound_session.state.manifest.entries["README.md"]
        assert isinstance(readme, File)
        return readme.content.decode()


class _ManifestMutationCapability(Capability):
    type: str = "manifest-mutation"
    rel_path: str
    content: bytes

    def __init__(self, *, rel_path: str = "cap.txt", content: bytes = b"capability") -> None:
        super().__init__(
            type="manifest-mutation",
            **cast(
                Any,
                {
                    "rel_path": rel_path,
                    "content": content,
                },
            ),
        )

    def process_manifest(self, manifest: Manifest) -> Manifest:
        manifest.entries[self.rel_path] = File(content=self.content)
        return manifest


class _ManifestUsersCapability(Capability):
    type: str = "manifest-users"

    def __init__(self) -> None:
        super().__init__(type="manifest-users")

    def process_manifest(self, manifest: Manifest) -> Manifest:
        manifest.users.append(User(name="sandbox-user"))
        return manifest


class _ProcessContextSessionCapability(Capability):
    type: str = "process-context-session"
    bound_session: BaseSandboxSession | None = None
    process_calls: int = 0

    def __init__(self) -> None:
        super().__init__(
            type="process-context-session",
            **cast(
                Any,
                {
                    "bound_session": None,
                    "process_calls": 0,
                },
            ),
        )

    def bind(self, session: BaseSandboxSession) -> None:
        self.bound_session = session

    def process_context(self, context: list[TResponseInputItem]) -> list[TResponseInputItem]:
        assert self.bound_session is not None
        self.process_calls += 1
        return [
            *context,
            cast(
                TResponseInputItem,
                {
                    "role": "user",
                    "content": f"process_calls={self.process_calls}",
                },
            ),
        ]


class _SessionFileCapability(Capability):
    type: str = "session-files"
    bound_session: BaseSandboxSession | None = None

    def __init__(self) -> None:
        super().__init__(type="session-files", **cast(Any, {"bound_session": None}))

    def bind(self, session: BaseSandboxSession) -> None:
        self.bound_session = session

    def tools(self) -> list[Tool]:
        @function_tool(name_override="write_file")
        async def write_file(path: str, content: str) -> str:
            assert self.bound_session is not None
            await self.bound_session.write(Path(path), io.BytesIO(content.encode("utf-8")))
            return "wrote"

        @function_tool(name_override="read_file")
        async def read_file(path: str) -> str:
            assert self.bound_session is not None
            data = await self.bound_session.read(Path(path))
            return cast(bytes, data.read()).decode("utf-8")

        return [write_file, read_file]


class _RecordingRunHooks(RunHooks[None]):
    def __init__(self) -> None:
        self.started_agents: list[Agent[None]] = []
        self.ended_agents: list[Agent[None]] = []
        self.llm_started_agents: list[Agent[None]] = []
        self.llm_ended_agents: list[Agent[None]] = []

    async def on_agent_start(self, context: AgentHookContext[None], agent: Agent[None]) -> None:
        _ = context
        self.started_agents.append(agent)

    async def on_llm_start(
        self,
        context: RunContextWrapper[None],
        agent: Agent[None],
        system_prompt: str | None,
        input_items: list[TResponseInputItem],
    ) -> None:
        _ = (context, system_prompt, input_items)
        self.llm_started_agents.append(agent)

    async def on_llm_end(
        self,
        context: RunContextWrapper[None],
        agent: Agent[None],
        response: ModelResponse,
    ) -> None:
        _ = (context, response)
        self.llm_ended_agents.append(agent)

    async def on_agent_end(
        self,
        context: AgentHookContext[None],
        agent: Agent[None],
        output: object,
    ) -> None:
        _ = (context, output)
        self.ended_agents.append(agent)


class _RecordingAgentHooks(AgentHooks[None]):
    def __init__(self) -> None:
        self.started_agents: list[Agent[None]] = []
        self.ended_agents: list[Agent[None]] = []
        self.llm_started_agents: list[Agent[None]] = []
        self.llm_ended_agents: list[Agent[None]] = []

    async def on_start(self, context: AgentHookContext[None], agent: Agent[None]) -> None:
        _ = context
        self.started_agents.append(agent)

    async def on_llm_start(
        self,
        context: RunContextWrapper[None],
        agent: Agent[None],
        system_prompt: str | None,
        input_items: list[TResponseInputItem],
    ) -> None:
        _ = (context, system_prompt, input_items)
        self.llm_started_agents.append(agent)

    async def on_llm_end(
        self,
        context: RunContextWrapper[None],
        agent: Agent[None],
        response: ModelResponse,
    ) -> None:
        _ = (context, response)
        self.llm_ended_agents.append(agent)

    async def on_end(
        self,
        context: AgentHookContext[None],
        agent: Agent[None],
        output: object,
    ) -> None:
        _ = (context, output)
        self.ended_agents.append(agent)


def _sandbox_run_config(client: _FakeClient | None = None) -> RunConfig:
    return RunConfig(
        sandbox=SandboxRunConfig(
            client=client,
            options={"image": "sandbox"} if client is not None else None,
        )
    )


def test_sandbox_package_exports_permission_types() -> None:
    assert User(name="sandbox-user").name == "sandbox-user"
    assert Group(name="sandbox-group", users=[]).users == []
    assert Permissions().owner == int(FileMode.ALL)


def _unix_local_manifest(**kwargs: Any) -> Manifest:
    return Manifest(**kwargs)


def _unix_local_run_config(
    *,
    client: UnixLocalSandboxClient | None = None,
    session_state: SandboxSessionState | None = None,
    manifest: Manifest | None = None,
) -> RunConfig:
    sandbox_kwargs: dict[str, Any] = {
        "client": client or UnixLocalSandboxClient(),
    }
    if session_state is not None:
        sandbox_kwargs["session_state"] = session_state
    else:
        sandbox_kwargs["manifest"] = manifest or _unix_local_manifest()
    return RunConfig(sandbox=SandboxRunConfig(**sandbox_kwargs))


@pytest.mark.asyncio
async def test_runner_merges_sandbox_instructions_and_tools() -> None:
    model = FakeModel(initial_output=[get_final_output_message("done")])
    capability_tool = get_function_tool("capability_tool", "ok")
    capability = _RecordingCapability(
        instruction_text="Capability instructions.",
        provided_tools=[capability_tool],
    )
    manifest = Manifest(entries={"README.md": File(content=b"Follow the repo contract.")})
    session = _FakeSession(manifest)
    client = _FakeClient(session)
    agent = SandboxAgent(
        name="sandbox",
        model=model,
        instructions="Additional instructions.",
        default_manifest=manifest,
        capabilities=[capability],
    )

    result = await Runner.run(
        agent,
        "hello",
        run_config=_sandbox_run_config(client),
    )

    assert result.final_output == "done"
    assert capability.bound_session is None
    assert session.start_calls == 1
    assert session.stop_calls == 1
    assert session.shutdown_calls == 1
    assert session.close_dependency_calls == 1
    assert client.delete_calls == 1

    state = result.to_state()
    assert state._sandbox is not None
    assert state._sandbox["backend_id"] == "fake"
    assert state._sandbox["current_agent_name"] == agent.name
    assert state._sandbox["current_agent_key"] == agent.name
    sessions_by_agent = state._sandbox["sessions_by_agent"]
    assert isinstance(sessions_by_agent, dict)
    assert sessions_by_agent[agent.name] == {
        "agent_name": agent.name,
        "session_state": state._sandbox["session_state"],
    }

    assert client.create_kwargs is not None
    assert client.create_kwargs["manifest"] is not manifest
    assert client.create_kwargs["options"] == {"image": "sandbox"}
    assert isinstance(client.create_kwargs["snapshot"], LocalSnapshotSpec)

    assert model.first_turn_args is not None
    assert model.first_turn_args["system_instructions"] == (
        f"{get_default_sandbox_instructions()}\n\n"
        "# Agent instructions\n\n"
        "Additional instructions.\n\n"
        "# Sandbox capability instructions\n\n"
        "Capability instructions.\n\n"
        f"{runtime_agent_preparation_module._filesystem_instructions(manifest)}"
    )
    assert [tool.name for tool in model.first_turn_args["tools"]] == ["capability_tool"]

    input_items = model.first_turn_args["input"]
    assert isinstance(input_items, list)
    assert _extract_user_text(input_items[0]) == "hello"


def test_filesystem_instructions_omit_extra_path_grants() -> None:
    manifest = Manifest(
        root="/workspace",
        extra_path_grants=(
            SandboxPathGrant(path="/tmp", description="temporary files"),
            SandboxPathGrant(
                path="/opt/toolchain",
                read_only=True,
                description="compiler runtime",
            ),
        ),
    )

    assert runtime_agent_preparation_module._filesystem_instructions(manifest) == (
        "# Filesystem\n"
        "You have access to a container with a filesystem. The filesystem layout is:\n"
        "\n"
        "/workspace"
    )


@pytest.mark.asyncio
async def test_runner_adds_run_as_user_to_created_manifest_without_default_manifest() -> None:
    model = FakeModel(initial_output=[get_final_output_message("done")])
    session = _FakeSession(Manifest())
    client = _FakeClient(session)
    run_as = User(name="sandbox-user")
    agent = SandboxAgent(
        name="sandbox",
        model=model,
        run_as=run_as,
    )

    result = await Runner.run(
        agent,
        "hello",
        run_config=_sandbox_run_config(client),
    )

    assert result.final_output == "done"
    assert client.create_kwargs is not None
    created_manifest = client.create_kwargs["manifest"]
    assert created_manifest is not None
    assert created_manifest.users == [run_as]
    assert session.state.manifest.users == [run_as]


@pytest.mark.asyncio
async def test_runner_uses_default_sandbox_prompt_when_instructions_missing() -> None:
    model = FakeModel(initial_output=[get_final_output_message("done")])
    capability = _RecordingCapability(instruction_text="Capability instructions.")
    session = _FakeSession(Manifest())
    client = _FakeClient(session)
    agent = SandboxAgent(
        name="sandbox",
        model=model,
        capabilities=[capability],
    )

    result = await Runner.run(
        agent,
        "hello",
        run_config=_sandbox_run_config(client),
    )

    assert result.final_output == "done"
    assert model.first_turn_args is not None
    expected_instructions = (
        f"{get_default_sandbox_instructions()}\n\n"
        "# Sandbox capability instructions\n\n"
        "Capability instructions.\n\n"
        f"{runtime_agent_preparation_module._filesystem_instructions(session.state.manifest)}"
    )
    assert model.first_turn_args["system_instructions"] == (expected_instructions)


@pytest.mark.asyncio
async def test_runner_handles_missing_default_sandbox_prompt_resource(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    model = FakeModel(initial_output=[get_final_output_message("done")])
    capability = _RecordingCapability(instruction_text="Capability instructions.")
    session = _FakeSession(Manifest())
    client = _FakeClient(session)
    agent = SandboxAgent(
        name="sandbox",
        model=model,
        instructions="Additional instructions.",
        capabilities=[capability],
    )

    def _raise_file_not_found(_package: object) -> object:
        raise FileNotFoundError("missing prompt.md")

    runtime_agent_preparation_module.get_default_sandbox_instructions.cache_clear()
    monkeypatch.setattr(runtime_agent_preparation_module, "files", _raise_file_not_found)
    try:
        result = await Runner.run(
            agent,
            "hello",
            run_config=_sandbox_run_config(client),
        )
    finally:
        runtime_agent_preparation_module.get_default_sandbox_instructions.cache_clear()

    assert result.final_output == "done"
    assert model.first_turn_args is not None
    assert model.first_turn_args["system_instructions"] == (
        "# Agent instructions\n\n"
        "Additional instructions.\n\n"
        "# Sandbox capability instructions\n\n"
        "Capability instructions.\n\n"
        f"{runtime_agent_preparation_module._filesystem_instructions(session.state.manifest)}"
    )


@pytest.mark.asyncio
async def test_runner_dynamic_instructions_do_not_override_default_sandbox_prompt() -> None:
    model = FakeModel(initial_output=[get_final_output_message("done")])
    capability = _RecordingCapability(instruction_text="Capability instructions.")
    session = _FakeSession(Manifest())
    client = _FakeClient(session)

    def dynamic_instructions(
        _ctx: RunContextWrapper[Any],
        _agent: Agent[Any],
    ) -> str:
        return ""

    agent = SandboxAgent(
        name="sandbox",
        model=model,
        instructions=dynamic_instructions,
        capabilities=[capability],
    )

    result = await Runner.run(
        agent,
        "hello",
        run_config=_sandbox_run_config(client),
    )

    assert result.final_output == "done"
    assert model.first_turn_args is not None
    assert model.first_turn_args["system_instructions"] == (
        f"{get_default_sandbox_instructions()}\n\n"
        "# Sandbox capability instructions\n\n"
        "Capability instructions.\n\n"
        f"{runtime_agent_preparation_module._filesystem_instructions(session.state.manifest)}"
    )


@pytest.mark.asyncio
async def test_runner_base_instructions_override_default_sandbox_prompt() -> None:
    model = FakeModel(initial_output=[get_final_output_message("done")])
    capability = _RecordingCapability(instruction_text="Capability instructions.")
    session = _FakeSession(Manifest())
    client = _FakeClient(session)
    agent = SandboxAgent(
        name="sandbox",
        model=model,
        base_instructions="Custom base instructions.",
        instructions="Additional instructions.",
        capabilities=[capability],
    )

    result = await Runner.run(
        agent,
        "hello",
        run_config=_sandbox_run_config(client),
    )

    assert result.final_output == "done"
    assert model.first_turn_args is not None
    assert model.first_turn_args["system_instructions"] == (
        "Custom base instructions.\n\n"
        "# Agent instructions\n\n"
        "Additional instructions.\n\n"
        "# Sandbox capability instructions\n\n"
        "Capability instructions.\n\n"
        f"{runtime_agent_preparation_module._filesystem_instructions(session.state.manifest)}"
    )


@pytest.mark.asyncio
async def test_runner_adds_remote_mount_policy_instructions() -> None:
    model = FakeModel(initial_output=[get_final_output_message("done")])
    manifest = Manifest(
        entries={
            "remote": S3Mount(
                bucket="bucket",
                mount_strategy=InContainerMountStrategy(pattern=MountpointMountPattern()),
            )
        }
    )
    session = _FakeSession(manifest)
    client = _FakeClient(session)
    agent = SandboxAgent(
        name="sandbox",
        model=model,
        instructions="Base instructions.",
        default_manifest=manifest,
    )

    result = await Runner.run(
        agent,
        "hello",
        run_config=_sandbox_run_config(client),
    )

    assert result.final_output == "done"
    assert model.first_turn_args is not None
    system_instructions = model.first_turn_args["system_instructions"]
    assert isinstance(system_instructions, str)
    expected_policy_pattern = re.escape(REMOTE_MOUNT_POLICY)
    expected_policy_pattern = expected_policy_pattern.replace(
        re.escape("{path_lines}"),
        re.escape("- /workspace/remote (mounted in read-only mode)"),
    )
    expected_policy_pattern = expected_policy_pattern.replace(
        re.escape("{REMOTE_MOUNT_COMMAND_ALLOWLIST_TEXT}"),
        re.escape(", ".join(f"`{command}`" for command in manifest.remote_mount_command_allowlist)),
    )
    expected_policy_pattern = expected_policy_pattern.replace(
        re.escape("{edit_instructions}"),
        re.escape(
            "Do not edit paths marked read-only in place, including with `apply_patch`, "
            "and do not write edited files back to them. Copy read-only files to a normal "
            "local workspace path only if you need an editable scratch copy."
        ),
    )
    assert isinstance(re.search(expected_policy_pattern, system_instructions), re.Match)
    agent_index = system_instructions.index("# Agent instructions")
    capability_index = system_instructions.index("# Sandbox capability instructions")
    remote_policy_index = system_instructions.index("# Sandbox remote mount policy")
    filesystem_index = system_instructions.index("# Filesystem")
    assert agent_index < capability_index < remote_policy_index < filesystem_index


@pytest.mark.asyncio
async def test_runner_adds_remote_mount_policy_for_non_ephemeral_mounts() -> None:
    model = FakeModel(initial_output=[get_final_output_message("done")])
    manifest = Manifest(
        entries={
            "remote": S3Mount(
                bucket="bucket",
                mount_strategy=InContainerMountStrategy(pattern=MountpointMountPattern()),
                ephemeral=False,
            )
        }
    )
    session = _FakeSession(manifest)
    client = _FakeClient(session)
    agent = SandboxAgent(
        name="sandbox",
        model=model,
        instructions="Base instructions.",
        default_manifest=manifest,
    )

    result = await Runner.run(
        agent,
        "hello",
        run_config=_sandbox_run_config(client),
    )

    assert result.final_output == "done"
    assert model.first_turn_args is not None
    system_instructions = model.first_turn_args["system_instructions"]
    assert isinstance(system_instructions, str)
    assert "- /workspace/remote (mounted in read-only mode)" in system_instructions


@pytest.mark.asyncio
async def test_runner_applies_compaction_capability_to_input_and_model_settings() -> None:
    model = FakeModel(initial_output=[get_final_output_message("done")])
    session = _FakeSession(Manifest())
    client = _FakeClient(session)
    agent = SandboxAgent(
        name="sandbox",
        model=model,
        default_manifest=Manifest(),
        capabilities=[Compaction(policy=StaticCompactionPolicy(threshold=123))],
    )
    input_items: list[TResponseInputItem] = [
        {"type": "message", "role": "user", "content": "old-user"},
        cast(TResponseInputItem, {"type": "compaction", "summary": "compacted-up-to-here"}),
        {"type": "message", "role": "assistant", "content": "recent-assistant"},
        {"type": "message", "role": "user", "content": "new-user"},
    ]

    result = await Runner.run(
        agent,
        input_items,
        run_config=_sandbox_run_config(client),
    )

    assert result.final_output == "done"
    assert model.first_turn_args is not None
    assert model.first_turn_args["input"] == input_items[1:]
    model_settings = model.first_turn_args["model_settings"]
    assert isinstance(model_settings, ModelSettings)
    assert model_settings.extra_args == {
        "context_management": [
            {
                "type": "compaction",
                "compact_threshold": 123,
            }
        ]
    }


@pytest.mark.asyncio
async def test_runner_marks_writable_remote_mounts_in_policy() -> None:
    model = FakeModel(initial_output=[get_final_output_message("done")])
    manifest = Manifest(
        entries={
            "remote": S3Mount(
                bucket="bucket",
                mount_strategy=InContainerMountStrategy(pattern=MountpointMountPattern()),
                read_only=False,
            )
        }
    )
    session = _FakeSession(manifest)
    client = _FakeClient(session)
    agent = SandboxAgent(
        name="sandbox",
        model=model,
        instructions="Base instructions.",
        default_manifest=manifest,
    )

    result = await Runner.run(
        agent,
        "hello",
        run_config=_sandbox_run_config(client),
    )

    assert result.final_output == "done"
    assert model.first_turn_args is not None
    system_instructions = model.first_turn_args["system_instructions"]
    assert isinstance(system_instructions, str)
    assert "- /workspace/remote (mounted in read+write mode)" in system_instructions
    assert "Use `apply_patch` directly for text edits on read+write mounts." in system_instructions
    assert (
        "For shell-based edits on read+write mounts, first `cp` the mounted file to a normal "
        "local workspace path, edit the local copy there, then copy it back." in system_instructions
    )


@pytest.mark.asyncio
async def test_runner_uses_manifest_remote_mount_command_allowlist_override() -> None:
    model = FakeModel(initial_output=[get_final_output_message("done")])
    manifest = Manifest(
        entries={
            "remote": S3Mount(
                bucket="bucket",
                mount_strategy=InContainerMountStrategy(pattern=MountpointMountPattern()),
            )
        },
        remote_mount_command_allowlist=["ls", "cp"],
    )
    session = _FakeSession(manifest)
    client = _FakeClient(session)
    agent = SandboxAgent(
        name="sandbox",
        model=model,
        instructions="Base instructions.",
        default_manifest=manifest,
    )

    result = await Runner.run(
        agent,
        "hello",
        run_config=_sandbox_run_config(client),
    )

    assert result.final_output == "done"
    assert model.first_turn_args is not None
    system_instructions = model.first_turn_args["system_instructions"]
    assert isinstance(system_instructions, str)
    assert "Only use these commands on remote mounts:" in system_instructions
    assert "`ls`, `cp`" in system_instructions


@pytest.mark.asyncio
async def test_runner_requires_sandbox_config_for_sandbox_agent() -> None:
    agent = SandboxAgent(
        name="sandbox",
        model=FakeModel(initial_output=[get_final_output_message("done")]),
        instructions="Base instructions.",
    )

    with pytest.raises(UserError, match="RunConfig\\(sandbox=.*\\)"):
        await Runner.run(agent, "hello")


@pytest.mark.asyncio
async def test_runner_streamed_cleans_runner_owned_session() -> None:
    model = FakeModel(initial_output=[get_final_output_message("done")])
    session = _FakeSession(Manifest())
    client = _FakeClient(session)
    agent = SandboxAgent(
        name="sandbox",
        model=model,
        instructions="Base instructions.",
    )

    result = Runner.run_streamed(
        agent,
        "hello",
        run_config=_sandbox_run_config(client),
    )
    events = [event async for event in result.stream_events()]

    assert events
    assert result.final_output == "done"
    assert session.start_calls == 1
    assert session.stop_calls == 1
    assert session.shutdown_calls == 1
    assert session.close_dependency_calls == 1
    assert client.delete_calls == 1

    state = result.to_state()
    assert state._sandbox is not None
    assert state._sandbox["backend_id"] == "fake"
    assert state._sandbox["current_agent_name"] == agent.name
    assert state._sandbox["current_agent_key"] == agent.name
    sessions_by_agent = state._sandbox["sessions_by_agent"]
    assert isinstance(sessions_by_agent, dict)
    assert sessions_by_agent[agent.name] == {
        "agent_name": agent.name,
        "session_state": state._sandbox["session_state"],
    }


@pytest.mark.asyncio
async def test_runner_streamed_guardrail_trip_blocks_runner_owned_sandbox_creation() -> None:
    session = _FakeSession(Manifest())
    client = _FakeClient(session)
    agent = SandboxAgent(
        name="sandbox",
        model=FakeModel(initial_output=[get_final_output_message("done")]),
        instructions="Base instructions.",
        input_guardrails=[
            InputGuardrail(
                guardrail_function=_tripwire_input_guardrail,
                run_in_parallel=False,
            )
        ],
    )

    with pytest.raises(InputGuardrailTripwireTriggered):
        result = Runner.run_streamed(agent, "hello", run_config=_sandbox_run_config(client))
        async for _ in result.stream_events():
            pass

    assert client.create_kwargs is None
    assert session.start_calls == 0
    assert session.stop_calls == 0
    assert session.shutdown_calls == 0
    assert session.close_dependency_calls == 0


@pytest.mark.asyncio
async def test_runner_does_not_close_injected_sandbox_session() -> None:
    model = FakeModel(initial_output=[get_final_output_message("done")])
    default_manifest = Manifest(entries={"default.txt": File(content=b"default")})
    session_manifest = Manifest(entries={"session.txt": File(content=b"session")})
    injected_session = _FakeSession(session_manifest)
    agent = SandboxAgent(
        name="sandbox",
        model=model,
        instructions="Base instructions.",
        default_manifest=default_manifest,
    )

    result = await Runner.run(
        agent,
        "hello",
        run_config=RunConfig(
            sandbox=SandboxRunConfig(
                session=injected_session,
                manifest=Manifest(entries={"override.txt": File(content=b"override")}),
            )
        ),
    )

    assert result.final_output == "done"
    assert injected_session.start_calls == 1
    assert injected_session.stop_calls == 0
    assert injected_session.shutdown_calls == 0
    assert injected_session.close_dependency_calls == 0

    assert model.first_turn_args is not None
    input_items = model.first_turn_args["input"]
    assert isinstance(input_items, str) or isinstance(input_items, list)
    assert injected_session.state.manifest.entries == session_manifest.entries


@pytest.mark.asyncio
async def test_runner_does_not_restart_running_injected_sandbox_session() -> None:
    model = FakeModel(initial_output=[get_final_output_message("done")])
    injected_session = _FakeSession(Manifest(entries={"session.txt": File(content=b"session")}))
    injected_session._running = True
    agent = SandboxAgent(
        name="sandbox",
        model=model,
        instructions="Base instructions.",
    )

    result = await Runner.run(
        agent,
        "hello",
        run_config=RunConfig(sandbox=SandboxRunConfig(session=injected_session)),
    )

    assert result.final_output == "done"
    assert injected_session.start_calls == 0
    assert injected_session.stop_calls == 0
    assert injected_session.shutdown_calls == 0


@pytest.mark.asyncio
async def test_runner_guardrail_trip_blocks_runner_owned_sandbox_creation() -> None:
    session = _FakeSession(Manifest())
    client = _FakeClient(session)
    agent = SandboxAgent(
        name="sandbox",
        model=FakeModel(initial_output=[get_final_output_message("done")]),
        instructions="Base instructions.",
        input_guardrails=[
            InputGuardrail(
                guardrail_function=_tripwire_input_guardrail,
                run_in_parallel=False,
            )
        ],
    )

    with pytest.raises(InputGuardrailTripwireTriggered):
        await Runner.run(agent, "hello", run_config=_sandbox_run_config(client))

    assert client.create_kwargs is None
    assert session.start_calls == 0
    assert session.stop_calls == 0
    assert session.shutdown_calls == 0
    assert session.close_dependency_calls == 0


@pytest.mark.asyncio
async def test_runner_guardrail_trip_blocks_running_injected_session_mutation() -> None:
    live_session = _LiveSessionDeltaRecorder(Manifest())
    live_session._running = True
    agent = SandboxAgent(
        name="sandbox",
        model=FakeModel(initial_output=[get_final_output_message("done")]),
        instructions="Base instructions.",
        capabilities=[_ManifestMutationCapability()],
        input_guardrails=[
            InputGuardrail(
                guardrail_function=_tripwire_input_guardrail,
                run_in_parallel=False,
            )
        ],
    )

    with pytest.raises(InputGuardrailTripwireTriggered):
        await Runner.run(
            agent,
            "hello",
            run_config=RunConfig(sandbox=SandboxRunConfig(session=live_session)),
        )

    assert "cap.txt" not in live_session.state.manifest.entries
    assert live_session.start_calls == 0
    assert live_session.applied_entry_batches == []
    assert live_session.stop_calls == 0
    assert live_session.shutdown_calls == 0


@pytest.mark.asyncio
async def test_runner_streamed_guardrail_trip_blocks_running_injected_session_mutation() -> None:
    live_session = _LiveSessionDeltaRecorder(Manifest())
    live_session._running = True
    agent = SandboxAgent(
        name="sandbox",
        model=FakeModel(initial_output=[get_final_output_message("done")]),
        instructions="Base instructions.",
        capabilities=[_ManifestMutationCapability()],
        input_guardrails=[
            InputGuardrail(
                guardrail_function=_tripwire_input_guardrail,
                run_in_parallel=False,
            )
        ],
    )

    with pytest.raises(InputGuardrailTripwireTriggered):
        result = Runner.run_streamed(
            agent,
            "hello",
            run_config=RunConfig(sandbox=SandboxRunConfig(session=live_session)),
        )
        async for _ in result.stream_events():
            pass

    assert "cap.txt" not in live_session.state.manifest.entries
    assert live_session.start_calls == 0
    assert live_session.applied_entry_batches == []
    assert live_session.stop_calls == 0
    assert live_session.shutdown_calls == 0


@pytest.mark.asyncio
async def test_runner_uses_public_sandbox_agent_for_dynamic_instructions() -> None:
    model = FakeModel(initial_output=[get_final_output_message("done")])
    session = _FakeSession(Manifest())
    client = _FakeClient(session)
    seen_agents: list[Agent[Any]] = []

    def dynamic_instructions(_ctx: RunContextWrapper[Any], current_agent: Agent[Any]) -> str:
        seen_agents.append(current_agent)
        return "Saw public agent." if current_agent is agent else "Saw execution clone."

    agent = SandboxAgent(
        name="sandbox",
        model=model,
        instructions=dynamic_instructions,
        capabilities=[
            _RecordingCapability(
                instruction_text="Capability instructions.",
                provided_tools=[get_function_tool("capability_tool", "ok")],
            )
        ],
    )

    result = await Runner.run(agent, "hello", run_config=_sandbox_run_config(client))

    assert result.final_output == "done"
    assert seen_agents == [agent]
    assert model.first_turn_args is not None
    assert model.first_turn_args["system_instructions"] == (
        f"{get_default_sandbox_instructions()}\n\n"
        "# Agent instructions\n\n"
        "Saw public agent.\n\n"
        "# Sandbox capability instructions\n\n"
        "Capability instructions.\n\n"
        f"{runtime_agent_preparation_module._filesystem_instructions(Manifest())}"
    )


@pytest.mark.asyncio
async def test_runner_uses_public_sandbox_agent_for_dynamic_prompts() -> None:
    seen_agents: list[Agent[Any]] = []

    def dynamic_prompt(data: GenerateDynamicPromptData) -> Prompt:
        seen_agents.append(data.agent)
        return {"id": "prompt_test", "variables": {"agent_name": data.agent.name}}

    agent = SandboxAgent(
        name="sandbox",
        model=FakeModel(initial_output=[get_final_output_message("done")]),
        instructions="Base instructions.",
        prompt=dynamic_prompt,
        capabilities=[_RecordingCapability(instruction_text="Capability instructions.")],
    )

    result = await Runner.run(
        agent, "hello", run_config=_sandbox_run_config(_FakeClient(_FakeSession(Manifest())))
    )

    assert result.final_output == "done"
    assert seen_agents == [agent]

    streamed_agent = SandboxAgent(
        name="streamed-sandbox",
        model=FakeModel(initial_output=[get_final_output_message("streamed done")]),
        instructions="Base instructions.",
        prompt=dynamic_prompt,
        capabilities=[_RecordingCapability(instruction_text="Capability instructions.")],
    )
    streamed = Runner.run_streamed(
        streamed_agent,
        "hello",
        run_config=_sandbox_run_config(_FakeClient(_FakeSession(Manifest()))),
    )
    async for _ in streamed.stream_events():
        pass

    assert streamed.final_output == "streamed done"
    assert seen_agents == [agent, streamed_agent]


@pytest.mark.asyncio
async def test_runner_uses_public_agent_for_call_model_input_filter() -> None:
    seen_agents: list[Agent[Any]] = []

    def capture_model_input(data: CallModelData[Any]) -> ModelInputData:
        seen_agents.append(data.agent)
        return data.model_data

    agent = SandboxAgent(
        name="sandbox",
        model=FakeModel(initial_output=[get_final_output_message("done")]),
        instructions="Base instructions.",
        capabilities=[_RecordingCapability(instruction_text="Capability instructions.")],
    )

    result = await Runner.run(
        agent,
        "hello",
        run_config=RunConfig(
            sandbox=SandboxRunConfig(
                client=_FakeClient(_FakeSession(Manifest())),
                options={"image": "sandbox"},
            ),
            call_model_input_filter=capture_model_input,
        ),
    )

    assert result.final_output == "done"
    assert seen_agents == [agent]


@pytest.mark.asyncio
async def test_runner_streamed_uses_public_agent_for_call_model_input_filter() -> None:
    seen_agents: list[Agent[Any]] = []

    def capture_model_input(data: CallModelData[Any]) -> ModelInputData:
        seen_agents.append(data.agent)
        return data.model_data

    agent = SandboxAgent(
        name="sandbox",
        model=FakeModel(initial_output=[get_final_output_message("done")]),
        instructions="Base instructions.",
        capabilities=[_RecordingCapability(instruction_text="Capability instructions.")],
    )

    result = Runner.run_streamed(
        agent,
        "hello",
        run_config=RunConfig(
            sandbox=SandboxRunConfig(
                client=_FakeClient(_FakeSession(Manifest())),
                options={"image": "sandbox"},
            ),
            call_model_input_filter=capture_model_input,
        ),
    )
    events = [event async for event in result.stream_events()]

    assert events
    assert result.final_output == "done"
    assert seen_agents == [agent]


@pytest.mark.asyncio
async def test_runner_reuses_prepared_sandbox_agent_across_turns_for_tool_choice_reset() -> None:
    model = FakeModel()
    tool = get_function_tool("capability_tool", "ok")
    model.add_multiple_turn_outputs(
        [
            [get_function_tool_call("capability_tool", json.dumps({}))],
            [get_final_output_message("done")],
        ]
    )
    session = _FakeSession(Manifest())
    client = _FakeClient(session)
    agent = SandboxAgent(
        name="sandbox",
        model=model,
        instructions="Base instructions.",
        tools=[tool],
        model_settings=ModelSettings(tool_choice="required"),
    )

    result = await Runner.run(agent, "hello", run_config=_sandbox_run_config(client))

    assert result.final_output == "done"
    assert model.first_turn_args is not None
    assert model.first_turn_args["model_settings"].tool_choice == "required"
    assert model.last_turn_args["model_settings"].tool_choice is None


@pytest.mark.asyncio
async def test_runner_rebuilds_sandbox_resources_for_handoff_target_agent() -> None:
    triage_model = FakeModel()
    worker_model = FakeModel(initial_output=[get_final_output_message("done")])
    client = _ManifestSessionClient()
    triage_manifest = Manifest(entries={"README.md": File(content=b"Triage workspace")})
    worker_manifest = Manifest(entries={"README.md": File(content=b"Worker workspace")})
    worker = SandboxAgent(
        name="worker",
        model=worker_model,
        instructions="Worker instructions.",
        default_manifest=worker_manifest,
        capabilities=[_ManifestInstructionsCapability()],
    )
    triage = SandboxAgent(
        name="triage",
        model=triage_model,
        instructions="Triage instructions.",
        default_manifest=triage_manifest,
        capabilities=[_ManifestInstructionsCapability()],
        handoffs=[worker],
    )
    triage_model.turn_outputs = [[get_handoff_tool_call(worker)]]

    result = await Runner.run(
        triage,
        "route this",
        run_config=RunConfig(sandbox=SandboxRunConfig(client=client)),
    )

    assert result.final_output == "done"
    assert len(client.created_manifests) == 2
    assert client.created_manifests[0] is not None
    assert client.created_manifests[1] is not None
    assert (
        client.created_manifests[0].entries["README.md"]
        != client.created_manifests[1].entries["README.md"]
    )
    assert worker_model.first_turn_args is not None
    assert worker_model.first_turn_args["system_instructions"] == (
        f"{get_default_sandbox_instructions()}\n\n"
        "# Agent instructions\n\n"
        "Worker instructions.\n\n"
        "# Sandbox capability instructions\n\n"
        "Worker workspace\n\n"
        f"{runtime_agent_preparation_module._filesystem_instructions(worker_manifest)}"
    )


@pytest.mark.asyncio
async def test_runner_resumed_handoff_materializes_manifest_for_new_sandbox_agent() -> None:
    triage_model = FakeModel()
    worker_model = FakeModel(initial_output=[get_final_output_message("done")])
    client = _ManifestSessionClient()

    @function_tool(name_override="approval_tool", needs_approval=True)
    def approval_tool() -> str:
        return "approved"

    triage_manifest = Manifest(entries={"README.md": File(content=b"Triage workspace")})
    worker_manifest = Manifest(entries={"README.md": File(content=b"Worker workspace")})
    worker = SandboxAgent(
        name="worker",
        model=worker_model,
        instructions="Worker instructions.",
        default_manifest=worker_manifest,
        capabilities=[_ManifestInstructionsCapability()],
    )
    triage = SandboxAgent(
        name="triage",
        model=triage_model,
        instructions="Triage instructions.",
        default_manifest=triage_manifest,
        tools=[approval_tool],
        capabilities=[_ManifestInstructionsCapability()],
        handoffs=[worker],
    )
    triage_model.add_multiple_turn_outputs(
        [
            [get_function_tool_call("approval_tool", json.dumps({}), call_id="call_resume")],
            [get_handoff_tool_call(worker)],
        ]
    )

    first_run = await Runner.run(
        triage,
        "route this",
        run_config=RunConfig(sandbox=SandboxRunConfig(client=client)),
    )

    assert len(first_run.interruptions) == 1
    state = first_run.to_state()
    state.approve(first_run.interruptions[0])

    resumed = await Runner.run(
        triage,
        state,
        run_config=RunConfig(sandbox=SandboxRunConfig(client=client)),
    )

    assert resumed.final_output == "done"
    assert len(client.created_manifests) == 2
    assert client.created_manifests[1] is not None
    assert worker_model.first_turn_args is not None
    assert worker_model.first_turn_args["system_instructions"] == (
        f"{get_default_sandbox_instructions()}\n\n"
        "# Agent instructions\n\n"
        "Worker instructions.\n\n"
        "# Sandbox capability instructions\n\n"
        "Worker workspace\n\n"
        f"{runtime_agent_preparation_module._filesystem_instructions(worker_manifest)}"
    )


@pytest.mark.asyncio
async def test_unix_local_client_rewrites_default_manifest_root_to_temp_workspace() -> None:
    client = UnixLocalSandboxClient()
    manifest = _unix_local_manifest(entries={"default.txt": File(content=b"default")})

    session = await client.create(manifest=manifest, options=None)
    workspace_root = Path(session.state.manifest.root)
    try:
        session_manifest = session.state.manifest
        session_state = cast(UnixLocalSandboxSessionState, session.state)

        assert session_manifest is not manifest
        assert session_manifest.entries == manifest.entries
        assert session_manifest.root != manifest.root
        assert workspace_root.is_absolute()
        assert workspace_root.name.startswith("sandbox-local-")
        assert session_state.workspace_root_owned is True
        assert manifest.root == "/workspace"
    finally:
        await client.delete(session)
    assert not workspace_root.exists()


@pytest.mark.asyncio
async def test_unix_local_client_delete_unmounts_workspace_mounts_before_rmtree(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    client = UnixLocalSandboxClient()
    manifest = _unix_local_manifest(
        entries={
            "remote": S3Mount(
                bucket="bucket",
                mount_strategy=InContainerMountStrategy(pattern=MountpointMountPattern()),
            ),
        }
    )
    session = await client.create(manifest=manifest, options=None)
    workspace_root = Path(session.state.manifest.root)
    calls: list[str] = []
    real_rmtree = shutil.rmtree

    async def _fake_unmount(
        self: S3Mount,
        session: BaseSandboxSession,
        dest: Path,
        base_dir: Path,
    ) -> None:
        _ = (self, session, dest, base_dir)
        calls.append("unmount")

    def _fake_rmtree(path: Path, ignore_errors: bool = False) -> None:
        _ = ignore_errors
        calls.append("rmtree")
        real_rmtree(path, ignore_errors=False)

    monkeypatch.setattr(S3Mount, "unmount", _fake_unmount)
    monkeypatch.setattr(shutil, "rmtree", _fake_rmtree)

    await client.delete(session)

    assert calls == ["unmount", "rmtree"]
    assert not workspace_root.exists()


@pytest.mark.asyncio
async def test_unix_local_client_delete_unmounts_nested_mounts_deepest_first(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    client = UnixLocalSandboxClient()
    manifest = _unix_local_manifest(
        entries={
            "outer": S3Mount(
                bucket="bucket",
                mount_strategy=InContainerMountStrategy(pattern=MountpointMountPattern()),
            ),
            "outer/child": S3Mount(
                bucket="bucket",
                mount_strategy=InContainerMountStrategy(pattern=MountpointMountPattern()),
            ),
        }
    )
    session = await client.create(manifest=manifest, options=None)
    order: list[Path] = []

    async def _fake_unmount(
        self: S3Mount,
        session: BaseSandboxSession,
        dest: Path,
        base_dir: Path,
    ) -> None:
        _ = (self, session, base_dir)
        order.append(dest)

    monkeypatch.setattr(S3Mount, "unmount", _fake_unmount)

    await client.delete(session)

    root = Path(session.state.manifest.root)
    assert order == [root / "outer" / "child", root / "outer"]


@pytest.mark.asyncio
async def test_unix_local_client_delete_skips_rmtree_when_unmount_fails(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    client = UnixLocalSandboxClient()
    manifest = _unix_local_manifest(
        entries={
            "remote": S3Mount(
                bucket="bucket",
                mount_strategy=InContainerMountStrategy(pattern=MountpointMountPattern()),
            ),
        }
    )
    session = await client.create(manifest=manifest, options=None)
    workspace_root = Path(session.state.manifest.root)
    rmtree_called = False

    async def _failing_unmount(
        self: S3Mount,
        session: BaseSandboxSession,
        dest: Path,
        base_dir: Path,
    ) -> None:
        _ = (self, session, dest, base_dir)
        raise RuntimeError("busy")

    def _fake_rmtree(path: Path, ignore_errors: bool = False) -> None:
        _ = (path, ignore_errors)
        nonlocal rmtree_called
        rmtree_called = True

    monkeypatch.setattr(S3Mount, "unmount", _failing_unmount)
    monkeypatch.setattr(shutil, "rmtree", _fake_rmtree)

    await client.delete(session)

    assert rmtree_called is False
    assert workspace_root.exists()

    shutil.rmtree(workspace_root, ignore_errors=True)


@pytest.mark.asyncio
async def test_unix_local_persist_workspace_excludes_mounted_directory_contents() -> None:
    workspace_root = Path(tempfile.mkdtemp(prefix="workspace-root-"))
    (workspace_root / "logical").mkdir(parents=True)
    (workspace_root / "logical" / "marker.txt").write_text("logical", encoding="utf-8")
    (workspace_root / "actual").mkdir(parents=True)
    (workspace_root / "actual" / "mounted.txt").write_text("mounted", encoding="utf-8")
    session = UnixLocalSandboxSession.from_state(
        UnixLocalSandboxSessionState(
            session_id=uuid.uuid4(),
            manifest=_unix_local_manifest(
                root=str(workspace_root),
                entries={
                    "logical": S3Mount(
                        bucket="bucket",
                        mount_path=Path("actual"),
                        mount_strategy=InContainerMountStrategy(pattern=MountpointMountPattern()),
                    ),
                },
            ),
            snapshot=NoopSnapshot(id="snapshot"),
            workspace_root_owned=False,
        )
    )

    try:
        archive = await session.persist_workspace()
        payload = archive.read()
        if not isinstance(payload, bytes):
            raise AssertionError(f"Expected bytes archive payload, got {type(payload)!r}")
        with tarfile.open(fileobj=io.BytesIO(payload), mode="r:*") as tar:
            names = tar.getnames()
    finally:
        shutil.rmtree(workspace_root)

    assert names == ["."]


@pytest.mark.asyncio
async def test_runner_allows_fresh_unix_local_sessions_without_options() -> None:
    agent = SandboxAgent(
        name="sandbox",
        model=FakeModel(initial_output=[get_final_output_message("done")]),
        instructions="Base instructions.",
    )

    result = await Runner.run(
        agent,
        "hello",
        run_config=_unix_local_run_config(),
    )

    assert result.final_output == "done"


@pytest.mark.asyncio
async def test_unix_local_client_delete_preserves_caller_owned_workspace_root() -> None:
    client = UnixLocalSandboxClient()
    workspace_root = Path(tempfile.mkdtemp(prefix="caller-owned-"))
    manifest = _unix_local_manifest(root=str(workspace_root))

    session = await client.create(manifest=manifest, options=None)
    assert cast(UnixLocalSandboxSessionState, session.state).workspace_root_owned is False

    await client.delete(session)

    assert workspace_root.exists()
    shutil.rmtree(workspace_root)


@pytest.mark.asyncio
async def test_unix_local_runner_cleanup_preserves_resumed_caller_owned_workspace_root() -> None:
    workspace_root = Path(tempfile.mkdtemp(prefix="resumed-owned-"))
    state = UnixLocalSandboxSessionState(
        session_id=uuid.uuid4(),
        manifest=_unix_local_manifest(root=str(workspace_root)),
        snapshot=NoopSnapshot(id=str(uuid.uuid4())),
    )
    agent = SandboxAgent(
        name="sandbox",
        model=FakeModel(initial_output=[get_final_output_message("done")]),
        instructions="Base instructions.",
    )

    try:
        result = await Runner.run(
            agent,
            "hello",
            run_config=_unix_local_run_config(session_state=state),
        )
    finally:
        assert workspace_root.exists()
        shutil.rmtree(workspace_root)

    assert result.final_output == "done"


@pytest.mark.asyncio
async def test_unix_local_read_and_write_reject_paths_outside_workspace_root() -> None:
    client = UnixLocalSandboxClient()
    workspace_root = Path(tempfile.mkdtemp(prefix="workspace-root-"))
    session = await client.create(
        manifest=_unix_local_manifest(root=str(workspace_root)),
        options=None,
    )

    try:
        with pytest.raises(InvalidManifestPathError, match="must not escape root"):
            await session.write(Path("../secret.txt"), io.BytesIO(b"nope"))
        with pytest.raises(InvalidManifestPathError, match="must not escape root"):
            await session.read(Path("../secret.txt"))
    finally:
        await client.delete(session)
        shutil.rmtree(workspace_root)


@pytest.mark.asyncio
async def test_unix_local_rm_recursive_ignores_missing_paths() -> None:
    client = UnixLocalSandboxClient()
    workspace_root = Path(tempfile.mkdtemp(prefix="workspace-root-"))
    session = await client.create(
        manifest=_unix_local_manifest(root=str(workspace_root)),
        options=None,
    )

    try:
        await session.rm("missing-dir", recursive=True)
    finally:
        await client.delete(session)
        shutil.rmtree(workspace_root)


@pytest.mark.asyncio
async def test_unix_local_rm_non_recursive_still_errors_for_missing_paths() -> None:
    client = UnixLocalSandboxClient()
    workspace_root = Path(tempfile.mkdtemp(prefix="workspace-root-"))
    session = await client.create(
        manifest=_unix_local_manifest(root=str(workspace_root)),
        options=None,
    )

    try:
        with pytest.raises(ExecNonZeroError):
            await session.rm("missing-dir")
    finally:
        await client.delete(session)
        shutil.rmtree(workspace_root)


@pytest.mark.asyncio
async def test_wrapped_unix_local_helpers_reject_symlink_escape_paths(tmp_path: Path) -> None:
    client = UnixLocalSandboxClient()
    workspace_root = tmp_path / "workspace"
    session = await client.create(
        manifest=_unix_local_manifest(root=str(workspace_root)),
        options=None,
    )

    try:
        workspace_root.mkdir(parents=True, exist_ok=True)
        outside = tmp_path / "outside"
        outside.mkdir()
        os.symlink(outside, workspace_root / "link", target_is_directory=True)

        with pytest.raises(InvalidManifestPathError, match="must not escape root"):
            await session.mkdir("link/nested", parents=True)
        with pytest.raises(InvalidManifestPathError, match="must not escape root"):
            await session.ls("link")
        with pytest.raises(InvalidManifestPathError, match="must not escape root"):
            await session.rm("link/file.txt")
    finally:
        await client.delete(session)


@pytest.mark.asyncio
async def test_runner_streamed_ignores_sandbox_cleanup_failures_after_success() -> None:
    session = _FailingStopSession(Manifest())
    client = _FakeClient(session)
    agent = SandboxAgent(
        name="sandbox",
        model=FakeModel(initial_output=[get_final_output_message("done")]),
        instructions="Base instructions.",
    )

    result = Runner.run_streamed(agent, "hello", run_config=_sandbox_run_config(client))
    events = [event async for event in result.stream_events()]

    assert events
    assert result.final_output == "done"
    assert result._sandbox_session is None


@pytest.mark.asyncio
async def test_runner_omits_sandbox_resume_state_when_cleanup_fails() -> None:
    session = _FailingStopSession(Manifest())
    client = _FakeClient(session)
    agent = SandboxAgent(
        name="sandbox",
        model=FakeModel(initial_output=[get_final_output_message("done")]),
        instructions="Base instructions.",
    )

    result = await Runner.run(agent, "hello", run_config=_sandbox_run_config(client))
    state = result.to_state()

    assert result.final_output == "done"
    assert result._sandbox_resume_state is None
    assert result._sandbox_session is None
    assert state._sandbox is None


@pytest.mark.asyncio
async def test_runner_clears_sandbox_session_from_non_streamed_results_after_cleanup() -> None:
    session = _FakeSession(Manifest())
    client = _FakeClient(session)
    agent = SandboxAgent(
        name="sandbox",
        model=FakeModel(initial_output=[get_final_output_message("done")]),
        instructions="Base instructions.",
    )

    result = await Runner.run(agent, "hello", run_config=_sandbox_run_config(client))

    assert result.final_output == "done"
    assert result._sandbox_session is None


@pytest.mark.asyncio
async def test_runner_streamed_cleans_sandbox_once_after_stream_completion() -> None:
    session = _FakeSession(Manifest())
    client = _FakeClient(session)
    agent = SandboxAgent(
        name="sandbox",
        model=FakeModel(initial_output=[get_final_output_message("done")]),
        instructions="Base instructions.",
    )

    result = Runner.run_streamed(agent, "hello", run_config=_sandbox_run_config(client))
    events = [event async for event in result.stream_events()]
    await asyncio.sleep(0)

    assert events
    assert result.final_output == "done"
    assert result._sandbox_session is None
    assert session.stop_calls == 1
    assert session.shutdown_calls == 1
    assert session.close_dependency_calls == 1
    assert client.delete_calls == 1


@pytest.mark.asyncio
async def test_runner_uses_public_agent_for_non_streaming_output_guardrails() -> None:
    seen_agents: list[Agent[None]] = []

    async def output_guardrail(
        _context: RunContextWrapper[None],
        guardrail_agent: Agent[None],
        _output: object,
    ) -> GuardrailFunctionOutput:
        seen_agents.append(guardrail_agent)
        return GuardrailFunctionOutput(output_info=None, tripwire_triggered=False)

    agent = SandboxAgent(
        name="sandbox",
        model=FakeModel(initial_output=[get_final_output_message("done")]),
        instructions="Base instructions.",
        capabilities=[_RecordingCapability(instruction_text="Capability instructions.")],
        output_guardrails=[OutputGuardrail(guardrail_function=output_guardrail)],
    )

    result = await Runner.run(
        agent, "hello", run_config=_sandbox_run_config(_FakeClient(_FakeSession(Manifest())))
    )

    assert result.final_output == "done"
    assert seen_agents == [agent]


@pytest.mark.asyncio
async def test_runner_streamed_immediate_cancel_skips_waiting_for_sandbox_cleanup() -> None:
    stop_gate = asyncio.Event()
    session = _BlockingStopSession(Manifest(), stop_gate)
    client = _FakeClient(session)
    agent = SandboxAgent(
        name="sandbox",
        model=FakeModel(initial_output=[get_final_output_message("done")]),
        instructions="Base instructions.",
    )

    result = Runner.run_streamed(agent, "hello", run_config=_sandbox_run_config(client))

    async def consume_with_cancel() -> None:
        async for _event in result.stream_events():
            result.cancel(mode="immediate")
            break

    try:
        await asyncio.wait_for(consume_with_cancel(), timeout=0.2)
    finally:
        stop_gate.set()
        await asyncio.sleep(0)


@pytest.mark.asyncio
async def test_runner_streamed_run_loop_task_waits_for_sandbox_cleanup_and_persisted_state() -> (
    None
):
    stop_gate = asyncio.Event()
    session = _PersistingStopSession(Manifest(), stop_gate)
    client = _FakeClient(session)
    model = FakeModel()
    model.add_multiple_turn_outputs(
        [
            [get_final_output_message("done")],
            [get_final_output_message("again")],
        ]
    )
    agent = SandboxAgent(
        name="sandbox",
        model=model,
        instructions="Base instructions.",
    )
    run_config = _sandbox_run_config(client)

    result = Runner.run_streamed(agent, "hello", run_config=run_config)
    assert result.run_loop_task is not None

    while session.stop_calls == 0:
        await asyncio.sleep(0)

    with pytest.raises(asyncio.TimeoutError):
        await asyncio.wait_for(asyncio.shield(result.run_loop_task), timeout=0.05)

    stop_gate.set()
    await result.run_loop_task

    state = result.to_state()
    assert state._sandbox is not None
    session_state = state._sandbox["session_state"]
    assert isinstance(session_state, dict)
    snapshot = session_state["snapshot"]
    assert isinstance(snapshot, dict)
    assert snapshot["marker"] == "persisted"

    second = await Runner.run(agent, "again", run_config=run_config)

    assert second.final_output == "again"


@pytest.mark.asyncio
async def test_runner_rejects_unix_local_manifest_user_and_group_provisioning() -> None:
    workspace_root = Path(tempfile.mkdtemp(prefix="unix-local-users-"))
    session = await UnixLocalSandboxClient().create(
        manifest=_unix_local_manifest(
            root=str(workspace_root),
            users=[User(name="sandbox-user")],
        ),
        options=None,
    )

    try:
        with pytest.raises(ValueError, match="does not support manifest users or groups"):
            await session.start()
    finally:
        shutil.rmtree(workspace_root)


@pytest.mark.asyncio
async def test_runner_persists_workspace_and_tool_choice_state_across_sandbox_resume() -> None:
    client = UnixLocalSandboxClient()
    file_capability = _SessionFileCapability()

    @function_tool(name_override="approval_tool", needs_approval=True)
    def approval_tool() -> str:
        return "approved"

    model = FakeModel()
    model.add_multiple_turn_outputs(
        [
            [
                get_function_tool_call(
                    "write_file",
                    json.dumps({"path": "note.txt", "content": "persist me"}),
                    call_id="call_write",
                )
            ],
            [
                get_function_tool_call(
                    "approval_tool",
                    json.dumps({}),
                    call_id="call_approval",
                )
            ],
        ]
    )
    agent = SandboxAgent(
        name="sandbox",
        model=model,
        instructions="Base instructions.",
        tools=[approval_tool],
        capabilities=[file_capability],
        model_settings=ModelSettings(tool_choice="required"),
    )

    first_run = await Runner.run(
        agent,
        "hello",
        run_config=_unix_local_run_config(client=client),
    )

    assert len(first_run.interruptions) == 1
    state = first_run.to_state()
    assert state._sandbox is not None
    assert state._sandbox["backend_id"] == "unix_local"
    session_state = state._sandbox["session_state"]
    assert isinstance(session_state, dict)
    snapshot_payload = session_state.get("snapshot")
    assert isinstance(snapshot_payload, dict)
    assert snapshot_payload.get("type") == "local"
    sessions_by_agent = state._sandbox["sessions_by_agent"]
    assert isinstance(sessions_by_agent, dict)
    assert sessions_by_agent[agent.name] == {
        "agent_name": agent.name,
        "session_state": session_state,
    }

    state_json = state.to_json()
    resumed_model = FakeModel()
    resumed_model.add_multiple_turn_outputs(
        [
            [
                get_function_tool_call(
                    "read_file",
                    json.dumps({"path": "note.txt"}),
                    call_id="call_read",
                )
            ],
            [get_final_output_message("done")],
        ]
    )
    resumed_agent = SandboxAgent(
        name="sandbox",
        model=resumed_model,
        instructions="Base instructions.",
        tools=[approval_tool],
        capabilities=[_SessionFileCapability()],
        model_settings=ModelSettings(tool_choice="required"),
    )

    restored_state = await RunState.from_json(resumed_agent, state_json)
    restored_state.approve(restored_state.get_interruptions()[0])
    resumed = await Runner.run(
        resumed_agent,
        restored_state,
        run_config=_unix_local_run_config(client=client),
    )

    assert resumed.final_output == "done"
    assert resumed_model.last_turn_args["model_settings"].tool_choice is None
    assert any(
        isinstance(item, ToolCallOutputItem)
        and item.output == "persist me"
        and item.agent is resumed_agent
        for item in resumed.new_items
    )


@pytest.mark.asyncio
async def test_runner_restores_all_sandbox_agents_from_run_state_across_handoffs() -> None:
    client = UnixLocalSandboxClient()
    file_capability = _SessionFileCapability()

    @function_tool(name_override="approval_tool", needs_approval=True)
    def approval_tool() -> str:
        return "approved"

    triage_model = FakeModel()
    worker_model = FakeModel()
    worker = SandboxAgent(
        name="worker",
        model=worker_model,
        instructions="Worker instructions.",
        tools=[approval_tool],
    )
    triage = SandboxAgent(
        name="triage",
        model=triage_model,
        instructions="Triage instructions.",
        capabilities=[file_capability],
        handoffs=[worker],
    )
    worker.handoffs = [triage]
    triage_model.add_multiple_turn_outputs(
        [
            [
                get_function_tool_call(
                    "write_file",
                    json.dumps({"path": "note.txt", "content": "persist triage"}),
                    call_id="call_write",
                )
            ],
            [get_handoff_tool_call(worker)],
        ]
    )
    worker_model.add_multiple_turn_outputs(
        [
            [get_function_tool_call("approval_tool", json.dumps({}), call_id="call_approval")],
        ]
    )

    first_run = await Runner.run(
        triage,
        "hello",
        run_config=_unix_local_run_config(client=client),
    )

    assert len(first_run.interruptions) == 1
    state = first_run.to_state()
    assert state._sandbox is not None
    assert state._sandbox["backend_id"] == "unix_local"
    assert state._sandbox["current_agent_name"] == worker.name
    sessions_by_agent = state._sandbox["sessions_by_agent"]
    assert isinstance(sessions_by_agent, dict)
    assert set(sessions_by_agent) == {triage.name, worker.name}

    state_json = state.to_json()
    resumed_triage_model = FakeModel()
    resumed_worker_model = FakeModel()
    resumed_worker = SandboxAgent(
        name="worker",
        model=resumed_worker_model,
        instructions="Worker instructions.",
        tools=[approval_tool],
    )
    resumed_triage = SandboxAgent(
        name="triage",
        model=resumed_triage_model,
        instructions="Triage instructions.",
        capabilities=[_SessionFileCapability()],
        handoffs=[resumed_worker],
    )
    resumed_worker.handoffs = [resumed_triage]
    resumed_worker_model.add_multiple_turn_outputs([[get_handoff_tool_call(resumed_triage)]])
    resumed_triage_model.add_multiple_turn_outputs(
        [
            [
                get_function_tool_call(
                    "read_file",
                    json.dumps({"path": "note.txt"}),
                    call_id="call_read",
                )
            ],
            [get_final_output_message("done")],
        ]
    )

    restored_state = await RunState.from_json(resumed_triage, state_json)
    restored_state.approve(restored_state.get_interruptions()[0])
    resumed = await Runner.run(
        resumed_triage,
        restored_state,
        run_config=_unix_local_run_config(client=client),
    )

    assert resumed.final_output == "done"
    assert any(
        isinstance(item, ToolCallOutputItem)
        and item.output == "persist triage"
        and item.agent is resumed_triage
        for item in resumed.new_items
    )


@pytest.mark.asyncio
async def test_runner_serializes_unique_sandbox_resume_keys_for_duplicate_agent_names() -> None:
    client = UnixLocalSandboxClient()
    file_capability = _SessionFileCapability()

    @function_tool(name_override="approval_tool", needs_approval=True)
    def approval_tool() -> str:
        return "approved"

    first_model = FakeModel()
    second_model = FakeModel()
    first = SandboxAgent(
        name="sandbox",
        model=first_model,
        instructions="First instructions.",
        capabilities=[file_capability],
    )
    second = SandboxAgent(
        name="sandbox",
        model=second_model,
        instructions="Second instructions.",
        tools=[approval_tool],
    )
    first.handoffs = [second]
    second.handoffs = [first]
    first_model.add_multiple_turn_outputs(
        [
            [
                get_function_tool_call(
                    "write_file",
                    json.dumps({"path": "note.txt", "content": "first"}),
                    call_id="call_write",
                )
            ],
            [get_handoff_tool_call(second)],
            [
                get_function_tool_call(
                    "read_file",
                    json.dumps({"path": "note.txt"}),
                    call_id="call_read",
                )
            ],
            [get_final_output_message("done")],
        ]
    )
    second_model.add_multiple_turn_outputs(
        [
            [get_function_tool_call("approval_tool", json.dumps({}), call_id="call_approval")],
            [get_handoff_tool_call(first)],
        ]
    )

    first_run = await Runner.run(
        first,
        "hello",
        run_config=_unix_local_run_config(client=client),
    )

    state = first_run.to_state()
    assert state._sandbox is not None
    sessions_by_agent = cast(dict[str, dict[str, object]], state._sandbox["sessions_by_agent"])
    assert len(sessions_by_agent) == 2
    assert state._sandbox["current_agent_key"] in sessions_by_agent

    state.approve(first_run.interruptions[0])
    resumed = await Runner.run(
        first,
        state,
        run_config=_unix_local_run_config(client=client),
    )

    assert resumed.final_output == "done"
    assert any(
        isinstance(item, ToolCallOutputItem) and item.output == "first" and item.agent is first
        for item in resumed.new_items
    )


def test_duplicate_name_sandbox_identity_map_uses_capability_and_manifest_config() -> None:
    """Duplicate-name sandbox identities should stay stable when only sandbox config differs."""

    def _make_agent(readme: bytes, capability_text: str) -> SandboxAgent[None]:
        return SandboxAgent(
            name="sandbox",
            model=FakeModel(),
            instructions="Base instructions.",
            default_manifest=Manifest(entries={"README.md": File(content=readme)}),
            capabilities=[_RecordingCapability(instruction_text=capability_text)],
        )

    def _identity_for(identity_map: dict[str, Agent[Any]], target: Agent[Any]) -> str:
        return next(identity for identity, agent in identity_map.items() if agent is target)

    first_alpha = _make_agent(b"alpha", "Alpha capability.")
    first_beta = _make_agent(b"beta", "Beta capability.")
    first_root = Agent(name="triage", handoffs=[first_beta, first_alpha])
    first_alpha.handoffs = [first_root]
    first_beta.handoffs = [first_root]

    second_alpha = _make_agent(b"alpha", "Alpha capability.")
    second_beta = _make_agent(b"beta", "Beta capability.")
    second_root = Agent(name="triage", handoffs=[second_alpha, second_beta])
    second_alpha.handoffs = [second_root]
    second_beta.handoffs = [second_root]

    first_identity_map = _build_agent_identity_map(first_root)
    second_identity_map = _build_agent_identity_map(second_root)

    assert _identity_for(first_identity_map, first_alpha) == _identity_for(
        second_identity_map, second_alpha
    )
    assert _identity_for(first_identity_map, first_beta) == _identity_for(
        second_identity_map, second_beta
    )


@pytest.mark.asyncio
async def test_session_manager_reserves_current_duplicate_resume_key_for_current_agent() -> None:
    manifest = Manifest(entries={"README.md": File(content=b"duplicate resume")})
    client = _FakeClient(_FakeSession(manifest))
    first = SandboxAgent(name="sandbox", model=FakeModel(), instructions="First.")
    second = SandboxAgent(name="sandbox", model=FakeModel(), instructions="Second.")
    first.handoffs = [second]
    second.handoffs = [first]
    first_session_state = client.serialize_session_state(
        TestSessionState(manifest=Manifest(), snapshot=NoopSnapshot(id="first"))
    )
    second_session_state = client.serialize_session_state(
        TestSessionState(manifest=Manifest(), snapshot=NoopSnapshot(id="second"))
    )
    run_state: RunState[Any, Agent[Any]] = cast(
        RunState[Any, Agent[Any]],
        RunState(
            context=RunContextWrapper(context={}),
            original_input="hello",
            starting_agent=first,
        ),
    )
    run_state._current_agent = second
    run_state._sandbox = {
        "backend_id": "fake",
        "current_agent_key": "sandbox#2",
        "current_agent_name": second.name,
        "session_state": second_session_state,
        "sessions_by_agent": {
            "sandbox": {"agent_name": first.name, "session_state": first_session_state},
            "sandbox#2": {"agent_name": second.name, "session_state": second_session_state},
        },
    }
    manager = SandboxRuntimeSessionManager(
        starting_agent=first,
        sandbox_config=SandboxRunConfig(client=client, options={"image": "sandbox"}),
        run_state=run_state,
    )

    assert (
        manager._resume_state_payload_for_agent(client=client, agent=first, agent_id=id(first))
        == first_session_state
    )
    assert (
        manager._resume_state_payload_for_agent(client=client, agent=second, agent_id=id(second))
        == second_session_state
    )


def test_session_manager_generates_collision_free_resume_keys_for_literal_suffix_names() -> None:
    client = _FakeClient(_FakeSession(Manifest()))
    first = SandboxAgent(name="sandbox", model=FakeModel(), instructions="First.")
    literal_suffix = SandboxAgent(name="sandbox#2", model=FakeModel(), instructions="Literal.")
    second = SandboxAgent(name="sandbox", model=FakeModel(), instructions="Second.")
    first.handoffs = [literal_suffix, second]
    literal_suffix.handoffs = [first, second]
    second.handoffs = [first, literal_suffix]
    manager = SandboxRuntimeSessionManager(
        starting_agent=first,
        sandbox_config=SandboxRunConfig(client=client, options={"image": "sandbox"}),
        run_state=None,
    )

    manager.acquire_agent(first)
    manager.acquire_agent(literal_suffix)
    manager.acquire_agent(second)

    assert manager._ensure_resume_key(first) == "sandbox"
    assert manager._ensure_resume_key(literal_suffix) == "sandbox#2"
    assert manager._ensure_resume_key(second) == "sandbox#3"


@pytest.mark.asyncio
@pytest.mark.parametrize("source", ["create", "resume", "live_session"])
async def test_session_manager_passes_concurrency_limits_from_run_config(
    source: str,
) -> None:
    agent = SandboxAgent(name="worker", model=FakeModel(), instructions="Worker.")
    live_session = _FakeSession(Manifest())
    client = _FakeClient(live_session)

    if source == "live_session":
        sandbox_config = SandboxRunConfig(
            session=live_session,
            concurrency_limits=SandboxConcurrencyLimits(
                manifest_entries=2,
                local_dir_files=3,
            ),
        )
    elif source == "resume":
        sandbox_config = SandboxRunConfig(
            client=client,
            session_state=TestSessionState(
                manifest=Manifest(),
                snapshot=NoopSnapshot(id="resume"),
            ),
            options={"image": "sandbox"},
            concurrency_limits=SandboxConcurrencyLimits(
                manifest_entries=2,
                local_dir_files=3,
            ),
        )
    else:
        sandbox_config = SandboxRunConfig(
            client=client,
            options={"image": "sandbox"},
            concurrency_limits=SandboxConcurrencyLimits(
                manifest_entries=2,
                local_dir_files=3,
            ),
        )

    manager = SandboxRuntimeSessionManager(
        starting_agent=agent,
        sandbox_config=sandbox_config,
        run_state=None,
    )

    manager.acquire_agent(agent)
    await manager.ensure_session(agent=agent, capabilities=[], is_resumed_state=source == "resume")

    assert live_session.concurrency_limit_values == [
        SandboxConcurrencyLimits(manifest_entries=2, local_dir_files=3)
    ]


@pytest.mark.asyncio
@pytest.mark.parametrize("source", ["create", "resume", "live_session"])
async def test_session_manager_passes_archive_limits_from_run_config(
    source: str,
) -> None:
    agent = SandboxAgent(name="worker", model=FakeModel(), instructions="Worker.")
    live_session = _FakeSession(Manifest())
    client = _FakeClient(live_session)
    archive_limits = SandboxArchiveLimits(
        max_input_bytes=10,
        max_extracted_bytes=20,
        max_members=30,
    )

    if source == "live_session":
        sandbox_config = SandboxRunConfig(
            session=live_session,
            archive_limits=archive_limits,
        )
    elif source == "resume":
        sandbox_config = SandboxRunConfig(
            client=client,
            session_state=TestSessionState(
                manifest=Manifest(),
                snapshot=NoopSnapshot(id="resume"),
            ),
            options={"image": "sandbox"},
            archive_limits=archive_limits,
        )
    else:
        sandbox_config = SandboxRunConfig(
            client=client,
            options={"image": "sandbox"},
            archive_limits=archive_limits,
        )

    manager = SandboxRuntimeSessionManager(
        starting_agent=agent,
        sandbox_config=sandbox_config,
        run_state=None,
    )

    manager.acquire_agent(agent)
    await manager.ensure_session(agent=agent, capabilities=[], is_resumed_state=source == "resume")

    assert live_session.archive_limit_values == [archive_limits]


@pytest.mark.asyncio
async def test_session_manager_default_archive_limits_preserves_no_resource_limits() -> None:
    agent = SandboxAgent(name="worker", model=FakeModel(), instructions="Worker.")
    live_session = _FakeSession(Manifest())
    client = _FakeClient(live_session)
    manager = SandboxRuntimeSessionManager(
        starting_agent=agent,
        sandbox_config=SandboxRunConfig(client=client, options={"image": "sandbox"}),
        run_state=None,
    )

    manager.acquire_agent(agent)
    await manager.ensure_session(agent=agent, capabilities=[], is_resumed_state=False)

    assert live_session.archive_limit_values == [None]


@pytest.mark.asyncio
async def test_session_manager_rejects_invalid_archive_limits() -> None:
    agent = SandboxAgent(name="worker", model=FakeModel(), instructions="Worker.")
    client = _FakeClient(_FakeSession(Manifest()))
    limits = SandboxArchiveLimits(max_input_bytes=1)
    limits.max_input_bytes = 0
    manager = SandboxRuntimeSessionManager(
        starting_agent=agent,
        sandbox_config=SandboxRunConfig(
            client=client,
            options={"image": "sandbox"},
            archive_limits=limits,
        ),
        run_state=None,
    )

    manager.acquire_agent(agent)
    with pytest.raises(ValueError) as exc_info:
        await manager.ensure_session(agent=agent, capabilities=[], is_resumed_state=False)

    assert str(exc_info.value) == "archive_limits.max_input_bytes must be at least 1"
    assert client.create_kwargs is None


@pytest.mark.asyncio
@pytest.mark.parametrize(
    ("limits", "message"),
    [
        (
            SandboxConcurrencyLimits(manifest_entries=0, local_dir_files=1),
            "concurrency_limits.manifest_entries must be at least 1",
        ),
        (
            SandboxConcurrencyLimits(manifest_entries=1, local_dir_files=0),
            "concurrency_limits.local_dir_files must be at least 1",
        ),
    ],
)
async def test_session_manager_rejects_invalid_concurrency_limits(
    limits: SandboxConcurrencyLimits,
    message: str,
) -> None:
    agent = SandboxAgent(name="worker", model=FakeModel(), instructions="Worker.")
    client = _FakeClient(_FakeSession(Manifest()))
    manager = SandboxRuntimeSessionManager(
        starting_agent=agent,
        sandbox_config=SandboxRunConfig(
            client=client,
            options={"image": "sandbox"},
            concurrency_limits=limits,
        ),
        run_state=None,
    )

    manager.acquire_agent(agent)
    with pytest.raises(ValueError) as exc_info:
        await manager.ensure_session(agent=agent, capabilities=[], is_resumed_state=False)

    assert str(exc_info.value) == message
    assert client.create_kwargs is None


@pytest.mark.asyncio
async def test_session_manager_preserves_untouched_run_state_sessions_on_cleanup() -> None:
    manifest = Manifest(entries={"README.md": File(content=b"duplicate resume")})
    client = _FakeClient(_FakeSession(manifest))
    triage = SandboxAgent(name="triage", model=FakeModel(), instructions="Triage.")
    worker = SandboxAgent(name="worker", model=FakeModel(), instructions="Worker.")
    triage.handoffs = [worker]
    worker.handoffs = [triage]
    triage_session_state = client.serialize_session_state(
        TestSessionState(manifest=Manifest(), snapshot=NoopSnapshot(id="triage"))
    )
    worker_session_state = client.serialize_session_state(
        TestSessionState(manifest=Manifest(), snapshot=NoopSnapshot(id="worker"))
    )
    run_state: RunState[Any, Agent[Any]] = cast(
        RunState[Any, Agent[Any]],
        RunState(
            context=RunContextWrapper(context={}),
            original_input="hello",
            starting_agent=triage,
        ),
    )
    run_state._current_agent = worker
    run_state._sandbox = {
        "backend_id": "fake",
        "current_agent_key": worker.name,
        "current_agent_name": worker.name,
        "session_state": worker_session_state,
        "sessions_by_agent": {
            triage.name: {"agent_name": triage.name, "session_state": triage_session_state},
            worker.name: {"agent_name": worker.name, "session_state": worker_session_state},
        },
    }
    manager = SandboxRuntimeSessionManager(
        starting_agent=triage,
        sandbox_config=SandboxRunConfig(client=client, options={"image": "sandbox"}),
        run_state=run_state,
    )

    manager.acquire_agent(worker)
    await manager.ensure_session(agent=worker, capabilities=[], is_resumed_state=True)
    payload = await manager.cleanup()

    assert payload is not None
    sessions_by_agent = cast(dict[str, dict[str, object]], payload["sessions_by_agent"])
    assert set(sessions_by_agent) == {triage.name, worker.name}
    assert sessions_by_agent[triage.name] == {
        "agent_name": triage.name,
        "session_state": triage_session_state,
    }
    assert sessions_by_agent[worker.name] == {
        "agent_name": worker.name,
        "session_state": worker_session_state,
    }


@pytest.mark.asyncio
@pytest.mark.parametrize("resume_source", ["run_state", "session_state"])
async def test_session_manager_reapplies_capability_manifest_mutations_on_resume(
    resume_source: str,
) -> None:
    client = _FakeClient(_FakeSession(Manifest()))
    capability = _ManifestMutationCapability()
    agent = SandboxAgent(name="worker", model=FakeModel(), instructions="Worker.")
    session_state = TestSessionState(
        manifest=Manifest(),
        snapshot=NoopSnapshot(id="resume"),
    )

    run_state: RunState[Any, Agent[Any]] | None = None
    if resume_source == "run_state":
        run_state = cast(
            RunState[Any, Agent[Any]],
            RunState(
                context=RunContextWrapper(context={}),
                original_input="hello",
                starting_agent=agent,
            ),
        )
        run_state._current_agent = agent
        serialized_state = client.serialize_session_state(session_state)
        run_state._sandbox = {
            "backend_id": client.backend_id,
            "current_agent_key": agent.name,
            "current_agent_name": agent.name,
            "session_state": serialized_state,
            "sessions_by_agent": {
                agent.name: {
                    "agent_name": agent.name,
                    "session_state": serialized_state,
                }
            },
        }
        sandbox_config = SandboxRunConfig(client=client, options={"image": "sandbox"})
    else:
        sandbox_config = SandboxRunConfig(
            client=client,
            session_state=session_state,
            options={"image": "sandbox"},
        )

    manager = SandboxRuntimeSessionManager(
        starting_agent=agent,
        sandbox_config=sandbox_config,
        run_state=run_state,
    )

    manager.acquire_agent(agent)
    session = await manager.ensure_session(
        agent=agent,
        capabilities=[capability],
        is_resumed_state=True,
    )

    assert session.state.manifest.entries["cap.txt"] == File(content=b"capability")
    assert client.resume_state is not None
    assert client.resume_state.manifest.entries["cap.txt"] == File(content=b"capability")


@pytest.mark.asyncio
async def test_session_manager_adds_run_as_user_on_resume() -> None:
    client = _FakeClient(_FakeSession(Manifest()))
    run_as = User(name="sandbox-user")
    agent = SandboxAgent(
        name="worker",
        model=FakeModel(),
        instructions="Worker.",
        run_as=run_as,
    )
    session_state = TestSessionState(
        manifest=Manifest(),
        snapshot=NoopSnapshot(id="resume"),
    )
    manager = SandboxRuntimeSessionManager(
        starting_agent=agent,
        sandbox_config=SandboxRunConfig(
            client=client,
            session_state=session_state,
            options={"image": "sandbox"},
        ),
        run_state=None,
    )

    manager.acquire_agent(agent)
    session = await manager.ensure_session(
        agent=agent,
        capabilities=[],
        is_resumed_state=True,
    )

    assert session.state.manifest.users == [run_as]
    assert client.resume_state is not None
    assert client.resume_state.manifest.users == [run_as]


def test_session_manager_does_not_duplicate_run_as_user_from_group() -> None:
    run_as = User(name="sandbox-user")
    manifest = Manifest(groups=[Group(name="sandbox-group", users=[run_as])])

    processed = SandboxRuntimeSessionManager._manifest_with_run_as_user(manifest, run_as)

    assert processed is manifest
    assert processed.users == []


@pytest.mark.asyncio
@pytest.mark.parametrize("source", ["live_session", "session_state", "create"])
async def test_session_manager_applies_capability_manifest_mutations_with_session_parity(
    source: str,
) -> None:
    capability = _ManifestMutationCapability()
    agent = SandboxAgent(name="worker", model=FakeModel(), instructions="Worker.")
    run_state: RunState[Any, Agent[Any]] | None = None

    if source == "live_session":
        live_session = _FakeSession(Manifest())
        sandbox_config = SandboxRunConfig(session=live_session)
    else:
        client = _FakeClient(_FakeSession(Manifest()))
        if source == "session_state":
            sandbox_config = SandboxRunConfig(
                client=client,
                session_state=TestSessionState(
                    manifest=Manifest(),
                    snapshot=NoopSnapshot(id="resume"),
                ),
                options={"image": "sandbox"},
            )
        else:
            sandbox_config = SandboxRunConfig(
                client=client,
                manifest=Manifest(),
                options={"image": "sandbox"},
            )

    manager = SandboxRuntimeSessionManager(
        starting_agent=agent,
        sandbox_config=sandbox_config,
        run_state=run_state,
    )

    manager.acquire_agent(agent)
    session = await manager.ensure_session(
        agent=agent,
        capabilities=[capability],
        is_resumed_state=False,
    )

    assert session.state.manifest.entries["cap.txt"] == File(content=b"capability")
    if source == "session_state":
        assert client.resume_state is not None
        assert client.resume_state.manifest.entries["cap.txt"] == File(content=b"capability")
    if source == "create":
        assert client.create_kwargs is not None
        manifest = client.create_kwargs["manifest"]
        assert manifest is not None
        assert manifest.entries["cap.txt"] == File(content=b"capability")


@pytest.mark.asyncio
async def test_session_manager_starts_stopped_injected_session_with_manifest_mutation() -> None:
    live_session = _LiveSessionDeltaRecorder(Manifest())
    capability = _ManifestMutationCapability()
    agent = SandboxAgent(name="worker", model=FakeModel(), instructions="Worker.")
    manager = SandboxRuntimeSessionManager(
        starting_agent=agent,
        sandbox_config=SandboxRunConfig(session=live_session),
        run_state=None,
    )

    manager.acquire_agent(agent)
    session = await manager.ensure_session(
        agent=agent,
        capabilities=[capability],
        is_resumed_state=False,
    )
    payload = await manager.cleanup()

    assert session is live_session
    assert live_session.start_calls == 1
    assert live_session.apply_manifest_calls == 0
    assert live_session.stop_calls == 0
    assert live_session.shutdown_calls == 0
    assert session.state.manifest.entries["cap.txt"] == File(content=b"capability")
    assert payload is None


@pytest.mark.asyncio
async def test_session_manager_materializes_running_injected_session_manifest_mutation() -> None:
    live_session = _LiveSessionDeltaRecorder(Manifest())
    live_session._running = True
    capability = _ManifestMutationCapability()
    agent = SandboxAgent(name="worker", model=FakeModel(), instructions="Worker.")
    manager = SandboxRuntimeSessionManager(
        starting_agent=agent,
        sandbox_config=SandboxRunConfig(session=live_session),
        run_state=None,
    )

    manager.acquire_agent(agent)
    session = await manager.ensure_session(
        agent=agent,
        capabilities=[capability],
        is_resumed_state=False,
    )
    payload = await manager.cleanup()

    assert session is live_session
    assert live_session.start_calls == 0
    assert live_session.apply_manifest_calls == 0
    assert live_session.applied_entry_batches == [
        [(Path("/workspace/cap.txt"), File(content=b"capability"))]
    ]
    assert session.state.manifest.entries["cap.txt"] == File(content=b"capability")
    assert live_session.stop_calls == 0
    assert live_session.shutdown_calls == 0
    assert payload is None


@pytest.mark.asyncio
async def test_session_manager_retries_running_injected_session_delta_apply_after_failure() -> None:
    live_session = _LiveSessionDeltaRecorder(Manifest(), fail_entry_batch_times=1)
    live_session._running = True
    capability = _ManifestMutationCapability()
    agent = SandboxAgent(name="worker", model=FakeModel(), instructions="Worker.")
    manager = SandboxRuntimeSessionManager(
        starting_agent=agent,
        sandbox_config=SandboxRunConfig(session=live_session),
        run_state=None,
    )

    manager.acquire_agent(agent)
    with pytest.raises(RuntimeError, match="delta apply failed"):
        await manager.ensure_session(
            agent=agent,
            capabilities=[capability],
            is_resumed_state=False,
        )

    assert live_session.state.manifest.entries == {}
    assert live_session.applied_entry_batches == [
        [(Path("/workspace/cap.txt"), File(content=b"capability"))]
    ]

    session = await manager.ensure_session(
        agent=agent,
        capabilities=[capability],
        is_resumed_state=False,
    )
    payload = await manager.cleanup()

    assert session is live_session
    assert live_session.state.manifest.entries["cap.txt"] == File(content=b"capability")
    assert live_session.applied_entry_batches == [
        [(Path("/workspace/cap.txt"), File(content=b"capability"))],
        [(Path("/workspace/cap.txt"), File(content=b"capability"))],
    ]
    assert payload is None


@pytest.mark.asyncio
async def test_session_manager_skips_rematerialization_for_unchanged_running_session() -> None:
    live_session = _LiveSessionDeltaRecorder(Manifest())
    live_session._running = True
    agent = SandboxAgent(name="worker", model=FakeModel(), instructions="Worker.")
    manager = SandboxRuntimeSessionManager(
        starting_agent=agent,
        sandbox_config=SandboxRunConfig(session=live_session),
        run_state=None,
    )

    manager.acquire_agent(agent)
    session = await manager.ensure_session(
        agent=agent,
        capabilities=[Capability(type="noop")],
        is_resumed_state=False,
    )
    payload = await manager.cleanup()

    assert session is live_session
    assert live_session.start_calls == 0
    assert live_session.apply_manifest_calls == 0
    assert live_session.applied_entry_batches == []
    assert session.state.manifest.entries == {}
    assert live_session.stop_calls == 0
    assert live_session.shutdown_calls == 0
    assert payload is None


@pytest.mark.asyncio
async def test_session_manager_rejects_running_injected_session_account_mutation() -> None:
    live_session = _LiveSessionDeltaRecorder(Manifest())
    live_session._running = True
    agent = SandboxAgent(name="worker", model=FakeModel(), instructions="Worker.")
    manager = SandboxRuntimeSessionManager(
        starting_agent=agent,
        sandbox_config=SandboxRunConfig(session=live_session),
        run_state=None,
    )

    manager.acquire_agent(agent)
    with pytest.raises(ValueError, match="manifest.users` or `manifest.groups"):
        await manager.ensure_session(
            agent=agent,
            capabilities=[_ManifestUsersCapability()],
            is_resumed_state=False,
        )

    assert live_session.apply_manifest_calls == 0
    assert live_session.applied_entry_batches == []
    assert live_session.state.manifest.users == []


@pytest.mark.asyncio
async def test_session_manager_preserves_existing_payload_when_no_sandbox_session_is_used() -> None:
    client = _FakeClient(_FakeSession(Manifest()))
    agent = SandboxAgent(name="sandbox", model=FakeModel(), instructions="Base instructions.")
    run_state: RunState[Any, Agent[Any]] = cast(
        RunState[Any, Agent[Any]],
        RunState(
            context=RunContextWrapper(context={}),
            original_input="hello",
            starting_agent=agent,
        ),
    )
    existing_payload = {
        "backend_id": "fake",
        "current_agent_key": agent.name,
        "current_agent_name": agent.name,
        "session_state": {"snapshot": {"id": "persisted"}},
        "sessions_by_agent": {
            agent.name: {
                "agent_name": agent.name,
                "session_state": {"snapshot": {"id": "persisted"}},
            }
        },
    }
    run_state._sandbox = existing_payload
    manager = SandboxRuntimeSessionManager(
        starting_agent=agent,
        sandbox_config=SandboxRunConfig(client=client, options={"image": "sandbox"}),
        run_state=run_state,
    )

    payload = await manager.cleanup()

    assert payload == existing_payload
    assert payload is not existing_payload


@pytest.mark.asyncio
async def test_session_manager_omits_existing_payload_for_injected_live_session() -> None:
    agent = SandboxAgent(name="sandbox", model=FakeModel(), instructions="Base instructions.")
    live_session = _FakeSession(Manifest())
    run_state: RunState[Any, Agent[Any]] = cast(
        RunState[Any, Agent[Any]],
        RunState(
            context=RunContextWrapper(context={}),
            original_input="hello",
            starting_agent=agent,
        ),
    )
    run_state._sandbox = {
        "backend_id": "fake",
        "current_agent_key": agent.name,
        "current_agent_name": agent.name,
        "session_state": {"snapshot": {"id": "persisted"}},
        "sessions_by_agent": {
            agent.name: {
                "agent_name": agent.name,
                "session_state": {"snapshot": {"id": "persisted"}},
            }
        },
    }
    manager = SandboxRuntimeSessionManager(
        starting_agent=agent,
        sandbox_config=SandboxRunConfig(session=live_session),
        run_state=run_state,
    )

    manager.acquire_agent(agent)
    await manager.ensure_session(agent=agent, capabilities=[], is_resumed_state=True)
    payload = await manager.cleanup()

    assert payload is None
    assert live_session.stop_calls == 0
    assert live_session.shutdown_calls == 0


@pytest.mark.asyncio
async def test_session_manager_uses_run_state_starting_agent_for_duplicate_resume_keys() -> None:
    manifest = Manifest(entries={"README.md": File(content=b"duplicate resume")})
    client = _FakeClient(_FakeSession(manifest))
    first = SandboxAgent(name="sandbox", model=FakeModel(), instructions="First.")
    second = SandboxAgent(name="sandbox", model=FakeModel(), instructions="Second.")
    approver = Agent(name="approver", model=FakeModel(), instructions="Approve.", handoffs=[])
    approver.handoffs = [second, first]
    first.handoffs = [second]
    second.handoffs = [approver]
    first_session_state = client.serialize_session_state(
        TestSessionState(manifest=Manifest(), snapshot=NoopSnapshot(id="first"))
    )
    second_session_state = client.serialize_session_state(
        TestSessionState(manifest=Manifest(), snapshot=NoopSnapshot(id="second"))
    )
    run_state: RunState[Any, Agent[Any]] = cast(
        RunState[Any, Agent[Any]],
        RunState(
            context=RunContextWrapper(context={}),
            original_input="hello",
            starting_agent=first,
        ),
    )
    run_state._current_agent = approver
    run_state._starting_agent = first
    run_state._sandbox = {
        "backend_id": "fake",
        "current_agent_key": "sandbox#2",
        "current_agent_name": second.name,
        "session_state": second_session_state,
        "sessions_by_agent": {
            "sandbox": {"agent_name": first.name, "session_state": first_session_state},
            "sandbox#2": {"agent_name": second.name, "session_state": second_session_state},
        },
    }
    manager = SandboxRuntimeSessionManager(
        starting_agent=approver,
        sandbox_config=SandboxRunConfig(client=client, options={"image": "sandbox"}),
        run_state=run_state,
    )

    assert (
        manager._resume_state_payload_for_agent(client=client, agent=first, agent_id=id(first))
        == first_session_state
    )
    assert (
        manager._resume_state_payload_for_agent(client=client, agent=second, agent_id=id(second))
        == second_session_state
    )


@pytest.mark.asyncio
async def test_session_manager_restores_duplicate_name_sessions_when_only_sandbox_config_differs():
    client = _FakeClient(_FakeSession(Manifest()))

    def _make_agent(readme: bytes, capability_text: str) -> SandboxAgent[None]:
        return SandboxAgent(
            name="sandbox",
            model=FakeModel(),
            instructions="Base instructions.",
            default_manifest=Manifest(entries={"README.md": File(content=readme)}),
            capabilities=[_RecordingCapability(instruction_text=capability_text)],
        )

    first = _make_agent(b"first", "First capability.")
    second = _make_agent(b"second", "Second capability.")
    root = Agent(name="triage", handoffs=[second, first])
    first.handoffs = [root]
    second.handoffs = [root]

    first_session_state = client.serialize_session_state(
        TestSessionState(manifest=Manifest(), snapshot=NoopSnapshot(id="first"))
    )
    second_session_state = client.serialize_session_state(
        TestSessionState(manifest=Manifest(), snapshot=NoopSnapshot(id="second"))
    )

    state: RunState[Any, Agent[Any]] = cast(
        RunState[Any, Agent[Any]],
        RunState(
            context=RunContextWrapper(context={}),
            original_input="hello",
            starting_agent=root,
        ),
    )
    state._current_agent = second
    state._sandbox = {
        "backend_id": "fake",
        "current_agent_key": "sandbox#2",
        "current_agent_name": second.name,
        "session_state": second_session_state,
        "sessions_by_agent": {
            "sandbox": {"agent_name": first.name, "session_state": first_session_state},
            "sandbox#2": {"agent_name": second.name, "session_state": second_session_state},
        },
    }

    restored_first = _make_agent(b"first", "First capability.")
    restored_second = _make_agent(b"second", "Second capability.")
    restored_root = Agent(name="triage", handoffs=[restored_first, restored_second])
    restored_first.handoffs = [restored_root]
    restored_second.handoffs = [restored_root]

    restored_state = await RunState.from_json(restored_root, state.to_json())
    assert restored_state._current_agent is restored_second

    manager = SandboxRuntimeSessionManager(
        starting_agent=restored_root,
        sandbox_config=SandboxRunConfig(client=client, options={"image": "sandbox"}),
        run_state=restored_state,
    )

    assert (
        manager._resume_state_payload_for_agent(
            client=client,
            agent=restored_first,
            agent_id=id(restored_first),
        )
        == first_session_state
    )
    assert (
        manager._resume_state_payload_for_agent(
            client=client,
            agent=restored_second,
            agent_id=id(restored_second),
        )
        == second_session_state
    )


@pytest.mark.asyncio
async def test_runner_restores_duplicate_name_sandbox_sessions_after_json_roundtrip() -> None:
    client = UnixLocalSandboxClient()
    file_capability = _SessionFileCapability()

    @function_tool(name_override="approval_tool", needs_approval=True)
    def approval_tool() -> str:
        return "approved"

    first_model = FakeModel()
    second_model = FakeModel()
    first = SandboxAgent(
        name="sandbox",
        model=first_model,
        instructions="First instructions.",
        capabilities=[file_capability],
    )
    second = SandboxAgent(
        name="sandbox",
        model=second_model,
        instructions="Second instructions.",
        tools=[approval_tool],
    )
    first.handoffs = [second]
    second.handoffs = [first]
    first_model.add_multiple_turn_outputs(
        [
            [
                get_function_tool_call(
                    "write_file",
                    json.dumps({"path": "note.txt", "content": "first"}),
                    call_id="call_write",
                )
            ],
            [get_handoff_tool_call(second)],
        ]
    )
    second_model.add_multiple_turn_outputs(
        [[get_function_tool_call("approval_tool", json.dumps({}), call_id="call_approval")]]
    )

    first_run = await Runner.run(
        first,
        "hello",
        run_config=_unix_local_run_config(client=client),
    )

    state = first_run.to_state()
    state_json = state.to_json()

    resumed_first_model = FakeModel()
    resumed_second_model = FakeModel()
    resumed_first = SandboxAgent(
        name="sandbox",
        model=resumed_first_model,
        instructions="First instructions.",
        capabilities=[_SessionFileCapability()],
    )
    resumed_second = SandboxAgent(
        name="sandbox",
        model=resumed_second_model,
        instructions="Second instructions.",
        tools=[approval_tool],
    )
    resumed_first.handoffs = [resumed_second]
    resumed_second.handoffs = [resumed_first]
    resumed_second_model.add_multiple_turn_outputs([[get_handoff_tool_call(resumed_first)]])
    resumed_first_model.add_multiple_turn_outputs(
        [
            [
                get_function_tool_call(
                    "read_file",
                    json.dumps({"path": "note.txt"}),
                    call_id="call_read",
                )
            ],
            [get_final_output_message("done")],
        ]
    )

    restored_state = await RunState.from_json(resumed_first, state_json)
    restored_state.approve(restored_state.get_interruptions()[0])
    resumed = await Runner.run(
        resumed_first,
        restored_state,
        run_config=_unix_local_run_config(client=client),
    )

    assert resumed.final_output == "done"
    assert any(
        isinstance(item, ToolCallOutputItem)
        and item.output == "first"
        and item.agent is resumed_first
        for item in resumed.new_items
    )


@pytest.mark.asyncio
async def test_runner_restores_legacy_current_sandbox_payload_after_json_roundtrip() -> None:
    client = UnixLocalSandboxClient()

    @function_tool(name_override="approval_tool", needs_approval=True)
    def approval_tool() -> str:
        return "approved"

    initial_model = FakeModel()
    initial_model.add_multiple_turn_outputs(
        [
            [
                get_function_tool_call(
                    "write_file", json.dumps({"path": "note.txt", "content": "legacy"})
                )
            ],
            [get_function_tool_call("approval_tool", json.dumps({}), call_id="call_approval")],
        ]
    )
    agent = SandboxAgent(
        name="sandbox",
        model=initial_model,
        instructions="Base instructions.",
        tools=[approval_tool],
        capabilities=[_SessionFileCapability()],
    )

    first_run = await Runner.run(
        agent,
        "hello",
        run_config=_unix_local_run_config(client=client),
    )
    state = first_run.to_state()
    assert state._sandbox is not None
    session_state = cast(dict[str, object], state._sandbox["session_state"])
    state._sandbox = {
        "backend_id": "unix_local",
        "current_agent_id": id(agent),
        "session_state": session_state,
        "sessions_by_agent": {str(id(agent)): session_state},
    }

    resumed_model = FakeModel()
    resumed_model.add_multiple_turn_outputs(
        [
            [
                get_function_tool_call(
                    "read_file", json.dumps({"path": "note.txt"}), call_id="call_read"
                )
            ],
            [get_final_output_message("done")],
        ]
    )
    resumed_agent = SandboxAgent(
        name="sandbox",
        model=resumed_model,
        instructions="Base instructions.",
        tools=[approval_tool],
        capabilities=[_SessionFileCapability()],
    )

    restored_state = await RunState.from_json(resumed_agent, state.to_json())
    restored_state.approve(restored_state.get_interruptions()[0])
    resumed = await Runner.run(
        resumed_agent,
        restored_state,
        run_config=_unix_local_run_config(client=client),
    )

    assert resumed.final_output == "done"
    assert any(
        isinstance(item, ToolCallOutputItem)
        and item.output == "legacy"
        and item.agent is resumed_agent
        for item in resumed.new_items
    )


@pytest.mark.asyncio
@pytest.mark.skipif(
    sys.platform != "darwin" or shutil.which("sandbox-exec") is None,
    reason="sandbox-exec is only available on macOS when installed",
)
async def test_unix_local_exec_confines_commands_to_workspace_root() -> None:
    workspace_root = Path(tempfile.mkdtemp(prefix="unix-local-exec-"))
    session = await UnixLocalSandboxClient().create(
        manifest=_unix_local_manifest(root=str(workspace_root)),
        options=None,
    )

    try:
        async with session:
            result = await session.exec("echo hi > note.txt && cat note.txt")
            assert result.ok()
            assert result.stdout.decode("utf-8", errors="replace").strip().endswith("hi")

            forbidden = await session.exec("cat /etc/passwd >/dev/null")
            assert not forbidden.ok()

            outside_write = await session.exec("echo nope > /usr/local/test-sandbox")
            assert not outside_write.ok()

            sibling = workspace_root.parent / "escape.txt"
            sibling.unlink(missing_ok=True)
            escaped = await session.exec("echo nope > ../escape.txt")
            assert not escaped.ok()
            assert not sibling.exists()
    finally:
        shutil.rmtree(workspace_root, ignore_errors=True)


@pytest.mark.asyncio
async def test_unix_local_exec_rejects_when_confinement_is_unavailable(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    workspace_root = Path(tempfile.mkdtemp(prefix="unix-local-exec-"))
    session = await UnixLocalSandboxClient().create(
        manifest=_unix_local_manifest(root=str(workspace_root)),
        options=None,
    )
    unix_local = cast(Any, unix_local_module)
    monkeypatch.setattr(unix_local.sys, "platform", "darwin")
    monkeypatch.setattr(unix_local.shutil, "which", lambda _name: None)

    try:
        with pytest.raises(ExecTransportError) as exc_info:
            await session.exec("pwd")
    finally:
        shutil.rmtree(workspace_root, ignore_errors=True)

    assert exc_info.value.context["reason"] == "unix_local_confinement_unavailable"


@pytest.mark.asyncio
async def test_unix_local_exec_runs_without_wrapper_on_linux(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    workspace_root = Path(tempfile.mkdtemp(prefix="unix-local-exec-"))
    session = await UnixLocalSandboxClient().create(
        manifest=_unix_local_manifest(root=str(workspace_root)),
        options=None,
    )
    unix_local = cast(Any, unix_local_module)
    monkeypatch.setattr(unix_local.sys, "platform", "linux")

    try:
        async with session:
            result = await session.exec("pwd")
    finally:
        shutil.rmtree(workspace_root, ignore_errors=True)

    assert result.ok()
    assert result.stdout.decode("utf-8", errors="replace").strip() == str(workspace_root.resolve())


@pytest.mark.asyncio
async def test_unix_local_file_io_allows_extra_path_grant(tmp_path: Path) -> None:
    workspace_root = tmp_path / "workspace"
    allowed_root = tmp_path / "allowed"
    workspace_root.mkdir()
    allowed_root.mkdir()
    session = UnixLocalSandboxSession.from_state(
        UnixLocalSandboxSessionState(
            session_id=uuid.uuid4(),
            manifest=Manifest(
                root=str(workspace_root),
                extra_path_grants=(SandboxPathGrant(path=str(allowed_root)),),
            ),
            snapshot=NoopSnapshot(id="extra-path-grant"),
            workspace_root_owned=False,
        )
    )

    await session.write(allowed_root / "result.txt", io.BytesIO(b"scratch output"))
    payload = await session.read(allowed_root / "result.txt")

    assert payload.read() == b"scratch output"


@pytest.mark.asyncio
async def test_unix_local_file_io_rejects_write_under_read_only_extra_path_grant(
    tmp_path: Path,
) -> None:
    workspace_root = tmp_path / "workspace"
    allowed_root = tmp_path / "allowed"
    workspace_root.mkdir()
    allowed_root.mkdir()
    (allowed_root / "existing.txt").write_text("readable", encoding="utf-8")
    session = UnixLocalSandboxSession.from_state(
        UnixLocalSandboxSessionState(
            session_id=uuid.uuid4(),
            manifest=Manifest(
                root=str(workspace_root),
                extra_path_grants=(SandboxPathGrant(path=str(allowed_root), read_only=True),),
            ),
            snapshot=NoopSnapshot(id="read-only-extra-path-grant"),
            workspace_root_owned=False,
        )
    )

    payload = await session.read(allowed_root / "existing.txt")
    with pytest.raises(WorkspaceArchiveWriteError) as exc_info:
        await session.write(allowed_root / "result.txt", io.BytesIO(b"scratch output"))

    assert payload.read() == b"readable"
    assert str(exc_info.value) == f"failed to write archive for path: {allowed_root / 'result.txt'}"
    assert exc_info.value.context == {
        "path": str(allowed_root / "result.txt"),
        "reason": "read_only_extra_path_grant",
        "grant_path": str(allowed_root),
    }


def test_unix_local_confined_exec_command_allows_common_darwin_interpreter_roots(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    workspace_root = Path(tempfile.mkdtemp(prefix="unix-local-exec-"))
    session = UnixLocalSandboxSession.from_state(
        UnixLocalSandboxSessionState(
            session_id=uuid.uuid4(),
            manifest=_unix_local_manifest(root=str(workspace_root)),
            snapshot=NoopSnapshot(id="darwin"),
            workspace_root_owned=False,
        )
    )
    unix_local = cast(Any, unix_local_module)
    host_home = Path.home()
    path_env = os.pathsep.join(
        [
            "/opt/homebrew/bin",
            "/usr/local/bin",
            str(host_home / ".local" / "bin"),
        ]
    )

    def _fake_which(name: str, path: str | None = None) -> str | None:
        if name == "sandbox-exec":
            return "/usr/bin/sandbox-exec"
        if name == "python3":
            assert path == path_env
            return "/opt/homebrew/bin/python3"
        return None

    monkeypatch.setattr(unix_local.sys, "platform", "darwin")
    monkeypatch.setattr(unix_local.shutil, "which", _fake_which)

    command = session._confined_exec_command(
        command_parts=["python3", "-V"],
        workspace_root=workspace_root,
        env={"PATH": path_env},
    )
    profile = command[2]

    assert command[:2] == ["/usr/bin/sandbox-exec", "-p"]
    assert '(allow file-read-data file-read-metadata (subpath "/opt/homebrew"))' in profile
    assert '(allow file-read-data file-read-metadata (subpath "/usr/local"))' in profile
    assert (
        f'(allow file-read-data file-read-metadata (subpath "{host_home / ".local"}"))' in profile
    )
    assert '(deny file-write* (subpath "/opt"))' in profile
    assert '(allow file-write* (subpath "/opt/homebrew"))' not in profile


def test_unix_local_darwin_exec_profile_allows_extra_path_grants(tmp_path: Path) -> None:
    workspace_root = tmp_path / "workspace"
    read_write_root = tmp_path / "read-write"
    read_only_root = tmp_path / "read-only"
    workspace_root.mkdir()
    read_write_root.mkdir()
    read_only_root.mkdir()
    session = UnixLocalSandboxSession.from_state(
        UnixLocalSandboxSessionState(
            session_id=uuid.uuid4(),
            manifest=Manifest(
                root=str(workspace_root),
                extra_path_grants=(
                    SandboxPathGrant(path=str(read_write_root)),
                    SandboxPathGrant(path=str(read_only_root), read_only=True),
                ),
            ),
            snapshot=NoopSnapshot(id="darwin-extra-path-grant"),
            workspace_root_owned=False,
        )
    )

    profile = session._darwin_exec_profile(
        workspace_root,
        extra_path_grants=session._darwin_extra_path_grant_roots(),
    )
    profile_lines = set(profile.splitlines())

    assert (
        f'(allow file-read-data file-read-metadata (subpath "{read_write_root}"))' in profile_lines
    )
    assert f'(allow file-write* (subpath "{read_write_root}"))' in profile_lines
    assert (
        f'(allow file-read-data file-read-metadata (subpath "{read_only_root}"))' in profile_lines
    )
    assert f'(allow file-write* (subpath "{read_only_root}"))' not in profile_lines


def test_unix_local_darwin_exec_profile_denies_nested_read_only_extra_path_grant(
    tmp_path: Path,
) -> None:
    workspace_root = tmp_path / "workspace"
    read_write_root = tmp_path / "read-write"
    read_only_root = read_write_root / "protected"
    workspace_root.mkdir()
    read_only_root.mkdir(parents=True)
    session = UnixLocalSandboxSession.from_state(
        UnixLocalSandboxSessionState(
            session_id=uuid.uuid4(),
            manifest=Manifest(
                root=str(workspace_root),
                extra_path_grants=(
                    SandboxPathGrant(path=str(read_write_root)),
                    SandboxPathGrant(path=str(read_only_root), read_only=True),
                ),
            ),
            snapshot=NoopSnapshot(id="darwin-nested-extra-path-grant"),
            workspace_root_owned=False,
        )
    )

    profile = session._darwin_exec_profile(
        workspace_root,
        extra_path_grants=session._darwin_extra_path_grant_roots(),
    )
    profile_lines = profile.splitlines()
    parent_write_allow = f'(allow file-write* (subpath "{read_write_root}"))'
    child_write_deny = f'(deny file-write* (subpath "{read_only_root}"))'

    assert parent_write_allow in profile_lines
    assert child_write_deny in profile_lines
    assert profile_lines.index(parent_write_allow) < profile_lines.index(child_write_deny)
    assert f'(allow file-write* (subpath "{read_only_root}"))' not in profile_lines


def test_unix_local_darwin_exec_profile_rejects_extra_path_grant_symlink_to_root(
    tmp_path: Path,
) -> None:
    workspace_root = tmp_path / "workspace"
    root_alias = tmp_path / "root-alias"
    workspace_root.mkdir()
    root_alias.symlink_to(Path("/"), target_is_directory=True)
    session = UnixLocalSandboxSession.from_state(
        UnixLocalSandboxSessionState(
            session_id=uuid.uuid4(),
            manifest=Manifest(
                root=str(workspace_root),
                extra_path_grants=(SandboxPathGrant(path=str(root_alias)),),
            ),
            snapshot=NoopSnapshot(id="darwin-extra-path-grant-root-alias"),
            workspace_root_owned=False,
        )
    )

    with pytest.raises(ValueError) as exc_info:
        session._darwin_extra_path_grant_roots()

    assert str(exc_info.value) == "sandbox path grant path must not resolve to filesystem root"


@pytest.mark.asyncio
async def test_sandbox_run_persists_only_new_session_input_items() -> None:
    session = SimpleListSession(
        history=[
            {
                "role": "user",
                "content": "old",
            }
        ]
    )
    model = FakeModel(initial_output=[get_final_output_message("done")])
    agent = SandboxAgent(
        name="sandbox",
        model=model,
        instructions="Base instructions.",
    )

    result = await Runner.run(
        agent,
        "new",
        session=session,
        run_config=_sandbox_run_config(_FakeClient(_FakeSession(Manifest()))),
    )

    assert result.final_output == "done"
    saved_user_items = [
        item
        for item in await session.get_items()
        if isinstance(item, dict) and item.get("role") == "user"
    ]
    assert saved_user_items == [
        {"role": "user", "content": "old"},
        {"role": "user", "content": "new"},
    ]


@pytest.mark.asyncio
async def test_runner_streamed_emits_public_agent_for_tool_and_reasoning_events() -> None:
    model = FakeModel()
    model.add_multiple_turn_outputs(
        [
            [
                _get_reasoning_item(),
                get_function_tool_call("tool1", json.dumps({}), call_id="call_tool"),
            ],
            [get_final_output_message("done")],
        ]
    )
    agent = SandboxAgent(
        name="sandbox",
        model=model,
        instructions="Base instructions.",
        tools=[get_function_tool("tool1", "tool result")],
    )

    result = Runner.run_streamed(
        agent,
        "hello",
        run_config=_sandbox_run_config(_FakeClient(_FakeSession(Manifest()))),
    )
    events = [event async for event in result.stream_events()]
    relevant_events = [
        event
        for event in events
        if isinstance(event, RunItemStreamEvent)
        and event.name in {"reasoning_item_created", "tool_called", "tool_output"}
    ]

    assert relevant_events
    assert all(event.item.agent is agent for event in relevant_events)


def test_capability_clone_deep_copies_nested_mutable_state() -> None:
    capability = _NestedStateCapability()

    cloned = cast(_NestedStateCapability, capability.clone())
    cloned.state["seen"].append("turn-1")

    assert capability.state == {"seen": []}
    assert cloned.state == {"seen": ["turn-1"]}


def test_capability_clone_deep_copies_nested_object_state() -> None:
    capability = _NestedObjectCapability()

    cloned = cast(_NestedObjectCapability, capability.clone())
    cloned.state.seen.append("turn-1")

    assert capability.state.seen == []
    assert cloned.state.seen == ["turn-1"]


def test_capability_clone_preserves_session_field_identity() -> None:
    capability = Shell()
    session = _FakeSession(Manifest())
    capability.bind(session)

    cloned = capability.clone()

    assert capability.session is session
    assert cloned.session is session
    assert capability.model_dump() == {"type": "shell"}
    assert cloned.model_dump() == {"type": "shell"}


@pytest.mark.asyncio
async def test_apply_manifest_raises_on_account_provisioning_failures() -> None:
    session = _ProvisioningFailureSession(
        Manifest(users=[User(name="sandbox-user")]),
    )

    with pytest.raises(ExecNonZeroError) as exc_info:
        await session.apply_manifest()

    assert exc_info.value.context["command_str"] == (
        "useradd -U -M -s /usr/sbin/nologin sandbox-user"
    )
    assert exc_info.value.context["stdout"] == "attempted useradd"
    assert exc_info.value.context["stderr"] == "missing useradd"
    assert exc_info.value.message == "stdout: attempted useradd\nstderr: missing useradd"


@pytest.mark.asyncio
async def test_apply_manifest_only_ephemeral_skips_account_provisioning_failures() -> None:
    session = _ProvisioningFailureSession(
        Manifest(users=[User(name="sandbox-user")]),
    )

    result = await session.apply_manifest(only_ephemeral=True)

    assert result.files == []


@pytest.mark.asyncio
async def test_resume_reprovisions_manifest_accounts_before_reapplying_ephemeral_entries() -> None:
    session = _RestorableProvisioningFailureSession(
        Manifest(users=[User(name="sandbox-user")]),
    )

    with pytest.raises(ExecNonZeroError):
        await session.start()

    assert session.cleared_workspace_root is True
    assert session.hydrate_calls == 1


@pytest.mark.asyncio
async def test_resume_can_skip_manifest_account_reprovisioning_when_os_state_is_preserved() -> None:
    session = _RestorableProvisioningFailureSession(
        Manifest(users=[User(name="sandbox-user")]),
        provision_on_resume=False,
    )

    await session.start()

    assert session.cleared_workspace_root is True
    assert session.hydrate_calls == 1


@pytest.mark.asyncio
async def test_clear_workspace_root_on_resume_preserves_nested_mounts(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    def _ls_entry(path: str, *, kind: EntryKind) -> FileEntry:
        return FileEntry(
            path=path,
            permissions=Permissions.from_str(
                "drwxr-xr-x" if kind == EntryKind.DIRECTORY else "-rw-r--r--"
            ),
            owner="root",
            group="root",
            size=0,
            kind=kind,
        )

    session = _FakeSession(
        Manifest(
            entries={
                "a/b": S3Mount(
                    bucket="bucket",
                    mount_strategy=InContainerMountStrategy(pattern=MountpointMountPattern()),
                ),
            }
        )
    )
    ls_calls: list[Path] = []
    rm_calls: list[tuple[Path, bool]] = []

    async def _fake_ls(path: Path | str) -> list[FileEntry]:
        rendered = Path(path)
        ls_calls.append(rendered)
        if rendered == Path("/workspace"):
            return [
                _ls_entry("/workspace/a", kind=EntryKind.DIRECTORY),
                _ls_entry("/workspace/root.txt", kind=EntryKind.FILE),
            ]
        if rendered == Path("/workspace/a"):
            return [
                _ls_entry("/workspace/a/b", kind=EntryKind.DIRECTORY),
                _ls_entry("/workspace/a/local.txt", kind=EntryKind.FILE),
            ]
        raise AssertionError(f"unexpected ls path: {rendered}")

    async def _fake_rm(path: Path | str, *, recursive: bool = False) -> None:
        rm_calls.append((Path(path), recursive))

    monkeypatch.setattr(session, "ls", _fake_ls)
    monkeypatch.setattr(session, "rm", _fake_rm)

    await session._clear_workspace_root_on_resume()  # noqa: SLF001

    assert ls_calls == [Path("/workspace"), Path("/workspace/a")]
    assert rm_calls == [
        (Path("/workspace/a/local.txt"), True),
        (Path("/workspace/root.txt"), True),
    ]


@pytest.mark.asyncio
async def test_clear_workspace_root_on_resume_deletes_file_ancestor_of_skipped_mount(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    def _ls_entry(path: str, *, kind: EntryKind) -> FileEntry:
        return FileEntry(
            path=path,
            permissions=Permissions.from_str(
                "drwxr-xr-x" if kind == EntryKind.DIRECTORY else "-rw-r--r--"
            ),
            owner="root",
            group="root",
            size=0,
            kind=kind,
        )

    session = _FakeSession(
        Manifest(
            entries={
                "a/b": S3Mount(
                    bucket="bucket",
                    mount_strategy=InContainerMountStrategy(pattern=MountpointMountPattern()),
                ),
            }
        )
    )
    ls_calls: list[Path] = []
    rm_calls: list[tuple[Path, bool]] = []

    async def _fake_ls(path: Path | str) -> list[FileEntry]:
        rendered = Path(path)
        ls_calls.append(rendered)
        if rendered == Path("/workspace"):
            return [
                _ls_entry("/workspace/a", kind=EntryKind.FILE),
                _ls_entry("/workspace/root.txt", kind=EntryKind.FILE),
            ]
        raise AssertionError(f"unexpected ls path: {rendered}")

    async def _fake_rm(path: Path | str, *, recursive: bool = False) -> None:
        rm_calls.append((Path(path), recursive))

    monkeypatch.setattr(session, "ls", _fake_ls)
    monkeypatch.setattr(session, "rm", _fake_rm)

    await session._clear_workspace_root_on_resume()  # noqa: SLF001

    assert ls_calls == [Path("/workspace")]
    assert rm_calls == [
        (Path("/workspace/a"), True),
        (Path("/workspace/root.txt"), True),
    ]


@pytest.mark.asyncio
async def test_clear_workspace_root_on_resume_preserves_workspace_root_mount(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    session = _FakeSession(
        Manifest(
            entries={
                ".": S3Mount(
                    bucket="bucket",
                    mount_strategy=InContainerMountStrategy(pattern=MountpointMountPattern()),
                ),
            }
        )
    )
    ls_calls: list[Path] = []
    rm_calls: list[tuple[Path, bool]] = []

    async def _fake_ls(path: Path | str) -> list[object]:
        ls_calls.append(Path(path))
        return []

    async def _fake_rm(path: Path | str, *, recursive: bool = False) -> None:
        rm_calls.append((Path(path), recursive))

    monkeypatch.setattr(session, "ls", _fake_ls)
    monkeypatch.setattr(session, "rm", _fake_rm)

    await session._clear_workspace_root_on_resume()  # noqa: SLF001

    assert ls_calls == []
    assert rm_calls == []


@pytest.mark.asyncio
async def test_prepare_agent_rechecks_session_liveness_before_reusing_cached_agent() -> None:
    session = _FakeSession(Manifest())
    client = _FakeClient(session)
    agent = SandboxAgent(
        name="sandbox",
        model=FakeModel(initial_output=[get_final_output_message("done")]),
        instructions="Base instructions.",
    )
    runtime = SandboxRuntime(
        starting_agent=agent,
        run_config=_sandbox_run_config(client),
        run_state=None,
    )
    context_wrapper = RunContextWrapper(context=None)

    first_prepared = await runtime.prepare_agent(
        current_agent=agent,
        current_input="hello",
        context_wrapper=context_wrapper,
        is_resumed_state=False,
    )
    assert session.start_calls == 1

    session._running = False

    second_prepared = await runtime.prepare_agent(
        current_agent=agent,
        current_input="hello again",
        context_wrapper=context_wrapper,
        is_resumed_state=False,
    )

    assert second_prepared.bindings.execution_agent is first_prepared.bindings.execution_agent
    assert session.start_calls == 2


@pytest.mark.asyncio
async def test_prepare_agent_binds_run_as_to_cloned_capabilities() -> None:
    session = _FakeSession(Manifest())
    client = _FakeClient(session)
    capability = _RecordingCapability()
    agent = SandboxAgent(
        name="sandbox",
        model=FakeModel(initial_output=[get_final_output_message("done")]),
        capabilities=[capability],
        run_as="sandbox-user",
    )
    runtime = SandboxRuntime(
        starting_agent=agent,
        run_config=_sandbox_run_config(client),
        run_state=None,
    )

    prepared = await runtime.prepare_agent(
        current_agent=agent,
        current_input="hello",
        context_wrapper=RunContextWrapper(context=None),
        is_resumed_state=False,
    )

    execution_agent = cast(SandboxAgent[Any], prepared.bindings.execution_agent)
    prepared_capability = cast(_RecordingCapability, execution_agent.capabilities[0])
    assert capability.bound_session is None
    assert prepared_capability.bound_session is client.session
    assert prepared_capability.run_as == User(name="sandbox-user")


@pytest.mark.asyncio
async def test_prepare_agent_processes_context_with_bound_cached_capabilities() -> None:
    session = _FakeSession(Manifest())
    client = _FakeClient(session)
    agent = SandboxAgent(
        name="sandbox",
        model=FakeModel(initial_output=[get_final_output_message("done")]),
        capabilities=[_ProcessContextSessionCapability()],
    )
    runtime = SandboxRuntime(
        starting_agent=agent,
        run_config=_sandbox_run_config(client),
        run_state=None,
    )
    context_wrapper = RunContextWrapper(context=None)

    first_prepared = await runtime.prepare_agent(
        current_agent=agent,
        current_input=[{"role": "user", "content": "hello"}],
        context_wrapper=context_wrapper,
        is_resumed_state=False,
    )

    assert first_prepared.input == [
        {"role": "user", "content": "hello"},
        {"role": "user", "content": "process_calls=1"},
    ]

    second_prepared = await runtime.prepare_agent(
        current_agent=agent,
        current_input=[{"role": "user", "content": "hello again"}],
        context_wrapper=context_wrapper,
        is_resumed_state=False,
    )

    assert second_prepared.bindings.execution_agent is first_prepared.bindings.execution_agent
    assert second_prepared.input == [
        {"role": "user", "content": "hello again"},
        {"role": "user", "content": "process_calls=2"},
    ]


@pytest.mark.asyncio
async def test_prepare_agent_starts_new_live_session_even_when_backend_reports_running() -> None:
    session = _FakeSession(Manifest())
    session._running = True
    client = _FakeClient(session)
    agent = SandboxAgent(
        name="sandbox",
        model=FakeModel(initial_output=[get_final_output_message("done")]),
        instructions="Base instructions.",
    )
    runtime = SandboxRuntime(
        starting_agent=agent,
        run_config=_sandbox_run_config(client),
        run_state=None,
    )

    await runtime.prepare_agent(
        current_agent=agent,
        current_input="hello",
        context_wrapper=RunContextWrapper(context=None),
        is_resumed_state=False,
    )

    assert session.start_calls == 1


@pytest.mark.asyncio
async def test_sandbox_runtime_emits_high_level_sdk_spans() -> None:
    session = _FakeSession(Manifest())
    client = _FakeClient(session)
    agent = SandboxAgent(
        name="sandbox",
        model=FakeModel(initial_output=[get_final_output_message("done")]),
        instructions="Base instructions.",
    )
    runtime = SandboxRuntime(
        starting_agent=agent,
        run_config=_sandbox_run_config(client),
        run_state=None,
    )

    with trace("sandbox_runtime_test"):
        await runtime.prepare_agent(
            current_agent=agent,
            current_input="hello",
            context_wrapper=RunContextWrapper(context=None),
            is_resumed_state=False,
        )
        await runtime.cleanup()

    def _custom_span_names(node: dict[str, object]) -> list[str]:
        names: list[str] = []
        children = node.get("children", [])
        if not isinstance(children, list):
            return names
        for child in children:
            assert isinstance(child, dict)
            if child.get("type") == "custom":
                data = child.get("data", {})
                if isinstance(data, dict):
                    name = data.get("name")
                    if isinstance(name, str):
                        names.append(name)
            names.extend(_custom_span_names(child))
        return names

    normalized = fetch_normalized_spans()
    assert len(normalized) == 1
    names = _custom_span_names(normalized[0])
    assert {
        "sandbox.prepare_agent",
        "sandbox.create_session",
        "sandbox.start",
        "sandbox.cleanup",
        "sandbox.cleanup_sessions",
        "sandbox.stop",
        "sandbox.shutdown",
    }.issubset(set(names))


@pytest.mark.asyncio
async def test_runner_uses_public_agent_for_non_function_tool_outputs() -> None:
    tool = LocalShellTool(executor=lambda _request: "shell result")
    action = LocalShellCallAction(
        command=["bash", "-lc", "echo sandbox"],
        env={},
        type="exec",
        timeout_ms=1000,
        working_directory="/workspace",
    )
    local_shell_call = LocalShellCall(
        id="lsh_sandbox",
        action=action,
        call_id="call_local_shell",
        status="completed",
        type="local_shell_call",
    )

    model = FakeModel()
    model.add_multiple_turn_outputs(
        [
            [local_shell_call],
            [get_final_output_message("done")],
        ]
    )

    agent = SandboxAgent(
        name="sandbox",
        model=model,
        instructions="Base instructions.",
        tools=[tool],
    )

    result = await Runner.run(
        agent,
        "hello",
        run_config=_sandbox_run_config(_FakeClient(_FakeSession(Manifest()))),
    )

    output_items = [
        item
        for item in result.new_items
        if isinstance(item, ToolCallOutputItem)
        and isinstance(item.raw_item, dict)
        and item.raw_item.get("type") == "local_shell_call_output"
    ]

    assert output_items
    assert all(item.agent is agent for item in output_items)


@pytest.mark.asyncio
async def test_sandbox_agent_as_tool_uses_runner_sandbox_prep() -> None:
    child_model = FakeModel(initial_output=[get_final_output_message("child done")])
    parent_model = FakeModel(
        initial_output=[
            get_function_tool_call("delegate_to_child", json.dumps({"input": "check sandbox"}))
        ]
    )
    parent_model.set_next_output([get_final_output_message("parent done")])

    capability = _RecordingCapability(instruction_text="Use the sandbox carefully.")
    manifest = Manifest(entries={"README.md": File(content=b"Use repo-safe commands only.")})
    session = _FakeSession(manifest)
    client = _FakeClient(session)

    child = SandboxAgent(
        name="child",
        model=child_model,
        instructions="Child base instructions.",
        default_manifest=manifest,
        capabilities=[capability],
    )
    parent = Agent(
        name="parent",
        model=parent_model,
        instructions="Parent instructions.",
        tools=[child.as_tool("delegate_to_child", "Delegate to the sandbox child.")],
    )

    result = await Runner.run(
        parent,
        "hello",
        run_config=_sandbox_run_config(client),
    )

    assert result.final_output == "parent done"
    assert capability.bound_session is None
    assert child_model.first_turn_args is not None
    child_input = child_model.first_turn_args["input"]
    assert isinstance(child_input, list)
    assert _extract_user_text(child_input[0]) == "check sandbox"


@pytest.mark.asyncio
async def test_runner_reapplies_sandbox_prep_on_handoff() -> None:
    triage_model = FakeModel()
    worker_model = FakeModel(initial_output=[get_final_output_message("done")])
    manifest = Manifest(entries={"README.md": File(content=b"Shared repo instructions.")})
    session = _FakeSession(manifest)
    client = _FakeClient(session)

    capability_one = _RecordingCapability(instruction_text="Triage capability.")
    capability_two = _RecordingCapability(instruction_text="Worker capability.")
    worker = SandboxAgent(
        name="worker",
        model=worker_model,
        instructions="Worker instructions.",
        default_manifest=manifest,
        capabilities=[capability_two],
    )
    triage = SandboxAgent(
        name="triage",
        model=triage_model,
        instructions="Triage instructions.",
        default_manifest=manifest,
        capabilities=[capability_one],
        handoffs=[worker],
    )
    triage_model.turn_outputs = [[get_handoff_tool_call(worker)]]

    result = await Runner.run(
        triage,
        "route this",
        run_config=_sandbox_run_config(client),
    )

    assert result.final_output == "done"
    assert capability_one.bound_session is None
    assert capability_two.bound_session is None
    assert worker_model.first_turn_args is not None
    assert worker_model.first_turn_args["system_instructions"] == (
        f"{get_default_sandbox_instructions()}\n\n"
        "# Agent instructions\n\n"
        "Worker instructions.\n\n"
        "# Sandbox capability instructions\n\n"
        "Worker capability.\n\n"
        f"{runtime_agent_preparation_module._filesystem_instructions(session.state.manifest)}"
    )


@pytest.mark.asyncio
async def test_prepare_agent_uses_active_sandbox_agent_memory_capability_for_handoffs() -> None:
    session = _FakeSession(Manifest())
    client = _FakeClient(session)
    triage = SandboxAgent(
        name="triage",
        model=FakeModel(),
        capabilities=[Memory(), Filesystem(), Shell()],
    )
    reviewer = SandboxAgent(
        name="reviewer",
        model=FakeModel(),
        capabilities=[Memory(generate=None), Filesystem(), Shell()],
    )
    runtime = SandboxRuntime(
        starting_agent=triage,
        run_config=_sandbox_run_config(client),
        run_state=None,
    )
    context_wrapper = RunContextWrapper(context=None)

    await runtime.prepare_agent(
        current_agent=triage,
        current_input="hello",
        context_wrapper=context_wrapper,
        is_resumed_state=False,
    )
    assert runtime._memory_generation_manager() is not None  # noqa: SLF001

    await runtime.prepare_agent(
        current_agent=reviewer,
        current_input="review this",
        context_wrapper=context_wrapper,
        is_resumed_state=False,
    )
    assert runtime._memory_generation_manager() is None  # noqa: SLF001


@pytest.mark.asyncio
async def test_prepare_agent_enables_memory_when_handoff_target_adds_capability() -> None:
    session = _FakeSession(Manifest())
    client = _FakeClient(session)
    triage = SandboxAgent(
        name="triage",
        model=FakeModel(),
    )
    worker = SandboxAgent(
        name="worker",
        model=FakeModel(),
        capabilities=[Memory(), Filesystem(), Shell()],
    )
    runtime = SandboxRuntime(
        starting_agent=triage,
        run_config=_sandbox_run_config(client),
        run_state=None,
    )
    context_wrapper = RunContextWrapper(context=None)

    await runtime.prepare_agent(
        current_agent=triage,
        current_input="hello",
        context_wrapper=context_wrapper,
        is_resumed_state=False,
    )
    assert runtime._memory_generation_manager() is None  # noqa: SLF001

    await runtime.prepare_agent(
        current_agent=worker,
        current_input="do the work",
        context_wrapper=context_wrapper,
        is_resumed_state=False,
    )
    assert runtime._memory_generation_manager() is not None  # noqa: SLF001


@pytest.mark.asyncio
async def test_runner_restores_sandbox_from_run_state() -> None:
    model = FakeModel()

    @function_tool(name_override="approval_tool", needs_approval=True)
    def approval_tool() -> str:
        return "approved"

    manifest = Manifest(entries={"README.md": File(content=b"Resume with sandbox state.")})
    session = _FakeSession(manifest)
    client = _FakeClient(session)
    agent = SandboxAgent(
        name="sandbox",
        model=model,
        instructions="Base instructions.",
        tools=[approval_tool],
        default_manifest=manifest,
    )
    model.add_multiple_turn_outputs(
        [
            [get_function_tool_call("approval_tool", json.dumps({}), call_id="call_resume")],
            [get_final_output_message("done")],
        ]
    )

    first_run = await Runner.run(
        agent,
        "hello",
        run_config=_sandbox_run_config(client),
    )

    assert len(first_run.interruptions) == 1
    state = first_run.to_state()
    assert state._sandbox is not None
    state.approve(first_run.interruptions[0])

    resumed = await Runner.run(
        agent,
        state,
        run_config=_sandbox_run_config(client),
    )

    assert resumed.final_output == "done"
    assert client.resume_state is not None


@pytest.mark.asyncio
async def test_runner_rejects_concurrent_reuse_of_same_sandbox_agent() -> None:
    model = FakeModel(initial_output=[get_final_output_message("done")])
    start_gate = asyncio.Event()
    session = _FakeSession(Manifest(), start_gate=start_gate)
    client = _FakeClient(session)
    agent = SandboxAgent(
        name="sandbox",
        model=model,
        instructions="Base instructions.",
    )
    run_config = _sandbox_run_config(client)

    first_run = asyncio.create_task(Runner.run(agent, "hello", run_config=run_config))
    while session.start_calls == 0:
        await asyncio.sleep(0)

    with pytest.raises(RuntimeError, match="cannot be reused concurrently"):
        await Runner.run(agent, "again", run_config=run_config)

    start_gate.set()
    result = await first_run
    assert result.final_output == "done"


@pytest.mark.asyncio
async def test_runner_isolates_shared_capabilities_per_run() -> None:
    release_gate = asyncio.Event()
    first_instruction_started = asyncio.Event()
    second_instruction_started = asyncio.Event()
    shared_capability = _AwaitableSessionCapability(
        release_gate=release_gate,
        first_instruction_started=first_instruction_started,
        second_instruction_started=second_instruction_started,
    )

    session_one = _FakeSession(
        Manifest(entries={"README.md": File(content=b"Session one instructions.")})
    )
    session_two = _FakeSession(
        Manifest(entries={"README.md": File(content=b"Session two instructions.")})
    )
    client_one = _FakeClient(session_one)
    client_two = _FakeClient(session_two)
    model_one = FakeModel(initial_output=[get_final_output_message("done one")])
    model_two = FakeModel(initial_output=[get_final_output_message("done two")])
    agent_one = SandboxAgent(
        name="sandbox-one",
        model=model_one,
        instructions="Base instructions.",
        capabilities=[shared_capability],
    )
    agent_two = SandboxAgent(
        name="sandbox-two",
        model=model_two,
        instructions="Base instructions.",
        capabilities=[shared_capability],
    )

    first_run = asyncio.create_task(
        Runner.run(agent_one, "hello one", run_config=_sandbox_run_config(client_one))
    )
    await first_instruction_started.wait()

    second_run = asyncio.create_task(
        Runner.run(agent_two, "hello two", run_config=_sandbox_run_config(client_two))
    )
    await second_instruction_started.wait()

    release_gate.set()
    first_result, second_result = await asyncio.gather(first_run, second_run)

    assert first_result.final_output == "done one"
    assert second_result.final_output == "done two"
    assert model_one.first_turn_args is not None
    assert model_two.first_turn_args is not None
    assert model_one.first_turn_args["system_instructions"] == (
        f"{get_default_sandbox_instructions()}\n\n"
        "# Agent instructions\n\n"
        "Base instructions.\n\n"
        "# Sandbox capability instructions\n\n"
        "Session one instructions.\n\n"
        f"{runtime_agent_preparation_module._filesystem_instructions(session_one.state.manifest)}"
    )
    assert model_two.first_turn_args["system_instructions"] == (
        f"{get_default_sandbox_instructions()}\n\n"
        "# Agent instructions\n\n"
        "Base instructions.\n\n"
        "# Sandbox capability instructions\n\n"
        "Session two instructions.\n\n"
        f"{runtime_agent_preparation_module._filesystem_instructions(session_two.state.manifest)}"
    )
    assert shared_capability.bound_session is None


@pytest.mark.asyncio
async def test_runner_deep_clones_capability_runtime_state() -> None:
    model = FakeModel(initial_output=[get_final_output_message("done")])
    session = _FakeSession(Manifest(entries={"README.md": File(content=b"hello")}))
    client = _FakeClient(session)

    class _MutableCapability(Capability):
        bound_labels: list[str]

        def __init__(self) -> None:
            super().__init__(type="mutable", **cast(Any, {"bound_labels": []}))

        def bind(self, session: BaseSandboxSession) -> None:
            readme = session.state.manifest.entries["README.md"]
            assert isinstance(readme, File)
            self.bound_labels.append(readme.content.decode())

    capability = _MutableCapability()
    agent = SandboxAgent(
        name="sandbox",
        model=model,
        instructions="Base instructions.",
        capabilities=[capability],
    )

    result = await Runner.run(agent, "hello", run_config=_sandbox_run_config(client))

    assert result.final_output == "done"
    assert capability.bound_labels == []


@pytest.mark.asyncio
async def test_runner_keeps_public_agent_identity_for_hooks_and_streaming() -> None:
    model = FakeModel(initial_output=[get_final_output_message("done")])
    session = _FakeSession(Manifest())
    client = _FakeClient(session)
    run_hooks = _RecordingRunHooks()
    agent_hooks = _RecordingAgentHooks()
    agent = SandboxAgent(
        name="sandbox",
        model=model,
        instructions="Base instructions.",
        hooks=agent_hooks,
        capabilities=[_RecordingCapability(instruction_text="Capability instructions.")],
    )

    result = await Runner.run(
        agent,
        "hello",
        run_config=_sandbox_run_config(client),
        hooks=run_hooks,
    )

    assert result.last_agent is agent
    assert run_hooks.started_agents == [agent]
    assert run_hooks.ended_agents == [agent]
    assert run_hooks.llm_started_agents == [agent]
    assert run_hooks.llm_ended_agents == [agent]
    assert agent_hooks.started_agents == [agent]
    assert agent_hooks.ended_agents == [agent]
    assert agent_hooks.llm_started_agents == [agent]
    assert agent_hooks.llm_ended_agents == [agent]
    assert all(item.agent is agent for item in result.new_items)

    streamed_model = FakeModel(initial_output=[get_final_output_message("streamed done")])
    streamed_session = _FakeSession(Manifest())
    streamed_client = _FakeClient(streamed_session)
    streamed_run_hooks = _RecordingRunHooks()
    streamed_agent_hooks = _RecordingAgentHooks()
    streamed_agent = SandboxAgent(
        name="streamed-sandbox",
        model=streamed_model,
        instructions="Base instructions.",
        hooks=streamed_agent_hooks,
        capabilities=[_RecordingCapability(instruction_text="Capability instructions.")],
    )

    streamed_result = Runner.run_streamed(
        streamed_agent,
        "hello",
        run_config=_sandbox_run_config(streamed_client),
        hooks=streamed_run_hooks,
    )
    streamed_events = [event async for event in streamed_result.stream_events()]
    run_item_events = [event for event in streamed_events if isinstance(event, RunItemStreamEvent)]

    assert streamed_result.current_agent is streamed_agent
    assert streamed_run_hooks.started_agents == [streamed_agent]
    assert streamed_run_hooks.ended_agents == [streamed_agent]
    assert streamed_run_hooks.llm_started_agents == [streamed_agent]
    assert streamed_run_hooks.llm_ended_agents == [streamed_agent]
    assert streamed_agent_hooks.started_agents == [streamed_agent]
    assert streamed_agent_hooks.ended_agents == [streamed_agent]
    assert streamed_agent_hooks.llm_started_agents == [streamed_agent]
    assert streamed_agent_hooks.llm_ended_agents == [streamed_agent]
    assert all(item.agent is streamed_agent for item in streamed_result.new_items)
    assert run_item_events
    assert all(event.item.agent is streamed_agent for event in run_item_events)
