mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-10-04 10:12:10 +00:00
fix: make default repository configurable (#1429)
* fix: make default repository configurable Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com> * fix: preserve dashboard repo-less runs * fix: distinguish explicit repo-less dashboard runs --------- Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
This commit is contained in:
parent
c28b1641f8
commit
449cb5d1a8
14 changed files with 347 additions and 13 deletions
|
|
@ -8,6 +8,7 @@ configuration in one place. Per-repo style prompts live in
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from typing import Any, Literal
|
from typing import Any, Literal
|
||||||
|
|
||||||
|
|
@ -41,6 +42,7 @@ class TeamSettingsUpdate(BaseModel):
|
||||||
default_agent_reasoning_effort: str | None = None
|
default_agent_reasoning_effort: str | None = None
|
||||||
default_agent_subagent_model: str | None = None
|
default_agent_subagent_model: str | None = None
|
||||||
default_agent_subagent_reasoning_effort: str | None = None
|
default_agent_subagent_reasoning_effort: str | None = None
|
||||||
|
default_repo: str | None = None
|
||||||
default_reviewer_model: str | None = None
|
default_reviewer_model: str | None = None
|
||||||
default_reviewer_reasoning_effort: str | None = None
|
default_reviewer_reasoning_effort: str | None = None
|
||||||
default_reviewer_subagent_model: str | None = None
|
default_reviewer_subagent_model: str | None = None
|
||||||
|
|
@ -82,6 +84,21 @@ def _client():
|
||||||
return get_client()
|
return get_client()
|
||||||
|
|
||||||
|
|
||||||
|
def _env_default_repo() -> str | None:
|
||||||
|
owner = os.environ.get("DEFAULT_REPO_OWNER", "").strip()
|
||||||
|
name = os.environ.get("DEFAULT_REPO_NAME", "").strip()
|
||||||
|
return f"{owner}/{name}" if owner and name else None
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_repo(value: object) -> dict[str, str] | None:
|
||||||
|
if not isinstance(value, str):
|
||||||
|
return None
|
||||||
|
owner, sep, name = value.strip().partition("/")
|
||||||
|
if not sep or not owner.strip() or not name.strip():
|
||||||
|
return None
|
||||||
|
return {"owner": owner.strip(), "name": name.strip()}
|
||||||
|
|
||||||
|
|
||||||
def _default_settings() -> dict[str, Any]:
|
def _default_settings() -> dict[str, Any]:
|
||||||
fallback_model, fallback_effort = default_model_pair()
|
fallback_model, fallback_effort = default_model_pair()
|
||||||
return {
|
return {
|
||||||
|
|
@ -95,6 +112,7 @@ def _default_settings() -> dict[str, Any]:
|
||||||
"default_agent_reasoning_effort": fallback_effort,
|
"default_agent_reasoning_effort": fallback_effort,
|
||||||
"default_agent_subagent_model": fallback_model,
|
"default_agent_subagent_model": fallback_model,
|
||||||
"default_agent_subagent_reasoning_effort": fallback_effort,
|
"default_agent_subagent_reasoning_effort": fallback_effort,
|
||||||
|
"default_repo": _env_default_repo(),
|
||||||
"default_reviewer_model": fallback_model,
|
"default_reviewer_model": fallback_model,
|
||||||
"default_reviewer_reasoning_effort": fallback_effort,
|
"default_reviewer_reasoning_effort": fallback_effort,
|
||||||
"default_reviewer_subagent_model": fallback_model,
|
"default_reviewer_subagent_model": fallback_model,
|
||||||
|
|
@ -138,6 +156,7 @@ async def upsert_team_settings(update: TeamSettingsUpdate) -> dict[str, Any]:
|
||||||
"default_agent_reasoning_effort": update.default_agent_reasoning_effort,
|
"default_agent_reasoning_effort": update.default_agent_reasoning_effort,
|
||||||
"default_agent_subagent_model": update.default_agent_subagent_model,
|
"default_agent_subagent_model": update.default_agent_subagent_model,
|
||||||
"default_agent_subagent_reasoning_effort": update.default_agent_subagent_reasoning_effort,
|
"default_agent_subagent_reasoning_effort": update.default_agent_subagent_reasoning_effort,
|
||||||
|
"default_repo": update.default_repo,
|
||||||
"default_reviewer_model": update.default_reviewer_model,
|
"default_reviewer_model": update.default_reviewer_model,
|
||||||
"default_reviewer_reasoning_effort": update.default_reviewer_reasoning_effort,
|
"default_reviewer_reasoning_effort": update.default_reviewer_reasoning_effort,
|
||||||
"default_reviewer_subagent_model": update.default_reviewer_subagent_model,
|
"default_reviewer_subagent_model": update.default_reviewer_subagent_model,
|
||||||
|
|
@ -148,6 +167,11 @@ async def upsert_team_settings(update: TeamSettingsUpdate) -> dict[str, Any]:
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
async def get_team_default_repo() -> dict[str, str] | None:
|
||||||
|
settings = await get_team_settings()
|
||||||
|
return _parse_repo(settings.get("default_repo"))
|
||||||
|
|
||||||
|
|
||||||
async def get_team_default_model(
|
async def get_team_default_model(
|
||||||
role: Literal["agent", "reviewer"],
|
role: Literal["agent", "reviewer"],
|
||||||
) -> tuple[str, str]:
|
) -> tuple[str, str]:
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,7 @@ async def _resolve_run_email(login: str, profile: dict[str, Any]) -> str | None:
|
||||||
class ThreadCreateBody(BaseModel):
|
class ThreadCreateBody(BaseModel):
|
||||||
prompt: str = Field(min_length=1, max_length=20_000)
|
prompt: str = Field(min_length=1, max_length=20_000)
|
||||||
repo: str | None = None
|
repo: str | None = None
|
||||||
|
repo_explicitly_none: bool = False
|
||||||
model_id: str | None = None
|
model_id: str | None = None
|
||||||
effort: str | None = None
|
effort: str | None = None
|
||||||
|
|
||||||
|
|
@ -269,13 +270,7 @@ async def get_dashboard_thread(
|
||||||
|
|
||||||
|
|
||||||
def _resolve_repo_config(repo: str | None) -> dict[str, str]:
|
def _resolve_repo_config(repo: str | None) -> dict[str, str]:
|
||||||
"""Resolve the run's repo from the request, or ``{}`` when none is given.
|
"""Resolve the run's repo from the request, or ``{}`` when none is given."""
|
||||||
|
|
||||||
A repo is optional: the agent identifies and clones the target repo from the
|
|
||||||
task itself. The dashboard pre-fills the user's default repo on the client,
|
|
||||||
so the request value is authoritative here — an empty value means an
|
|
||||||
intentionally repo-less run, not "fall back to the saved default".
|
|
||||||
"""
|
|
||||||
return _parse_repo(repo) or {}
|
return _parse_repo(repo) or {}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -284,6 +279,7 @@ async def _start_agent_run(
|
||||||
*,
|
*,
|
||||||
login: str,
|
login: str,
|
||||||
repo_config: dict[str, str],
|
repo_config: dict[str, str],
|
||||||
|
repo_explicitly_none: bool = False,
|
||||||
prompt: str,
|
prompt: str,
|
||||||
title: str | None = None,
|
title: str | None = None,
|
||||||
model_id: str | None = None,
|
model_id: str | None = None,
|
||||||
|
|
@ -309,6 +305,8 @@ async def _start_agent_run(
|
||||||
if has_repo:
|
if has_repo:
|
||||||
metadata["repo_owner"] = repo_config["owner"]
|
metadata["repo_owner"] = repo_config["owner"]
|
||||||
metadata["repo_name"] = repo_config["name"]
|
metadata["repo_name"] = repo_config["name"]
|
||||||
|
elif repo_explicitly_none:
|
||||||
|
metadata["repo_explicitly_none"] = True
|
||||||
|
|
||||||
client = langgraph_client()
|
client = langgraph_client()
|
||||||
await client.threads.create(thread_id=thread_id, metadata=metadata, if_exists="do_nothing")
|
await client.threads.create(thread_id=thread_id, metadata=metadata, if_exists="do_nothing")
|
||||||
|
|
@ -323,6 +321,8 @@ async def _start_agent_run(
|
||||||
}
|
}
|
||||||
if has_repo:
|
if has_repo:
|
||||||
configurable["repo"] = repo_config
|
configurable["repo"] = repo_config
|
||||||
|
elif repo_explicitly_none:
|
||||||
|
configurable["repo_explicitly_none"] = True
|
||||||
if chosen_model and chosen_effort:
|
if chosen_model and chosen_effort:
|
||||||
configurable["agent_model_id"] = chosen_model
|
configurable["agent_model_id"] = chosen_model
|
||||||
configurable["agent_effort"] = chosen_effort
|
configurable["agent_effort"] = chosen_effort
|
||||||
|
|
@ -354,6 +354,7 @@ async def create_dashboard_thread(login: str, body: ThreadCreateBody) -> dict[st
|
||||||
thread_id,
|
thread_id,
|
||||||
login=login,
|
login=login,
|
||||||
repo_config=repo_config,
|
repo_config=repo_config,
|
||||||
|
repo_explicitly_none=body.repo_explicitly_none,
|
||||||
prompt=body.prompt.strip(),
|
prompt=body.prompt.strip(),
|
||||||
model_id=body.model_id,
|
model_id=body.model_id,
|
||||||
effort=body.effort,
|
effort=body.effort,
|
||||||
|
|
@ -404,6 +405,8 @@ async def send_dashboard_message(
|
||||||
}
|
}
|
||||||
if owner and name:
|
if owner and name:
|
||||||
configurable["repo"] = {"owner": owner, "name": name}
|
configurable["repo"] = {"owner": owner, "name": name}
|
||||||
|
elif metadata.get("repo_explicitly_none") is True:
|
||||||
|
configurable["repo_explicitly_none"] = True
|
||||||
if chosen_model and chosen_effort:
|
if chosen_model and chosen_effort:
|
||||||
configurable["agent_model_id"] = chosen_model
|
configurable["agent_model_id"] = chosen_model
|
||||||
configurable["agent_effort"] = chosen_effort
|
configurable["agent_effort"] = chosen_effort
|
||||||
|
|
|
||||||
|
|
@ -429,8 +429,15 @@ def construct_system_prompt(
|
||||||
linear_issue_number: str = "",
|
linear_issue_number: str = "",
|
||||||
triggering_user_identity: CollaboratorIdentity | None = None,
|
triggering_user_identity: CollaboratorIdentity | None = None,
|
||||||
create_prs: bool = False,
|
create_prs: bool = False,
|
||||||
|
default_repo: dict[str, str] | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
default_prompt_section = _load_default_prompt()
|
default_prompt_section = _load_default_prompt()
|
||||||
|
if default_repo and default_repo.get("owner") and default_repo.get("name"):
|
||||||
|
repo_line = (
|
||||||
|
"When a repository is not explicitly mentioned, use "
|
||||||
|
f"`{default_repo['owner']}/{default_repo['name']}`."
|
||||||
|
)
|
||||||
|
default_prompt_section += f"\n\n{repo_line}"
|
||||||
# Shell-escape: display names/emails are user-controlled (e.g. O'Connor) and
|
# Shell-escape: display names/emails are user-controlled (e.g. O'Connor) and
|
||||||
# are embedded in a `git config` command the agent copies verbatim.
|
# are embedded in a `git config` command the agent copies verbatim.
|
||||||
if triggering_user_identity is not None:
|
if triggering_user_identity is not None:
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,7 @@ from .dashboard.agent_overrides import (
|
||||||
)
|
)
|
||||||
from .dashboard.agent_usage import record_agent_thread_usage
|
from .dashboard.agent_usage import record_agent_thread_usage
|
||||||
from .dashboard.options import DEFAULT_MODEL_ID, SUPPORTED_MODEL_IDS, model_supports_effort
|
from .dashboard.options import DEFAULT_MODEL_ID, SUPPORTED_MODEL_IDS, model_supports_effort
|
||||||
from .dashboard.team_settings import get_team_default_model_pair
|
from .dashboard.team_settings import get_team_default_model_pair, get_team_default_repo
|
||||||
from .integrations.langsmith import _configure_github_proxy
|
from .integrations.langsmith import _configure_github_proxy
|
||||||
from .middleware import (
|
from .middleware import (
|
||||||
ModelFallbackMiddleware,
|
ModelFallbackMiddleware,
|
||||||
|
|
@ -100,6 +100,24 @@ from .utils.sandbox_state import (
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _resolve_prompt_default_repo(configurable: dict[str, Any]) -> dict[str, str] | None:
|
||||||
|
repo_config = configurable.get("repo")
|
||||||
|
if isinstance(repo_config, dict):
|
||||||
|
owner = repo_config.get("owner")
|
||||||
|
name = repo_config.get("name")
|
||||||
|
if isinstance(owner, str) and isinstance(name, str):
|
||||||
|
return {"owner": owner, "name": name}
|
||||||
|
|
||||||
|
if configurable.get("repo_explicitly_none") is True:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
return await get_team_default_repo()
|
||||||
|
except Exception:
|
||||||
|
logger.debug("Failed to load team default repo for prompt", exc_info=True)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
async def _start_langsmith_sandbox_if_needed(sandbox_backend: SandboxBackendProtocol) -> None:
|
async def _start_langsmith_sandbox_if_needed(sandbox_backend: SandboxBackendProtocol) -> None:
|
||||||
"""Start a LangSmith sandbox before operations that require it to be running."""
|
"""Start a LangSmith sandbox before operations that require it to be running."""
|
||||||
if os.getenv("SANDBOX_TYPE", "langsmith") != "langsmith":
|
if os.getenv("SANDBOX_TYPE", "langsmith") != "langsmith":
|
||||||
|
|
@ -541,6 +559,8 @@ async def get_agent(config: RunnableConfig) -> Pregel:
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.debug("Failed to record agent usage for thread %s", thread_id, exc_info=True)
|
logger.debug("Failed to record agent usage for thread %s", thread_id, exc_info=True)
|
||||||
|
|
||||||
|
prompt_default_repo = await _resolve_prompt_default_repo(configurable)
|
||||||
|
|
||||||
logger.info("Returning agent with sandbox for thread %s", thread_id)
|
logger.info("Returning agent with sandbox for thread %s", thread_id)
|
||||||
main_model = make_model(model_id, **model_kwargs)
|
main_model = make_model(model_id, **model_kwargs)
|
||||||
subagent_model = make_model(subagent_model_id, **subagent_model_kwargs)
|
subagent_model = make_model(subagent_model_id, **subagent_model_kwargs)
|
||||||
|
|
@ -552,6 +572,7 @@ async def get_agent(config: RunnableConfig) -> Pregel:
|
||||||
linear_issue_number=linear_issue_number,
|
linear_issue_number=linear_issue_number,
|
||||||
triggering_user_identity=triggering_user_identity,
|
triggering_user_identity=triggering_user_identity,
|
||||||
create_prs=always_create_prs,
|
create_prs=always_create_prs,
|
||||||
|
default_repo=prompt_default_repo,
|
||||||
),
|
),
|
||||||
tools=[
|
tools=[
|
||||||
http_request,
|
http_request,
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ from .dashboard.agent_overrides import (
|
||||||
from .dashboard.enabled_repos import is_review_repo_enabled
|
from .dashboard.enabled_repos import is_review_repo_enabled
|
||||||
from .dashboard.oauth import build_settings_url
|
from .dashboard.oauth import build_settings_url
|
||||||
from .dashboard.profiles import get_profile, get_valid_access_token, has_access_token_record
|
from .dashboard.profiles import get_profile, get_valid_access_token, has_access_token_record
|
||||||
from .dashboard.team_settings import get_team_settings
|
from .dashboard.team_settings import get_team_default_repo, get_team_settings
|
||||||
from .dashboard.user_mappings import (
|
from .dashboard.user_mappings import (
|
||||||
email_for_login,
|
email_for_login,
|
||||||
login_for_email,
|
login_for_email,
|
||||||
|
|
@ -137,7 +137,7 @@ SLACK_SIGNING_SECRET = os.environ.get("SLACK_SIGNING_SECRET", "")
|
||||||
SLACK_BOT_USER_ID = os.environ.get("SLACK_BOT_USER_ID", "")
|
SLACK_BOT_USER_ID = os.environ.get("SLACK_BOT_USER_ID", "")
|
||||||
SLACK_BOT_USERNAME = os.environ.get("SLACK_BOT_USERNAME", "")
|
SLACK_BOT_USERNAME = os.environ.get("SLACK_BOT_USERNAME", "")
|
||||||
DEFAULT_REPO_OWNER = os.environ.get("DEFAULT_REPO_OWNER", "langchain-ai")
|
DEFAULT_REPO_OWNER = os.environ.get("DEFAULT_REPO_OWNER", "langchain-ai")
|
||||||
DEFAULT_REPO_NAME = os.environ.get("DEFAULT_REPO_NAME", "langchainplus")
|
DEFAULT_REPO_NAME = os.environ.get("DEFAULT_REPO_NAME", "")
|
||||||
SLACK_REPO_OWNER = os.environ.get("SLACK_REPO_OWNER", "") or DEFAULT_REPO_OWNER
|
SLACK_REPO_OWNER = os.environ.get("SLACK_REPO_OWNER", "") or DEFAULT_REPO_OWNER
|
||||||
SLACK_REPO_NAME = os.environ.get("SLACK_REPO_NAME", "") or DEFAULT_REPO_NAME
|
SLACK_REPO_NAME = os.environ.get("SLACK_REPO_NAME", "") or DEFAULT_REPO_NAME
|
||||||
|
|
||||||
|
|
@ -183,7 +183,7 @@ def get_repo_config_from_team_mapping(
|
||||||
team_identifier: str, project_name: str = ""
|
team_identifier: str, project_name: str = ""
|
||||||
) -> dict[str, str]:
|
) -> dict[str, str]:
|
||||||
"""Look up repository configuration from LINEAR_TEAM_TO_REPO mapping."""
|
"""Look up repository configuration from LINEAR_TEAM_TO_REPO mapping."""
|
||||||
fallback = {"owner": DEFAULT_REPO_OWNER, "name": DEFAULT_REPO_NAME}
|
fallback = {"owner": DEFAULT_REPO_OWNER, "name": DEFAULT_REPO_NAME} if DEFAULT_REPO_NAME else {}
|
||||||
|
|
||||||
if not team_identifier or team_identifier not in LINEAR_TEAM_TO_REPO:
|
if not team_identifier or team_identifier not in LINEAR_TEAM_TO_REPO:
|
||||||
return fallback
|
return fallback
|
||||||
|
|
@ -608,8 +608,14 @@ async def get_slack_repo_config(
|
||||||
logger.exception("Failed to apply dashboard default_repo for Slack user")
|
logger.exception("Failed to apply dashboard default_repo for Slack user")
|
||||||
|
|
||||||
if not repo_config:
|
if not repo_config:
|
||||||
|
repo_config = await get_team_default_repo()
|
||||||
|
|
||||||
|
if not repo_config and default_owner and default_name:
|
||||||
repo_config = {"owner": default_owner, "name": default_name}
|
repo_config = {"owner": default_owner, "name": default_name}
|
||||||
|
|
||||||
|
if not repo_config:
|
||||||
|
raise HTTPException(400, "no default repository configured")
|
||||||
|
|
||||||
return repo_config
|
return repo_config
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1305,6 +1311,12 @@ async def linear_webhook( # noqa: PLR0911, PLR0912, PLR0915
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if not repo_config:
|
||||||
|
repo_config = await get_team_default_repo()
|
||||||
|
|
||||||
|
if not repo_config:
|
||||||
|
return {"status": "ignored", "reason": "No default repository configured"}
|
||||||
|
|
||||||
if not _is_repo_allowed(repo_config):
|
if not _is_repo_allowed(repo_config):
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Rejecting Linear webhook: repo '%s/%s' not in allowlist",
|
"Rejecting Linear webhook: repo '%s/%s' not in allowlist",
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
# Default Prompt
|
# Default Prompt
|
||||||
|
|
||||||
When a repository is not explicitly mentioned, use `langchain-ai/langchainplus`. Always assume the organization is `langchain-ai`.
|
When a repository is not explicitly mentioned, use the repository provided in the run metadata or dashboard settings. Do not assume a hardcoded repository name.
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,10 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
from agent.dashboard import thread_api
|
from agent.dashboard import thread_api
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -8,7 +13,6 @@ def test_resolve_repo_config_parses_request_repo() -> None:
|
||||||
|
|
||||||
|
|
||||||
def test_resolve_repo_config_returns_empty_when_no_repo_given() -> None:
|
def test_resolve_repo_config_returns_empty_when_no_repo_given() -> None:
|
||||||
# None / blank / malformed all mean an intentionally repo-less run — never an error.
|
|
||||||
assert thread_api._resolve_repo_config(None) == {}
|
assert thread_api._resolve_repo_config(None) == {}
|
||||||
assert thread_api._resolve_repo_config("") == {}
|
assert thread_api._resolve_repo_config("") == {}
|
||||||
assert thread_api._resolve_repo_config("not-a-repo") == {}
|
assert thread_api._resolve_repo_config("not-a-repo") == {}
|
||||||
|
|
@ -36,3 +40,116 @@ def test_thread_summary_keeps_repo_when_present() -> None:
|
||||||
)
|
)
|
||||||
assert summary["repo"] == "repo"
|
assert summary["repo"] == "repo"
|
||||||
assert summary["repoFullName"] == "octo/repo"
|
assert summary["repoFullName"] == "octo/repo"
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeThreadsClient:
|
||||||
|
async def create(
|
||||||
|
self, *, thread_id: str, metadata: dict[str, Any], if_exists: str
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return {"thread_id": thread_id, "metadata": metadata, "if_exists": if_exists}
|
||||||
|
|
||||||
|
async def update(self, *, thread_id: str, metadata: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
return {"thread_id": thread_id, "metadata": metadata}
|
||||||
|
|
||||||
|
async def get(self, thread_id: str) -> dict[str, Any]:
|
||||||
|
return {"thread_id": thread_id, "metadata": {}}
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeRunsClient:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.configurable: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
async def create(
|
||||||
|
self,
|
||||||
|
thread_id: str,
|
||||||
|
assistant_id: str,
|
||||||
|
*,
|
||||||
|
input: dict[str, Any],
|
||||||
|
config: dict[str, Any],
|
||||||
|
if_not_exists: str = "reject",
|
||||||
|
stream_mode: list[str] | None = None,
|
||||||
|
stream_resumable: bool = False,
|
||||||
|
) -> dict[str, str]:
|
||||||
|
self.configurable = config["configurable"]
|
||||||
|
return {"run_id": "run-id"}
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeLangGraphClient:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.threads = _FakeThreadsClient()
|
||||||
|
self.runs = _FakeRunsClient()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def dashboard_run_client(monkeypatch: pytest.MonkeyPatch) -> _FakeLangGraphClient:
|
||||||
|
client = _FakeLangGraphClient()
|
||||||
|
|
||||||
|
async def fake_get_profile(login: str) -> dict[str, Any]:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
async def fake_ensure_token(login: str) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def fake_resolve_email(login: str, profile: dict[str, Any]) -> str:
|
||||||
|
return "octo@example.com"
|
||||||
|
|
||||||
|
monkeypatch.setattr(thread_api, "langgraph_client", lambda: client)
|
||||||
|
monkeypatch.setattr(thread_api, "get_profile", fake_get_profile)
|
||||||
|
monkeypatch.setattr(thread_api, "_ensure_dashboard_github_token", fake_ensure_token)
|
||||||
|
monkeypatch.setattr(thread_api, "_resolve_run_email", fake_resolve_email)
|
||||||
|
return client
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_agent_run_omits_repo_less_marker_when_repo_unset(
|
||||||
|
dashboard_run_client: _FakeLangGraphClient,
|
||||||
|
) -> None:
|
||||||
|
asyncio.run(
|
||||||
|
thread_api._start_agent_run(
|
||||||
|
"thread-id",
|
||||||
|
login="octo",
|
||||||
|
repo_config={},
|
||||||
|
prompt="do work",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
configurable = dashboard_run_client.runs.configurable
|
||||||
|
assert configurable is not None
|
||||||
|
assert "repo_explicitly_none" not in configurable
|
||||||
|
assert "repo" not in configurable
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_agent_run_marks_repo_less_config_when_explicit(
|
||||||
|
dashboard_run_client: _FakeLangGraphClient,
|
||||||
|
) -> None:
|
||||||
|
asyncio.run(
|
||||||
|
thread_api._start_agent_run(
|
||||||
|
"thread-id",
|
||||||
|
login="octo",
|
||||||
|
repo_config={},
|
||||||
|
repo_explicitly_none=True,
|
||||||
|
prompt="do work",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
configurable = dashboard_run_client.runs.configurable
|
||||||
|
assert configurable is not None
|
||||||
|
assert configurable["repo_explicitly_none"] is True
|
||||||
|
assert "repo" not in configurable
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_agent_run_omits_repo_less_marker_when_repo_configured(
|
||||||
|
dashboard_run_client: _FakeLangGraphClient,
|
||||||
|
) -> None:
|
||||||
|
asyncio.run(
|
||||||
|
thread_api._start_agent_run(
|
||||||
|
"thread-id",
|
||||||
|
login="octo",
|
||||||
|
repo_config={"owner": "octo", "name": "repo"},
|
||||||
|
prompt="do work",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
configurable = dashboard_run_client.runs.configurable
|
||||||
|
assert configurable is not None
|
||||||
|
assert configurable["repo"] == {"owner": "octo", "name": "repo"}
|
||||||
|
assert "repo_explicitly_none" not in configurable
|
||||||
|
|
|
||||||
|
|
@ -120,3 +120,58 @@ async def test_dashboard_followup_on_busy_thread_queues_dashboard_handoff(
|
||||||
|
|
||||||
assert client.threads.updates[0]["source"] == "dashboard"
|
assert client.threads.updates[0]["source"] == "dashboard"
|
||||||
assert queued_messages == [{"text": "continue in web", "source": "dashboard"}]
|
assert queued_messages == [{"text": "continue in web", "source": "dashboard"}]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_dashboard_followup_preserves_explicit_repo_less_thread(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
metadata = {
|
||||||
|
"source": "dashboard",
|
||||||
|
"github_login": "octocat",
|
||||||
|
"repo_explicitly_none": True,
|
||||||
|
}
|
||||||
|
client = _FakeClient(metadata)
|
||||||
|
|
||||||
|
monkeypatch.setattr(thread_api, "langgraph_client", lambda: client)
|
||||||
|
monkeypatch.setattr(thread_api, "is_thread_active", _inactive_thread)
|
||||||
|
monkeypatch.setattr(thread_api, "_ensure_dashboard_github_token", _noop_token_check)
|
||||||
|
monkeypatch.setattr(thread_api, "get_profile", _empty_profile)
|
||||||
|
monkeypatch.setattr(thread_api, "_resolve_run_email", _run_email)
|
||||||
|
|
||||||
|
await thread_api.send_dashboard_message(
|
||||||
|
"thread-1",
|
||||||
|
"octocat",
|
||||||
|
thread_api.ThreadMessageBody(content="continue in web"),
|
||||||
|
)
|
||||||
|
|
||||||
|
run_config = client.runs.created[0]["kwargs"]["config"]["configurable"]
|
||||||
|
assert run_config["repo_explicitly_none"] is True
|
||||||
|
assert "repo" not in run_config
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_dashboard_followup_without_repo_metadata_allows_team_default(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
metadata = {
|
||||||
|
"source": "dashboard",
|
||||||
|
"github_login": "octocat",
|
||||||
|
}
|
||||||
|
client = _FakeClient(metadata)
|
||||||
|
|
||||||
|
monkeypatch.setattr(thread_api, "langgraph_client", lambda: client)
|
||||||
|
monkeypatch.setattr(thread_api, "is_thread_active", _inactive_thread)
|
||||||
|
monkeypatch.setattr(thread_api, "_ensure_dashboard_github_token", _noop_token_check)
|
||||||
|
monkeypatch.setattr(thread_api, "get_profile", _empty_profile)
|
||||||
|
monkeypatch.setattr(thread_api, "_resolve_run_email", _run_email)
|
||||||
|
|
||||||
|
await thread_api.send_dashboard_message(
|
||||||
|
"thread-1",
|
||||||
|
"octocat",
|
||||||
|
thread_api.ThreadMessageBody(content="continue in web"),
|
||||||
|
)
|
||||||
|
|
||||||
|
run_config = client.runs.created[0]["kwargs"]["config"]["configurable"]
|
||||||
|
assert "repo_explicitly_none" not in run_config
|
||||||
|
assert "repo" not in run_config
|
||||||
|
|
|
||||||
48
tests/test_prompt_default_repo.py
Normal file
48
tests/test_prompt_default_repo.py
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from agent import server
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_prompt_default_repo_uses_explicit_repo(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
async def fake_get_team_default_repo() -> dict[str, str] | None:
|
||||||
|
raise AssertionError("team default should not be loaded")
|
||||||
|
|
||||||
|
monkeypatch.setattr(server, "get_team_default_repo", fake_get_team_default_repo)
|
||||||
|
|
||||||
|
repo = asyncio.run(
|
||||||
|
server._resolve_prompt_default_repo({"repo": {"owner": "octo", "name": "repo"}})
|
||||||
|
)
|
||||||
|
|
||||||
|
assert repo == {"owner": "octo", "name": "repo"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_prompt_default_repo_skips_team_default_for_repo_less_run(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
async def fake_get_team_default_repo() -> dict[str, str] | None:
|
||||||
|
raise AssertionError("team default should not be loaded")
|
||||||
|
|
||||||
|
monkeypatch.setattr(server, "get_team_default_repo", fake_get_team_default_repo)
|
||||||
|
|
||||||
|
repo = asyncio.run(server._resolve_prompt_default_repo({"repo_explicitly_none": True}))
|
||||||
|
|
||||||
|
assert repo is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_prompt_default_repo_falls_back_to_team_default(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
async def fake_get_team_default_repo() -> dict[str, str] | None:
|
||||||
|
return {"owner": "team", "name": "repo"}
|
||||||
|
|
||||||
|
monkeypatch.setattr(server, "get_team_default_repo", fake_get_team_default_repo)
|
||||||
|
|
||||||
|
repo = asyncio.run(server._resolve_prompt_default_repo({}))
|
||||||
|
|
||||||
|
assert repo == {"owner": "team", "name": "repo"}
|
||||||
|
|
@ -395,6 +395,24 @@ def test_get_slack_repo_config_applies_profile_default_repo(
|
||||||
assert repo == {"owner": "profile-owner", "name": "profile-repo"}
|
assert repo == {"owner": "profile-owner", "name": "profile-repo"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_slack_repo_config_applies_team_default_repo(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
threads_client = _FakeThreadsClient(thread={"metadata": {}})
|
||||||
|
|
||||||
|
async def fake_get_team_default_repo() -> dict[str, str] | None:
|
||||||
|
return {"owner": "team-owner", "name": "team-repo"}
|
||||||
|
|
||||||
|
monkeypatch.setattr(webapp, "get_client", lambda url: _FakeClient(threads_client))
|
||||||
|
monkeypatch.setattr(webapp, "get_team_default_repo", fake_get_team_default_repo)
|
||||||
|
monkeypatch.setattr(webapp, "SLACK_REPO_NAME", "")
|
||||||
|
monkeypatch.setattr(webapp, "DEFAULT_REPO_NAME", "")
|
||||||
|
|
||||||
|
repo = asyncio.run(webapp.get_slack_repo_config("C123", "1.234"))
|
||||||
|
|
||||||
|
assert repo == {"owner": "team-owner", "name": "team-repo"}
|
||||||
|
|
||||||
|
|
||||||
def _setup_slack_mention_fakes(
|
def _setup_slack_mention_fakes(
|
||||||
monkeypatch: pytest.MonkeyPatch, captured: dict[str, object]
|
monkeypatch: pytest.MonkeyPatch, captured: dict[str, object]
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ export function AgentsHome() {
|
||||||
createThread.mutate({
|
createThread.mutate({
|
||||||
prompt,
|
prompt,
|
||||||
repo,
|
repo,
|
||||||
|
repo_explicitly_none: repoOverride === null,
|
||||||
model_id: activeSelection?.modelId ?? null,
|
model_id: activeSelection?.modelId ?? null,
|
||||||
effort: activeSelection?.effort ?? null,
|
effort: activeSelection?.effort ?? null,
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ export type { AgentSchedule, AgentThread, Message }
|
||||||
export interface ThreadCreateRequest {
|
export interface ThreadCreateRequest {
|
||||||
prompt: string
|
prompt: string
|
||||||
repo?: string | null
|
repo?: string | null
|
||||||
|
repo_explicitly_none?: boolean
|
||||||
model_id?: string | null
|
model_id?: string | null
|
||||||
effort?: string | null
|
effort?: string | null
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -113,6 +113,7 @@ export interface TeamSettings {
|
||||||
default_agent_reasoning_effort?: string | null;
|
default_agent_reasoning_effort?: string | null;
|
||||||
default_agent_subagent_model?: string | null;
|
default_agent_subagent_model?: string | null;
|
||||||
default_agent_subagent_reasoning_effort?: string | null;
|
default_agent_subagent_reasoning_effort?: string | null;
|
||||||
|
default_repo?: string | null;
|
||||||
default_reviewer_model?: string | null;
|
default_reviewer_model?: string | null;
|
||||||
default_reviewer_reasoning_effort?: string | null;
|
default_reviewer_reasoning_effort?: string | null;
|
||||||
default_reviewer_subagent_model?: string | null;
|
default_reviewer_subagent_model?: string | null;
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { useEffect, useState } from "react";
|
||||||
import type { ModelOption, TeamSettings, UserMapping } from "@/lib/api";
|
import type { ModelOption, TeamSettings, UserMapping } from "@/lib/api";
|
||||||
import { AppShell, SettingsRow, SettingsSection } from "@/components/AppShell";
|
import { AppShell, SettingsRow, SettingsSection } from "@/components/AppShell";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
|
|
@ -156,6 +157,11 @@ function GlobalDefaultsSection({ models }: { models: Array<ModelOption> }) {
|
||||||
queryFn: api.getTeamSettings,
|
queryFn: api.getTeamSettings,
|
||||||
});
|
});
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [defaultRepoDraft, setDefaultRepoDraft] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setDefaultRepoDraft(settings.data?.default_repo ?? "");
|
||||||
|
}, [settings.data?.default_repo]);
|
||||||
|
|
||||||
const save = useMutation({
|
const save = useMutation({
|
||||||
mutationFn: (body: TeamSettings) => api.saveTeamSettings(body),
|
mutationFn: (body: TeamSettings) => api.saveTeamSettings(body),
|
||||||
|
|
@ -204,6 +210,26 @@ function GlobalDefaultsSection({ models }: { models: Array<ModelOption> }) {
|
||||||
}
|
}
|
||||||
disabled={!settings.data || save.isPending}
|
disabled={!settings.data || save.isPending}
|
||||||
/>
|
/>
|
||||||
|
<SettingsRow
|
||||||
|
label="Default Repository"
|
||||||
|
description="Global fallback used when a run has no explicit repo and the user has no profile default. Use owner/repo."
|
||||||
|
control={
|
||||||
|
<Input
|
||||||
|
className="w-56"
|
||||||
|
placeholder="owner/repo"
|
||||||
|
value={defaultRepoDraft}
|
||||||
|
onChange={(e) => setDefaultRepoDraft(e.target.value)}
|
||||||
|
onBlur={() =>
|
||||||
|
settings.data &&
|
||||||
|
save.mutate({
|
||||||
|
...settings.data,
|
||||||
|
default_repo: defaultRepoDraft.trim() || null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
disabled={!settings.data || save.isPending}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<RolePicker
|
<RolePicker
|
||||||
label="Open SWE Reviewer"
|
label="Open SWE Reviewer"
|
||||||
description="Model used for PR review runs."
|
description="Model used for PR review runs."
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue