diff --git a/README.md b/README.md index 0cdacfb..fef8c99 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # detect_agent -> This is a Python port of Vercel's `@vercel/detect-agent` npm package. +> This is a Python port of Vercel's [`detect-agent`](https://github.com/vercel/detect-agent) package. A lightweight utility for detecting if code is being executed by an AI agent or automated development environment. @@ -23,20 +23,34 @@ if result["is_agent"]: # Adapt behavior for AI agent context ``` +## Agent Definitions + +Detection rules are vendored from upstream [`agents.json`](https://raw.githubusercontent.com/vercel/detect-agent/main/agents.json) (validated against [`agents.schema.json`](https://raw.githubusercontent.com/vercel/detect-agent/main/agents.schema.json)) and evaluated in array order; the first match wins. + ## Supported Agents This package can detect the following AI agents and development environments: -- **Custom agents** via `AI_AGENT` environment variable -- **Cursor** (cursor editor and cursor-cli) -- **Claude Code** (Anthropic's Claude) +- **Cursor** (Anysphere) +- **Claude Code** (Anthropic) +- **Claude Cowork** (Anthropic) - **Devin** (Cognition Labs) - **Gemini CLI** (Google) - **Codex** (OpenAI) - **Antigravity** (Google DeepMind) -- **GitHub Copilot** (via `AI_AGENT=github-copilot|github-copilot-cli`, `COPILOT_MODEL`, `COPILOT_ALLOW_ALL`, or `COPILOT_GITHUB_TOKEN`) -- **Replit** (online IDE) -- **v0** (Vercel's AI assistant, via `AI_AGENT=v0`) +- **Augment** +- **Cline** +- **OpenCode** +- **OpenClaw** +- **Goose** (Block) +- **Junie** (JetBrains) +- **Kiro** (AWS) +- **Pi** +- **GitHub Copilot** +- **Replit** +- **Custom agents** (via the `AI_AGENT` environment variable) + +See [`agents.json`](https://raw.githubusercontent.com/vercel/detect-agent/main/agents.json) for the exact environment variables and conditions used to detect each one. ## The AI_AGENT Standard @@ -131,12 +145,11 @@ def should_enable_feature(feature: str) -> bool: To add support for a new AI agent: -1. Add detection logic to `detect_agent/__init__.py` -2. Add comprehensive test cases in `tests/test_detect_agent.py` -3. Update this README with the new agent information -4. Follow the existing priority order pattern +1. Sync detection rules from upstream [`agents.json`](https://github.com/vercel/detect-agent/blob/main/agents.json) into `detect_agent/agents.json`. Agents are evaluated in array order — the first match wins. +2. Sync or extend test cases in `tests/testcases.json`. +3. Update this README with the new agent information. ## Links - [GitHub Repository](https://github.com/togethercomputer/detect_agent) -- [Vercel upstream package](https://github.com/vercel/vercel/tree/main/packages/detect-agent) +- [Vercel upstream package](https://github.com/vercel/detect-agent) diff --git a/detect_agent/__init__.py b/detect_agent/__init__.py index 1a5f3f1..55755ba 100644 --- a/detect_agent/__init__.py +++ b/detect_agent/__init__.py @@ -1,9 +1,18 @@ +"""Detect if code is running in an AI agent or automated development environment.""" + +from __future__ import annotations + import os -from pathlib import Path from typing import Literal, TypedDict, Union +from detect_agent._evaluate import evaluate_condition +from detect_agent._spec import known_agents_map, load_agents_file + +# Kept for backward-compatible imports/tests; Devin detection reads path from agents.json. DEVIN_LOCAL_PATH = "/opt/.devin" +KNOWN_AGENTS: dict[str, str] = known_agents_map() + CURSOR: Literal["cursor"] = "cursor" CURSOR_CLI: Literal["cursor-cli"] = "cursor-cli" CLAUDE: Literal["claude"] = "claude" @@ -16,9 +25,12 @@ AUGMENT_CLI: Literal["augment-cli"] = "augment-cli" OPENCODE: Literal["opencode"] = "opencode" GITHUB_COPILOT: Literal["github-copilot"] = "github-copilot" -GITHUB_COPILOT_CLI: Literal["github-copilot-cli"] = "github-copilot-cli" -V0: Literal["v0"] = "v0" +CLINE: Literal["cline"] = "cline" +GOOSE: Literal["goose"] = "goose" +JUNIE: Literal["junie"] = "junie" PI: Literal["pi"] = "pi" +KIRO: Literal["kiro"] = "kiro" +OPENCLAW: Literal["openclaw"] = "openclaw" KnownAgentNames = Literal[ "cursor", @@ -33,13 +45,17 @@ "augment-cli", "opencode", "github-copilot", - "v0", + "cline", + "goose", + "junie", "pi", + "kiro", + "openclaw", ] class KnownAgentDetails(TypedDict): - name: KnownAgentNames + name: str class AgentResultAgent(TypedDict): @@ -54,84 +70,38 @@ class AgentResultNone(TypedDict): AgentResult = Union[AgentResultAgent, AgentResultNone] -KNOWN_AGENTS = { - "PI": PI, - "CURSOR": CURSOR, - "CURSOR_CLI": CURSOR_CLI, - "CLAUDE": CLAUDE, - "COWORK": COWORK, - "DEVIN": DEVIN, - "REPLIT": REPLIT, - "GEMINI": GEMINI, - "CODEX": CODEX, - "ANTIGRAVITY": ANTIGRAVITY, - "AUGMENT_CLI": AUGMENT_CLI, - "OPENCODE": OPENCODE, - "GITHUB_COPILOT": GITHUB_COPILOT, - "V0": V0, -} + +def _resolve_ai_agent_standard(ai_agent_var: str) -> str | None: + raw = os.environ.get(ai_agent_var) + if not raw: + return None + value = raw.strip() + if not value: + return None + return value def determine_agent() -> AgentResult: - ai_agent = os.environ.get("AI_AGENT") + """Inspect the environment and return which AI agent is running, if any. + + ``AI_AGENT`` takes highest priority. After that, agents from ``agents.json`` + are evaluated in order and the first match wins. + + Python-port extension: ``PI_CODING_AGENT`` also detects Pi for backward + compatibility with earlier releases of this package. + """ + spec = load_agents_file() + + ai_agent = _resolve_ai_agent_standard(spec["aiAgentVar"]) if ai_agent: - name = ai_agent.strip() - if name: - if name in (GITHUB_COPILOT, GITHUB_COPILOT_CLI): - return {"is_agent": True, "agent": {"name": GITHUB_COPILOT}} - if name == V0: - return {"is_agent": True, "agent": {"name": V0}} - return {"is_agent": True, "agent": {"name": name}} # type: ignore[return-value, misc] + return {"is_agent": True, "agent": {"name": ai_agent}} + # Backward-compatible Pi marker from earlier Python port releases. if os.environ.get("PI_CODING_AGENT"): - return {"is_agent": True, "agent": {"name": PI}} - - # Cursor IDE agent-terminal sessions expose CURSOR_TRACE_ID; the - # cursor-agent CLI sets CURSOR_AGENT for commands it executes. - if os.environ.get("CURSOR_TRACE_ID"): - return {"is_agent": True, "agent": {"name": CURSOR}} - - if ( - os.environ.get("CURSOR_AGENT") - or os.environ.get("CURSOR_EXTENSION_HOST_ROLE") == "agent-exec" - ): - return {"is_agent": True, "agent": {"name": CURSOR_CLI}} - - if os.environ.get("GEMINI_CLI"): - return {"is_agent": True, "agent": {"name": GEMINI}} - - if ( - os.environ.get("CODEX_SANDBOX") - or os.environ.get("CODEX_CI") - or os.environ.get("CODEX_THREAD_ID") - ): - return {"is_agent": True, "agent": {"name": CODEX}} - - if os.environ.get("ANTIGRAVITY_AGENT"): - return {"is_agent": True, "agent": {"name": ANTIGRAVITY}} - - if os.environ.get("AUGMENT_AGENT"): - return {"is_agent": True, "agent": {"name": AUGMENT_CLI}} - - if os.environ.get("OPENCODE_CLIENT"): - return {"is_agent": True, "agent": {"name": OPENCODE}} - - if os.environ.get("CLAUDECODE") or os.environ.get("CLAUDE_CODE"): - if os.environ.get("CLAUDE_CODE_IS_COWORK"): - return {"is_agent": True, "agent": {"name": COWORK}} - return {"is_agent": True, "agent": {"name": CLAUDE}} - - if os.environ.get("REPL_ID"): - return {"is_agent": True, "agent": {"name": REPLIT}} - - if ( - os.environ.get("COPILOT_MODEL") - or os.environ.get("COPILOT_ALLOW_ALL") - or os.environ.get("COPILOT_GITHUB_TOKEN") - ): - return {"is_agent": True, "agent": {"name": GITHUB_COPILOT}} - - if Path(DEVIN_LOCAL_PATH).exists(): - return {"is_agent": True, "agent": {"name": DEVIN}} + return {"is_agent": True, "agent": {"name": KNOWN_AGENTS["PI"]}} + + for agent in spec["agents"]: + if evaluate_condition(agent["match"]): + return {"is_agent": True, "agent": {"name": agent["name"]}} return {"is_agent": False, "agent": None} diff --git a/detect_agent/_evaluate.py b/detect_agent/_evaluate.py new file mode 100644 index 0000000..a136b8e --- /dev/null +++ b/detect_agent/_evaluate.py @@ -0,0 +1,81 @@ +"""Evaluate agent detection condition trees from agents.json.""" + +from __future__ import annotations + +import os +import re +import sys +from pathlib import Path +from typing import Any, Callable + + +def _env_set(name: str) -> bool: + return bool(os.environ.get(name)) + + +def _env_value(name: str, value: str) -> bool: + return name in os.environ and os.environ[name] == value + + +def _env_matches(name: str, pattern: str) -> bool: + value = os.environ.get(name) + if not value: + return False + try: + return re.search(pattern, value) is not None + except re.error: + # A malformed pattern in the spec should never throw at detection time. + return False + + +def _path_exists(path: str) -> bool: + try: + return Path(path).exists() + except OSError: + return False + + +def _is_tty() -> bool: + try: + return bool(sys.stdout.isatty()) + except Exception: + return False + + +# Swappable in tests (mirrors upstream Go isTTYFn / path checks). +path_exists_fn: Callable[[str], bool] = _path_exists +is_tty_fn: Callable[[], bool] = _is_tty + + +def evaluate_condition(condition: dict[str, Any]) -> bool: + """Evaluate a condition tree. ``anyOf``/``allOf`` are combinators; the rest are leaves.""" + ctype = condition.get("type") + + if ctype == "env_set": + return _env_set(condition["name"]) + + if ctype == "env_value": + return _env_value(condition["name"], condition["value"]) + + if ctype == "env_matches": + return _env_matches(condition["name"], condition["pattern"]) + + if ctype == "no_tty": + return not is_tty_fn() + + if ctype == "file_exists": + return path_exists_fn(condition["path"]) + + if ctype == "anyOf": + for sub in condition.get("conditions", []): + if evaluate_condition(sub): + return True + return False + + if ctype == "allOf": + for sub in condition.get("conditions", []): + if not evaluate_condition(sub): + return False + return True + + return False diff --git a/detect_agent/_spec.py b/detect_agent/_spec.py new file mode 100644 index 0000000..3054767 --- /dev/null +++ b/detect_agent/_spec.py @@ -0,0 +1,50 @@ +"""Load the language-agnostic agents.json specification.""" + +from __future__ import annotations + +import json +from functools import lru_cache +from importlib import resources +from pathlib import Path +from typing import Any, TypedDict + + +class AgentSpec(TypedDict): + key: str + name: str + match: dict[str, Any] + + +class AgentsFile(TypedDict): + version: int + aiAgentVar: str + agents: list[AgentSpec] + + +def _read_agents_json() -> str: + # Prefer importlib.resources (installed wheel); fall back to source tree path. + try: + return (resources.files("detect_agent") / "agents.json").read_text(encoding="utf-8") + except (FileNotFoundError, TypeError, ModuleNotFoundError, AttributeError): + return (Path(__file__).with_name("agents.json")).read_text(encoding="utf-8") + + +@lru_cache(maxsize=1) +def load_agents_file() -> AgentsFile: + data = json.loads(_read_agents_json()) + return { + "version": int(data["version"]), + "aiAgentVar": str(data["aiAgentVar"]), + "agents": [ + { + "key": str(agent["key"]), + "name": str(agent["name"]), + "match": agent["match"], + } + for agent in data["agents"] + ], + } + + +def known_agents_map() -> dict[str, str]: + return {agent["key"]: agent["name"] for agent in load_agents_file()["agents"]} diff --git a/detect_agent/agents.json b/detect_agent/agents.json new file mode 100644 index 0000000..78f24f6 --- /dev/null +++ b/detect_agent/agents.json @@ -0,0 +1,201 @@ +{ + "$schema": "./agents.schema.json", + "version": 1, + "description": "Language-agnostic specification for detecting AI agents and automated development environments. Agents are evaluated in array order; the first agent whose `match` condition is satisfied wins. Every agent's `match` is a combinator (`anyOf`/`allOf`) whose `conditions` are evaluated as a tree: combinators nest, and `env_set`/`env_value`/`file_exists` are leaf checks. See agents.schema.json for the full structure.", + "aiAgentVar": "AI_AGENT", + "agents": [ + { + "key": "CURSOR", + "name": "cursor", + "match": { + "type": "anyOf", + "conditions": [{ "type": "env_set", "name": "CURSOR_TRACE_ID" }] + } + }, + { + "key": "CURSOR_CLI", + "name": "cursor-cli", + "match": { + "type": "anyOf", + "conditions": [ + { "type": "env_set", "name": "CURSOR_AGENT" }, + { + "type": "env_value", + "name": "CURSOR_EXTENSION_HOST_ROLE", + "value": "agent-exec" + } + ] + } + }, + { + "key": "GEMINI", + "name": "gemini", + "match": { + "type": "anyOf", + "conditions": [{ "type": "env_set", "name": "GEMINI_CLI" }] + } + }, + { + "key": "CLINE", + "name": "cline", + "match": { + "type": "anyOf", + "conditions": [{ "type": "env_set", "name": "CLINE_ACTIVE" }] + } + }, + { + "key": "CODEX", + "name": "codex", + "match": { + "type": "anyOf", + "conditions": [ + { "type": "env_set", "name": "CODEX_SANDBOX" }, + { "type": "env_set", "name": "CODEX_CI" }, + { "type": "env_set", "name": "CODEX_THREAD_ID" }, + { "type": "env_set", "name": "CODEX_SANDBOX_NETWORK_DISABLED" } + ] + } + }, + { + "key": "ANTIGRAVITY", + "name": "antigravity", + "match": { + "type": "anyOf", + "conditions": [ + { "type": "env_set", "name": "ANTIGRAVITY_AGENT" }, + { "type": "env_set", "name": "ANTIGRAVITY_CLI_ALIAS" } + ] + } + }, + { + "key": "AUGMENT_CLI", + "name": "augment-cli", + "match": { + "type": "anyOf", + "conditions": [{ "type": "env_set", "name": "AUGMENT_AGENT" }] + } + }, + { + "key": "OPENCODE", + "name": "opencode", + "match": { + "type": "anyOf", + "conditions": [ + { "type": "env_set", "name": "OPENCODE_CLIENT" }, + { "type": "env_set", "name": "OPENCODE" } + ] + } + }, + { + "key": "GOOSE", + "name": "goose", + "match": { + "type": "anyOf", + "conditions": [{ "type": "env_set", "name": "GOOSE_PROVIDER" }] + } + }, + { + "key": "JUNIE", + "name": "junie", + "match": { + "type": "anyOf", + "conditions": [ + { "type": "env_set", "name": "JUNIE_DATA" }, + { "type": "env_set", "name": "JUNIE_SHIM_PATH" } + ] + } + }, + { + "key": "PI", + "name": "pi", + "description": "Matches when the PATH contains a '.pi/agent' (or '.pi\\\\agent') segment.", + "match": { + "type": "anyOf", + "conditions": [ + { + "type": "env_matches", + "name": "PATH", + "pattern": "\\.pi[\\\\/]agent" + } + ] + } + }, + { + "key": "COWORK", + "name": "cowork", + "description": "Claude Cowork. Evaluated before `claude` so the more specific COWORK marker wins. Requires CLAUDE_CODE_IS_COWORK plus a Claude Code marker.", + "match": { + "type": "allOf", + "conditions": [ + { "type": "env_set", "name": "CLAUDE_CODE_IS_COWORK" }, + { + "type": "anyOf", + "conditions": [ + { "type": "env_set", "name": "CLAUDECODE" }, + { "type": "env_set", "name": "CLAUDE_CODE" } + ] + } + ] + } + }, + { + "key": "CLAUDE", + "name": "claude", + "match": { + "type": "anyOf", + "conditions": [ + { "type": "env_set", "name": "CLAUDECODE" }, + { "type": "env_set", "name": "CLAUDE_CODE" } + ] + } + }, + { + "key": "REPLIT", + "name": "replit", + "match": { + "type": "anyOf", + "conditions": [{ "type": "env_set", "name": "REPL_ID" }] + } + }, + { + "key": "GITHUB_COPILOT", + "name": "github-copilot", + "match": { + "type": "anyOf", + "conditions": [ + { "type": "env_set", "name": "COPILOT_MODEL" }, + { "type": "env_set", "name": "COPILOT_ALLOW_ALL" }, + { "type": "env_set", "name": "COPILOT_GITHUB_TOKEN" } + ] + } + }, + { + "key": "KIRO", + "name": "kiro", + "description": "AWS Kiro. TERM_PROGRAM=kiro is set by both the IDE terminal and the CLI agent, so gate on no_tty to avoid misdetecting a human at the integrated terminal.", + "match": { + "type": "allOf", + "conditions": [ + { "type": "env_matches", "name": "TERM_PROGRAM", "pattern": "kiro" }, + { "type": "no_tty" } + ] + } + }, + { + "key": "OPENCLAW", + "name": "openclaw", + "match": { + "type": "anyOf", + "conditions": [{ "type": "env_set", "name": "OPENCLAW_SHELL" }] + } + }, + { + "key": "DEVIN", + "name": "devin", + "match": { + "type": "anyOf", + "conditions": [{ "type": "file_exists", "path": "/opt/.devin" }] + } + } + ] +} diff --git a/pyproject.toml b/pyproject.toml index 3292a88..4631134 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,6 +5,9 @@ build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] packages = ["detect_agent"] +[tool.hatch.build.targets.wheel.force-include] +"detect_agent/agents.json" = "detect_agent/agents.json" + [project] name = "detect_agent" version = "0.3.0" diff --git a/tests/test_detect_agent.py b/tests/test_detect_agent.py index 21c456d..1795207 100644 --- a/tests/test_detect_agent.py +++ b/tests/test_detect_agent.py @@ -1,17 +1,20 @@ -"""Tests for determine_agent.""" +"""Tests for determine_agent, driven primarily by upstream testcases.json.""" +from __future__ import annotations + +import json from pathlib import Path -from unittest.mock import patch +from typing import Any import pytest -from detect_agent import ( - DEVIN_LOCAL_PATH, - KNOWN_AGENTS, - determine_agent, -) +from detect_agent import KNOWN_AGENTS, determine_agent +from detect_agent import _evaluate as evaluate_mod -# Env vars we reset so tests don't leak into each other +_TESTCASES_PATH = Path(__file__).with_name("testcases.json") +_TESTCASES: list[dict[str, Any]] = json.loads(_TESTCASES_PATH.read_text(encoding="utf-8")) + +# Env vars cleared between tests so cases don't leak into each other. _AGENT_ENV_VARS = ( "AI_AGENT", "PI_CODING_AGENT", @@ -19,12 +22,19 @@ "CURSOR_AGENT", "CURSOR_EXTENSION_HOST_ROLE", "GEMINI_CLI", + "CLINE_ACTIVE", "CODEX_SANDBOX", "CODEX_CI", "CODEX_THREAD_ID", + "CODEX_SANDBOX_NETWORK_DISABLED", "ANTIGRAVITY_AGENT", + "ANTIGRAVITY_CLI_ALIAS", "AUGMENT_AGENT", "OPENCODE_CLIENT", + "OPENCODE", + "GOOSE_PROVIDER", + "JUNIE_DATA", + "JUNIE_SHIM_PATH", "CLAUDECODE", "CLAUDE_CODE", "CLAUDE_CODE_IS_COWORK", @@ -32,359 +42,143 @@ "COPILOT_MODEL", "COPILOT_ALLOW_ALL", "COPILOT_GITHUB_TOKEN", + "TERM_PROGRAM", + "OPENCLAW_SHELL", + "PATH", ) @pytest.fixture(autouse=True) -def _clear_agent_env(monkeypatch): +def _clear_agent_env(monkeypatch: pytest.MonkeyPatch): for key in _AGENT_ENV_VARS: monkeypatch.delenv(key, raising=False) + # Neutral PATH so Pi's env_matches cannot fire accidentally from the host. + monkeypatch.setenv("PATH", "/usr/bin") + monkeypatch.setattr(evaluate_mod, "is_tty_fn", lambda: True) + monkeypatch.setattr(evaluate_mod, "path_exists_fn", lambda _path: False) yield - for key in _AGENT_ENV_VARS: - monkeypatch.delenv(key, raising=False) - -class TestCustomAgentFromAI_AGENT: - """Custom agent detection from AI_AGENT.""" - - def test_ai_agent_not_set_returns_no_agent(self): - result = determine_agent() - assert result == {"is_agent": False, "agent": None} - def test_ai_agent_set_detects_custom_agent(self, monkeypatch): - monkeypatch.setenv("AI_AGENT", "custom-agent") - result = determine_agent() - assert result == {"is_agent": True, "agent": {"name": "custom-agent"}} +def _expected_name(tc: dict[str, Any]) -> str | None: + if "expectedName" in tc: + return tc["expectedName"] + key = tc.get("expectedAgentKey") + if key is None: + return None + return KNOWN_AGENTS[key] -class TestGitHubCopilotDetection: - """GitHub Copilot detection.""" +@pytest.mark.parametrize("tc", _TESTCASES, ids=[tc["name"] for tc in _TESTCASES]) +def test_upstream_testcase(tc: dict[str, Any], monkeypatch: pytest.MonkeyPatch): + for key, value in tc.get("env", {}).items(): + monkeypatch.setenv(key, value) - def test_pi_coding_agent_set_detects_pi(self, monkeypatch): - monkeypatch.setenv("PI_CODING_AGENT", "1") - result = determine_agent() - assert result == {"is_agent": True, "agent": {"name": KNOWN_AGENTS["PI"]}} + if tc.get("tty") is not None: + monkeypatch.setattr(evaluate_mod, "is_tty_fn", lambda: bool(tc["tty"])) - def test_from_ai_agent_github_copilot(self, monkeypatch): - monkeypatch.setenv("AI_AGENT", "github-copilot") - result = determine_agent() - assert result == {"is_agent": True, "agent": {"name": KNOWN_AGENTS["GITHUB_COPILOT"]}} + files = set(tc.get("files") or []) + if files: + monkeypatch.setattr(evaluate_mod, "path_exists_fn", lambda path: path in files) - def test_from_ai_agent_github_copilot_cli(self, monkeypatch): - monkeypatch.setenv("AI_AGENT", "github-copilot-cli") - result = determine_agent() - assert result == {"is_agent": True, "agent": {"name": KNOWN_AGENTS["GITHUB_COPILOT"]}} - - def test_from_copilot_model(self, monkeypatch): - monkeypatch.setenv("COPILOT_MODEL", "gpt-5") - result = determine_agent() - assert result == {"is_agent": True, "agent": {"name": KNOWN_AGENTS["GITHUB_COPILOT"]}} - - def test_from_copilot_allow_all(self, monkeypatch): - monkeypatch.setenv("COPILOT_ALLOW_ALL", "true") - result = determine_agent() - assert result == {"is_agent": True, "agent": {"name": KNOWN_AGENTS["GITHUB_COPILOT"]}} - - def test_from_copilot_github_token(self, monkeypatch): - monkeypatch.setenv("COPILOT_GITHUB_TOKEN", "ghp_xxx") - result = determine_agent() - assert result == {"is_agent": True, "agent": {"name": KNOWN_AGENTS["GITHUB_COPILOT"]}} - - -class TestV0Detection: - """v0 detection.""" - - def test_from_ai_agent_v0(self, monkeypatch): - monkeypatch.setenv("AI_AGENT", "v0") - result = determine_agent() - assert result == {"is_agent": True, "agent": {"name": KNOWN_AGENTS["V0"]}} - - -class TestCursorDetection: - """Cursor detection.""" - - def test_cursor_trace_id_set_detects_cursor(self, monkeypatch): - monkeypatch.setenv("CURSOR_TRACE_ID", "some-uuid") - result = determine_agent() - assert result == {"is_agent": True, "agent": {"name": KNOWN_AGENTS["CURSOR"]}} + result = determine_agent() + expected_name = _expected_name(tc) - -class TestCursorCliDetection: - """Cursor CLI detection.""" - - def test_cursor_agent_not_set_returns_no_agent(self): - result = determine_agent() + if tc["expectedIsAgent"]: + assert result == {"is_agent": True, "agent": {"name": expected_name}} + else: assert result == {"is_agent": False, "agent": None} - def test_cursor_agent_set_detects_cursor_cli(self, monkeypatch): - monkeypatch.setenv("CURSOR_AGENT", "1") - result = determine_agent() - assert result == {"is_agent": True, "agent": {"name": KNOWN_AGENTS["CURSOR_CLI"]}} - def test_cursor_extension_host_role_agent_exec_detects_cursor_cli(self, monkeypatch): - monkeypatch.setenv("CURSOR_EXTENSION_HOST_ROLE", "agent-exec") - result = determine_agent() - assert result == {"is_agent": True, "agent": {"name": KNOWN_AGENTS["CURSOR_CLI"]}} +class TestPythonPortExtensions: + """Behavior kept for Python-port compatibility / extra coverage.""" - def test_cursor_extension_host_role_other_value_returns_no_agent(self, monkeypatch): - monkeypatch.setenv("CURSOR_EXTENSION_HOST_ROLE", "something-else") + def test_pi_coding_agent_set_detects_pi(self, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv("PI_CODING_AGENT", "1") result = determine_agent() - assert result == {"is_agent": False, "agent": None} - - -class TestGeminiDetection: - """Gemini detection.""" + assert result == {"is_agent": True, "agent": {"name": KNOWN_AGENTS["PI"]}} - def test_gemini_cli_not_set_returns_no_agent(self): + def test_ai_agent_github_copilot_cli_is_emitted_verbatim(self, monkeypatch: pytest.MonkeyPatch): + # Upstream emits AI_AGENT values verbatim (no alias rewriting). + monkeypatch.setenv("AI_AGENT", "github-copilot-cli") result = determine_agent() - assert result == {"is_agent": False, "agent": None} + assert result == {"is_agent": True, "agent": {"name": "github-copilot-cli"}} - def test_gemini_cli_set_detects_gemini(self, monkeypatch): - monkeypatch.setenv("GEMINI_CLI", "1") + def test_ai_agent_v0(self, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv("AI_AGENT", "v0") result = determine_agent() - assert result == {"is_agent": True, "agent": {"name": KNOWN_AGENTS["GEMINI"]}} - + assert result == {"is_agent": True, "agent": {"name": "v0"}} -class TestCodexDetection: - """Codex detection.""" - def test_codex_sandbox_not_set_returns_no_agent(self): - result = determine_agent() - assert result == {"is_agent": False, "agent": None} +class TestNewUpstreamAgents: + """Coverage for agents added upstream after the initial JSON testcases.""" - def test_codex_sandbox_set_detects_codex(self, monkeypatch): - monkeypatch.setenv("CODEX_SANDBOX", "seatbelt") + def test_cline_active_detects_cline(self, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv("CLINE_ACTIVE", "1") result = determine_agent() - assert result == {"is_agent": True, "agent": {"name": KNOWN_AGENTS["CODEX"]}} + assert result == {"is_agent": True, "agent": {"name": KNOWN_AGENTS["CLINE"]}} - def test_codex_ci_set_detects_codex(self, monkeypatch): - monkeypatch.setenv("CODEX_CI", "1") + def test_openclaw_shell_detects_openclaw(self, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv("OPENCLAW_SHELL", "1") result = determine_agent() - assert result == {"is_agent": True, "agent": {"name": KNOWN_AGENTS["CODEX"]}} + assert result == {"is_agent": True, "agent": {"name": KNOWN_AGENTS["OPENCLAW"]}} - def test_codex_thread_id_set_detects_codex(self, monkeypatch): - monkeypatch.setenv("CODEX_THREAD_ID", "thread-123") + def test_codex_sandbox_network_disabled_detects_codex(self, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv("CODEX_SANDBOX_NETWORK_DISABLED", "1") result = determine_agent() assert result == {"is_agent": True, "agent": {"name": KNOWN_AGENTS["CODEX"]}} - -class TestAntigravityDetection: - """Antigravity detection.""" - - def test_antigravity_agent_not_set_returns_no_agent(self): - result = determine_agent() - assert result == {"is_agent": False, "agent": None} - - def test_antigravity_agent_set_detects_antigravity(self, monkeypatch): - monkeypatch.setenv("ANTIGRAVITY_AGENT", "1") + def test_antigravity_cli_alias_detects_antigravity(self, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv("ANTIGRAVITY_CLI_ALIAS", "1") result = determine_agent() assert result == {"is_agent": True, "agent": {"name": KNOWN_AGENTS["ANTIGRAVITY"]}} -class TestAugmentCliDetection: - """Augment CLI detection.""" - - def test_augment_agent_not_set_returns_no_agent(self): - result = determine_agent() - assert result == {"is_agent": False, "agent": None} - - def test_augment_agent_set_detects_augment_cli(self, monkeypatch): - monkeypatch.setenv("AUGMENT_AGENT", "1") - result = determine_agent() - assert result == {"is_agent": True, "agent": {"name": KNOWN_AGENTS["AUGMENT_CLI"]}} - - -class TestOpencodeDetection: - """Opencode detection.""" - - def test_opencode_client_not_set_returns_no_agent(self): - result = determine_agent() - assert result == {"is_agent": False, "agent": None} - - def test_opencode_client_set_detects_opencode(self, monkeypatch): - monkeypatch.setenv("OPENCODE_CLIENT", "opencode") - result = determine_agent() - assert result == {"is_agent": True, "agent": {"name": KNOWN_AGENTS["OPENCODE"]}} - - -class TestClaudeDetection: - """Claude detection.""" - - def test_claude_code_not_set_returns_no_agent(self): - result = determine_agent() - assert result == {"is_agent": False, "agent": None} - - def test_claude_code_set_detects_claude(self, monkeypatch): - monkeypatch.setenv("CLAUDE_CODE", "1") - result = determine_agent() - assert result == {"is_agent": True, "agent": {"name": KNOWN_AGENTS["CLAUDE"]}} - - def test_claudecode_set_detects_claude(self, monkeypatch): - monkeypatch.setenv("CLAUDECODE", "1") - result = determine_agent() - assert result == {"is_agent": True, "agent": {"name": KNOWN_AGENTS["CLAUDE"]}} - - -class TestCoworkDetection: - """Cowork detection.""" - - def test_claude_code_is_cowork_not_set_detects_claude(self, monkeypatch): - monkeypatch.setenv("CLAUDECODE", "1") - result = determine_agent() - assert result == {"is_agent": True, "agent": {"name": KNOWN_AGENTS["CLAUDE"]}} - - def test_claude_code_is_cowork_set_with_claudecode_detects_cowork(self, monkeypatch): - monkeypatch.setenv("CLAUDECODE", "1") - monkeypatch.setenv("CLAUDE_CODE_IS_COWORK", "1") - result = determine_agent() - assert result == {"is_agent": True, "agent": {"name": KNOWN_AGENTS["COWORK"]}} - - def test_claude_code_is_cowork_set_with_claude_code_detects_cowork(self, monkeypatch): - monkeypatch.setenv("CLAUDE_CODE", "1") - monkeypatch.setenv("CLAUDE_CODE_IS_COWORK", "1") - result = determine_agent() - assert result == {"is_agent": True, "agent": {"name": KNOWN_AGENTS["COWORK"]}} - - def test_claude_code_is_cowork_set_without_claudecode_or_claude_code_returns_no_agent( - self, monkeypatch - ): - monkeypatch.setenv("CLAUDE_CODE_IS_COWORK", "1") - result = determine_agent() - assert result == {"is_agent": False, "agent": None} - - -class TestDevinDetection: - """Devin detection.""" - - def test_devin_path_does_not_exist_returns_no_agent(self): - with patch.object(Path, "exists", return_value=False): - result = determine_agent() - assert result == {"is_agent": False, "agent": None} - - def test_devin_path_exists_detects_devin(self): - with patch.object(Path, "exists", return_value=True): - result = determine_agent() - assert result == {"is_agent": True, "agent": {"name": KNOWN_AGENTS["DEVIN"]}} - - -class TestReplitDetection: - """Replit detection.""" - - def test_repl_id_not_set_returns_no_agent(self): - result = determine_agent() - assert result == {"is_agent": False, "agent": None} - - def test_repl_id_set_detects_replit(self, monkeypatch): - monkeypatch.setenv("REPL_ID", "1") - result = determine_agent() - assert result == {"is_agent": True, "agent": {"name": KNOWN_AGENTS["REPLIT"]}} - - -class TestPriorityOrderDetection: - """Priority order detection.""" - - def test_ai_agent_takes_highest_priority(self, monkeypatch): - monkeypatch.setenv("AI_AGENT", "custom-priority") - monkeypatch.setenv("PI_CODING_AGENT", "1") - monkeypatch.setenv("CURSOR_TRACE_ID", "some-uuid") - monkeypatch.setenv("CURSOR_AGENT", "1") - monkeypatch.setenv("GEMINI_CLI", "1") - monkeypatch.setenv("CODEX_SANDBOX", "seatbelt") - monkeypatch.setenv("ANTIGRAVITY_AGENT", "1") - monkeypatch.setenv("AUGMENT_AGENT", "1") - monkeypatch.setenv("OPENCODE_CLIENT", "opencode") - monkeypatch.setenv("CLAUDE_CODE", "1") - monkeypatch.setenv("REPL_ID", "1") - monkeypatch.setenv("COPILOT_MODEL", "gpt-5") - monkeypatch.setenv("COPILOT_ALLOW_ALL", "true") - monkeypatch.setenv("COPILOT_GITHUB_TOKEN", "ghp_xxx") - with patch.object(Path, "exists") as mock_exists: - mock_exists.side_effect = lambda self: str(self) == DEVIN_LOCAL_PATH - result = determine_agent() - assert result == {"is_agent": True, "agent": {"name": "custom-priority"}} - - def test_cursor_trace_id_takes_priority_over_remaining_agents(self, monkeypatch): - monkeypatch.setenv("CURSOR_TRACE_ID", "some-uuid") - monkeypatch.setenv("CURSOR_AGENT", "1") - monkeypatch.setenv("GEMINI_CLI", "1") - monkeypatch.setenv("CODEX_SANDBOX", "seatbelt") - monkeypatch.setenv("ANTIGRAVITY_AGENT", "1") - monkeypatch.setenv("AUGMENT_AGENT", "1") - monkeypatch.setenv("OPENCODE_CLIENT", "opencode") - monkeypatch.setenv("CLAUDE_CODE", "1") - monkeypatch.setenv("REPL_ID", "1") - monkeypatch.setenv("COPILOT_MODEL", "gpt-5") - monkeypatch.setenv("COPILOT_ALLOW_ALL", "true") - monkeypatch.setenv("COPILOT_GITHUB_TOKEN", "ghp_xxx") - with patch.object(Path, "exists") as mock_exists: - mock_exists.side_effect = lambda self: str(self) == DEVIN_LOCAL_PATH - result = determine_agent() - assert result == {"is_agent": True, "agent": {"name": KNOWN_AGENTS["CURSOR"]}} - - def test_cursor_agent_takes_priority_over_remaining_agents(self, monkeypatch): - monkeypatch.setenv("CURSOR_AGENT", "1") - monkeypatch.setenv("GEMINI_CLI", "1") - monkeypatch.setenv("CODEX_SANDBOX", "seatbelt") - monkeypatch.setenv("ANTIGRAVITY_AGENT", "1") - monkeypatch.setenv("AUGMENT_AGENT", "1") - monkeypatch.setenv("OPENCODE_CLIENT", "opencode") - monkeypatch.setenv("CLAUDE_CODE", "1") - monkeypatch.setenv("REPL_ID", "1") - monkeypatch.setenv("COPILOT_MODEL", "gpt-5") - monkeypatch.setenv("COPILOT_ALLOW_ALL", "true") - monkeypatch.setenv("COPILOT_GITHUB_TOKEN", "ghp_xxx") - with patch.object(Path, "exists") as mock_exists: - mock_exists.side_effect = lambda self: str(self) == DEVIN_LOCAL_PATH - result = determine_agent() - assert result == {"is_agent": True, "agent": {"name": KNOWN_AGENTS["CURSOR_CLI"]}} - - -class TestEdgeCases: - """Edge cases.""" - - def test_empty_string_env_vars(self, monkeypatch): - monkeypatch.setenv("AI_AGENT", "") - monkeypatch.setenv("CURSOR_TRACE_ID", "") - result = determine_agent() - assert result == {"is_agent": False, "agent": None} - - def test_whitespace_only_ai_agent(self, monkeypatch): - monkeypatch.setenv("AI_AGENT", " ") - result = determine_agent() - assert result == {"is_agent": False, "agent": None} - - def test_special_characters_in_ai_agent(self, monkeypatch): - monkeypatch.setenv("AI_AGENT", "my-custom-agent@v1.0") - result = determine_agent() - assert result == {"is_agent": True, "agent": {"name": "my-custom-agent@v1.0"}} - - def test_trims_whitespace_from_ai_agent(self, monkeypatch): - monkeypatch.setenv("AI_AGENT", " custom-agent ") - result = determine_agent() - assert result == {"is_agent": True, "agent": {"name": "custom-agent"}} - - def test_devin_path_not_accessible_returns_no_agent(self): - with patch.object(Path, "exists", return_value=False): - result = determine_agent() - assert result == {"is_agent": False, "agent": None} - - -class TestConvenienceMethods: - """Convenience methods.""" - - def test_is_agent_boolean(self, monkeypatch): - monkeypatch.setenv("AI_AGENT", "test-agent") - result = determine_agent() - assert result["is_agent"] is True - - def test_agent_details_when_detected(self, monkeypatch): - monkeypatch.setenv("CURSOR_TRACE_ID", "some-id") - result = determine_agent() - assert result["is_agent"] is True - assert result.get("agent") is not None - assert result["agent"]["name"] == KNOWN_AGENTS["CURSOR"] - - def test_no_agent_details_when_not_detected(self): - result = determine_agent() - assert result["is_agent"] is False - assert result.get("agent") is None +class TestEvaluateCondition: + """Unit tests for condition leaves/combinators.""" + + def test_env_set_true(self, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv("SOME_VAR", "1") + assert evaluate_mod.evaluate_condition({"type": "env_set", "name": "SOME_VAR"}) + + def test_env_set_false_when_empty(self, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv("SOME_VAR", "") + assert not evaluate_mod.evaluate_condition({"type": "env_set", "name": "SOME_VAR"}) + + def test_env_value_match(self, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv("ROLE", "agent-exec") + assert evaluate_mod.evaluate_condition( + {"type": "env_value", "name": "ROLE", "value": "agent-exec"} + ) + + def test_env_matches_and_malformed_pattern(self, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv("PATH", "/home/me/.pi/agent/bin") + assert evaluate_mod.evaluate_condition( + {"type": "env_matches", "name": "PATH", "pattern": r"\.pi[\\/]agent"} + ) + assert not evaluate_mod.evaluate_condition( + {"type": "env_matches", "name": "PATH", "pattern": "("} + ) + + def test_any_of_and_all_of(self, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv("A", "1") + assert evaluate_mod.evaluate_condition( + { + "type": "anyOf", + "conditions": [ + {"type": "env_set", "name": "MISSING"}, + {"type": "env_set", "name": "A"}, + ], + } + ) + assert not evaluate_mod.evaluate_condition( + { + "type": "allOf", + "conditions": [ + {"type": "env_set", "name": "A"}, + {"type": "env_set", "name": "MISSING"}, + ], + } + ) + + def test_unknown_condition_type_is_false(self): + assert not evaluate_mod.evaluate_condition({"type": "not-a-real-type"}) diff --git a/tests/testcases.json b/tests/testcases.json new file mode 100644 index 0000000..49943ed --- /dev/null +++ b/tests/testcases.json @@ -0,0 +1,273 @@ +[ + { "name": "no agent when nothing set", "env": {}, "expectedIsAgent": false }, + + { + "name": "detects custom agent from AI_AGENT", + "env": { "AI_AGENT": "custom-agent" }, + "expectedIsAgent": true, + "expectedName": "custom-agent" + }, + + { + "name": "detects github copilot from AI_AGENT=github-copilot", + "env": { "AI_AGENT": "github-copilot" }, + "expectedIsAgent": true, + "expectedAgentKey": "GITHUB_COPILOT" + }, + { + "name": "detects github copilot from COPILOT_MODEL", + "env": { "COPILOT_MODEL": "gpt-5" }, + "expectedIsAgent": true, + "expectedAgentKey": "GITHUB_COPILOT" + }, + { + "name": "detects github copilot from COPILOT_ALLOW_ALL", + "env": { "COPILOT_ALLOW_ALL": "true" }, + "expectedIsAgent": true, + "expectedAgentKey": "GITHUB_COPILOT" + }, + { + "name": "detects github copilot from COPILOT_GITHUB_TOKEN", + "env": { "COPILOT_GITHUB_TOKEN": "ghp_xxx" }, + "expectedIsAgent": true, + "expectedAgentKey": "GITHUB_COPILOT" + }, + + { + "name": "detects cursor from CURSOR_TRACE_ID", + "env": { "CURSOR_TRACE_ID": "some-uuid" }, + "expectedIsAgent": true, + "expectedAgentKey": "CURSOR" + }, + + { + "name": "detects cursor cli from CURSOR_AGENT", + "env": { "CURSOR_AGENT": "1" }, + "expectedIsAgent": true, + "expectedAgentKey": "CURSOR_CLI" + }, + { + "name": "detects cursor cli from CURSOR_EXTENSION_HOST_ROLE=agent-exec", + "env": { "CURSOR_EXTENSION_HOST_ROLE": "agent-exec" }, + "expectedIsAgent": true, + "expectedAgentKey": "CURSOR_CLI" + }, + + { + "name": "detects gemini from GEMINI_CLI", + "env": { "GEMINI_CLI": "1" }, + "expectedIsAgent": true, + "expectedAgentKey": "GEMINI" + }, + + { + "name": "detects codex from CODEX_SANDBOX", + "env": { "CODEX_SANDBOX": "seatbelt" }, + "expectedIsAgent": true, + "expectedAgentKey": "CODEX" + }, + { + "name": "detects codex from CODEX_CI", + "env": { "CODEX_CI": "1" }, + "expectedIsAgent": true, + "expectedAgentKey": "CODEX" + }, + { + "name": "detects codex from CODEX_THREAD_ID", + "env": { "CODEX_THREAD_ID": "thread-123" }, + "expectedIsAgent": true, + "expectedAgentKey": "CODEX" + }, + + { + "name": "detects antigravity from ANTIGRAVITY_AGENT", + "env": { "ANTIGRAVITY_AGENT": "1" }, + "expectedIsAgent": true, + "expectedAgentKey": "ANTIGRAVITY" + }, + + { + "name": "detects augment cli from AUGMENT_AGENT", + "env": { "AUGMENT_AGENT": "1" }, + "expectedIsAgent": true, + "expectedAgentKey": "AUGMENT_CLI" + }, + + { + "name": "detects opencode from OPENCODE_CLIENT", + "env": { "OPENCODE_CLIENT": "opencode" }, + "expectedIsAgent": true, + "expectedAgentKey": "OPENCODE" + }, + { + "name": "detects opencode from OPENCODE", + "env": { "OPENCODE": "1" }, + "expectedIsAgent": true, + "expectedAgentKey": "OPENCODE" + }, + + { + "name": "detects goose from GOOSE_PROVIDER", + "env": { "GOOSE_PROVIDER": "anthropic" }, + "expectedIsAgent": true, + "expectedAgentKey": "GOOSE" + }, + + { + "name": "detects junie from JUNIE_DATA", + "env": { "JUNIE_DATA": "/tmp/junie" }, + "expectedIsAgent": true, + "expectedAgentKey": "JUNIE" + }, + { + "name": "detects junie from JUNIE_SHIM_PATH", + "env": { "JUNIE_SHIM_PATH": "/tmp/junie/shim" }, + "expectedIsAgent": true, + "expectedAgentKey": "JUNIE" + }, + + { + "name": "detects pi from PATH containing .pi/agent", + "env": { "PATH": "/usr/bin:/home/me/.pi/agent/bin" }, + "expectedIsAgent": true, + "expectedAgentKey": "PI" + }, + + { + "name": "detects kiro when TERM_PROGRAM=kiro and no TTY", + "env": { "TERM_PROGRAM": "kiro" }, + "tty": false, + "expectedIsAgent": true, + "expectedAgentKey": "KIRO" + }, + { + "name": "does not detect kiro when TERM_PROGRAM=kiro with a TTY", + "env": { "TERM_PROGRAM": "kiro" }, + "tty": true, + "expectedIsAgent": false + }, + + { + "name": "detects claude from CLAUDE_CODE", + "env": { "CLAUDE_CODE": "1" }, + "expectedIsAgent": true, + "expectedAgentKey": "CLAUDE" + }, + { + "name": "detects claude from CLAUDECODE", + "env": { "CLAUDECODE": "1" }, + "expectedIsAgent": true, + "expectedAgentKey": "CLAUDE" + }, + + { + "name": "detects cowork from CLAUDECODE + CLAUDE_CODE_IS_COWORK", + "env": { "CLAUDECODE": "1", "CLAUDE_CODE_IS_COWORK": "1" }, + "expectedIsAgent": true, + "expectedAgentKey": "COWORK" + }, + { + "name": "detects cowork from CLAUDE_CODE + CLAUDE_CODE_IS_COWORK", + "env": { "CLAUDE_CODE": "1", "CLAUDE_CODE_IS_COWORK": "1" }, + "expectedIsAgent": true, + "expectedAgentKey": "COWORK" + }, + { + "name": "CLAUDE_CODE_IS_COWORK alone returns no agent", + "env": { "CLAUDE_CODE_IS_COWORK": "1" }, + "expectedIsAgent": false + }, + + { + "name": "detects devin when /opt/.devin exists", + "env": {}, + "files": ["/opt/.devin"], + "expectedIsAgent": true, + "expectedAgentKey": "DEVIN", + "skipGo": true + }, + + { + "name": "detects replit from REPL_ID", + "env": { "REPL_ID": "1" }, + "expectedIsAgent": true, + "expectedAgentKey": "REPLIT" + }, + + { + "name": "AI_AGENT takes highest priority", + "env": { + "AI_AGENT": "custom-priority", + "CURSOR_TRACE_ID": "some-uuid", + "CURSOR_AGENT": "1", + "GEMINI_CLI": "1", + "CODEX_SANDBOX": "seatbelt", + "ANTIGRAVITY_AGENT": "1", + "AUGMENT_AGENT": "1", + "OPENCODE_CLIENT": "opencode", + "CLAUDE_CODE": "1", + "REPL_ID": "1", + "COPILOT_MODEL": "gpt-5", + "COPILOT_ALLOW_ALL": "true", + "COPILOT_GITHUB_TOKEN": "ghp_xxx" + }, + "expectedIsAgent": true, + "expectedName": "custom-priority" + }, + { + "name": "CURSOR_TRACE_ID takes priority over other agents", + "env": { + "CURSOR_TRACE_ID": "some-uuid", + "CURSOR_AGENT": "1", + "GEMINI_CLI": "1", + "CODEX_SANDBOX": "seatbelt", + "ANTIGRAVITY_AGENT": "1", + "AUGMENT_AGENT": "1", + "OPENCODE_CLIENT": "opencode", + "CLAUDE_CODE": "1", + "REPL_ID": "1", + "COPILOT_MODEL": "gpt-5" + }, + "expectedIsAgent": true, + "expectedAgentKey": "CURSOR" + }, + { + "name": "CURSOR_AGENT takes priority over remaining agents", + "env": { + "CURSOR_AGENT": "1", + "GEMINI_CLI": "1", + "CODEX_SANDBOX": "seatbelt", + "ANTIGRAVITY_AGENT": "1", + "AUGMENT_AGENT": "1", + "OPENCODE_CLIENT": "opencode", + "CLAUDE_CODE": "1", + "REPL_ID": "1", + "COPILOT_MODEL": "gpt-5" + }, + "expectedIsAgent": true, + "expectedAgentKey": "CURSOR_CLI" + }, + + { + "name": "empty string values return no agent", + "env": { "AI_AGENT": "", "CURSOR_TRACE_ID": "" }, + "expectedIsAgent": false + }, + { + "name": "whitespace-only AI_AGENT returns no agent", + "env": { "AI_AGENT": " " }, + "expectedIsAgent": false + }, + { + "name": "special characters in AI_AGENT are preserved", + "env": { "AI_AGENT": "my-custom-agent@v1.0" }, + "expectedIsAgent": true, + "expectedName": "my-custom-agent@v1.0" + }, + { + "name": "leading and trailing whitespace in AI_AGENT is trimmed", + "env": { "AI_AGENT": " custom-agent " }, + "expectedIsAgent": true, + "expectedName": "custom-agent" + } +]