mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-09-30 09:13:14 +00:00
Merge pull request #206 from Sea-Haven-Industries/feature/port-upstream-clean-batch
feat: (port upstream) UI labels, git panel, Linear search, sandbox config
This commit is contained in:
commit
a3f243433b
33 changed files with 944 additions and 124 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_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_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`.
|
||||
|
||||
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_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_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`.
|
||||
|
||||
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,6 +72,7 @@ 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,8 +115,10 @@ 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_langsmith_api_key
|
||||
from agent.integrations.langsmith import _get_sandbox_api_endpoint, _get_sandbox_api_key
|
||||
|
||||
api_key = _get_langsmith_api_key()
|
||||
api_key = _get_sandbox_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)
|
||||
client = SandboxClient(api_key=api_key, api_endpoint=_get_sandbox_api_endpoint())
|
||||
try:
|
||||
with tempfile.TemporaryDirectory(prefix="openswe-snapshot-") as context_dir:
|
||||
dockerfile_path = Path(context_dir) / "Dockerfile"
|
||||
|
|
|
|||
|
|
@ -4,9 +4,11 @@ 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
|
||||
|
|
@ -44,6 +46,69 @@ 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:
|
||||
|
|
@ -87,6 +152,43 @@ 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 [
|
||||
|
|
@ -134,6 +236,22 @@ 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.
|
||||
|
||||
|
|
@ -145,11 +263,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_langsmith_api_key()
|
||||
api_key = _get_sandbox_api_key()
|
||||
if not api_key:
|
||||
logger.warning("No LangSmith API key found, skipping GitHub proxy configuration")
|
||||
return
|
||||
langsmith_endpoint = os.environ.get("LANGSMITH_ENDPOINT", "https://api.smith.langchain.com")
|
||||
langsmith_endpoint = _get_sandbox_endpoint()
|
||||
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:
|
||||
|
|
@ -211,7 +329,7 @@ def create_langsmith_sandbox(
|
|||
Returns:
|
||||
SandboxBackendProtocol instance
|
||||
"""
|
||||
api_key = _get_langsmith_api_key()
|
||||
api_key = _get_sandbox_api_key()
|
||||
(
|
||||
default_snapshot_id,
|
||||
fs_capacity_bytes,
|
||||
|
|
@ -227,6 +345,7 @@ 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,
|
||||
|
|
@ -246,11 +365,9 @@ def _update_thread_sandbox_metadata(sandbox_id: str) -> None:
|
|||
try:
|
||||
import asyncio
|
||||
|
||||
from langgraph.config import get_config
|
||||
from langgraph_sdk import get_client
|
||||
|
||||
config = get_config()
|
||||
thread_id = config.get("configurable", {}).get("thread_id")
|
||||
thread_id = _current_thread_id()
|
||||
if not thread_id:
|
||||
return
|
||||
client = get_client()
|
||||
|
|
@ -416,11 +533,14 @@ class LangSmithProvider(SandboxProvider):
|
|||
def __init__(self, api_key: str | None = None) -> None:
|
||||
from langsmith import sandbox
|
||||
|
||||
self._api_key = api_key or _get_langsmith_api_key()
|
||||
self._api_key = api_key or _get_sandbox_api_key()
|
||||
self._api_endpoint = _get_sandbox_api_endpoint()
|
||||
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)
|
||||
self._client: SandboxClient = sandbox.SandboxClient(
|
||||
api_key=self._api_key, api_endpoint=self._api_endpoint
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def validate_startup_config(cls) -> None:
|
||||
|
|
@ -453,6 +573,7 @@ class LangSmithProvider(SandboxProvider):
|
|||
):
|
||||
msg = f"{name} must be >= 0, got {value}"
|
||||
raise ValueError(msg)
|
||||
_get_sandbox_create_extra_fields()
|
||||
|
||||
def get_or_create(
|
||||
self,
|
||||
|
|
@ -460,6 +581,7 @@ 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,
|
||||
|
|
@ -483,9 +605,13 @@ 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,6 +63,7 @@ _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,10 +854,11 @@ 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)
|
||||
|
|
|
|||
|
|
@ -105,6 +105,7 @@ 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,
|
||||
|
|
@ -1019,6 +1020,7 @@ 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,6 +24,7 @@ _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",
|
||||
|
|
@ -67,6 +68,7 @@ __all__ = [
|
|||
"linear_get_issue",
|
||||
"linear_get_issue_comments",
|
||||
"linear_list_teams",
|
||||
"linear_search_issues",
|
||||
"linear_update_issue",
|
||||
"list_findings",
|
||||
"list_review_findings",
|
||||
|
|
@ -110,6 +112,7 @@ 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
|
||||
|
|
|
|||
34
agent/tools/linear_search_issues.py
Normal file
34
agent/tools/linear_search_issues.py
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
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,
|
||||
)
|
||||
|
|
@ -132,6 +132,84 @@ 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,
|
||||
|
|
|
|||
|
|
@ -69,6 +69,8 @@ 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:
|
||||
|
|
|
|||
122
tests/reviewer/test_factory_config_isolation.py
Normal file
122
tests/reviewer/test_factory_config_isolation.py
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
"""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}
|
||||
|
|
@ -1288,13 +1288,17 @@ 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),
|
||||
):
|
||||
await reviewer.get_reviewer_agent(config)
|
||||
agent = await reviewer.get_reviewer_agent(config)
|
||||
|
||||
mock_fetch_diff.assert_awaited_once_with(
|
||||
owner="acme", repo="repo", pr_number=42, token="gh-token"
|
||||
)
|
||||
assert config["configurable"]["diff_text"] == pr_diff
|
||||
assert config["configurable"]["diff_line_set"] == {"in_diff.py": {"RIGHT": {10}, "LEFT": {1}}}
|
||||
# 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}}}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -1353,10 +1357,14 @@ 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),
|
||||
):
|
||||
await reviewer.get_reviewer_agent(config)
|
||||
agent = await reviewer.get_reviewer_agent(config)
|
||||
|
||||
assert config["configurable"]["diff_text"] == ""
|
||||
assert config["configurable"]["diff_line_set"] is None
|
||||
# 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
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
"""Tests for LangSmith sandbox env-var configuration parsing."""
|
||||
|
||||
from unittest.mock import patch
|
||||
import base64
|
||||
import uuid
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
|
@ -11,10 +13,62 @@ 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",
|
||||
|
|
@ -97,3 +151,67 @@ 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,6 +125,35 @@ 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(
|
||||
|
|
|
|||
131
tests/tools/test_linear_search_issues.py
Normal file
131
tests/tools/test_linear_search_issues.py
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
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,9 +88,6 @@ 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
|
||||
|
|
@ -119,23 +116,6 @@ 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,10 +12,12 @@ import type { ModelSelection } from "@/features/agents/lib/provider/useModelOpti
|
|||
import {
|
||||
AgentGitPanel,
|
||||
PANEL_MIN_CHAT_WIDTH,
|
||||
readStoredPanelCollapsed,
|
||||
writeStoredPanelCollapsed,
|
||||
} from "@/features/agents/components/AgentGitPanel"
|
||||
import { AgentPromptBar } from "@/features/agents/components/AgentPromptBar"
|
||||
import {
|
||||
readStoredPanelCollapsed,
|
||||
writeStoredPanelCollapsed,
|
||||
} from "@/features/agents/lib/gitPanelPreferences"
|
||||
import { Messages } from "@/features/agents/components/messages"
|
||||
import { streamMessagesToUi } from "@/features/agents/lib/streamMessagesToUi"
|
||||
import { messageArrivalTimestamp } from "@/features/agents/lib/messageTimestamps"
|
||||
|
|
|
|||
|
|
@ -61,6 +61,10 @@ 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,
|
||||
|
|
@ -279,8 +283,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"} · {thread.status}
|
||||
{isResolved ? " · resolved" : ""}
|
||||
{thread.repoFullName || "No repo"} · {displayStatus(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
|
||||
|
|
|
|||
49
ui/src/features/agents/components/chat/ToolExecution.test.ts
Normal file
49
ui/src/features/agents/components/chat/ToolExecution.test.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
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,7 +1,8 @@
|
|||
import { memo, useCallback, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { MultiFileDiff } from "@pierre/diffs/react";
|
||||
import { DiffView } from "./DiffView";
|
||||
import type { AcpToolKind, ToolExecutionChunk } from "@/features/agents/lib/types";
|
||||
import { formatToolDisplay } from "./toolExecutionDisplay";
|
||||
import type { ToolExecutionChunk } from "@/features/agents/lib/types";
|
||||
import { useDiffOptions } from "@/features/agents/utils/diffUtils";
|
||||
import { countLineChanges } from "@/features/agents/utils/diffStats";
|
||||
|
||||
|
|
@ -27,60 +28,6 @@ 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,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,91 @@
|
|||
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,9 +1,7 @@
|
|||
import { useStreamContext as useAgentThreadStream, useToolCalls } from "@langchain/react"
|
||||
import { Check, Loader2, X } from "lucide-react"
|
||||
|
||||
function humanizeToolName(name: string): string {
|
||||
return name.replace(/_/g, " ").trim() || "tool"
|
||||
}
|
||||
import { humanizeToolName } from "@/features/agents/lib/toolNames"
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
|
|
|||
58
ui/src/features/agents/lib/gitPanelPreferences.test.ts
Normal file
58
ui/src/features/agents/lib/gitPanelPreferences.test.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
/** @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)
|
||||
})
|
||||
})
|
||||
20
ui/src/features/agents/lib/gitPanelPreferences.ts
Normal file
20
ui/src/features/agents/lib/gitPanelPreferences.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
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,14 +1,16 @@
|
|||
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", "glob", "grep"]);
|
||||
const READ_TOOLS = new Set(["read_file", "read", "ls"]);
|
||||
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", "fetch_url", "search"]);
|
||||
const SEARCH_TOOLS = new Set(["glob", "grep", "web_search", "search"]);
|
||||
const FETCH_TOOLS = new Set(["fetch", "fetch_url", "http_request"]);
|
||||
const INTERNAL_TOOLS = new Set(["confirming_completion", "no_op"]);
|
||||
|
||||
type ToolKind = ToolExecutionChunk["toolKind"];
|
||||
|
|
@ -19,14 +21,15 @@ 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";
|
||||
}
|
||||
|
||||
|
|
@ -37,7 +40,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 name.replace(/_/g, " ").trim() || "Tool";
|
||||
return humanizeToolName(name);
|
||||
}
|
||||
|
||||
function parseToolArgs(raw: unknown): Record<string, unknown> {
|
||||
|
|
|
|||
10
ui/src/features/agents/lib/toolNames.ts
Normal file
10
ui/src/features/agents/lib/toolNames.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
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