From 25dc4181216b8400aa435e2752fc25a4fbdffb97 Mon Sep 17 00:00:00 2001 From: Rob E Date: Thu, 14 May 2026 14:05:58 +1000 Subject: [PATCH 1/4] Add ClaudeCode environment variable support --- pkg/services/api/automation_environment_provider.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pkg/services/api/automation_environment_provider.go b/pkg/services/api/automation_environment_provider.go index ed5338a3..1b7fcec3 100644 --- a/pkg/services/api/automation_environment_provider.go +++ b/pkg/services/api/automation_environment_provider.go @@ -17,6 +17,9 @@ var knownEnvironments = map[string][]string{ // https://docs.microsoft.com/en-us/azure/devops/pipelines/build/variables "AzureDevOps": {"TF_BUILD", "BUILD_BUILDID", "AGENT_WORKFOLDER"}, + // https://code.claude.com/docs/en/env-vars + "ClaudeCode": {"CLAUDECODE"}, + // https://confluence.atlassian.com/bamboo/bamboo-variables-289277087.html "Bamboo": {"bamboo_agentId"}, "AppVeyor": {"APPVEYOR"}, From 449678a9af496f1298ce8f5e32c6613d2fad694a Mon Sep 17 00:00:00 2001 From: Rob E Date: Fri, 13 Nov 2026 08:57:01 +1000 Subject: [PATCH 2/4] feat: detect common AI coding agents in the user agent Report the AI agent driving a process as its own user agent token, separate from the automation (CI) environment, so an agent running in CI or on an Octopus worker reports both rather than one at random. - Honour the emerging AI_AGENT convention, mapping known values to a canonical name and passing the sanitised raw value through as a comment - Fall back to vendor variables for Claude Code, Cursor, Codex, Gemini CLI, GitHub Copilot, OpenCode, Antigravity, Goose, Amp, Cline, Roo Code, Augment, Qwen Code, Factory Droid, Kimi CLI, Trae, Aider and OpenHands, checked in a fixed order - Exclude variables that are set without an agent driving the process (e.g. CURSOR_TRACE_ID, COPILOT_GITHUB_TOKEN, REPL_ID) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01T4ijpGHFpAqBBMhn5WKUq2 --- pkg/services/api/ai_agent_provider.go | 155 ++++++++++++++++++ pkg/services/api/ai_agent_provider_test.go | 147 +++++++++++++++++ pkg/services/api/api.go | 6 +- .../api/automation_environment_provider.go | 3 - 4 files changed, 307 insertions(+), 4 deletions(-) create mode 100644 pkg/services/api/ai_agent_provider.go create mode 100644 pkg/services/api/ai_agent_provider_test.go diff --git a/pkg/services/api/ai_agent_provider.go b/pkg/services/api/ai_agent_provider.go new file mode 100644 index 00000000..7fbbb758 --- /dev/null +++ b/pkg/services/api/ai_agent_provider.go @@ -0,0 +1,155 @@ +package api + +import ( + "fmt" + "strings" +) + +// AI agents are detected separately from automation (CI) environments because the two are +// independent dimensions: an agent commonly runs inside a CI system or on an Octopus worker, +// and both should be reported rather than one masking the other. + +// aiAgentEnvVar is the emerging cross-vendor convention for an agent to identify itself. +// https://github.com/vercel/vercel/tree/main/packages/detect-agent +const aiAgentEnvVar = "AI_AGENT" + +const maxAIAgentValueLength = 64 + +type aiAgentRule struct { + variable string + // value, when set, must match the variable's value (case-insensitive). When empty, any + // non-empty value matches. + value string +} + +type aiAgent struct { + name string + rules []aiAgentRule +} + +// knownAIAgents is ordered: the first agent with a matching rule wins, so the result is +// deterministic when several agents' variables are present (e.g. one agent running another). +// +// Variables that are commonly present without an agent driving the process are deliberately +// excluded: CURSOR_TRACE_ID (any Cursor terminal), COPILOT_GITHUB_TOKEN and GOOSE_PROVIDER +// (user configuration), REPL_ID (any Replit workspace) and ZED_TERM (any Zed terminal). +// +// References: +// https://code.claude.com/docs/en/env-vars +// https://source.dot.net/aspire/Telemetry/CodingAgentDetector.cs.html +// https://docs.rs/is-ai-agent +var knownAIAgents = []aiAgent{ + {"ClaudeCode", []aiAgentRule{{variable: "CLAUDECODE"}, {variable: "CLAUDE_CODE_ENTRYPOINT"}}}, + {"Cursor", []aiAgentRule{{variable: "CURSOR_AGENT"}, {variable: "CURSOR_EXTENSION_HOST_ROLE", value: "agent-exec"}}}, + {"Codex", []aiAgentRule{{variable: "CODEX_SANDBOX"}, {variable: "CODEX_THREAD_ID"}, {variable: "CODEX_CI"}}}, + {"GeminiCLI", []aiAgentRule{{variable: "GEMINI_CLI"}}}, + {"GitHubCopilot", []aiAgentRule{{variable: "COPILOT_CLI"}, {variable: "COPILOT_AGENT"}, {variable: "GITHUB_COPILOT_CLI_MODE"}}}, + {"OpenCode", []aiAgentRule{{variable: "OPENCODE", value: "1"}, {variable: "OPENCODE_CLIENT"}, {variable: "OPENCODE_AI"}}}, + {"Antigravity", []aiAgentRule{{variable: "ANTIGRAVITY_AGENT"}}}, + {"Goose", []aiAgentRule{{variable: "GOOSE_TERMINAL"}, {variable: "AGENT", value: "goose"}}}, + {"Amp", []aiAgentRule{{variable: "AGENT", value: "amp"}}}, + {"Cline", []aiAgentRule{{variable: "CLINE_ACTIVE"}, {variable: "CLINE_TASK_ID"}}}, + {"RooCode", []aiAgentRule{{variable: "ROO_CODE_TASK_ID"}}}, + {"Augment", []aiAgentRule{{variable: "AUGMENT_AGENT"}}}, + {"QwenCode", []aiAgentRule{{variable: "QWEN_CODE"}}}, + {"FactoryDroid", []aiAgentRule{{variable: "DROID_CLI"}}}, + {"KimiCLI", []aiAgentRule{{variable: "KIMI_CLI"}}}, + {"Trae", []aiAgentRule{{variable: "TRAE_AI_SHELL_ID"}}}, + {"Aider", []aiAgentRule{{variable: "OR_APP_NAME", value: "Aider"}}}, + {"OpenHands", []aiAgentRule{{variable: "OR_APP_NAME", value: "OpenHands"}}}, +} + +// aiAgentValuePrefixes maps the start of a normalised AI_AGENT value to a known agent name. +// Vendors don't agree on a format (e.g. "claude-code_2-1-322_agent", "github_copilot_vscode_agent", +// "devin@1"), so values are lowercased and underscores replaced with hyphens before matching. +var aiAgentValuePrefixes = []struct { + prefix string + name string +}{ + {"claude-code", "ClaudeCode"}, + {"cursor", "Cursor"}, + {"codex", "Codex"}, + {"gemini", "GeminiCLI"}, + {"github-copilot", "GitHubCopilot"}, + {"opencode", "OpenCode"}, + {"antigravity", "Antigravity"}, + {"goose", "Goose"}, + {"amp", "Amp"}, + {"cline", "Cline"}, + {"augment", "Augment"}, + {"devin", "Devin"}, + {"replit", "Replit"}, + {"v0", "V0"}, +} + +// GetAIAgent returns a user agent token describing the AI agent driving this process, or an +// empty string when none is detected. Detection is based on self-reported environment +// variables, so it is suitable for analytics but must not be relied on for security decisions. +// +// Examples: +// +// AIAgent/ClaudeCode (claude-code_2-1-322_agent) AI_AGENT set by a known agent +// AIAgent/Other (devin@1) AI_AGENT set by an unrecognised agent +// AIAgent/Cursor detected from vendor-specific variables +func GetAIAgent(e Environment) string { + if raw := sanitiseAIAgentValue(e.Getenv(aiAgentEnvVar)); raw != "" { + name := nameFromAIAgentValue(raw) + if name == "" { + // Vendor variables may still identify an agent that sets AI_AGENT in an unfamiliar format. + name = nameFromVendorVariables(e) + } + if name == "" { + name = "Other" + } + return fmt.Sprintf("AIAgent/%s (%s)", name, raw) + } + + if name := nameFromVendorVariables(e); name != "" { + return "AIAgent/" + name + } + + return "" +} + +func nameFromVendorVariables(e Environment) string { + for _, agent := range knownAIAgents { + for _, rule := range agent.rules { + value := e.Getenv(rule.variable) + if value == "" { + continue + } + if rule.value == "" || strings.EqualFold(value, rule.value) { + return agent.name + } + } + } + return "" +} + +func nameFromAIAgentValue(raw string) string { + normalised := strings.ReplaceAll(strings.ToLower(raw), "_", "-") + for _, p := range aiAgentValuePrefixes { + if strings.HasPrefix(normalised, p.prefix) { + return p.name + } + } + return "" +} + +// sanitiseAIAgentValue keeps AI_AGENT safe to embed in a User-Agent comment: it is free-form and +// user-settable, so restrict it to a conservative character set and bound its length. +func sanitiseAIAgentValue(value string) string { + var b strings.Builder + for _, r := range strings.TrimSpace(value) { + if b.Len() >= maxAIAgentValueLength { + break + } + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9': + b.WriteRune(r) + case r == '.', r == '_', r == '-', r == '@', r == '+': + b.WriteRune(r) + } + } + return b.String() +} diff --git a/pkg/services/api/ai_agent_provider_test.go b/pkg/services/api/ai_agent_provider_test.go new file mode 100644 index 00000000..b697dd95 --- /dev/null +++ b/pkg/services/api/ai_agent_provider_test.go @@ -0,0 +1,147 @@ +package api_test + +import ( + "strings" + "testing" + + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/services/api" + "github.com/stretchr/testify/require" +) + +func TestGetAIAgent_None(t *testing.T) { + e := api.NewMockEnvironment() + require.Equal(t, "", api.GetAIAgent(e)) +} + +func TestGetAIAgent_VendorVariables(t *testing.T) { + cases := []struct { + variable string + value string + expected string + }{ + {"CLAUDECODE", "1", "AIAgent/ClaudeCode"}, + {"CLAUDE_CODE_ENTRYPOINT", "cli", "AIAgent/ClaudeCode"}, + {"CURSOR_AGENT", "1", "AIAgent/Cursor"}, + {"CURSOR_EXTENSION_HOST_ROLE", "agent-exec", "AIAgent/Cursor"}, + {"CODEX_SANDBOX", "seatbelt", "AIAgent/Codex"}, + {"CODEX_THREAD_ID", "abc", "AIAgent/Codex"}, + {"GEMINI_CLI", "1", "AIAgent/GeminiCLI"}, + {"COPILOT_CLI", "1", "AIAgent/GitHubCopilot"}, + {"OPENCODE", "1", "AIAgent/OpenCode"}, + {"OPENCODE_CLIENT", "cli", "AIAgent/OpenCode"}, + {"ANTIGRAVITY_AGENT", "1", "AIAgent/Antigravity"}, + {"GOOSE_TERMINAL", "1", "AIAgent/Goose"}, + {"AGENT", "goose", "AIAgent/Goose"}, + {"AGENT", "amp", "AIAgent/Amp"}, + {"CLINE_ACTIVE", "true", "AIAgent/Cline"}, + {"ROO_CODE_TASK_ID", "abc", "AIAgent/RooCode"}, + {"AUGMENT_AGENT", "1", "AIAgent/Augment"}, + {"QWEN_CODE", "1", "AIAgent/QwenCode"}, + {"DROID_CLI", "1", "AIAgent/FactoryDroid"}, + {"KIMI_CLI", "1", "AIAgent/KimiCLI"}, + {"TRAE_AI_SHELL_ID", "abc", "AIAgent/Trae"}, + {"OR_APP_NAME", "aider", "AIAgent/Aider"}, + {"OR_APP_NAME", "OpenHands", "AIAgent/OpenHands"}, + } + + for _, c := range cases { + t.Run(c.variable+"="+c.value, func(t *testing.T) { + e := api.NewMockEnvironment() + e.Setenv(c.variable, c.value) + require.Equal(t, c.expected, api.GetAIAgent(e)) + }) + } +} + +func TestGetAIAgent_ValueRulesRequireMatch(t *testing.T) { + cases := map[string]string{ + "CURSOR_EXTENSION_HOST_ROLE": "user", + "OPENCODE": "0", + "AGENT": "teamcity-agent-01", + "OR_APP_NAME": "SomethingElse", + } + + for variable, value := range cases { + t.Run(variable, func(t *testing.T) { + e := api.NewMockEnvironment() + e.Setenv(variable, value) + require.Equal(t, "", api.GetAIAgent(e)) + }) + } +} + +func TestGetAIAgent_ExcludesNoisyVariables(t *testing.T) { + for _, variable := range []string{"CURSOR_TRACE_ID", "COPILOT_GITHUB_TOKEN", "GOOSE_PROVIDER", "REPL_ID", "ZED_TERM"} { + t.Run(variable, func(t *testing.T) { + e := api.NewMockEnvironment() + e.Setenv(variable, "1") + require.Equal(t, "", api.GetAIAgent(e)) + }) + } +} + +func TestGetAIAgent_IsDeterministicWhenAgentsAreNested(t *testing.T) { + e := api.NewMockEnvironment() + e.Setenv("CURSOR_AGENT", "1") + e.Setenv("CLAUDECODE", "1") + for i := 0; i < 50; i++ { + require.Equal(t, "AIAgent/ClaudeCode", api.GetAIAgent(e)) + } +} + +func TestGetAIAgent_AIAgentVariable(t *testing.T) { + cases := []struct { + value string + expected string + }{ + {"claude-code_2-1-322_agent", "AIAgent/ClaudeCode (claude-code_2-1-322_agent)"}, + {"claude-code", "AIAgent/ClaudeCode (claude-code)"}, + {"cursor-cli", "AIAgent/Cursor (cursor-cli)"}, + {"github_copilot_vscode_agent", "AIAgent/GitHubCopilot (github_copilot_vscode_agent)"}, + {"github-copilot-cli", "AIAgent/GitHubCopilot (github-copilot-cli)"}, + {"devin@1", "AIAgent/Devin (devin@1)"}, + {"custom-agent@2.0", "AIAgent/Other (custom-agent@2.0)"}, + } + + for _, c := range cases { + t.Run(c.value, func(t *testing.T) { + e := api.NewMockEnvironment() + e.Setenv("AI_AGENT", c.value) + require.Equal(t, c.expected, api.GetAIAgent(e)) + }) + } +} + +func TestGetAIAgent_AIAgentTakesPrecedenceOverVendorVariables(t *testing.T) { + e := api.NewMockEnvironment() + e.Setenv("CURSOR_AGENT", "1") + e.Setenv("AI_AGENT", "claude-code_2-1-322_agent") + require.Equal(t, "AIAgent/ClaudeCode (claude-code_2-1-322_agent)", api.GetAIAgent(e)) +} + +func TestGetAIAgent_UnrecognisedAIAgentFallsBackToVendorName(t *testing.T) { + e := api.NewMockEnvironment() + e.Setenv("GEMINI_CLI", "1") + e.Setenv("AI_AGENT", "gcli") + require.Equal(t, "AIAgent/GeminiCLI (gcli)", api.GetAIAgent(e)) +} + +func TestGetAIAgent_SanitisesAIAgentValue(t *testing.T) { + e := api.NewMockEnvironment() + e.Setenv("AI_AGENT", " evil) agent\r\nX-Injected: 1 ") + require.Equal(t, "AIAgent/Other (evilagentX-Injected1)", api.GetAIAgent(e)) + + e.Setenv("AI_AGENT", strings.Repeat("a", 200)) + require.Equal(t, "AIAgent/Other ("+strings.Repeat("a", 64)+")", api.GetAIAgent(e)) + + e.Setenv("AI_AGENT", "()\r\n") + require.Equal(t, "", api.GetAIAgent(e)) +} + +func TestGetAutomationEnvironment_IgnoresAIAgents(t *testing.T) { + e := api.NewMockEnvironment() + e.Setenv("CLAUDECODE", "1") + e.Setenv("GITHUB_ACTIONS", "true") + require.Equal(t, "GitHubActions", api.GetAutomationEnvironment(e)) + require.Equal(t, "AIAgent/ClaudeCode", api.GetAIAgent(e)) +} diff --git a/pkg/services/api/api.go b/pkg/services/api/api.go index 856369b8..1e58a058 100644 --- a/pkg/services/api/api.go +++ b/pkg/services/api/api.go @@ -63,7 +63,11 @@ func GetUserAgentString(requestingTool string) string { } } - automationEnvironment := GetAutomationEnvironment(&OsEnvironment{}) + environment := &OsEnvironment{} + automationEnvironment := GetAutomationEnvironment(environment) + if aiAgent := GetAIAgent(environment); aiAgent != "" { + automationEnvironment = automationEnvironment + " " + aiAgent + } return strings.TrimSpace(fmt.Sprintf("%s/%s (%s; %s) go/%s %s %s", "go-octopusdeploy", version, runtime.GOOS, runtime.GOARCH, runtime.Version(), automationEnvironment, requestingTool)) } diff --git a/pkg/services/api/automation_environment_provider.go b/pkg/services/api/automation_environment_provider.go index 1b7fcec3..ed5338a3 100644 --- a/pkg/services/api/automation_environment_provider.go +++ b/pkg/services/api/automation_environment_provider.go @@ -17,9 +17,6 @@ var knownEnvironments = map[string][]string{ // https://docs.microsoft.com/en-us/azure/devops/pipelines/build/variables "AzureDevOps": {"TF_BUILD", "BUILD_BUILDID", "AGENT_WORKFOLDER"}, - // https://code.claude.com/docs/en/env-vars - "ClaudeCode": {"CLAUDECODE"}, - // https://confluence.atlassian.com/bamboo/bamboo-variables-289277087.html "Bamboo": {"bamboo_agentId"}, "AppVeyor": {"APPVEYOR"}, From 3422651d48e0c23f457465fd4d51cd8defd9a786 Mon Sep 17 00:00:00 2001 From: Rob E Date: Fri, 13 Nov 2026 08:57:01 +1000 Subject: [PATCH 3/4] refactor: report only a normalised AI agent name Map AI_AGENT to one of the known agent names instead of passing the raw value through as a user agent comment. Unrecognised values report as AIAgent/Other. The raw value is free-form and user-settable, so it is now only used for matching, which also removes the need to sanitise it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01T4ijpGHFpAqBBMhn5WKUq2 --- pkg/services/api/ai_agent_provider.go | 72 ++++++++-------------- pkg/services/api/ai_agent_provider_test.go | 27 ++++---- 2 files changed, 40 insertions(+), 59 deletions(-) diff --git a/pkg/services/api/ai_agent_provider.go b/pkg/services/api/ai_agent_provider.go index 7fbbb758..addc1905 100644 --- a/pkg/services/api/ai_agent_provider.go +++ b/pkg/services/api/ai_agent_provider.go @@ -1,9 +1,6 @@ package api -import ( - "fmt" - "strings" -) +import "strings" // AI agents are detected separately from automation (CI) environments because the two are // independent dimensions: an agent commonly runs inside a CI system or on an Octopus worker, @@ -13,8 +10,6 @@ import ( // https://github.com/vercel/vercel/tree/main/packages/detect-agent const aiAgentEnvVar = "AI_AGENT" -const maxAIAgentValueLength = 64 - type aiAgentRule struct { variable string // value, when set, must match the variable's value (case-insensitive). When empty, any @@ -82,33 +77,33 @@ var aiAgentValuePrefixes = []struct { {"v0", "V0"}, } -// GetAIAgent returns a user agent token describing the AI agent driving this process, or an -// empty string when none is detected. Detection is based on self-reported environment -// variables, so it is suitable for analytics but must not be relied on for security decisions. +// GetAIAgent returns a user agent token naming the AI agent driving this process, or an empty +// string when none is detected. Only a normalised name is reported: the raw AI_AGENT value is +// free-form and user-settable, so it is used for matching and never included in the output. +// Detection is based on self-reported environment variables, so it is suitable for analytics but +// must not be relied on for security decisions. // // Examples: // -// AIAgent/ClaudeCode (claude-code_2-1-322_agent) AI_AGENT set by a known agent -// AIAgent/Other (devin@1) AI_AGENT set by an unrecognised agent -// AIAgent/Cursor detected from vendor-specific variables +// AIAgent/ClaudeCode AI_AGENT=claude-code_2-1-322_agent, or CLAUDECODE=1 +// AIAgent/Cursor CURSOR_AGENT=1 +// AIAgent/Other AI_AGENT set to a value that isn't recognised func GetAIAgent(e Environment) string { - if raw := sanitiseAIAgentValue(e.Getenv(aiAgentEnvVar)); raw != "" { - name := nameFromAIAgentValue(raw) - if name == "" { - // Vendor variables may still identify an agent that sets AI_AGENT in an unfamiliar format. - name = nameFromVendorVariables(e) - } - if name == "" { - name = "Other" - } - return fmt.Sprintf("AIAgent/%s (%s)", name, raw) - } + aiAgentValue := strings.TrimSpace(e.Getenv(aiAgentEnvVar)) - if name := nameFromVendorVariables(e); name != "" { - return "AIAgent/" + name + name := nameFromAIAgentValue(aiAgentValue) + if name == "" { + // Also covers an agent that sets AI_AGENT in a format that isn't recognised. + name = nameFromVendorVariables(e) + } + if name == "" && aiAgentValue != "" { + name = "Other" + } + if name == "" { + return "" } - return "" + return "AIAgent/" + name } func nameFromVendorVariables(e Environment) string { @@ -126,8 +121,11 @@ func nameFromVendorVariables(e Environment) string { return "" } -func nameFromAIAgentValue(raw string) string { - normalised := strings.ReplaceAll(strings.ToLower(raw), "_", "-") +func nameFromAIAgentValue(value string) string { + if value == "" { + return "" + } + normalised := strings.ReplaceAll(strings.ToLower(value), "_", "-") for _, p := range aiAgentValuePrefixes { if strings.HasPrefix(normalised, p.prefix) { return p.name @@ -135,21 +133,3 @@ func nameFromAIAgentValue(raw string) string { } return "" } - -// sanitiseAIAgentValue keeps AI_AGENT safe to embed in a User-Agent comment: it is free-form and -// user-settable, so restrict it to a conservative character set and bound its length. -func sanitiseAIAgentValue(value string) string { - var b strings.Builder - for _, r := range strings.TrimSpace(value) { - if b.Len() >= maxAIAgentValueLength { - break - } - switch { - case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9': - b.WriteRune(r) - case r == '.', r == '_', r == '-', r == '@', r == '+': - b.WriteRune(r) - } - } - return b.String() -} diff --git a/pkg/services/api/ai_agent_provider_test.go b/pkg/services/api/ai_agent_provider_test.go index b697dd95..95af6ff5 100644 --- a/pkg/services/api/ai_agent_provider_test.go +++ b/pkg/services/api/ai_agent_provider_test.go @@ -94,13 +94,14 @@ func TestGetAIAgent_AIAgentVariable(t *testing.T) { value string expected string }{ - {"claude-code_2-1-322_agent", "AIAgent/ClaudeCode (claude-code_2-1-322_agent)"}, - {"claude-code", "AIAgent/ClaudeCode (claude-code)"}, - {"cursor-cli", "AIAgent/Cursor (cursor-cli)"}, - {"github_copilot_vscode_agent", "AIAgent/GitHubCopilot (github_copilot_vscode_agent)"}, - {"github-copilot-cli", "AIAgent/GitHubCopilot (github-copilot-cli)"}, - {"devin@1", "AIAgent/Devin (devin@1)"}, - {"custom-agent@2.0", "AIAgent/Other (custom-agent@2.0)"}, + {"claude-code_2-1-322_agent", "AIAgent/ClaudeCode"}, + {"claude-code", "AIAgent/ClaudeCode"}, + {"Claude-Code", "AIAgent/ClaudeCode"}, + {"cursor-cli", "AIAgent/Cursor"}, + {"github_copilot_vscode_agent", "AIAgent/GitHubCopilot"}, + {"github-copilot-cli", "AIAgent/GitHubCopilot"}, + {"devin@1", "AIAgent/Devin"}, + {"custom-agent@2.0", "AIAgent/Other"}, } for _, c := range cases { @@ -116,25 +117,25 @@ func TestGetAIAgent_AIAgentTakesPrecedenceOverVendorVariables(t *testing.T) { e := api.NewMockEnvironment() e.Setenv("CURSOR_AGENT", "1") e.Setenv("AI_AGENT", "claude-code_2-1-322_agent") - require.Equal(t, "AIAgent/ClaudeCode (claude-code_2-1-322_agent)", api.GetAIAgent(e)) + require.Equal(t, "AIAgent/ClaudeCode", api.GetAIAgent(e)) } func TestGetAIAgent_UnrecognisedAIAgentFallsBackToVendorName(t *testing.T) { e := api.NewMockEnvironment() e.Setenv("GEMINI_CLI", "1") e.Setenv("AI_AGENT", "gcli") - require.Equal(t, "AIAgent/GeminiCLI (gcli)", api.GetAIAgent(e)) + require.Equal(t, "AIAgent/GeminiCLI", api.GetAIAgent(e)) } -func TestGetAIAgent_SanitisesAIAgentValue(t *testing.T) { +func TestGetAIAgent_NeverIncludesRawAIAgentValue(t *testing.T) { e := api.NewMockEnvironment() e.Setenv("AI_AGENT", " evil) agent\r\nX-Injected: 1 ") - require.Equal(t, "AIAgent/Other (evilagentX-Injected1)", api.GetAIAgent(e)) + require.Equal(t, "AIAgent/Other", api.GetAIAgent(e)) e.Setenv("AI_AGENT", strings.Repeat("a", 200)) - require.Equal(t, "AIAgent/Other ("+strings.Repeat("a", 64)+")", api.GetAIAgent(e)) + require.Equal(t, "AIAgent/Other", api.GetAIAgent(e)) - e.Setenv("AI_AGENT", "()\r\n") + e.Setenv("AI_AGENT", " \t ") require.Equal(t, "", api.GetAIAgent(e)) } From ffd7928fd90a3eb09e176e66373501223e280360 Mon Sep 17 00:00:00 2001 From: Rob E Date: Fri, 13 Nov 2026 09:29:46 +1000 Subject: [PATCH 4/4] refactor: align agent name matching with the .NET and TypeScript clients Collapse runs of underscores and whitespace into a single hyphen before matching a self-reported agent identifier, so a name such as "Claude Code" maps the same way in all three clients. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01T4ijpGHFpAqBBMhn5WKUq2 --- pkg/services/api/ai_agent_provider.go | 5 +++-- pkg/services/api/ai_agent_provider_test.go | 1 + 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/pkg/services/api/ai_agent_provider.go b/pkg/services/api/ai_agent_provider.go index addc1905..2aa66597 100644 --- a/pkg/services/api/ai_agent_provider.go +++ b/pkg/services/api/ai_agent_provider.go @@ -56,7 +56,8 @@ var knownAIAgents = []aiAgent{ // aiAgentValuePrefixes maps the start of a normalised AI_AGENT value to a known agent name. // Vendors don't agree on a format (e.g. "claude-code_2-1-322_agent", "github_copilot_vscode_agent", -// "devin@1"), so values are lowercased and underscores replaced with hyphens before matching. +// "devin@1"), so values are lowercased and runs of underscores and whitespace replaced with a single hyphen +// before matching. var aiAgentValuePrefixes = []struct { prefix string name string @@ -125,7 +126,7 @@ func nameFromAIAgentValue(value string) string { if value == "" { return "" } - normalised := strings.ReplaceAll(strings.ToLower(value), "_", "-") + normalised := strings.Join(strings.Fields(strings.ReplaceAll(strings.ToLower(value), "_", " ")), "-") for _, p := range aiAgentValuePrefixes { if strings.HasPrefix(normalised, p.prefix) { return p.name diff --git a/pkg/services/api/ai_agent_provider_test.go b/pkg/services/api/ai_agent_provider_test.go index 95af6ff5..c6d59350 100644 --- a/pkg/services/api/ai_agent_provider_test.go +++ b/pkg/services/api/ai_agent_provider_test.go @@ -97,6 +97,7 @@ func TestGetAIAgent_AIAgentVariable(t *testing.T) { {"claude-code_2-1-322_agent", "AIAgent/ClaudeCode"}, {"claude-code", "AIAgent/ClaudeCode"}, {"Claude-Code", "AIAgent/ClaudeCode"}, + {"Claude Code", "AIAgent/ClaudeCode"}, {"cursor-cli", "AIAgent/Cursor"}, {"github_copilot_vscode_agent", "AIAgent/GitHubCopilot"}, {"github-copilot-cli", "AIAgent/GitHubCopilot"},