diff --git a/internal/output/agentmode.go b/internal/output/agentmode.go index 653e9f51..d4814115 100644 --- a/internal/output/agentmode.go +++ b/internal/output/agentmode.go @@ -40,22 +40,30 @@ var agentEnvVars = []string{ } // InitAgentMode detects and caches agent mode state for the lifetime of the -// process. In production, each CLI invocation is a separate process, so this -// is evaluated exactly once. For in-process test scenarios with multiple -// command executions, call ResetAgentMode() between runs to re-evaluate. +// process. In production, each CLI invocation is a separate process, so the +// environment is checked exactly once, while an explicit --agent-mode is +// applied by any call that can see it. For in-process test scenarios with +// multiple command executions, call ResetAgentMode() between runs to +// re-evaluate. // // Checks --agent-mode flag first (explicit override), then auto-detects // from well-known AI agent environment variables. func InitAgentMode(cmd *cobra.Command) { - // CompareAndSwap ensures only the first caller runs detection; subsequent - // calls return immediately without touching agentMode. - if !agentDetected.CompareAndSwap(false, true) { + // Explicit flag takes priority: --agent-mode=false overrides env vars. It is + // checked before the CompareAndSwap below because Execute calls this once + // before cobra parses flags, to keep the explorer TUI away from agents. + // That first call can only see the environment, and used to settle the + // mode for good, so the flag was ignored. The second call, from + // PersistentPreRunE, is the first that can see the flag. + if flagVal, changed := flagutil.GetBoolFlag(cmd, "agent-mode"); changed { + agentDetected.Store(true) + agentMode.Store(flagVal) return } - // Explicit flag takes priority: --agent-mode=false overrides env vars. - if flagVal, changed := flagutil.GetBoolFlag(cmd, "agent-mode"); changed { - agentMode.Store(flagVal) + // CompareAndSwap ensures only the first caller runs detection; subsequent + // calls return immediately without touching agentMode. + if !agentDetected.CompareAndSwap(false, true) { return } diff --git a/internal/output/agentmode_test.go b/internal/output/agentmode_test.go new file mode 100644 index 00000000..a9e9690e --- /dev/null +++ b/internal/output/agentmode_test.go @@ -0,0 +1,59 @@ +package output + +import ( + "testing" + + "github.com/spf13/cobra" +) + +// commandTree returns a root with the global --agent-mode flag and a child, +// the two commands Execute and PersistentPreRunE pass to InitAgentMode. +func commandTree() (root, child *cobra.Command) { + root = &cobra.Command{Use: "vf"} + root.PersistentFlags().Bool("agent-mode", false, "") + child = &cobra.Command{Use: "workspace", Run: func(*cobra.Command, []string) {}} + root.AddCommand(child) + return root, child +} + +// Execute calls InitAgentMode before cobra parses flags, so it sees only the +// environment; PersistentPreRunE calls it again once flags are parsed. An +// explicit --agent-mode must win on that second call, in both directions. +func TestAnExplicitAgentModeFlagWinsOverTheEnvironment(t *testing.T) { + cases := []struct { + name string + underAnAI bool + flag string + want bool + }{ + {"--agent-mode=false under an agent", true, "false", false}, + {"--agent-mode outside an agent", false, "true", true}, + {"no flag under an agent", true, "", true}, + {"no flag outside an agent", false, "", false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + for _, name := range agentEnvVars { + t.Setenv(name, "") + } + if tc.underAnAI { + t.Setenv("CLAUDECODE", "1") + } + ResetAgentMode() + t.Cleanup(ResetAgentMode) + root, child := commandTree() + + InitAgentMode(root) // as Execute does, before flags are parsed + if tc.flag != "" { + if err := root.PersistentFlags().Set("agent-mode", tc.flag); err != nil { + t.Fatal(err) + } + } + InitAgentMode(child) // as PersistentPreRunE does, after parsing + + if got := IsAgentMode(); got != tc.want { + t.Errorf("IsAgentMode() = %v, want %v", got, tc.want) + } + }) + } +} diff --git a/test/agent-mode-flag.test.ts b/test/agent-mode-flag.test.ts new file mode 100644 index 00000000..02f93d56 --- /dev/null +++ b/test/agent-mode-flag.test.ts @@ -0,0 +1,103 @@ +// Tests for the --agent-mode flag (internal/output/agentmode.go): an explicit +// value wins over agent detection from the environment, in both directions. +// +// Execute settles agent mode once before cobra parses flags, from the +// environment alone, and that first answer used to be final, so the flag did +// nothing: --agent-mode=false under Claude Code still printed JSON envelopes, +// and --agent-mode in a plain shell still printed human errors. +// +// Requires: go build -o vf ./cmd/vf + +import { execa } from 'execa'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +const VF = path.resolve(__dirname, '..', 'vf'); + +// Every variable that puts the CLI into agent mode. Mirrors the list in +// internal/output/agentmode.go; each case sets the one it needs. +const AGENT_ENV_VARS = [ + 'CLAUDECODE', 'CLAUDE_CODE', 'CURSOR_AGENT', 'CODEX', 'AIDER', 'CLINE', + 'WINDSURF_AGENT', 'GITHUB_COPILOT', 'AMAZON_Q', 'GEMINI_CODE_ASSIST', + 'SRC_CODY', 'FORCE_AGENT_MODE', +]; + +let home: string; + +beforeAll(() => { + // vf keeps credentials under HOME; an empty one keeps the developer's out. + home = fs.mkdtempSync(path.join(os.tmpdir(), 'vf-agent-mode-home-')); +}); + +afterAll(() => { + fs.rmSync(home, { recursive: true, force: true }); +}); + +function run(args: string[], env: Record = {}) { + const cleared: Record = Object.fromEntries(AGENT_ENV_VARS.map((name) => [name, undefined])); + return execa({ + reject: false, + timeout: 20_000, + stdin: 'ignore', + env: { + ...cleared, + HOME: home, + VF_TOKEN: '', + // The no-token cases must find no token anywhere. An empty HOME hides the + // macOS Keychain; on Linux the keyring is reached over D-Bus, so point + // the session bus at a socket that does not exist. + DBUS_SESSION_BUS_ADDRESS: `unix:path=${path.join(home, 'no-session-bus')}`, + ...env, + }, + extendEnv: true, + })(VF, args); +} + +// No token and an unroutable server: vf fails its preflight before sending +// anything, and reports it in whichever mode is in effect. +const NO_TOKEN = ['workspace', 'list', '--server-url', 'http://127.0.0.1:1']; + +/** In agent mode stderr is the JSON envelope and nothing else. */ +const isEnvelope = (stderr: string) => stderr.trimStart().startsWith('{'); + +describe('--agent-mode', () => { + it('turns agent mode off under an agent', async () => { + const result = await run([...NO_TOKEN, '--agent-mode=false'], { CLAUDECODE: '1' }); + + expect(result.exitCode).toBe(1); + expect(isEnvelope(result.stderr), result.stderr).toBe(false); + expect(result.stderr).toContain('API Error'); + }); + + it('turns agent mode on outside an agent', async () => { + const result = await run([...NO_TOKEN, '--agent-mode']); + + expect(result.exitCode).toBe(1); + expect(isEnvelope(result.stderr), result.stderr).toBe(true); + expect(JSON.parse(result.stderr).error_type).toBe('authentication_error'); + }); + + it('leaves detection from the environment in charge when it is not given', async () => { + const underAnAgent = await run(NO_TOKEN, { CLAUDECODE: '1' }); + const inAPlainShell = await run(NO_TOKEN); + + expect(isEnvelope(underAnAgent.stderr), underAnAgent.stderr).toBe(true); + expect(isEnvelope(inAPlainShell.stderr), inAPlainShell.stderr).toBe(false); + }); + + // A bad flag value is reported after the command has run, so this checks the + // flag reaches that path too. + it('also decides how a bad flag value is reported', async () => { + const result = await run( + ['agent', 'update', '--project-id', 'p', '--environment-alias', 'main', '--dry-run', '--token', 'vfp_x', + '--llm', 'gpt-4', '--agent-mode=false'], + { CLAUDECODE: '1' }, + ); + + expect(result.exitCode).toBe(1); + expect(isEnvelope(result.stderr), result.stderr).toBe(false); + expect(result.stderr).toContain('invalid value for --llm'); + }); +});