use std::env;
use std::path::Path;
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct Agent {
pub id: AgentId,
pub name: &'static str,
pub signal: Signal,
pub session_id: Option<String>,
pub traceparent: Option<String>,
}
impl Agent {
pub fn trace_id(&self) -> Option<&str> {
let mut parts = self.traceparent.as_deref()?.split('-');
let _version = parts.next()?;
let trace_id = parts.next()?;
let valid = trace_id.len() == 32
&& trace_id.bytes().all(|b| b.is_ascii_hexdigit())
&& trace_id.bytes().any(|b| b != b'0');
valid.then_some(trace_id)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum AgentId {
ClaudeCode,
Cursor,
CursorCli,
GeminiCli,
Codex,
Augment,
Cline,
OpenCode,
Trae,
Goose,
Amp,
Devin,
Replit,
Antigravity,
GitHubCopilot,
Crush,
QwenCode,
IflowCli,
AmazonQCli,
RooCode,
Cowork,
CodeBuddy,
GrokCli,
Warp,
Pi,
Kiro,
Firebender,
OpenHands,
VeCli,
V0,
Unknown,
}
impl AgentId {
pub fn as_str(&self) -> &'static str {
match self {
AgentId::ClaudeCode => "claude-code",
AgentId::Cursor => "cursor",
AgentId::CursorCli => "cursor-cli",
AgentId::GeminiCli => "gemini-cli",
AgentId::Codex => "codex",
AgentId::Augment => "augment",
AgentId::Cline => "cline",
AgentId::OpenCode => "opencode",
AgentId::Trae => "trae",
AgentId::Goose => "goose",
AgentId::Amp => "amp",
AgentId::Devin => "devin",
AgentId::Replit => "replit",
AgentId::Antigravity => "antigravity",
AgentId::GitHubCopilot => "github-copilot",
AgentId::Crush => "crush",
AgentId::QwenCode => "qwen-code",
AgentId::IflowCli => "iflow-cli",
AgentId::AmazonQCli => "amazon-q-cli",
AgentId::RooCode => "roo-code",
AgentId::Cowork => "cowork",
AgentId::CodeBuddy => "codebuddy",
AgentId::GrokCli => "grok-cli",
AgentId::Warp => "warp",
AgentId::Pi => "pi",
AgentId::Kiro => "kiro",
AgentId::Firebender => "firebender",
AgentId::OpenHands => "openhands",
AgentId::VeCli => "vecli",
AgentId::V0 => "v0",
AgentId::Unknown => "unknown",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum Signal {
EnvVar { name: &'static str, value: String },
File { path: &'static str },
}
const TOOL_VARS: &[(&str, AgentId, &str)] = &[
("AMP_CURRENT_THREAD_ID", AgentId::Amp, "Amp"),
("CODEBUDDY", AgentId::CodeBuddy, "CodeBuddy"),
("CODEBUDDY_SESSION_ID", AgentId::CodeBuddy, "CodeBuddy"),
("CODEBUDDY_PROJECT_DIR", AgentId::CodeBuddy, "CodeBuddy"),
("CLAUDE_CODE_IS_COWORK", AgentId::Cowork, "Claude Cowork"),
("CLAUDECODE", AgentId::ClaudeCode, "Claude Code"),
("CLAUDE_CODE_ENTRYPOINT", AgentId::ClaudeCode, "Claude Code"),
("CLAUDE_CODE_SESSION_ID", AgentId::ClaudeCode, "Claude Code"),
("CLAUDE_CODE_EXECPATH", AgentId::ClaudeCode, "Claude Code"),
("CURSOR_AGENT", AgentId::Cursor, "Cursor"),
("CURSOR_SANDBOX", AgentId::CursorCli, "Cursor CLI"),
("QWEN_CODE", AgentId::QwenCode, "Qwen Code"),
("VECLI_SANDBOX", AgentId::VeCli, "veCLI"),
("VECLI_DIR", AgentId::VeCli, "veCLI"),
("GEMINI_CLI", AgentId::GeminiCli, "Gemini CLI"),
("CODEX_THREAD_ID", AgentId::Codex, "OpenAI Codex"),
("CODEX_SANDBOX", AgentId::Codex, "OpenAI Codex"),
(
"CODEX_SANDBOX_NETWORK_DISABLED",
AgentId::Codex,
"OpenAI Codex",
),
("CODEX_CI", AgentId::Codex, "OpenAI Codex"),
("ANTIGRAVITY_AGENT", AgentId::Antigravity, "Antigravity"),
(
"ANTIGRAVITY_PROJECT_ID",
AgentId::Antigravity,
"Antigravity",
),
("AUGMENT_AGENT", AgentId::Augment, "Augment"),
("CLINE_ACTIVE", AgentId::Cline, "Cline"),
("CLINE_TASK_ID", AgentId::Cline, "Cline"),
("ROO_CODE_TASK_ID", AgentId::RooCode, "Roo Code"),
("CRUSH", AgentId::Crush, "Crush"),
("IFLOW_CLI", AgentId::IflowCli, "iFlow CLI"),
("GROK_AGENT", AgentId::GrokCli, "Grok CLI"),
("OZ_RUN_ID", AgentId::Warp, "Warp"),
("PI_CODING_AGENT", AgentId::Pi, "Pi"),
("KIRO_AGENT_PATH", AgentId::Kiro, "Kiro"),
("FIREBENDER_TERMINAL", AgentId::Firebender, "Firebender"),
("OPENCODE", AgentId::OpenCode, "OpenCode"),
("OPENCODE_PID", AgentId::OpenCode, "OpenCode"),
("OPENCODE_BIN_PATH", AgentId::OpenCode, "OpenCode"),
("OPENCODE_SERVER", AgentId::OpenCode, "OpenCode"),
("OPENCODE_APP_INFO", AgentId::OpenCode, "OpenCode"),
("OPENCODE_MODES", AgentId::OpenCode, "OpenCode"),
("OPENCODE_CLIENT", AgentId::OpenCode, "OpenCode"),
("TRAE_AI_SHELL_ID", AgentId::Trae, "TRAE AI"),
("GOOSE_TERMINAL", AgentId::Goose, "Goose"),
("REPL_ID", AgentId::Replit, "Replit"),
(
"COPILOT_AGENT_SESSION_ID",
AgentId::GitHubCopilot,
"GitHub Copilot",
),
("COPILOT_AGENT", AgentId::GitHubCopilot, "GitHub Copilot"),
("COPILOT_CLI", AgentId::GitHubCopilot, "GitHub Copilot"),
(
"COPILOT_AGENT_JOB_ID",
AgentId::GitHubCopilot,
"GitHub Copilot",
),
("COPILOT_MODEL", AgentId::GitHubCopilot, "GitHub Copilot"),
(
"COPILOT_ALLOW_ALL",
AgentId::GitHubCopilot,
"GitHub Copilot",
),
(
"COPILOT_GITHUB_TOKEN",
AgentId::GitHubCopilot,
"GitHub Copilot",
),
];
const FILE_SIGNALS: &[(&str, AgentId, &str)] = &[("/opt/.devin", AgentId::Devin, "Devin")];
const SESSION_ID_VARS: &[(AgentId, &[&str])] = &[
(AgentId::ClaudeCode, &["CLAUDE_CODE_SESSION_ID"]),
(AgentId::Cowork, &["CLAUDE_CODE_SESSION_ID"]),
(
AgentId::CodeBuddy,
&["CODEBUDDY_SESSION_ID", "CLAUDE_SESSION_ID"],
),
(AgentId::Codex, &["CODEX_THREAD_ID"]),
(AgentId::Amp, &["AMP_CURRENT_THREAD_ID", "AGENT_THREAD_ID"]),
(AgentId::QwenCode, &["QWEN_CODE_SESSION_ID"]),
(AgentId::Cursor, &["CURSOR_TRACE_ID"]),
(AgentId::CursorCli, &["CURSOR_TRACE_ID"]),
(
AgentId::GitHubCopilot,
&["COPILOT_AGENT_SESSION_ID", "COPILOT_AGENT_JOB_ID"],
),
(AgentId::Warp, &["OZ_RUN_ID"]),
(AgentId::Cline, &["CLINE_TASK_ID"]),
(AgentId::RooCode, &["ROO_CODE_TASK_ID"]),
];
pub fn is_ai_agent() -> bool {
detect().is_some()
}
pub fn detect() -> Option<Agent> {
detect_with(|name| env::var(name).ok(), |path| Path::new(path).exists())
}
pub fn detect_with<E, F>(env: E, file_exists: F) -> Option<Agent>
where
E: Fn(&str) -> Option<String>,
F: Fn(&str) -> bool,
{
let make = |id: AgentId, name: &'static str, signal: Signal| {
let (id, name) =
if id == AgentId::ClaudeCode && nonempty(env("CLAUDE_CODE_IS_COWORK")).is_some() {
(AgentId::Cowork, "Claude Cowork")
} else {
(id, name)
};
Agent {
id,
name,
signal,
session_id: session_id_for(id, &env),
traceparent: nonempty(env("TRACEPARENT")),
}
};
let mut generic_fallback = None;
for var in ["AGENT", "AI_AGENT"] {
if let Some(value) = nonempty(env(var)) {
let (id, name) = classify_generic_value(&value);
let agent = make(id, name, Signal::EnvVar { name: var, value });
if id != AgentId::Unknown {
return Some(agent);
}
if generic_fallback.is_none() {
generic_fallback = Some(agent);
}
}
}
if let Some(value) = nonempty(env("CURSOR_EXTENSION_HOST_ROLE"))
&& value.trim() == "agent-exec"
{
return Some(make(
AgentId::CursorCli,
"Cursor CLI",
Signal::EnvVar {
name: "CURSOR_EXTENSION_HOST_ROLE",
value,
},
));
}
if let Some(value) = nonempty(env("CURSOR_TRACE_ID"))
&& env("PAGER").as_deref() == Some("head -n 10000 | cat")
{
return Some(make(
AgentId::Cursor,
"Cursor",
Signal::EnvVar {
name: "CURSOR_TRACE_ID",
value,
},
));
}
if let Some(value) = nonempty(env("AWS_EXECUTION_ENV"))
&& value.contains("AmazonQ-For-CLI")
{
return Some(make(
AgentId::AmazonQCli,
"Amazon Q Developer CLI",
Signal::EnvVar {
name: "AWS_EXECUTION_ENV",
value,
},
));
}
if let Some(value) = nonempty(env("AGENT_CONTEXT_OUT"))
&& nonempty(env("AGENT_DISPLAY_OUT")).is_some()
{
return Some(make(
AgentId::Kiro,
"Kiro",
Signal::EnvVar {
name: "AGENT_CONTEXT_OUT",
value,
},
));
}
for var in ["PS1", "PROMPT_COMMAND"] {
if let Some(value) = nonempty(env(var))
&& value.contains("###PS1JSON###")
{
return Some(make(
AgentId::OpenHands,
"OpenHands",
Signal::EnvVar { name: var, value },
));
}
}
for &(var, id, name) in TOOL_VARS {
if let Some(value) = nonempty(env(var)) {
return Some(make(id, name, Signal::EnvVar { name: var, value }));
}
}
for &(path, id, name) in FILE_SIGNALS {
if file_exists(path) {
return Some(make(id, name, Signal::File { path }));
}
}
generic_fallback
}
fn session_id_for<E>(id: AgentId, env: &E) -> Option<String>
where
E: Fn(&str) -> Option<String>,
{
let vars = SESSION_ID_VARS
.iter()
.find(|(agent, _)| *agent == id)
.map(|(_, vars)| *vars)?;
vars.iter().find_map(|var| nonempty(env(var)))
}
fn nonempty(v: Option<String>) -> Option<String> {
v.filter(|s| !s.is_empty())
}
fn classify_generic_value(value: &str) -> (AgentId, &'static str) {
match classify_agent_value(value) {
(AgentId::Unknown, _) => classify_agent_value(agent_name_part(value)),
known => known,
}
}
fn agent_name_part(value: &str) -> &str {
let name = value.trim();
let name = name.split('@').next().unwrap_or(name);
name.split('_').next().unwrap_or(name)
}
fn classify_agent_value(value: &str) -> (AgentId, &'static str) {
match value.trim().to_ascii_lowercase().as_str() {
"goose" => (AgentId::Goose, "Goose"),
"amp" => (AgentId::Amp, "Amp"),
"claude" | "claude-code" | "claudecode" => (AgentId::ClaudeCode, "Claude Code"),
"cursor" => (AgentId::Cursor, "Cursor"),
"cursor-cli" => (AgentId::CursorCli, "Cursor CLI"),
"gemini" | "gemini-cli" => (AgentId::GeminiCli, "Gemini CLI"),
"codex" => (AgentId::Codex, "OpenAI Codex"),
"augment" | "augment-cli" => (AgentId::Augment, "Augment"),
"cline" => (AgentId::Cline, "Cline"),
"opencode" => (AgentId::OpenCode, "OpenCode"),
"trae" => (AgentId::Trae, "TRAE AI"),
"devin" => (AgentId::Devin, "Devin"),
"replit" => (AgentId::Replit, "Replit"),
"antigravity" => (AgentId::Antigravity, "Antigravity"),
"github-copilot" | "github-copilot-cli" | "github_copilot_vscode_agent" => {
(AgentId::GitHubCopilot, "GitHub Copilot")
}
"crush" => (AgentId::Crush, "Crush"),
"qwen" | "qwen-code" | "qwencode" => (AgentId::QwenCode, "Qwen Code"),
"iflow" | "iflow-cli" => (AgentId::IflowCli, "iFlow CLI"),
"amazonq" | "amazon-q" | "amazon-q-cli" => (AgentId::AmazonQCli, "Amazon Q Developer CLI"),
"roo" | "roo-code" | "roocode" => (AgentId::RooCode, "Roo Code"),
"cowork" => (AgentId::Cowork, "Claude Cowork"),
"codebuddy" => (AgentId::CodeBuddy, "CodeBuddy"),
"grok" | "grok-cli" => (AgentId::GrokCli, "Grok CLI"),
"warp" | "oz" => (AgentId::Warp, "Warp"),
"pi" => (AgentId::Pi, "Pi"),
"kiro" | "kiro-cli" => (AgentId::Kiro, "Kiro"),
"firebender" => (AgentId::Firebender, "Firebender"),
"openhands" => (AgentId::OpenHands, "OpenHands"),
"vecli" => (AgentId::VeCli, "veCLI"),
"v0" => (AgentId::V0, "v0"),
_ => (AgentId::Unknown, "AI agent"),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
fn env_from(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> + use<> {
let map: HashMap<String, String> = pairs
.iter()
.map(|(k, v)| ((*k).to_string(), (*v).to_string()))
.collect();
move |name| map.get(name).cloned()
}
#[test]
fn returns_none_when_nothing_set() {
let env = env_from(&[]);
assert!(detect_with(env, |_| false).is_none());
}
#[test]
fn agent_var_with_known_name_classifies() {
let env = env_from(&[("AGENT", "goose")]);
let agent = detect_with(env, |_| false).unwrap();
assert_eq!(agent.id, AgentId::Goose);
assert_eq!(
agent.signal,
Signal::EnvVar {
name: "AGENT",
value: "goose".to_string()
}
);
}
#[test]
fn agent_var_normalizes_case_and_aliases() {
let env = env_from(&[("AGENT", "Claude-Code")]);
assert_eq!(detect_with(env, |_| false).unwrap().id, AgentId::ClaudeCode);
}
#[test]
fn agent_var_with_truthy_value_is_unknown() {
let env = env_from(&[("AGENT", "1")]);
let agent = detect_with(env, |_| false).unwrap();
assert_eq!(agent.id, AgentId::Unknown);
assert_eq!(agent.name, "AI agent");
}
#[test]
fn agent_var_takes_priority_over_tool_var() {
let env = env_from(&[("AGENT", "amp"), ("CLAUDECODE", "1")]);
assert_eq!(detect_with(env, |_| false).unwrap().id, AgentId::Amp);
}
#[test]
fn tool_var_falls_back_when_agent_unset() {
let env = env_from(&[("CURSOR_AGENT", "1")]);
let agent = detect_with(env, |_| false).unwrap();
assert_eq!(agent.id, AgentId::Cursor);
assert_eq!(
agent.signal,
Signal::EnvVar {
name: "CURSOR_AGENT",
value: "1".to_string()
}
);
}
#[test]
fn empty_var_value_is_ignored() {
let env = env_from(&[("AGENT", ""), ("CLAUDECODE", "1")]);
assert_eq!(detect_with(env, |_| false).unwrap().id, AgentId::ClaudeCode);
}
#[test]
fn devin_marker_file_detected() {
let env = env_from(&[]);
let agent = detect_with(env, |p| p == "/opt/.devin").unwrap();
assert_eq!(agent.id, AgentId::Devin);
assert_eq!(
agent.signal,
Signal::File {
path: "/opt/.devin"
}
);
}
#[test]
fn env_vars_take_priority_over_files() {
let env = env_from(&[("CLAUDECODE", "1")]);
assert_eq!(detect_with(env, |_| true).unwrap().id, AgentId::ClaudeCode);
}
#[test]
fn claude_code_sibling_vars_detected() {
for var in [
"CLAUDE_CODE_ENTRYPOINT",
"CLAUDE_CODE_SESSION_ID",
"CLAUDE_CODE_EXECPATH",
] {
let env = env_from(&[(var, "x")]);
let agent = detect_with(env, |_| false).unwrap();
assert_eq!(agent.id, AgentId::ClaudeCode, "var={var}");
}
}
#[test]
fn ai_agent_var_with_known_name_classifies() {
let env = env_from(&[("AI_AGENT", "crush")]);
let agent = detect_with(env, |_| false).unwrap();
assert_eq!(agent.id, AgentId::Crush);
assert_eq!(
agent.signal,
Signal::EnvVar {
name: "AI_AGENT",
value: "crush".to_string()
}
);
}
#[test]
fn ai_agent_var_strips_version_suffixes() {
let env = env_from(&[("AI_AGENT", "claude-code_2.1.0_cli")]);
assert_eq!(detect_with(env, |_| false).unwrap().id, AgentId::ClaudeCode);
let env = env_from(&[("AI_AGENT", "goose@1.2.3")]);
assert_eq!(detect_with(env, |_| false).unwrap().id, AgentId::Goose);
let env = env_from(&[("AI_AGENT", "claude-code_2-1-201_agent")]);
assert_eq!(detect_with(env, |_| false).unwrap().id, AgentId::ClaudeCode);
}
#[test]
fn vscode_copilot_underscore_name_classifies_whole() {
let env = env_from(&[("AI_AGENT", "github_copilot_vscode_agent")]);
let agent = detect_with(env, |_| false).unwrap();
assert_eq!(agent.id, AgentId::GitHubCopilot);
}
#[test]
fn v0_classified_with_and_without_version() {
for val in ["v0", "v0@1.2.3"] {
let env = env_from(&[("AI_AGENT", val)]);
assert_eq!(detect_with(env, |_| false).unwrap().id, AgentId::V0);
}
}
#[test]
fn cowork_marker_refines_claude_code() {
let env = env_from(&[("CLAUDECODE", "1"), ("CLAUDE_CODE_IS_COWORK", "1")]);
let agent = detect_with(env, |_| false).unwrap();
assert_eq!(agent.id, AgentId::Cowork);
assert_eq!(agent.name, "Claude Cowork");
let env = env_from(&[
("AI_AGENT", "claude-code_2-1-201_agent"),
("CLAUDE_CODE_IS_COWORK", "1"),
]);
assert_eq!(detect_with(env, |_| false).unwrap().id, AgentId::Cowork);
}
#[test]
fn cowork_marker_alone_detected() {
let env = env_from(&[("CLAUDE_CODE_IS_COWORK", "1")]);
assert_eq!(detect_with(env, |_| false).unwrap().id, AgentId::Cowork);
}
#[test]
fn cowork_shares_claude_code_session_id() {
let env = env_from(&[
("CLAUDECODE", "1"),
("CLAUDE_CODE_IS_COWORK", "1"),
("CLAUDE_CODE_SESSION_ID", "s-1"),
]);
let agent = detect_with(env, |_| false).unwrap();
assert_eq!(agent.id, AgentId::Cowork);
assert_eq!(agent.session_id.as_deref(), Some("s-1"));
}
#[test]
fn codebuddy_detected_with_session_id() {
let env = env_from(&[("CODEBUDDY", "1"), ("CODEBUDDY_SESSION_ID", "cb-1")]);
let agent = detect_with(env, |_| false).unwrap();
assert_eq!(agent.id, AgentId::CodeBuddy);
assert_eq!(agent.session_id.as_deref(), Some("cb-1"));
}
#[test]
fn codebuddy_falls_back_to_claude_session_mirror() {
let env = env_from(&[("CODEBUDDY", "1"), ("CLAUDE_SESSION_ID", "cb-2")]);
let agent = detect_with(env, |_| false).unwrap();
assert_eq!(agent.id, AgentId::CodeBuddy);
assert_eq!(agent.session_id.as_deref(), Some("cb-2"));
}
#[test]
fn vecli_marker_outranks_gemini_cli_var() {
let env = env_from(&[("VECLI_DIR", "/x"), ("GEMINI_CLI", "1")]);
assert_eq!(detect_with(env, |_| false).unwrap().id, AgentId::VeCli);
}
#[test]
fn kiro_cli_detected_via_fifo_pair() {
let env = env_from(&[
("AGENT_CONTEXT_OUT", "/tmp/a"),
("AGENT_DISPLAY_OUT", "/tmp/b"),
]);
let agent = detect_with(env, |_| false).unwrap();
assert_eq!(agent.id, AgentId::Kiro);
assert_eq!(
agent.signal,
Signal::EnvVar {
name: "AGENT_CONTEXT_OUT",
value: "/tmp/a".to_string()
}
);
}
#[test]
fn kiro_fifo_var_alone_is_ignored() {
for var in ["AGENT_CONTEXT_OUT", "AGENT_DISPLAY_OUT"] {
let env = env_from(&[(var, "/tmp/a")]);
assert!(detect_with(env, |_| false).is_none(), "var={var}");
}
}
#[test]
fn openhands_detected_via_prompt_marker() {
for var in ["PS1", "PROMPT_COMMAND"] {
let env = env_from(&[(var, "###PS1JSON###{\"pid\":1}###PS1END###")]);
let agent = detect_with(env, |_| false).unwrap();
assert_eq!(agent.id, AgentId::OpenHands, "var={var}");
}
}
#[test]
fn ordinary_prompt_vars_ignored() {
let env = env_from(&[("PS1", "\\u@\\h \\w $ "), ("PROMPT_COMMAND", "history -a")]);
assert!(detect_with(env, |_| false).is_none());
}
#[test]
fn warp_run_id_doubles_as_session_id() {
let env = env_from(&[("OZ_RUN_ID", "run-42")]);
let agent = detect_with(env, |_| false).unwrap();
assert_eq!(agent.id, AgentId::Warp);
assert_eq!(agent.session_id.as_deref(), Some("run-42"));
}
#[test]
fn truthy_agent_var_defers_to_tool_vars() {
let env = env_from(&[("AGENT", "1"), ("OPENCODE", "1")]);
let agent = detect_with(env, |_| false).unwrap();
assert_eq!(agent.id, AgentId::OpenCode);
assert_eq!(
agent.signal,
Signal::EnvVar {
name: "OPENCODE",
value: "1".to_string()
}
);
}
#[test]
fn truthy_agent_var_defers_to_file_signals() {
let env = env_from(&[("AGENT", "true")]);
let agent = detect_with(env, |p| p == "/opt/.devin").unwrap();
assert_eq!(agent.id, AgentId::Devin);
}
#[test]
fn truthy_ai_agent_var_is_unknown_fallback() {
let env = env_from(&[("AI_AGENT", "1")]);
let agent = detect_with(env, |_| false).unwrap();
assert_eq!(agent.id, AgentId::Unknown);
assert_eq!(
agent.signal,
Signal::EnvVar {
name: "AI_AGENT",
value: "1".to_string()
}
);
}
#[test]
fn amp_marker_outranks_claudecode_compat_var() {
let env = env_from(&[("AMP_CURRENT_THREAD_ID", "t-1"), ("CLAUDECODE", "1")]);
assert_eq!(detect_with(env, |_| false).unwrap().id, AgentId::Amp);
}
#[test]
fn qwen_code_outranks_gemini_cli_var() {
let env = env_from(&[("QWEN_CODE", "1"), ("GEMINI_CLI", "1")]);
assert_eq!(detect_with(env, |_| false).unwrap().id, AgentId::QwenCode);
}
#[test]
fn full_agent_environments_resolve_to_expected_identity() {
let cases: &[(&str, &[(&str, &str)], AgentId)] = &[
(
"amp",
&[
("AGENT", "amp"),
("AGENT_THREAD_ID", "T-1"),
("AMP_CURRENT_THREAD_ID", "T-1"),
("CLAUDECODE", "1"),
],
AgentId::Amp,
),
(
"crush",
&[("CRUSH", "1"), ("AGENT", "crush"), ("AI_AGENT", "crush")],
AgentId::Crush,
),
(
"opencode",
&[("AGENT", "1"), ("OPENCODE", "1"), ("OPENCODE_PID", "123")],
AgentId::OpenCode,
),
(
"goose",
&[("AGENT", "goose"), ("GOOSE_TERMINAL", "1")],
AgentId::Goose,
),
(
"claude-code",
&[
("AI_AGENT", "claude-code_2-1-201_agent"),
("CLAUDECODE", "1"),
("CLAUDE_CODE_ENTRYPOINT", "cli"),
("CLAUDE_CODE_SESSION_ID", "s-1"),
],
AgentId::ClaudeCode,
),
(
"cowork",
&[
("AI_AGENT", "claude-code_2-1-201_agent"),
("CLAUDECODE", "1"),
("CLAUDE_CODE_ENTRYPOINT", "cli"),
("CLAUDE_CODE_SESSION_ID", "s-1"),
("CLAUDE_CODE_IS_COWORK", "1"),
],
AgentId::Cowork,
),
(
"codebuddy",
&[
("CODEBUDDY", "1"),
("CODEBUDDY_SESSION_ID", "cb-1"),
("CODEBUDDY_PROJECT_DIR", "/p"),
("CLAUDE_SESSION_ID", "cb-1"),
("CLAUDE_PROJECT_DIR", "/p"),
],
AgentId::CodeBuddy,
),
(
"qwen-code",
&[
("QWEN_CODE", "1"),
("GEMINI_CLI", "1"),
("QWEN_CODE_SESSION_ID", "q-1"),
],
AgentId::QwenCode,
),
(
"vecli",
&[("VECLI_DIR", "/x"), ("GEMINI_CLI", "1")],
AgentId::VeCli,
),
(
"vscode-copilot",
&[
("AI_AGENT", "github_copilot_vscode_agent"),
("COPILOT_AGENT", "1"),
("TERM_PROGRAM", "vscode"),
("GIT_PAGER", "cat"),
],
AgentId::GitHubCopilot,
),
(
"cursor-cli",
&[
("CURSOR_AGENT", "1"),
("CURSOR_EXTENSION_HOST_ROLE", "agent-exec"),
("CURSOR_TRACE_ID", "t-1"),
],
AgentId::CursorCli,
),
(
"grok",
&[("GROK_AGENT", "1"), ("CLAUDE_PROJECT_DIR", "/p")],
AgentId::GrokCli,
),
];
for (label, vars, expected) in cases {
let env = env_from(vars);
let agent = detect_with(env, |_| false).unwrap();
assert_eq!(agent.id, *expected, "fixture={label}");
}
}
#[test]
fn cowork_refinement_does_not_hijack_other_agents() {
let env = env_from(&[
("AMP_CURRENT_THREAD_ID", "t-1"),
("CLAUDECODE", "1"),
("CLAUDE_CODE_IS_COWORK", "1"),
]);
assert_eq!(detect_with(env, |_| false).unwrap().id, AgentId::Amp);
}
#[test]
fn claude_mirror_vars_alone_do_not_trigger_claude_code() {
let env = env_from(&[("CLAUDE_SESSION_ID", "x"), ("CLAUDE_PROJECT_DIR", "/p")]);
assert!(detect_with(env, |_| false).is_none());
}
#[test]
fn truthy_agent_var_defers_to_special_cases() {
let env = env_from(&[
("AGENT", "1"),
("AGENT_CONTEXT_OUT", "/tmp/a"),
("AGENT_DISPLAY_OUT", "/tmp/b"),
]);
assert_eq!(detect_with(env, |_| false).unwrap().id, AgentId::Kiro);
let env = env_from(&[("AGENT", "1"), ("PS1", "###PS1JSON###{}###PS1END###")]);
assert_eq!(detect_with(env, |_| false).unwrap().id, AgentId::OpenHands);
}
#[test]
fn new_tool_vars_detected() {
for (var, expected) in [
("CRUSH", AgentId::Crush),
("QWEN_CODE", AgentId::QwenCode),
("IFLOW_CLI", AgentId::IflowCli),
("ROO_CODE_TASK_ID", AgentId::RooCode),
("CLINE_TASK_ID", AgentId::Cline),
("OPENCODE", AgentId::OpenCode),
("OPENCODE_PID", AgentId::OpenCode),
("OPENCODE_BIN_PATH", AgentId::OpenCode),
("OPENCODE_SERVER", AgentId::OpenCode),
("OPENCODE_APP_INFO", AgentId::OpenCode),
("OPENCODE_MODES", AgentId::OpenCode),
("CURSOR_SANDBOX", AgentId::CursorCli),
("CODEX_SANDBOX_NETWORK_DISABLED", AgentId::Codex),
("AMP_CURRENT_THREAD_ID", AgentId::Amp),
("COPILOT_AGENT_SESSION_ID", AgentId::GitHubCopilot),
("COPILOT_AGENT", AgentId::GitHubCopilot),
("COPILOT_CLI", AgentId::GitHubCopilot),
("COPILOT_AGENT_JOB_ID", AgentId::GitHubCopilot),
("ANTIGRAVITY_PROJECT_ID", AgentId::Antigravity),
("CODEBUDDY", AgentId::CodeBuddy),
("CLAUDE_CODE_IS_COWORK", AgentId::Cowork),
("GROK_AGENT", AgentId::GrokCli),
("OZ_RUN_ID", AgentId::Warp),
("PI_CODING_AGENT", AgentId::Pi),
("KIRO_AGENT_PATH", AgentId::Kiro),
("FIREBENDER_TERMINAL", AgentId::Firebender),
("VECLI_DIR", AgentId::VeCli),
("VECLI_SANDBOX", AgentId::VeCli),
] {
let env = env_from(&[(var, "1")]);
let agent = detect_with(env, |_| false).unwrap();
assert_eq!(agent.id, expected, "var={var}");
}
}
#[test]
fn amazon_q_detected_via_aws_execution_env_substring() {
let env = env_from(&[("AWS_EXECUTION_ENV", "AmazonQ-For-CLI Version/1.16.0")]);
let agent = detect_with(env, |_| false).unwrap();
assert_eq!(agent.id, AgentId::AmazonQCli);
}
#[test]
fn aws_execution_env_without_amazon_q_ignored() {
let env = env_from(&[("AWS_EXECUTION_ENV", "AWS_Lambda_nodejs22.x")]);
assert!(detect_with(env, |_| false).is_none());
}
#[test]
fn cursor_trace_id_with_pager_override_detected() {
let env = env_from(&[
("CURSOR_TRACE_ID", "abc123"),
("PAGER", "head -n 10000 | cat"),
]);
let agent = detect_with(env, |_| false).unwrap();
assert_eq!(agent.id, AgentId::Cursor);
assert_eq!(
agent.signal,
Signal::EnvVar {
name: "CURSOR_TRACE_ID",
value: "abc123".to_string()
}
);
}
#[test]
fn cursor_trace_id_alone_is_interactive_not_agent() {
let env = env_from(&[("CURSOR_TRACE_ID", "abc123")]);
assert!(detect_with(env, |_| false).is_none());
}
#[test]
fn cursor_agent_var_maps_to_generic_cursor() {
let env = env_from(&[("CURSOR_AGENT", "1")]);
let agent = detect_with(env, |_| false).unwrap();
assert_eq!(agent.id, AgentId::Cursor);
assert_eq!(agent.name, "Cursor");
}
#[test]
fn cursor_cli_detected_via_extension_host_role() {
let env = env_from(&[("CURSOR_EXTENSION_HOST_ROLE", "agent-exec")]);
let agent = detect_with(env, |_| false).unwrap();
assert_eq!(agent.id, AgentId::CursorCli);
}
#[test]
fn cursor_extension_host_role_other_value_ignored() {
let env = env_from(&[("CURSOR_EXTENSION_HOST_ROLE", "ui")]);
assert!(detect_with(env, |_| false).is_none());
}
#[test]
fn codex_alternate_signals_detected() {
for var in ["CODEX_SANDBOX", "CODEX_CI", "CODEX_THREAD_ID"] {
let env = env_from(&[(var, "1")]);
let agent = detect_with(env, |_| false).unwrap();
assert_eq!(agent.id, AgentId::Codex, "var={var}");
}
}
#[test]
fn antigravity_detected() {
let env = env_from(&[("ANTIGRAVITY_AGENT", "1")]);
assert_eq!(
detect_with(env, |_| false).unwrap().id,
AgentId::Antigravity
);
}
#[test]
fn replit_detected() {
let env = env_from(&[("REPL_ID", "x")]);
assert_eq!(detect_with(env, |_| false).unwrap().id, AgentId::Replit);
}
#[test]
fn github_copilot_detected_via_each_var() {
for var in ["COPILOT_MODEL", "COPILOT_ALLOW_ALL", "COPILOT_GITHUB_TOKEN"] {
let env = env_from(&[(var, "1")]);
let agent = detect_with(env, |_| false).unwrap();
assert_eq!(agent.id, AgentId::GitHubCopilot, "var={var}");
}
}
#[test]
fn claude_code_session_id_extracted() {
let env = env_from(&[
("CLAUDECODE", "1"),
(
"CLAUDE_CODE_SESSION_ID",
"c3bb91d6-b133-4b8f-8ef5-7b4824bf9e00",
),
]);
let agent = detect_with(env, |_| false).unwrap();
assert_eq!(agent.id, AgentId::ClaudeCode);
assert_eq!(
agent.session_id.as_deref(),
Some("c3bb91d6-b133-4b8f-8ef5-7b4824bf9e00")
);
}
#[test]
fn session_id_resolved_on_generic_var_path() {
let env = env_from(&[
("AI_AGENT", "claude-code_2.1.179_agent"),
("CLAUDE_CODE_SESSION_ID", "abc"),
]);
let agent = detect_with(env, |_| false).unwrap();
assert_eq!(agent.id, AgentId::ClaudeCode);
assert_eq!(agent.session_id.as_deref(), Some("abc"));
}
#[test]
fn session_id_none_when_agent_has_no_session_var() {
let env = env_from(&[("AGENT", "goose")]);
let agent = detect_with(env, |_| false).unwrap();
assert_eq!(agent.id, AgentId::Goose);
assert_eq!(agent.session_id, None);
}
#[test]
fn session_id_none_when_var_absent() {
let env = env_from(&[("CLAUDECODE", "1")]);
let agent = detect_with(env, |_| false).unwrap();
assert_eq!(agent.id, AgentId::ClaudeCode);
assert_eq!(agent.session_id, None);
}
#[test]
fn codex_thread_id_is_session_id() {
let env = env_from(&[("CODEX_THREAD_ID", "th-123")]);
let agent = detect_with(env, |_| false).unwrap();
assert_eq!(agent.id, AgentId::Codex);
assert_eq!(agent.session_id.as_deref(), Some("th-123"));
}
#[test]
fn amp_session_id_prefers_current_thread_then_falls_back() {
let env = env_from(&[("AMP_CURRENT_THREAD_ID", "T-primary")]);
assert_eq!(
detect_with(env, |_| false).unwrap().session_id.as_deref(),
Some("T-primary")
);
let env = env_from(&[("AGENT", "amp"), ("AGENT_THREAD_ID", "T-fallback")]);
let agent = detect_with(env, |_| false).unwrap();
assert_eq!(agent.id, AgentId::Amp);
assert_eq!(agent.session_id.as_deref(), Some("T-fallback"));
}
#[test]
fn qwen_code_session_id_extracted() {
let env = env_from(&[("QWEN_CODE", "1"), ("QWEN_CODE_SESSION_ID", "q-1")]);
let agent = detect_with(env, |_| false).unwrap();
assert_eq!(agent.id, AgentId::QwenCode);
assert_eq!(agent.session_id.as_deref(), Some("q-1"));
}
#[test]
fn cursor_trace_id_doubles_as_session_id() {
let env = env_from(&[
("CURSOR_TRACE_ID", "trace-9"),
("PAGER", "head -n 10000 | cat"),
]);
let agent = detect_with(env, |_| false).unwrap();
assert_eq!(agent.id, AgentId::Cursor);
assert_eq!(agent.session_id.as_deref(), Some("trace-9"));
}
#[test]
fn copilot_session_id_extracted() {
let env = env_from(&[("COPILOT_AGENT_SESSION_ID", "sess-7")]);
let agent = detect_with(env, |_| false).unwrap();
assert_eq!(agent.id, AgentId::GitHubCopilot);
assert_eq!(agent.session_id.as_deref(), Some("sess-7"));
}
#[test]
fn traceparent_surfaced_and_trace_id_parsed() {
let tp = "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01";
let env = env_from(&[("CLAUDECODE", "1"), ("TRACEPARENT", tp)]);
let agent = detect_with(env, |_| false).unwrap();
assert_eq!(agent.traceparent.as_deref(), Some(tp));
assert_eq!(agent.trace_id(), Some("0af7651916cd43dd8448eb211c80319c"));
}
#[test]
fn traceparent_none_when_unset() {
let env = env_from(&[("CLAUDECODE", "1")]);
let agent = detect_with(env, |_| false).unwrap();
assert_eq!(agent.traceparent, None);
assert_eq!(agent.trace_id(), None);
}
#[test]
fn traceparent_read_for_any_detected_agent() {
let tp = "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01";
let env = env_from(&[("AGENT", "goose"), ("TRACEPARENT", tp)]);
let agent = detect_with(env, |_| false).unwrap();
assert_eq!(agent.id, AgentId::Goose);
assert_eq!(agent.traceparent.as_deref(), Some(tp));
}
#[test]
fn trace_id_rejects_malformed_traceparent() {
for bad in [
"garbage",
"00-tooshort-b7ad6b7169203331-01",
"00-00000000000000000000000000000000-b7ad6b7169203331-01",
"00-0af7651916cd43dd8448eb211c80319g-b7ad6b7169203331-01",
] {
let env = env_from(&[("CLAUDECODE", "1"), ("TRACEPARENT", bad)]);
let agent = detect_with(env, |_| false).unwrap();
assert_eq!(agent.trace_id(), None, "traceparent={bad}");
}
}
#[test]
fn as_str_returns_url_safe_slug() {
assert_eq!(AgentId::ClaudeCode.as_str(), "claude-code");
assert_eq!(AgentId::CursorCli.as_str(), "cursor-cli");
assert_eq!(AgentId::GitHubCopilot.as_str(), "github-copilot");
assert_eq!(AgentId::Goose.as_str(), "goose");
assert_eq!(AgentId::Unknown.as_str(), "unknown");
}
#[test]
fn as_str_round_trips_through_agent_var() {
for id in [
AgentId::ClaudeCode,
AgentId::Cursor,
AgentId::CursorCli,
AgentId::GeminiCli,
AgentId::Codex,
AgentId::Augment,
AgentId::Cline,
AgentId::OpenCode,
AgentId::Trae,
AgentId::Goose,
AgentId::Amp,
AgentId::Devin,
AgentId::Replit,
AgentId::Antigravity,
AgentId::GitHubCopilot,
AgentId::Crush,
AgentId::QwenCode,
AgentId::IflowCli,
AgentId::AmazonQCli,
AgentId::RooCode,
AgentId::Cowork,
AgentId::CodeBuddy,
AgentId::GrokCli,
AgentId::Warp,
AgentId::Pi,
AgentId::Kiro,
AgentId::Firebender,
AgentId::OpenHands,
AgentId::VeCli,
AgentId::V0,
] {
let slug = id.as_str();
let env = env_from(&[("AGENT", slug)]);
assert_eq!(
detect_with(env, |_| false).unwrap().id,
id,
"slug {slug} did not round-trip"
);
}
}
#[test]
fn agent_var_classifies_new_names() {
for (val, expected) in [
("replit", AgentId::Replit),
("antigravity", AgentId::Antigravity),
("github-copilot", AgentId::GitHubCopilot),
("github-copilot-cli", AgentId::GitHubCopilot),
("cursor-cli", AgentId::CursorCli),
("augment-cli", AgentId::Augment),
("crush", AgentId::Crush),
("qwen", AgentId::QwenCode),
("iflow", AgentId::IflowCli),
("amazonq", AgentId::AmazonQCli),
("roo", AgentId::RooCode),
("cowork", AgentId::Cowork),
("codebuddy", AgentId::CodeBuddy),
("grok", AgentId::GrokCli),
("oz", AgentId::Warp),
("kiro-cli", AgentId::Kiro),
("firebender", AgentId::Firebender),
("openhands", AgentId::OpenHands),
("vecli", AgentId::VeCli),
("v0", AgentId::V0),
] {
let env = env_from(&[("AGENT", val)]);
assert_eq!(
detect_with(env, |_| false).unwrap().id,
expected,
"val={val}"
);
}
}
}