mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-10-07 16:19:09 +00:00
Compare commits
No commits in common. "26f8fe91a97eddceaaa1ffdeac6d4d84f767dbb7" and "298443ac8bee0f94c58d3242f3beea9d7c4c3c7d" have entirely different histories.
26f8fe91a9
...
298443ac8b
41 changed files with 205 additions and 1192 deletions
|
|
@ -88,7 +88,7 @@ There is intentionally no after-agent safety net that opens a PR for the agent.
|
|||
All tools live in `agent/tools/` and are flat-imported via `agent/tools/__init__.py`. The set is intentionally small and curated — see README "Tools — Curated, Not Accumulated".
|
||||
|
||||
Wired into `get_agent`:
|
||||
`http_request`, `fetch_url`, `web_search`, `linear_comment`, `linear_create_issue`, `linear_delete_issue`, `linear_get_issue`, `linear_get_issue_comments`, `linear_list_teams`, `linear_search_issues`, `linear_update_issue`, `jira_comment`, `jira_create_issue`, `jira_get_issue`, `jira_get_issue_comments`, `jira_list_projects`, `jira_update_issue`, `confluence_get_page`, `confluence_create_page`, `confluence_update_page`, `confluence_comment`, `confluence_search`, `request_pr_review`, `schedule_thread_wakeup`, `slack_add_reaction`, `slack_read_thread_messages`, `slack_thread_reply`.
|
||||
`http_request`, `fetch_url`, `web_search`, `linear_comment`, `linear_create_issue`, `linear_delete_issue`, `linear_get_issue`, `linear_get_issue_comments`, `linear_list_teams`, `linear_update_issue`, `jira_comment`, `jira_create_issue`, `jira_get_issue`, `jira_get_issue_comments`, `jira_list_projects`, `jira_update_issue`, `confluence_get_page`, `confluence_create_page`, `confluence_update_page`, `confluence_comment`, `confluence_search`, `request_pr_review`, `schedule_thread_wakeup`, `slack_add_reaction`, `slack_read_thread_messages`, `slack_thread_reply`.
|
||||
|
||||
Reviewer-only tools (in `agent/reviewer.py`): `add_finding`, `update_finding`, `list_findings`, `publish_review`. The review-style analyzer uses `save_review_style` (exported as `save_review_style_prompt`).
|
||||
|
||||
|
|
|
|||
|
|
@ -89,7 +89,7 @@ There is intentionally no after-agent safety net that opens a PR for the agent.
|
|||
All tools live in `agent/tools/` and are flat-imported via `agent/tools/__init__.py`. The set is intentionally small and curated — see README "Tools — Curated, Not Accumulated".
|
||||
|
||||
Wired into `get_agent`:
|
||||
`http_request`, `fetch_url`, `web_search`, `linear_comment`, `linear_create_issue`, `linear_delete_issue`, `linear_get_issue`, `linear_get_issue_comments`, `linear_list_teams`, `linear_search_issues`, `linear_update_issue`, `jira_comment`, `jira_create_issue`, `jira_get_issue`, `jira_get_issue_comments`, `jira_list_projects`, `jira_update_issue`, `confluence_get_page`, `confluence_create_page`, `confluence_update_page`, `confluence_comment`, `confluence_search`, `request_pr_review`, `schedule_thread_wakeup`, `slack_add_reaction`, `slack_read_thread_messages`, `slack_thread_reply`.
|
||||
`http_request`, `fetch_url`, `web_search`, `linear_comment`, `linear_create_issue`, `linear_delete_issue`, `linear_get_issue`, `linear_get_issue_comments`, `linear_list_teams`, `linear_update_issue`, `jira_comment`, `jira_create_issue`, `jira_get_issue`, `jira_get_issue_comments`, `jira_list_projects`, `jira_update_issue`, `confluence_get_page`, `confluence_create_page`, `confluence_update_page`, `confluence_comment`, `confluence_search`, `request_pr_review`, `schedule_thread_wakeup`, `slack_add_reaction`, `slack_read_thread_messages`, `slack_thread_reply`.
|
||||
|
||||
Jira uses a service-account REST client (`agent/utils/jira.py`, Basic auth) with ADF↔markdown conversion (`agent/utils/adf.py`); Confluence likewise (`agent/utils/confluence.py`, XHTML storage-format). Both are dark-safe: unset env returns a clean error.
|
||||
|
||||
|
|
|
|||
|
|
@ -72,7 +72,6 @@ Stripe's key insight: *tool curation matters more than tool quantity.* Open SWE
|
|||
| `fetch_url` | Fetch web pages as markdown |
|
||||
| `http_request` | API calls (GET, POST, etc.) |
|
||||
| `linear_comment` | Post updates to Linear tickets |
|
||||
| `linear_search_issues` | Search Linear issues by free text |
|
||||
| `jira_*` | Read/comment/create/update Jira issues |
|
||||
| `confluence_*` | Read/write Confluence pages + comments |
|
||||
| `slack_add_reaction` | React to Slack messages |
|
||||
|
|
|
|||
|
|
@ -115,10 +115,8 @@ async def _resolve_chat_model(configurable: dict) -> tuple[str, str]:
|
|||
|
||||
async def get_chat_agent(config: RunnableConfig) -> Pregel:
|
||||
"""Get a read-only PR chat agent. No sandbox; PR context comes via config."""
|
||||
config = config.copy()
|
||||
config["configurable"] = config["configurable"].copy()
|
||||
config.setdefault("recursion_limit", DEFAULT_RECURSION_LIMIT)
|
||||
thread_id = config["configurable"].get("thread_id")
|
||||
config["recursion_limit"] = DEFAULT_RECURSION_LIMIT
|
||||
|
||||
if thread_id is None or not graph_loaded_for_execution(config):
|
||||
return create_deep_agent(system_prompt="", tools=[]).with_config(config)
|
||||
|
|
|
|||
|
|
@ -342,9 +342,9 @@ def _build_snapshot_sync(record: dict[str, Any], snapshot_name: str) -> tuple[st
|
|||
"""
|
||||
from langsmith.sandbox import SandboxClient
|
||||
|
||||
from agent.integrations.langsmith import _get_sandbox_api_endpoint, _get_sandbox_api_key
|
||||
from agent.integrations.langsmith import _get_langsmith_api_key
|
||||
|
||||
api_key = _get_sandbox_api_key()
|
||||
api_key = _get_langsmith_api_key()
|
||||
if not api_key:
|
||||
raise RuntimeError("LANGSMITH_API_KEY is not configured")
|
||||
|
||||
|
|
@ -356,7 +356,7 @@ def _build_snapshot_sync(record: dict[str, Any], snapshot_name: str) -> tuple[st
|
|||
timeout = int(
|
||||
os.environ.get("REPO_SNAPSHOT_BUILD_TIMEOUT_SECONDS", DEFAULT_BUILD_TIMEOUT_SECONDS)
|
||||
)
|
||||
client = SandboxClient(api_key=api_key, api_endpoint=_get_sandbox_api_endpoint())
|
||||
client = SandboxClient(api_key=api_key)
|
||||
try:
|
||||
with tempfile.TemporaryDirectory(prefix="openswe-snapshot-") as context_dir:
|
||||
dockerfile_path = Path(context_dir) / "Dockerfile"
|
||||
|
|
|
|||
|
|
@ -4,11 +4,9 @@ from __future__ import annotations
|
|||
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
from abc import ABC, abstractmethod
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from concurrent.futures import TimeoutError as FuturesTimeout
|
||||
|
|
@ -46,69 +44,6 @@ def _get_langsmith_api_key() -> str | None:
|
|||
return os.environ.get("LANGSMITH_API_KEY") or os.environ.get("LANGSMITH_API_KEY_PROD")
|
||||
|
||||
|
||||
def _get_sandbox_api_key() -> str | None:
|
||||
"""LangSmith API key for sandbox operations.
|
||||
|
||||
``SANDBOX_LANGSMITH_API_KEY`` lets sandboxes run against a different
|
||||
LangSmith workspace than the one used for tracing/other API calls; falls
|
||||
back to the standard key.
|
||||
"""
|
||||
return os.environ.get("SANDBOX_LANGSMITH_API_KEY") or _get_langsmith_api_key()
|
||||
|
||||
|
||||
def _get_sandbox_endpoint() -> str:
|
||||
"""LangSmith API **root** for sandbox operations.
|
||||
|
||||
Overridable via ``SANDBOX_LANGSMITH_ENDPOINT`` to pair with
|
||||
``SANDBOX_LANGSMITH_API_KEY``; falls back to ``LANGSMITH_ENDPOINT``. This is
|
||||
the bare root (e.g. ``https://api.smith.langchain.com``) used to build the
|
||||
proxy-config URL; the SDK clients take :func:`_get_sandbox_api_endpoint`.
|
||||
"""
|
||||
return (
|
||||
os.environ.get("SANDBOX_LANGSMITH_ENDPOINT")
|
||||
or os.environ.get("LANGSMITH_ENDPOINT")
|
||||
or "https://api.smith.langchain.com"
|
||||
)
|
||||
|
||||
|
||||
def _get_sandbox_api_endpoint() -> str:
|
||||
"""Sandbox API base URL for the langsmith SDK clients.
|
||||
|
||||
The SDK's ``api_endpoint`` is the sandbox base (root + ``/v2/sandboxes``),
|
||||
not the API root, and its methods append ``/boxes``, ``/snapshots``, etc.
|
||||
"""
|
||||
root = _get_sandbox_endpoint().rstrip("/")
|
||||
suffix = "/v2/sandboxes"
|
||||
return root if root.endswith(suffix) else f"{root}{suffix}"
|
||||
|
||||
|
||||
def _current_thread_id() -> str | None:
|
||||
"""The LangGraph thread id for the active run, if any."""
|
||||
try:
|
||||
from langgraph.config import get_config
|
||||
|
||||
return get_config().get("configurable", {}).get("thread_id")
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _sandbox_name_for_thread(thread_id: str | None) -> str | None:
|
||||
"""Deterministic, thread-traceable sandbox name: ``openswe-<b32(thread uuid)>``.
|
||||
|
||||
The thread id (a UUID) is base32-encoded lowercase without padding so the
|
||||
name is a compact, hyphen-free token that maps back to the thread. Returns
|
||||
None when the thread id is missing or not a UUID, leaving the name unset.
|
||||
"""
|
||||
if not thread_id:
|
||||
return None
|
||||
try:
|
||||
raw = uuid.UUID(thread_id).bytes
|
||||
except ValueError:
|
||||
return None
|
||||
encoded = base64.b32encode(raw).decode("ascii").rstrip("=").lower()
|
||||
return f"openswe-{encoded}"
|
||||
|
||||
|
||||
def _parse_optional_int(name: str, default: int) -> int:
|
||||
raw = os.environ.get(name)
|
||||
if not raw:
|
||||
|
|
@ -152,43 +87,6 @@ def _get_sandbox_snapshot_config() -> tuple[str | None, int, int, int, int, int]
|
|||
)
|
||||
|
||||
|
||||
def _get_sandbox_create_extra_fields() -> dict[str, Any]:
|
||||
"""Parse SANDBOX_CREATE_EXTRA_JSON into extra fields merged into the
|
||||
sandbox-create request body, e.g. ``{"_internal_runtime": "v2"}``."""
|
||||
raw = os.environ.get("SANDBOX_CREATE_EXTRA_JSON")
|
||||
if not raw or not raw.strip():
|
||||
return {}
|
||||
try:
|
||||
parsed = json.loads(raw)
|
||||
except json.JSONDecodeError as e:
|
||||
msg = f"SANDBOX_CREATE_EXTRA_JSON must be valid JSON, got {raw!r}"
|
||||
raise ValueError(msg) from e
|
||||
if not isinstance(parsed, dict):
|
||||
msg = f"SANDBOX_CREATE_EXTRA_JSON must be a JSON object, got {type(parsed).__name__}"
|
||||
raise ValueError(msg)
|
||||
return parsed
|
||||
|
||||
|
||||
def _install_create_extra_fields(client: SandboxClient, extra: dict[str, Any]) -> None:
|
||||
"""Merge ``extra`` into the JSON body of the sandbox-create request.
|
||||
|
||||
The SDK's ``create_sandbox`` builds a fixed payload with no passthrough, so
|
||||
wrap the HTTP client's ``post`` to inject the fields on the ``POST /boxes``
|
||||
request only (other endpoints post to ``/boxes/{name}/...``).
|
||||
"""
|
||||
if not extra:
|
||||
return
|
||||
original_post = client._http.post
|
||||
|
||||
def post_with_extra(url: Any, *args: Any, **kwargs: Any) -> Any:
|
||||
payload = kwargs.get("json")
|
||||
if str(url).endswith("/boxes") and isinstance(payload, dict):
|
||||
kwargs["json"] = {**payload, **extra}
|
||||
return original_post(url, *args, **kwargs)
|
||||
|
||||
client._http.post = post_with_extra
|
||||
|
||||
|
||||
def _github_proxy_rules(github_token: str) -> list[dict[str, Any]]:
|
||||
basic_auth = base64.b64encode(f"x-access-token:{github_token}".encode()).decode()
|
||||
return [
|
||||
|
|
@ -236,22 +134,6 @@ def _is_retryable_proxy_config_error(exc: BaseException) -> bool:
|
|||
return isinstance(exc, httpx.TransportError)
|
||||
|
||||
|
||||
def _release_sandbox_name(client: SandboxClient, name: str | None) -> None:
|
||||
"""Best-effort delete of any existing sandbox holding ``name``.
|
||||
|
||||
Sandbox names are unique in LangSmith and thread-deterministic, so the only
|
||||
box that can hold this name is this thread's own — typically a dead one
|
||||
(idle-stopped past its TTL) we're recreating. Provisioning is serialized per
|
||||
thread, so this never races a live box. Without this, recreate would 409.
|
||||
"""
|
||||
if not name:
|
||||
return
|
||||
try:
|
||||
client.delete_sandbox(name)
|
||||
except Exception as exc: # noqa: BLE001 - name is free if nothing to delete
|
||||
logger.debug("No pre-existing sandbox %s to release (%s)", name, type(exc).__name__)
|
||||
|
||||
|
||||
def _configure_github_proxy(sandbox_name: str, github_token: str) -> None:
|
||||
"""Configure sandbox proxy to inject GitHub auth for GitHub traffic.
|
||||
|
||||
|
|
@ -263,11 +145,11 @@ def _configure_github_proxy(sandbox_name: str, github_token: str) -> None:
|
|||
sandbox_name: The sandbox name/ID returned by the LangSmith API.
|
||||
github_token: GitHub token to inject as Authorization header.
|
||||
"""
|
||||
api_key = _get_sandbox_api_key()
|
||||
api_key = _get_langsmith_api_key()
|
||||
if not api_key:
|
||||
logger.warning("No LangSmith API key found, skipping GitHub proxy configuration")
|
||||
return
|
||||
langsmith_endpoint = _get_sandbox_endpoint()
|
||||
langsmith_endpoint = os.environ.get("LANGSMITH_ENDPOINT", "https://api.smith.langchain.com")
|
||||
url = f"{langsmith_endpoint}/v2/sandboxes/boxes/{sandbox_name}"
|
||||
payload = {"proxy_config": {"rules": _github_proxy_rules(github_token)}}
|
||||
with httpx.Client(timeout=PROXY_CONFIG_TIMEOUT_SECONDS) as client:
|
||||
|
|
@ -329,7 +211,7 @@ def create_langsmith_sandbox(
|
|||
Returns:
|
||||
SandboxBackendProtocol instance
|
||||
"""
|
||||
api_key = _get_sandbox_api_key()
|
||||
api_key = _get_langsmith_api_key()
|
||||
(
|
||||
default_snapshot_id,
|
||||
fs_capacity_bytes,
|
||||
|
|
@ -345,7 +227,6 @@ def create_langsmith_sandbox(
|
|||
backend = provider.get_or_create(
|
||||
sandbox_id=sandbox_id,
|
||||
snapshot_id=effective_snapshot_id,
|
||||
name=_sandbox_name_for_thread(_current_thread_id()),
|
||||
fs_capacity_bytes=fs_capacity_bytes,
|
||||
vcpus=vcpus,
|
||||
mem_bytes=mem_bytes,
|
||||
|
|
@ -365,9 +246,11 @@ def _update_thread_sandbox_metadata(sandbox_id: str) -> None:
|
|||
try:
|
||||
import asyncio
|
||||
|
||||
from langgraph.config import get_config
|
||||
from langgraph_sdk import get_client
|
||||
|
||||
thread_id = _current_thread_id()
|
||||
config = get_config()
|
||||
thread_id = config.get("configurable", {}).get("thread_id")
|
||||
if not thread_id:
|
||||
return
|
||||
client = get_client()
|
||||
|
|
@ -533,14 +416,11 @@ class LangSmithProvider(SandboxProvider):
|
|||
def __init__(self, api_key: str | None = None) -> None:
|
||||
from langsmith import sandbox
|
||||
|
||||
self._api_key = api_key or _get_sandbox_api_key()
|
||||
self._api_endpoint = _get_sandbox_api_endpoint()
|
||||
self._api_key = api_key or _get_langsmith_api_key()
|
||||
if not self._api_key:
|
||||
msg = "LANGSMITH_API_KEY (or LANGSMITH_API_KEY_PROD) not set"
|
||||
raise ValueError(msg)
|
||||
self._client: SandboxClient = sandbox.SandboxClient(
|
||||
api_key=self._api_key, api_endpoint=self._api_endpoint
|
||||
)
|
||||
self._client: SandboxClient = sandbox.SandboxClient(api_key=self._api_key)
|
||||
|
||||
@classmethod
|
||||
def validate_startup_config(cls) -> None:
|
||||
|
|
@ -573,7 +453,6 @@ class LangSmithProvider(SandboxProvider):
|
|||
):
|
||||
msg = f"{name} must be >= 0, got {value}"
|
||||
raise ValueError(msg)
|
||||
_get_sandbox_create_extra_fields()
|
||||
|
||||
def get_or_create(
|
||||
self,
|
||||
|
|
@ -581,7 +460,6 @@ class LangSmithProvider(SandboxProvider):
|
|||
sandbox_id: str | None = None,
|
||||
timeout: int = 180,
|
||||
snapshot_id: str | None = None,
|
||||
name: str | None = None,
|
||||
fs_capacity_bytes: int | None = None,
|
||||
vcpus: int | None = None,
|
||||
mem_bytes: int | None = None,
|
||||
|
|
@ -605,13 +483,9 @@ class LangSmithProvider(SandboxProvider):
|
|||
msg = "DEFAULT_SANDBOX_SNAPSHOT_ID must be set when SANDBOX_TYPE=langsmith"
|
||||
raise ValueError(msg)
|
||||
|
||||
_install_create_extra_fields(self._client, _get_sandbox_create_extra_fields())
|
||||
_release_sandbox_name(self._client, name)
|
||||
|
||||
try:
|
||||
sandbox = self._client.create_sandbox(
|
||||
snapshot_id=snapshot_id,
|
||||
name=name,
|
||||
fs_capacity_bytes=fs_capacity_bytes,
|
||||
vcpus=vcpus,
|
||||
mem_bytes=mem_bytes,
|
||||
|
|
|
|||
|
|
@ -63,7 +63,6 @@ _TOOL_STATUS: dict[str, str] = {
|
|||
"linear_get_issue": "checking Linear...",
|
||||
"linear_get_issue_comments": "checking Linear...",
|
||||
"linear_list_teams": "checking Linear...",
|
||||
"linear_search_issues": "searching Linear...",
|
||||
"linear_update_issue": "updating Linear...",
|
||||
"linear_delete_issue": "updating Linear...",
|
||||
"add_finding": "recording review findings...",
|
||||
|
|
|
|||
|
|
@ -854,11 +854,10 @@ async def _resolve_grouping_model(
|
|||
|
||||
async def get_reviewer_agent(config: RunnableConfig) -> Pregel:
|
||||
"""Get or create a reviewer agent with a sandbox + prepped repo."""
|
||||
config = config.copy()
|
||||
config["configurable"] = config["configurable"].copy()
|
||||
config.setdefault("recursion_limit", DEFAULT_RECURSION_LIMIT)
|
||||
thread_id = config["configurable"].get("thread_id", None)
|
||||
|
||||
config["recursion_limit"] = DEFAULT_RECURSION_LIMIT
|
||||
|
||||
if thread_id is None or not graph_loaded_for_execution(config):
|
||||
logger.info("No thread_id or not for execution, returning reviewer agent without sandbox")
|
||||
return create_deep_agent(system_prompt="", tools=[]).with_config(config)
|
||||
|
|
@ -880,7 +879,7 @@ 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=repo_config, is_bot_token=True
|
||||
thread_id, github_token, expires_at=expires_at, repo=repo_config
|
||||
)
|
||||
|
||||
github_proxy_token = github_token
|
||||
|
|
|
|||
|
|
@ -105,7 +105,6 @@ from .tools import (
|
|||
linear_get_issue,
|
||||
linear_get_issue_comments,
|
||||
linear_list_teams,
|
||||
linear_search_issues,
|
||||
linear_update_issue,
|
||||
open_pull_request,
|
||||
report_platform_issue,
|
||||
|
|
@ -1020,7 +1019,6 @@ async def get_agent(config: RunnableConfig) -> Pregel:
|
|||
linear_get_issue,
|
||||
linear_get_issue_comments,
|
||||
linear_list_teams,
|
||||
linear_search_issues,
|
||||
linear_update_issue,
|
||||
jira_comment,
|
||||
jira_create_issue,
|
||||
|
|
|
|||
|
|
@ -24,7 +24,6 @@ _TOOL_MODULES = {
|
|||
"linear_get_issue": ".linear_get_issue",
|
||||
"linear_get_issue_comments": ".linear_get_issue_comments",
|
||||
"linear_list_teams": ".linear_list_teams",
|
||||
"linear_search_issues": ".linear_search_issues",
|
||||
"linear_update_issue": ".linear_update_issue",
|
||||
"list_findings": ".list_findings",
|
||||
"list_review_findings": ".list_review_findings",
|
||||
|
|
@ -68,7 +67,6 @@ __all__ = [
|
|||
"linear_get_issue",
|
||||
"linear_get_issue_comments",
|
||||
"linear_list_teams",
|
||||
"linear_search_issues",
|
||||
"linear_update_issue",
|
||||
"list_findings",
|
||||
"list_review_findings",
|
||||
|
|
@ -112,7 +110,6 @@ if TYPE_CHECKING:
|
|||
from .linear_get_issue import linear_get_issue
|
||||
from .linear_get_issue_comments import linear_get_issue_comments
|
||||
from .linear_list_teams import linear_list_teams
|
||||
from .linear_search_issues import linear_search_issues
|
||||
from .linear_update_issue import linear_update_issue
|
||||
from .list_findings import list_findings
|
||||
from .list_review_findings import list_review_findings
|
||||
|
|
|
|||
|
|
@ -1,34 +0,0 @@
|
|||
from typing import Any
|
||||
|
||||
from ..utils.linear import search_issues
|
||||
|
||||
|
||||
async def linear_search_issues(
|
||||
query: str,
|
||||
team_id: str | None = None,
|
||||
limit: int = 10,
|
||||
include_archived: bool = False,
|
||||
include_comments: bool = False,
|
||||
after: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Search Linear issues by title, description, and optionally comments.
|
||||
|
||||
Args:
|
||||
query: Free-text search query.
|
||||
team_id: Optional team UUID used to restrict matches to that team.
|
||||
limit: Maximum results to return, from 1 to 50.
|
||||
include_archived: Whether to include archived issues.
|
||||
include_comments: Whether to search issue comments in addition to issue content.
|
||||
after: Optional pagination cursor from a previous result's page_info.endCursor.
|
||||
|
||||
Returns:
|
||||
Matching issues plus total_count and page_info for pagination.
|
||||
"""
|
||||
return await search_issues(
|
||||
query=query,
|
||||
team_id=team_id,
|
||||
limit=limit,
|
||||
include_archived=include_archived,
|
||||
include_comments=include_comments,
|
||||
after=after,
|
||||
)
|
||||
|
|
@ -14,11 +14,7 @@ from langgraph.graph.state import RunnableConfig
|
|||
from langgraph_sdk import get_client
|
||||
|
||||
from .github_app import get_github_app_installation_token_with_expiry
|
||||
from .github_token import (
|
||||
cache_github_token_for_thread,
|
||||
get_github_token_from_thread,
|
||||
github_token_principal,
|
||||
)
|
||||
from .github_token import cache_github_token_for_thread, get_github_token_from_thread
|
||||
from .http import DEFAULT_HTTP_TIMEOUT
|
||||
from .linear import comment_on_linear_issue
|
||||
from .slack import post_slack_thread_reply
|
||||
|
|
@ -308,21 +304,9 @@ def _current_repo() -> Any:
|
|||
|
||||
|
||||
def _cache_resolved_github_token(
|
||||
thread_id: str,
|
||||
token: str,
|
||||
expires_at: str | None = None,
|
||||
*,
|
||||
principal: str | None = None,
|
||||
is_bot_token: bool = False,
|
||||
thread_id: str, token: str, expires_at: str | None = None
|
||||
) -> tuple[str, str | None]:
|
||||
cache_github_token_for_thread(
|
||||
thread_id,
|
||||
token,
|
||||
expires_at=expires_at,
|
||||
repo=_current_repo(),
|
||||
principal=principal,
|
||||
is_bot_token=is_bot_token,
|
||||
)
|
||||
cache_github_token_for_thread(thread_id, token, expires_at=expires_at, repo=_current_repo())
|
||||
return token, expires_at
|
||||
|
||||
|
||||
|
|
@ -390,13 +374,7 @@ async def resolve_token_from_email(
|
|||
|
||||
expires_at = auth_result.get("expires_at") if isinstance(auth_result, dict) else None
|
||||
return _cache_resolved_github_token(
|
||||
thread_id,
|
||||
token,
|
||||
expires_at=expires_at if isinstance(expires_at, str) else None,
|
||||
principal=github_token_principal(
|
||||
login=configurable.get("github_login"),
|
||||
email=email,
|
||||
),
|
||||
thread_id, token, expires_at=expires_at if isinstance(expires_at, str) else None
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -417,10 +395,7 @@ async def _resolve_dashboard_user_token(
|
|||
record = await get_oauth_record(OAUTH_TOKENS_NAMESPACE, login)
|
||||
expires_at = record.get("token_expires_at") if isinstance(record, dict) else None
|
||||
return _cache_resolved_github_token(
|
||||
thread_id,
|
||||
token,
|
||||
expires_at=expires_at if isinstance(expires_at, str) else None,
|
||||
principal=github_token_principal(login=login),
|
||||
thread_id, token, expires_at=expires_at if isinstance(expires_at, str) else None
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -442,9 +417,7 @@ async def _resolve_bot_installation_token(thread_id: str) -> tuple[str, str | No
|
|||
logger.info(
|
||||
"Using GitHub App installation token for thread %s (bot-token-only mode)", thread_id
|
||||
)
|
||||
return _cache_resolved_github_token(
|
||||
thread_id, bot_token, expires_at=expires_at, is_bot_token=True
|
||||
)
|
||||
return _cache_resolved_github_token(thread_id, bot_token, expires_at=expires_at)
|
||||
|
||||
|
||||
async def resolve_github_token(config: RunnableConfig, thread_id: str) -> tuple[str, str | None]:
|
||||
|
|
@ -503,9 +476,7 @@ async def resolve_github_token(config: RunnableConfig, thread_id: str) -> tuple[
|
|||
try:
|
||||
if source == "github":
|
||||
cached_token, cached_expires_at = await get_github_token_from_thread(
|
||||
thread_id,
|
||||
principal=github_token_principal(login=github_login),
|
||||
expected_repo=configurable.get("repo"),
|
||||
thread_id, expected_repo=configurable.get("repo")
|
||||
)
|
||||
if cached_token:
|
||||
return cached_token, cached_expires_at
|
||||
|
|
|
|||
|
|
@ -17,23 +17,10 @@ _GITHUB_TOKEN_EXPIRY_SKEW_SECONDS = 60
|
|||
# Hard cap on how long an entry stays cached regardless of the token's own
|
||||
# expiry, so entries for threads that are never read again don't accumulate.
|
||||
_GITHUB_TOKEN_MAX_TTL = timedelta(hours=24)
|
||||
_BOT_PRINCIPAL = "bot"
|
||||
# (thread_id, principal) -> (token, token_expires_at, cached_at, bound_repo).
|
||||
# ``principal`` binds user tokens to the user they were resolved for so a
|
||||
# cached token can never be served to a different user on the same thread.
|
||||
# ``bound_repo`` ("owner/name") binds the entry to the repo it was resolved for
|
||||
# so a colliding thread_id originating from a different repo cannot reuse
|
||||
# another repo's token.
|
||||
_GITHUB_TOKEN_CACHE: dict[tuple[str, str], tuple[str, str | None, datetime, str | None]] = {}
|
||||
|
||||
|
||||
def github_token_principal(*, login: str | None = None, email: str | None = None) -> str | None:
|
||||
"""Return the normalized principal used to isolate cached user tokens."""
|
||||
if isinstance(login, str) and login.strip():
|
||||
return f"login:{login.strip().casefold()}"
|
||||
if isinstance(email, str) and email.strip():
|
||||
return f"email:{email.strip().casefold()}"
|
||||
return None
|
||||
# thread_id -> (token, token_expires_at, cached_at, bound_repo). ``bound_repo``
|
||||
# ("owner/name") binds the entry to the repo it was resolved for so a colliding
|
||||
# thread_id originating from a different repo cannot reuse another repo's token.
|
||||
_GITHUB_TOKEN_CACHE: dict[str, tuple[str, str | None, datetime, str | None]] = {}
|
||||
|
||||
|
||||
class GitHubAuthError(Exception):
|
||||
|
|
@ -59,28 +46,13 @@ def repo_cache_key(repo: Any) -> str | None:
|
|||
|
||||
|
||||
def cache_github_token_for_thread(
|
||||
thread_id: str,
|
||||
token: str,
|
||||
expires_at: str | None = None,
|
||||
*,
|
||||
repo: Any = None,
|
||||
principal: str | None = None,
|
||||
is_bot_token: bool = False,
|
||||
thread_id: str, token: str, expires_at: str | None = None, *, repo: Any = None
|
||||
) -> None:
|
||||
"""Cache a GitHub token in process for the current thread and principal."""
|
||||
"""Cache a GitHub token in process for the current thread."""
|
||||
if not thread_id or not token:
|
||||
return
|
||||
cache_principal = _BOT_PRINCIPAL if is_bot_token else principal
|
||||
if not cache_principal:
|
||||
logger.warning("Refusing to cache an unbound user GitHub token for thread %s", thread_id)
|
||||
return
|
||||
now = datetime.now(UTC)
|
||||
_GITHUB_TOKEN_CACHE[(thread_id, cache_principal)] = (
|
||||
token,
|
||||
expires_at,
|
||||
now,
|
||||
repo_cache_key(repo),
|
||||
)
|
||||
_GITHUB_TOKEN_CACHE[thread_id] = (token, expires_at, now, repo_cache_key(repo))
|
||||
_evict_expired(now=now)
|
||||
|
||||
|
||||
|
|
@ -125,45 +97,39 @@ def _entry_expired(expires_at: str | None, cached_at: datetime, *, now: datetime
|
|||
def _evict_expired(*, now: datetime | None = None) -> None:
|
||||
current = now or datetime.now(UTC)
|
||||
stale = [
|
||||
key
|
||||
for key, (_token, expires_at, cached_at, _repo) in _GITHUB_TOKEN_CACHE.items()
|
||||
tid
|
||||
for tid, (_token, expires_at, cached_at, _repo) in _GITHUB_TOKEN_CACHE.items()
|
||||
if _entry_expired(expires_at, cached_at, now=current)
|
||||
]
|
||||
for key in stale:
|
||||
_GITHUB_TOKEN_CACHE.pop(key, None)
|
||||
for tid in stale:
|
||||
_GITHUB_TOKEN_CACHE.pop(tid, None)
|
||||
|
||||
|
||||
def _cached_token_if_fresh(
|
||||
thread_id: str | None, principal: str | None, *, expected_repo: Any = None
|
||||
thread_id: str | None, *, expected_repo: Any = None
|
||||
) -> tuple[str | None, str | None]:
|
||||
if not thread_id:
|
||||
return None, None
|
||||
keys = []
|
||||
if principal:
|
||||
keys.append((thread_id, principal))
|
||||
keys.append((thread_id, _BOT_PRINCIPAL))
|
||||
cached = _GITHUB_TOKEN_CACHE.get(thread_id)
|
||||
if not cached:
|
||||
return None, None
|
||||
token, expires_at, cached_at, bound_repo = cached
|
||||
if _entry_expired(expires_at, cached_at, now=datetime.now(UTC)):
|
||||
_GITHUB_TOKEN_CACHE.pop(thread_id, None)
|
||||
logger.info("Cached GitHub token for thread %s has expired; re-resolving", thread_id)
|
||||
return None, None
|
||||
expected = repo_cache_key(expected_repo)
|
||||
for key in keys:
|
||||
cached = _GITHUB_TOKEN_CACHE.get(key)
|
||||
if not cached:
|
||||
continue
|
||||
token, expires_at, cached_at, bound_repo = cached
|
||||
if _entry_expired(expires_at, cached_at, now=datetime.now(UTC)):
|
||||
_GITHUB_TOKEN_CACHE.pop(key, None)
|
||||
logger.info("Cached GitHub token for thread %s has expired; re-resolving", thread_id)
|
||||
continue
|
||||
if expected and bound_repo and expected != bound_repo:
|
||||
_GITHUB_TOKEN_CACHE.pop(key, None)
|
||||
logger.warning(
|
||||
"Cached GitHub token for thread %s is bound to repo %s, not %s; "
|
||||
"refusing cross-repo reuse",
|
||||
thread_id,
|
||||
bound_repo,
|
||||
expected,
|
||||
)
|
||||
continue
|
||||
return token, expires_at
|
||||
return None, None
|
||||
if expected and bound_repo and expected != bound_repo:
|
||||
_GITHUB_TOKEN_CACHE.pop(thread_id, None)
|
||||
logger.warning(
|
||||
"Cached GitHub token for thread %s is bound to repo %s, not %s; "
|
||||
"refusing cross-repo reuse",
|
||||
thread_id,
|
||||
bound_repo,
|
||||
expected,
|
||||
)
|
||||
return None, None
|
||||
return token, expires_at
|
||||
|
||||
|
||||
def _thread_id_from_config(run_config: Mapping[str, Any]) -> str | None:
|
||||
|
|
@ -181,36 +147,23 @@ def _repo_from_config(run_config: Mapping[str, Any]) -> Any:
|
|||
return configurable.get("repo")
|
||||
|
||||
|
||||
def _principal_from_config(run_config: Mapping[str, Any]) -> str | None:
|
||||
configurable = run_config.get("configurable", {})
|
||||
if not isinstance(configurable, Mapping):
|
||||
return None
|
||||
return github_token_principal(
|
||||
login=configurable.get("github_login"),
|
||||
email=configurable.get("user_email"),
|
||||
)
|
||||
|
||||
|
||||
def get_github_token(run_config: Mapping[str, Any] | None = None) -> str | None:
|
||||
"""Resolve the current thread's GitHub token from process memory."""
|
||||
resolved = run_config if run_config is not None else get_config()
|
||||
token, _expires_at = _cached_token_if_fresh(
|
||||
_thread_id_from_config(resolved),
|
||||
_principal_from_config(resolved),
|
||||
expected_repo=_repo_from_config(resolved),
|
||||
_thread_id_from_config(resolved), expected_repo=_repo_from_config(resolved)
|
||||
)
|
||||
return token
|
||||
|
||||
|
||||
async def get_github_token_from_thread(
|
||||
thread_id: str, *, principal: str | None = None, expected_repo: Any = None
|
||||
thread_id: str, *, expected_repo: Any = None
|
||||
) -> tuple[str | None, str | None]:
|
||||
"""Resolve the current process's cached GitHub token for a thread and principal."""
|
||||
return _cached_token_if_fresh(thread_id, principal, expected_repo=expected_repo)
|
||||
"""Resolve the current process's cached GitHub token for a thread."""
|
||||
return _cached_token_if_fresh(thread_id, expected_repo=expected_repo)
|
||||
|
||||
|
||||
async def invalidate_cached_github_token(thread_id: str) -> None:
|
||||
"""Clear every cached GitHub token for a thread."""
|
||||
for key in [key for key in _GITHUB_TOKEN_CACHE if key[0] == thread_id]:
|
||||
_GITHUB_TOKEN_CACHE.pop(key, None)
|
||||
"""Clear a cached GitHub token for a thread."""
|
||||
_GITHUB_TOKEN_CACHE.pop(thread_id, None)
|
||||
logger.info("Invalidated cached GitHub token for thread %s", thread_id)
|
||||
|
|
|
|||
|
|
@ -132,84 +132,6 @@ async def get_issue(issue_id: str) -> dict[str, Any]:
|
|||
return {"issue": result.get("issue")}
|
||||
|
||||
|
||||
async def search_issues(
|
||||
query: str,
|
||||
team_id: str | None = None,
|
||||
limit: int = 10,
|
||||
include_archived: bool = False,
|
||||
include_comments: bool = False,
|
||||
after: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Search Linear issues by free-text query."""
|
||||
query = query.strip()
|
||||
if not query:
|
||||
return {"error": "Search query must not be empty"}
|
||||
if not 1 <= limit <= 50:
|
||||
return {"error": "Search limit must be between 1 and 50"}
|
||||
|
||||
search_query = """
|
||||
query SearchIssues(
|
||||
$query: String!
|
||||
$filter: IssueFilter
|
||||
$limit: Int!
|
||||
$includeArchived: Boolean
|
||||
$includeComments: Boolean
|
||||
$after: String
|
||||
) {
|
||||
searchIssues(
|
||||
term: $query
|
||||
filter: $filter
|
||||
first: $limit
|
||||
includeArchived: $includeArchived
|
||||
includeComments: $includeComments
|
||||
after: $after
|
||||
) {
|
||||
totalCount
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
endCursor
|
||||
}
|
||||
nodes {
|
||||
id
|
||||
identifier
|
||||
title
|
||||
priority
|
||||
priorityLabel
|
||||
state { id name type }
|
||||
assignee { id name email }
|
||||
team { id name key }
|
||||
project { id name }
|
||||
labels { nodes { id name } }
|
||||
createdAt
|
||||
updatedAt
|
||||
archivedAt
|
||||
url
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
result = await _graphql_request(
|
||||
search_query,
|
||||
{
|
||||
"query": query,
|
||||
"filter": {"team": {"id": {"eq": team_id}}} if team_id else None,
|
||||
"limit": limit,
|
||||
"includeArchived": include_archived,
|
||||
"includeComments": include_comments,
|
||||
"after": after,
|
||||
},
|
||||
)
|
||||
if "error" in result:
|
||||
return result
|
||||
|
||||
search_results = result.get("searchIssues", {})
|
||||
return {
|
||||
"issues": search_results.get("nodes", []),
|
||||
"total_count": search_results.get("totalCount", 0),
|
||||
"page_info": search_results.get("pageInfo", {}),
|
||||
}
|
||||
|
||||
|
||||
async def create_issue(
|
||||
team_id: str,
|
||||
title: str,
|
||||
|
|
|
|||
|
|
@ -89,7 +89,6 @@ from ..utils.github_org_membership import INTERNAL_BOT_LOGINS, is_user_active_or
|
|||
from ..utils.github_token import (
|
||||
cache_github_token_for_thread,
|
||||
get_github_token_from_thread,
|
||||
github_token_principal,
|
||||
invalidate_cached_github_token,
|
||||
)
|
||||
from ..utils.http import DEFAULT_HTTP_TIMEOUT
|
||||
|
|
@ -1657,17 +1656,12 @@ async def _get_or_resolve_thread_github_token(
|
|||
if is_bot_token_only_mode():
|
||||
bot_token, expires_at = await get_github_app_installation_token_with_expiry()
|
||||
if bot_token:
|
||||
cache_github_token_for_thread(
|
||||
thread_id, bot_token, expires_at=expires_at, repo=repo, is_bot_token=True
|
||||
)
|
||||
cache_github_token_for_thread(thread_id, bot_token, expires_at=expires_at, repo=repo)
|
||||
return bot_token
|
||||
logger.warning("Bot-token-only mode but GitHub App token unavailable")
|
||||
return None
|
||||
|
||||
principal = github_token_principal(email=email)
|
||||
github_token, _expires_at = await get_github_token_from_thread(
|
||||
thread_id, principal=principal, expected_repo=repo
|
||||
)
|
||||
github_token, _expires_at = await get_github_token_from_thread(thread_id, expected_repo=repo)
|
||||
if github_token:
|
||||
return github_token
|
||||
|
||||
|
|
@ -1682,7 +1676,6 @@ async def _get_or_resolve_thread_github_token(
|
|||
github_token,
|
||||
expires_at=expires_at if isinstance(expires_at, str) else None,
|
||||
repo=repo,
|
||||
principal=principal,
|
||||
)
|
||||
return github_token
|
||||
|
||||
|
|
|
|||
|
|
@ -69,8 +69,6 @@ Set the `SANDBOX_TYPE` environment variable to switch providers. Each provider h
|
|||
|
||||
> **Warning**: `local` runs commands directly on your host with no sandboxing. Only use for local development with human-in-the-loop enabled.
|
||||
|
||||
For `langsmith`, sandboxes default to the same LangSmith credentials as tracing. To run sandboxes against a **different** LangSmith workspace, set `SANDBOX_LANGSMITH_API_KEY` (falls back to `LANGSMITH_API_KEY` / `LANGSMITH_API_KEY_PROD`) and optionally `SANDBOX_LANGSMITH_ENDPOINT` (falls back to `LANGSMITH_ENDPOINT`). These apply to sandbox create/connect/delete, the GitHub proxy config, and repo snapshot builds — the `DEFAULT_SANDBOX_SNAPSHOT_ID` must exist in whichever workspace these credentials point at.
|
||||
|
||||
### Adding a new sandbox provider
|
||||
|
||||
1. **Create an integration file** at `agent/integrations/my_provider.py` with a factory function matching this signature:
|
||||
|
|
|
|||
|
|
@ -98,28 +98,28 @@
|
|||
{"sha": "5136079d", "pr": 1725, "subject": "chore: disable todos for GPT-5.6 Sol (#1725)", "disposition": "wont-merge", "reason": "superseded — #1733 (136d28e6) rewrites the same todo-exclusion block to a global default-off with env opt-in; per-model GPT-5.6 Sol list moot (fork picker has no OpenAI models)", "branch": "", "local_sha": null, "updated": "2026-07-16T18:46:21Z"}
|
||||
{"sha": "09eaf94c", "pr": 1730, "subject": "fix: let admins interrupt runaway agents (#1730)", "disposition": "landed", "reason": "admin interrupt for runaway agents (dashboard route + UI); useful ops control; UI half on post-reorg ui/src/features — remap to ui/src/components/agents", "branch": "feat/admin-thread-interrupt", "local_sha": null, "updated": "2026-07-16T19:36:07Z"}
|
||||
{"sha": "30832d29", "pr": 1731, "subject": "fix: preserve OpenAI Responses tool history (#1731)", "disposition": "deferred", "reason": "deletes SanitizeOpenAIResponsesMiddleware in favor of replay-history preservation in utils/model.py; supersedes deferred #1718 (35659177) — triage the pair together against fork-diverged middleware + model.py", "branch": "openai-sanitize", "local_sha": null, "updated": "2026-07-16T18:46:38Z"}
|
||||
{"sha": "1ea0e600", "pr": 1736, "subject": "fix: bind cached GitHub tokens to users (#1736)", "disposition": "landed", "reason": "Landed via fork PR #207 after both gates passed (GPT-4.1 cross-family: no BLOCK; /sh-security-review: 0 confirmed critical/high, 1 accepted low CWE-613). Composed with the fork's repo-binding: cache key (thread_id, principal), values keep bound_repo; unprincipaled user tokens refused. Added bot-fallback + cross-repo composition tests.", "branch": "bug/bind-cached-github-tokens", "local_sha": "8d8d5bbbbf32a08be92b0acaa566c7aefb2bb8aa", "updated": "2026-07-17T21:31:43Z"}
|
||||
{"sha": "1ea0e600", "pr": 1736, "subject": "fix: bind cached GitHub tokens to users (#1736)", "disposition": "deferred", "reason": "FLAG-HUMAN: security fix — binds per-thread cached GitHub tokens to a user principal (closes cross-user token-reuse leak). Fork has github_token.py but not webhooks/common.py — hand-map; auth surface: GPT-4.1 cross-review + /sh-security-review on landing. Priority pick. Re-triage (post-reorg 8356eb34 landed): biggest beneficiary — agent/webhooks/common.py now exists, so the pick is near-clean against it; auth gates (GPT-4.1 cross-review + /sh-security-review) still apply.", "branch": "github-token-binding", "local_sha": null, "updated": "2026-07-17T00:00:00Z"}
|
||||
{"sha": "3fcb27ce", "pr": 1737, "subject": "fix: bound reviewer diff fetching (#1737)", "disposition": "deferred", "reason": "bounds reviewer diff fetching + new fetch_review_diff tool; reviewer.py fork-diverged — reconcile like #1713 (71e3b818)", "branch": "reviewer-misc", "local_sha": null, "updated": "2026-07-16T18:46:52Z"}
|
||||
{"sha": "d714586c", "pr": 1732, "subject": "fix: normalize dashboard label rendering (#1732)", "disposition": "landed", "reason": "Landed via fork PR #206 (clean cherry-pick).", "branch": "feature/port-upstream-clean-batch", "local_sha": "58941d1edd38e0f8f8cbf744f72332671da3bb88", "updated": "2026-07-17T21:31:43Z"}
|
||||
{"sha": "d714586c", "pr": 1732, "subject": "fix: normalize dashboard label rendering (#1732)", "disposition": "deferred", "reason": "dashboard tool-label normalization; post-reorg ui/src/features paths — remap. Re-triage (post-reorg 8356eb34 landed): path-remap blocker removed — clean pick; ui/src/features/ now matches upstream.", "branch": "dashboard-ui", "local_sha": null, "updated": "2026-07-17T00:00:00Z"}
|
||||
{"sha": "ef68c09b", "pr": 1734, "subject": "fix: handle Slack DMs as mentions (#1734)", "disposition": "deferred", "reason": "treat Slack DMs as mentions; touches fork-diverged Slack webhook + e2e harness; post-reorg test paths", "branch": "slack-tooling", "local_sha": null, "updated": "2026-07-16T18:46:52Z"}
|
||||
{"sha": "bfa67a7a", "pr": 1711, "subject": "chore(deps): bump soupsieve from 2.8.3 to 2.8.4 (#1711)", "disposition": "wont-merge", "reason": "already in dev — uv.lock resolves soupsieve 2.8.4", "branch": "", "local_sha": null, "updated": "2026-07-16T18:46:21Z"}
|
||||
{"sha": "26828ff9", "pr": 1745, "subject": "chore: upgrade deepagents to 0.7.0a7 (#1745)", "disposition": "deferred", "reason": "deepagents 0.6.12 -> 0.7.0a7 alpha + [tool.uv] override-dependencies to bypass sandbox integrations <0.7.0 bound; fork is built on create_deep_agent — dedicated validation (unit + e2e) before adopting an alpha", "branch": "deps", "local_sha": null, "updated": "2026-07-16T18:46:38Z"}
|
||||
{"sha": "ef0ed5af", "pr": 1741, "subject": "fix: centralize SSRF-safe image fetches (#1741)", "disposition": "deferred", "reason": "security hardening — centralizes image fetches through url_safety.py (SSRF); fork has url_safety.py/multimodal.py (diverged) — hand-reconcile; untrusted-input surface: /sh-security-review on landing", "branch": "ssrf-hardening", "local_sha": null, "updated": "2026-07-16T18:46:37Z"}
|
||||
{"sha": "136d28e6", "pr": 1733, "subject": "fix: disable todos by default (#1733)", "disposition": "deferred", "reason": "global write_todos/TodoListMiddleware default-off with OPEN_SWE_ENABLE_TODOS opt-in; edits fork-customized prompt.py — small hand-merge; supersedes #1725", "branch": "prompt-tweaks", "local_sha": null, "updated": "2026-07-16T18:47:07Z"}
|
||||
{"sha": "3e8089c3", "pr": 1744, "subject": "fix: collapse git panel by default (#1744)", "disposition": "landed", "reason": "Landed via fork PR #206. One import-context conflict in AgentThreadView.tsx resolved (upstream moved panel prefs to ui/src/features/agents/lib/gitPanelPreferences.ts).", "branch": "feature/port-upstream-clean-batch", "local_sha": "e542f0d7a6aa0b912aefe69e8b5abc1dc928987a", "updated": "2026-07-17T21:31:43Z"}
|
||||
{"sha": "3e8089c3", "pr": 1744, "subject": "fix: collapse git panel by default (#1744)", "disposition": "deferred", "reason": "collapse git panel by default (localStorage pref); post-reorg UI paths — remap to ui/src/components/agents. Re-triage (post-reorg 8356eb34 landed): path-remap blocker removed — clean pick; ui/src/features/ now matches upstream.", "branch": "dashboard-ui", "local_sha": null, "updated": "2026-07-17T00:00:00Z"}
|
||||
{"sha": "5077e2c7", "pr": 1735, "subject": "feat(open-swe): untagged two-party Slack replies + debounced interrupts (#1735)", "disposition": "deferred", "reason": "untagged two-party Slack replies + debounced interrupts (~620 LOC incl. e2e); rides fork-diverged Slack webhook stack; own branch + e2e validation", "branch": "slack-untagged-replies", "local_sha": null, "updated": "2026-07-16T18:46:52Z"}
|
||||
{"sha": "f7d94ad3", "pr": 1721, "subject": "docs: add e2b to sandbox provider lists in AGENTS.md and CLAUDE.md (#1721)", "disposition": "wont-merge", "reason": "N/A — edits upstream AGENTS.md/CLAUDE.md, both fully fork-rewritten; E2B provider itself deferred (48217b68) — add a doc line if/when E2B lands", "branch": "", "local_sha": null, "updated": "2026-07-16T18:46:21Z"}
|
||||
{"sha": "8b26819f", "pr": 1719, "subject": "fix: offload web tool results to sandbox (#1719)", "disposition": "deferred", "reason": "offloads large web tool results to sandbox files; touches fork-relevant web_search/http tools; check interplay with fork sandbox lifecycle", "branch": "tool-offloading", "local_sha": null, "updated": "2026-07-16T18:46:52Z"}
|
||||
{"sha": "c34e04f4", "pr": 1742, "subject": "fix: defensive copy in get_reviewer_agent and get_chat_agent [closes #1584] (#1742)", "disposition": "landed", "reason": "Landed via fork PR #206 (cherry-pick, authors reset). Two fork-only reviewer tests that relied on the pre-fix in-place config mutation updated to assert against the agent-bound config.", "branch": "feature/port-upstream-clean-batch", "local_sha": "ead210927dc9145150a5faaf36ece5ecaefa66c7", "updated": "2026-07-17T21:31:43Z"}
|
||||
{"sha": "c34e04f4", "pr": 1742, "subject": "fix: defensive copy in get_reviewer_agent and get_chat_agent [closes #1584] (#1742)", "disposition": "deferred", "reason": "defensive copy of config in get_reviewer_agent/get_chat_agent; small correctness fix; dev has chat.py + reviewer.py (diverged) — likely near-clean pick. Re-triage (post-reorg 8356eb34 landed): path-remap blocker removed — clean pick; reviewer.py/chat.py factories unmoved.", "branch": "small-tools", "local_sha": null, "updated": "2026-07-17T00:00:00Z"}
|
||||
{"sha": "4773b336", "pr": 1746, "subject": "chore: point basedpyright at uv's .venv (#1746)", "disposition": "wont-merge", "reason": "tooling — fork does not use basedpyright (lint stack is ruff); nothing to point at .venv", "branch": "", "local_sha": null, "updated": "2026-07-16T18:46:21Z"}
|
||||
{"sha": "79df6b2f", "pr": 1748, "subject": "feat: add Linear issue search tool (#1748)", "disposition": "landed", "reason": "Landed via fork PR #206. Python auto-merged; fork's CLAUDE.md/AGENTS.md/README kept their own tool-list style with linear_search_issues inserted in place.", "branch": "feature/port-upstream-clean-batch", "local_sha": "bfb36be948d6d451a29e8e96f006f99d79dd84b5", "updated": "2026-07-17T21:31:43Z"}
|
||||
{"sha": "79df6b2f", "pr": 1748, "subject": "feat: add Linear issue search tool (#1748)", "disposition": "deferred", "reason": "additive linear_search_issues tool + utils/linear.py search helper; fork ships Linear tools — straightforward port; post-reorg test path remap. Re-triage (post-reorg 8356eb34 landed): test-path blocker removed — clean pick; tests/<domain>/ now matches upstream.", "branch": "small-tools", "local_sha": null, "updated": "2026-07-17T00:00:00Z"}
|
||||
{"sha": "b7c5dbd6", "pr": 1747, "subject": "fix: simplify Slack run links (#1747)", "disposition": "deferred", "reason": "simplifies Slack run links; heavy churn on fork-diverged Slack context/prompt tests", "branch": "slack-tooling", "local_sha": null, "updated": "2026-07-16T18:46:52Z"}
|
||||
{"sha": "c69459ad", "pr": 1751, "subject": "fix: prefer LangSmith tools for trace links (#1751)", "disposition": "landed", "reason": "1-line prompt: prefer LangSmith tools for trace links; trivial but edits fork-customized prompt.py", "branch": "chore/upstream-easy-picks", "local_sha": null, "updated": "2026-07-16T19:15:42Z"}
|
||||
{"sha": "5cb2e2bb", "pr": 1750, "subject": "fix: add trace link to error banner (#1750)", "disposition": "landed", "reason": "adds trace link to error banner (13 lines); remap AgentThreadView.tsx path (fork: ui/src/components/agents/)", "branch": "chore/upstream-easy-picks", "local_sha": null, "updated": "2026-07-16T19:15:42Z"}
|
||||
{"sha": "697adaa7", "pr": 1752, "subject": "fix: update PyJWT to 2.13.0 (#1752)", "disposition": "wont-merge", "reason": "already in dev — uv.lock resolves PyJWT 2.13.0", "branch": "", "local_sha": null, "updated": "2026-07-16T18:46:21Z"}
|
||||
{"sha": "22383033", "pr": 1758, "subject": "feat: inject extra JSON fields into sandbox create via env var (#1758)", "disposition": "landed", "reason": "Landed via fork PR #206, re-implemented against the fork's sync SandboxClient (upstream is async AsyncSandboxClient — retry/reconnect helpers not adopted; future picks touching them will conflict).", "branch": "feature/port-upstream-clean-batch", "local_sha": "6d34c42578c91c7697ef837453470607ca2e1e8f", "updated": "2026-07-17T21:31:43Z"}
|
||||
{"sha": "22383033", "pr": 1758, "subject": "feat: inject extra JSON fields into sandbox create via env var (#1758)", "disposition": "deferred", "reason": "additive: extra JSON fields into sandbox create via env var (integrations/langsmith.py); fork langsmith.py diverged (proxy config) — small reconcile. Re-triage (post-reorg 8356eb34 landed): near-clean — path-remap tax gone; remaining work is the additive reconcile against fork's diverged langsmith.py (integrations/ unmoved by reorg).", "branch": "sandbox-config", "local_sha": null, "updated": "2026-07-17T00:00:00Z"}
|
||||
{"sha": "714ea4a2", "pr": 1759, "subject": "fix: clear basedpyright standard-mode type errors (#1759)", "disposition": "wont-merge", "reason": "tooling — basedpyright type-error cleanup across 123 post-reorg files; fork uses ruff and the pre-reorg layout", "branch": "", "local_sha": null, "updated": "2026-07-16T18:46:21Z"}
|
||||
{"sha": "e826864d", "pr": 1760, "subject": "feat: optional separate LangSmith key/endpoint for sandboxes (#1760)", "disposition": "landed", "reason": "Landed via fork PR #206 on the sync client. Sandbox endpoint honors SANDBOX_LANGSMITH_ENDPOINT/LANGSMITH_ENDPOINT (LANGCHAIN_ENDPOINT alone no longer applies); new sandbox names thread-deterministic with _release_sandbox_name before create.", "branch": "feature/port-upstream-clean-batch", "local_sha": "a2794118ff6b8ffe9463888fd609b47a83c5cfd6", "updated": "2026-07-17T21:31:43Z"}
|
||||
{"sha": "dd5b7bec", "pr": 1761, "subject": "fix: capitalize dashboard tool labels (#1761)", "disposition": "landed", "reason": "Landed via fork PR #206 (clean cherry-pick, stacked on #1732).", "branch": "feature/port-upstream-clean-batch", "local_sha": "3b3d45cd949f1136beb1a9d5c8a55a0ee7ea9d31", "updated": "2026-07-17T21:31:43Z"}
|
||||
{"sha": "e826864d", "pr": 1760, "subject": "feat: optional separate LangSmith key/endpoint for sandboxes (#1760)", "disposition": "deferred", "reason": "optional separate LangSmith key/endpoint for sandboxes; additive to langsmith.py + proxy auth; useful for fork LangSmith sandbox usage. Re-triage (post-reorg 8356eb34 landed): near-clean — path-remap tax gone; remaining work is the additive reconcile against fork's diverged langsmith.py + proxy auth (integrations/ unmoved).", "branch": "sandbox-config", "local_sha": null, "updated": "2026-07-17T00:00:00Z"}
|
||||
{"sha": "dd5b7bec", "pr": 1761, "subject": "fix: capitalize dashboard tool labels (#1761)", "disposition": "deferred", "reason": "capitalize dashboard tool labels; post-reorg UI paths; stacks on #1732 (d714586c). Re-triage (post-reorg 8356eb34 landed): path-remap blocker removed — clean pick after #1732.", "branch": "dashboard-ui", "local_sha": null, "updated": "2026-07-17T00:00:00Z"}
|
||||
{"sha": "dccf6437", "pr": 1769, "subject": "fix: tighten sandbox config test types (#1769)", "disposition": "wont-merge", "reason": "test-only change in post-reorg path tests/sandbox/ — fork doesn't have this file (domain reorg #1726 deferred)", "branch": "", "local_sha": null, "updated": "2026-07-17T12:42:37Z"}
|
||||
{"sha": "c9a193e2", "pr": 1766, "subject": "chore(deps): bump mcp from 1.27.2 to 1.28.1 (#1766)", "disposition": "wont-merge", "reason": "indirect dependency bump (mcp); fork's own Dependabot handles these", "branch": "", "local_sha": null, "updated": "2026-07-17T12:42:37Z"}
|
||||
|
|
|
|||
|
|
@ -75,16 +75,8 @@ Rows key on the **upstream SHA** (stable across local cherry-picks). Deferred ro
|
|||
| `129ddcf9` | #1728 | chore: include ripgrep in sandbox image (#1728) | Landed | 1-line Dockerfile add (ripgrep); dev image lacks it; trivial pick | chore/upstream-easy-picks |
|
||||
| `1ea03a43` | #1729 | feat: include Cargo in sandbox image (#1729) | Landed | 2-line Dockerfile add (Cargo); adopt with #1728 | chore/upstream-easy-picks |
|
||||
| `09eaf94c` | #1730 | fix: let admins interrupt runaway agents (#1730) | Landed | admin interrupt for runaway agents (dashboard route + UI); useful ops control; UI half on post-reorg ui/src/features — remap to ui/src/components/agents | feat/admin-thread-interrupt |
|
||||
| `1ea0e600` | #1736 | fix: bind cached GitHub tokens to users (#1736) | Landed | Landed via fork PR #207 after both gates passed (GPT-4.1 cross-family: no BLOCK; /sh-security-review: 0 confirmed critical/high, 1 accepted low CWE-613). Composed with the fork's repo-binding: cache key (thread_id, principal), values keep bound_repo; unprincipaled user tokens refused. Added bot-fallback + cross-repo composition tests. | bug/bind-cached-github-tokens |
|
||||
| `d714586c` | #1732 | fix: normalize dashboard label rendering (#1732) | Landed | Landed via fork PR #206 (clean cherry-pick). | feature/port-upstream-clean-batch |
|
||||
| `3e8089c3` | #1744 | fix: collapse git panel by default (#1744) | Landed | Landed via fork PR #206. One import-context conflict in AgentThreadView.tsx resolved (upstream moved panel prefs to ui/src/features/agents/lib/gitPanelPreferences.ts). | feature/port-upstream-clean-batch |
|
||||
| `c34e04f4` | #1742 | fix: defensive copy in get_reviewer_agent and get_chat_agent [closes #1584] (#1742) | Landed | Landed via fork PR #206 (cherry-pick, authors reset). Two fork-only reviewer tests that relied on the pre-fix in-place config mutation updated to assert against the agent-bound config. | feature/port-upstream-clean-batch |
|
||||
| `79df6b2f` | #1748 | feat: add Linear issue search tool (#1748) | Landed | Landed via fork PR #206. Python auto-merged; fork's CLAUDE.md/AGENTS.md/README kept their own tool-list style with linear_search_issues inserted in place. | feature/port-upstream-clean-batch |
|
||||
| `c69459ad` | #1751 | fix: prefer LangSmith tools for trace links (#1751) | Landed | 1-line prompt: prefer LangSmith tools for trace links; trivial but edits fork-customized prompt.py | chore/upstream-easy-picks |
|
||||
| `5cb2e2bb` | #1750 | fix: add trace link to error banner (#1750) | Landed | adds trace link to error banner (13 lines); remap AgentThreadView.tsx path (fork: ui/src/components/agents/) | chore/upstream-easy-picks |
|
||||
| `22383033` | #1758 | feat: inject extra JSON fields into sandbox create via env var (#1758) | Landed | Landed via fork PR #206, re-implemented against the fork's sync SandboxClient (upstream is async AsyncSandboxClient — retry/reconnect helpers not adopted; future picks touching them will conflict). | feature/port-upstream-clean-batch |
|
||||
| `e826864d` | #1760 | feat: optional separate LangSmith key/endpoint for sandboxes (#1760) | Landed | Landed via fork PR #206 on the sync client. Sandbox endpoint honors SANDBOX_LANGSMITH_ENDPOINT/LANGSMITH_ENDPOINT (LANGCHAIN_ENDPOINT alone no longer applies); new sandbox names thread-deterministic with _release_sandbox_name before create. | feature/port-upstream-clean-batch |
|
||||
| `dd5b7bec` | #1761 | fix: capitalize dashboard tool labels (#1761) | Landed | Landed via fork PR #206 (clean cherry-pick, stacked on #1732). | feature/port-upstream-clean-batch |
|
||||
| `c3292d82` | #1611 | bake sfw binary into sandbox image | Won't merge | already in dev | |
|
||||
| `48bf712b` | #1609 | show message timestamps | Won't merge | already in dev | |
|
||||
| `85c0f63e` | #1620 | clickable shared PR header | Won't merge | already in dev | |
|
||||
|
|
@ -126,13 +118,21 @@ Rows key on the **upstream SHA** (stable across local cherry-picks). Deferred ro
|
|||
| `83abea26` | #1724 | fix: accept natural-language Slack plan approvals (#1724) | Deferred | natural-language Slack plan approvals; touches fork-diverged plan-mode + Slack webhook stack (#130); post-reorg test paths need remap | plan-approval |
|
||||
| `ddbe457b` | #1727 | fix: restore GPT-5.5 as default model (#1727) | Deferred | restores GPT-5.5 default in options/team_settings; fork picker is Bedrock/Fireworks-only — rides the #1708 OpenAI-models product decision (27b0ddeb) | model-picker |
|
||||
| `30832d29` | #1731 | fix: preserve OpenAI Responses tool history (#1731) | Deferred | deletes SanitizeOpenAIResponsesMiddleware in favor of replay-history preservation in utils/model.py; supersedes deferred #1718 (35659177) — triage the pair together against fork-diverged middleware + model.py | openai-sanitize |
|
||||
| `1ea0e600` | #1736 | fix: bind cached GitHub tokens to users (#1736) | Deferred | FLAG-HUMAN: security fix — binds per-thread cached GitHub tokens to a user principal (closes cross-user token-reuse leak). Fork has github_token.py but not webhooks/common.py — hand-map; auth surface: GPT-4.1 cross-review + /sh-security-review on landing. Priority pick. Re-triage (post-reorg 8356eb34 landed): biggest beneficiary — agent/webhooks/common.py now exists, so the pick is near-clean against it; auth gates (GPT-4.1 cross-review + /sh-security-review) still apply. | github-token-binding |
|
||||
| `3fcb27ce` | #1737 | fix: bound reviewer diff fetching (#1737) | Deferred | bounds reviewer diff fetching + new fetch_review_diff tool; reviewer.py fork-diverged — reconcile like #1713 (71e3b818) | reviewer-misc |
|
||||
| `d714586c` | #1732 | fix: normalize dashboard label rendering (#1732) | Deferred | dashboard tool-label normalization; post-reorg ui/src/features paths — remap. Re-triage (post-reorg 8356eb34 landed): path-remap blocker removed — clean pick; ui/src/features/ now matches upstream. | dashboard-ui |
|
||||
| `ef68c09b` | #1734 | fix: handle Slack DMs as mentions (#1734) | Deferred | treat Slack DMs as mentions; touches fork-diverged Slack webhook + e2e harness; post-reorg test paths | slack-tooling |
|
||||
| `26828ff9` | #1745 | chore: upgrade deepagents to 0.7.0a7 (#1745) | Deferred | deepagents 0.6.12 -> 0.7.0a7 alpha + [tool.uv] override-dependencies to bypass sandbox integrations <0.7.0 bound; fork is built on create_deep_agent — dedicated validation (unit + e2e) before adopting an alpha | deps |
|
||||
| `ef0ed5af` | #1741 | fix: centralize SSRF-safe image fetches (#1741) | Deferred | security hardening — centralizes image fetches through url_safety.py (SSRF); fork has url_safety.py/multimodal.py (diverged) — hand-reconcile; untrusted-input surface: /sh-security-review on landing | ssrf-hardening |
|
||||
| `136d28e6` | #1733 | fix: disable todos by default (#1733) | Deferred | global write_todos/TodoListMiddleware default-off with OPEN_SWE_ENABLE_TODOS opt-in; edits fork-customized prompt.py — small hand-merge; supersedes #1725 | prompt-tweaks |
|
||||
| `3e8089c3` | #1744 | fix: collapse git panel by default (#1744) | Deferred | collapse git panel by default (localStorage pref); post-reorg UI paths — remap to ui/src/components/agents. Re-triage (post-reorg 8356eb34 landed): path-remap blocker removed — clean pick; ui/src/features/ now matches upstream. | dashboard-ui |
|
||||
| `5077e2c7` | #1735 | feat(open-swe): untagged two-party Slack replies + debounced interrupts (#1735) | Deferred | untagged two-party Slack replies + debounced interrupts (~620 LOC incl. e2e); rides fork-diverged Slack webhook stack; own branch + e2e validation | slack-untagged-replies |
|
||||
| `8b26819f` | #1719 | fix: offload web tool results to sandbox (#1719) | Deferred | offloads large web tool results to sandbox files; touches fork-relevant web_search/http tools; check interplay with fork sandbox lifecycle | tool-offloading |
|
||||
| `c34e04f4` | #1742 | fix: defensive copy in get_reviewer_agent and get_chat_agent [closes #1584] (#1742) | Deferred | defensive copy of config in get_reviewer_agent/get_chat_agent; small correctness fix; dev has chat.py + reviewer.py (diverged) — likely near-clean pick. Re-triage (post-reorg 8356eb34 landed): path-remap blocker removed — clean pick; reviewer.py/chat.py factories unmoved. | small-tools |
|
||||
| `79df6b2f` | #1748 | feat: add Linear issue search tool (#1748) | Deferred | additive linear_search_issues tool + utils/linear.py search helper; fork ships Linear tools — straightforward port; post-reorg test path remap. Re-triage (post-reorg 8356eb34 landed): test-path blocker removed — clean pick; tests/<domain>/ now matches upstream. | small-tools |
|
||||
| `b7c5dbd6` | #1747 | fix: simplify Slack run links (#1747) | Deferred | simplifies Slack run links; heavy churn on fork-diverged Slack context/prompt tests | slack-tooling |
|
||||
| `22383033` | #1758 | feat: inject extra JSON fields into sandbox create via env var (#1758) | Deferred | additive: extra JSON fields into sandbox create via env var (integrations/langsmith.py); fork langsmith.py diverged (proxy config) — small reconcile. Re-triage (post-reorg 8356eb34 landed): near-clean — path-remap tax gone; remaining work is the additive reconcile against fork's diverged langsmith.py (integrations/ unmoved by reorg). | sandbox-config |
|
||||
| `e826864d` | #1760 | feat: optional separate LangSmith key/endpoint for sandboxes (#1760) | Deferred | optional separate LangSmith key/endpoint for sandboxes; additive to langsmith.py + proxy auth; useful for fork LangSmith sandbox usage. Re-triage (post-reorg 8356eb34 landed): near-clean — path-remap tax gone; remaining work is the additive reconcile against fork's diverged langsmith.py + proxy auth (integrations/ unmoved). | sandbox-config |
|
||||
| `dd5b7bec` | #1761 | fix: capitalize dashboard tool labels (#1761) | Deferred | capitalize dashboard tool labels; post-reorg UI paths; stacks on #1732 (d714586c). Re-triage (post-reorg 8356eb34 landed): path-remap blocker removed — clean pick after #1732. | dashboard-ui |
|
||||
|
||||
_Maintenance: after a `git sync`, add new `dev..upstream/main` SHAs as **Untriaged** (edit `triage.jsonl`) and bump "Last synced". A successful `git cherry-pick -x` auto-moves the row to **Landed** via the `post-commit` journal + `make triage-reconcile`._
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ def _stub_dashboard_store(
|
|||
) -> None:
|
||||
from agent.dashboard import profiles
|
||||
|
||||
async def fake_get_from_thread(thread_id: str, **_kwargs):
|
||||
async def fake_get_from_thread(thread_id: str):
|
||||
return cached
|
||||
|
||||
async def fake_get_valid(login: str):
|
||||
|
|
|
|||
|
|
@ -50,86 +50,42 @@ def test_is_expired_treats_unparseable_as_not_expired() -> None:
|
|||
|
||||
def test_get_github_token_returns_none_for_expired_cache() -> None:
|
||||
past = (datetime.now(UTC) - timedelta(hours=1)).isoformat()
|
||||
github_token.cache_github_token_for_thread(
|
||||
"tid", "ghp_secret", expires_at=past, is_bot_token=True
|
||||
)
|
||||
github_token.cache_github_token_for_thread("tid", "ghp_secret", expires_at=past)
|
||||
assert github_token.get_github_token({"configurable": {"thread_id": "tid"}}) is None
|
||||
|
||||
|
||||
def test_get_github_token_returns_fresh_cached_token() -> None:
|
||||
future = (datetime.now(UTC) + timedelta(hours=1)).isoformat()
|
||||
github_token.cache_github_token_for_thread(
|
||||
"tid", "ghp_secret", expires_at=future, is_bot_token=True
|
||||
)
|
||||
github_token.cache_github_token_for_thread("tid", "ghp_secret", expires_at=future)
|
||||
assert github_token.get_github_token({"configurable": {"thread_id": "tid"}}) == "ghp_secret"
|
||||
|
||||
|
||||
def test_get_github_token_returns_cached_token_when_no_expires_at() -> None:
|
||||
github_token.cache_github_token_for_thread("tid", "ghp_secret", is_bot_token=True)
|
||||
github_token.cache_github_token_for_thread("tid", "ghp_secret")
|
||||
assert github_token.get_github_token({"configurable": {"thread_id": "tid"}}) == "ghp_secret"
|
||||
|
||||
|
||||
def test_user_token_cache_is_bound_to_github_login() -> None:
|
||||
principal = github_token.github_token_principal(login=" Alice ")
|
||||
github_token.cache_github_token_for_thread("shared-thread", "alice-token", principal=principal)
|
||||
|
||||
alice_config = {"configurable": {"thread_id": "shared-thread", "github_login": "ALICE"}}
|
||||
bob_config = {"configurable": {"thread_id": "shared-thread", "github_login": "bob"}}
|
||||
|
||||
assert github_token.get_github_token(alice_config) == "alice-token"
|
||||
assert github_token.get_github_token(bob_config) is None
|
||||
|
||||
|
||||
def test_unbound_user_token_is_not_cached() -> None:
|
||||
github_token.cache_github_token_for_thread("tid", "unbound-token")
|
||||
assert github_token._GITHUB_TOKEN_CACHE == {}
|
||||
|
||||
|
||||
def test_cached_bot_token_is_available_to_any_principal() -> None:
|
||||
github_token.cache_github_token_for_thread("tid", "bot-token", is_bot_token=True)
|
||||
config = {"configurable": {"thread_id": "tid", "github_login": "alice"}}
|
||||
assert github_token.get_github_token(config) == "bot-token"
|
||||
|
||||
|
||||
def test_refused_unbound_user_token_falls_back_to_cached_bot_token() -> None:
|
||||
github_token.cache_github_token_for_thread("tid", "bot-token", is_bot_token=True)
|
||||
github_token.cache_github_token_for_thread("tid", "unbound-user-token")
|
||||
|
||||
config = {"configurable": {"thread_id": "tid", "github_login": "alice"}}
|
||||
assert github_token.get_github_token(config) == "bot-token"
|
||||
assert list(github_token._GITHUB_TOKEN_CACHE) == [("tid", github_token._BOT_PRINCIPAL)]
|
||||
|
||||
|
||||
def test_cached_token_expires_after_max_ttl() -> None:
|
||||
"""A token with no/far expiry is still dropped once it's older than the 24h cap."""
|
||||
far_future = (datetime.now(UTC) + timedelta(days=30)).isoformat()
|
||||
old_cached_at = datetime.now(UTC) - timedelta(hours=25)
|
||||
github_token._GITHUB_TOKEN_CACHE[("tid", github_token._BOT_PRINCIPAL)] = (
|
||||
"ghp_secret",
|
||||
far_future,
|
||||
old_cached_at,
|
||||
None,
|
||||
)
|
||||
github_token._GITHUB_TOKEN_CACHE["tid"] = ("ghp_secret", far_future, old_cached_at, None)
|
||||
assert github_token.get_github_token({"configurable": {"thread_id": "tid"}}) is None
|
||||
|
||||
|
||||
def test_cache_write_sweeps_other_expired_entries() -> None:
|
||||
"""Writing one entry evicts unrelated entries that have passed their expiry."""
|
||||
past = (datetime.now(UTC) - timedelta(hours=1)).isoformat()
|
||||
github_token.cache_github_token_for_thread(
|
||||
"stale", "ghp_stale", expires_at=past, is_bot_token=True
|
||||
)
|
||||
github_token.cache_github_token_for_thread("fresh", "ghp_fresh", is_bot_token=True)
|
||||
assert ("stale", github_token._BOT_PRINCIPAL) not in github_token._GITHUB_TOKEN_CACHE
|
||||
assert ("fresh", github_token._BOT_PRINCIPAL) in github_token._GITHUB_TOKEN_CACHE
|
||||
github_token.cache_github_token_for_thread("stale", "ghp_stale", expires_at=past)
|
||||
github_token.cache_github_token_for_thread("fresh", "ghp_fresh")
|
||||
assert "stale" not in github_token._GITHUB_TOKEN_CACHE
|
||||
assert "fresh" in github_token._GITHUB_TOKEN_CACHE
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_github_token_from_thread_skips_expired() -> None:
|
||||
past = (datetime.now(UTC) - timedelta(hours=1)).isoformat()
|
||||
github_token.cache_github_token_for_thread(
|
||||
"tid", "ghp_revoked", expires_at=past, is_bot_token=True
|
||||
)
|
||||
github_token.cache_github_token_for_thread("tid", "ghp_revoked", expires_at=past)
|
||||
token, expires_at = await github_token.get_github_token_from_thread("tid")
|
||||
assert token is None
|
||||
assert expires_at is None
|
||||
|
|
@ -138,9 +94,7 @@ async def test_get_github_token_from_thread_skips_expired() -> None:
|
|||
@pytest.mark.asyncio
|
||||
async def test_get_github_token_from_thread_returns_fresh() -> None:
|
||||
future = (datetime.now(UTC) + timedelta(hours=1)).isoformat()
|
||||
github_token.cache_github_token_for_thread(
|
||||
"tid", "ghp_live", expires_at=future, is_bot_token=True
|
||||
)
|
||||
github_token.cache_github_token_for_thread("tid", "ghp_live", expires_at=future)
|
||||
token, expires_at = await github_token.get_github_token_from_thread("tid")
|
||||
assert token == "ghp_live"
|
||||
assert expires_at == future
|
||||
|
|
@ -148,7 +102,7 @@ async def test_get_github_token_from_thread_returns_fresh() -> None:
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalidate_cached_github_token_clears_cache() -> None:
|
||||
github_token.cache_github_token_for_thread("tid-42", "ghp_live", is_bot_token=True)
|
||||
github_token.cache_github_token_for_thread("tid-42", "ghp_live")
|
||||
await github_token.invalidate_cached_github_token("tid-42")
|
||||
token, expires_at = await github_token.get_github_token_from_thread("tid-42")
|
||||
assert token is None
|
||||
|
|
|
|||
|
|
@ -1,122 +0,0 @@
|
|||
"""Tests that get_reviewer_agent and get_chat_agent do not mutate the caller's config."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from langgraph.graph.state import RunnableConfig
|
||||
|
||||
|
||||
def _make_config(recursion_limit: int = 25) -> RunnableConfig:
|
||||
return {
|
||||
"configurable": {"thread_id": None},
|
||||
"recursion_limit": recursion_limit,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_reviewer_agent_does_not_mutate_caller_config() -> None:
|
||||
"""get_reviewer_agent must not overwrite the caller's recursion_limit."""
|
||||
from agent import reviewer
|
||||
|
||||
config = _make_config(recursion_limit=25)
|
||||
original_limit = config["recursion_limit"]
|
||||
|
||||
fake_pregel = MagicMock()
|
||||
fake_pregel.with_config = MagicMock(return_value=fake_pregel)
|
||||
|
||||
with patch("agent.reviewer.create_deep_agent", return_value=fake_pregel):
|
||||
await reviewer.get_reviewer_agent(config)
|
||||
|
||||
assert config["recursion_limit"] == original_limit, (
|
||||
f"get_reviewer_agent mutated caller's recursion_limit: "
|
||||
f"expected {original_limit}, got {config['recursion_limit']}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_reviewer_agent_applies_default_when_limit_unset() -> None:
|
||||
"""get_reviewer_agent should apply DEFAULT_RECURSION_LIMIT when the caller didn't set one."""
|
||||
from agent import reviewer
|
||||
|
||||
config: RunnableConfig = {"configurable": {"thread_id": None}}
|
||||
|
||||
fake_pregel = MagicMock()
|
||||
fake_pregel.with_config = MagicMock(return_value=fake_pregel)
|
||||
|
||||
with patch("agent.reviewer.create_deep_agent", return_value=fake_pregel):
|
||||
await reviewer.get_reviewer_agent(config)
|
||||
|
||||
assert "recursion_limit" not in config
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_chat_agent_does_not_mutate_caller_config() -> None:
|
||||
"""get_chat_agent must not overwrite the caller's recursion_limit."""
|
||||
from agent import chat
|
||||
|
||||
config = _make_config(recursion_limit=50)
|
||||
original_limit = config["recursion_limit"]
|
||||
|
||||
fake_pregel = MagicMock()
|
||||
fake_pregel.with_config = MagicMock(return_value=fake_pregel)
|
||||
|
||||
with patch("agent.chat.create_deep_agent", return_value=fake_pregel):
|
||||
await chat.get_chat_agent(config)
|
||||
|
||||
assert config["recursion_limit"] == original_limit, (
|
||||
f"get_chat_agent mutated caller's recursion_limit: "
|
||||
f"expected {original_limit}, got {config['recursion_limit']}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_chat_agent_applies_default_when_limit_unset() -> None:
|
||||
"""get_chat_agent should apply DEFAULT_RECURSION_LIMIT when the caller didn't set one."""
|
||||
from agent import chat
|
||||
|
||||
config: RunnableConfig = {"configurable": {"thread_id": None}}
|
||||
|
||||
fake_pregel = MagicMock()
|
||||
fake_pregel.with_config = MagicMock(return_value=fake_pregel)
|
||||
|
||||
with patch("agent.chat.create_deep_agent", return_value=fake_pregel):
|
||||
await chat.get_chat_agent(config)
|
||||
|
||||
assert "recursion_limit" not in config
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("module_name", "factory_name"),
|
||||
[("agent.reviewer", "get_reviewer_agent"), ("agent.chat", "get_chat_agent")],
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_factory_copies_config_dicts_but_preserves_runtime_objects(
|
||||
module_name: str, factory_name: str
|
||||
) -> None:
|
||||
"""Factory config isolation must preserve callback and configurable value identities."""
|
||||
module = __import__(module_name, fromlist=[factory_name])
|
||||
factory = getattr(module, factory_name)
|
||||
callback = object()
|
||||
configurable_value = object()
|
||||
callbacks = [callback]
|
||||
config: RunnableConfig = {
|
||||
"configurable": {"thread_id": None, "custom_key": configurable_value},
|
||||
"callbacks": callbacks,
|
||||
}
|
||||
|
||||
fake_pregel = MagicMock()
|
||||
fake_pregel.with_config = MagicMock(return_value=fake_pregel)
|
||||
|
||||
with patch(f"{module_name}.create_deep_agent", return_value=fake_pregel):
|
||||
await factory(config)
|
||||
|
||||
bound_config = fake_pregel.with_config.call_args.args[0]
|
||||
assert bound_config is not config
|
||||
assert bound_config["configurable"] is not config["configurable"]
|
||||
assert bound_config["configurable"]["custom_key"] is configurable_value
|
||||
assert bound_config["callbacks"] is callbacks
|
||||
assert bound_config["callbacks"][0] is callback
|
||||
assert "recursion_limit" not in config
|
||||
assert config["configurable"] == {"thread_id": None, "custom_key": configurable_value}
|
||||
|
|
@ -235,11 +235,7 @@ async def test_reviewer_resolves_app_installation_token_at_run_start() -> None:
|
|||
# from a cache the webhook handler populated in a different process.
|
||||
mock_app_token.assert_awaited_once_with(repositories=["repo"])
|
||||
mock_cache_token.assert_called_once_with(
|
||||
"reviewer-thread-id",
|
||||
"app-token",
|
||||
expires_at=None,
|
||||
repo={"owner": "acme", "name": "repo"},
|
||||
is_bot_token=True,
|
||||
"reviewer-thread-id", "app-token", expires_at=None, repo={"owner": "acme", "name": "repo"}
|
||||
)
|
||||
middleware = create_agent.call_args.kwargs["middleware"]
|
||||
assert reviewer.check_message_queue_before_model in middleware
|
||||
|
|
@ -1292,17 +1288,13 @@ async def test_reviewer_populates_diff_line_set_from_github_api() -> None:
|
|||
patch("agent.utils.deferred_model.make_model", return_value=MagicMock()),
|
||||
patch("agent.reviewer.create_deep_agent", side_effect=fake_create_deep_agent),
|
||||
):
|
||||
agent = await reviewer.get_reviewer_agent(config)
|
||||
await reviewer.get_reviewer_agent(config)
|
||||
|
||||
mock_fetch_diff.assert_awaited_once_with(
|
||||
owner="acme", repo="repo", pr_number=42, token="gh-token"
|
||||
)
|
||||
# get_reviewer_agent copies the caller's config before mutating it (it must not
|
||||
# mutate the caller's dict in place), so assert against the config actually
|
||||
# bound to the returned agent rather than the original input dict.
|
||||
bound_configurable = agent.config["configurable"]
|
||||
assert bound_configurable["diff_text"] == pr_diff
|
||||
assert bound_configurable["diff_line_set"] == {"in_diff.py": {"RIGHT": {10}, "LEFT": {1}}}
|
||||
assert config["configurable"]["diff_text"] == pr_diff
|
||||
assert config["configurable"]["diff_line_set"] == {"in_diff.py": {"RIGHT": {10}, "LEFT": {1}}}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -1361,14 +1353,10 @@ async def test_reviewer_leaves_validation_disabled_when_diff_fetch_fails() -> No
|
|||
patch("agent.utils.deferred_model.make_model", return_value=MagicMock()),
|
||||
patch("agent.reviewer.create_deep_agent", side_effect=fake_create_deep_agent),
|
||||
):
|
||||
agent = await reviewer.get_reviewer_agent(config)
|
||||
await reviewer.get_reviewer_agent(config)
|
||||
|
||||
# get_reviewer_agent copies the caller's config before mutating it (it must not
|
||||
# mutate the caller's dict in place), so assert against the config actually
|
||||
# bound to the returned agent rather than the original input dict.
|
||||
bound_configurable = agent.config["configurable"]
|
||||
assert bound_configurable["diff_text"] == ""
|
||||
assert bound_configurable["diff_line_set"] is None
|
||||
assert config["configurable"]["diff_text"] == ""
|
||||
assert config["configurable"]["diff_line_set"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
|
|||
|
|
@ -1,8 +1,6 @@
|
|||
"""Tests for LangSmith sandbox env-var configuration parsing."""
|
||||
|
||||
import base64
|
||||
import uuid
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
|
@ -13,62 +11,10 @@ from agent.integrations.langsmith import (
|
|||
DEFAULT_SANDBOX_VCPUS,
|
||||
DEFAULT_SNAPSHOT_FS_CAPACITY_BYTES,
|
||||
LangSmithProvider,
|
||||
_get_sandbox_api_endpoint,
|
||||
_get_sandbox_create_extra_fields,
|
||||
_get_sandbox_snapshot_config,
|
||||
_install_create_extra_fields,
|
||||
_release_sandbox_name,
|
||||
_sandbox_name_for_thread,
|
||||
)
|
||||
|
||||
|
||||
def test_sandbox_api_endpoint_appends_v2_sandboxes() -> None:
|
||||
with patch.dict("os.environ", {"LANGSMITH_ENDPOINT": "https://eu.smith.langchain.com"}):
|
||||
assert _get_sandbox_api_endpoint() == "https://eu.smith.langchain.com/v2/sandboxes"
|
||||
|
||||
|
||||
def test_sandbox_api_endpoint_no_double_suffix() -> None:
|
||||
with patch.dict(
|
||||
"os.environ",
|
||||
{"SANDBOX_LANGSMITH_ENDPOINT": "https://x.smith.langchain.com/v2/sandboxes"},
|
||||
):
|
||||
assert _get_sandbox_api_endpoint() == "https://x.smith.langchain.com/v2/sandboxes"
|
||||
|
||||
|
||||
def test_sandbox_name_for_thread_encodes_uuid() -> None:
|
||||
thread_id = "12345678-1234-5678-1234-567812345678"
|
||||
name = _sandbox_name_for_thread(thread_id)
|
||||
assert name is not None
|
||||
prefix, _, encoded = name.partition("-")
|
||||
assert prefix == "openswe"
|
||||
assert encoded == encoded.lower()
|
||||
assert "=" not in encoded and "-" not in encoded
|
||||
# Round-trips back to the original UUID.
|
||||
padded = encoded.upper() + "=" * (-len(encoded) % 8)
|
||||
assert uuid.UUID(bytes=base64.b32decode(padded)) == uuid.UUID(thread_id)
|
||||
|
||||
|
||||
def test_sandbox_name_for_thread_none_or_invalid() -> None:
|
||||
assert _sandbox_name_for_thread(None) is None
|
||||
assert _sandbox_name_for_thread("not-a-uuid") is None
|
||||
|
||||
|
||||
def test_release_sandbox_name_deletes_stale_box() -> None:
|
||||
client = MagicMock()
|
||||
_release_sandbox_name(client, "openswe-abc")
|
||||
client.delete_sandbox.assert_called_once_with("openswe-abc")
|
||||
|
||||
|
||||
def test_release_sandbox_name_swallows_missing_and_skips_none() -> None:
|
||||
client = MagicMock()
|
||||
client.delete_sandbox.side_effect = RuntimeError("not found")
|
||||
_release_sandbox_name(client, "openswe-abc") # must not raise
|
||||
|
||||
client.delete_sandbox.reset_mock(side_effect=True)
|
||||
_release_sandbox_name(client, None)
|
||||
client.delete_sandbox.assert_not_called()
|
||||
|
||||
|
||||
def test_defaults_when_env_unset() -> None:
|
||||
with patch.dict(
|
||||
"os.environ",
|
||||
|
|
@ -151,67 +97,3 @@ def test_validate_startup_accepts_valid_config() -> None:
|
|||
clear=True,
|
||||
):
|
||||
LangSmithProvider.validate_startup_config()
|
||||
|
||||
|
||||
def test_extra_fields_unset_is_empty() -> None:
|
||||
with patch.dict("os.environ", {}, clear=True):
|
||||
assert _get_sandbox_create_extra_fields() == {}
|
||||
with patch.dict("os.environ", {"SANDBOX_CREATE_EXTRA_JSON": " "}, clear=True):
|
||||
assert _get_sandbox_create_extra_fields() == {}
|
||||
|
||||
|
||||
def test_extra_fields_parsed() -> None:
|
||||
with patch.dict(
|
||||
"os.environ",
|
||||
{"SANDBOX_CREATE_EXTRA_JSON": '{"_internal_runtime": "v2"}'},
|
||||
clear=True,
|
||||
):
|
||||
assert _get_sandbox_create_extra_fields() == {"_internal_runtime": "v2"}
|
||||
|
||||
|
||||
def test_extra_fields_rejects_invalid_json() -> None:
|
||||
with patch.dict("os.environ", {"SANDBOX_CREATE_EXTRA_JSON": "{not json"}, clear=True):
|
||||
with pytest.raises(ValueError, match="valid JSON"):
|
||||
_get_sandbox_create_extra_fields()
|
||||
|
||||
|
||||
def test_extra_fields_rejects_non_object() -> None:
|
||||
with patch.dict("os.environ", {"SANDBOX_CREATE_EXTRA_JSON": "[1, 2]"}, clear=True):
|
||||
with pytest.raises(ValueError, match="JSON object"):
|
||||
_get_sandbox_create_extra_fields()
|
||||
|
||||
|
||||
def test_install_create_extra_fields_merges_only_boxes_post() -> None:
|
||||
calls: list[tuple[str, dict]] = []
|
||||
|
||||
class _FakeHttp:
|
||||
def post(self, url, **kwargs): # noqa: ANN001, ANN003
|
||||
calls.append((url, kwargs.get("json")))
|
||||
return "ok"
|
||||
|
||||
class _FakeClient:
|
||||
def __init__(self) -> None:
|
||||
self._http = _FakeHttp()
|
||||
|
||||
client = _FakeClient()
|
||||
_install_create_extra_fields(client, {"_internal_runtime": "v2"})
|
||||
|
||||
client._http.post("https://api/v2/sandboxes/boxes", json={"snapshot_id": "s"})
|
||||
client._http.post("https://api/v2/sandboxes/boxes/abc/start", json={"foo": "bar"})
|
||||
|
||||
assert calls[0][1] == {"snapshot_id": "s", "_internal_runtime": "v2"}
|
||||
assert calls[1][1] == {"foo": "bar"}
|
||||
|
||||
|
||||
def test_install_create_extra_fields_noop_when_empty() -> None:
|
||||
class _FakeHttp:
|
||||
def __init__(self) -> None:
|
||||
self.post = "sentinel"
|
||||
|
||||
class _FakeClient:
|
||||
def __init__(self) -> None:
|
||||
self._http = _FakeHttp()
|
||||
|
||||
client = _FakeClient()
|
||||
_install_create_extra_fields(client, {})
|
||||
assert client._http.post == "sentinel"
|
||||
|
|
|
|||
|
|
@ -125,35 +125,6 @@ class TestConfigureGithubProxy:
|
|||
headers = mock_client.patch.call_args.kwargs["headers"]
|
||||
assert headers == {"X-API-Key": "my-api-key"}
|
||||
|
||||
def test_sandbox_overrides_take_precedence(self) -> None:
|
||||
"""SANDBOX_LANGSMITH_* override the shared key/endpoint for the proxy call."""
|
||||
with (
|
||||
patch("agent.integrations.langsmith.httpx.Client") as mock_client_cls,
|
||||
patch.dict(
|
||||
"os.environ",
|
||||
{
|
||||
"LANGSMITH_API_KEY": "shared-key",
|
||||
"LANGSMITH_ENDPOINT": "https://shared.smith.langchain.com",
|
||||
"SANDBOX_LANGSMITH_API_KEY": "sandbox-key",
|
||||
"SANDBOX_LANGSMITH_ENDPOINT": "https://sandbox.smith.langchain.com",
|
||||
},
|
||||
),
|
||||
):
|
||||
mock_client = MagicMock()
|
||||
mock_response = MagicMock()
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
mock_client.patch.return_value = mock_response
|
||||
mock_client_cls.return_value.__enter__ = MagicMock(return_value=mock_client)
|
||||
mock_client_cls.return_value.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
_configure_github_proxy("sandbox-abc", "token")
|
||||
|
||||
assert (
|
||||
mock_client.patch.call_args.args[0]
|
||||
== "https://sandbox.smith.langchain.com/v2/sandboxes/boxes/sandbox-abc"
|
||||
)
|
||||
assert mock_client.patch.call_args.kwargs["headers"] == {"X-API-Key": "sandbox-key"}
|
||||
|
||||
def test_retries_transient_http_error(self) -> None:
|
||||
"""Transient proxy API errors should be retried on the same sandbox."""
|
||||
request = httpx.Request(
|
||||
|
|
|
|||
|
|
@ -28,50 +28,20 @@ def test_cached_token_not_reused_across_repos() -> None:
|
|||
"""A token bound to repo A is refused for a colliding thread_id from repo B."""
|
||||
future = (datetime.now(UTC) + timedelta(hours=1)).isoformat()
|
||||
github_token.cache_github_token_for_thread(
|
||||
"tid",
|
||||
"ghp_repoA",
|
||||
expires_at=future,
|
||||
repo={"owner": "acme", "name": "alpha"},
|
||||
is_bot_token=True,
|
||||
"tid", "ghp_repoA", expires_at=future, repo={"owner": "acme", "name": "alpha"}
|
||||
)
|
||||
|
||||
# Same thread_id, different repo → must NOT serve repo A's token.
|
||||
cfg_b = {"configurable": {"thread_id": "tid", "repo": {"owner": "evil", "name": "beta"}}}
|
||||
assert github_token.get_github_token(cfg_b) is None
|
||||
# And the poisoned entry is evicted.
|
||||
assert all(key[0] != "tid" for key in github_token._GITHUB_TOKEN_CACHE)
|
||||
|
||||
|
||||
def test_cached_user_token_not_reused_across_repos() -> None:
|
||||
"""Repo binding still applies to principal-bound user tokens."""
|
||||
future = (datetime.now(UTC) + timedelta(hours=1)).isoformat()
|
||||
github_token.cache_github_token_for_thread(
|
||||
"tid",
|
||||
"ghp_userA",
|
||||
expires_at=future,
|
||||
repo={"owner": "acme", "name": "alpha"},
|
||||
principal=github_token.github_token_principal(login="alice"),
|
||||
)
|
||||
|
||||
cfg_same_user_other_repo = {
|
||||
"configurable": {
|
||||
"thread_id": "tid",
|
||||
"github_login": "alice",
|
||||
"repo": {"owner": "evil", "name": "beta"},
|
||||
}
|
||||
}
|
||||
assert github_token.get_github_token(cfg_same_user_other_repo) is None
|
||||
assert all(key[0] != "tid" for key in github_token._GITHUB_TOKEN_CACHE)
|
||||
assert "tid" not in github_token._GITHUB_TOKEN_CACHE
|
||||
|
||||
|
||||
def test_cached_token_reused_for_same_repo() -> None:
|
||||
future = (datetime.now(UTC) + timedelta(hours=1)).isoformat()
|
||||
github_token.cache_github_token_for_thread(
|
||||
"tid",
|
||||
"ghp_repoA",
|
||||
expires_at=future,
|
||||
repo={"owner": "acme", "name": "alpha"},
|
||||
is_bot_token=True,
|
||||
"tid", "ghp_repoA", expires_at=future, repo={"owner": "acme", "name": "alpha"}
|
||||
)
|
||||
cfg_a = {"configurable": {"thread_id": "tid", "repo": {"owner": "acme", "name": "alpha"}}}
|
||||
assert github_token.get_github_token(cfg_a) == "ghp_repoA"
|
||||
|
|
@ -79,7 +49,7 @@ def test_cached_token_reused_for_same_repo() -> None:
|
|||
|
||||
def test_unbound_token_served_when_repo_unknown() -> None:
|
||||
"""Legacy entries with no bound repo still resolve when no repo is supplied."""
|
||||
github_token.cache_github_token_for_thread("tid", "ghp_legacy", is_bot_token=True)
|
||||
github_token.cache_github_token_for_thread("tid", "ghp_legacy")
|
||||
assert github_token.get_github_token({"configurable": {"thread_id": "tid"}}) == "ghp_legacy"
|
||||
|
||||
|
||||
|
|
@ -87,11 +57,7 @@ def test_cached_token_reused_for_same_repo_case_insensitive() -> None:
|
|||
"""``Org/Repo`` and ``org/repo`` are the same repo: no spurious refusal."""
|
||||
future = (datetime.now(UTC) + timedelta(hours=1)).isoformat()
|
||||
github_token.cache_github_token_for_thread(
|
||||
"tid",
|
||||
"ghp_repoA",
|
||||
expires_at=future,
|
||||
repo={"owner": "Acme", "name": "Alpha"},
|
||||
is_bot_token=True,
|
||||
"tid", "ghp_repoA", expires_at=future, repo={"owner": "Acme", "name": "Alpha"}
|
||||
)
|
||||
cfg = {"configurable": {"thread_id": "tid", "repo": {"owner": "acme", "name": "alpha"}}}
|
||||
assert github_token.get_github_token(cfg) == "ghp_repoA"
|
||||
|
|
|
|||
|
|
@ -1,131 +0,0 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from agent.utils import linear
|
||||
|
||||
linear_search_tool = importlib.import_module("agent.tools.linear_search_issues")
|
||||
|
||||
|
||||
async def test_search_issues_returns_results_and_pagination(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
async def fake_graphql_request(
|
||||
query: str, variables: dict[str, Any] | None = None
|
||||
) -> dict[str, Any]:
|
||||
captured.update({"query": query, "variables": variables})
|
||||
return {
|
||||
"searchIssues": {
|
||||
"nodes": [
|
||||
{
|
||||
"id": "issue-id",
|
||||
"identifier": "DCD-20",
|
||||
"title": "User-message styling improvement",
|
||||
}
|
||||
],
|
||||
"totalCount": 12,
|
||||
"pageInfo": {"hasNextPage": True, "endCursor": "next-page"},
|
||||
}
|
||||
}
|
||||
|
||||
monkeypatch.setattr(linear, "_graphql_request", fake_graphql_request)
|
||||
|
||||
result = await linear.search_issues(
|
||||
" user message styling ",
|
||||
team_id="team-id",
|
||||
limit=5,
|
||||
include_archived=True,
|
||||
include_comments=True,
|
||||
after="current-page",
|
||||
)
|
||||
|
||||
assert "searchIssues" in captured["query"]
|
||||
assert captured["variables"] == {
|
||||
"query": "user message styling",
|
||||
"filter": {"team": {"id": {"eq": "team-id"}}},
|
||||
"limit": 5,
|
||||
"includeArchived": True,
|
||||
"includeComments": True,
|
||||
"after": "current-page",
|
||||
}
|
||||
assert result == {
|
||||
"issues": [
|
||||
{
|
||||
"id": "issue-id",
|
||||
"identifier": "DCD-20",
|
||||
"title": "User-message styling improvement",
|
||||
}
|
||||
],
|
||||
"total_count": 12,
|
||||
"page_info": {"hasNextPage": True, "endCursor": "next-page"},
|
||||
}
|
||||
|
||||
|
||||
async def test_search_issues_rejects_blank_query(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
async def unexpected_request(*_args: Any, **_kwargs: Any) -> dict[str, Any]:
|
||||
pytest.fail("GraphQL request should not be made")
|
||||
|
||||
monkeypatch.setattr(linear, "_graphql_request", unexpected_request)
|
||||
|
||||
result = await linear.search_issues(" ")
|
||||
|
||||
assert result == {"error": "Search query must not be empty"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("limit", [0, 51])
|
||||
async def test_search_issues_rejects_invalid_limit(
|
||||
monkeypatch: pytest.MonkeyPatch, limit: int
|
||||
) -> None:
|
||||
async def unexpected_request(*_args: Any, **_kwargs: Any) -> dict[str, Any]:
|
||||
pytest.fail("GraphQL request should not be made")
|
||||
|
||||
monkeypatch.setattr(linear, "_graphql_request", unexpected_request)
|
||||
|
||||
result = await linear.search_issues("styling", limit=limit)
|
||||
|
||||
assert result == {"error": "Search limit must be between 1 and 50"}
|
||||
|
||||
|
||||
async def test_search_issues_propagates_graphql_errors(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
async def fake_graphql_request(
|
||||
_query: str, _variables: dict[str, Any] | None = None
|
||||
) -> dict[str, Any]:
|
||||
return {"error": "rate limited"}
|
||||
|
||||
monkeypatch.setattr(linear, "_graphql_request", fake_graphql_request)
|
||||
|
||||
assert await linear.search_issues("styling") == {"error": "rate limited"}
|
||||
|
||||
|
||||
async def test_linear_search_issues_tool_delegates(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
async def fake_search_issues(**kwargs: Any) -> dict[str, Any]:
|
||||
captured.update(kwargs)
|
||||
return {"issues": []}
|
||||
|
||||
monkeypatch.setattr(linear_search_tool, "search_issues", fake_search_issues)
|
||||
|
||||
result = await linear_search_tool.linear_search_issues(
|
||||
"styling",
|
||||
team_id="team-id",
|
||||
limit=20,
|
||||
include_archived=True,
|
||||
include_comments=True,
|
||||
after="cursor",
|
||||
)
|
||||
|
||||
assert result == {"issues": []}
|
||||
assert captured == {
|
||||
"query": "styling",
|
||||
"team_id": "team-id",
|
||||
"limit": 20,
|
||||
"include_archived": True,
|
||||
"include_comments": True,
|
||||
"after": "cursor",
|
||||
}
|
||||
|
|
@ -88,6 +88,9 @@ function commonDirPrefix(paths: Array<string>): string {
|
|||
}
|
||||
|
||||
const PANEL_STORAGE_WIDTH = "open-swe.gitpanel.width"
|
||||
const PANEL_STORAGE_COLLAPSED = "open-swe.gitpanel.collapsed"
|
||||
const COLLAPSED_STATE_TRUE = "1"
|
||||
const COLLAPSED_STATE_FALSE = "0"
|
||||
const PANEL_DEFAULT_WIDTH = 420
|
||||
const PANEL_MIN_WIDTH = 320
|
||||
// Keep at least this much room for the chat so the panel can grow to nearly the
|
||||
|
|
@ -116,6 +119,23 @@ function readStoredPanelWidth(): number {
|
|||
return clampPanelWidth(parsed)
|
||||
}
|
||||
|
||||
export function readStoredPanelCollapsed(): boolean {
|
||||
if (typeof window === "undefined") return true
|
||||
// Default to collapsed until the user opens it once.
|
||||
return (
|
||||
window.localStorage.getItem(PANEL_STORAGE_COLLAPSED) !==
|
||||
COLLAPSED_STATE_FALSE
|
||||
)
|
||||
}
|
||||
|
||||
export function writeStoredPanelCollapsed(collapsed: boolean): void {
|
||||
if (typeof window === "undefined") return
|
||||
window.localStorage.setItem(
|
||||
PANEL_STORAGE_COLLAPSED,
|
||||
collapsed ? COLLAPSED_STATE_TRUE : COLLAPSED_STATE_FALSE
|
||||
)
|
||||
}
|
||||
|
||||
function PanelResizeHandle({
|
||||
width,
|
||||
onResize,
|
||||
|
|
|
|||
|
|
@ -12,12 +12,10 @@ import type { ModelSelection } from "@/features/agents/lib/provider/useModelOpti
|
|||
import {
|
||||
AgentGitPanel,
|
||||
PANEL_MIN_CHAT_WIDTH,
|
||||
} from "@/features/agents/components/AgentGitPanel"
|
||||
import { AgentPromptBar } from "@/features/agents/components/AgentPromptBar"
|
||||
import {
|
||||
readStoredPanelCollapsed,
|
||||
writeStoredPanelCollapsed,
|
||||
} from "@/features/agents/lib/gitPanelPreferences"
|
||||
} from "@/features/agents/components/AgentGitPanel"
|
||||
import { AgentPromptBar } from "@/features/agents/components/AgentPromptBar"
|
||||
import { Messages } from "@/features/agents/components/messages"
|
||||
import { streamMessagesToUi } from "@/features/agents/lib/streamMessagesToUi"
|
||||
import { messageArrivalTimestamp } from "@/features/agents/lib/messageTimestamps"
|
||||
|
|
|
|||
|
|
@ -61,10 +61,6 @@ function triToBool(value: TriState): boolean | undefined {
|
|||
return undefined
|
||||
}
|
||||
|
||||
function displayStatus(status: AgentStatus): string {
|
||||
return STATUS_OPTIONS.find((option) => option.value === status)?.label ?? status
|
||||
}
|
||||
|
||||
export function AgentsThreadsPage({
|
||||
filters,
|
||||
onFiltersChange,
|
||||
|
|
@ -283,8 +279,8 @@ function ThreadListItem({ thread }: { thread: AgentThread }) {
|
|||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm text-[var(--ui-text)]">{thread.title}</p>
|
||||
<p className="truncate text-[11px] text-[var(--ui-text-dim)]">
|
||||
{thread.repoFullName || "No repo"} · {displayStatus(thread.status)}
|
||||
{isResolved ? " · Resolved" : ""}
|
||||
{thread.repoFullName || "no repo"} · {thread.status}
|
||||
{isResolved ? " · resolved" : ""}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -75,11 +75,11 @@ export function WorkflowApprovalCard({
|
|||
</div>
|
||||
<p className="text-xs text-[var(--ui-text-dim)]">
|
||||
{approval.repo || "Repository"} on{" "}
|
||||
{approval.branch || "Current branch"} ·{" "}
|
||||
{approval.branch || "current branch"} ·{" "}
|
||||
{shortSha(approval.baseSha)} → {shortSha(approval.headSha)}
|
||||
</p>
|
||||
<p className="font-mono text-[0.68rem] break-all text-[var(--ui-text-dim)]">
|
||||
Fingerprint: {approval.fingerprint}
|
||||
fingerprint: {approval.fingerprint}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-wrap gap-2">
|
||||
|
|
|
|||
|
|
@ -393,7 +393,7 @@ export const CloudPromptBar = memo(function CloudPromptBarComponent({
|
|||
>
|
||||
<img
|
||||
src={`data:${image.mimeType};base64,${image.base64}`}
|
||||
alt={image.fileName || "Pending image"}
|
||||
alt={image.fileName || "pending image"}
|
||||
className="size-16 rounded-lg border border-[var(--ui-border)] object-cover"
|
||||
/>
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -1,49 +0,0 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { formatToolDisplay } from "./toolExecutionDisplay";
|
||||
|
||||
describe("formatToolDisplay", () => {
|
||||
const projectPath = "/workspace/open-swe";
|
||||
|
||||
it("renders read_file with the file_path alias consistently", () => {
|
||||
expect(
|
||||
formatToolDisplay(
|
||||
"read_file /workspace/open-swe/AGENTS.md",
|
||||
"read",
|
||||
{ file_path: "/workspace/open-swe/AGENTS.md" },
|
||||
projectPath,
|
||||
),
|
||||
).toBe("Read AGENTS.md");
|
||||
});
|
||||
|
||||
it("renders ls as a list operation with a relative path", () => {
|
||||
expect(
|
||||
formatToolDisplay(
|
||||
"ls /workspace/open-swe/ui",
|
||||
"read",
|
||||
{ path: "/workspace/open-swe/ui" },
|
||||
projectPath,
|
||||
),
|
||||
).toBe("List ui");
|
||||
});
|
||||
|
||||
it("renders search tools with their pattern", () => {
|
||||
expect(
|
||||
formatToolDisplay("grep", "search", { pattern: "tool_calls" }, projectPath),
|
||||
).toBe('Search "tool_calls"');
|
||||
});
|
||||
|
||||
it("normalizes write_todos", () => {
|
||||
expect(formatToolDisplay("write todos", "other", {}, projectPath)).toBe("Update todos");
|
||||
});
|
||||
|
||||
it("sentence-cases raw tool names", () => {
|
||||
expect(formatToolDisplay("enter_plan_mode", "other", {}, projectPath)).toBe(
|
||||
"Enter plan mode",
|
||||
);
|
||||
expect(formatToolDisplay("save_plan", "other", {}, projectPath)).toBe("Save plan");
|
||||
expect(formatToolDisplay("slack_thread_reply", "other", {}, projectPath)).toBe(
|
||||
"Slack thread reply",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,8 +1,7 @@
|
|||
import { memo, useCallback, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { MultiFileDiff } from "@pierre/diffs/react";
|
||||
import { DiffView } from "./DiffView";
|
||||
import { formatToolDisplay } from "./toolExecutionDisplay";
|
||||
import type { ToolExecutionChunk } from "@/features/agents/lib/types";
|
||||
import type { AcpToolKind, ToolExecutionChunk } from "@/features/agents/lib/types";
|
||||
import { useDiffOptions } from "@/features/agents/utils/diffUtils";
|
||||
import { countLineChanges } from "@/features/agents/utils/diffStats";
|
||||
|
||||
|
|
@ -28,6 +27,60 @@ function getFileName(path: string): string {
|
|||
return parts[parts.length - 1] || path;
|
||||
}
|
||||
|
||||
function formatToolDisplay(
|
||||
title: string,
|
||||
toolKind: AcpToolKind,
|
||||
input: Record<string, unknown> | undefined,
|
||||
projectPath?: string,
|
||||
): string {
|
||||
const path = input?.path as string | undefined;
|
||||
const pattern = input?.pattern as string | undefined;
|
||||
const query = input?.query as string | undefined;
|
||||
const url = input?.url as string | undefined;
|
||||
const command = input?.command as string | undefined;
|
||||
|
||||
switch (toolKind) {
|
||||
case "read": {
|
||||
if (path) {
|
||||
const displayPath = stripProjectPath(path, projectPath);
|
||||
return `Read(${displayPath})`;
|
||||
}
|
||||
return title;
|
||||
}
|
||||
case "search": {
|
||||
if (pattern) {
|
||||
const truncated = pattern.length > 40 ? pattern.slice(0, 40) + "..." : pattern;
|
||||
return `Search("${truncated}")`;
|
||||
}
|
||||
if (query) {
|
||||
return `Search("${query.slice(0, 40)}${query.length > 40 ? "..." : ""}")`;
|
||||
}
|
||||
return title;
|
||||
}
|
||||
case "fetch": {
|
||||
if (url) {
|
||||
return `Fetch(${url.slice(0, 50)}${url.length > 50 ? "..." : ""})`;
|
||||
}
|
||||
return title;
|
||||
}
|
||||
case "execute": {
|
||||
if (command) {
|
||||
const truncated = command.length > 60 ? command.slice(0, 60) + "..." : command;
|
||||
return `Shell(${truncated})`;
|
||||
}
|
||||
return title;
|
||||
}
|
||||
case "edit":
|
||||
case "delete":
|
||||
case "move":
|
||||
return title;
|
||||
case "think":
|
||||
return "Thinking...";
|
||||
default:
|
||||
return title;
|
||||
}
|
||||
}
|
||||
|
||||
const InlineDiffCollapsible = memo(function InlineDiffCollapsible({
|
||||
filePath,
|
||||
fileName,
|
||||
|
|
|
|||
|
|
@ -1,91 +0,0 @@
|
|||
import { humanizeToolName } from "@/features/agents/lib/toolNames";
|
||||
import type { AcpToolKind } from "@/features/agents/lib/types";
|
||||
|
||||
function stripProjectPath(path: string, projectPath?: string): string {
|
||||
if (!projectPath || !path.startsWith(projectPath)) return path;
|
||||
const relative = path.slice(projectPath.length);
|
||||
return relative.replace(/^\/+/, "") || ".";
|
||||
}
|
||||
|
||||
function firstStringArg(
|
||||
input: Record<string, unknown> | undefined,
|
||||
keys: Array<string>,
|
||||
): string | undefined {
|
||||
if (!input) return undefined;
|
||||
for (const key of keys) {
|
||||
const value = input[key];
|
||||
if (typeof value === "string" && value.trim()) return value.trim();
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function truncateMiddle(value: string, maxLength: number): string {
|
||||
return value.length > maxLength ? `${value.slice(0, maxLength)}...` : value;
|
||||
}
|
||||
|
||||
function normalizedToolName(title: string): string {
|
||||
return title.trim().split(/\s+/, 1)[0]?.toLowerCase() ?? "";
|
||||
}
|
||||
|
||||
function humanizeToolTitle(title: string): string {
|
||||
const trimmed = title.trim();
|
||||
if (!trimmed) return "Tool";
|
||||
|
||||
const [name, ...rest] = trimmed.split(/\s+/);
|
||||
const suffix = rest.join(" ");
|
||||
if (name && suffix && /^(?:[./~]|[a-z]+:\/\/)/i.test(suffix)) {
|
||||
return `${humanizeToolName(name)} ${suffix}`;
|
||||
}
|
||||
return humanizeToolName(trimmed);
|
||||
}
|
||||
|
||||
export function formatToolDisplay(
|
||||
title: string,
|
||||
toolKind: AcpToolKind,
|
||||
input: Record<string, unknown> | undefined,
|
||||
projectPath?: string,
|
||||
): string {
|
||||
const toolName = normalizedToolName(title);
|
||||
const path = firstStringArg(input, ["path", "file_path", "target_file"]);
|
||||
const pattern = firstStringArg(input, ["pattern"]);
|
||||
const query = firstStringArg(input, ["query"]);
|
||||
const url = firstStringArg(input, ["url"]);
|
||||
const command = firstStringArg(input, ["command"]);
|
||||
|
||||
switch (toolKind) {
|
||||
case "read": {
|
||||
if (path) {
|
||||
const displayPath = stripProjectPath(path, projectPath);
|
||||
return toolName === "ls" ? `List ${displayPath}` : `Read ${displayPath}`;
|
||||
}
|
||||
return humanizeToolTitle(title);
|
||||
}
|
||||
case "search": {
|
||||
if (pattern) return `Search "${truncateMiddle(pattern, 40)}"`;
|
||||
if (query) return `Search "${truncateMiddle(query, 40)}"`;
|
||||
if (path) return `Search ${stripProjectPath(path, projectPath)}`;
|
||||
return humanizeToolTitle(title);
|
||||
}
|
||||
case "fetch": {
|
||||
if (url) return `Fetch ${truncateMiddle(url, 50)}`;
|
||||
return humanizeToolTitle(title);
|
||||
}
|
||||
case "execute": {
|
||||
if (command) return `Shell ${truncateMiddle(command, 60)}`;
|
||||
return humanizeToolTitle(title);
|
||||
}
|
||||
case "edit":
|
||||
case "delete":
|
||||
case "move":
|
||||
return humanizeToolTitle(title);
|
||||
case "think":
|
||||
return "Thinking...";
|
||||
default: {
|
||||
if (toolName === "write_todos" || title.toLowerCase().startsWith("write todos")) {
|
||||
return "Update todos";
|
||||
}
|
||||
if (toolName === "ls" && path) return `List ${stripProjectPath(path, projectPath)}`;
|
||||
return humanizeToolTitle(title);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -3,17 +3,17 @@ import { useEffect, useRef, useState } from "react";
|
|||
import { formatElapsed } from "@/lib/utils";
|
||||
|
||||
const BUSY_TEXTS: Array<{ present: string; past: string }> = [
|
||||
{ present: "Vibing...", past: "Vibed" },
|
||||
{ present: "Noodling...", past: "Noodled" },
|
||||
{ present: "Pondering...", past: "Pondered" },
|
||||
{ present: "Thinking really hard...", past: "Thought really hard" },
|
||||
{ present: "Spinning up...", past: "Spun up" },
|
||||
{ present: "Connecting the dots...", past: "Connected the dots" },
|
||||
{ present: "Brewing ideas...", past: "Brewed ideas" },
|
||||
{ present: "Cooking...", past: "Cooked" },
|
||||
{ present: "Crunching...", past: "Crunched" },
|
||||
{ present: "Scheming...", past: "Schemed" },
|
||||
{ present: "Processing...", past: "Processed" },
|
||||
{ present: "vibing...", past: "Vibed" },
|
||||
{ present: "noodling...", past: "Noodled" },
|
||||
{ present: "pondering...", past: "Pondered" },
|
||||
{ present: "thinking really hard...", past: "Thought really hard" },
|
||||
{ present: "spinning up...", past: "Spun up" },
|
||||
{ present: "connecting the dots...", past: "Connected the dots" },
|
||||
{ present: "brewing ideas...", past: "Brewed ideas" },
|
||||
{ present: "cooking...", past: "Cooked" },
|
||||
{ present: "crunching...", past: "Crunched" },
|
||||
{ present: "scheming...", past: "Schemed" },
|
||||
{ present: "processing...", past: "Processed" },
|
||||
];
|
||||
|
||||
const THINKING_SETTLE_MS = 300;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import { useStreamContext as useAgentThreadStream, useToolCalls } from "@langchain/react"
|
||||
import { Check, Loader2, X } from "lucide-react"
|
||||
|
||||
import { humanizeToolName } from "@/features/agents/lib/toolNames"
|
||||
function humanizeToolName(name: string): string {
|
||||
return name.replace(/_/g, " ").trim() || "tool"
|
||||
}
|
||||
|
||||
/**
|
||||
* Live status for a single subagent, read straight from the SDK's scoped
|
||||
|
|
|
|||
|
|
@ -528,7 +528,7 @@ export const PromptBar = memo(function PromptBar({
|
|||
<div key={i} className="relative group">
|
||||
<img
|
||||
src={`data:${img.mimeType};base64,${img.base64}`}
|
||||
alt={img.fileName || "Pending image"}
|
||||
alt={img.fileName || "pending image"}
|
||||
className="w-16 h-16 object-cover rounded border border-gray-600"
|
||||
/>
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -1,58 +0,0 @@
|
|||
/** @vitest-environment jsdom */
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||
|
||||
import {
|
||||
readStoredPanelCollapsed,
|
||||
writeStoredPanelCollapsed,
|
||||
} from "./gitPanelPreferences"
|
||||
|
||||
function mockViewport(matches: boolean): void {
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation((query: string) => ({
|
||||
matches,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear()
|
||||
mockViewport(false)
|
||||
})
|
||||
|
||||
describe("git panel collapsed preference", () => {
|
||||
it("defaults to collapsed before really wide screens", () => {
|
||||
mockViewport(false)
|
||||
|
||||
expect(readStoredPanelCollapsed()).toBe(true)
|
||||
})
|
||||
|
||||
it("defaults to expanded on really wide screens", () => {
|
||||
mockViewport(true)
|
||||
|
||||
expect(readStoredPanelCollapsed()).toBe(false)
|
||||
})
|
||||
|
||||
it("keeps an explicit collapsed preference on really wide screens", () => {
|
||||
mockViewport(true)
|
||||
writeStoredPanelCollapsed(true)
|
||||
|
||||
expect(readStoredPanelCollapsed()).toBe(true)
|
||||
})
|
||||
|
||||
it("keeps an explicit expanded preference before really wide screens", () => {
|
||||
mockViewport(false)
|
||||
writeStoredPanelCollapsed(false)
|
||||
|
||||
expect(readStoredPanelCollapsed()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
const PANEL_STORAGE_COLLAPSED = "open-swe.gitpanel.collapsed"
|
||||
const COLLAPSED_STATE_TRUE = "1"
|
||||
const COLLAPSED_STATE_FALSE = "0"
|
||||
const PANEL_DEFAULT_EXPANDED_MEDIA_QUERY = "(min-width: 1536px)"
|
||||
|
||||
export function readStoredPanelCollapsed(): boolean {
|
||||
if (typeof window === "undefined") return true
|
||||
const stored = window.localStorage.getItem(PANEL_STORAGE_COLLAPSED)
|
||||
if (stored === COLLAPSED_STATE_TRUE) return true
|
||||
if (stored === COLLAPSED_STATE_FALSE) return false
|
||||
return !window.matchMedia(PANEL_DEFAULT_EXPANDED_MEDIA_QUERY).matches
|
||||
}
|
||||
|
||||
export function writeStoredPanelCollapsed(collapsed: boolean): void {
|
||||
if (typeof window === "undefined") return
|
||||
window.localStorage.setItem(
|
||||
PANEL_STORAGE_COLLAPSED,
|
||||
collapsed ? COLLAPSED_STATE_TRUE : COLLAPSED_STATE_FALSE
|
||||
)
|
||||
}
|
||||
|
|
@ -1,16 +1,14 @@
|
|||
import { AIMessage, HumanMessage, ToolMessage } from "@langchain/core/messages";
|
||||
import { messageArrivalTimestamp } from "./messageTimestamps";
|
||||
import { humanizeToolName } from "./toolNames";
|
||||
import type { BaseMessage, ContentBlock } from "@langchain/core/messages";
|
||||
import type { AssembledToolCall, SubagentDiscoverySnapshot } from "@langchain/react";
|
||||
|
||||
import type { Chunk, DiffData, Message, ToolExecutionChunk } from "./types";
|
||||
|
||||
const READ_TOOLS = new Set(["read_file", "read", "ls"]);
|
||||
const READ_TOOLS = new Set(["read_file", "read", "glob", "grep"]);
|
||||
const EDIT_TOOLS = new Set(["write_file", "edit_file", "str_replace", "write", "edit", "patch"]);
|
||||
const EXECUTE_TOOLS = new Set(["execute", "bash", "shell", "run_terminal_cmd"]);
|
||||
const SEARCH_TOOLS = new Set(["glob", "grep", "web_search", "search"]);
|
||||
const FETCH_TOOLS = new Set(["fetch", "fetch_url", "http_request"]);
|
||||
const SEARCH_TOOLS = new Set(["glob", "grep", "web_search", "fetch_url", "search"]);
|
||||
const INTERNAL_TOOLS = new Set(["confirming_completion", "no_op"]);
|
||||
|
||||
type ToolKind = ToolExecutionChunk["toolKind"];
|
||||
|
|
@ -21,15 +19,14 @@ function toolKind(name: string): ToolKind {
|
|||
if (lowered === "task") return "task";
|
||||
if (lowered === "slack_thread_reply") return "slack";
|
||||
if (lowered === "linear_comment") return "linear";
|
||||
if (lowered === "write_todos") return "other";
|
||||
if (EDIT_TOOLS.has(lowered) || ["edit", "write", "replace"].some((t) => lowered.includes(t))) {
|
||||
return "edit";
|
||||
}
|
||||
if (EXECUTE_TOOLS.has(lowered)) return "execute";
|
||||
if (FETCH_TOOLS.has(lowered)) return "fetch";
|
||||
if (SEARCH_TOOLS.has(lowered)) return "search";
|
||||
if (READ_TOOLS.has(lowered) || lowered.includes("read")) return "read";
|
||||
if (lowered === "think") return "think";
|
||||
if (["fetch", "fetch_url", "http_request"].includes(lowered)) return "fetch";
|
||||
return "other";
|
||||
}
|
||||
|
||||
|
|
@ -40,7 +37,7 @@ function toolTitle(name: string, args: Record<string, unknown>): string {
|
|||
if (typeof command === "string" && command.trim()) {
|
||||
return command.trim().split("\n")[0]?.slice(0, 120) ?? "";
|
||||
}
|
||||
return humanizeToolName(name);
|
||||
return name.replace(/_/g, " ").trim() || "Tool";
|
||||
}
|
||||
|
||||
function parseToolArgs(raw: unknown): Record<string, unknown> {
|
||||
|
|
|
|||
|
|
@ -1,10 +0,0 @@
|
|||
export function humanizeToolName(name: string, fallback = "Tool"): string {
|
||||
const normalized = name
|
||||
.replace(/[_-]+/g, " ")
|
||||
.trim()
|
||||
.replace(/\s+/g, " ")
|
||||
.toLowerCase()
|
||||
|
||||
if (!normalized) return fallback
|
||||
return `${normalized.charAt(0).toUpperCase()}${normalized.slice(1)}`
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue