Files
crewAI/lib/crewai/tests/telemetry/test_coding_agent_detection.py
Joao Moura 59c932cd68 feat(telemetry): widen assistant detection from published marker sets
The table previously covered three assistants because the rest were
unverified. They are documented after all: vercel/detect-agent publishes
a machine-readable detection matrix (agents.json), corroborated by the
proposal in agentsmd/agents.md#136 and by microsoft/vscode#311734.

Adds cline, gemini_cli, augment, opencode, antigravity and junie, plus
CLAUDE_CODE alongside CLAUDECODE. Gemini's marker is confirmed by its own
docs, which state that run_shell_command sets GEMINI_CLI=1 in the
subprocess environment.

Rule 2 excluded several entries those sources list. Goose's
GOOSE_PROVIDER and Copilot's COPILOT_MODEL and COPILOT_GITHUB_TOKEN are
user configuration, and a committed .env carrying one would relabel every
ordinary run - the AIDER_MODEL trap the guard test already pins, now
parametrized over all four. Replit's REPL_ID names a hosted environment
rather than an assistant, so it stays a runtime context. Copilot sets no
session marker at all today; that is an open request upstream.

The new assistants are ordered ahead of Cursor, since CURSOR_* is set for
every integrated terminal and would otherwise mask anything spawned
inside it - the same ordering Codex already needed.

Also adds the proposed cross-vendor AI_AGENT marker as a last resort,
reported as "other". It establishes that an assistant is present without
naming one, and its value is an arbitrary vendor string, so the value is
never read.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 15:33:41 -07:00

688 lines
23 KiB
Python

"""Tests for AI coding assistant detection in telemetry."""
import os
from unittest.mock import patch
import pytest
from crewai.telemetry.utils import (
KNOWN_CODING_AGENTS,
KNOWN_RUNTIME_CONTEXTS,
detect_coding_agent,
detect_runtime_context,
)
from crewai.utilities.constants import (
ANTIGRAVITY_ENV_VARS,
AUGMENT_ENV_VARS,
CC_ENV_VAR,
CC_ENV_VARS,
CLINE_ENV_VARS,
CODEX_ENV_VARS,
CODING_AGENT_ENV_MARKERS,
CURSOR_ENV_VARS,
GEMINI_CLI_ENV_VARS,
GENERIC_AGENT_ENV_VARS,
JUNIE_ENV_VARS,
OPENCODE_ENV_VARS,
RUNTIME_CONTEXT_ENV_MARKERS,
)
# Derived from the shared tables rather than restated, so adding an assistant
# or runtime there cannot leave these tests silently checking a stale set.
RUNTIME_MARKERS = tuple(
var for _, env_vars in RUNTIME_CONTEXT_ENV_MARKERS for var in env_vars
)
ALL_MARKERS = (
tuple(var for _, env_vars in CODING_AGENT_ENV_MARKERS for var in env_vars)
+ RUNTIME_MARKERS
+ ("TERM_PROGRAM", "TERMINAL_EMULATOR")
)
EVERY_RUNTIME_CASE = [
(var, context)
for context, env_vars in RUNTIME_CONTEXT_ENV_MARKERS
for var in env_vars
]
EVERY_MARKER_CASE = [
(var, agent) for agent, env_vars in CODING_AGENT_ENV_MARKERS for var in env_vars
]
@pytest.fixture
def clean_env(monkeypatch):
"""Remove every marker so each test starts from a known state."""
for var in ALL_MARKERS:
monkeypatch.delenv(var, raising=False)
return monkeypatch
@pytest.fixture
def isolated_telemetry(monkeypatch):
"""Build a fresh Telemetry without touching the process-wide singleton.
Telemetry is a singleton whose __init__ registers atexit and signal
handlers. Re-initializing the shared instance would leak state into later
tests and stack duplicate handlers, so replace _instance for the duration
of the test and suppress lifecycle registration.
"""
from crewai.telemetry.telemetry import Telemetry
monkeypatch.setattr(Telemetry, "_instance", None)
monkeypatch.setattr(Telemetry, "_register_shutdown_handlers", lambda self: None)
def build():
with patch.dict(
os.environ,
{
"CREWAI_DISABLE_TELEMETRY": "false",
"CREWAI_DISABLE_TRACKING": "false",
"OTEL_SDK_DISABLED": "false",
},
):
return Telemetry()
yield build
Telemetry._instance = None
@pytest.mark.parametrize(("env_var", "expected"), EVERY_MARKER_CASE)
def test_detects_every_marker_in_the_shared_table(clean_env, env_var, expected):
"""Every marker must map to its assistant, including Codex/Cursor extras."""
clean_env.setenv(env_var, "1")
assert detect_coding_agent() == expected
def test_shares_the_canonical_marker_sets():
"""Detection must not maintain a second, narrower set of markers.
The env-context events and telemetry previously disagreed: a session
exposing only CODEX_THREAD_ID was Codex to get_env_context() but unknown
here. Both now read the same table.
"""
by_agent = dict(CODING_AGENT_ENV_MARKERS)
assert CC_ENV_VAR in by_agent["claude_code"]
assert by_agent["codex"] is CODEX_ENV_VARS
assert by_agent["cursor"] is CURSOR_ENV_VARS
def test_codex_takes_precedence_over_cursor(clean_env):
"""Codex running inside Cursor must report codex, matching get_env_context().
Cursor sets CURSOR_* in every integrated terminal, so checking Cursor first
would mask any assistant spawned inside it.
"""
clean_env.setenv("CURSOR_TRACE_ID", "t-1")
clean_env.setenv("CODEX_THREAD_ID", "th-1")
assert detect_coding_agent() == "codex"
def test_claude_code_takes_precedence_over_cursor(clean_env):
clean_env.setenv("CURSOR_TRACE_ID", "t-1")
clean_env.setenv("CLAUDECODE", "1")
assert detect_coding_agent() == "claude_code"
def test_precedence_matches_get_env_context(clean_env):
"""The two signals must agree on which assistant is present."""
from crewai.events.types.env_events import (
CCEnvEvent,
CodexEnvEvent,
CursorEnvEvent,
)
from crewai.utilities import env as env_module
event_to_agent = {
CCEnvEvent: "claude_code",
CodexEnvEvent: "codex",
CursorEnvEvent: "cursor",
}
for markers in (
{"CLAUDECODE": "1"},
{"CODEX_THREAD_ID": "1"},
{"CURSOR_TRACE_ID": "1"},
{"CURSOR_TRACE_ID": "1", "CODEX_CI": "1"},
{"CURSOR_SANDBOX": "1", "CLAUDECODE": "1"},
):
for var in ALL_MARKERS:
clean_env.delenv(var, raising=False)
for var, value in markers.items():
clean_env.setenv(var, value)
emitted: list[type] = []
clean_env.setattr(
env_module.crewai_event_bus,
"emit",
lambda _source, event, sink=emitted: sink.append(type(event)),
)
env_module._env_context_emitted.set(False)
env_module.get_env_context()
expected = event_to_agent[emitted[0]]
assert detect_coding_agent() == expected, markers
@pytest.mark.parametrize(
"config_var",
[
"AIDER_MODEL",
"COPILOT_GITHUB_TOKEN",
"COPILOT_MODEL",
"GOOSE_PROVIDER",
],
)
def test_config_style_variables_are_not_used_as_markers(config_var):
"""Persistent user config must never be treated as a session marker.
crewai loads dotenv files on normal runs, so a committed AIDER_MODEL or
GOOSE_PROVIDER would mislabel ordinary human executions. Published
detection matrices list several of these; they are deliberately excluded
here rather than copied wholesale.
"""
all_vars = {var for _, env_vars in CODING_AGENT_ENV_MARKERS for var in env_vars}
assert config_var not in all_vars
def test_hosted_environments_are_not_reported_as_assistants():
"""REPL_ID marks a hosted environment, not an assistant driving the run."""
all_vars = {var for _, env_vars in CODING_AGENT_ENV_MARKERS for var in env_vars}
assert "REPL_ID" not in all_vars
def test_every_marker_comes_from_a_verified_set():
"""Guard against reintroducing guessed variable names.
A wrong name never matches, so the assistant is silently counted as
"unknown" while the table implies it is covered - worse than omitting it.
Adding an assistant means extending the canonical sets, which keeps both
detection paths in sync.
"""
verified = {
*ANTIGRAVITY_ENV_VARS,
*AUGMENT_ENV_VARS,
*CC_ENV_VARS,
*CLINE_ENV_VARS,
*CODEX_ENV_VARS,
*CURSOR_ENV_VARS,
*GEMINI_CLI_ENV_VARS,
*GENERIC_AGENT_ENV_VARS,
*JUNIE_ENV_VARS,
*OPENCODE_ENV_VARS,
}
declared = {var for _, env_vars in CODING_AGENT_ENV_MARKERS for var in env_vars}
assert declared == verified, (
"markers must come from the canonical per-assistant sets; "
f"unverified names present: {sorted(declared - verified)}"
)
def test_generic_marker_is_the_last_resort(clean_env):
"""AI_AGENT says an assistant is present without naming which one.
A named marker must win, so the generic entry cannot mask a specific one.
"""
clean_env.setenv("AI_AGENT", "1")
assert detect_coding_agent() == "other"
clean_env.setenv("CLINE_ACTIVE", "true")
assert detect_coding_agent() == "cline"
def test_generic_marker_value_is_never_reported(clean_env):
"""Its value is an arbitrary vendor string, so it is never read."""
clean_env.setenv("AI_AGENT", "some-unreleased-tool/2.0")
assert detect_coding_agent() == "other"
def test_terminal_bound_assistants_outrank_cursor(clean_env):
"""CURSOR_* is set for every integrated terminal.
Checking Cursor first would report cursor for anything spawned inside it,
the same trap Codex already had to be ordered around.
"""
clean_env.setenv("CURSOR_TRACE_ID", "t-1")
for marker, expected in (
("CLINE_ACTIVE", "cline"),
("GEMINI_CLI", "gemini_cli"),
("AUGMENT_AGENT", "augment"),
("OPENCODE_CLIENT", "opencode"),
):
clean_env.setenv(marker, "1")
assert detect_coding_agent() == expected, marker
clean_env.delenv(marker)
def test_concurrent_attach_registers_the_processor_once(isolated_telemetry, clean_env):
"""Check-then-act on the provider set must be locked.
Crews and flows created from different threads can both reach set_tracer()
before trace_set flips, and without a lock each would attach its own
processor to the same provider for the life of the process.
"""
import threading
import time
telemetry = isolated_telemetry()
threads_count = 8
class SlowProvider:
"""Widens the check-then-act window so the race is deterministic.
Sleeping inside add_span_processor guarantees every unlocked thread gets
past the membership check before any of them records the provider.
"""
def __init__(self) -> None:
self.processors: list[object] = []
def add_span_processor(self, processor: object) -> None:
time.sleep(0.05)
self.processors.append(processor)
provider = SlowProvider()
start = threading.Barrier(threads_count)
def attach() -> None:
start.wait()
telemetry._attach_common_attributes(provider)
threads = [threading.Thread(target=attach) for _ in range(threads_count)]
for thread in threads:
thread.start()
for thread in threads:
thread.join()
assert len(provider.processors) == 1
def test_editor_terminal_requires_exact_value(clean_env):
clean_env.setenv("TERM_PROGRAM", "vscode")
assert detect_runtime_context() == "vscode_terminal"
clean_env.setenv("TERM_PROGRAM", "iTerm.app")
assert detect_runtime_context() != "vscode_terminal"
def test_editor_terminal_is_not_reported_as_an_assistant(clean_env):
"""An editor's terminal says where a process runs, not who drove it."""
clean_env.setenv("TERM_PROGRAM", "vscode")
assert detect_coding_agent() == "unknown"
def test_empty_marker_value_is_ignored(clean_env):
clean_env.setenv("CLAUDECODE", "")
assert detect_coding_agent() != "claude_code"
@pytest.mark.parametrize(("env_var", "expected"), EVERY_RUNTIME_CASE)
def test_detects_every_runtime_marker(clean_env, env_var, expected):
"""Every runtime marker must map to its context."""
clean_env.setenv(env_var, "1")
assert detect_runtime_context() == expected
def test_runtime_precedence_prefers_the_most_specific(clean_env):
"""CI and hosted IDEs usually run in containers; the specific one wins."""
clean_env.setenv("KUBERNETES_SERVICE_HOST", "10.0.0.1")
assert detect_runtime_context() == "container"
clean_env.setenv("GITHUB_ACTIONS", "true")
assert detect_runtime_context() == "ci"
def test_an_automated_run_still_reports_an_unknown_assistant(clean_env):
"""The split must keep the two fields independent.
A CI run has no assistant to find, which is different from failing to
recognize one - the reason they no longer share a field.
"""
clean_env.setenv("CI", "true")
assert detect_runtime_context() == "ci"
assert detect_coding_agent() == "unknown"
def test_assistant_and_runtime_are_reported_together(clean_env):
"""An assistant inside CI must not mask either signal."""
clean_env.setenv("CI", "true")
clean_env.setenv("CLAUDECODE", "1")
assert detect_coding_agent() == "claude_code"
assert detect_runtime_context() == "ci"
def test_falls_back_to_non_interactive_without_tty(clean_env, monkeypatch):
monkeypatch.setattr("os.path.exists", lambda path: False)
monkeypatch.setattr("sys.stdout", type("S", (), {"isatty": lambda self: False})())
assert detect_runtime_context() == "non_interactive"
def test_falls_back_to_interactive_with_tty(clean_env, monkeypatch):
monkeypatch.setattr("os.path.exists", lambda path: False)
monkeypatch.setattr("sys.stdout", type("S", (), {"isatty": lambda self: True})())
assert detect_runtime_context() == "interactive"
def test_dockerenv_marks_a_container(clean_env, monkeypatch):
"""The container check is the last resort before the TTY fallback."""
monkeypatch.setattr("os.path.exists", lambda path: path == "/.dockerenv")
assert detect_runtime_context() == "container"
def test_unmatched_assistant_is_unknown(clean_env, monkeypatch):
"""No marker means a gap in the table, reported as unknown."""
monkeypatch.setattr("sys.stdout", type("S", (), {"isatty": lambda self: True})())
assert detect_coding_agent() == "unknown"
def test_never_returns_env_var_value(clean_env):
"""The detected name must never leak the environment variable's contents."""
secret = "sk-super-secret-token"
clean_env.setenv("CURSOR_TRACE_ID", secret)
assert secret not in detect_coding_agent()
def test_handles_broken_stdout(clean_env, monkeypatch):
class BrokenStdout:
def isatty(self):
raise ValueError("detached")
monkeypatch.setattr("os.path.exists", lambda path: False)
monkeypatch.setattr("sys.stdout", BrokenStdout())
assert detect_runtime_context() == "unknown"
def test_result_is_always_a_known_literal(clean_env):
"""PII guarantee: the return value can only ever be a known literal.
Every marker is set to a value that would be catastrophic to emit, and the
result must still come from the fixed vocabulary.
"""
sensitive = "/Users/jane.doe/secrets/api-key-sk-live-1234"
for var in ALL_MARKERS:
clean_env.setenv(var, sensitive)
agent = detect_coding_agent()
context = detect_runtime_context()
assert agent in KNOWN_CODING_AGENTS
assert context in KNOWN_RUNTIME_CONTEXTS
assert sensitive not in agent
assert sensitive not in context
clean_env.delenv(var, raising=False)
def test_known_agents_contains_no_pii_shaped_values():
"""Every possible emitted value is a short, opaque identifier."""
for name in KNOWN_CODING_AGENTS | KNOWN_RUNTIME_CONTEXTS:
assert name.replace("_", "").isalnum(), name
assert len(name) <= 32, name
def test_coding_agent_lands_on_every_exported_span(clean_env):
"""End-to-end: the attribute must appear as a *span attribute* on any span.
It cannot be a Resource attribute - the ingestion pipeline preserves only
serviceName from the resource, so anything else set there is dropped before
it reaches storage. This test exports through a real TracerProvider and
asserts the attribute survives on arbitrary spans.
"""
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
from opentelemetry.sdk.trace.export.in_memory_span_exporter import (
InMemorySpanExporter,
)
from crewai.telemetry.telemetry import CommonAttributesSpanProcessor
exporter = InMemorySpanExporter()
provider = TracerProvider()
provider.add_span_processor(
CommonAttributesSpanProcessor({"coding_agent": "claude_code"})
)
provider.add_span_processor(SimpleSpanProcessor(exporter))
tracer = provider.get_tracer("crewai.telemetry")
for name in ("Crew Created", "Task Execution", "Tool Usage", "Feature Usage"):
span = tracer.start_span(name)
span.end()
exported = exporter.get_finished_spans()
assert len(exported) == 4
for span in exported:
assert span.attributes["coding_agent"] == "claude_code", span.name
# It must be a span attribute, not a resource attribute, or ingestion drops it.
assert "coding_agent" not in exported[0].resource.attributes
def test_common_attributes_processor_never_breaks_span_creation(clean_env):
"""A failure applying attributes must not propagate into user execution."""
from crewai.telemetry.telemetry import CommonAttributesSpanProcessor
class ExplodingSpan:
def set_attributes(self, _):
raise RuntimeError("boom")
CommonAttributesSpanProcessor({"coding_agent": "cursor"}).on_start(
ExplodingSpan() # type: ignore[arg-type]
)
def test_coding_agent_span_emits_once(isolated_telemetry, clean_env, monkeypatch):
clean_env.setenv("CLAUDECODE", "1")
telemetry = isolated_telemetry()
emitted: list[str] = []
monkeypatch.setattr(telemetry, "feature_usage_span", emitted.append)
telemetry.coding_agent_span()
telemetry.coding_agent_span()
telemetry.coding_agent_span()
assert emitted == ["coding_agent:claude_code"]
def test_attribute_survives_an_externally_installed_provider(
isolated_telemetry, clean_env
):
"""Spans must keep coding_agent when the app installs its own provider.
set_tracer() leaves an existing non-proxy provider in place, and telemetry
methods resolve their tracer through the global provider - so attaching the
processor only to our own provider would drop the attribute entirely in any
already-instrumented application.
"""
from opentelemetry import trace as ot
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
from opentelemetry.sdk.trace.export.in_memory_span_exporter import (
InMemorySpanExporter,
)
clean_env.setenv("CLAUDECODE", "1")
exporter = InMemorySpanExporter()
app_provider = TracerProvider()
app_provider.add_span_processor(SimpleSpanProcessor(exporter))
with patch.object(ot, "get_tracer_provider", return_value=app_provider):
telemetry = isolated_telemetry()
telemetry.set_tracer()
span = app_provider.get_tracer("crewai.telemetry").start_span("Crew Created")
span.end()
exported = exporter.get_finished_spans()
assert len(exported) == 1
assert exported[0].attributes["coding_agent"] == "claude_code"
def test_attaching_common_attributes_is_idempotent(isolated_telemetry, clean_env):
"""Repeated set_tracer() calls must not stack duplicate processors."""
from opentelemetry.sdk.trace import TracerProvider
provider = TracerProvider()
telemetry = isolated_telemetry()
before = len(provider._active_span_processor._span_processors)
telemetry._attach_common_attributes(provider)
telemetry._attach_common_attributes(provider)
after = len(provider._active_span_processor._span_processors)
assert after - before == 1
def test_attaching_to_a_provider_without_processors_is_safe(isolated_telemetry):
"""A NoOp provider has no add_span_processor; this must not raise."""
telemetry = isolated_telemetry()
telemetry._attach_common_attributes(object())
def _common_attributes(monkeypatch, project_id=None):
"""Build the process-wide span attributes with a stubbed project id."""
from crewai.telemetry.telemetry import Telemetry
monkeypatch.setattr(
"crewai.telemetry.telemetry.get_project_id", lambda *a, **k: project_id
)
telemetry = Telemetry.__new__(Telemetry)
telemetry._common_attributes = None
return telemetry._common_span_attributes()
def test_common_attributes_carry_agent_and_runtime(clean_env, monkeypatch):
"""Both fields ride on every span, independently of each other."""
clean_env.setenv("CLAUDECODE", "1")
clean_env.setenv("GITHUB_ACTIONS", "true")
attributes = _common_attributes(monkeypatch)
assert attributes["coding_agent"] == "claude_code"
assert attributes["runtime_context"] == "ci"
def test_common_attributes_include_project_id_when_declared(clean_env, monkeypatch):
attributes = _common_attributes(monkeypatch, project_id="proj-123")
assert attributes["project_id"] == "proj-123"
def test_project_id_is_omitted_when_absent(clean_env, monkeypatch):
"""Projects without an id must not report a placeholder."""
attributes = _common_attributes(monkeypatch, project_id=None)
assert "project_id" not in attributes
def test_project_id_lookup_never_breaks_telemetry(clean_env, monkeypatch):
"""A failed lookup degrades to omitting the attribute."""
from crewai.telemetry.telemetry import Telemetry
def boom(*args, **kwargs):
raise OSError("unreadable")
monkeypatch.setattr("crewai.telemetry.telemetry.get_project_id", boom)
telemetry = Telemetry.__new__(Telemetry)
telemetry._common_attributes = None
attributes = telemetry._common_span_attributes()
assert "project_id" not in attributes
assert "coding_agent" in attributes
def test_common_attributes_are_computed_once(clean_env, monkeypatch):
"""The project file must not be re-read for each provider."""
from crewai.telemetry.telemetry import Telemetry
calls = []
def counting_get_project_id(*args, **kwargs):
calls.append(1)
return "proj-123"
monkeypatch.setattr(
"crewai.telemetry.telemetry.get_project_id", counting_get_project_id
)
telemetry = Telemetry.__new__(Telemetry)
telemetry._common_attributes = None
first = telemetry._common_span_attributes()
second = telemetry._common_span_attributes()
assert first is second
assert len(calls) == 1
def test_all_common_attributes_land_on_exported_spans(clean_env, monkeypatch):
"""End-to-end: every common attribute survives onto arbitrary spans."""
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
from opentelemetry.sdk.trace.export.in_memory_span_exporter import (
InMemorySpanExporter,
)
from crewai.telemetry.telemetry import CommonAttributesSpanProcessor
clean_env.setenv("CLAUDECODE", "1")
clean_env.setenv("CI", "true")
attributes = _common_attributes(monkeypatch, project_id="proj-123")
exporter = InMemorySpanExporter()
provider = TracerProvider()
provider.add_span_processor(CommonAttributesSpanProcessor(attributes))
provider.add_span_processor(SimpleSpanProcessor(exporter))
provider.get_tracer("test").start_span("Feature Usage").end()
provider.force_flush()
exported = dict(exporter.get_finished_spans()[0].attributes)
assert exported["coding_agent"] == "claude_code"
assert exported["runtime_context"] == "ci"
assert exported["project_id"] == "proj-123"
def test_runtime_markers_are_detected_by_presence(clean_env, monkeypatch):
"""An empty value still means the platform set the marker.
Some platforms export a bare `CI=`; truthiness checks would drop those
runs to the TTY fallback and mislabel them as ordinary local executions.
"""
monkeypatch.setattr("os.path.exists", lambda path: False)
clean_env.setenv("CI", "")
assert detect_runtime_context() == "ci"
def test_managed_platforms_are_not_reported_as_serverless(clean_env):
"""Long-lived managed platforms must not claim the serverless label.
DYNO and WEBSITE_INSTANCE_ID mark Heroku dynos and Azure App Service
instances, which are containers rather than per-invocation functions.
"""
clean_env.setenv("DYNO", "web.1")
assert detect_runtime_context() == "paas"
clean_env.delenv("DYNO")
clean_env.setenv("WEBSITE_INSTANCE_ID", "abc123")
assert detect_runtime_context() == "paas"
def test_serverless_markers_still_win_over_paas(clean_env):
clean_env.setenv("DYNO", "web.1")
clean_env.setenv("AWS_LAMBDA_FUNCTION_NAME", "my-fn")
assert detect_runtime_context() == "serverless"