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:
Adam Moussa 2026-07-17 16:53:32 -04:00 • committed by GitHub
commit a3f243433b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
33 changed files with 944 additions and 124 deletions

View file

@ -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`).

View file

@ -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.

View file

@ -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 |

View file

@ -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)

View file

@ -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"

View file

@ -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,

View file

@ -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...",

View file

@ -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)

View file

@ -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,

View file

@ -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

View 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,
)

View file

@ -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,

View file

@ -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:

View 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}

View file

@ -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

View file

@ -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"

View file

@ -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(

View 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",
}

View file

@ -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,

View file

@ -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"

View file

@ -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

View file

@ -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">

View file

@ -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

View 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",
);
});
});

View file

@ -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,

View file

@ -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);
}
}
}

View file

@ -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;

View file

@ -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

View file

@ -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

View 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)
})
})

View 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
)
}

View file

@ -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> {

View 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)}`
}