diff --git a/agent/dashboard/routes.py b/agent/dashboard/routes.py index bfb7fff1..3681db32 100644 --- a/agent/dashboard/routes.py +++ b/agent/dashboard/routes.py @@ -91,6 +91,8 @@ from .user_mappings import ( logger = logging.getLogger(__name__) router = APIRouter(prefix="/dashboard/api", tags=["dashboard"]) +_GITHUB_API_TIMEOUT = httpx.Timeout(10.0, connect=3.0) +_SKIPPABLE_INSTALLATION_REPO_STATUS_CODES = frozenset({403, 404}) def _require_admin(session: dict[str, Any]) -> dict[str, Any]: @@ -446,6 +448,16 @@ def _next_link_url(link_header: str | None) -> str | None: return None +def _github_api_http_exception(status_code: int) -> HTTPException: + if status_code == 401: + return HTTPException(401, "github token expired, re-login required") + if status_code == 403: + return HTTPException(403, "github API forbidden") + if status_code == 404: + return HTTPException(404, "github API resource not found") + return HTTPException(502, f"github API error ({status_code})") + + async def _paginate( client: httpx.AsyncClient, url: str, @@ -466,10 +478,23 @@ async def _paginate( first = True while next_url and len(out) < cap: params = {"per_page": "100"} if first else None - r = await client.get(next_url, headers=headers, params=params) - if r.status_code == 401: - raise HTTPException(401, "github token expired, re-login required") - r.raise_for_status() + try: + r = await client.get(next_url, headers=headers, params=params) + except httpx.TimeoutException as exc: + logger.warning("GitHub API timed out while paginating %s", next_url) + raise HTTPException(503, "github API request timed out") from exc + except httpx.RequestError as exc: + logger.warning("GitHub API request failed while paginating %s: %s", next_url, exc) + raise HTTPException(502, "github API request failed") from exc + try: + r.raise_for_status() + except httpx.HTTPStatusError as exc: + logger.warning( + "GitHub API returned %s while paginating %s", + r.status_code, + next_url, + ) + raise _github_api_http_exception(r.status_code) from exc body = r.json() page = body.get(items_key, []) if items_key else body if isinstance(page, list): @@ -498,7 +523,7 @@ async def list_repos( "Accept": "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28", } - async with httpx.AsyncClient() as client: + async with httpx.AsyncClient(timeout=_GITHUB_API_TIMEOUT) as client: try: installations = await _paginate( client, @@ -531,10 +556,13 @@ async def list_repos( headers=headers, items_key="repositories", ) - except HTTPException: + except HTTPException as exc: + if exc.status_code in _SKIPPABLE_INSTALLATION_REPO_STATUS_CODES: + logger.warning( + "Skipping installation %s repository list: %s", inst_id, exc.detail + ) + continue raise - except httpx.HTTPStatusError: - continue repositories.extend(repos) return { "installations": [ diff --git a/agent/dashboard/team_settings.py b/agent/dashboard/team_settings.py index 3dc81c3b..15df3e59 100644 --- a/agent/dashboard/team_settings.py +++ b/agent/dashboard/team_settings.py @@ -169,6 +169,32 @@ async def get_team_default_model( return _resolve_default_pair(model, effort) +async def get_team_default_model_pair( + role: Literal["agent", "reviewer"], +) -> tuple[tuple[str, str], tuple[str, str]]: + """Return default ``(main, subagent)`` model pairs for ``role`` from one store read.""" + settings = await get_team_settings() + if role == "agent": + main = _resolve_default_pair( + settings.get("default_agent_model"), + settings.get("default_agent_reasoning_effort"), + ) + subagent = _resolve_default_pair( + settings.get("default_agent_subagent_model"), + settings.get("default_agent_subagent_reasoning_effort"), + ) + else: + main = _resolve_default_pair( + settings.get("default_reviewer_model"), + settings.get("default_reviewer_reasoning_effort"), + ) + subagent = _resolve_default_pair( + settings.get("default_reviewer_subagent_model"), + settings.get("default_reviewer_subagent_reasoning_effort"), + ) + return main, subagent + + async def get_team_review_trace_links_enabled() -> bool: """Return whether GitHub review bodies should include a LangSmith trace link.""" settings = await get_team_settings() diff --git a/agent/reviewer.py b/agent/reviewer.py index 46f0a80e..e834373c 100644 --- a/agent/reviewer.py +++ b/agent/reviewer.py @@ -15,6 +15,7 @@ agent for code review only: """ # ruff: noqa: E402 +import asyncio import logging import re import warnings @@ -30,6 +31,7 @@ warnings.filterwarnings("ignore", message=".*Pydantic V1.*", category=UserWarnin from deepagents import create_deep_agent from langchain.agents.middleware import ModelCallLimitMiddleware +from .dashboard.team_settings import get_team_default_model_pair from .middleware import ( SanitizeThinkingBlocksMiddleware, SanitizeToolInputsMiddleware, @@ -640,8 +642,8 @@ async def get_reviewer_agent(config: RunnableConfig) -> Pregel: # Cache in-process so reviewer tools and the sandbox proxy can read it this run. cache_github_token_for_thread(thread_id, github_token, expires_at=expires_at) - repo_private = config["configurable"].get("repo_private") - github_proxy_token = github_token if repo_private is False else None + github_proxy_token = github_token + github_api_token = github_token sandbox_backend = await ensure_sandbox_for_thread( thread_id, github_proxy_token=github_proxy_token, @@ -659,74 +661,51 @@ async def get_reviewer_agent(config: RunnableConfig) -> Pregel: is_re_review = bool(config["configurable"].get("re_review")) reviewer_event = str(config["configurable"].get("reviewer_event", "") or "") - # Fetch the PR's unified diff from the GitHub API and populate - # diff_text + diff_line_set so add_finding can reject bad anchors at - # creation time (instead of letting them fail at publish_review with a - # 422 the agent then has to clean up). The API path is reliable — the - # previous sandbox-based prep was sometimes producing empty diffs, - # which is what forced the earlier hotfix. If the fetch fails, leave - # the validation disabled so the run isn't blocked entirely. - pr_diff_text = "" - pr_diff_line_set: dict[str, set[int]] | None = None - if ( + can_fetch_pr = ( pr_number is not None and isinstance(pr_number, int) - and repo_owner - and repo_name - and github_token - ): + and bool(repo_owner) + and bool(repo_name) + and bool(github_api_token) + ) + + async def _fetch_diff_context() -> tuple[str, dict[str, dict[str, set[int]]] | None]: + if not can_fetch_pr or github_api_token is None or not isinstance(pr_number, int): + return "", None fetched_diff = await fetch_pr_diff( owner=repo_owner, repo=repo_name, pr_number=pr_number, - token=github_token, + token=github_api_token, ) - if fetched_diff is not None: - pr_diff_text = fetched_diff - pr_diff_line_set = compute_diff_line_set(fetched_diff) - config["configurable"]["diff_text"] = pr_diff_text - config["configurable"]["diff_line_set"] = pr_diff_line_set + if fetched_diff is None: + return "", None + return fetched_diff, compute_diff_line_set(fetched_diff) - # Fetch the PR title and body fresh every run (never cached) so an edited - # title/description is reflected on re-reviews. Injected into the review - # context so the agent knows the original intent of the PR. On failure we - # leave both blank and the overview block is simply omitted. - pr_title = "" - pr_body = "" - if ( - pr_number is not None - and isinstance(pr_number, int) - and repo_owner - and repo_name - and github_token - ): + async def _fetch_pr_overview() -> tuple[str, str]: + if not can_fetch_pr or github_api_token is None or not isinstance(pr_number, int): + return "", "" metadata = await fetch_pr_metadata( owner=repo_owner, repo=repo_name, pr_number=pr_number, - token=github_token, + token=github_api_token, ) - if metadata is not None: - pr_title, pr_body = metadata + return metadata if metadata is not None else ("", "") - existing_threads_block = "" - if ( - pr_number is not None - and isinstance(pr_number, int) - and repo_owner - and repo_name - and github_token - ): + async def _fetch_existing_threads_block() -> str: + if not can_fetch_pr or github_api_token is None or not isinstance(pr_number, int): + return "" try: threads = await fetch_pr_review_threads( owner=repo_owner, repo=repo_name, pr_number=pr_number, - token=github_token, + token=github_api_token, ) await reconcile_findings_with_review_threads(thread_id, threads) - existing_threads_block = _format_pr_review_threads(threads) - if existing_threads_block: + block = _format_pr_review_threads(threads) + if block: logger.info( "Loaded %d existing PR review thread(s) into reviewer context for %s/%s#%s", len(threads), @@ -734,6 +713,7 @@ async def get_reviewer_agent(config: RunnableConfig) -> Pregel: repo_name, pr_number, ) + return block except Exception: # noqa: BLE001 logger.exception( "Failed to load existing PR review threads for %s/%s#%s; " @@ -742,6 +722,51 @@ async def get_reviewer_agent(config: RunnableConfig) -> Pregel: repo_name, pr_number, ) + return "" + + async def _fetch_repo_style_prompt() -> str | None: + if not repo_owner or not repo_name: + return None + from .dashboard.review_styles import get_repo_custom_prompt + + return await get_repo_custom_prompt(repo_owner, repo_name) + + async def _fetch_agents_md_context() -> str | None: + if not repo_owner or not repo_name or not base_sha: + return None + content = await fetch_agents_md( + repo_owner, + repo_name, + base_sha, + token=github_api_token, + ) + if content: + logger.info( + "Loaded AGENTS.md (%d chars) from %s/%s@%s into reviewer prompt", + len(content), + repo_owner, + repo_name, + base_sha, + ) + return content + + ( + diff_context, + pr_overview, + existing_threads_block, + repo_style_prompt, + agents_md_content, + ) = await asyncio.gather( + _fetch_diff_context(), + _fetch_pr_overview(), + _fetch_existing_threads_block(), + _fetch_repo_style_prompt(), + _fetch_agents_md_context(), + ) + pr_diff_text, pr_diff_line_set = diff_context + pr_title, pr_body = pr_overview + config["configurable"]["diff_text"] = pr_diff_text + config["configurable"]["diff_line_set"] = pr_diff_line_set review_context = "" if pr_number is not None and isinstance(pr_number, int): @@ -787,8 +812,6 @@ async def get_reviewer_agent(config: RunnableConfig) -> Pregel: existing_threads_block=existing_threads_block, ) - from .dashboard.team_settings import get_team_default_model, get_team_default_subagent_model - configured_model_id = config["configurable"].get("reviewer_model_id") configured_effort = config["configurable"].get("reviewer_reasoning_effort") if isinstance(configured_model_id, str) and configured_model_id: @@ -797,13 +820,15 @@ async def get_reviewer_agent(config: RunnableConfig) -> Pregel: subagent_model_id = model_id subagent_effort = reasoning_effort else: - model_id, reasoning_effort = await get_team_default_model("reviewer") + ( + (model_id, reasoning_effort), + (subagent_model_id, subagent_effort), + ) = await get_team_default_model_pair("reviewer") logger.info( "Using team default reviewer model: model=%s effort=%s", model_id, reasoning_effort, ) - subagent_model_id, subagent_effort = await get_team_default_subagent_model("reviewer") logger.info( "Using team default reviewer subagent model: model=%s effort=%s", subagent_model_id, @@ -833,34 +858,8 @@ async def get_reviewer_agent(config: RunnableConfig) -> Pregel: config["configurable"].get("reviewer_eval") is True or config["configurable"].get("eval") is True ) - repo_style_prompt: str | None = None - if repo_owner and repo_name: - from .dashboard.review_styles import get_repo_custom_prompt - - repo_style_prompt = await get_repo_custom_prompt(repo_owner, repo_name) - - # Fetch AGENTS.md from base_sha (the target branch's state before this - # PR's changes), not head_sha. The contents are inlined into the system - # prompt, so reading from head would let a PR author smuggle reviewer - # instructions ("ignore all bugs", "publish no findings") into the - # review. base_sha is the trusted ref. - agents_md_content: str | None = None - if repo_owner and repo_name and base_sha: - agents_md_content = await fetch_agents_md( - repo_owner, - repo_name, - base_sha, - token=github_token, - ) - if agents_md_content: - logger.info( - "Loaded AGENTS.md (%d chars) from %s/%s@%s into reviewer prompt", - len(agents_md_content), - repo_owner, - repo_name, - base_sha, - ) - del github_token + github_api_token = None + github_token = None system_prompt = _reviewer_system_prompt( f"{work_dir}/{repo_name}" if repo_name else work_dir, diff --git a/agent/server.py b/agent/server.py index 15667949..7c715689 100644 --- a/agent/server.py +++ b/agent/server.py @@ -38,7 +38,7 @@ from .dashboard.agent_overrides import ( resolve_github_login, ) from .dashboard.options import DEFAULT_MODEL_ID, SUPPORTED_MODEL_IDS, model_supports_effort -from .dashboard.team_settings import get_team_default_model, get_team_default_subagent_model +from .dashboard.team_settings import get_team_default_model_pair from .integrations.langsmith import _configure_github_proxy from .middleware import ( ModelFallbackMiddleware, @@ -411,13 +411,21 @@ async def get_agent(config: RunnableConfig) -> Pregel: ).with_config(config) github_token, _expires_at = await resolve_github_token(config, thread_id) - triggering_user_identity = await asyncio.to_thread( - resolve_triggering_user_identity, config, github_token + profile_login = resolve_github_login(config) + triggering_user_identity_task = asyncio.create_task( + asyncio.to_thread(resolve_triggering_user_identity, config, github_token) ) + sandbox_task = asyncio.create_task(ensure_sandbox_for_thread(thread_id)) + team_defaults_task = asyncio.create_task(get_team_default_model_pair("agent")) + profile_task = asyncio.create_task(load_profile(profile_login)) if profile_login else None + triggering_user_identity, sandbox_backend, team_defaults = await asyncio.gather( + triggering_user_identity_task, + sandbox_task, + team_defaults_task, + ) + profile = await profile_task if profile_task is not None else None del github_token - sandbox_backend = await ensure_sandbox_for_thread(thread_id) - linear_issue = config["configurable"].get("linear_issue", {}) linear_project_id = linear_issue.get("linear_project_id", "") linear_issue_number = linear_issue.get("linear_issue_number", "") @@ -427,44 +435,39 @@ async def get_agent(config: RunnableConfig) -> Pregel: def backend_factory(_runtime: object, _thread_id: str = thread_id) -> SandboxBackendProtocol: return _get_cached_sandbox_backend(_thread_id) - model_id, profile_effort = await get_team_default_model("agent") + (model_id, profile_effort), (subagent_model_id, subagent_effort) = team_defaults logger.info("Using team default agent model: model=%s effort=%s", model_id, profile_effort) - subagent_model_id, subagent_effort = await get_team_default_subagent_model("agent") logger.info( "Using team default agent subagent model: model=%s effort=%s", subagent_model_id, subagent_effort, ) - profile: dict[str, Any] | None = None - profile_login = resolve_github_login(config) - if profile_login: - profile = await load_profile(profile_login) - if profile: - overridden_model, overridden_effort = normalize_profile_overrides(profile) - if overridden_model: - logger.info( - "Applying dashboard profile override for %s: model=%s effort=%s", - profile_login, - overridden_model, - overridden_effort, - ) - model_id = overridden_model - profile_effort = overridden_effort - subagent_model_id = overridden_model - subagent_effort = overridden_effort - overridden_subagent_model, overridden_subagent_effort = ( - normalize_profile_subagent_overrides(profile) + if profile_login and profile: + overridden_model, overridden_effort = normalize_profile_overrides(profile) + if overridden_model: + logger.info( + "Applying dashboard profile override for %s: model=%s effort=%s", + profile_login, + overridden_model, + overridden_effort, ) - if overridden_subagent_model: - logger.info( - "Applying dashboard profile subagent override for %s: model=%s effort=%s", - profile_login, - overridden_subagent_model, - overridden_subagent_effort, - ) - subagent_model_id = overridden_subagent_model - subagent_effort = overridden_subagent_effort + model_id = overridden_model + profile_effort = overridden_effort + subagent_model_id = overridden_model + subagent_effort = overridden_effort + overridden_subagent_model, overridden_subagent_effort = ( + normalize_profile_subagent_overrides(profile) + ) + if overridden_subagent_model: + logger.info( + "Applying dashboard profile subagent override for %s: model=%s effort=%s", + profile_login, + overridden_subagent_model, + overridden_subagent_effort, + ) + subagent_model_id = overridden_subagent_model + subagent_effort = overridden_subagent_effort configurable = (config or {}).get("configurable") or {} per_thread_model = configurable.get("agent_model_id") diff --git a/agent/utils/sandbox.py b/agent/utils/sandbox.py index a2d9d8d5..c703e029 100644 --- a/agent/utils/sandbox.py +++ b/agent/utils/sandbox.py @@ -1,22 +1,32 @@ import os +from collections.abc import Callable +from importlib import import_module from deepagents.backends.protocol import SandboxBackendProtocol -from agent.integrations.daytona import create_daytona_sandbox -from agent.integrations.langsmith import create_langsmith_sandbox -from agent.integrations.local import create_local_sandbox -from agent.integrations.modal import create_modal_sandbox -from agent.integrations.runloop import create_runloop_sandbox +SandboxFactory = Callable[[str | None], SandboxBackendProtocol] -SANDBOX_FACTORIES = { - "langsmith": create_langsmith_sandbox, - "daytona": create_daytona_sandbox, - "modal": create_modal_sandbox, - "runloop": create_runloop_sandbox, - "local": create_local_sandbox, +SANDBOX_FACTORIES: dict[str, tuple[str, str]] = { + "langsmith": ("agent.integrations.langsmith", "create_langsmith_sandbox"), + "daytona": ("agent.integrations.daytona", "create_daytona_sandbox"), + "modal": ("agent.integrations.modal", "create_modal_sandbox"), + "runloop": ("agent.integrations.runloop", "create_runloop_sandbox"), + "local": ("agent.integrations.local", "create_local_sandbox"), } +def _load_sandbox_factory(sandbox_type: str) -> SandboxFactory: + factory_path = SANDBOX_FACTORIES.get(sandbox_type) + if factory_path is None: + supported = ", ".join(sorted(SANDBOX_FACTORIES)) + raise ValueError(f"Invalid sandbox type: {sandbox_type}. Supported types: {supported}") + module_name, function_name = factory_path + factory = getattr(import_module(module_name), function_name) + if not callable(factory): + raise TypeError(f"Sandbox factory {module_name}.{function_name} is not callable") + return factory + + def create_sandbox(sandbox_id: str | None = None) -> SandboxBackendProtocol: """Create or reconnect to a sandbox using the configured provider. @@ -30,10 +40,7 @@ def create_sandbox(sandbox_id: str | None = None) -> SandboxBackendProtocol: A sandbox backend implementing SandboxBackendProtocol. """ sandbox_type = os.getenv("SANDBOX_TYPE", "langsmith") - factory = SANDBOX_FACTORIES.get(sandbox_type) - if not factory: - supported = ", ".join(sorted(SANDBOX_FACTORIES)) - raise ValueError(f"Invalid sandbox type: {sandbox_type}. Supported types: {supported}") + factory = _load_sandbox_factory(sandbox_type) return factory(sandbox_id) diff --git a/agent/webapp.py b/agent/webapp.py index e9928fc8..3bcf721a 100644 --- a/agent/webapp.py +++ b/agent/webapp.py @@ -82,7 +82,6 @@ from .utils.linear import post_linear_trace_comment from .utils.linear_team_repo_map import LINEAR_TEAM_TO_REPO from .utils.multimodal import dedupe_urls, extract_image_urls, fetch_image_block from .utils.repo import extract_repo_from_text -from .utils.sandbox import validate_sandbox_startup_config from .utils.slack import ( GitHubPrRef, fetch_slack_thread_messages, @@ -110,6 +109,8 @@ logger = logging.getLogger(__name__) @asynccontextmanager async def lifespan(_app: FastAPI) -> AsyncIterator[None]: + from .utils.sandbox import validate_sandbox_startup_config + validate_sandbox_startup_config() yield diff --git a/tests/test_agent_subagent_models.py b/tests/test_agent_subagent_models.py index f6f6d3c5..2b056354 100644 --- a/tests/test_agent_subagent_models.py +++ b/tests/test_agent_subagent_models.py @@ -48,14 +48,9 @@ async def test_agent_uses_profile_subagent_model_override() -> None: return_value="/workspace", ), patch( - "agent.server.get_team_default_model", + "agent.server.get_team_default_model_pair", new_callable=AsyncMock, - return_value=("openai:gpt-5.5", "medium"), - ), - patch( - "agent.server.get_team_default_subagent_model", - new_callable=AsyncMock, - return_value=("openai:gpt-5.5", "low"), + return_value=(("openai:gpt-5.5", "medium"), ("openai:gpt-5.5", "low")), ), patch( "agent.server.load_profile", @@ -126,14 +121,9 @@ async def test_agent_subagent_inherits_profile_model_override_without_explicit_p return_value="/workspace", ), patch( - "agent.server.get_team_default_model", + "agent.server.get_team_default_model_pair", new_callable=AsyncMock, - return_value=("openai:gpt-5.5", "medium"), - ), - patch( - "agent.server.get_team_default_subagent_model", - new_callable=AsyncMock, - return_value=("openai:gpt-5.5", "low"), + return_value=(("openai:gpt-5.5", "medium"), ("openai:gpt-5.5", "low")), ), patch( "agent.server.load_profile", diff --git a/tests/test_dashboard_repos.py b/tests/test_dashboard_repos.py new file mode 100644 index 00000000..cb8b437d --- /dev/null +++ b/tests/test_dashboard_repos.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +from unittest.mock import AsyncMock + +import httpx +import pytest +from fastapi import HTTPException + +from agent.dashboard import routes + + +@pytest.mark.asyncio +async def test_paginate_converts_github_timeout_to_503() -> None: + request = httpx.Request("GET", "https://api.github.com/user/installations") + + async def handler(_request: httpx.Request) -> httpx.Response: + raise httpx.ConnectTimeout("connect timed out", request=request) + + transport = httpx.MockTransport(handler) + async with httpx.AsyncClient(transport=transport) as client: + with pytest.raises(HTTPException) as exc: + await routes._paginate( + client, + "https://api.github.com/user/installations", + headers={}, + items_key="installations", + ) + + assert exc.value.status_code == 503 + assert exc.value.detail == "github API request timed out" + + +@pytest.mark.asyncio +async def test_paginate_converts_github_status_error_to_502() -> None: + async def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(500, request=request, json={"message": "server error"}) + + transport = httpx.MockTransport(handler) + async with httpx.AsyncClient(transport=transport) as client: + with pytest.raises(HTTPException) as exc: + await routes._paginate( + client, + "https://api.github.com/user/installations", + headers={}, + items_key="installations", + ) + + assert exc.value.status_code == 502 + assert exc.value.detail == "github API error (500)" + + +@pytest.mark.asyncio +async def test_list_repos_propagates_repository_page_timeouts(monkeypatch) -> None: + monkeypatch.setattr(routes, "get_valid_access_token", AsyncMock(return_value="token")) + calls = 0 + + async def fake_paginate(*args: object, **kwargs: object) -> list[dict[str, object]]: + nonlocal calls + calls += 1 + if calls == 1: + return [{"id": 123, "account": {"login": "acme", "type": "Organization"}}] + raise HTTPException(503, "github API request timed out") + + monkeypatch.setattr(routes, "_paginate", fake_paginate) + + with pytest.raises(HTTPException) as exc: + await routes.list_repos(session={"sub": "octocat"}) + + assert exc.value.status_code == 503 + assert exc.value.detail == "github API request timed out" + + +@pytest.mark.asyncio +async def test_list_repos_skips_inaccessible_installations(monkeypatch) -> None: + monkeypatch.setattr(routes, "get_valid_access_token", AsyncMock(return_value="token")) + calls = 0 + + async def fake_paginate(*args: object, **kwargs: object) -> list[dict[str, object]]: + nonlocal calls + calls += 1 + if calls == 1: + return [{"id": 123, "account": {"login": "acme", "type": "Organization"}}] + raise HTTPException(403, "github API forbidden") + + monkeypatch.setattr(routes, "_paginate", fake_paginate) + + result = await routes.list_repos(session={"sub": "octocat"}) + + assert result == { + "installations": [{"id": 123, "account": "acme", "account_type": "Organization"}], + "repositories": [], + } diff --git a/tests/test_proxy_auth.py b/tests/test_proxy_auth.py index b38e047c..5bbaf33c 100644 --- a/tests/test_proxy_auth.py +++ b/tests/test_proxy_auth.py @@ -11,6 +11,25 @@ import pytest from agent.integrations.langsmith import _configure_github_proxy +class TestSandboxFactoryLoading: + def test_create_sandbox_loads_only_selected_provider(self) -> None: + with ( + patch("agent.utils.sandbox.import_module") as mock_import_module, + patch.dict("os.environ", {"SANDBOX_TYPE": "local"}), + ): + module = MagicMock() + module.create_local_sandbox.return_value = MagicMock(id="local") + mock_import_module.return_value = module + + from agent.utils.sandbox import create_sandbox + + sandbox = create_sandbox("existing") + + assert sandbox.id == "local" + mock_import_module.assert_called_once_with("agent.integrations.local") + module.create_local_sandbox.assert_called_once_with("existing") + + class TestConfigureGithubProxy: """Tests for _configure_github_proxy payload shape and error handling.""" diff --git a/tests/test_reviewer.py b/tests/test_reviewer.py index b714e4a7..881ebc4c 100644 --- a/tests/test_reviewer.py +++ b/tests/test_reviewer.py @@ -105,6 +105,55 @@ async def test_reviewer_resolves_app_installation_token_at_run_start() -> None: assert reviewer.check_message_queue_before_model in middleware +@pytest.mark.asyncio +async def test_reviewer_reuses_app_token_for_sandbox_proxy() -> None: + config: RunnableConfig = { + "configurable": { + "__is_for_execution__": True, + "thread_id": "reviewer-thread-id", + "repo": {"owner": "acme", "name": "repo"}, + "source": "github", + "pr_number": 42, + "base_sha": "base", + }, + "metadata": {}, + } + + with ( + patch( + "agent.reviewer.get_github_app_installation_token_with_expiry", + new_callable=AsyncMock, + return_value=("app-token", "exp"), + ), + patch( + "agent.reviewer.ensure_sandbox_for_thread", + new_callable=AsyncMock, + return_value=MagicMock(), + ) as mock_sandbox, + patch( + "agent.reviewer.aresolve_sandbox_work_dir", + new_callable=AsyncMock, + return_value="/workspace", + ), + patch("agent.reviewer.fetch_pr_diff", new_callable=AsyncMock, return_value=None), + patch("agent.reviewer.fetch_pr_metadata", new_callable=AsyncMock, return_value=None), + patch("agent.reviewer.fetch_pr_review_threads", new_callable=AsyncMock, return_value=[]), + patch( + "agent.reviewer.reconcile_findings_with_review_threads", + new_callable=AsyncMock, + ), + patch("agent.reviewer.fetch_agents_md", new_callable=AsyncMock, return_value=None), + patch("agent.reviewer.make_model", return_value=MagicMock()), + patch("agent.reviewer.create_deep_agent", return_value=_DummyAgent()), + ): + await reviewer.get_reviewer_agent(config) + + mock_sandbox.assert_awaited_once_with( + "reviewer-thread-id", + github_proxy_token="app-token", + ) + + @pytest.mark.asyncio async def test_reviewer_raises_when_app_installation_token_unavailable() -> None: config: RunnableConfig = {