From 6ed8f34673b8f2f5ec4cfd1ea3ebdd088c91037b Mon Sep 17 00:00:00 2001 From: Nick Yasnohorodskyi Date: Thu, 20 Aug 2026 14:37:16 +0200 Subject: [PATCH] fix: split Harness name and version in persona analytics (CLI-1771) persona.agent fused Claude Code's name, version, and role into one string (e.g. claude-code_2-1-233_agent), fragmenting it into ~96 distinct values in analytics and making a simple "how many Claude Code sessions" query impossible. Replace the hand-ported detect-agent signature table with the upstream github.com/vercel/detect-agent module (13 -> 20 harnesses, also fixes a live OpenCode detection bug), and split any version-shaped suffix off the detected name into a new persona.agent_version field, canonicalising the remaining name fragment against the module's own vocabulary rather than a hand-written alias list. persona.agent now always reports "claude_code" for Claude Code regardless of detection path. Needs OSS/legal sign-off before merge: the upstream module ships no LICENSE file (see PR description). --- cliv2-private/go.mod | 1 + cliv2-private/go.sum | 2 + cliv2/go.mod | 1 + cliv2/go.sum | 2 + cliv2/internal/persona/agent/agent.go | 163 +++++++-------------- cliv2/internal/persona/agent/agent_test.go | 51 ------- cliv2/internal/persona/persona.go | 13 +- cliv2/internal/persona/persona_test.go | 98 +++++++++++++ cliv2/pkg/core/main.go | 15 +- 9 files changed, 173 insertions(+), 173 deletions(-) delete mode 100644 cliv2/internal/persona/agent/agent_test.go diff --git a/cliv2-private/go.mod b/cliv2-private/go.mod index e90b52eb3b..d8e9833cbc 100644 --- a/cliv2-private/go.mod +++ b/cliv2-private/go.mod @@ -249,6 +249,7 @@ require ( github.com/tmc/langchaingo v0.1.14 // indirect github.com/toon-format/toon-go v0.0.0-20251202084852-7ca0e27c4e8c // indirect github.com/ulikunitz/xz v0.5.15 // indirect + github.com/vercel/detect-agent v1.2.0 // indirect github.com/vincent-petithory/dataurl v1.0.0 // indirect github.com/writeas/go-strip-markdown v2.0.1+incompatible // indirect github.com/xanzy/ssh-agent v0.3.3 // indirect diff --git a/cliv2-private/go.sum b/cliv2-private/go.sum index 8c8d4e0efc..41854f54ea 100644 --- a/cliv2-private/go.sum +++ b/cliv2-private/go.sum @@ -669,6 +669,8 @@ github.com/toon-format/toon-go v0.0.0-20251202084852-7ca0e27c4e8c h1:D8lDFovBMZy github.com/toon-format/toon-go v0.0.0-20251202084852-7ca0e27c4e8c/go.mod h1:j/BOnpF2ihnz4lELs99h9mwGJBx/zdleOUCnLLRPCsc= github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY= github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= +github.com/vercel/detect-agent v1.2.0 h1:cv+oFiSknLWbsidRlZWXcrdHP9FXVeeZRb2ib67Zd8k= +github.com/vercel/detect-agent v1.2.0/go.mod h1:hQafiuMWbYQSNhDa/2/cxz4V/3m6WRiQWkcllhNULp8= github.com/vincent-petithory/dataurl v1.0.0 h1:cXw+kPto8NLuJtlMsI152irrVw9fRDX8AbShPRpg2CI= github.com/vincent-petithory/dataurl v1.0.0/go.mod h1:FHafX5vmDzyP+1CQATJn7WFKc9CvnvxyvZy6I1MrG/U= github.com/writeas/go-strip-markdown v2.0.1+incompatible h1:IIqxTM5Jr7RzhigcL6FkrCNfXkvbR+Nbu1ls48pXYcw= diff --git a/cliv2/go.mod b/cliv2/go.mod index 24e2fb8707..576491152f 100644 --- a/cliv2/go.mod +++ b/cliv2/go.mod @@ -30,6 +30,7 @@ require ( github.com/spf13/cobra v1.9.1 github.com/spf13/pflag v1.0.10 github.com/stretchr/testify v1.11.1 + github.com/vercel/detect-agent v1.2.0 golang.org/x/mod v0.38.0 ) diff --git a/cliv2/go.sum b/cliv2/go.sum index e9ed4fa49a..d4583bb085 100644 --- a/cliv2/go.sum +++ b/cliv2/go.sum @@ -605,6 +605,8 @@ github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfj github.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XVuEeem8LsQ= github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY= github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= +github.com/vercel/detect-agent v1.2.0 h1:cv+oFiSknLWbsidRlZWXcrdHP9FXVeeZRb2ib67Zd8k= +github.com/vercel/detect-agent v1.2.0/go.mod h1:hQafiuMWbYQSNhDa/2/cxz4V/3m6WRiQWkcllhNULp8= github.com/vincent-petithory/dataurl v1.0.0 h1:cXw+kPto8NLuJtlMsI152irrVw9fRDX8AbShPRpg2CI= github.com/vincent-petithory/dataurl v1.0.0/go.mod h1:FHafX5vmDzyP+1CQATJn7WFKc9CvnvxyvZy6I1MrG/U= github.com/writeas/go-strip-markdown v2.0.1+incompatible h1:IIqxTM5Jr7RzhigcL6FkrCNfXkvbR+Nbu1ls48pXYcw= diff --git a/cliv2/internal/persona/agent/agent.go b/cliv2/internal/persona/agent/agent.go index 2cb22fa0ab..ed2611e061 100644 --- a/cliv2/internal/persona/agent/agent.go +++ b/cliv2/internal/persona/agent/agent.go @@ -1,133 +1,72 @@ -// This file is adapted from @vercel/detect-agent (v1.2.3) by Vercel, Inc. -// Source: https://github.com/vercel/vercel/blob/0d0b990edda112c5cc91e95e0d054878542fe3be/packages/detect-agent/src/index.ts -// -// Original work Copyright 2017 Vercel, Inc. -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// This Go port has been modified from the original TypeScript source. - +// Package agent identifies the coding-tool Harness driving the current CLI +// invocation (e.g. Claude Code, Cursor, Codex), split into a canonical name +// and, when one can be determined, a version. package agent import ( - "os" + "regexp" "strings" -) -// Agent is the canonical name of a known AI coding agent / harness. -type Agent string - -const ( - AgentCursor Agent = "cursor" - AgentCursorCLI Agent = "cursor-cli" - AgentClaude Agent = "claude" - AgentCowork Agent = "cowork" - AgentDevin Agent = "devin" - AgentReplit Agent = "replit" - AgentGemini Agent = "gemini" - AgentCodex Agent = "codex" - AgentAntigravity Agent = "antigravity" - AgentAugmentCLI Agent = "augment-cli" - AgentOpenCode Agent = "opencode" - AgentGitHubCopilot Agent = "github-copilot" - AgentV0 Agent = "v0" + detectagent "github.com/vercel/detect-agent" ) -// devinMarkerPath is a filesystem marker present inside the Devin sandbox. -const devinMarkerPath = "/opt/.devin" - -// lookup abstracts the environment so detection can be tested without mutating -// the real process environment. -type lookup struct { - getenv func(string) string - fileExists func(string) bool -} - -func osLookup() lookup { - return lookup{ - getenv: os.Getenv, - fileExists: func(path string) bool { - _, err := os.Stat(path) - return err == nil - }, +// DetectAgent resolves the Harness identifier the current process declared +// via AI_AGENT, or that detect-agent recognised from an environment +// signature. The value is returned exactly as detect-agent produced it: an +// explicit AI_AGENT declaration passes through verbatim (trimmed), a +// signature match is already canonical. No version split or canonicalisation +// is applied here — see SplitVersion. +func DetectAgent() (string, bool) { + details, err := detectagent.Detect() + if err != nil { + return "", false } + return details.Name, true } -// signature describes how a single agent is recognised from the environment. -type signature struct { - agent Agent - match func(l lookup) bool -} +// versionSuffix recognises a version-shaped run of digits at the end of a +// Harness identifier, with an optional trailing role/surface segment that is +// captured only so it isn't absorbed into the version, then discarded. Both +// '_' and '/' are accepted as separators, so the legacy slash format +// normalises identically to the current underscore format. Requiring at +// least two numeric components avoids matching a name that merely ends in a +// single digit. +var versionSuffix = regexp.MustCompile(`^(?P.+?)[_/](?P\d+(?:[.\-]\d+){1,3})(?:[_/](?P[A-Za-z0-9-]+))?$`) -// anySet reports true if any of the given environment variables is set to a -// non-empty value. -func anySet(l lookup, keys ...string) bool { - for _, k := range keys { - if l.getenv(k) != "" { - return true - } - } - return false -} +// knownNames maps a normalised (lowercased, separators stripped) form of +// every Harness name detect-agent knows about to that name's canonical +// spelling, so a version-stripped fragment resolves to the same vocabulary +// detect-agent itself uses rather than a hand-written alias list. +var knownNames = buildKnownNames() -// signatures is the ordered list of agent detectors. Order matters: the first -// match wins, so more specific signatures must precede more generic ones. -var signatures = []signature{ - {AgentCursor, func(l lookup) bool { return anySet(l, "CURSOR_TRACE_ID") }}, - {AgentCursorCLI, func(l lookup) bool { - return anySet(l, "CURSOR_AGENT") || l.getenv("CURSOR_EXTENSION_HOST_ROLE") == "agent-exec" - }}, - {AgentGemini, func(l lookup) bool { return anySet(l, "GEMINI_CLI") }}, - {AgentCodex, func(l lookup) bool { return anySet(l, "CODEX_SANDBOX", "CODEX_CI", "CODEX_THREAD_ID") }}, - {AgentAntigravity, func(l lookup) bool { return anySet(l, "ANTIGRAVITY_AGENT") }}, - {AgentAugmentCLI, func(l lookup) bool { return anySet(l, "AUGMENT_AGENT") }}, - {AgentOpenCode, func(l lookup) bool { return anySet(l, "OPENCODE_CLIENT") }}, - // Claude Code: the "cowork" surface is a more specific variant and must be - // checked first. - {AgentCowork, func(l lookup) bool { - return anySet(l, "CLAUDECODE", "CLAUDE_CODE") && anySet(l, "CLAUDE_CODE_IS_COWORK") - }}, - {AgentClaude, func(l lookup) bool { return anySet(l, "CLAUDECODE", "CLAUDE_CODE") }}, - {AgentReplit, func(l lookup) bool { return anySet(l, "REPL_ID") }}, - {AgentGitHubCopilot, func(l lookup) bool { - return anySet(l, "COPILOT_MODEL", "COPILOT_ALLOW_ALL", "COPILOT_GITHUB_TOKEN") - }}, - {AgentDevin, func(l lookup) bool { return l.fileExists(devinMarkerPath) }}, +func buildKnownNames() map[string]string { + m := make(map[string]string, len(detectagent.KnownAgents)) + for _, name := range detectagent.KnownAgents { + m[normalize(name)] = name + } + return m } -// DetectAgent resolves the active AI agent, if any, from the current process -// environment. -func DetectAgent() (Agent, bool) { - return detectAgent(osLookup()) +func normalize(s string) string { + return strings.NewReplacer("_", "", "-", "", "/", "").Replace(strings.ToLower(s)) } -// detectAgent resolves the active AI agent, if any. -// -// The AI_AGENT environment variable is the explicit, highest-priority signal: -// when set it is trusted verbatim (with a couple of canonicalisations). When it -// is absent, detection falls back to per-agent environment / filesystem -// signatures. -func detectAgent(l lookup) (Agent, bool) { - if name := strings.TrimSpace(l.getenv("AI_AGENT")); name != "" { - return canonicalAgent(name), true +// SplitVersion splits a version-shaped suffix off a raw Harness identifier +// and canonicalises the remaining name fragment against detect-agent's known +// vocabulary. It returns the identifier unchanged with an empty version when +// no version-shaped suffix is present, or when the extracted fragment does +// not resolve to a known Harness — never a guess. +func SplitVersion(raw string) (name string, version string) { + m := versionSuffix.FindStringSubmatch(raw) + if m == nil { + return raw, "" } - for _, s := range signatures { - if s.match(l) { - return s.agent, true - } + fragment := m[versionSuffix.SubexpIndex("name")] + canonical, ok := knownNames[normalize(fragment)] + if !ok { + return raw, "" } - return "", false -} - -// canonicalAgent normalises an explicitly declared AI_AGENT value onto the set -// of canonical agent names. -func canonicalAgent(name string) Agent { - if name == "github-copilot-cli" { - return AgentGitHubCopilot - } - return Agent(name) + return canonical, strings.ReplaceAll(m[versionSuffix.SubexpIndex("version")], "-", ".") } diff --git a/cliv2/internal/persona/agent/agent_test.go b/cliv2/internal/persona/agent/agent_test.go deleted file mode 100644 index f773d890cd..0000000000 --- a/cliv2/internal/persona/agent/agent_test.go +++ /dev/null @@ -1,51 +0,0 @@ -package agent - -import "testing" - -func newLookup(env map[string]string, files map[string]bool) lookup { - return lookup{ - getenv: func(k string) string { return env[k] }, - fileExists: func(p string) bool { return files[p] }, - } -} - -func TestDetectAgent_Signatures(t *testing.T) { - tests := []struct { - name string - env map[string]string - files map[string]bool - want Agent - }{ - {name: "none", want: ""}, - {name: "explicit AI_AGENT", env: map[string]string{"AI_AGENT": " windsurf "}, want: "windsurf"}, - {name: "AI_AGENT overrides signatures", env: map[string]string{"AI_AGENT": "v0", "CLAUDECODE": "1"}, want: AgentV0}, - {name: "github-copilot-cli canonicalised", env: map[string]string{"AI_AGENT": "github-copilot-cli"}, want: AgentGitHubCopilot}, - {name: "cursor", env: map[string]string{"CURSOR_TRACE_ID": "x"}, want: AgentCursor}, - {name: "cursor-cli via CURSOR_AGENT", env: map[string]string{"CURSOR_AGENT": "1"}, want: AgentCursorCLI}, - {name: "cursor-cli via host role", env: map[string]string{"CURSOR_EXTENSION_HOST_ROLE": "agent-exec"}, want: AgentCursorCLI}, - {name: "cursor-cli host role mismatch", env: map[string]string{"CURSOR_EXTENSION_HOST_ROLE": "editor"}, want: ""}, - {name: "gemini", env: map[string]string{"GEMINI_CLI": "1"}, want: AgentGemini}, - {name: "codex", env: map[string]string{"CODEX_THREAD_ID": "abc"}, want: AgentCodex}, - {name: "antigravity", env: map[string]string{"ANTIGRAVITY_AGENT": "1"}, want: AgentAntigravity}, - {name: "augment", env: map[string]string{"AUGMENT_AGENT": "1"}, want: AgentAugmentCLI}, - {name: "opencode", env: map[string]string{"OPENCODE_CLIENT": "1"}, want: AgentOpenCode}, - {name: "claude", env: map[string]string{"CLAUDECODE": "1"}, want: AgentClaude}, - {name: "cowork", env: map[string]string{"CLAUDE_CODE": "1", "CLAUDE_CODE_IS_COWORK": "1"}, want: AgentCowork}, - {name: "replit", env: map[string]string{"REPL_ID": "1"}, want: AgentReplit}, - {name: "github copilot", env: map[string]string{"COPILOT_MODEL": "gpt"}, want: AgentGitHubCopilot}, - {name: "devin via marker file", files: map[string]bool{devinMarkerPath: true}, want: AgentDevin}, - {name: "empty AI_AGENT falls through", env: map[string]string{"AI_AGENT": " ", "GEMINI_CLI": "1"}, want: AgentGemini}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, ok := detectAgent(newLookup(tt.env, tt.files)) - if (tt.want != "") != ok { - t.Fatalf("ok = %v, want %v (agent %q)", ok, tt.want != "", tt.want) - } - if got != tt.want { - t.Fatalf("agent = %q, want %q", got, tt.want) - } - }) - } -} diff --git a/cliv2/internal/persona/persona.go b/cliv2/internal/persona/persona.go index b473a0da63..114a80321e 100644 --- a/cliv2/internal/persona/persona.go +++ b/cliv2/internal/persona/persona.go @@ -12,9 +12,12 @@ const ( // keyInteractiveMode is the bitmask of which standard streams are attached to // a terminal (StdinTTY | StdoutTTY | StderrTTY). keyInteractiveMode = "persona.interactive_mode" - // keyAgent reports the canonical name of the AI coding agent driving the + // keyAgent reports the canonical name of the Harness driving the // invocation, when one is detected. keyAgent = "persona.agent" + // keyAgentVersion reports the Harness version, when one can be + // determined. Absent rather than empty when unknown. + keyAgentVersion = "persona.agent_version" ) // Report adds the persona extension values to the given analytics instance. @@ -23,7 +26,11 @@ func Report(a analytics.Analytics) { // A terminal on stdin is our signal that a human is driving the session. a.AddExtensionBoolValue(keyInteractive, mode.Has(interactive.StdinTTY)) a.AddExtensionIntegerValue(keyInteractiveMode, int(mode)) - if agent, ok := agent.DetectAgent(); ok { - a.AddExtensionStringValue(keyAgent, string(agent)) + if raw, ok := agent.DetectAgent(); ok { + name, version := agent.SplitVersion(raw) + a.AddExtensionStringValue(keyAgent, name) + if version != "" { + a.AddExtensionStringValue(keyAgentVersion, version) + } } } diff --git a/cliv2/internal/persona/persona_test.go b/cliv2/internal/persona/persona_test.go index c7b5777de6..95da67b97e 100644 --- a/cliv2/internal/persona/persona_test.go +++ b/cliv2/internal/persona/persona_test.go @@ -44,3 +44,101 @@ func TestReport(t *testing.T) { t.Fatalf("expected %q to be reported", keyInteractiveMode) } } + +// TestReport_Agent covers every real-world AI_AGENT shape observed in +// production analytics, plus the signature-detection and edge-case paths: +// the environment goes in, the recorded persona.agent / persona.agent_version +// values come out, and everything in between (detection, version split, +// canonicalisation) is free to change. +func TestReport_Agent(t *testing.T) { + cases := []struct { + name string + env map[string]string + wantAgent string + wantVersion string + }{ + { + name: "fused underscore, agent role", + env: map[string]string{"AI_AGENT": "claude-code_2-1-233_agent"}, + wantAgent: "claude_code", + wantVersion: "2.1.233", + }, + { + name: "fused underscore, harness role", + env: map[string]string{"AI_AGENT": "claude-code_2-1-229_harness"}, + wantAgent: "claude_code", + wantVersion: "2.1.229", + }, + { + // Different literal shape, same version, to prove both normalise + // to the identical bucket rather than merely similar ones. + name: "legacy slash format normalises to the same bucket", + env: map[string]string{"AI_AGENT": "claude-code/2.1.233/agent"}, + wantAgent: "claude_code", + wantVersion: "2.1.233", + }, + { + // Same canonical name as the fused AI_AGENT cases above, proving + // Claude Code converges to one bucket regardless of which + // detection path (declaration vs. signature) produced it. + name: "bare signature detection, no explicit declaration", + env: map[string]string{"AI_AGENT": "", "CLAUDECODE": "1"}, + wantAgent: "claude_code", + wantVersion: "", + }, + { + name: "harness that already groups correctly", + env: map[string]string{"AI_AGENT": "cursor-cli"}, + wantAgent: "cursor-cli", + wantVersion: "", + }, + { + name: "fused identifier with no version-shaped segment", + env: map[string]string{"AI_AGENT": "github_copilot_vscode_agent"}, + wantAgent: "github_copilot_vscode_agent", + wantVersion: "", + }, + { + name: "version-shaped but unrecognised harness", + env: map[string]string{"AI_AGENT": "mystery-tool_9-1_agent"}, + wantAgent: "mystery-tool_9-1_agent", + wantVersion: "", + }, + { + name: "redacted identifier", + env: map[string]string{"AI_AGENT": "***"}, + wantAgent: "***", + wantVersion: "", + }, + { + name: "version-shaped run with no trailing role segment", + env: map[string]string{"AI_AGENT": "claude-code_2-1-233"}, + wantAgent: "claude_code", + wantVersion: "2.1.233", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + for k, v := range tc.env { + t.Setenv(k, v) + } + + a := newFakeAnalytics() + Report(a) + + if got := a.strings[keyAgent]; got != tc.wantAgent { + t.Fatalf("%s = %q, want %q", keyAgent, got, tc.wantAgent) + } + if tc.wantVersion == "" { + if v, ok := a.strings[keyAgentVersion]; ok { + t.Fatalf("expected %q to be absent, got %q", keyAgentVersion, v) + } + return + } + if got := a.strings[keyAgentVersion]; got != tc.wantVersion { + t.Fatalf("%s = %q, want %q", keyAgentVersion, got, tc.wantVersion) + } + }) + } +} diff --git a/cliv2/pkg/core/main.go b/cliv2/pkg/core/main.go index bd7350c914..1ae2ae7620 100644 --- a/cliv2/pkg/core/main.go +++ b/cliv2/pkg/core/main.go @@ -726,14 +726,15 @@ func mainWithErrorCode(additionalExts []workflow.ExtensionInit) int { func populateRedactionTerms(config configuration.Configuration, engine workflow.Engine) []string { knownTerms, _ := instrumentation.GetKnownCommandsAndFlags(engine) knownTerms = append(knownTerms, config.GetString(configuration.API_URL), config.GetString(configuration.ORGANIZATION), config.GetString(configuration.ORGANIZATION_SLUG), config.GetString(clientMachineIdConfigKey)) - // AI_AGENT is trusted verbatim into the persona.agent extension (see - // agent.canonicalAgent) for any harness not on its short canonical list, so its - // raw value needs the same exclusion as the client machine id above. - // GetUnknownParameters tokenizes its input on whitespace, so a multi-word - // value only excludes as a whole if each of its words is excluded too. + // AI_AGENT is trusted verbatim by agent.DetectAgent, and persona.Report + // falls back to that same raw value whenever the Harness name/version split + // or canonicalisation does not apply, so its raw value needs the same + // exclusion as the client machine id above. GetUnknownParameters tokenizes + // its input on whitespace, so a multi-word value only excludes as a whole if + // each of its words is excluded too. if detectedAgent, ok := agent.DetectAgent(); ok { - knownTerms = append(knownTerms, string(detectedAgent)) - knownTerms = append(knownTerms, strings.Fields(string(detectedAgent))...) + knownTerms = append(knownTerms, detectedAgent) + knownTerms = append(knownTerms, strings.Fields(detectedAgent)...) } termsToRedact := cliv2utils.GetUnknownParameters(os.Args[1:], os.Environ(), knownTerms) config.Set(logging.REDACTION_TERMS, termsToRedact)