Skip to content

Commit 9a1ea9d

Browse files
feat: refine AI agent operator detection (#1398)
1 parent 572ff91 commit 9a1ea9d

3 files changed

Lines changed: 321 additions & 79 deletions

File tree

Lines changed: 178 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -1,218 +1,343 @@
1-
import { beforeEach, describe, expect, it } from 'vitest'
1+
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
22
import { detectOperator, detectCliMode } from '../cli-mode.js'
33

44
const operatorEnvVars = [
5-
'CLAUDECODE', 'CURSOR_TRACE_ID', 'CURSOR_AGENT', 'TERM_PROGRAM', 'GITHUB_COPILOT',
6-
'AIDER', 'WINDSURF', 'CODEIUM_ENV', 'CLINE_ACTIVE', 'CODEX_SANDBOX', 'CODEX_THREAD_ID',
7-
'GEMINI_CLI', 'OPENCODE', 'GITHUB_ACTIONS', 'GITLAB_CI', 'CI',
8-
]
5+
'AI_AGENT', 'AGENT', 'CLAUDECODE', 'CLAUDE_CODE', 'CLAUDE_CODE_IS_COWORK',
6+
'AMP_CURRENT_THREAD_ID', 'CURSOR_TRACE_ID', 'CURSOR_AGENT', 'CURSOR_EXTENSION_HOST_ROLE',
7+
'TERM_PROGRAM', 'VSCODE_AGENT', 'COPILOT_AGENT', 'COPILOT_CLI', 'GITHUB_COPILOT',
8+
'COPILOT_MODEL', 'COPILOT_ALLOW_ALL', 'COPILOT_GITHUB_TOKEN', 'AIDER', 'WINDSURF',
9+
'WINDSURF_AGENT', 'CODEIUM_ENV', 'CLINE_ACTIVE', 'CODEX_SANDBOX', 'CODEX_CI',
10+
'CODEX_THREAD_ID', 'GEMINI_CLI', 'OPENCODE', 'ANTIGRAVITY_AGENT', 'AUGMENT_AGENT',
11+
'ANDROID_STUDIO_AGENT', 'KIRO_AGENT_PATH', 'TRAE_AI_SHELL_ID', 'GOOSE_TERMINAL',
12+
'GITHUB_AW', 'REPL_ID', 'MONOSPACE_ENV', 'GITHUB_ACTIONS', 'GITLAB_CI', 'CI',
13+
'CHECKLY_CLI_MODE', 'HOME', 'PATH',
14+
] as const
15+
16+
const originalEnv = Object.fromEntries(operatorEnvVars.map(key => [key, process.env[key]]))
17+
const noAgentFiles = () => false
18+
19+
beforeEach(() => {
20+
for (const key of operatorEnvVars) delete process.env[key]
21+
})
922

10-
describe('detectOperator', () => {
11-
beforeEach(() => {
12-
for (const key of operatorEnvVars) delete process.env[key]
13-
})
23+
afterEach(() => {
24+
for (const key of operatorEnvVars) {
25+
const value = originalEnv[key]
26+
if (value === undefined) delete process.env[key]
27+
else process.env[key] = value
28+
}
29+
})
1430

31+
describe('detectOperator', () => {
1532
it('returns "manual" when no agent env vars are set', () => {
16-
expect(detectOperator()).toBe('manual')
33+
expect(detectOperator(noAgentFiles)).toBe('manual')
1734
})
1835

1936
it('detects Claude Code', () => {
2037
process.env.CLAUDECODE = '1'
21-
expect(detectOperator()).toBe('claude-code')
38+
expect(detectOperator(noAgentFiles)).toBe('claude-code')
2239
})
2340

2441
it('detects Cursor via CURSOR_TRACE_ID', () => {
2542
process.env.CURSOR_TRACE_ID = 'some-trace-id'
26-
expect(detectOperator()).toBe('cursor')
43+
expect(detectOperator(noAgentFiles)).toBe('cursor')
2744
})
2845

2946
it('detects Cursor via CURSOR_AGENT', () => {
3047
process.env.CURSOR_AGENT = '1'
31-
expect(detectOperator()).toBe('cursor')
48+
expect(detectOperator(noAgentFiles)).toBe('cursor')
3249
})
3350

3451
it('detects VS Code', () => {
3552
process.env.TERM_PROGRAM = 'vscode'
36-
expect(detectOperator()).toBe('vscode')
53+
expect(detectOperator(noAgentFiles)).toBe('vscode')
3754
})
3855

3956
it('does not detect VS Code for other TERM_PROGRAM values', () => {
4057
process.env.TERM_PROGRAM = 'iTerm.app'
41-
expect(detectOperator()).toBe('manual')
58+
expect(detectOperator(noAgentFiles)).toBe('manual')
4259
})
4360

4461
it('detects GitHub Copilot', () => {
4562
process.env.GITHUB_COPILOT = '1'
46-
expect(detectOperator()).toBe('github-copilot')
63+
expect(detectOperator(noAgentFiles)).toBe('github-copilot')
4764
})
4865

4966
it('detects Aider', () => {
5067
process.env.AIDER = '1'
51-
expect(detectOperator()).toBe('aider')
68+
expect(detectOperator(noAgentFiles)).toBe('aider')
5269
})
5370

5471
it('detects Windsurf via WINDSURF env var', () => {
5572
process.env.WINDSURF = '1'
56-
expect(detectOperator()).toBe('windsurf')
73+
expect(detectOperator(noAgentFiles)).toBe('windsurf')
5774
})
5875

5976
it('detects Windsurf via CODEIUM_ENV env var', () => {
6077
process.env.CODEIUM_ENV = 'production'
61-
expect(detectOperator()).toBe('windsurf')
78+
expect(detectOperator(noAgentFiles)).toBe('windsurf')
6279
})
6380

6481
it('detects GitHub Actions', () => {
6582
process.env.GITHUB_ACTIONS = 'true'
66-
expect(detectOperator()).toBe('github-actions')
83+
expect(detectOperator(noAgentFiles)).toBe('github-actions')
6784
})
6885

6986
it('detects GitLab CI', () => {
7087
process.env.GITLAB_CI = 'true'
71-
expect(detectOperator()).toBe('gitlab-ci')
88+
expect(detectOperator(noAgentFiles)).toBe('gitlab-ci')
7289
})
7390

7491
it('detects Cline', () => {
7592
process.env.CLINE_ACTIVE = '1'
76-
expect(detectOperator()).toBe('cline')
93+
expect(detectOperator(noAgentFiles)).toBe('cline')
7794
})
7895

7996
it('detects Codex CLI via CODEX_SANDBOX', () => {
8097
process.env.CODEX_SANDBOX = '1'
81-
expect(detectOperator()).toBe('codex-cli')
98+
expect(detectOperator(noAgentFiles)).toBe('codex-cli')
8299
})
83100

84101
it('detects Codex CLI via CODEX_THREAD_ID', () => {
85102
process.env.CODEX_THREAD_ID = 'thread-123'
86-
expect(detectOperator()).toBe('codex-cli')
103+
expect(detectOperator(noAgentFiles)).toBe('codex-cli')
87104
})
88105

89106
it('detects Gemini CLI', () => {
90107
process.env.GEMINI_CLI = '1'
91-
expect(detectOperator()).toBe('gemini-cli')
108+
expect(detectOperator(noAgentFiles)).toBe('gemini-cli')
92109
})
93110

94111
it('detects OpenCode', () => {
95112
process.env.OPENCODE = '1'
96-
expect(detectOperator()).toBe('opencode')
113+
expect(detectOperator(noAgentFiles)).toBe('opencode')
114+
})
115+
116+
it.each([
117+
{ key: 'CLAUDE_CODE', value: '1', expected: 'claude-code' },
118+
{ key: 'AMP_CURRENT_THREAD_ID', value: 'thread-123', expected: 'amp' },
119+
{ key: 'CURSOR_EXTENSION_HOST_ROLE', value: 'agent-exec', expected: 'cursor' },
120+
{ key: 'VSCODE_AGENT', value: '1', expected: 'vscode-agent' },
121+
{ key: 'COPILOT_AGENT', value: '1', expected: 'vscode-agent' },
122+
{ key: 'COPILOT_CLI', value: '1', expected: 'github-copilot-cli' },
123+
{ key: 'COPILOT_MODEL', value: 'gpt-5', expected: 'github-copilot' },
124+
{ key: 'WINDSURF_AGENT', value: '1', expected: 'windsurf' },
125+
{ key: 'CODEX_CI', value: '1', expected: 'codex-cli' },
126+
{ key: 'ANTIGRAVITY_AGENT', value: '1', expected: 'antigravity' },
127+
{ key: 'AUGMENT_AGENT', value: '1', expected: 'augment-cli' },
128+
{ key: 'ANDROID_STUDIO_AGENT', value: '1', expected: 'android-studio-agent' },
129+
{ key: 'KIRO_AGENT_PATH', value: '/path/to/kiro-agent', expected: 'kiro-agent' },
130+
{ key: 'TRAE_AI_SHELL_ID', value: 'shell-123', expected: 'trae' },
131+
{ key: 'GOOSE_TERMINAL', value: '1', expected: 'goose' },
132+
{ key: 'GITHUB_AW', value: 'true', expected: 'github-agentic-workflow' },
133+
{ key: 'REPL_ID', value: 'workspace-123', expected: 'replit' },
134+
])('detects $key as $expected', ({ key, value, expected }) => {
135+
process.env[key] = value
136+
expect(detectOperator(noAgentFiles)).toBe(expected)
137+
expect(detectCliMode(noAgentFiles)).toBe(key === 'REPL_ID' ? 'interactive' : 'agent')
138+
})
139+
140+
it('detects named generic agent markers and normalizes known aliases', () => {
141+
process.env.AI_AGENT = ' github_copilot_vscode_agent '
142+
expect(detectOperator(noAgentFiles)).toBe('vscode-agent')
143+
144+
delete process.env.AI_AGENT
145+
process.env.AGENT = 'goose'
146+
expect(detectOperator(noAgentFiles)).toBe('goose')
147+
})
148+
149+
it('uses a specific agent marker over a boolean generic marker', () => {
150+
process.env.AGENT = '1'
151+
process.env.OPENCODE = '1'
152+
expect(detectOperator(noAgentFiles)).toBe('opencode')
153+
})
154+
155+
it('uses a safe generic operator for invalid explicit agent names', () => {
156+
process.env.AI_AGENT = 'invalid\nheader'
157+
expect(detectOperator(noAgentFiles)).toBe('agent')
158+
})
159+
160+
it('does not allow an explicit agent marker to report itself as manual', () => {
161+
process.env.AI_AGENT = 'manual'
162+
expect(detectOperator(noAgentFiles)).toBe('agent')
163+
})
164+
165+
it('ignores explicitly disabled generic agent markers', () => {
166+
process.env.AI_AGENT = 'false'
167+
process.env.AGENT = '0'
168+
expect(detectOperator(noAgentFiles)).toBe('manual')
169+
})
170+
171+
it('prioritizes Claude Cowork over its shared Claude Code marker', () => {
172+
process.env.CLAUDE_CODE_IS_COWORK = '1'
173+
process.env.CLAUDECODE = '1'
174+
expect(detectOperator(noAgentFiles)).toBe('claude-cowork')
175+
})
176+
177+
it('detects Devin via its sandbox filesystem marker', () => {
178+
expect(detectOperator(path => path === '/opt/.devin')).toBe('devin')
179+
})
180+
181+
it('detects Jules only when its user and VM marker agree', () => {
182+
process.env.HOME = '/home/jules'
183+
expect(detectOperator(path => path === '/opt/environment_summary.sh')).toBe('jules')
184+
expect(detectCliMode(path => path === '/opt/environment_summary.sh')).toBe('agent')
185+
})
186+
187+
it('detects Pi from its agent path segment', () => {
188+
process.env.PATH = '/usr/bin:/home/user/.pi/agent/bin'
189+
expect(detectOperator(noAgentFiles)).toBe('pi')
190+
expect(detectCliMode(noAgentFiles)).toBe('agent')
191+
})
192+
193+
it('detects Firebase Studio without claiming its terminal is agent-driven', () => {
194+
process.env.MONOSPACE_ENV = 'true'
195+
expect(detectOperator(noAgentFiles)).toBe('firebase-studio')
196+
expect(detectCliMode(noAgentFiles)).toBe('interactive')
97197
})
98198

99199
it('detects generic CI', () => {
100200
process.env.CI = 'true'
101-
expect(detectOperator()).toBe('ci')
201+
expect(detectOperator(noAgentFiles)).toBe('ci')
102202
})
103203

104204
it('prioritizes Cline over VS Code (Cline is a VS Code extension)', () => {
105205
process.env.CLINE_ACTIVE = '1'
106206
process.env.TERM_PROGRAM = 'vscode'
107-
expect(detectOperator()).toBe('cline')
207+
expect(detectOperator(noAgentFiles)).toBe('cline')
108208
})
109209

110210
it('prioritizes Claude Code over CI', () => {
111211
process.env.CLAUDECODE = '1'
112212
process.env.CI = 'true'
113-
expect(detectOperator()).toBe('claude-code')
213+
expect(detectOperator(noAgentFiles)).toBe('claude-code')
114214
})
115215

116216
it('prioritizes GitHub Actions over generic CI', () => {
117217
process.env.GITHUB_ACTIONS = 'true'
118218
process.env.CI = 'true'
119-
expect(detectOperator()).toBe('github-actions')
219+
expect(detectOperator(noAgentFiles)).toBe('github-actions')
120220
})
121-
})
122221

123-
describe('detectCliMode', () => {
124-
beforeEach(() => {
125-
delete process.env.CHECKLY_CLI_MODE
126-
for (const key of operatorEnvVars) delete process.env[key]
222+
it('prioritizes CI over broad IDE environment markers', () => {
223+
process.env.CI = 'true'
224+
process.env.TERM_PROGRAM = 'vscode'
225+
expect(detectOperator(noAgentFiles)).toBe('ci')
127226
})
227+
})
128228

229+
describe('detectCliMode', () => {
129230
it('returns "interactive" by default', () => {
130-
expect(detectCliMode()).toBe('interactive')
231+
expect(detectCliMode(noAgentFiles)).toBe('interactive')
131232
})
132233

133234
it('returns "agent" when operator is claude-code', () => {
134235
process.env.CLAUDECODE = '1'
135-
expect(detectCliMode()).toBe('agent')
236+
expect(detectCliMode(noAgentFiles)).toBe('agent')
136237
})
137238

138239
it('returns "agent" when operator is cursor', () => {
139240
process.env.CURSOR_TRACE_ID = 'trace'
140-
expect(detectCliMode()).toBe('agent')
241+
expect(detectCliMode(noAgentFiles)).toBe('agent')
141242
})
142243

143244
it('returns "agent" when operator is windsurf', () => {
144245
process.env.WINDSURF = '1'
145-
expect(detectCliMode()).toBe('agent')
246+
expect(detectCliMode(noAgentFiles)).toBe('agent')
146247
})
147248

148249
it('returns "agent" when operator is aider', () => {
149250
process.env.AIDER = '1'
150-
expect(detectCliMode()).toBe('agent')
251+
expect(detectCliMode(noAgentFiles)).toBe('agent')
151252
})
152253

153254
it('returns "agent" when operator is github-copilot', () => {
154255
process.env.GITHUB_COPILOT = '1'
155-
expect(detectCliMode()).toBe('agent')
256+
expect(detectCliMode(noAgentFiles)).toBe('agent')
156257
})
157258

158259
it('returns "agent" when operator is cline', () => {
159260
process.env.CLINE_ACTIVE = '1'
160-
expect(detectCliMode()).toBe('agent')
261+
expect(detectCliMode(noAgentFiles)).toBe('agent')
161262
})
162263

163264
it('returns "agent" when operator is codex-cli', () => {
164265
process.env.CODEX_SANDBOX = '1'
165-
expect(detectCliMode()).toBe('agent')
266+
expect(detectCliMode(noAgentFiles)).toBe('agent')
166267
})
167268

168269
it('returns "agent" when operator is gemini-cli', () => {
169270
process.env.GEMINI_CLI = '1'
170-
expect(detectCliMode()).toBe('agent')
271+
expect(detectCliMode(noAgentFiles)).toBe('agent')
171272
})
172273

173274
it('returns "agent" when operator is opencode', () => {
174275
process.env.OPENCODE = '1'
175-
expect(detectCliMode()).toBe('agent')
276+
expect(detectCliMode(noAgentFiles)).toBe('agent')
277+
})
278+
279+
it('returns "agent" for current VS Code agent commands', () => {
280+
process.env.VSCODE_AGENT = '1'
281+
expect(detectCliMode(noAgentFiles)).toBe('agent')
282+
})
283+
284+
it('returns "agent" for custom explicit agent names', () => {
285+
process.env.AI_AGENT = 'my-company-agent'
286+
expect(detectCliMode(noAgentFiles)).toBe('agent')
287+
})
288+
289+
it('returns "agent" for Devin filesystem markers', () => {
290+
expect(detectCliMode(path => path === '/opt/.devin')).toBe('agent')
291+
})
292+
293+
it('keeps a generic Replit workspace interactive', () => {
294+
process.env.REPL_ID = 'workspace-123'
295+
expect(detectCliMode(noAgentFiles)).toBe('interactive')
296+
})
297+
298+
it('keeps a generic Kiro terminal interactive', () => {
299+
process.env.TERM_PROGRAM = 'kiro'
300+
expect(detectCliMode(noAgentFiles)).toBe('interactive')
176301
})
177302

178303
it('returns "ci" when operator is github-actions', () => {
179304
process.env.GITHUB_ACTIONS = 'true'
180-
expect(detectCliMode()).toBe('ci')
305+
expect(detectCliMode(noAgentFiles)).toBe('ci')
181306
})
182307

183308
it('returns "ci" when operator is gitlab-ci', () => {
184309
process.env.GITLAB_CI = 'true'
185-
expect(detectCliMode()).toBe('ci')
310+
expect(detectCliMode(noAgentFiles)).toBe('ci')
186311
})
187312

188313
it('returns "ci" when operator is generic ci', () => {
189314
process.env.CI = 'true'
190-
expect(detectCliMode()).toBe('ci')
315+
expect(detectCliMode(noAgentFiles)).toBe('ci')
191316
})
192317

193318
it('returns "interactive" for vscode (not an agent)', () => {
194319
process.env.TERM_PROGRAM = 'vscode'
195-
expect(detectCliMode()).toBe('interactive')
320+
expect(detectCliMode(noAgentFiles)).toBe('interactive')
196321
})
197322

198323
it('env var CHECKLY_CLI_MODE overrides auto-detection', () => {
199324
process.env.CLAUDECODE = '1'
200325
process.env.CHECKLY_CLI_MODE = 'interactive'
201-
expect(detectCliMode()).toBe('interactive')
326+
expect(detectCliMode(noAgentFiles)).toBe('interactive')
202327
})
203328

204329
it('env var CHECKLY_CLI_MODE=agent forces agent mode', () => {
205330
process.env.CHECKLY_CLI_MODE = 'agent'
206-
expect(detectCliMode()).toBe('agent')
331+
expect(detectCliMode(noAgentFiles)).toBe('agent')
207332
})
208333

209334
it('env var CHECKLY_CLI_MODE=ci forces ci mode', () => {
210335
process.env.CHECKLY_CLI_MODE = 'ci'
211-
expect(detectCliMode()).toBe('ci')
336+
expect(detectCliMode(noAgentFiles)).toBe('ci')
212337
})
213338

214339
it('ignores invalid CHECKLY_CLI_MODE values and falls back to auto-detect', () => {
215340
process.env.CHECKLY_CLI_MODE = 'bogus'
216-
expect(detectCliMode()).toBe('interactive')
341+
expect(detectCliMode(noAgentFiles)).toBe('interactive')
217342
})
218343
})

0 commit comments

Comments
 (0)