Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 91 additions & 0 deletions code/core/src/telemetry/detect-agent.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { describe, expect, it } from 'vitest';

import { detectAgent } from './detect-agent';

describe('detectAgent', () => {
it('detects amp via AGENT=amp (highest precedence)', () => {
expect(
detectAgent({
stdoutIsTTY: true,
env: { AGENT: 'amp', CLAUDECODE: '1', GEMINI_CLI: '1', CODEX_SANDBOX: '1', CURSOR_AGENT: '1' },
})
).toEqual({ isAgent: true, agent: { name: 'amp' } });
});

it('prefers CLAUDECODE over other explicit signals', () => {
expect(
detectAgent({
stdoutIsTTY: true,
env: { CLAUDECODE: '1', GEMINI_CLI: '1', CODEX_SANDBOX: '1', CURSOR_AGENT: '1', AGENT: 'something' },
})
).toEqual({ isAgent: true, agent: { name: 'claude-code' } });
});

it('detects Gemini CLI via GEMINI_CLI', () => {
expect(detectAgent({ stdoutIsTTY: true, env: { GEMINI_CLI: '1' } })).toEqual({
isAgent: true,
agent: { name: 'gemini-cli' },
});
});

it('detects OpenAI Codex via CODEX_SANDBOX', () => {
expect(detectAgent({ stdoutIsTTY: true, env: { CODEX_SANDBOX: '1' } })).toEqual({
isAgent: true,
agent: { name: 'codex' },
});
});

it('detects Cursor Agent via CURSOR_AGENT (even if AGENT is also set)', () => {
expect(detectAgent({ stdoutIsTTY: true, env: { CURSOR_AGENT: '1', AGENT: 'something' } })).toEqual({
isAgent: true,
agent: { name: 'cursor' },
});
});

it('treats generic AGENT as unknown', () => {
expect(detectAgent({ stdoutIsTTY: true, env: { AGENT: 'some-agent' } })).toEqual({
isAgent: true,
agent: { name: 'unknown' },
});
});

it('does not use heuristics when stdout is a TTY', () => {
expect(detectAgent({ stdoutIsTTY: true, env: { TERM: 'dumb' } })).toEqual({ isAgent: false });
expect(detectAgent({ stdoutIsTTY: true, env: { GIT_PAGER: 'cat' } })).toEqual({ isAgent: false });
});

it('detects unknown agent via TERM=dumb when stdout is not a TTY', () => {
expect(detectAgent({ stdoutIsTTY: false, env: { TERM: 'dumb' } })).toEqual({
isAgent: true,
agent: { name: 'unknown' },
});
});

it('detects unknown agent via GIT_PAGER=cat when stdout is not a TTY', () => {
expect(detectAgent({ stdoutIsTTY: false, env: { GIT_PAGER: 'cat' } })).toEqual({
isAgent: true,
agent: { name: 'unknown' },
});
});

it('returns isAgent=false when there are no signals', () => {
expect(detectAgent({ stdoutIsTTY: false, env: {} })).toEqual({ isAgent: false });
});
Comment on lines +77 to +79
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Fix misleading test description.

The test description says "returns isAgent=false" but the assertion expects undefined. The detectAgent function returns AgentInfo | undefined, not an object with an isAgent field.

📝 Suggested fix
-  it('returns isAgent=false when there are no signals', () => {
+  it('returns undefined when there are no signals', () => {
     expect(detectAgent({ stdoutIsTTY: false, env: {} })).toEqual(undefined);
   });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
it('returns isAgent=false when there are no signals', () => {
expect(detectAgent({ stdoutIsTTY: false, env: {} })).toEqual(undefined);
});
it('returns undefined when there are no signals', () => {
expect(detectAgent({ stdoutIsTTY: false, env: {} })).toEqual(undefined);
});
🤖 Prompt for AI Agents
In `@code/core/src/telemetry/detect-agent.test.ts` around lines 77 - 79, The test
description is misleading: update the test's "it" description in
detect-agent.test.ts to reflect the actual expected return value of detectAgent
(which is AgentInfo | undefined); change the string "returns isAgent=false when
there are no signals" to something like "returns undefined when there are no
signals" so it matches the assertion that detectAgent({ stdoutIsTTY: false, env:
{} }) returns undefined and references the detectAgent function in the test.


it('applies heuristics even when CI is set (no CI special-casing)', () => {
expect(
detectAgent({
stdoutIsTTY: false,
env: { CI: 'true', TERM: 'dumb' },
})
).toEqual({ isAgent: true, agent: { name: 'unknown' } });
});

it('still detects explicit agents in CI', () => {
expect(detectAgent({ stdoutIsTTY: false, env: { CI: 'true', CODEX_SANDBOX: '1' } })).toEqual({
isAgent: true,
agent: { name: 'codex' },
});
});
});

93 changes: 93 additions & 0 deletions code/core/src/telemetry/detect-agent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
export type KnownAgentName =
| 'claude-code'
| 'gemini-cli'
| 'cursor'
| 'codex'
| 'opencode'
| 'amp'
| 'unknown';

export type AgentInfo = {
name: KnownAgentName;
};

export type AgentDetection = {
isAgent: boolean;
agent?: AgentInfo;
};

type DetectAgentOptions = {
stdoutIsTTY: boolean;
env: NodeJS.ProcessEnv;
};

function detectExplicitAgent(env: NodeJS.ProcessEnv): AgentInfo | undefined {
// Amp
if (env.AGENT === 'amp') {
return {
name: 'amp',
};
}

// Claude Code
if (env.CLAUDECODE) {
return {
name: 'claude-code',
};
}

// Gemini CLI
if (env.GEMINI_CLI) {
return {
name: 'gemini-cli',
};
}

// OpenAI Codex
if (env.CODEX_SANDBOX) {
return {
name: 'codex',
};
}

// Cursor Agent (proposed / best-effort; Cursor often sets VSCode env vars too)
if (env.CURSOR_AGENT) {
return {
name: 'cursor',
};
}

// Generic "AGENT" marker (unknown implementation)
if (env.AGENT) {
return { name: 'unknown' };
}
Comment on lines +1 to +60
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

# First, let's examine the detect-agent.ts file to confirm the current state
cat -n code/core/src/telemetry/detect-agent.ts

Repository: storybookjs/storybook

Length of output: 2555


🏁 Script executed:

# Search for any references to 'opencode' in the codebase
rg -i "opencode" --type ts --type tsx --type js --type jsx -B 2 -A 2

Repository: storybookjs/storybook

Length of output: 92


🏁 Script executed:

# Also search for 'OPENCODE' environment variable references
rg "OPENCODE" -B 2 -A 2

Repository: storybookjs/storybook

Length of output: 47


🏁 Script executed:

# Let me search more broadly for 'opencode' with simpler rg command
rg -i "opencode"

Repository: storybookjs/storybook

Length of output: 119


🏁 Script executed:

# Also check git history to see when 'opencode' was added
git log --oneline --all -- code/core/src/telemetry/detect-agent.ts | head -20

Repository: storybookjs/storybook

Length of output: 106


🏁 Script executed:

# Check the actual git diff for that file to understand the context
git log -p --all -- code/core/src/telemetry/detect-agent.ts | head -150

Repository: storybookjs/storybook

Length of output: 47


🏁 Script executed:

# Check the full git history of that commit to understand intent
git show 017acedcc0

Repository: storybookjs/storybook

Length of output: 50382


🏁 Script executed:

# Search for any TODO or FIXME comments near opencode
rg -i "opencode|TODO|FIXME" code/core/src/telemetry/ -B 2 -A 2

Repository: storybookjs/storybook

Length of output: 1327


🌐 Web query:

OpenCode CLI environment variable identification agent

💡 Result:

In OpenCode CLI, the environment variable used to identify the client (agent/client identifier) is:

  • OPENCODE_CLIENT — “Client identifier (defaults to cli)” (set this if you want OpenCode to tag/identify where requests are coming from). [1]

To select which OpenCode “agent” to run, there isn’t an OPENCODE_* env var for that; you choose it via CLI flags:

  • opencode --agent <agent> (TUI) / opencode run --agent <agent> (non-interactive). [1]

Add explicit detection for opencode or remove it from KnownAgentName.

KnownAgentName includes 'opencode' (line 6), but detectExplicitAgent never returns it, so telemetry will never emit that value. Either add detection logic using the appropriate environment variable (OpenCode uses OPENCODE_CLIENT as a client identifier) or remove 'opencode' from the union to avoid a dead value.

🤖 Prompt for AI Agents
In `@code/core/src/telemetry/detect-agent.ts` around lines 1 - 63, The
KnownAgentName union includes 'opencode' but detectExplicitAgent never returns
it; update detectExplicitAgent to check for the OpenCode marker (e.g.
env.OPENCODE_CLIENT or the correct OpenCode env var) and return { name:
'opencode' } when present, referencing the detectExplicitAgent function and
KnownAgentName type; alternatively, if you prefer not to detect OpenCode, remove
'opencode' from the KnownAgentName union to avoid an unreachable value.


return undefined;
}

/** Detect whether Storybook CLI is likely being invoked by an AI agent. */
export const detectAgent = (options: DetectAgentOptions): AgentDetection => {
const env = options.env;

// 1) Explicit agent variables (strong signal; allow even in CI/TTY)
const explicit = detectExplicitAgent(env);
if (explicit) {
return { isAgent: true, agent: explicit };
}

const stdoutIsTTY = options.stdoutIsTTY;

// 2) Behavioral / fingerprint heuristics (exclude CI to reduce false positives)
if (stdoutIsTTY) {
return { isAgent: false };
}

const isDumbTerm = env.TERM === 'dumb';
const hasAgentPager = env.GIT_PAGER === 'cat';

if (isDumbTerm || hasAgentPager) {
return { isAgent: true, agent: { name: 'unknown' } };
}

return { isAgent: false };
};
7 changes: 6 additions & 1 deletion code/core/src/telemetry/telemetry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { nanoid } from 'nanoid';
import { version } from '../../package.json';
import { resolvePackageDir } from '../shared/utils/module';
import { getAnonymousProjectId } from './anonymous-id';
import { detectAgent } from './detect-agent';
import { set as saveToCache } from './event-cache';
import { fetch } from './fetch';
import { getSessionId } from './session-id';
Expand Down Expand Up @@ -49,9 +50,13 @@ const getOperatingSystem = (): 'Windows' | 'macOS' | 'Linux' | `Other: ${string}
// context info sent with all events, provided
// by the app. currently:
// - cliVersion
const inCI = isCI();
const agentDetection = detectAgent({ stdoutIsTTY: process.stdout.isTTY, env: process.env });
const globalContext = {
inCI: isCI(),
inCI,
isTTY: process.stdout.isTTY,
isAgent: agentDetection.isAgent,
agent: agentDetection.agent,
platform: getOperatingSystem(),
nodeVersion: process.versions.node,
storybookVersion: getVersionNumber(),
Expand Down
5 changes: 5 additions & 0 deletions code/core/src/telemetry/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { StorybookConfig, TypescriptOptions } from 'storybook/internal/type

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

import type { AgentInfo } from './detect-agent';
import type { KnownPackagesList } from './get-known-packages';
import type { MonorepoType } from './get-monorepo-type';

Expand Down Expand Up @@ -56,6 +57,10 @@ export type StorybookMetadata = {
storybookVersionSpecifier: string;
generatedAt?: number;
userSince?: number;
/** Whether this process is likely invoked by an AI agent */
isAgent?: boolean;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant to agent: { name: 'unknown' } IMO

/** If we can identify the agent, report it; otherwise `unknown` when detected heuristically. */
agent?: AgentInfo;
language: 'typescript' | 'javascript';
framework?: {
name?: string;
Expand Down
Loading