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;
+ }
+ }
}