diff --git a/dotnet/samples/02-agents/Harness/ConsoleReactiveComponents/AnsiEscapes.cs b/dotnet/samples/02-agents/Harness/ConsoleReactiveComponents/AnsiEscapes.cs index cf916938e7..6e5d51380d 100644 --- a/dotnet/samples/02-agents/Harness/ConsoleReactiveComponents/AnsiEscapes.cs +++ b/dotnet/samples/02-agents/Harness/ConsoleReactiveComponents/AnsiEscapes.cs @@ -72,6 +72,95 @@ public static class AnsiEscapes /// public static string ResetAttributes => "\x1b[0m"; + /// + /// Returns the visible (printed) length of a string after stripping ANSI escape sequences. + /// Escape sequences are zero-width on screen but occupy characters in the raw string. + /// + /// + /// This counts UTF-16 code units (chars) rather than terminal display cells. Emoji, + /// combining characters, variation selectors, and East Asian wide characters may be + /// measured incorrectly. For the console harness this is acceptable since content is + /// predominantly ASCII, and emoji are padded with surrounding spaces. + /// + public static int VisibleLength(string text) + { + if (string.IsNullOrEmpty(text)) + { + return 0; + } + + int length = 0; + for (int i = 0; i < text.Length; i++) + { + if (text[i] == '\x1b' && i + 1 < text.Length && text[i + 1] == '[') + { + // Skip the ESC[ and all characters up to and including the final byte (0x40–0x7E). + i += 2; + while (i < text.Length && text[i] < 0x40) + { + i++; + } + + // i now points to the final byte of the escape sequence; the for-loop will advance past it. + } + else if (text[i] != '\n' && text[i] != '\r') + { + length++; + } + } + + return length; + } + + /// + /// Counts the number of physical terminal rows a text item will occupy, + /// accounting for both explicit newlines and terminal line wrapping. + /// + /// The text to measure. + /// The terminal width in columns. If <= 0, wrapping is ignored (1 row per logical line). + /// The number of physical rows the text occupies. + public static int CountPhysicalLines(string text, int terminalWidth) + { + if (string.IsNullOrEmpty(text)) + { + return 0; + } + + int physicalLines = 0; + int lineStart = 0; + + for (int i = 0; i <= text.Length; i++) + { + if (i == text.Length || text[i] == '\n') + { + if (terminalWidth <= 0) + { + // No wrapping — each logical line is one physical row + physicalLines += 1; + } + else + { + string logicalLine = text[lineStart..i]; + int visibleWidth = VisibleLength(logicalLine); + + physicalLines += visibleWidth == 0 + ? 1 + : (visibleWidth - 1) / terminalWidth + 1; + } + + lineStart = i + 1; + } + } + + // If text ends with a newline, don't count the trailing empty line + if (text[text.Length - 1] == '\n') + { + physicalLines--; + } + + return physicalLines; + } + private static int ConsoleColorToAnsi(ConsoleColor color) => color switch { ConsoleColor.Black => 30, diff --git a/dotnet/samples/02-agents/Harness/ConsoleReactiveComponents/TextPanel.cs b/dotnet/samples/02-agents/Harness/ConsoleReactiveComponents/TextPanel.cs index 5692b58266..d63c243108 100644 --- a/dotnet/samples/02-agents/Harness/ConsoleReactiveComponents/TextPanel.cs +++ b/dotnet/samples/02-agents/Harness/ConsoleReactiveComponents/TextPanel.cs @@ -23,16 +23,18 @@ public record TextPanelProps : ConsoleReactiveProps public class TextPanel : ConsoleReactiveComponent { /// - /// Calculates the height (in lines) needed to render all items. + /// Calculates the height (in lines) needed to render all items, + /// accounting for terminal line wrapping at the specified width. /// /// The items to measure. - /// The total number of lines all items will occupy. - public static int CalculateHeight(IReadOnlyList items) + /// The terminal width in columns. When 0 or negative, wrapping is ignored. + /// The total number of physical lines all items will occupy. + public static int CalculateHeight(IReadOnlyList items, int terminalWidth = 0) { int total = 0; for (int i = 0; i < items.Count; i++) { - total += CountLines(items[i]); + total += AnsiEscapes.CountPhysicalLines(items[i], terminalWidth); } return total; @@ -47,13 +49,20 @@ public override void RenderCore(TextPanelProps props, ConsoleReactiveState state { string text = props.Items[i]; string[] lines = text.Split('\n'); - int lineCount = CountLines(text); + int itemLineCount = AnsiEscapes.CountPhysicalLines(text, props.Width); + int itemRow = 0; - for (int j = 0; j < lineCount; j++) + for (int j = 0; j < lines.Length && itemRow < itemLineCount; j++) { + int linePhysicalRows = props.Width > 0 + ? Math.Max(1, (AnsiEscapes.VisibleLength(lines[j]) - 1) / props.Width + 1) + : 1; + Console.Write(AnsiEscapes.MoveAndEraseLine(props.Y + currentRow)); Console.Write(lines[j]); - currentRow++; + + currentRow += linePhysicalRows; + itemRow += linePhysicalRows; } } @@ -66,29 +75,4 @@ public override void RenderCore(TextPanelProps props, ConsoleReactiveState state } } } - - private static int CountLines(string text) - { - if (string.IsNullOrEmpty(text)) - { - return 0; - } - - int count = 1; - for (int i = 0; i < text.Length; i++) - { - if (text[i] == '\n') - { - count++; - } - } - - // If text ends with a newline, don't count the trailing empty line - if (text[text.Length - 1] == '\n') - { - count--; - } - - return count; - } } diff --git a/dotnet/samples/02-agents/Harness/ConsoleReactiveComponents/TextScrollPanel.cs b/dotnet/samples/02-agents/Harness/ConsoleReactiveComponents/TextScrollPanel.cs index 3d86a400b3..eb5a19d37a 100644 --- a/dotnet/samples/02-agents/Harness/ConsoleReactiveComponents/TextScrollPanel.cs +++ b/dotnet/samples/02-agents/Harness/ConsoleReactiveComponents/TextScrollPanel.cs @@ -77,36 +77,12 @@ public override void RenderCore(TextScrollPanelProps props, TextScrollPanelState Console.Write(props.Items[i]); } - // Calculate the offset from bottom for the start of the new last item - int lastItemLines = CountLines(props.Items[^1]); + // Calculate the offset from bottom for the start of the new last item, + // accounting for terminal line wrapping at the available width. + int lastItemLines = AnsiEscapes.CountPhysicalLines(props.Items[^1], props.Width); this._lastItemOffsetFromBottom = lastItemLines > 0 ? lastItemLines - 1 : 0; // Update rendered count this._renderedCount = props.Items.Count; } - - private static int CountLines(string text) - { - if (string.IsNullOrEmpty(text)) - { - return 0; - } - - int count = 1; - for (int i = 0; i < text.Length; i++) - { - if (text[i] == '\n') - { - count++; - } - } - - // If text ends with a newline, don't count the trailing empty line - if (text[text.Length - 1] == '\n') - { - count--; - } - - return count; - } } diff --git a/dotnet/samples/02-agents/Harness/ConsoleReactiveFramework/ConsoleReactiveComponent.cs b/dotnet/samples/02-agents/Harness/ConsoleReactiveFramework/ConsoleReactiveComponent.cs index d71436e5e6..fa923efdf8 100644 --- a/dotnet/samples/02-agents/Harness/ConsoleReactiveFramework/ConsoleReactiveComponent.cs +++ b/dotnet/samples/02-agents/Harness/ConsoleReactiveFramework/ConsoleReactiveComponent.cs @@ -13,6 +13,11 @@ internal ConsoleReactiveComponent() { } + /// + /// Gets the shared render lock across all component types to prevent ANSI escape sequence interleaving. + /// + protected static object RenderLock { get; } = new(); + /// /// Gets or sets the component's props as the base type. /// Used by parent components to set layout (X, Y, Width, Height) on children without @@ -40,7 +45,6 @@ public abstract class ConsoleReactiveComponent : ConsoleReactive where TProps : ConsoleReactiveProps where TState : ConsoleReactiveState { - private readonly object _renderLock = new(); private TProps? _lastRenderedProps; private TState? _lastRenderedState; @@ -74,7 +78,7 @@ public void SetState(TState newState) /// public override void Render() { - lock (this._renderLock) + lock (RenderLock) { if (this.Props is null) { @@ -97,7 +101,7 @@ public override void Render() /// public override void Invalidate() { - lock (this._renderLock) + lock (RenderLock) { this._lastRenderedProps = default; this._lastRenderedState = default; diff --git a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/HarnessAppComponent.cs b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/HarnessAppComponent.cs index d100c9d081..b2104aa5fd 100644 --- a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/HarnessAppComponent.cs +++ b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/HarnessAppComponent.cs @@ -28,6 +28,7 @@ public class HarnessAppComponent : ConsoleReactiveComponent /// Initializes a new instance of the class. @@ -341,7 +342,7 @@ public override void RenderCore(ConsoleReactiveProps props, HarnessAppComponentS } // Calculate queued items panel height - int queuedPanelHeight = TextPanel.CalculateHeight(state.QueuedItems); + int queuedPanelHeight = TextPanel.CalculateHeight(state.QueuedItems, state.ConsoleWidth); // Build the bottom panel child based on mode ConsoleReactiveComponent bottomChild; @@ -406,6 +407,14 @@ public override void RenderCore(ConsoleReactiveProps props, HarnessAppComponentS bottomChild = this._textInput; } + // When the bottom panel mode changes, the new child must repaint even if its + // props haven't changed — the screen area was overwritten by the previous child. + if (state.Mode != this._lastRenderedBottomPanelMode) + { + bottomChild.Invalidate(); + this._lastRenderedBottomPanelMode = state.Mode; + } + var ruleProps = new TopBottomRuleProps { Width = state.ConsoleWidth, diff --git a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Observers/PlanningOutputObserver.cs b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Observers/PlanningOutputObserver.cs index 1e7a73df96..153c11e755 100644 --- a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Observers/PlanningOutputObserver.cs +++ b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Observers/PlanningOutputObserver.cs @@ -88,16 +88,17 @@ public override Task OnTextAsync(IUXStateDriver ux, string text, AIAgent agent, { planningResponse = JsonSerializer.Deserialize(collectedText); } - catch (JsonException ex) + catch (JsonException) { - await ux.WriteInfoLineAsync($"❌ Failed to parse planning response: {ex.Message}", ConsoleColor.Red); - await ux.WriteInfoLineAsync($"(raw response) {collectedText}", ConsoleColor.DarkYellow); + // JSON parsing failed — fall back to rendering as regular text output. + await ux.WriteTextAsync(collectedText).ConfigureAwait(false); return null; } if (planningResponse is null) { - await ux.WriteInfoLineAsync("(no structured response from agent)", ConsoleColor.DarkYellow); + // Null result — fall back to rendering as regular text output. + await ux.WriteTextAsync(collectedText).ConfigureAwait(false); return null; } @@ -118,7 +119,8 @@ public override Task OnTextAsync(IUXStateDriver ux, string text, AIAgent agent, return new List { this.BuildApprovalAction(question, session) }; } - await ux.WriteInfoLineAsync($"(unexpected response type: {planningResponse.Type})", ConsoleColor.DarkYellow); + // Unexpected type — fall back to rendering as regular text output. + await ux.WriteTextAsync(collectedText).ConfigureAwait(false); return null; } diff --git a/dotnet/samples/02-agents/Harness/Harness_Shared_Console_OpenAI/OpenAIResponsesErrorObserver.cs b/dotnet/samples/02-agents/Harness/Harness_Shared_Console_OpenAI/OpenAIResponsesErrorObserver.cs index 9db00c9a65..8cf5abee57 100644 --- a/dotnet/samples/02-agents/Harness/Harness_Shared_Console_OpenAI/OpenAIResponsesErrorObserver.cs +++ b/dotnet/samples/02-agents/Harness/Harness_Shared_Console_OpenAI/OpenAIResponsesErrorObserver.cs @@ -2,6 +2,7 @@ #pragma warning disable OPENAI001 // Suppress experimental API warnings for Responses API usage. +using System.Text.Json; using Harness.Shared.Console.Observers; using Microsoft.Agents.AI; using Microsoft.Extensions.AI; @@ -53,9 +54,94 @@ public override async Task OnResponseUpdateAsync(IUXStateDriver ux, AgentRespons case StreamingResponseIncompleteUpdate incompleteUpdate: string? reason = incompleteUpdate.Response?.IncompleteStatusDetails?.Reason?.ToString(); - string incompleteText = $"⚠️ Response incomplete: {reason ?? "unknown reason"}"; - await ux.WriteInfoLineAsync(incompleteText, ConsoleColor.Yellow); + if (string.Equals(reason, "content_filter", StringComparison.OrdinalIgnoreCase)) + { + string detail = GetContentFilterDetails(incompleteUpdate); + const string Message = "🛡️ The service's built-in content filter guardrails were triggered and the response was cut short."; + await ux.WriteInfoLineAsync( + string.IsNullOrEmpty(detail) ? Message : $"{Message}\n{detail}", + ConsoleColor.Yellow); + } + else + { + string incompleteText = $"⚠️ Response incomplete: {reason ?? "unknown reason"}"; + await ux.WriteInfoLineAsync(incompleteText, ConsoleColor.Yellow); + } + break; } } + + /// + /// Extracts content filter details from the serialized response JSON and returns + /// a formatted string showing which specific categories were triggered. + /// Returns if details cannot be extracted. + /// + private static string GetContentFilterDetails(StreamingResponseIncompleteUpdate incompleteUpdate) + { + try + { + var data = System.ClientModel.Primitives.ModelReaderWriter.Write(incompleteUpdate); + using var doc = JsonDocument.Parse(data.ToString()); + var root = doc.RootElement; + + // Navigate into the nested response object if present. + JsonElement responseElement = root.TryGetProperty("response", out var resp) ? resp : root; + + if (!responseElement.TryGetProperty("content_filters", out var filtersArray) + || filtersArray.ValueKind != JsonValueKind.Array) + { + return string.Empty; + } + + foreach (var filter in filtersArray.EnumerateArray()) + { + if (!filter.TryGetProperty("content_filter_results", out var results) + || results.ValueKind != JsonValueKind.Object) + { + continue; + } + + // Collect category data for aligned output. + var categories = new List<(string Name, bool Filtered, string? Severity)>(); + foreach (var category in results.EnumerateObject()) + { + if (category.Value.ValueKind != JsonValueKind.Object) + { + continue; + } + + bool filtered = category.Value.TryGetProperty("filtered", out var f) && f.GetBoolean(); + string? severity = category.Value.TryGetProperty("severity", out var s) ? s.GetString() : null; + categories.Add((category.Name, filtered, severity)); + } + + // Build all category lines into a single string. + int maxNameLen = categories.Count > 0 ? categories.Max(c => c.Name.Length) : 0; + var lines = new List(); + + foreach (var (name, filtered, severity) in categories) + { + string paddedName = name.PadRight(maxNameLen); + string icon = filtered ? "❌" : "✅"; + string statusText = filtered ? "Filtered " : "Not Filtered"; + string severityText = severity is not null ? $" Severity: {severity}" : ""; + + lines.Add($" {icon} {paddedName} {statusText}{severityText}"); + } + + if (lines.Count > 0) + { + return string.Join("\n", lines); + } + } + + return string.Empty; + } + catch + { + // Parsing not critical — skip silently if it fails. + return string.Empty; + } + } }