From 6788d8c41a443c8dd9f412893097e4ad94dac2a9 Mon Sep 17 00:00:00 2001 From: Youssef1313 Date: Wed, 1 Apr 2026 13:14:46 +0200 Subject: [PATCH 1/2] Avoid ANSI and progress output when running in LLM environment --- .../Helpers/LLMEnvironmentDetector.cs | 193 ++++++++++++++++++ .../OutputDevice/TerminalOutputDevice.cs | 6 +- 2 files changed, 197 insertions(+), 2 deletions(-) create mode 100644 src/Platform/Microsoft.Testing.Platform/Helpers/LLMEnvironmentDetector.cs diff --git a/src/Platform/Microsoft.Testing.Platform/Helpers/LLMEnvironmentDetector.cs b/src/Platform/Microsoft.Testing.Platform/Helpers/LLMEnvironmentDetector.cs new file mode 100644 index 0000000000..06a9c9ca93 --- /dev/null +++ b/src/Platform/Microsoft.Testing.Platform/Helpers/LLMEnvironmentDetector.cs @@ -0,0 +1,193 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Microsoft.Testing.Platform.Helpers; + +// Copy from https://github.com/dotnet/sdk/tree/1e5d8e39d3026edb222cdf4f8d8240f1eb99f24b/src/Cli/Microsoft.DotNet.Cli.Definitions/Telemetry +internal static class LLMEnvironmentDetector +{ + private static readonly EnvironmentDetectionRuleWithResult[] DetectionRules = + [ + // Claude Code + new EnvironmentDetectionRuleWithResult("claude", new AnyPresentEnvironmentRule("CLAUDECODE", "CLAUDE_CODE_ENTRYPOINT")), + // Cursor AI + new EnvironmentDetectionRuleWithResult("cursor", new AnyPresentEnvironmentRule("CURSOR_EDITOR", "CURSOR_AI")), + // Gemini + new EnvironmentDetectionRuleWithResult("gemini", new BooleanEnvironmentRule("GEMINI_CLI")), + // GitHub Copilot (legacy gh extension: GITHUB_COPILOT_CLI_MODE=true; new Copilot CLI: GH_COPILOT_WORKING_DIRECTORY is set) + new EnvironmentDetectionRuleWithResult("copilot", new AnyMatchEnvironmentRule( + new BooleanEnvironmentRule("GITHUB_COPILOT_CLI_MODE"), + new AnyPresentEnvironmentRule("GH_COPILOT_WORKING_DIRECTORY"))), + // Codex CLI + new EnvironmentDetectionRuleWithResult("codex", new AnyPresentEnvironmentRule("CODEX_CLI", "CODEX_SANDBOX")), + // Aider + new EnvironmentDetectionRuleWithResult("aider", new EnvironmentVariableValueRule("OR_APP_NAME", "Aider")), + // Plandex + new EnvironmentDetectionRuleWithResult("plandex", new EnvironmentVariableValueRule("OR_APP_NAME", "plandex")), + // Amp + new EnvironmentDetectionRuleWithResult("amp", new AnyPresentEnvironmentRule("AMP_HOME")), + // Qwen Code + new EnvironmentDetectionRuleWithResult("qwen", new AnyPresentEnvironmentRule("QWEN_CODE")), + // Droid + new EnvironmentDetectionRuleWithResult("droid", new BooleanEnvironmentRule("DROID_CLI")), + // OpenCode + new EnvironmentDetectionRuleWithResult("opencode", new AnyPresentEnvironmentRule("OPENCODE_AI")), + // Zed AI + new EnvironmentDetectionRuleWithResult("zed", new AnyPresentEnvironmentRule("ZED_ENVIRONMENT", "ZED_TERM")), + // Kimi CLI + new EnvironmentDetectionRuleWithResult("kimi", new BooleanEnvironmentRule("KIMI_CLI")), + // OpenHands + new EnvironmentDetectionRuleWithResult("openhands", new EnvironmentVariableValueRule("OR_APP_NAME", "OpenHands")), + // Goose + new EnvironmentDetectionRuleWithResult("goose", new AnyPresentEnvironmentRule("GOOSE_TERMINAL")), + // Cline + new EnvironmentDetectionRuleWithResult("cline", new AnyPresentEnvironmentRule("CLINE_TASK_ID")), + // Roo Code + new EnvironmentDetectionRuleWithResult("roo", new AnyPresentEnvironmentRule("ROO_CODE_TASK_ID")), + // Windsurf + new EnvironmentDetectionRuleWithResult("windsurf", new AnyPresentEnvironmentRule("WINDSURF_SESSION")), + // (proposed) generic flag for Agentic usage + new EnvironmentDetectionRuleWithResult("generic_agent", new BooleanEnvironmentRule("AGENT_CLI")), + ]; + + private static string? LLMEnvironment { get; } = GetLLMEnvironment(); + + private static string? GetLLMEnvironment() + { + string?[] results = DetectionRules.Select(r => r.GetResult()).Where(r => r != null).ToArray(); + return results.Length > 0 ? string.Join(", ", results) : null; + } + + public static bool IsLLMEnvironment() => !RoslynString.IsNullOrEmpty(LLMEnvironment); + + /// + /// Base class for environment detection rules that can be evaluated against environment variables. + /// + private abstract class EnvironmentDetectionRule + { + /// + /// Evaluates the rule against the current environment. + /// + /// True if the rule matches the current environment; otherwise, false. + public abstract bool IsMatch(); + } + + /// + /// Rule that matches when any of the specified environment variables is set to "true". + /// + private sealed class BooleanEnvironmentRule : EnvironmentDetectionRule + { + private readonly string[] _variables; + + public BooleanEnvironmentRule(params string[] variables) + => _variables = variables ?? throw new ArgumentNullException(nameof(variables)); + + public override bool IsMatch() +#pragma warning disable RS0030 // Do not use banned APIs - fine here. + => _variables.Any(variable => EnvironmentVariableParser.ParseBool(Environment.GetEnvironmentVariable(variable), defaultValue: false)); +#pragma warning restore RS0030 // Do not use banned APIs + } + + private static class EnvironmentVariableParser + { + public static bool ParseBool(string? str, bool defaultValue) + { + if (str is "1" || + string.Equals(str, "true", StringComparison.OrdinalIgnoreCase) || + string.Equals(str, "yes", StringComparison.OrdinalIgnoreCase) || + string.Equals(str, "on", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + if (str is "0" || + string.Equals(str, "false", StringComparison.OrdinalIgnoreCase) || + string.Equals(str, "no", StringComparison.OrdinalIgnoreCase) || + string.Equals(str, "off", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + // Not set to a known value, return default value. + return defaultValue; + } + } + + /// + /// Rule that matches when any of the specified environment variables is present and not null/empty. + /// + private sealed class AnyPresentEnvironmentRule : EnvironmentDetectionRule + { + private readonly string[] _variables; + + public AnyPresentEnvironmentRule(params string[] variables) + => _variables = variables ?? throw new ArgumentNullException(nameof(variables)); + + public override bool IsMatch() +#pragma warning disable RS0030 // Do not use banned APIs - fine here. + => _variables.Any(variable => !RoslynString.IsNullOrEmpty(Environment.GetEnvironmentVariable(variable))); +#pragma warning restore RS0030 // Do not use banned APIs + } + + /// + /// Rule that matches when any of the specified sub-rules match. + /// + private sealed class AnyMatchEnvironmentRule : EnvironmentDetectionRule + { + private readonly EnvironmentDetectionRule[] _rules; + + public AnyMatchEnvironmentRule(params EnvironmentDetectionRule[] rules) + => _rules = rules ?? throw new ArgumentNullException(nameof(rules)); + + public override bool IsMatch() + => _rules.Any(rule => rule.IsMatch()); + } + + /// + /// Rule that matches when an environment variable contains a specific value (case-insensitive). + /// + private sealed class EnvironmentVariableValueRule : EnvironmentDetectionRule + { + private readonly string _variable; + private readonly string _expectedValue; + + public EnvironmentVariableValueRule(string variable, string expectedValue) + { + _variable = variable ?? throw new ArgumentNullException(nameof(variable)); + _expectedValue = expectedValue ?? throw new ArgumentNullException(nameof(expectedValue)); + } + + public override bool IsMatch() + { +#pragma warning disable RS0030 // Do not use banned APIs - fine here. + string? value = Environment.GetEnvironmentVariable(_variable); +#pragma warning restore RS0030 // Do not use banned APIs + return !RoslynString.IsNullOrEmpty(value) && value.Equals(_expectedValue, StringComparison.OrdinalIgnoreCase); + } + } + + /// + /// Rule that matches when any of the specified environment variables is present and not null/empty, + /// and returns the associated result value. + /// + /// The type of the result value. + private sealed class EnvironmentDetectionRuleWithResult + where T : class + { + private readonly EnvironmentDetectionRule _rule; + private readonly T _result; + + public EnvironmentDetectionRuleWithResult(T result, EnvironmentDetectionRule rule) + { + _rule = rule ?? throw new ArgumentNullException(nameof(rule)); + _result = result ?? throw new ArgumentNullException(nameof(result)); + } + + /// + /// Evaluates the rule and returns the result if matched. + /// + /// The result value if the rule matches; otherwise, null. + public T? GetResult() + => _rule.IsMatch() ? _result : null; + } +} diff --git a/src/Platform/Microsoft.Testing.Platform/OutputDevice/TerminalOutputDevice.cs b/src/Platform/Microsoft.Testing.Platform/OutputDevice/TerminalOutputDevice.cs index a0984459c3..502600b5ca 100644 --- a/src/Platform/Microsoft.Testing.Platform/OutputDevice/TerminalOutputDevice.cs +++ b/src/Platform/Microsoft.Testing.Platform/OutputDevice/TerminalOutputDevice.cs @@ -125,7 +125,9 @@ await _policiesService.RegisterOnAbortCallbackAsync( bool inCI = string.Equals(_environment.GetEnvironmentVariable("TF_BUILD"), "true", StringComparison.OrdinalIgnoreCase) || string.Equals(_environment.GetEnvironmentVariable("GITHUB_ACTIONS"), "true", StringComparison.OrdinalIgnoreCase); AnsiMode ansiMode = AnsiMode.AnsiIfPossible; - if (noAnsi) + // In LLM environments, prefer simple text output so that LLM can parse it easily. + // Note that NoAnsi also implies also no progress. + if (noAnsi || LLMEnvironmentDetector.IsLLMEnvironment()) { // User explicitly specified --no-ansi. // We should respect that. @@ -150,7 +152,7 @@ await _policiesService.RegisterOnAbortCallbackAsync( showPassed = () => true; } - Func shouldShowProgress = noProgress || ansiMode is AnsiMode.NoAnsi or AnsiMode.SimpleAnsi + Func shouldShowProgress = noProgress || ansiMode is AnsiMode.NoAnsi or AnsiMode.SimpleAnsi || LLMEnvironmentDetector.IsLLMEnvironment() // User preference is to not show progress. // Or, we are in terminal that's not capable of changing cursor and we can't update progress in-place. // In that case, we force disable progress as well. From d6587c12cfdf23985ddfdc79ff7bb15a1736596c Mon Sep 17 00:00:00 2001 From: Youssef1313 Date: Wed, 1 Apr 2026 13:32:23 +0200 Subject: [PATCH 2/2] Fixes --- .../OutputDevice/TerminalOutputDevice.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Platform/Microsoft.Testing.Platform/OutputDevice/TerminalOutputDevice.cs b/src/Platform/Microsoft.Testing.Platform/OutputDevice/TerminalOutputDevice.cs index 502600b5ca..8d2112e110 100644 --- a/src/Platform/Microsoft.Testing.Platform/OutputDevice/TerminalOutputDevice.cs +++ b/src/Platform/Microsoft.Testing.Platform/OutputDevice/TerminalOutputDevice.cs @@ -126,7 +126,7 @@ await _policiesService.RegisterOnAbortCallbackAsync( AnsiMode ansiMode = AnsiMode.AnsiIfPossible; // In LLM environments, prefer simple text output so that LLM can parse it easily. - // Note that NoAnsi also implies also no progress. + // Note that NoAnsi also implies no progress. if (noAnsi || LLMEnvironmentDetector.IsLLMEnvironment()) { // User explicitly specified --no-ansi. @@ -152,7 +152,7 @@ await _policiesService.RegisterOnAbortCallbackAsync( showPassed = () => true; } - Func shouldShowProgress = noProgress || ansiMode is AnsiMode.NoAnsi or AnsiMode.SimpleAnsi || LLMEnvironmentDetector.IsLLMEnvironment() + Func shouldShowProgress = noProgress || ansiMode is AnsiMode.NoAnsi or AnsiMode.SimpleAnsi // User preference is to not show progress. // Or, we are in terminal that's not capable of changing cursor and we can't update progress in-place. // In that case, we force disable progress as well.