Skip to content

Commit 945a876

Browse files
Merge pull request #33675 from storybookjs/valentin/implement-agent-detection-telemetry
Telemetry: Add agent detection
2 parents ac039aa + 0d8afbe commit 945a876

File tree

4 files changed

+193
-1
lines changed

4 files changed

+193
-1
lines changed
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import { describe, expect, it } from 'vitest';
2+
3+
import { detectAgent } from './detect-agent';
4+
5+
describe('detectAgent', () => {
6+
it('detects amp via AGENT=amp (highest precedence)', () => {
7+
expect(
8+
detectAgent({
9+
stdoutIsTTY: true,
10+
env: {
11+
AGENT: 'amp',
12+
CLAUDECODE: '1',
13+
GEMINI_CLI: '1',
14+
CODEX_SANDBOX: '1',
15+
CURSOR_AGENT: '1',
16+
},
17+
})
18+
).toEqual({ name: 'amp' });
19+
20+
expect(
21+
detectAgent({
22+
stdoutIsTTY: true,
23+
env: {
24+
CLAUDECODE: '1',
25+
GEMINI_CLI: '1',
26+
CODEX_SANDBOX: '1',
27+
CURSOR_AGENT: '1',
28+
AGENT: 'something',
29+
},
30+
})
31+
).toEqual({ name: 'claude-code' });
32+
});
33+
34+
it('detects Gemini CLI via GEMINI_CLI', () => {
35+
expect(detectAgent({ stdoutIsTTY: true, env: { GEMINI_CLI: '1' } })).toEqual({
36+
name: 'gemini-cli',
37+
});
38+
});
39+
40+
it('detects OpenAI Codex via CODEX_SANDBOX', () => {
41+
expect(detectAgent({ stdoutIsTTY: true, env: { CODEX_SANDBOX: '1' } })).toEqual({
42+
name: 'codex',
43+
});
44+
});
45+
46+
it('detects Cursor Agent via CURSOR_AGENT (even if AGENT is also set)', () => {
47+
expect(
48+
detectAgent({ stdoutIsTTY: true, env: { CURSOR_AGENT: '1', AGENT: 'something' } })
49+
).toEqual({
50+
name: 'cursor',
51+
});
52+
});
53+
54+
it('treats generic AGENT as unknown', () => {
55+
expect(detectAgent({ stdoutIsTTY: true, env: { AGENT: 'some-agent' } })).toEqual({
56+
name: 'unknown',
57+
});
58+
});
59+
60+
it('does not use heuristics when stdout is a TTY', () => {
61+
expect(detectAgent({ stdoutIsTTY: true, env: { TERM: 'dumb' } })).toEqual(undefined);
62+
expect(detectAgent({ stdoutIsTTY: true, env: { GIT_PAGER: 'cat' } })).toEqual(undefined);
63+
});
64+
65+
it('detects unknown agent via TERM=dumb when stdout is not a TTY', () => {
66+
expect(detectAgent({ stdoutIsTTY: false, env: { TERM: 'dumb' } })).toEqual({
67+
name: 'unknown',
68+
});
69+
});
70+
71+
it('detects unknown agent via GIT_PAGER=cat when stdout is not a TTY', () => {
72+
expect(detectAgent({ stdoutIsTTY: false, env: { GIT_PAGER: 'cat' } })).toEqual({
73+
name: 'unknown',
74+
});
75+
});
76+
77+
it('returns isAgent=false when there are no signals', () => {
78+
expect(detectAgent({ stdoutIsTTY: false, env: {} })).toEqual(undefined);
79+
});
80+
81+
it('applies heuristics even when CI is set (no CI special-casing)', () => {
82+
expect(
83+
detectAgent({
84+
stdoutIsTTY: false,
85+
env: { CI: 'true', TERM: 'dumb' },
86+
})
87+
).toEqual({ name: 'unknown' });
88+
});
89+
90+
it('still detects explicit agents in CI', () => {
91+
expect(detectAgent({ stdoutIsTTY: false, env: { CI: 'true', CODEX_SANDBOX: '1' } })).toEqual({
92+
name: 'codex',
93+
});
94+
});
95+
});
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
export type KnownAgentName =
2+
| 'claude-code'
3+
| 'gemini-cli'
4+
| 'cursor'
5+
| 'codex'
6+
| 'opencode'
7+
| 'amp'
8+
| 'unknown';
9+
10+
export type AgentInfo = {
11+
name: KnownAgentName;
12+
};
13+
14+
export type AgentDetection = AgentInfo | undefined;
15+
16+
type DetectAgentOptions = {
17+
stdoutIsTTY: boolean;
18+
env: NodeJS.ProcessEnv;
19+
};
20+
21+
function detectExplicitAgent(env: NodeJS.ProcessEnv): AgentInfo | undefined {
22+
// Amp
23+
if (env.AGENT === 'amp') {
24+
return {
25+
name: 'amp',
26+
};
27+
}
28+
29+
// Claude Code
30+
if (env.CLAUDECODE) {
31+
return {
32+
name: 'claude-code',
33+
};
34+
}
35+
36+
// Gemini CLI
37+
if (env.GEMINI_CLI) {
38+
return {
39+
name: 'gemini-cli',
40+
};
41+
}
42+
43+
// OpenAI Codex
44+
if (env.CODEX_SANDBOX) {
45+
return {
46+
name: 'codex',
47+
};
48+
}
49+
50+
// Cursor Agent (proposed / best-effort; Cursor often sets VSCode env vars too)
51+
if (env.CURSOR_AGENT) {
52+
return {
53+
name: 'cursor',
54+
};
55+
}
56+
57+
// Generic "AGENT" marker (unknown implementation)
58+
if (env.AGENT) {
59+
return { name: 'unknown' };
60+
}
61+
62+
return undefined;
63+
}
64+
65+
/** Detect whether Storybook CLI is likely being invoked by an AI agent. */
66+
export const detectAgent = (options: DetectAgentOptions): AgentDetection => {
67+
const env = options.env;
68+
69+
// 1) Explicit agent variables (strong signal; allow even in CI/TTY)
70+
const explicit = detectExplicitAgent(env);
71+
if (explicit) {
72+
return explicit;
73+
}
74+
75+
const stdoutIsTTY = options.stdoutIsTTY;
76+
77+
// 2) Behavioral / fingerprint heuristics (exclude CI to reduce false positives)
78+
if (stdoutIsTTY) {
79+
return undefined;
80+
}
81+
82+
const isDumbTerm = env.TERM === 'dumb';
83+
const hasAgentPager = env.GIT_PAGER === 'cat';
84+
85+
if (isDumbTerm || hasAgentPager) {
86+
return { name: 'unknown' };
87+
}
88+
89+
return undefined;
90+
};

code/core/src/telemetry/telemetry.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { nanoid } from 'nanoid';
1111
import { version } from '../../package.json';
1212
import { resolvePackageDir } from '../shared/utils/module';
1313
import { getAnonymousProjectId } from './anonymous-id';
14+
import { detectAgent } from './detect-agent';
1415
import { set as saveToCache } from './event-cache';
1516
import { fetch } from './fetch';
1617
import { getSessionId } from './session-id';
@@ -49,9 +50,12 @@ const getOperatingSystem = (): 'Windows' | 'macOS' | 'Linux' | `Other: ${string}
4950
// context info sent with all events, provided
5051
// by the app. currently:
5152
// - cliVersion
53+
const inCI = isCI();
54+
const agentDetection = detectAgent({ stdoutIsTTY: process.stdout.isTTY, env: process.env });
5255
const globalContext = {
53-
inCI: isCI(),
56+
inCI,
5457
isTTY: process.stdout.isTTY,
58+
agent: agentDetection,
5559
platform: getOperatingSystem(),
5660
nodeVersion: process.versions.node,
5761
storybookVersion: getVersionNumber(),

code/core/src/telemetry/types.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { StorybookConfig, TypescriptOptions } from 'storybook/internal/type
22

33
import type { DetectResult } from 'package-manager-detector';
44

5+
import type { AgentInfo } from './detect-agent';
56
import type { KnownPackagesList } from './get-known-packages';
67
import type { MonorepoType } from './get-monorepo-type';
78

@@ -56,6 +57,8 @@ export type StorybookMetadata = {
5657
storybookVersionSpecifier: string;
5758
generatedAt?: number;
5859
userSince?: number;
60+
/** If we can identify the agent, report it; otherwise `unknown` when detected heuristically. */
61+
agent?: AgentInfo;
5962
language: 'typescript' | 'javascript';
6063
framework?: {
6164
name?: string;

0 commit comments

Comments
 (0)