Skip to content

Commit 8a53f25

Browse files
claudeapgiorgi
authored andcommitted
chore: classify User-Agent mode as interactive/agent/noninteractive
The non-TTY soft signal (pipes, redirects, CI/CD) enables agent-mode behavior but isn't a confirmed AI agent. Tagging it mode=agent would conflate genuine agent traffic with CI and piped use in analytics. Derive a three-value mode= token from the detection reason: agent (only for explicit --agent/DCI_AGENT_MODE=1 or a known AI-agent env var), noninteractive (non-TTY soft signal), and interactive (human TTY or explicit human mode). Runtime behavior is unchanged.
1 parent d223cf8 commit 8a53f25

3 files changed

Lines changed: 84 additions & 50 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ that is cheap to parse and free of decoration:
9292
- Default `--output` becomes `toon` (compact, token-efficient) instead of `table`
9393
- No color, spinners, or other terminal decoration
9494
- Banners, tips, and status chatter go to **stderr**, leaving **stdout** for data only
95-
- The request `User-Agent` carries `mode=agent` (human invocations carry `mode=interactive`) so API traffic can be segmented by interface
95+
- The request `User-Agent` carries a `mode=` token — `agent` (explicit flag/env or a known AI-agent environment), `noninteractive` (piped/redirected output or CI/CD), or `interactive` (human at a terminal) — so API traffic can be segmented by interface
9696

9797
### How agent mode is detected
9898

main.go

Lines changed: 39 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,22 @@ var agentMode bool
6464
// agentModeReason records why agentMode resolved the way it did, for diagnostics.
6565
var agentModeReason string
6666

67+
// uaMode classifies how dci is being driven, for the User-Agent "mode=" token.
68+
// It is finer-grained than agentMode (a bool): agent mode reached via the
69+
// non-TTY soft signal (pipes, redirects, CI/CD) is reported as noninteractive
70+
// rather than agent, so analytics can tell genuine AI-agent traffic apart from
71+
// incidental non-interactive use.
72+
type uaMode string
73+
74+
const (
75+
uaModeInteractive uaMode = "interactive" // human at a TTY (or explicit human mode)
76+
uaModeAgent uaMode = "agent" // explicit --agent/DCI_AGENT_MODE=1, or a known AI-agent env var
77+
uaModeNonInteractive uaMode = "noninteractive" // non-TTY soft signal: pipe/redirect/CI
78+
)
79+
80+
// agentUAMode records the interface classification for the User-Agent token.
81+
var agentUAMode uaMode
82+
6783
// agentEnvDetected holds the name of the detected agent environment variable (if
6884
// any), used to surface the human-mode "an optimized agent mode exists" tip.
6985
var agentEnvDetected string
@@ -218,6 +234,7 @@ func run() (exitCode int) {
218234
dec := resolveAgentMode(os.Getenv("DCI_AGENT_MODE"), os.Args, agentEnvDetected, stdoutIsTTY())
219235
agentMode = dec.enabled
220236
agentModeReason = dec.reason
237+
agentUAMode = dec.mode
221238
if agentMode {
222239
// Disable restish/aurora color before cli.Init configures the output
223240
// writers; it reads this via viper's automatic env binding.
@@ -260,10 +277,10 @@ func run() (exitCode int) {
260277
viper.Set("rsh-profile", "default")
261278

262279
// Hardcode user-agent so the DCI API can identify CLI traffic. It carries a
263-
// mode=agent|interactive token (never the end user) so traffic can be
264-
// segmented by interface. Restish picks this up via rsh-header and skips its
265-
// own default.
266-
viper.Set("rsh-header", []string{"user-agent:" + buildUserAgent(agentMode)})
280+
// mode=<interactive|agent|noninteractive> token (never the end user) so
281+
// traffic can be segmented by interface. Restish picks this up via rsh-header
282+
// and skips its own default.
283+
viper.Set("rsh-header", []string{"user-agent:" + buildUserAgent(agentUAMode)})
267284

268285
cli.Load("dci", cli.Root)
269286
applyAPIKeyAuth()
@@ -513,6 +530,7 @@ var agentEnvVars = []string{
513530

514531
type agentModeResult struct {
515532
enabled bool
533+
mode uaMode
516534
reason string
517535
}
518536

@@ -530,22 +548,27 @@ func resolveAgentMode(dciAgentMode string, args []string, agentEnv string, stdou
530548
// (e.g. DCI_AGENT_MODE=2) is ignored so a typo can't silently force a
531549
// mode; run() warns about it separately.
532550
if b, ok := parseBoolish(v); ok {
533-
return agentModeResult{enabled: b, reason: "DCI_AGENT_MODE override"}
551+
if b {
552+
return agentModeResult{enabled: true, mode: uaModeAgent, reason: "DCI_AGENT_MODE override"}
553+
}
554+
return agentModeResult{enabled: false, mode: uaModeInteractive, reason: "DCI_AGENT_MODE override"}
534555
}
535556
}
536557
switch agentFlagOverride(args) {
537558
case 1:
538-
return agentModeResult{enabled: true, reason: "--agent/--no-agent flag"}
559+
return agentModeResult{enabled: true, mode: uaModeAgent, reason: "--agent/--no-agent flag"}
539560
case -1:
540-
return agentModeResult{enabled: false, reason: "--agent/--no-agent flag"}
561+
return agentModeResult{enabled: false, mode: uaModeInteractive, reason: "--agent/--no-agent flag"}
541562
}
542563
if agentEnv != "" {
543-
return agentModeResult{enabled: true, reason: "agent env var " + agentEnv}
564+
return agentModeResult{enabled: true, mode: uaModeAgent, reason: "agent env var " + agentEnv}
544565
}
545566
if !stdoutTTY {
546-
return agentModeResult{enabled: true, reason: "non-TTY stdout"}
567+
// Non-TTY is a soft signal (pipe/redirect/CI), not a confirmed agent, so
568+
// it gets its own UA classification even though behavior matches agent mode.
569+
return agentModeResult{enabled: true, mode: uaModeNonInteractive, reason: "non-TTY stdout"}
547570
}
548-
return agentModeResult{enabled: false, reason: "interactive terminal"}
571+
return agentModeResult{enabled: false, mode: uaModeInteractive, reason: "interactive terminal"}
549572
}
550573

551574
// detectedAgentEnv returns the name of the first agent env var found, or "".
@@ -611,14 +634,13 @@ func parseBoolish(v string) (bool, bool) {
611634
}
612635

613636
// buildUserAgent returns the User-Agent header value identifying CLI traffic to
614-
// the DCI API. It always carries a mode=agent|interactive token so API traffic
615-
// can be segmented by interface (agent- vs human-driven) in analytics. The
616-
// token reflects only the interface, never the end user, so the value stays a
637+
// the DCI API. It always carries a mode=<interactive|agent|noninteractive>
638+
// token so API traffic can be segmented by interface in analytics. The token
639+
// reflects only how dci was driven, never the end user, so the value stays a
617640
// stable client identifier.
618-
func buildUserAgent(agent bool) string {
619-
mode := "interactive"
620-
if agent {
621-
mode = "agent"
641+
func buildUserAgent(mode uaMode) string {
642+
if mode == "" {
643+
mode = uaModeInteractive
622644
}
623645
return fmt.Sprintf("dci-cli/%s (%s; %s/%s; mode=%s)", version, runtime.Version(), runtime.GOOS, runtime.GOARCH, mode)
624646
}

main_test.go

Lines changed: 44 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -395,34 +395,39 @@ func TestResolveAgentMode(t *testing.T) {
395395
agentEnv string
396396
stdoutTTY bool
397397
want bool
398+
wantMode uaMode
398399
}{
399400
// 1. DCI_AGENT_MODE always wins.
400-
{name: "env mode 1 wins over tty", envMode: "1", args: nil, agentEnv: "", stdoutTTY: true, want: true},
401-
{name: "env mode true", envMode: "true", stdoutTTY: true, want: true},
402-
{name: "env mode 0 forces human", envMode: "0", agentEnv: "CLAUDECODE", stdoutTTY: false, want: false},
403-
{name: "env mode false forces human", envMode: "false", stdoutTTY: false, want: false},
404-
{name: "env mode wins over --agent", envMode: "0", args: []string{"--agent"}, stdoutTTY: false, want: false},
405-
{name: "env mode garbage ignored, falls through to tty", envMode: "2", stdoutTTY: true, want: false},
406-
{name: "env mode garbage ignored, falls through to env var", envMode: "banana", agentEnv: "KIRO_AGENT", stdoutTTY: true, want: true},
401+
{name: "env mode 1 wins over tty", envMode: "1", args: nil, agentEnv: "", stdoutTTY: true, want: true, wantMode: uaModeAgent},
402+
{name: "env mode true", envMode: "true", stdoutTTY: true, want: true, wantMode: uaModeAgent},
403+
{name: "env mode 0 forces human", envMode: "0", agentEnv: "CLAUDECODE", stdoutTTY: false, want: false, wantMode: uaModeInteractive},
404+
{name: "env mode false forces human", envMode: "false", stdoutTTY: false, want: false, wantMode: uaModeInteractive},
405+
{name: "env mode wins over --agent", envMode: "0", args: []string{"--agent"}, stdoutTTY: false, want: false, wantMode: uaModeInteractive},
406+
{name: "env mode garbage ignored, falls through to tty", envMode: "2", stdoutTTY: true, want: false, wantMode: uaModeInteractive},
407+
{name: "env mode garbage ignored, falls through to env var", envMode: "banana", agentEnv: "KIRO_AGENT", stdoutTTY: true, want: true, wantMode: uaModeAgent},
407408

408409
// 2. Flags override heuristics.
409-
{name: "--agent forces agent", args: []string{"dci", "--agent", "status"}, stdoutTTY: true, want: true},
410-
{name: "--no-agent forces human", args: []string{"dci", "--no-agent", "list"}, agentEnv: "CLAUDECODE", stdoutTTY: false, want: false},
411-
{name: "--no-agent over tty", args: []string{"--no-agent"}, stdoutTTY: false, want: false},
410+
{name: "--agent forces agent", args: []string{"dci", "--agent", "status"}, stdoutTTY: true, want: true, wantMode: uaModeAgent},
411+
{name: "--no-agent forces human", args: []string{"dci", "--no-agent", "list"}, agentEnv: "CLAUDECODE", stdoutTTY: false, want: false, wantMode: uaModeInteractive},
412+
{name: "--no-agent over tty", args: []string{"--no-agent"}, stdoutTTY: false, want: false, wantMode: uaModeInteractive},
412413

413414
// 3. Agent env var heuristic.
414-
{name: "agent env enables", agentEnv: "CURSOR_AGENT", stdoutTTY: true, want: true},
415+
{name: "agent env enables", agentEnv: "CURSOR_AGENT", stdoutTTY: true, want: true, wantMode: uaModeAgent},
415416

416-
// 4. Non-TTY soft signal.
417-
{name: "non-tty enables", stdoutTTY: false, want: true},
418-
{name: "interactive tty stays human", stdoutTTY: true, want: false},
417+
// 4. Non-TTY soft signal — agent behavior, but classified noninteractive
418+
// (covers CI/CD and piped/redirected use).
419+
{name: "non-tty enables", stdoutTTY: false, want: true, wantMode: uaModeNonInteractive},
420+
{name: "interactive tty stays human", stdoutTTY: true, want: false, wantMode: uaModeInteractive},
419421
}
420422

421423
for _, tt := range tests {
422424
t.Run(tt.name, func(t *testing.T) {
423425
got := resolveAgentMode(tt.envMode, tt.args, tt.agentEnv, tt.stdoutTTY)
424426
if got.enabled != tt.want {
425-
t.Fatalf("resolveAgentMode() = %v (reason %q), want %v", got.enabled, got.reason, tt.want)
427+
t.Fatalf("resolveAgentMode() enabled = %v (reason %q), want %v", got.enabled, got.reason, tt.want)
428+
}
429+
if got.mode != tt.wantMode {
430+
t.Fatalf("resolveAgentMode() mode = %q (reason %q), want %q", got.mode, got.reason, tt.wantMode)
426431
}
427432
})
428433
}
@@ -460,26 +465,33 @@ func TestAgentFlagOverride(t *testing.T) {
460465
}
461466

462467
func TestBuildUserAgent(t *testing.T) {
463-
human := buildUserAgent(false)
464-
if !strings.HasPrefix(human, "dci-cli/") {
465-
t.Fatalf("unexpected User-Agent prefix: %q", human)
466-
}
467468
// The mode token is always present and self-describing so analytics can
468-
// segment by interface; human mode is explicitly mode=interactive.
469-
if !strings.Contains(human, "mode=interactive") {
470-
t.Fatalf("human User-Agent must carry mode=interactive: %q", human)
471-
}
472-
if strings.Contains(human, "mode=agent") {
473-
t.Fatalf("human User-Agent must not carry mode=agent: %q", human)
469+
// segment by interface across all three classifications.
470+
cases := []struct {
471+
mode uaMode
472+
want string
473+
}{
474+
{uaModeInteractive, "mode=interactive"},
475+
{uaModeAgent, "mode=agent"},
476+
{uaModeNonInteractive, "mode=noninteractive"},
477+
}
478+
for _, c := range cases {
479+
ua := buildUserAgent(c.mode)
480+
if !strings.HasPrefix(ua, "dci-cli/") {
481+
t.Fatalf("unexpected User-Agent prefix: %q", ua)
482+
}
483+
if !strings.Contains(ua, c.want) {
484+
t.Fatalf("buildUserAgent(%q) = %q, want it to contain %q", c.mode, ua, c.want)
485+
}
486+
// Guard against the legacy opaque tag regressing.
487+
if strings.Contains(ua, "agent=1") {
488+
t.Fatalf("User-Agent must use mode=, not the legacy agent=1 tag: %q", ua)
489+
}
474490
}
475491

476-
agent := buildUserAgent(true)
477-
if !strings.Contains(agent, "mode=agent") {
478-
t.Fatalf("agent User-Agent must carry mode=agent: %q", agent)
479-
}
480-
// Guard against the legacy opaque tag regressing.
481-
if strings.Contains(agent, "agent=1") {
482-
t.Fatalf("User-Agent must use mode=agent, not the legacy agent=1 tag: %q", agent)
492+
// An unset mode falls back to interactive rather than emitting "mode=".
493+
if ua := buildUserAgent(""); !strings.Contains(ua, "mode=interactive") {
494+
t.Fatalf("empty mode should default to interactive: %q", ua)
483495
}
484496
}
485497

0 commit comments

Comments
 (0)