From aa782d94e2d9ae32269eba6da759b26e72913809 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 01:43:05 +0000 Subject: [PATCH 1/8] Update NuGet pins to latest stable: Microsoft.Agents.AI 1.17.0, ModelContextProtocol(.Core) 2.1.0; pin OpenAI 2.12.0 The OpenAI pin makes the previously transitive-only version explicit and is required by the upcoming Responses API sample under central package management. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0165XhSYHxBtRZy97c7KtxkR --- Directory.Packages.props | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index a67c6f0..9e2a159 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -8,7 +8,7 @@ - + @@ -16,8 +16,9 @@ - - + + + From 26f644ae545493afc1258a57099fe2be8870ce6e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 01:47:41 +0000 Subject: [PATCH 2/8] Replace auto-emitted Started/Thinking statuses with detection-driven Reasoning Breaking changes: - Rename ChatProgressKind.Thinking to ChatProgressKind.Reasoning (value preserved). - The middleware no longer emits RequestStarted/Thinking at request start or after tool round-trips; the first in-band event of a tool request is now ToolInvoking. The Reasoning status is now truthful: it is emitted once per model turn when TextReasoningContent is detected in the stream (OpenAI Responses API today; any provider surfacing reasoning content works), re-armed after each tool round-trip, and mirrored post-hoc for non-streaming responses. The event never carries reasoning text. Developers own request-level statuses outside the middleware via the new ChatProgressUpdate.CreateRequestStarted/CreateReasoning factories (stamped with the well-known ExternalScopeId) and ToResponseUpdate(), which wraps an update in the same synthetic shape the middleware emits for UI consumption. Also adds an optional skippable Responses API integration test gated on the new AzureOpenAI:ResponsesDeployment setting. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0165XhSYHxBtRZy97c7KtxkR --- .../AssistantStatusSnapshot.cs | 2 +- .../AssistantUiEventKind.cs | 2 +- .../typescript/andes-assistant-ui.ts | 2 +- .../Internal/RequestTracker.cs | 37 +++--- .../Progress/ChatProgressKind.cs | 12 +- .../Progress/ChatProgressUpdate.cs | 57 +++++++++ .../Progress/ChatProgressUpdateExtensions.cs | 38 ++++++ Andes.Extensions.AI/ToolTrackingChatClient.cs | 17 ++- ...ndes.Extensions.AI.Integration.Test.csproj | 3 + .../AzureOpenAIFixture.cs | 14 +++ .../ResponsesStreamingIntegrationTests.cs | 108 ++++++++++++++++ .../StreamingIntegrationTests.cs | 2 +- .../appsettings.integration.sample.json | 3 +- .../AssistantStatusReducerTests.cs | 4 +- .../ChatResponseUiExtensionsTests.cs | 31 +++++ .../ChatProgressUpdateFactoryTests.cs | 67 ++++++++++ .../NonStreamingTests.cs | 6 +- .../ReasoningDetectionTests.cs | 115 ++++++++++++++++++ .../StreamingProgressTests.cs | 8 +- .../StripProgressContentTests.cs | 4 +- 20 files changed, 491 insertions(+), 41 deletions(-) create mode 100644 Andes.Extensions.AI/Progress/ChatProgressUpdateExtensions.cs create mode 100644 tests/Andes.Extensions.AI.Integration.Test/ResponsesStreamingIntegrationTests.cs create mode 100644 tests/Andes.Extensions.AI.Unit.Test/ChatProgressUpdateFactoryTests.cs create mode 100644 tests/Andes.Extensions.AI.Unit.Test/ReasoningDetectionTests.cs diff --git a/Andes.Extensions.AI.UI/AssistantStatusSnapshot.cs b/Andes.Extensions.AI.UI/AssistantStatusSnapshot.cs index 687ea6c..1b7810b 100644 --- a/Andes.Extensions.AI.UI/AssistantStatusSnapshot.cs +++ b/Andes.Extensions.AI.UI/AssistantStatusSnapshot.cs @@ -13,7 +13,7 @@ namespace Andes.Extensions.AI; public sealed record AssistantStatusSnapshot { /// - /// Gets the current request-level status line, such as "Thinking…", or + /// Gets the current request-level status line, such as "Reasoning…", or /// before the first status arrives. /// public string? AssistantStatus { get; init; } diff --git a/Andes.Extensions.AI.UI/AssistantUiEventKind.cs b/Andes.Extensions.AI.UI/AssistantUiEventKind.cs index fec2314..936dae1 100644 --- a/Andes.Extensions.AI.UI/AssistantUiEventKind.cs +++ b/Andes.Extensions.AI.UI/AssistantUiEventKind.cs @@ -7,7 +7,7 @@ namespace Andes.Extensions.AI; public enum AssistantUiEventKind { /// - /// A request-level status line, such as "Thinking…" — carried by . + /// A request-level status line, such as "Reasoning…" — carried by . /// Status, diff --git a/Andes.Extensions.AI.UI/typescript/andes-assistant-ui.ts b/Andes.Extensions.AI.UI/typescript/andes-assistant-ui.ts index ad36bb7..912fd1d 100644 --- a/Andes.Extensions.AI.UI/typescript/andes-assistant-ui.ts +++ b/Andes.Extensions.AI.UI/typescript/andes-assistant-ui.ts @@ -77,7 +77,7 @@ export interface AssistantActivity { * Bind to this and re-render whenever a new snapshot arrives. */ export interface AssistantStatusSnapshot { - /** The current request-level status line, such as "Thinking…". */ + /** The current request-level status line, such as "Reasoning…". */ assistantStatus?: string; /** The overall state of the request. */ phase: ActivityState; diff --git a/Andes.Extensions.AI/Internal/RequestTracker.cs b/Andes.Extensions.AI/Internal/RequestTracker.cs index 47faf8a..355e0bb 100644 --- a/Andes.Extensions.AI/Internal/RequestTracker.cs +++ b/Andes.Extensions.AI/Internal/RequestTracker.cs @@ -20,6 +20,7 @@ internal sealed class RequestTracker private readonly long _startTimestamp; private int _scopeCounter; private int _iteration; + private bool _reasoningAnnounced; private string? _lastResponseId; private string? _lastModelId; @@ -45,24 +46,28 @@ public void RegisterWrappedTool(string name) } } - public void EmitRequestStarted() + /// + /// Emits a single event for the current model turn the + /// first time reasoning content is detected; repeat detections in the same turn are ignored. + /// Re-armed by after each tool round-trip. The event carries only + /// the fact that reasoning is happening — never the reasoning text. + /// + public void OnReasoningDetected() { - Emit(new ChatProgressUpdate + lock (_lock) { - Kind = ChatProgressKind.RequestStarted, - Message = "Starting request", - ScopeId = RootScope.ScopeId, - Depth = 0, - Timestamp = Now(), - }); - } + if (_reasoningAnnounced) + { + return; + } + + _reasoningAnnounced = true; + } - public void EmitThinking() - { Emit(new ChatProgressUpdate { - Kind = ChatProgressKind.Thinking, - Message = "Thinking...", + Kind = ChatProgressKind.Reasoning, + Message = "Reasoning...", ScopeId = RootScope.ScopeId, Depth = 0, Timestamp = Now(), @@ -214,16 +219,16 @@ public void OnFunctionCall(string callId, string name) } /// - /// Advances the model-turn counter after a tool round-trip and announces the next turn. + /// Advances the model-turn counter after a tool round-trip and re-arms reasoning detection + /// for the next turn. /// public void AdvanceIteration() { lock (_lock) { _iteration++; + _reasoningAnnounced = false; } - - EmitThinking(); } public ChatProgressUpdate CreateRequestCompletedUpdate(TimeSpan duration) diff --git a/Andes.Extensions.AI/Progress/ChatProgressKind.cs b/Andes.Extensions.AI/Progress/ChatProgressKind.cs index 7acb79f..b409bc3 100644 --- a/Andes.Extensions.AI/Progress/ChatProgressKind.cs +++ b/Andes.Extensions.AI/Progress/ChatProgressKind.cs @@ -6,14 +6,20 @@ namespace Andes.Extensions.AI; public enum ChatProgressKind { /// - /// The tracked request has started and no work has been forwarded to the inner client yet. + /// A request is starting. Never emitted by the middleware; construct one with + /// to announce your own + /// request start outside the tracked pipeline. /// RequestStarted = 0, /// - /// A model turn is beginning; emitted before the first inner call and again after each tool round-trip. + /// The model is producing reasoning output. Emitted once per model turn when reasoning content + /// () is detected on the response — + /// for example from the OpenAI Responses API. The event never carries the reasoning text + /// itself. Also constructible via + /// for emission outside the middleware. /// - Thinking = 1, + Reasoning = 1, /// /// A tool invocation is starting; the message carries the display header (for example, "Calling GetWeather Tool"). diff --git a/Andes.Extensions.AI/Progress/ChatProgressUpdate.cs b/Andes.Extensions.AI/Progress/ChatProgressUpdate.cs index 3de5c96..dba1ceb 100644 --- a/Andes.Extensions.AI/Progress/ChatProgressUpdate.cs +++ b/Andes.Extensions.AI/Progress/ChatProgressUpdate.cs @@ -10,6 +10,13 @@ namespace Andes.Extensions.AI; /// public sealed class ChatProgressUpdate { + /// + /// The well-known scope identifier stamped on updates created outside a tracked request via + /// and . + /// The middleware's own per-request identifiers ("scope-1", "scope-2", …) never collide with it. + /// + public const string ExternalScopeId = "scope-external"; + /// /// Gets the kind of progress event. /// @@ -93,4 +100,54 @@ public sealed class ChatProgressUpdate /// Gets the total amount of work required — the denominator for — when known. /// public double? ProgressTotal { get; init; } + + /// + /// Creates a request-level update for emitting + /// outside the middleware — for example, prepended to the update stream a UI consumes so a + /// status line shows before the first tracked event arrives. + /// + /// The status text, or to use "Starting request". + /// An update stamped with , depth 0, and the current UTC time. + /// + /// The middleware never emits this kind itself. Construct the update with an object initializer + /// instead when a custom or is needed. + /// + /// + /// + /// ChatResponseUpdate started = ChatProgressUpdate.CreateRequestStarted().ToResponseUpdate(); + /// + /// + public static ChatProgressUpdate CreateRequestStarted(string? message = null) + { + return new ChatProgressUpdate + { + Kind = ChatProgressKind.RequestStarted, + Message = message ?? "Starting request", + ScopeId = ExternalScopeId, + Depth = 0, + Timestamp = DateTimeOffset.UtcNow, + }; + } + + /// + /// Creates a request-level update for emitting outside + /// the middleware, mirroring the event the middleware raises when it detects reasoning content. + /// + /// The status text, or to use "Reasoning...". + /// An update stamped with , depth 0, and the current UTC time. + /// + /// Construct the update with an object initializer instead when a custom + /// or is needed. + /// + public static ChatProgressUpdate CreateReasoning(string? message = null) + { + return new ChatProgressUpdate + { + Kind = ChatProgressKind.Reasoning, + Message = message ?? "Reasoning...", + ScopeId = ExternalScopeId, + Depth = 0, + Timestamp = DateTimeOffset.UtcNow, + }; + } } diff --git a/Andes.Extensions.AI/Progress/ChatProgressUpdateExtensions.cs b/Andes.Extensions.AI/Progress/ChatProgressUpdateExtensions.cs new file mode 100644 index 0000000..ca4354c --- /dev/null +++ b/Andes.Extensions.AI/Progress/ChatProgressUpdateExtensions.cs @@ -0,0 +1,38 @@ +using Microsoft.Extensions.AI; + +namespace Andes.Extensions.AI; + +/// +/// Helpers for sending developer-constructed instances through the +/// same in-band shape the middleware emits. +/// +public static class ChatProgressUpdateExtensions +{ + /// + /// Wraps the update in a synthetic role-less carrying a single + /// item, ready to interleave into a stream consumed by + /// progress-aware helpers such as the UI package's ToStatusSnapshotsAsync(). + /// + /// The progress update to wrap. + /// The synthetic update; it contains no text, so text-accumulation helpers are unaffected. + /// is . + /// + /// + /// async IAsyncEnumerable<ChatResponseUpdate> StreamTurn() + /// { + /// yield return ChatProgressUpdate.CreateRequestStarted().ToResponseUpdate(); + /// + /// await foreach (ChatResponseUpdate update in client.GetStreamingResponseAsync(history, options)) + /// { + /// yield return update; + /// } + /// } + /// + /// + public static ChatResponseUpdate ToResponseUpdate(this ChatProgressUpdate update) + { + ArgumentNullException.ThrowIfNull(update); + + return new ChatResponseUpdate(role: null, contents: [new ChatProgressContent(update)]); + } +} diff --git a/Andes.Extensions.AI/ToolTrackingChatClient.cs b/Andes.Extensions.AI/ToolTrackingChatClient.cs index dfe882a..607b23a 100644 --- a/Andes.Extensions.AI/ToolTrackingChatClient.cs +++ b/Andes.Extensions.AI/ToolTrackingChatClient.cs @@ -57,9 +57,6 @@ public override async Task GetResponseAsync( var tracker = new RequestTracker(_options, GetMetadata(), writer: null); ChatOptions? effectiveOptions = WrapTools(options, tracker); - tracker.EmitRequestStarted(); - tracker.EmitThinking(); - ChatResponse response; ToolScope? previous = AmbientScope.Current; AmbientScope.Current = tracker.RootScope; @@ -82,6 +79,13 @@ public override async Task GetResponseAsync( tracker.RecordAssistantUsage(usage, response.ResponseId, response.ModelId); } + // Post-hoc parity with the streaming path: turns are indistinguishable in an aggregated + // response, so at most one Reasoning event is raised per request, observers-only. + if (response.Messages.SelectMany(static message => message.Contents).OfType().Any()) + { + tracker.OnReasoningDetected(); + } + ChatUsageReport report = tracker.BuildReport(); tracker.EmitRequestCompleted(report.Duration); tracker.NotifyRequestCompleted(report); @@ -113,9 +117,6 @@ public override async IAsyncEnumerable GetStreamingResponseA var tracker = new RequestTracker(_options, GetMetadata(), channel.Writer); ChatOptions? effectiveOptions = WrapTools(options, tracker); - tracker.EmitRequestStarted(); - tracker.EmitThinking(); - using var pumpCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); CancellationToken pumpToken = pumpCancellation.Token; @@ -242,6 +243,10 @@ private static bool Inspect(ChatResponseUpdate update, RequestTracker tracker) sawFunctionResult = true; break; + case TextReasoningContent: + tracker.OnReasoningDetected(); + break; + default: break; } diff --git a/tests/Andes.Extensions.AI.Integration.Test/Andes.Extensions.AI.Integration.Test.csproj b/tests/Andes.Extensions.AI.Integration.Test/Andes.Extensions.AI.Integration.Test.csproj index fa7064d..5d333d6 100644 --- a/tests/Andes.Extensions.AI.Integration.Test/Andes.Extensions.AI.Integration.Test.csproj +++ b/tests/Andes.Extensions.AI.Integration.Test/Andes.Extensions.AI.Integration.Test.csproj @@ -2,12 +2,15 @@ net10.0 + + $(NoWarn);OPENAI001 + diff --git a/tests/Andes.Extensions.AI.Integration.Test/AzureOpenAIFixture.cs b/tests/Andes.Extensions.AI.Integration.Test/AzureOpenAIFixture.cs index 925744d..cac034d 100644 --- a/tests/Andes.Extensions.AI.Integration.Test/AzureOpenAIFixture.cs +++ b/tests/Andes.Extensions.AI.Integration.Test/AzureOpenAIFixture.cs @@ -16,6 +16,12 @@ public sealed class AzureOpenAISettings public string? ApiKey { get; set; } public string? Deployment { get; set; } + + /// + /// Optional reasoning-capable deployment (gpt-5 family / o-series) used by the Responses API + /// tests; when absent those tests skip while the chat-deployment tests still run. + /// + public string? ResponsesDeployment { get; set; } } /// @@ -27,6 +33,9 @@ public sealed class AzureOpenAIFixture public const string SkipReason = "appsettings.integration.json is missing or incomplete; copy appsettings.integration.sample.json and fill in the AzureOpenAI section."; + public const string ResponsesSkipReason = + "AzureOpenAI:ResponsesDeployment is not configured; add a reasoning-capable deployment name to appsettings.integration.json to run the Responses API tests."; + public AzureOpenAIFixture() { IConfigurationRoot configuration = new ConfigurationBuilder() @@ -43,6 +52,11 @@ public AzureOpenAIFixture() && !string.IsNullOrWhiteSpace(Settings.ApiKey) && !string.IsNullOrWhiteSpace(Settings.Deployment); + public bool IsResponsesConfigured => + !string.IsNullOrWhiteSpace(Settings.Endpoint) + && !string.IsNullOrWhiteSpace(Settings.ApiKey) + && !string.IsNullOrWhiteSpace(Settings.ResponsesDeployment); + public IChatClient CreatePipeline(Action? configure = null) { var azureClient = new AzureOpenAIClient(new Uri(Settings.Endpoint!), new AzureKeyCredential(Settings.ApiKey!)); diff --git a/tests/Andes.Extensions.AI.Integration.Test/ResponsesStreamingIntegrationTests.cs b/tests/Andes.Extensions.AI.Integration.Test/ResponsesStreamingIntegrationTests.cs new file mode 100644 index 0000000..d7a48b8 --- /dev/null +++ b/tests/Andes.Extensions.AI.Integration.Test/ResponsesStreamingIntegrationTests.cs @@ -0,0 +1,108 @@ +using System.ClientModel; +using Microsoft.Extensions.AI; +using OpenAI; + +namespace Andes.Extensions.AI.Integration.Test; + +/// +/// Exercises the tracked pipeline against the Azure OpenAI Responses API, reached through the +/// OpenAI-v1-compatible endpoint with the stable OpenAI library (the stable Azure.AI.OpenAI client +/// has no Responses surface). Requires the optional "AzureOpenAI:ResponsesDeployment" setting. +/// +public class ResponsesStreamingIntegrationTests(AzureOpenAIFixture fixture) : IClassFixture +{ + private readonly AzureOpenAIFixture _fixture = fixture; + + [SkippableFact] + public async Task GetStreamingResponseAsync_ResponsesApi_DetectsReasoningAndTracksUsage() + { + Skip.IfNot(_fixture.IsResponsesConfigured, AzureOpenAIFixture.ResponsesSkipReason); + + IChatClient client = CreateResponsesPipeline(); + var options = new ChatOptions + { + Reasoning = new ReasoningOptions { Output = ReasoningOutput.Summary }, + }; + + var updates = new List(); + await foreach (ChatResponseUpdate update in client.GetStreamingResponseAsync( + "What is the sum of the first ten prime numbers? Reason it out, then answer.", + options)) + { + updates.Add(update); + } + + List progress = updates + .SelectMany(update => update.Contents) + .OfType() + .Select(content => content.Progress) + .ToList(); + ChatUsageReport report = updates + .SelectMany(update => update.Contents) + .OfType() + .Single() + .Report; + + Assert.DoesNotContain(progress, update => update.Kind == ChatProgressKind.RequestStarted); + Assert.True(report.AssistantUsage.TotalTokenCount > 0, "The assistant should report token usage."); + Assert.NotEmpty(string.Concat(updates.Select(update => update.Text))); + + // Whether summaries stream depends on the deployment (and possibly organization + // verification); when they do, the middleware must announce Reasoning exactly once + // for this single-turn request, ahead of the first reasoning content. + int reasoningContentIndex = IndexOfContent(updates); + Skip.If(reasoningContentIndex < 0, + "The deployment streamed no reasoning summaries; enable summaries on a reasoning-capable deployment to exercise detection."); + + ChatProgressUpdate reasoning = Assert.Single(progress, update => update.Kind == ChatProgressKind.Reasoning); + Assert.Equal("Reasoning...", reasoning.Message); + int reasoningStatusIndex = IndexOfProgress(updates, ChatProgressKind.Reasoning); + Assert.True(reasoningStatusIndex < reasoningContentIndex, + $"The Reasoning status (index {reasoningStatusIndex}) must precede the reasoning content (index {reasoningContentIndex})."); + } + + private IChatClient CreateResponsesPipeline() + { + var openAIClient = new OpenAIClient( + new ApiKeyCredential(_fixture.Settings.ApiKey!), + new OpenAIClientOptions + { + Endpoint = new Uri(_fixture.Settings.Endpoint!.TrimEnd('/') + "/openai/v1"), + }); + + return openAIClient + .GetResponsesClient() + .AsIChatClient(_fixture.Settings.ResponsesDeployment!) + .AsBuilder() + .UseToolTracking() + .UseFunctionInvocation() + .Build(); + } + + private static int IndexOfProgress(IReadOnlyList updates, ChatProgressKind kind) + { + for (int i = 0; i < updates.Count; i++) + { + if (updates[i].Contents.OfType().Any(content => content.Progress.Kind == kind)) + { + return i; + } + } + + return -1; + } + + private static int IndexOfContent(IReadOnlyList updates) + where TContent : AIContent + { + for (int i = 0; i < updates.Count; i++) + { + if (updates[i].Contents.OfType().Any()) + { + return i; + } + } + + return -1; + } +} diff --git a/tests/Andes.Extensions.AI.Integration.Test/StreamingIntegrationTests.cs b/tests/Andes.Extensions.AI.Integration.Test/StreamingIntegrationTests.cs index 939e510..ecc6970 100644 --- a/tests/Andes.Extensions.AI.Integration.Test/StreamingIntegrationTests.cs +++ b/tests/Andes.Extensions.AI.Integration.Test/StreamingIntegrationTests.cs @@ -40,7 +40,7 @@ public async Task GetStreamingResponseAsync_WithTool_TracksUsageAndProgress() .Single() .Report; - Assert.Equal(ChatProgressKind.RequestStarted, progress[0].Kind); + Assert.DoesNotContain(progress, update => update.Kind == ChatProgressKind.RequestStarted); Assert.Contains(progress, update => update.Kind == ChatProgressKind.ToolInvoking && update.ToolName == "GetCurrentTime"); Assert.Contains(progress, update => update.Kind == ChatProgressKind.ToolProgress && update.Message == "Formatting time..."); Assert.Contains(progress, update => update.Kind == ChatProgressKind.ToolCompleted); diff --git a/tests/Andes.Extensions.AI.Integration.Test/appsettings.integration.sample.json b/tests/Andes.Extensions.AI.Integration.Test/appsettings.integration.sample.json index 6414e25..191bb09 100644 --- a/tests/Andes.Extensions.AI.Integration.Test/appsettings.integration.sample.json +++ b/tests/Andes.Extensions.AI.Integration.Test/appsettings.integration.sample.json @@ -2,6 +2,7 @@ "AzureOpenAI": { "Endpoint": "https://your-resource.openai.azure.com/", "ApiKey": "", - "Deployment": "" + "Deployment": "", + "ResponsesDeployment": "" } } diff --git a/tests/Andes.Extensions.AI.UI.Unit.Test/AssistantStatusReducerTests.cs b/tests/Andes.Extensions.AI.UI.Unit.Test/AssistantStatusReducerTests.cs index db6836e..86d0bba 100644 --- a/tests/Andes.Extensions.AI.UI.Unit.Test/AssistantStatusReducerTests.cs +++ b/tests/Andes.Extensions.AI.UI.Unit.Test/AssistantStatusReducerTests.cs @@ -7,7 +7,7 @@ public void Apply_NestedActivities_BuildsHierarchyWithSubStatuses() { var reducer = new AssistantStatusReducer(); - reducer.Apply(new AssistantUiEvent { Kind = AssistantUiEventKind.Status, Message = "Thinking…" }); + reducer.Apply(new AssistantUiEvent { Kind = AssistantUiEventKind.Status, Message = "Reasoning…" }); reducer.Apply(new AssistantUiEvent { Kind = AssistantUiEventKind.ActivityStarted, @@ -43,7 +43,7 @@ public void Apply_NestedActivities_BuildsHierarchyWithSubStatuses() DurationSeconds = 2.1, }); - Assert.Equal("Thinking…", snapshot.AssistantStatus); + Assert.Equal("Reasoning…", snapshot.AssistantStatus); AssistantActivity agent = Assert.Single(snapshot.Activities); Assert.Equal("Research Agent", agent.DisplayName); Assert.Equal(ToolKind.Agent, agent.Kind); diff --git a/tests/Andes.Extensions.AI.UI.Unit.Test/ChatResponseUiExtensionsTests.cs b/tests/Andes.Extensions.AI.UI.Unit.Test/ChatResponseUiExtensionsTests.cs index 4738f3e..de2d609 100644 --- a/tests/Andes.Extensions.AI.UI.Unit.Test/ChatResponseUiExtensionsTests.cs +++ b/tests/Andes.Extensions.AI.UI.Unit.Test/ChatResponseUiExtensionsTests.cs @@ -90,6 +90,37 @@ public async Task ToStatusSnapshotsAsync_FunctionTool_FoldsIntoCompletedActivity Assert.Equal(ActivityState.Completed, last.Phase); } + [Fact] + public async Task ToStatusSnapshotsAsync_DevPrependedRequestStarted_SetsAssistantStatus() + { + var scripted = new ScriptedChatClient(ScriptedTurn.Text("Done.")); + IChatClient client = TestPipeline.Build(scripted); + + async IAsyncEnumerable StreamWithPrependedStatus() + { + yield return ChatProgressUpdate.CreateRequestStarted().ToResponseUpdate(); + + await foreach (ChatResponseUpdate update in client.GetStreamingResponseAsync("prompt")) + { + yield return update; + } + } + + AssistantStatusSnapshot? first = null; + AssistantStatusSnapshot? last = null; + await foreach (AssistantStatusSnapshot snapshot in StreamWithPrependedStatus().ToStatusSnapshotsAsync()) + { + first ??= snapshot; + last = snapshot; + } + + Assert.NotNull(first); + Assert.Equal("Starting request", first!.AssistantStatus); + Assert.NotNull(last); + Assert.Equal(ActivityState.Completed, last!.Phase); + Assert.Contains("Done.", last.Text); + } + private static async Task> CollectAsync(IChatClient client, ChatOptions options) { var events = new List(); diff --git a/tests/Andes.Extensions.AI.Unit.Test/ChatProgressUpdateFactoryTests.cs b/tests/Andes.Extensions.AI.Unit.Test/ChatProgressUpdateFactoryTests.cs new file mode 100644 index 0000000..12a4312 --- /dev/null +++ b/tests/Andes.Extensions.AI.Unit.Test/ChatProgressUpdateFactoryTests.cs @@ -0,0 +1,67 @@ +using Microsoft.Extensions.AI; + +namespace Andes.Extensions.AI.Unit.Test; + +public class ChatProgressUpdateFactoryTests +{ + [Fact] + public void CreateRequestStarted_Default_PopulatesWellKnownFields() + { + ChatProgressUpdate update = ChatProgressUpdate.CreateRequestStarted(); + + Assert.Equal(ChatProgressKind.RequestStarted, update.Kind); + Assert.Equal("Starting request", update.Message); + Assert.Equal(ChatProgressUpdate.ExternalScopeId, update.ScopeId); + Assert.Equal(0, update.Depth); + Assert.NotEqual(default, update.Timestamp); + } + + [Fact] + public void CreateRequestStarted_CustomMessage_UsesIt() + { + ChatProgressUpdate update = ChatProgressUpdate.CreateRequestStarted("Warming up…"); + + Assert.Equal(ChatProgressKind.RequestStarted, update.Kind); + Assert.Equal("Warming up…", update.Message); + } + + [Fact] + public void CreateReasoning_Default_PopulatesWellKnownFields() + { + ChatProgressUpdate update = ChatProgressUpdate.CreateReasoning(); + + Assert.Equal(ChatProgressKind.Reasoning, update.Kind); + Assert.Equal("Reasoning...", update.Message); + Assert.Equal(ChatProgressUpdate.ExternalScopeId, update.ScopeId); + Assert.Equal(0, update.Depth); + Assert.NotEqual(default, update.Timestamp); + } + + [Fact] + public void CreateReasoning_CustomMessage_UsesIt() + { + ChatProgressUpdate update = ChatProgressUpdate.CreateReasoning("Pondering deeply…"); + + Assert.Equal(ChatProgressKind.Reasoning, update.Kind); + Assert.Equal("Pondering deeply…", update.Message); + } + + [Fact] + public void ToResponseUpdate_Always_WrapsSingleProgressContent() + { + ChatProgressUpdate update = ChatProgressUpdate.CreateRequestStarted(); + + ChatResponseUpdate wrapped = update.ToResponseUpdate(); + + Assert.Null(wrapped.Role); + ChatProgressContent content = Assert.IsType(Assert.Single(wrapped.Contents)); + Assert.Same(update, content.Progress); + Assert.Empty(wrapped.Text); + } + + [Fact] + public void ToResponseUpdate_Null_Throws() + { + Assert.Throws(() => ((ChatProgressUpdate)null!).ToResponseUpdate()); + } +} diff --git a/tests/Andes.Extensions.AI.Unit.Test/NonStreamingTests.cs b/tests/Andes.Extensions.AI.Unit.Test/NonStreamingTests.cs index 5b6d3c0..7149b84 100644 --- a/tests/Andes.Extensions.AI.Unit.Test/NonStreamingTests.cs +++ b/tests/Andes.Extensions.AI.Unit.Test/NonStreamingTests.cs @@ -24,9 +24,9 @@ public async Task GetResponseAsync_ToolLoop_NotifiesObserversInOrder() ChatResponse response = await client.GetResponseAsync("prompt", new ChatOptions { Tools = [tool] }); List kinds = observer.Updates.Select(update => update.Kind).ToList(); - Assert.Equal(ChatProgressKind.RequestStarted, kinds[0]); - Assert.Equal(ChatProgressKind.Thinking, kinds[1]); - Assert.Contains(ChatProgressKind.ToolInvoking, kinds); + Assert.Equal(ChatProgressKind.ToolInvoking, kinds[0]); + Assert.DoesNotContain(ChatProgressKind.RequestStarted, kinds); + Assert.DoesNotContain(ChatProgressKind.Reasoning, kinds); Assert.Contains(ChatProgressKind.ToolProgress, kinds); Assert.Contains(ChatProgressKind.ToolCompleted, kinds); Assert.Equal(ChatProgressKind.RequestCompleted, kinds[^1]); diff --git a/tests/Andes.Extensions.AI.Unit.Test/ReasoningDetectionTests.cs b/tests/Andes.Extensions.AI.Unit.Test/ReasoningDetectionTests.cs new file mode 100644 index 0000000..2e4ece7 --- /dev/null +++ b/tests/Andes.Extensions.AI.Unit.Test/ReasoningDetectionTests.cs @@ -0,0 +1,115 @@ +using Andes.Extensions.AI.Unit.Test.Infrastructure; +using Microsoft.Extensions.AI; + +namespace Andes.Extensions.AI.Unit.Test; + +public class ReasoningDetectionTests +{ + [Fact] + public async Task GetStreamingResponseAsync_ReasoningContent_EmitsSingleReasoningStatus() + { + var scripted = new ScriptedChatClient(new ScriptedTurn + { + Updates = + [ + new ChatResponseUpdate(ChatRole.Assistant, [new TextReasoningContent("secret chain of thought")]), + new ChatResponseUpdate(ChatRole.Assistant, [new TextReasoningContent("more hidden reasoning")]), + new ChatResponseUpdate(ChatRole.Assistant, "The answer is 42."), + ], + }); + IChatClient client = TestPipeline.Build(scripted); + + List updates = await TestPipeline.CollectAsync(client); + List progress = TestPipeline.ProgressOf(updates); + + ChatProgressUpdate reasoning = Assert.Single(progress, update => update.Kind == ChatProgressKind.Reasoning); + Assert.Equal("Reasoning...", reasoning.Message); + Assert.Equal(0, reasoning.Depth); + Assert.DoesNotContain("chain of thought", reasoning.Message); + Assert.DoesNotContain("hidden reasoning", reasoning.Message); + + int reasoningIndex = TestPipeline.IndexOfProgress(updates, ChatProgressKind.Reasoning); + int contentIndex = TestPipeline.IndexOfContent(updates); + Assert.True(reasoningIndex >= 0 && contentIndex >= 0 && reasoningIndex < contentIndex, + $"The Reasoning status (index {reasoningIndex}) must precede the reasoning content (index {contentIndex})."); + } + + [Fact] + public async Task GetStreamingResponseAsync_ReasoningAcrossToolRoundTrip_ReemitsPerTurn() + { + var scripted = new ScriptedChatClient( + new ScriptedTurn + { + Updates = + [ + new ChatResponseUpdate(ChatRole.Assistant, [new TextReasoningContent("planning the call")]), + new ChatResponseUpdate(ChatRole.Assistant, [new FunctionCallContent("call-1", "GetWeather")]), + ], + }, + new ScriptedTurn + { + Updates = + [ + new ChatResponseUpdate(ChatRole.Assistant, [new TextReasoningContent("interpreting the result")]), + new ChatResponseUpdate(ChatRole.Assistant, "It's sunny."), + ], + }); + AIFunction tool = AIFunctionFactory.Create(() => "sunny", "GetWeather"); + IChatClient client = TestPipeline.Build(scripted); + + List updates = await TestPipeline.CollectAsync(client, new ChatOptions { Tools = [tool] }); + List progress = TestPipeline.ProgressOf(updates); + + List kinds = progress.Select(update => update.Kind).ToList(); + Assert.Equal(2, kinds.Count(kind => kind == ChatProgressKind.Reasoning)); + + int firstReasoning = kinds.IndexOf(ChatProgressKind.Reasoning); + int toolInvoking = kinds.IndexOf(ChatProgressKind.ToolInvoking); + int toolCompleted = kinds.IndexOf(ChatProgressKind.ToolCompleted); + int secondReasoning = kinds.LastIndexOf(ChatProgressKind.Reasoning); + Assert.True(firstReasoning < toolInvoking, + "The first turn's Reasoning must precede the tool header."); + Assert.True(toolCompleted < secondReasoning, + "The second turn's Reasoning must follow the tool completion."); + } + + [Fact] + public async Task GetStreamingResponseAsync_NoReasoningContent_EmitsNoRequestLevelStatuses() + { + var scripted = new ScriptedChatClient( + ScriptedTurn.FunctionCall("call-1", "GetWeather"), + ScriptedTurn.Text("It's sunny.")); + AIFunction tool = AIFunctionFactory.Create(() => "sunny", "GetWeather"); + IChatClient client = TestPipeline.Build(scripted); + + List updates = await TestPipeline.CollectAsync(client, new ChatOptions { Tools = [tool] }); + List progress = TestPipeline.ProgressOf(updates); + + Assert.Equal(ChatProgressKind.ToolInvoking, progress[0].Kind); + Assert.DoesNotContain(progress, update => update.Kind == ChatProgressKind.RequestStarted); + Assert.DoesNotContain(progress, update => update.Kind == ChatProgressKind.Reasoning); + } + + [Fact] + public async Task GetResponseAsync_ReasoningContent_NotifiesObserversOnce() + { + var observer = new CollectingProgressObserver(); + var scripted = new ScriptedChatClient(new ScriptedTurn + { + Updates = + [ + new ChatResponseUpdate(ChatRole.Assistant, [new TextReasoningContent("weighing options")]), + new ChatResponseUpdate(ChatRole.Assistant, [new TextReasoningContent("choosing an answer")]), + new ChatResponseUpdate(ChatRole.Assistant, "Done."), + ], + }); + IChatClient client = TestPipeline.Build(scripted, options => options.Observers.Add(observer)); + + ChatResponse response = await client.GetResponseAsync("prompt"); + + ChatProgressUpdate reasoning = Assert.Single(observer.Updates, update => update.Kind == ChatProgressKind.Reasoning); + Assert.Equal("Reasoning...", reasoning.Message); + Assert.DoesNotContain("weighing options", reasoning.Message); + Assert.Contains("Done.", response.Text); + } +} diff --git a/tests/Andes.Extensions.AI.Unit.Test/StreamingProgressTests.cs b/tests/Andes.Extensions.AI.Unit.Test/StreamingProgressTests.cs index 533c826..aa34a23 100644 --- a/tests/Andes.Extensions.AI.Unit.Test/StreamingProgressTests.cs +++ b/tests/Andes.Extensions.AI.Unit.Test/StreamingProgressTests.cs @@ -23,8 +23,7 @@ public async Task GetStreamingResponseAsync_ToolLoop_EmitsEventsInExpectedOrder( List updates = await TestPipeline.CollectAsync(client, new ChatOptions { Tools = [tool] }); List progress = TestPipeline.ProgressOf(updates); - Assert.Equal(ChatProgressKind.RequestStarted, progress[0].Kind); - Assert.Equal(ChatProgressKind.Thinking, progress[1].Kind); + Assert.Equal(ChatProgressKind.ToolInvoking, progress[0].Kind); int invokingIndex = TestPipeline.IndexOfProgress(updates, ChatProgressKind.ToolInvoking); int resultIndex = TestPipeline.IndexOfContent(updates); @@ -40,7 +39,8 @@ public async Task GetStreamingResponseAsync_ToolLoop_EmitsEventsInExpectedOrder( Assert.Equal("Calling GetWeather Tool", progress[toolInvoking].Message); Assert.Equal("Extracting...", progress[toolProgress].Message); - Assert.Contains(kinds.Skip(toolCompleted), kind => kind == ChatProgressKind.Thinking); + Assert.DoesNotContain(ChatProgressKind.RequestStarted, kinds); + Assert.DoesNotContain(ChatProgressKind.Reasoning, kinds); Assert.Equal(ChatProgressKind.RequestCompleted, progress[^1].Kind); Assert.IsType(updates[^1].Contents.Single()); @@ -63,7 +63,7 @@ public async Task GetStreamingResponseAsync_ProgressContentDisabled_ObserversSti Assert.Empty(updates.SelectMany(update => update.Contents).OfType()); Assert.NotEmpty(updates.SelectMany(update => update.Contents).OfType()); - Assert.Contains(observer.Updates, update => update.Kind == ChatProgressKind.RequestStarted); + Assert.DoesNotContain(observer.Updates, update => update.Kind == ChatProgressKind.RequestStarted); Assert.Contains(observer.Updates, update => update.Kind == ChatProgressKind.RequestCompleted); Assert.NotNull(observer.Report); } diff --git a/tests/Andes.Extensions.AI.Unit.Test/StripProgressContentTests.cs b/tests/Andes.Extensions.AI.Unit.Test/StripProgressContentTests.cs index ef60ebf..30dffc1 100644 --- a/tests/Andes.Extensions.AI.Unit.Test/StripProgressContentTests.cs +++ b/tests/Andes.Extensions.AI.Unit.Test/StripProgressContentTests.cs @@ -37,8 +37,8 @@ public void StripProgressContent_OnMessage_RemovesOnlySyntheticItems() { var progress = new ChatProgressContent(new ChatProgressUpdate { - Kind = ChatProgressKind.Thinking, - Message = "Thinking...", + Kind = ChatProgressKind.Reasoning, + Message = "Reasoning...", ScopeId = "scope-1", }); var message = new ChatMessage(ChatRole.Assistant, [new TextContent("keep me"), progress]); From 375c703ce2945025442c697b705a90f595c07802 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 01:50:40 +0000 Subject: [PATCH 3/8] Add Responses API sample and prepend developer-emitted start status in the demo The new samples/Andes.Extensions.AI.Demo.Responses console app drives the tracked pipeline over the Azure OpenAI Responses API using stable packages only: the plain OpenAIClient against the OpenAI-v1-compatible endpoint, GetResponsesClient().AsIChatClient(deployment), and ChatOptions.Reasoning = Summary so reasoning summaries stream back as TextReasoningContent and light up the middleware's Reasoning status live. The existing demo now prepends ChatProgressUpdate.CreateRequestStarted() .ToResponseUpdate() to the stream its renderer consumes, demonstrating the developer-owned request statuses that replaced the middleware's auto-emission. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0165XhSYHxBtRZy97c7KtxkR --- Andes.Extensions.slnx | 1 + .../Andes.Extensions.AI.Demo.Responses.csproj | 29 +++ .../AzureOpenAISettings.cs | 31 ++++ .../Program.cs | 157 ++++++++++++++++ .../README.md | 79 ++++++++ .../ResponsesDemoTools.cs | 42 +++++ .../StatusRenderer.cs | 174 ++++++++++++++++++ .../appsettings.sample.json | 7 + samples/Andes.Extensions.AI.Demo/Program.cs | 5 + samples/Andes.Extensions.AI.Demo/README.md | 3 +- 10 files changed, 527 insertions(+), 1 deletion(-) create mode 100644 samples/Andes.Extensions.AI.Demo.Responses/Andes.Extensions.AI.Demo.Responses.csproj create mode 100644 samples/Andes.Extensions.AI.Demo.Responses/AzureOpenAISettings.cs create mode 100644 samples/Andes.Extensions.AI.Demo.Responses/Program.cs create mode 100644 samples/Andes.Extensions.AI.Demo.Responses/README.md create mode 100644 samples/Andes.Extensions.AI.Demo.Responses/ResponsesDemoTools.cs create mode 100644 samples/Andes.Extensions.AI.Demo.Responses/StatusRenderer.cs create mode 100644 samples/Andes.Extensions.AI.Demo.Responses/appsettings.sample.json diff --git a/Andes.Extensions.slnx b/Andes.Extensions.slnx index edf3099..6600e2e 100644 --- a/Andes.Extensions.slnx +++ b/Andes.Extensions.slnx @@ -1,6 +1,7 @@ + diff --git a/samples/Andes.Extensions.AI.Demo.Responses/Andes.Extensions.AI.Demo.Responses.csproj b/samples/Andes.Extensions.AI.Demo.Responses/Andes.Extensions.AI.Demo.Responses.csproj new file mode 100644 index 0000000..71406d4 --- /dev/null +++ b/samples/Andes.Extensions.AI.Demo.Responses/Andes.Extensions.AI.Demo.Responses.csproj @@ -0,0 +1,29 @@ + + + + Exe + net10.0 + Andes.Extensions.AI.Demo.Responses + + $(NoWarn);OPENAI001 + + + + + + + + + + + + + + + + + + + + + diff --git a/samples/Andes.Extensions.AI.Demo.Responses/AzureOpenAISettings.cs b/samples/Andes.Extensions.AI.Demo.Responses/AzureOpenAISettings.cs new file mode 100644 index 0000000..82f2783 --- /dev/null +++ b/samples/Andes.Extensions.AI.Demo.Responses/AzureOpenAISettings.cs @@ -0,0 +1,31 @@ +using Microsoft.Extensions.Configuration; + +namespace Andes.Extensions.AI.Demo.Responses; + +/// +/// Azure OpenAI connection settings loaded from the gitignored appsettings.json +/// (copy appsettings.sample.json and fill in the AzureOpenAI section). +/// +internal sealed class AzureOpenAISettings +{ + public string? Endpoint { get; set; } + + public string? ApiKey { get; set; } + + public string? Deployment { get; set; } + + public bool IsConfigured => + Uri.TryCreate(Endpoint, UriKind.Absolute, out _) && + !string.IsNullOrWhiteSpace(ApiKey) && !ApiKey.StartsWith('<') && + !string.IsNullOrWhiteSpace(Deployment) && !Deployment.StartsWith('<'); + + public static AzureOpenAISettings Load() + { + IConfigurationRoot configuration = new ConfigurationBuilder() + .SetBasePath(AppContext.BaseDirectory) + .AddJsonFile("appsettings.json", optional: true) + .Build(); + + return configuration.GetSection("AzureOpenAI").Get() ?? new AzureOpenAISettings(); + } +} diff --git a/samples/Andes.Extensions.AI.Demo.Responses/Program.cs b/samples/Andes.Extensions.AI.Demo.Responses/Program.cs new file mode 100644 index 0000000..bf03d60 --- /dev/null +++ b/samples/Andes.Extensions.AI.Demo.Responses/Program.cs @@ -0,0 +1,157 @@ +using System.ClientModel; +using System.Diagnostics; +using System.Runtime.CompilerServices; +using System.Text; +using Andes.Extensions.AI; +using Andes.Extensions.AI.Demo.Responses; +using Microsoft.Extensions.AI; +using OpenAI; +using Spectre.Console; + +Console.OutputEncoding = Encoding.UTF8; + +AzureOpenAISettings settings = AzureOpenAISettings.Load(); +if (!settings.IsConfigured) +{ + AnsiConsole.Write(new Panel(new Markup( + "[yellow]Azure OpenAI is not configured.[/]\n\n" + + "Copy [bold]appsettings.sample.json[/] to [bold]appsettings.json[/] next to this project\n" + + "and fill in the [bold]AzureOpenAI[/] section ([grey]Endpoint, ApiKey, Deployment[/]).\n" + + "The deployment must be [bold]reasoning-capable[/] (gpt-5 family / o-series).")) + .Header("Andes.Extensions.AI Responses demo") + .BorderColor(Color.Yellow)); + return; +} + +// Azure OpenAI's OpenAI-v1-compatible endpoint (https://{resource}.openai.azure.com/openai/v1) +// lets the plain OpenAIClient reach the Responses API with stable packages — the stable +// Azure.AI.OpenAI client has no Responses surface. The deployment name doubles as the model id. +// The one ordering invariant: UseToolTracking BEFORE UseFunctionInvocation, so the tracker +// wraps the tools the invoker executes and observes the merged stream from outside the loop. +IChatClient client = new OpenAIClient( + new ApiKeyCredential(settings.ApiKey!), + new OpenAIClientOptions { Endpoint = new Uri(settings.Endpoint!.TrimEnd('/') + "/openai/v1") }) + .GetResponsesClient() + .AsIChatClient(settings.Deployment!) + .AsBuilder() + .UseToolTracking() + .UseFunctionInvocation() + .Build(); + +// Summary output is what streams back as TextReasoningContent — the trigger for the middleware's +// Reasoning status. No Temperature: reasoning models reject non-default values. +var chatOptions = new ChatOptions +{ + Reasoning = new ReasoningOptions { Output = ReasoningOutput.Summary }, + Tools = + [ + AIFunctionFactory.Create(ResponsesDemoTools.GetWeather), + AIFunctionFactory.Create(ResponsesDemoTools.ConvertTemperature), + ], +}; + +AnsiConsole.Write(new Rule("[bold]Andes.Extensions.AI[/] [dim]responses demo[/]").LeftJustified()); +AnsiConsole.MarkupLine("[dim]The Responses API pipeline: watch the header switch to \"Reasoning...\" as summaries stream.[/]"); +AnsiConsole.MarkupLine("[dim]Try:[/] [italic]Get the weather in Quito, then convert the high to Fahrenheit.[/]"); +AnsiConsole.MarkupLine("[dim] [/] [italic]What is the sum of the first ten prime numbers? Reason it out.[/]"); +AnsiConsole.MarkupLine("[dim]Press Enter on an empty line (or type 'exit') to quit.[/]"); +AnsiConsole.WriteLine(); + +List history = []; + +while (true) +{ + string prompt = ReadPrompt(); + if (string.IsNullOrWhiteSpace(prompt) || prompt.Trim().ToLowerInvariant() is "exit" or "quit") + { + break; + } + + // Checkpoint so a failed turn can roll back everything it added to history. + int checkpoint = history.Count; + history.Add(new ChatMessage(ChatRole.User, prompt)); + List updates = []; + + // Tee: record the raw updates for chat history while the same stream drives the renderer. + async IAsyncEnumerable StreamTurn( + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + // The middleware no longer auto-emits request-start statuses — the app owns them. + // Prepended outside the recording loop, the status drives the Live header immediately + // without ever entering the chat history or the usage report. + yield return ChatProgressUpdate.CreateRequestStarted().ToResponseUpdate(); + + await foreach (ChatResponseUpdate update in client.GetStreamingResponseAsync(history, chatOptions, cancellationToken)) + { + updates.Add(update); + yield return update; + } + } + + AssistantStatusSnapshot? last = null; + + // Snapshots arrive per text delta; throttle redraws to stay flicker-free. + async Task ConsumeAsync(Action? render) + { + var throttle = Stopwatch.StartNew(); + await foreach (AssistantStatusSnapshot snapshot in StreamTurn().ToStatusSnapshotsAsync()) + { + last = snapshot; + if (render is not null && throttle.ElapsedMilliseconds >= 80) + { + render(snapshot); + throttle.Restart(); + } + } + } + + try + { + if (AnsiConsole.Profile.Capabilities.Interactive) + { + await AnsiConsole.Live(Text.Empty) + .AutoClear(true) + .Overflow(VerticalOverflow.Ellipsis) + .StartAsync(context => ConsumeAsync(snapshot => context.UpdateTarget(StatusRenderer.RenderLive(snapshot)))); + } + else + { + // Redirected output (scripts, CI): the Live region needs a real terminal, so only + // the persistent final frame below is rendered. + await ConsumeAsync(render: null); + } + + // The last snapshot already carries the completed phase and total usage from the + // trailing Finished event; see the main Demo's FinalSnapshot for the report-merge + // pattern that adds per-activity token usage. + if (last is not null) + { + AnsiConsole.Write(StatusRenderer.RenderFinal(last)); + } + + // Strip only the synthetic progress/usage content before history re-enters the next + // request. TextReasoningContent is deliberately kept: the Responses API expects prior + // reasoning items to be replayed across tool round-trips and follow-up turns. + history.AddRange(updates.ToChatResponse().StripProgressContent().Messages); + } + catch (Exception exception) + { + history.RemoveRange(checkpoint, history.Count - checkpoint); + AnsiConsole.Write(StatusRenderer.RenderFailed(last, exception)); + } + + AnsiConsole.WriteLine(); +} + +static string ReadPrompt() +{ + if (AnsiConsole.Profile.Capabilities.Interactive) + { + return AnsiConsole.Prompt(new TextPrompt("[bold green]›[/]").AllowEmpty()); + } + + // Piped or redirected input (scripts, CI): Spectre's interactive prompt would throw, + // so fall back to plain line reading. Null at end-of-input exits the loop. + AnsiConsole.Markup("[bold green]›[/] "); + return Console.ReadLine() ?? string.Empty; +} diff --git a/samples/Andes.Extensions.AI.Demo.Responses/README.md b/samples/Andes.Extensions.AI.Demo.Responses/README.md new file mode 100644 index 0000000..f86a208 --- /dev/null +++ b/samples/Andes.Extensions.AI.Demo.Responses/README.md @@ -0,0 +1,79 @@ +# Andes.Extensions.AI Responses Demo + +An interactive console chat like the [main demo](../Andes.Extensions.AI.Demo/README.md), but built on the **Azure OpenAI Responses API** instead of Chat Completions — the pipeline where the core package's detection-driven **`Reasoning` status** comes alive. Every turn streams through `ToStatusSnapshotsAsync()` and renders live with [Spectre.Console](https://spectreconsole.net/); while the model works through its hidden reasoning, the header switches to "Reasoning..." the moment reasoning summaries start streaming. The project is intentionally not packable (`samples/Directory.Build.props` sets `IsPackable=false`) — it never ships to NuGet. + +## What it demonstrates + +| Feature | Where | +| --- | --- | +| Responses API with **stable packages only**: the stable `Azure.AI.OpenAI` client has no Responses surface, so the plain `OpenAIClient` (stable `OpenAI` 2.12+) targets Azure's OpenAI-v1-compatible endpoint (`https://{resource}.openai.azure.com/openai/v1`) and `GetResponsesClient().AsIChatClient(deployment)` adapts it to `IChatClient` | `Program.cs` | +| Requesting reasoning summaries provider-agnostically with `ChatOptions.Reasoning = new ReasoningOptions { Output = ReasoningOutput.Summary }` — the summaries stream back as `TextReasoningContent` | `Program.cs` | +| The middleware's detection-driven `Reasoning` status: emitted once per model turn when reasoning content is detected, re-armed after each tool round-trip — no synthetic "Thinking" guesses | core `ToolTrackingChatClient` (just observe the header) | +| A developer-emitted request status: the middleware no longer auto-announces request start, so the app prepends `ChatProgressUpdate.CreateRequestStarted().ToResponseUpdate()` to the stream the renderer consumes | `Program.cs` (`StreamTurn`) | +| Local function tools reporting sub-statuses with numeric progress via `ChatProgress.Report(status, progress, progressTotal)` | `ResponsesDemoTools.cs` | +| `ToStatusSnapshotsAsync()` → `AssistantStatusSnapshot` → Spectre.Console `Live` rendering | `StatusRenderer.cs` + `Program.cs` | + +## Prerequisites + +- .NET SDK **10.0** or later. +- An Azure OpenAI resource with a **reasoning-capable deployment** (gpt-5 family / o-series). + +Notes: + +- The `Endpoint` setting is the plain resource endpoint — the app appends `/openai/v1` itself. +- Whether reasoning **summaries** actually stream depends on the deployment; some models additionally require [organization verification](https://learn.microsoft.com/azure/ai-services/openai/how-to/reasoning) before summaries are returned. Without summaries the demo still works — the `Reasoning` status simply has nothing to detect. +- The demo never sets `Temperature` — reasoning deployments reject non-default values. + +## Configure + +Copy the sample settings file next to it in this folder and fill in the `AzureOpenAI` section: + +```shell +cp samples/Andes.Extensions.AI.Demo.Responses/appsettings.sample.json samples/Andes.Extensions.AI.Demo.Responses/appsettings.json +``` + +```json +{ + "AzureOpenAI": { + "Endpoint": "https://your-resource.openai.azure.com/", + "ApiKey": "", + "Deployment": "" + } +} +``` + +`appsettings.json` is gitignored, so secrets never land in git. Environment variables are deliberately not used — the file is the single configuration source. + +## Run + +From the repo root: + +```shell +dotnet run --project samples/Andes.Extensions.AI.Demo.Responses +``` + +Try the prompts printed at startup: + +> Get the weather in Quito, then convert the high to Fahrenheit. + +A tool loop over the Responses API: the header shows "Reasoning..." while the model plans each call, then the `fn` activity cards stream their numeric sub-status progress. + +> What is the sum of the first ten prime numbers? Reason it out. + +A pure reasoning turn — no tools, just the detection-driven status followed by the streamed answer. + +Exit with an empty line, `exit`, or `quit`. In a non-interactive console (piped input or redirected output — scripts, CI) the Spectre `Live` region is skipped and only the persistent final frame of each turn is rendered. + +## How it fits together + +`Program.cs` builds the pipeline with the one ordering invariant: `UseToolTracking()` **before** `UseFunctionInvocation()`. The stream tee prepends a developer-emitted `RequestStarted` status outside the recording loop, so the Live header lights up immediately while the synthetic update never enters the history or the usage report. + +When history re-enters the next request, only the synthetic progress/usage content is stripped (`StripProgressContent()`). `TextReasoningContent` is deliberately kept: the Responses API expects prior reasoning items to be replayed across tool round-trips and follow-up turns. + +Unlike the main demo this sample renders its final frame from the last live snapshot directly (the trailing `Finished` event carries the completed phase and total usage); see the main demo's `FinalSnapshot.cs` for the report-merge pattern that adds per-activity token usage. + +## See also + +- [Main demo](../Andes.Extensions.AI.Demo/README.md) — all four packages, MCP + agent tools, report-derived final frames. +- [Root README](../../README.md) — package overview and quickstarts. +- [Getting started](../../docs/getting-started.md) — the core pipeline, step by step. diff --git a/samples/Andes.Extensions.AI.Demo.Responses/ResponsesDemoTools.cs b/samples/Andes.Extensions.AI.Demo.Responses/ResponsesDemoTools.cs new file mode 100644 index 0000000..d9b6f2a --- /dev/null +++ b/samples/Andes.Extensions.AI.Demo.Responses/ResponsesDemoTools.cs @@ -0,0 +1,42 @@ +using System.ComponentModel; + +namespace Andes.Extensions.AI.Demo.Responses; + +/// +/// Local function tools for the Responses demo: +/// emits sub-statuses with numeric progress that surface under the tool's activity card while the +/// model reasons between turns. +/// +internal static class ResponsesDemoTools +{ + [Description("Gets the current weather for a city.")] + public static async Task GetWeather( + [Description("The city to get the weather for.")] string city, + CancellationToken cancellationToken) + { + const int steps = 3; + for (int i = 1; i <= steps; i++) + { + // Statuses deliberately omit the tool's arguments: progress events stay + // argument-free unless ToolTrackingOptions.IncludeToolArguments is opted in. + ChatProgress.Report($"Checking station {i} of {steps}…", i, steps); + await Task.Delay(250, cancellationToken); + } + + return $"The weather in {city} is sunny with a high of 25C."; + } + + [Description("Converts a temperature between Celsius and Fahrenheit.")] + public static string ConvertTemperature( + [Description("The temperature value to convert.")] double value, + [Description("The unit of the input value: C or F.")] string fromUnit) + { + ChatProgress.Report("Converting…"); + return fromUnit.Trim().ToUpperInvariant() switch + { + "C" => $"{value}C is {(value * 9 / 5) + 32}F.", + "F" => $"{value}F is {(value - 32) * 5 / 9:0.#}C.", + _ => $"Unknown unit '{fromUnit}'; expected C or F.", + }; + } +} diff --git a/samples/Andes.Extensions.AI.Demo.Responses/StatusRenderer.cs b/samples/Andes.Extensions.AI.Demo.Responses/StatusRenderer.cs new file mode 100644 index 0000000..daeae30 --- /dev/null +++ b/samples/Andes.Extensions.AI.Demo.Responses/StatusRenderer.cs @@ -0,0 +1,174 @@ +using Spectre.Console; +using Spectre.Console.Rendering; + +namespace Andes.Extensions.AI.Demo.Responses; + +/// +/// Renders instances from the UI package as +/// Claude-Code-style console frames: a status header, an activity tree with per-kind +/// badges and progress bars, the streamed answer text, and a token-usage footer. +/// +internal static class StatusRenderer +{ + public static IRenderable RenderLive(AssistantStatusSnapshot snapshot) + { + // The Live region cannot scroll, so only the tail of the streamed answer is shown + // while working; RenderFinal prints the full text once the turn completes. + const int liveTextTailLines = 10; + + var rows = new List { Header(snapshot) }; + AppendActivities(rows, snapshot); + if (!string.IsNullOrEmpty(snapshot.Text)) + { + rows.Add(TextPanel(TailLines(snapshot.Text, liveTextTailLines))); + } + + return new Rows(rows); + } + + public static IRenderable RenderFinal(AssistantStatusSnapshot snapshot) + { + var rows = new List(); + AppendActivities(rows, snapshot); + if (!string.IsNullOrEmpty(snapshot.Text)) + { + rows.Add(TextPanel(snapshot.Text)); + } + + if (UsageLine(snapshot.Usage) is { } usage) + { + rows.Add(usage); + } + + return new Rows(rows); + } + + public static IRenderable RenderFailed(AssistantStatusSnapshot? snapshot, Exception exception) + { + var rows = new List(); + if (snapshot is not null) + { + // Request-level failure is reported out-of-band (observers only), so the last + // snapshot still says Running — flip the running cards to failed for display. + AppendActivities(rows, snapshot, forceRunningToFailed: true); + } + + rows.Add(new Panel(new Markup($"[red]{Markup.Escape(exception.Message)}[/]")) + .Header("[red]request failed[/]") + .BorderColor(Color.Red)); + return new Rows(rows); + } + + private static IRenderable Header(AssistantStatusSnapshot snapshot) + { + return snapshot.Phase switch + { + ActivityState.Completed => new Markup("[green]✓[/] [bold]Done[/]"), + ActivityState.Failed => new Markup("[red]✗[/] [bold]Failed[/]"), + _ => new Markup($"[yellow]●[/] [bold]{Markup.Escape(snapshot.AssistantStatus ?? "Working…")}[/]"), + }; + } + + private static void AppendActivities( + List rows, + AssistantStatusSnapshot snapshot, + bool forceRunningToFailed = false) + { + if (snapshot.Activities.Count == 0) + { + return; + } + + var tree = new Tree("[dim]activity[/]"); + foreach (AssistantActivity activity in snapshot.Activities) + { + AddActivityNode(tree, activity, forceRunningToFailed); + } + + rows.Add(tree); + } + + private static void AddActivityNode(IHasTreeNodes parent, AssistantActivity activity, bool forceRunningToFailed) + { + ActivityState state = forceRunningToFailed && activity.State == ActivityState.Running + ? ActivityState.Failed + : activity.State; + string glyph = state switch + { + ActivityState.Completed => "[green]✓[/]", + ActivityState.Failed => "[red]✗[/]", + _ => "[yellow]●[/]", + }; + string badge = activity.Kind switch + { + ToolKind.Function => "[white on blue] fn [/]", + ToolKind.McpTool => "[white on purple] mcp [/]", + ToolKind.Agent => "[black on green] agent [/]", + _ => "[black on grey] tool [/]", + }; + string duration = activity.DurationSeconds is { } seconds ? $" [dim]{seconds:0.0}s[/]" : string.Empty; + string usage = activity.Usage?.TotalTokens is { } tokens ? $" [dim]· {tokens:N0} tok[/]" : string.Empty; + + TreeNode node = parent.AddNode(new Markup( + $"{glyph} [bold]{Markup.Escape(activity.DisplayName)}[/] {badge}{duration}{usage}")); + + foreach (SubStatus subStatus in activity.SubStatuses) + { + node.AddNode(new Markup(SubStatusMarkup(subStatus))); + } + + foreach (AssistantActivity child in activity.Children) + { + AddActivityNode(node, child, forceRunningToFailed); + } + } + + private static string SubStatusMarkup(SubStatus subStatus) + { + string text = $"[grey]{Markup.Escape(subStatus.Message)}[/]"; + if (subStatus is { Progress: { } progress, ProgressTotal: { } total } && total > 0) + { + const int width = 20; + int filled = Math.Clamp((int)Math.Round(width * progress / total), 0, width); + text += $" [green]{new string('█', filled)}[/][grey]{new string('░', width - filled)}[/]" + + $" [dim]{progress / total:P0}[/]"; + } + + return text; + } + + private static IRenderable TextPanel(string text) + { + return new Panel(new Markup(Markup.Escape(text))) + .Border(BoxBorder.Rounded) + .BorderColor(Color.Grey) + .Header("[dim]assistant[/]"); + } + + private static string TailLines(string text, int maxLines) + { + string[] lines = text.Split('\n'); + if (lines.Length <= maxLines) + { + return text; + } + + return "…\n" + string.Join('\n', lines[^maxLines..]); + } + + private static IRenderable? UsageLine(UsageSummary? usage) + { + if (usage is null) + { + return null; + } + + return new Markup( + $"[dim]tokens: in {Format(usage.InputTokens)} · out {Format(usage.OutputTokens)} · total {Format(usage.TotalTokens)}[/]"); + + static string Format(long? tokens) + { + return tokens?.ToString("N0") ?? "—"; + } + } +} diff --git a/samples/Andes.Extensions.AI.Demo.Responses/appsettings.sample.json b/samples/Andes.Extensions.AI.Demo.Responses/appsettings.sample.json new file mode 100644 index 0000000..8853bfe --- /dev/null +++ b/samples/Andes.Extensions.AI.Demo.Responses/appsettings.sample.json @@ -0,0 +1,7 @@ +{ + "AzureOpenAI": { + "Endpoint": "https://your-resource.openai.azure.com/", + "ApiKey": "", + "Deployment": "" + } +} diff --git a/samples/Andes.Extensions.AI.Demo/Program.cs b/samples/Andes.Extensions.AI.Demo/Program.cs index a204229..98f4c16 100644 --- a/samples/Andes.Extensions.AI.Demo/Program.cs +++ b/samples/Andes.Extensions.AI.Demo/Program.cs @@ -86,6 +86,11 @@ .. mcp.Tools.WithTracking(mcp.Client), async IAsyncEnumerable StreamTurn( [EnumeratorCancellation] CancellationToken cancellationToken = default) { + // The middleware no longer auto-emits request-start statuses — the app owns them. + // Prepended outside the recording loop, the status drives the Live header immediately + // without ever entering the chat history or the usage report. + yield return ChatProgressUpdate.CreateRequestStarted().ToResponseUpdate(); + await foreach (ChatResponseUpdate update in client.GetStreamingResponseAsync(history, chatOptions, cancellationToken)) { updates.Add(update); diff --git a/samples/Andes.Extensions.AI.Demo/README.md b/samples/Andes.Extensions.AI.Demo/README.md index 4f19cee..d0c47ad 100644 --- a/samples/Andes.Extensions.AI.Demo/README.md +++ b/samples/Andes.Extensions.AI.Demo/README.md @@ -10,6 +10,7 @@ An interactive, Claude-Code-style console chat that exercises all four packages | `DemoMcpServer.cs` | `Andes.Extensions.AI.Mcp` | A genuine in-process MCP client/server pair over pipe streams; `get_forecast` reports MCP progress notifications that the satellite bridges into chat progress; tools exposed via `WithTracking(client)` | | `DemoAgents.cs` | `Andes.Extensions.AI.Agent` | A "Research Agent" and a "Packing Agent", both over raw (untracked) Azure OpenAI clients. The Research Agent is a top-level tool (`WithTracking(reportFunctionCalls: true)`); the Packing Agent is wrapped once with `WithTracking()` and nests two ways — as a tool of the Research Agent and inside the `PlanTrip` tool body — rendering as a child activity card with its own usage either way. Inner clients stay untracked because `WithTracking`'s usage capture already attributes each agent's tokens — a tracked inner pipeline would double-count them | | `StatusRenderer.cs` + `Program.cs` | `Andes.Extensions.AI.UI` | `ToStatusSnapshotsAsync()` → `AssistantStatusSnapshot` → Spectre.Console `Live` rendering: an activity tree with `fn`/`mcp`/`agent` badges, nested child cards, per-step progress bars, durations, and token usage | +| `Program.cs` (`StreamTurn`) | `Andes.Extensions.AI` (core) | A developer-emitted request status: the middleware no longer auto-announces request start, so the app prepends `ChatProgressUpdate.CreateRequestStarted().ToResponseUpdate()` to the stream the renderer consumes — the header shows "Starting request" before the first tracked event arrives | | `FinalSnapshot.cs` | `Andes.Extensions.AI.UI` | The persistent end-of-turn frame: `ChatUsageReport.ToSnapshot()`'s report-derived tree (per-activity token usage lives only there) merged positionally with the last live snapshot's answer text and sub-status lines | ## Prerequisites @@ -69,7 +70,7 @@ Exit with an empty line, `exit`, or `quit`. In a non-interactive console (piped The Packing Agent is created once and shared by both nesting scenarios: registered as a tool of the Research Agent, and captured by the `PlanTrip` tool body. Either invocation path opens its own child scope, so the live tree and the final usage report both show it as a child of whatever called it. -Each turn tees the raw `ChatResponseUpdate` stream: one side drives the live renderer through `ToStatusSnapshotsAsync()`, the other is recorded for history. After the stream drains, `FinalSnapshot.Merge` builds the persistent frame — the report's `ToSnapshot()` tree (the only place per-activity token usage exists) merged with the live snapshot's text and sub-statuses. Before the next turn, the app calls `StripProgressContent()` on the recorded response so the synthetic in-band progress and usage content never re-enters the request. +Each turn tees the raw `ChatResponseUpdate` stream: one side drives the live renderer through `ToStatusSnapshotsAsync()`, the other is recorded for history. The tee prepends a developer-emitted `RequestStarted` status outside the recording loop, so the Live header lights up immediately while the synthetic update never enters the history or the usage report. After the stream drains, `FinalSnapshot.Merge` builds the persistent frame — the report's `ToSnapshot()` tree (the only place per-activity token usage exists) merged with the live snapshot's text and sub-statuses. Before the next turn, the app calls `StripProgressContent()` on the recorded response so the synthetic in-band progress and usage content never re-enters the request. ## See also From 5791d17c8c032a6522216b183884da834a14a518 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 01:52:24 +0000 Subject: [PATCH 4/8] Bump packages to 0.5.0; add README badges; refresh CLAUDE.md The README gains NuGet version badges for all four packages, the NuGet Publish workflow badge, license and target-framework badges, a Reasoning feature bullet, an 'Emit your own statuses' quickstart section, and the Responses sample. CLAUDE.md now describes the detection-driven status behavior, the new sample projects, the optional ResponsesDeployment integration setting, and the 0.5.0 lockstep version. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0165XhSYHxBtRZy97c7KtxkR --- .claude/CLAUDE.md | 7 +++- .../Andes.Extensions.AI.Agent.csproj | 2 +- .../Andes.Extensions.AI.Mcp.csproj | 2 +- .../Andes.Extensions.AI.UI.csproj | 2 +- .../Andes.Extensions.AI.csproj | 2 +- README.md | 40 +++++++++++++++++++ 6 files changed, 49 insertions(+), 6 deletions(-) diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 941ba5b..50ea080 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -8,6 +8,7 @@ Project memory for **Andes.Extensions.AI** — a C#/.NET solution that ships **` - Tracks every `AIFunction` invocation made by the assistant by wrapping tools in an internal `TrackingAIFunction : DelegatingAIFunction` (request-scoped; the caller's `ChatOptions` is cloned, never mutated). - Emits progress statuses ("Calling {Tool} Tool" headers with tool-reported subheaders like "Extracting…") **in-band** as `ChatProgressContent` items merged into the streaming response via a `Channel` pump, and **out-of-band** to `IChatProgressObserver` implementations. Tool authors report subheaders through the ambient `ChatProgress.Report(...)` API (AsyncLocal; safe no-op outside a tracked request). +- **Request-level statuses are detection-driven (v0.5)**: the middleware never auto-emits `RequestStarted` or a synthetic "Thinking" — the first in-band event of a tool request is `ToolInvoking`. `ChatProgressKind.Reasoning` (renamed from `Thinking` in 0.5.0) is emitted **once per model turn** when `TextReasoningContent` is detected in the stream (OpenAI Responses API today; content-based, so any provider surfacing it works), re-armed by `AdvanceIteration()` after each tool round-trip, mirrored post-hoc (once per request, observers-only) for non-streaming responses, and never carries reasoning text. Developers emit their own request-level statuses via the public factories `ChatProgressUpdate.CreateRequestStarted(...)`/`CreateReasoning(...)` (stamped `ChatProgressUpdate.ExternalScopeId`) wrapped with `ToResponseUpdate()` into the same synthetic shape the middleware writes. - Records token usage (input/output/total, model id, provider name from `ChatClientMetadata`) per request, per model turn (streaming), and per tool-call scope — including usage reported inside tools (`ChatProgress.ReportUsage`) and totals of nested tracked pipelines (AsyncLocal ambient scope tree) — rolled up into a `ChatUsageReport` (streaming: final `UsageReportContent` update; non-streaming: `ChatResponse.AdditionalProperties["andes.ai.usage_report"]`). - Numeric progress is first-class: `ChatProgress.Report(status, progress, progressTotal)` and `IChatProgressReporter.Report(status, progress, progressTotal)` (default interface method) populate `ChatProgressUpdate.Progress`/`ProgressTotal` (doubles, nullable). - **Nested tool scopes**: `ChatProgress.BeginToolScope(descriptor, owner)` (returns a public `ChatProgressToolScope` handle, `Fail()`/`Dispose()`) opens a child scope on the ambient tracker so nested operations render as child activity cards and appear as child `ToolCallUsage` entries. Dedup is by scope **owner identity** (`ToolScope.IsOwnedBy` — reference equality plus the `GetService` probe chain): when the outer tracker already opened the scope for the same function, the call returns an inactive no-op. Both satellite wrappers (`AgentTrackingAIFunction`, `McpTrackingAIFunction`) call it in `InvokeCoreAsync`, so an agent/MCP tool nested inside another agent or invoked directly inside a tool body gets its own child card; a recursive self-invocation stays flat (documented limitation). CallId is taken from `FunctionInvokingChatClient.CurrentContext` only when the context's `Function` IS the owner. Static-only by design — not on `IChatProgressReporter` (captured reporters may run off-flow). @@ -26,11 +27,13 @@ Privacy invariant: progress events and reports never carry prompt content, tool - `Andes.Extensions.AI.Agent\` — the Agent Framework satellite package (same RootNamespace convention). Public `AgentToolTrackingExtensions` + `ToolTrackingOptionsAgentExtensions` at the root; `AgentTrackingAIFunction`/`UsageReportingAIAgent` in `Internal\`. - `tests\Andes.Extensions.AI.Unit.Test\` — core unit tests; no network. The `Infrastructure\ScriptedChatClient` fake replays scripted `ChatResponseUpdate` turns and drives the **real** `FunctionInvokingChatClient`. - `tests\Andes.Extensions.AI.Mcp.Unit.Test\` — MCP unit tests; no network. Links the core test infrastructure files (``), and `Infrastructure\InMemoryMcpFixture` hosts a **real** MCP client/server pair over in-process pipes (with a `ProgressAck` gate so progress tests are deterministic). -- `tests\Andes.Extensions.AI.Integration.Test\` — Azure OpenAI tests. Configuration comes from a **gitignored `appsettings.integration.json`** (copy `appsettings.integration.sample.json`; section `AzureOpenAI` with `Endpoint`/`ApiKey`/`Deployment`). **Never environment variables.** Tests `[SkippableFact]`-skip cleanly when the file is missing or incomplete. Do not set `Temperature` in integration tests — reasoning-model deployments reject non-default values. +- `tests\Andes.Extensions.AI.Integration.Test\` — Azure OpenAI tests. Configuration comes from a **gitignored `appsettings.integration.json`** (copy `appsettings.integration.sample.json`; section `AzureOpenAI` with `Endpoint`/`ApiKey`/`Deployment`, plus optional `ResponsesDeployment` — a reasoning-capable deployment that gates the Responses API tests, which otherwise skip). **Never environment variables.** Tests `[SkippableFact]`-skip cleanly when the file is missing or incomplete. Do not set `Temperature` in integration tests — reasoning-model deployments reject non-default values. - `tests\Andes.Extensions.AI.Mcp.Integration.Test\` — MCP Azure OpenAI tests; links the sibling's `AzureOpenAIFixture.cs` and its gitignored `appsettings.integration.json` (single config location), and spawns `Andes.Extensions.AI.TestMcpServer` over stdio. - `tests\Andes.Extensions.AI.Agent.Unit.Test\` — Agent satellite unit tests; no network. Links the core test infrastructure files; inner agents are real `ChatClientAgent`s built with `scriptedChatClient.AsAIAgent(...)`. - `tests\Andes.Extensions.AI.Agent.Integration.Test\` — Agent satellite Azure OpenAI tests; links `AzureOpenAIFixture.cs` and the shared gitignored `appsettings.integration.json`; the inner agent runs over a raw (untracked) chat client built from the fixture settings. - `tests\Andes.Extensions.AI.TestMcpServer\` — stdio MCP console server ("Andes Test MCP": `echo`, `add`, `count_down`) used by the MCP integration tests via `ProjectReference` + `dotnet `. +- `samples\Andes.Extensions.AI.Demo\` — interactive Spectre.Console chat exercising all four packages over Azure OpenAI Chat Completions (gitignored `appsettings.json`, copy the sample file; `samples\Directory.Build.props` makes samples non-packable). +- `samples\Andes.Extensions.AI.Demo.Responses\` — sibling demo over the **Azure OpenAI Responses API** with stable packages only: plain `OpenAIClient` against the OpenAI-v1-compatible endpoint (`{endpoint}/openai/v1`), `GetResponsesClient().AsIChatClient(deployment)`, `ChatOptions.Reasoning = Summary`; needs a reasoning-capable deployment and `NoWarn OPENAI001` (the Responses surface is still `[Experimental]` in OpenAI 2.12). - `docs\` — developer documentation (getting-started, architecture, mcp, agents, ui). - `releases\` — per-release notes (`v{version}.md`, matching the release-tag convention); a new file is required for every version bump. - Build infrastructure: `Directory.Build.props` (warnings as errors, C# 14, deterministic builds, XML docs required), `Directory.Packages.props` (**central package management — all versions live here**), `global.json` (SDK pin), `.editorconfig` (style rules; `CA2007` is an error in the library, off in tests via `tests\.editorconfig`). @@ -43,7 +46,7 @@ Privacy invariant: progress events and reports never carry prompt content, tool - Events and logs must never carry prompt content, tool arguments, or tool results. Tool-argument capture exists only behind `ToolTrackingOptions.IncludeToolArguments` (default `false`). - Public API changes require XML docs (missing docs fail the build) and a matching update under `docs\`. - Packaging metadata lives in each package's csproj; `dotnet pack -c Release` must produce the nupkg + snupkg with the README embedded (root README for core, each satellite's own `README.md` for the satellites). -- The four packages version in **lockstep** (all `0.3.0` today); each satellite's `ProjectReference` to core becomes a `>= {version}` NuGet dependency automatically. +- The four packages version in **lockstep** (all `0.5.0` today); each satellite's `ProjectReference` to core becomes a `>= {version}` NuGet dependency automatically. ## C# coding standards (always) diff --git a/Andes.Extensions.AI.Agent/Andes.Extensions.AI.Agent.csproj b/Andes.Extensions.AI.Agent/Andes.Extensions.AI.Agent.csproj index 294f1a7..04c276e 100644 --- a/Andes.Extensions.AI.Agent/Andes.Extensions.AI.Agent.csproj +++ b/Andes.Extensions.AI.Agent/Andes.Extensions.AI.Agent.csproj @@ -4,7 +4,7 @@ net10.0 Andes.Extensions.AI Andes.Extensions.AI.Agent - 0.4.0 + 0.5.0 Rodrigo Rojas Microsoft Agent Framework support for Andes.Extensions.AI tool tracking: classifies agents exposed as function tools as agent tools ("Calling {Agent} Agent"), attributes each agent run's token usage to the calling tool's scope, and optionally reports the agent's own function invocations as progress statuses. AI;IChatClient;Microsoft.Extensions.AI;AgentFramework;Microsoft.Agents.AI;agents;middleware;progress;tools diff --git a/Andes.Extensions.AI.Mcp/Andes.Extensions.AI.Mcp.csproj b/Andes.Extensions.AI.Mcp/Andes.Extensions.AI.Mcp.csproj index d934339..681099f 100644 --- a/Andes.Extensions.AI.Mcp/Andes.Extensions.AI.Mcp.csproj +++ b/Andes.Extensions.AI.Mcp/Andes.Extensions.AI.Mcp.csproj @@ -4,7 +4,7 @@ net10.0 Andes.Extensions.AI Andes.Extensions.AI.Mcp - 0.4.0 + 0.5.0 Rodrigo Rojas Model Context Protocol (MCP) support for Andes.Extensions.AI tool tracking: classifies McpClientTool instances as MCP tools ("Calling {Server} MCP") and bridges MCP progress notifications into chat progress updates with numeric progress values. AI;IChatClient;Microsoft.Extensions.AI;MCP;ModelContextProtocol;middleware;progress;tools diff --git a/Andes.Extensions.AI.UI/Andes.Extensions.AI.UI.csproj b/Andes.Extensions.AI.UI/Andes.Extensions.AI.UI.csproj index a1407cf..f5ab70e 100644 --- a/Andes.Extensions.AI.UI/Andes.Extensions.AI.UI.csproj +++ b/Andes.Extensions.AI.UI/Andes.Extensions.AI.UI.csproj @@ -4,7 +4,7 @@ net10.0 Andes.Extensions.AI Andes.Extensions.AI.UI - 0.4.0 + 0.5.0 Rodrigo Rojas UI status contract for Andes.Extensions.AI tool tracking: serializable records (and a matching TypeScript interface) that project the tracked chat stream into an assistant-activity hierarchy — the assistant's own functions, MCP tools, and agents, each with sub-statuses, nested children, and token usage — for rendering live progress in Blazor, a console, or any TypeScript SPA. AI;IChatClient;Microsoft.Extensions.AI;UI;Blazor;TypeScript;progress;streaming;middleware;tools diff --git a/Andes.Extensions.AI/Andes.Extensions.AI.csproj b/Andes.Extensions.AI/Andes.Extensions.AI.csproj index bfbd1b5..ef184b9 100644 --- a/Andes.Extensions.AI/Andes.Extensions.AI.csproj +++ b/Andes.Extensions.AI/Andes.Extensions.AI.csproj @@ -4,7 +4,7 @@ net10.0 Andes.Extensions.AI Andes.Extensions.AI - 0.4.0 + 0.5.0 Rodrigo Rojas Middleware extensions for Microsoft.Extensions.AI: per-request and per-tool token usage tracking, and streaming status/progress propagation for IChatClient pipelines. AI;IChatClient;Microsoft.Extensions.AI;middleware;tokens;usage;streaming;progress;tools diff --git a/README.md b/README.md index 5a899d7..45f542c 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,21 @@ # Andes.Extensions.AI +[![Andes.Extensions.AI](https://img.shields.io/nuget/v/Andes.Extensions.AI.svg?logo=nuget&label=Andes.Extensions.AI)](https://www.nuget.org/packages/Andes.Extensions.AI) +[![Andes.Extensions.AI.Mcp](https://img.shields.io/nuget/v/Andes.Extensions.AI.Mcp.svg?logo=nuget&label=Andes.Extensions.AI.Mcp)](https://www.nuget.org/packages/Andes.Extensions.AI.Mcp) +[![Andes.Extensions.AI.Agent](https://img.shields.io/nuget/v/Andes.Extensions.AI.Agent.svg?logo=nuget&label=Andes.Extensions.AI.Agent)](https://www.nuget.org/packages/Andes.Extensions.AI.Agent) +[![Andes.Extensions.AI.UI](https://img.shields.io/nuget/v/Andes.Extensions.AI.UI.svg?logo=nuget&label=Andes.Extensions.AI.UI)](https://www.nuget.org/packages/Andes.Extensions.AI.UI) +[![NuGet Publish](https://github.com/Andes-Software-Solutions/Andes.Extensions.AI/actions/workflows/nuget.yml/badge.svg)](https://github.com/Andes-Software-Solutions/Andes.Extensions.AI/actions/workflows/nuget.yml) +[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) +[![.NET 10](https://img.shields.io/badge/.NET-10.0-512BD4?logo=dotnet)](global.json) + Middleware extensions for [Microsoft.Extensions.AI](https://learn.microsoft.com/dotnet/ai/microsoft-extensions-ai): per-request and per-tool **token usage tracking**, and **streaming status propagation** for `IChatClient` pipelines. Add one line to your pipeline and get: - **Token usage tracking** — input/output/total tokens for the main assistant, per model turn, and attributed to each tool call (including LLM calls nested inside tools), rolled up into a `ChatUsageReport`. - **Streaming progress statuses** — synthetic `ChatProgressContent` updates interleaved into the live stream so your UI can show "Calling GetWeather Tool", sub-statuses reported from inside the tool ("Extracting…", "Processing…"), and completion — while the model and tools are still working. +- **Reasoning detection** — when the model streams reasoning content (`TextReasoningContent`, e.g. via the OpenAI Responses API), a `Reasoning` status is emitted once per model turn — truthful, detection-driven, and never carrying the reasoning text itself. +- **Developer-owned request statuses** — the middleware doesn't invent request-level statuses; construct your own with `ChatProgressUpdate.CreateRequestStarted()` / `CreateReasoning()` and interleave them with `ToResponseUpdate()`. - **Out-of-band observers** — implement `IChatProgressObserver` to receive the same events and the final report without parsing the stream. - **Privacy by default** — progress events never carry prompt content, tool arguments, or tool results unless explicitly opted in. @@ -66,6 +76,25 @@ Before persisting responses into conversation history, remove the synthetic cont ChatResponse response = updates.ToChatResponse().StripProgressContent(); ``` +### Emit your own statuses + +The middleware only reports what it can observe — tool activity, detected reasoning, completion. Request-level statuses like "Starting request" are yours to send: create them with the public factories and interleave them into whatever stream your UI consumes, in the exact shape the middleware itself emits: + +```csharp +async IAsyncEnumerable StreamTurn() +{ + // Shows in the UI before the first tracked event arrives. + yield return ChatProgressUpdate.CreateRequestStarted().ToResponseUpdate(); + + await foreach (ChatResponseUpdate update in client.GetStreamingResponseAsync(history, chatOptions)) + { + yield return update; + } +} +``` + +`CreateReasoning()` works the same way, and both accept a custom message. The updates are stamped with the well-known `ChatProgressUpdate.ExternalScopeId`, so they never collide with the middleware's own scopes. + ## MCP tools First-class MCP support ships as a satellite package, [Andes.Extensions.AI.Mcp](https://www.nuget.org/packages/Andes.Extensions.AI.Mcp), so the core stays dependency-lean: @@ -166,6 +195,16 @@ dotnet run --project samples/Andes.Extensions.AI.Demo See the [sample README](samples/Andes.Extensions.AI.Demo/README.md) for what each file demonstrates. +`samples/Andes.Extensions.AI.Demo.Responses` is its sibling built on the **Azure OpenAI Responses API** (stable packages only, via the OpenAI-v1-compatible endpoint): the same live rendering, plus the detection-driven `Reasoning` status lighting up as reasoning summaries stream. It needs a reasoning-capable deployment (gpt-5 family / o-series): + +```bash +cp samples/Andes.Extensions.AI.Demo.Responses/appsettings.sample.json samples/Andes.Extensions.AI.Demo.Responses/appsettings.json +# fill in the AzureOpenAI section, then: +dotnet run --project samples/Andes.Extensions.AI.Demo.Responses +``` + +See the [Responses sample README](samples/Andes.Extensions.AI.Demo.Responses/README.md) for details. + ## Documentation - [Getting started](docs/getting-started.md) @@ -176,6 +215,7 @@ See the [sample README](samples/Andes.Extensions.AI.Demo/README.md) for what eac - [Example: the Progress Board — every tool kind in one stream](docs/examples/progress-board.md) - [Example: the UI contract, three ways](docs/examples/ui-contract.md) - [Sample: the interactive demo console app](samples/Andes.Extensions.AI.Demo/README.md) +- [Sample: the Responses API demo console app](samples/Andes.Extensions.AI.Demo.Responses/README.md) - [Release notes](releases/) ## License From a6d84e6f31f0cce99e8ce2b555323d9540126542 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 02:02:12 +0000 Subject: [PATCH 5/8] Apply code-review polish: TimeProvider in status factories, tighter tests, sample config guard The status factories now stamp timestamps via TimeProvider.System for symmetry with the tracker, the factory tests bound the timestamp with InRange instead of a weak default check, and both samples' IsConfigured now rejects the sample file's placeholder endpoint so a half-filled config gets the friendly setup panel instead of a DNS failure. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0165XhSYHxBtRZy97c7KtxkR --- Andes.Extensions.AI/Progress/ChatProgressUpdate.cs | 4 ++-- .../AzureOpenAISettings.cs | 3 ++- samples/Andes.Extensions.AI.Demo/AzureOpenAISettings.cs | 3 ++- .../ChatProgressUpdateFactoryTests.cs | 8 ++++++-- 4 files changed, 12 insertions(+), 6 deletions(-) diff --git a/Andes.Extensions.AI/Progress/ChatProgressUpdate.cs b/Andes.Extensions.AI/Progress/ChatProgressUpdate.cs index dba1ceb..f3a9d0f 100644 --- a/Andes.Extensions.AI/Progress/ChatProgressUpdate.cs +++ b/Andes.Extensions.AI/Progress/ChatProgressUpdate.cs @@ -125,7 +125,7 @@ public static ChatProgressUpdate CreateRequestStarted(string? message = null) Message = message ?? "Starting request", ScopeId = ExternalScopeId, Depth = 0, - Timestamp = DateTimeOffset.UtcNow, + Timestamp = TimeProvider.System.GetUtcNow(), }; } @@ -147,7 +147,7 @@ public static ChatProgressUpdate CreateReasoning(string? message = null) Message = message ?? "Reasoning...", ScopeId = ExternalScopeId, Depth = 0, - Timestamp = DateTimeOffset.UtcNow, + Timestamp = TimeProvider.System.GetUtcNow(), }; } } diff --git a/samples/Andes.Extensions.AI.Demo.Responses/AzureOpenAISettings.cs b/samples/Andes.Extensions.AI.Demo.Responses/AzureOpenAISettings.cs index 82f2783..310e8b9 100644 --- a/samples/Andes.Extensions.AI.Demo.Responses/AzureOpenAISettings.cs +++ b/samples/Andes.Extensions.AI.Demo.Responses/AzureOpenAISettings.cs @@ -15,7 +15,8 @@ internal sealed class AzureOpenAISettings public string? Deployment { get; set; } public bool IsConfigured => - Uri.TryCreate(Endpoint, UriKind.Absolute, out _) && + Endpoint is { } endpoint && Uri.TryCreate(endpoint, UriKind.Absolute, out _) && + !endpoint.Contains("your-resource", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(ApiKey) && !ApiKey.StartsWith('<') && !string.IsNullOrWhiteSpace(Deployment) && !Deployment.StartsWith('<'); diff --git a/samples/Andes.Extensions.AI.Demo/AzureOpenAISettings.cs b/samples/Andes.Extensions.AI.Demo/AzureOpenAISettings.cs index d8c5d53..2f97e40 100644 --- a/samples/Andes.Extensions.AI.Demo/AzureOpenAISettings.cs +++ b/samples/Andes.Extensions.AI.Demo/AzureOpenAISettings.cs @@ -15,7 +15,8 @@ internal sealed class AzureOpenAISettings public string? Deployment { get; set; } public bool IsConfigured => - Uri.TryCreate(Endpoint, UriKind.Absolute, out _) && + Endpoint is { } endpoint && Uri.TryCreate(endpoint, UriKind.Absolute, out _) && + !endpoint.Contains("your-resource", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(ApiKey) && !ApiKey.StartsWith('<') && !string.IsNullOrWhiteSpace(Deployment) && !Deployment.StartsWith('<'); diff --git a/tests/Andes.Extensions.AI.Unit.Test/ChatProgressUpdateFactoryTests.cs b/tests/Andes.Extensions.AI.Unit.Test/ChatProgressUpdateFactoryTests.cs index 12a4312..42afe08 100644 --- a/tests/Andes.Extensions.AI.Unit.Test/ChatProgressUpdateFactoryTests.cs +++ b/tests/Andes.Extensions.AI.Unit.Test/ChatProgressUpdateFactoryTests.cs @@ -7,13 +7,15 @@ public class ChatProgressUpdateFactoryTests [Fact] public void CreateRequestStarted_Default_PopulatesWellKnownFields() { + DateTimeOffset before = DateTimeOffset.UtcNow; ChatProgressUpdate update = ChatProgressUpdate.CreateRequestStarted(); + DateTimeOffset after = DateTimeOffset.UtcNow; Assert.Equal(ChatProgressKind.RequestStarted, update.Kind); Assert.Equal("Starting request", update.Message); Assert.Equal(ChatProgressUpdate.ExternalScopeId, update.ScopeId); Assert.Equal(0, update.Depth); - Assert.NotEqual(default, update.Timestamp); + Assert.InRange(update.Timestamp, before, after); } [Fact] @@ -28,13 +30,15 @@ public void CreateRequestStarted_CustomMessage_UsesIt() [Fact] public void CreateReasoning_Default_PopulatesWellKnownFields() { + DateTimeOffset before = DateTimeOffset.UtcNow; ChatProgressUpdate update = ChatProgressUpdate.CreateReasoning(); + DateTimeOffset after = DateTimeOffset.UtcNow; Assert.Equal(ChatProgressKind.Reasoning, update.Kind); Assert.Equal("Reasoning...", update.Message); Assert.Equal(ChatProgressUpdate.ExternalScopeId, update.ScopeId); Assert.Equal(0, update.Depth); - Assert.NotEqual(default, update.Timestamp); + Assert.InRange(update.Timestamp, before, after); } [Fact] From 4821422959c4dfa57cc3da6b41d5cc3a8a84b5d4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 02:05:02 +0000 Subject: [PATCH 6/8] Document the v0.5.0 status model and author release notes Updates getting-started (kinds, transcripts, new 'Emit request-level statuses yourself' section), architecture (detection design and the non-streaming post-hoc note), the MCP/Agent transcripts, ui.md, and the progress-board example for the detection-driven Reasoning status, and adds releases/v0.5.0.md covering the breaking changes with migration guidance, the additions, and the dependency updates. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0165XhSYHxBtRZy97c7KtxkR --- docs/agents.md | 5 +--- docs/architecture.md | 8 ++++--- docs/examples/progress-board.md | 13 +++++----- docs/getting-started.md | 42 ++++++++++++++++++++++++++++----- docs/mcp.md | 5 +--- docs/ui.md | 6 +++-- releases/v0.5.0.md | 33 ++++++++++++++++++++++++++ 7 files changed, 87 insertions(+), 25 deletions(-) create mode 100644 releases/v0.5.0.md diff --git a/docs/agents.md b/docs/agents.md index 1ded05b..f679d57 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -18,7 +18,7 @@ This guide covers installation, how classification and usage capture work, the d dotnet add package Andes.Extensions.AI.Agent ``` -Installing the package brings in the core `Andes.Extensions.AI` package (>= 0.3.0) and [`Microsoft.Agents.AI`](https://www.nuget.org/packages/Microsoft.Agents.AI) (>= 1.15.0, stable). +Installing the package brings in the core `Andes.Extensions.AI` package (>= 0.5.0) and [`Microsoft.Agents.AI`](https://www.nuget.org/packages/Microsoft.Agents.AI) (>= 1.17.0, stable). ## Quickstart @@ -47,13 +47,10 @@ var chatOptions = new ChatOptions { Tools = [weatherAgent.WithTracking()] }; A typical rendering while the outer model delegates to the agent: ```text -[RequestStarted] Starting request -[Thinking] Thinking... [ToolInvoking] Calling Weather Agent [ToolProgress] Calling GetWeather Tool [ToolProgress] Extracting... [ToolCompleted] Weather_Agent completed -[Thinking] Thinking... [RequestCompleted] Request completed ``` diff --git a/docs/architecture.md b/docs/architecture.md index c063b0a..eef90ad 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -2,7 +2,7 @@ `Andes.Extensions.AI` is a middleware library for [Microsoft.Extensions.AI](https://learn.microsoft.com/dotnet/ai/microsoft-extensions-ai) `IChatClient` pipelines, targeting **net10.0** and built with **C# 14**. Its first deliverable is **tool tracking**: a single `DelegatingChatClient` (`ToolTrackingChatClient`, registered with `UseToolTracking()`) that observes a chat request end to end and produces two things a production application needs but the raw pipeline does not give you: -1. **Progress events** — "Starting request", "Thinking...", "Calling GetWeather Tool", sub-statuses reported from inside the tool, completion — delivered *while tools are still executing*, both in-band (as synthetic content in the streamed response) and out-of-band (to `IChatProgressObserver` implementations). +1. **Progress events** — "Calling GetWeather Tool" headers, sub-statuses reported from inside the tool, a detected "Reasoning..." status when the model streams reasoning content, completion — delivered *while tools are still executing*, both in-band (as synthetic content in the streamed response) and out-of-band (to `IChatProgressObserver` implementations). Every event reports something the middleware actually observed; request-level statuses like "Starting request" are deliberately left to the application (see [Streaming design](#streaming-design-channel-merge)). 2. **A usage report** — token usage for the main assistant (per model turn when streaming), attributed per tool call (including LLM calls nested inside tools), rolled up into a `ChatUsageReport`. This document explains why the middleware is shaped the way it is. For hands-on usage, see [Getting started](getting-started.md). @@ -59,16 +59,18 @@ The hard problem in streaming is that **tools execute inside the inner client's after drain: RequestCompleted + UsageReportContent ``` -- A **background pump task** enumerates the inner stream and `TryWrite`s every real update into the channel. While enumerating it records each `UsageContent` as assistant-turn usage, emits best-effort headers for `FunctionCallContent` naming tools it did not wrap, and advances the turn counter when it sees `FunctionResultContent` (a completed tool round-trip), announcing the next turn with a "Thinking..." event. +- A **background pump task** enumerates the inner stream and `TryWrite`s every real update into the channel. While enumerating it records each `UsageContent` as assistant-turn usage, emits best-effort headers for `FunctionCallContent` naming tools it did not wrap, announces a single `Reasoning` event the first time it sees `TextReasoningContent` in a model turn (each update is inspected *before* it is written, so the status enters the channel ahead of the update carrying the reasoning content — and the event never carries the reasoning text), and advances the turn counter when it sees `FunctionResultContent` (a completed tool round-trip), re-arming reasoning detection for the next turn. - **Tool wrappers and the ambient reporter** write synthetic updates into the same channel from whatever thread the function-invocation loop runs them on. Synthetic updates carry a single `ChatProgressContent` item and **no `TextContent`**, so text-accumulation helpers (`update.Text`, `ToChatResponse()`) are unaffected. - **Bridged MCP progress notifications** (via the [`Andes.Extensions.AI.Mcp` satellite](mcp.md)) write from yet another thread — the MCP client's receive loop — so those `ToolProgress` events can arrive out of order relative to request-path events. The channel guarantees arrival order, not source order; `IChatProgressObserver` implementations must be thread-safe (already their documented contract); and a late notification racing request completion is dropped best-effort — the in-band write to the completed channel is a no-op, though an observer may see one late event. - The **outer iterator** simply drains the channel with `ReadAllAsync` and yields each update in arrival order. After the channel completes, it builds the report, notifies observers, and appends the final `RequestCompleted` progress update and a `UsageReportContent` update (each independently switchable via options). +**Reasoning is detected, never guessed.** Earlier versions opened every request with synthetic `RequestStarted` ("Starting request") and `Thinking` ("Thinking...") events and re-announced "Thinking..." after each tool round-trip — statuses that asserted more than the middleware could know. Since v0.5 the only request-level status emitted mid-stream is `Reasoning` (the renamed `Thinking`, same underlying value), raised at most once per model turn and only because `TextReasoningContent` was observed on the response. Detection is content-based and therefore provider-agnostic: the OpenAI Responses API streams reasoning summaries as `TextReasoningContent` today, and any future provider producing the same content lights the status up with no middleware changes — while plain Chat Completions never streams reasoning, so chat pipelines simply never see it. Applications that want "Starting request"-style statuses construct them with the public factories (`ChatProgressUpdate.CreateRequestStarted()` / `CreateReasoning()`, stamped with the well-known `ChatProgressUpdate.ExternalScopeId`) and interleave them via `ToResponseUpdate()`, which produces the exact synthetic shape the middleware emits — see [Getting started](getting-started.md#emit-request-level-statuses-yourself). + **Cancellation and failure.** The pump runs under a CTS linked to the consumer's token. The `finally` around the drain loop cancels that CTS — a no-op on normal completion, but it stops the inner stream promptly if the consumer abandons the iterator early — and then awaits the pump task so it is always observed. If the inner stream throws, the pump completes the channel with that exception, so the failure surfaces to the consumer through the drain loop exactly as it would have without the middleware. **Accounting always lands.** Tokens consumed before a fault, cancellation, or abandonment were still billed, so the middleware never discards them: whenever a request ends without draining to completion (both call styles), observers receive a `ChatProgressKind.RequestFailed` event followed by their once-per-request `OnRequestCompleted` call with the (possibly partial) report, and a nested parent scope still receives the rollup. In-band synthetic updates are yielded on the success path only — on failure there is no stream left to write to. -The non-streaming path (`GetResponseAsync`) needs no channel: there is nowhere to interleave synthetic updates, so progress goes to observers only, and the report is attached to the response (see below). +The non-streaming path (`GetResponseAsync`) needs no channel: there is nowhere to interleave synthetic updates, so progress goes to observers only, and the report is attached to the response (see below). Reasoning detection mirrors the streaming path post-hoc: model turns are indistinguishable in an aggregated response, so if any response message contains `TextReasoningContent`, at most one `Reasoning` event is raised for the whole request — observers only, like every other non-streaming progress event. ## The ambient scope tree diff --git a/docs/examples/progress-board.md b/docs/examples/progress-board.md index 270e7dc..35b641d 100644 --- a/docs/examples/progress-board.md +++ b/docs/examples/progress-board.md @@ -121,7 +121,7 @@ public sealed record TextDelta(string Text) : AssistantUiEvent; public sealed record RequestFinished(ChatUsageReport Report) : AssistantUiEvent; ``` -The service's `IAsyncEnumerable` is the **single channel to the UI** — and because it is an ordinary iterator, the app can yield any number of its own "Thinking"-style statuses **before the pipeline streaming even starts**. The first two `yield return`s below run before `GetStreamingResponseAsync` is ever called; the pipeline's in-band events then follow through the same channel: +The service's `IAsyncEnumerable` is the **single channel to the UI** — and because it is an ordinary iterator, the app can yield any number of its own "Starting request"-style statuses **before the pipeline streaming even starts**. That matters more than it used to: the middleware never invents request-level statuses (since core v0.5 it emits nothing until it observes something), so app-authored lines like these are the only way the user sees a status before the first tracked event. The first two `yield return`s below run before `GetStreamingResponseAsync` is ever called; the pipeline's in-band events then follow through the same channel. (This service yields its own app-owned records; a consumer reading `ChatResponseUpdate` directly would instead prepend `ChatProgressUpdate.CreateRequestStarted().ToResponseUpdate()` into the stream — see [Getting started](../getting-started.md#emit-request-level-statuses-yourself).) ```csharp using System.Runtime.CompilerServices; @@ -186,7 +186,7 @@ The board holds no middleware state — it reconstructs the tool-call tree from | Event kind | `ScopeId` | `Depth` | Board action | | --- | --- | --- | --- | -| `RequestStarted` / `Thinking` / `RequestCompleted` | The request root | 0 | Update the top-level assistant status line | +| `Reasoning` / `RequestCompleted` — and `RequestStarted`, which only ever arrives [app-emitted](../getting-started.md#emit-request-level-statuses-yourself) | The request root (`ChatProgressUpdate.ExternalScopeId` for app-emitted updates) | 0 | Update the top-level assistant status line | | `ToolInvoking` | A fresh scope | parent + 1 | New box (`Title` = the header; `ToolKind`/`ToolName`/`ToolSource` for badges), parented via `ParentScopeId` — a top-level tool's parent is the request root, which has no box, so it becomes a root | | `ToolProgress` | **The owning tool's scope** | tool + 1 | Append a subtitle line (`Message`, plus `Progress`/`ProgressTotal` rendered as "2/3") | | `ToolCompleted` / `ToolFailed` | The tool's scope | tool | Mark the state, set `Duration` | @@ -264,7 +264,7 @@ public sealed class ProgressBoard private readonly Dictionary _byScope = []; private readonly List _roots = []; - /// The request-level status line ("Thinking...", "Request completed"). + /// The request-level status line ("Reasoning...", "Request completed"). public string? AssistantStatus { get; private set; } /// The top-level boxes, one per tool call the assistant made, in order. @@ -275,7 +275,7 @@ public sealed class ProgressBoard { switch (update.Kind) { - case ChatProgressKind.RequestStarted or ChatProgressKind.Thinking or ChatProgressKind.RequestCompleted: + case ChatProgressKind.RequestStarted or ChatProgressKind.Reasoning or ChatProgressKind.RequestCompleted: AssistantStatus = update.Message; break; @@ -408,13 +408,12 @@ public static class ConsoleRenderer } ``` -Condensed (the renderer reprints the board on every event; this is the two app-authored statuses, the final board frame, the answer, and the usage line): +Condensed (the renderer reprints the board on every event; this is the two app-authored statuses, the last mid-run board frame — no request-level status heads it, because the middleware emits none on a Chat Completions pipeline — then the answer, and the closing `RequestCompleted` status and usage line): ```text · Connecting to tools… · Planning your trip… -· Thinking... ┌ Calling GetForecast Tool [done in 0.2s] │ Contacting the forecast service… │ Crunching the numbers… (2/3) @@ -426,6 +425,8 @@ Condensed (the renderer reprints the board on every event; this is the two app-a │ Calling SearchDocs Tool │ Summarizing… A day in Quito: sunny all week, countdown complete, and the old town ... + +· Request completed — 1234 tokens total across 3 tool calls — ``` diff --git a/docs/getting-started.md b/docs/getting-started.md index 229b071..4092caa 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -109,24 +109,54 @@ await foreach (var update in client.GetStreamingResponseAsync( Each `ChatProgressUpdate` carries the fields a UI needs to render a hierarchy without holding extra state: -- **`Kind`** — `RequestStarted`, `Thinking`, `ToolInvoking` (the header, e.g. "Calling GetWeather Tool"), `ToolProgress` (a sub-status), `ToolCompleted`, `ToolFailed`, `RequestCompleted`, and `RequestFailed` (out-of-band only: raised to observers when the request faults or is canceled, followed by `OnRequestCompleted` with a possibly partial report). +- **`Kind`** — `ToolInvoking` (the header, e.g. "Calling GetWeather Tool"), `ToolProgress` (a sub-status), `ToolCompleted`, `ToolFailed`, `Reasoning` (emitted at most once per model turn when the middleware detects reasoning content — `TextReasoningContent` — on the stream, as the OpenAI Responses API produces for reasoning-capable models; the event never carries the reasoning text), `RequestCompleted`, `RequestFailed` (out-of-band only: raised to observers when the request faults or is canceled, followed by `OnRequestCompleted` with a possibly partial report), and `RequestStarted` (never emitted by the middleware — reserved for statuses you construct yourself, see [Emit request-level statuses yourself](#emit-request-level-statuses-yourself)). - **`ScopeId` / `ParentScopeId`** — `ToolProgress` events share the `ScopeId` of their owning tool call, so group sub-statuses under the header with the matching `ScopeId`; `ParentScopeId` links nested tool calls to their parent. - **`Depth`** — 0 for request-level events, 1 for tool headers, 2 for sub-statuses under a header, deeper for nested tools; ideal for indentation. - **`ToolName` / `ToolKind` / `ToolSource` / `CallId` / `Duration`** — for richer rendering and correlation with the model's function calls. - **`Progress` / `ProgressTotal`** — optional numeric progress (`double?`) on `ToolProgress` events whose reporter supplied values, via the `ChatProgress.Report(status, progress, progressTotal)` overload or a bridged MCP progress notification (see [MCP tools](#mcp-tools)). -A typical rendering of the events for one tool call: +A typical rendering of the events for one tool call — the middleware emits nothing until it observes something, so the stream opens with the `ToolInvoking` header: ```text -[RequestStarted] Starting request -[Thinking] Thinking... [ToolInvoking] Calling GetWeather Tool [ToolProgress] Extracting... [ToolCompleted] GetWeather completed -[Thinking] Thinking... [RequestCompleted] Request completed ``` +On a pipeline whose model streams reasoning content — the OpenAI Responses API with a reasoning-capable deployment; plain Chat Completions never streams reasoning, so chat pipelines never see it — a `Reasoning` status additionally announces each model turn the moment reasoning is detected, re-armed after every tool round-trip: + +```text +[Reasoning] Reasoning... + [ToolInvoking] Calling GetWeather Tool + [ToolProgress] Extracting... + [ToolCompleted] GetWeather completed +[Reasoning] Reasoning... +[RequestCompleted] Request completed +``` + +## Emit request-level statuses yourself + +The middleware only reports what it can observe — tool activity, detected reasoning, completion. Request-level statuses like "Starting request" are the application's to send: two public factories construct them, and `ToResponseUpdate()` wraps one in the exact synthetic shape the middleware emits — a role-less `ChatResponseUpdate` carrying a single `ChatProgressContent` — so it can be prepended or interleaved into whatever stream your consumer reads, whether that is a rendering loop like the one above or the [UI package](ui.md)'s `ToStatusSnapshotsAsync()`: + +```csharp +async IAsyncEnumerable StreamTurn() +{ + // Shows in the UI before the first tracked event arrives. + yield return ChatProgressUpdate.CreateRequestStarted().ToResponseUpdate(); + + await foreach (ChatResponseUpdate update in client.GetStreamingResponseAsync(history, chatOptions)) + { + yield return update; + } +} +``` + +- **`ChatProgressUpdate.CreateRequestStarted(message)`** — a `RequestStarted` update; `message` defaults to "Starting request". +- **`ChatProgressUpdate.CreateReasoning(message)`** — a `Reasoning` update; `message` defaults to "Reasoning..." — the same shape the middleware raises on detection, for streams where you announce it yourself. + +Both stamp the update with depth 0, the current UTC time, and the well-known scope id `ChatProgressUpdate.ExternalScopeId` (`"scope-external"`), which the middleware's own per-request scope identifiers never collide with. The synthetic update carries no text, so `update.Text` accumulation is unaffected; prepend it outside any loop that records updates for chat history (as both sample apps do in their `StreamTurn` iterators — see [`samples/Andes.Extensions.AI.Demo`](../samples/Andes.Extensions.AI.Demo/README.md) and [`samples/Andes.Extensions.AI.Demo.Responses`](../samples/Andes.Extensions.AI.Demo.Responses/README.md)), or strip it with [`StripProgressContent()`](#strip-synthetic-content-before-persisting-history) like any other synthetic content. + ## Report from inside a tool Tools report sub-statuses and attribute token usage through the static `ChatProgress` ambient reporter — no changes to tool signatures: @@ -343,6 +373,6 @@ public sealed class AzureOpenAISettings dotnet test ``` -Unit tests (`tests\Andes.Extensions.AI.Unit.Test`) need no network: a scripted fake drives the real `FunctionInvokingChatClient`. Integration tests (`tests\Andes.Extensions.AI.Integration.Test`) call Azure OpenAI for real, but **skip cleanly** when `appsettings.integration.json` is missing or incomplete — so `dotnet test` always passes out of the box. To run them for real, copy `appsettings.integration.sample.json` to `appsettings.integration.json` in the integration test project and fill in the `AzureOpenAI` section. The file is gitignored; do not commit it. +Unit tests (`tests\Andes.Extensions.AI.Unit.Test`) need no network: a scripted fake drives the real `FunctionInvokingChatClient`. Integration tests (`tests\Andes.Extensions.AI.Integration.Test`) call Azure OpenAI for real, but **skip cleanly** when `appsettings.integration.json` is missing or incomplete — so `dotnet test` always passes out of the box. To run them for real, copy `appsettings.integration.sample.json` to `appsettings.integration.json` in the integration test project and fill in the `AzureOpenAI` section. An optional `ResponsesDeployment` entry (a reasoning-capable deployment — gpt-5 family / o-series) additionally enables the Responses API suite, which exercises the detection-driven `Reasoning` status end to end; without it those tests skip while the chat-deployment tests still run. The file is gitignored; do not commit it. The [MCP package](mcp.md) adds three more projects: `tests\Andes.Extensions.AI.Mcp.Unit.Test` (also no network — an in-memory pipe fixture hosts a real MCP client/server pair in-process), `tests\Andes.Extensions.AI.TestMcpServer` (a runnable stdio server, "Andes Test MCP", with `echo`/`add`/`count_down` tools), and `tests\Andes.Extensions.AI.Mcp.Integration.Test`, which drives Azure OpenAI against that stdio server and **links the same gitignored `appsettings.integration.json`** — configure the file once and both integration projects use it. diff --git a/docs/mcp.md b/docs/mcp.md index 1c8ef74..bb9bed5 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -16,7 +16,7 @@ This guide covers installation, how classification and the progress bridge work, dotnet add package Andes.Extensions.AI.Mcp ``` -Installing the package brings in the core `Andes.Extensions.AI` package (>= 0.3.0) and [`ModelContextProtocol.Core`](https://www.nuget.org/packages/ModelContextProtocol.Core) (>= 1.4.1). Apps that build MCP clients or servers with the full `ModelContextProtocol` package are unaffected — the satellite only needs the Core types. +Installing the package brings in the core `Andes.Extensions.AI` package (>= 0.5.0) and [`ModelContextProtocol.Core`](https://www.nuget.org/packages/ModelContextProtocol.Core) (>= 2.1.0). Apps that build MCP clients or servers with the full `ModelContextProtocol` package are unaffected — the satellite only needs the Core types. ## Quickstart @@ -53,14 +53,11 @@ await foreach (var update in client.GetStreamingResponseAsync("prompt", chatOpti A typical rendering while a progress-reporting MCP tool runs: ```text -[RequestStarted] Starting request -[Thinking] Thinking... [ToolInvoking] Calling GitHub MCP [ToolProgress] step 1 of 3 [ToolProgress] step 2 of 3 [ToolProgress] step 3 of 3 [ToolCompleted] search_issues completed -[Thinking] Thinking... [RequestCompleted] Request completed ``` diff --git a/docs/ui.md b/docs/ui.md index 53304d4..071c3ad 100644 --- a/docs/ui.md +++ b/docs/ui.md @@ -16,7 +16,7 @@ This guide covers installation, the two DTO layers, the mapper and reducer, the dotnet add package Andes.Extensions.AI.UI ``` -Installing the package brings in the core `Andes.Extensions.AI` package (>= 0.3.0) and `Microsoft.Extensions.AI.Abstractions` — nothing else. The package does not reference the [MCP](mcp.md) or [Agent](agents.md) satellites; it doesn't need to, because `ToolKind` (the `Unknown`/`Function`/`McpTool`/`Agent` badge every activity carries) already lives in core, shared by every satellite. +Installing the package brings in the core `Andes.Extensions.AI` package (>= 0.5.0) and `Microsoft.Extensions.AI.Abstractions` — nothing else. The package does not reference the [MCP](mcp.md) or [Agent](agents.md) satellites; it doesn't need to, because `ToolKind` (the `Unknown`/`Function`/`McpTool`/`Agent` badge every activity carries) already lives in core, shared by every satellite. ## Quickstart @@ -63,7 +63,7 @@ The contract is deliberately split into two shapes, mirroring a common streaming ```text AssistantUiEventKind -├── Status — Message is the new request-level status line ("Thinking…") +├── Status — Message is the new request-level status line ("Reasoning…") ├── ActivityStarted — ScopeId/ParentScopeId/Depth/ToolKind/DisplayName/Source describe the new card ├── ActivityProgress — ScopeId targets the owning activity; Message/Progress/ProgressTotal are the sub-status ├── ActivityCompleted — ScopeId targets the activity; DurationSeconds is set @@ -74,6 +74,8 @@ AssistantUiEventKind `ScopeId`/`ParentScopeId`/`Depth` are carried over unchanged from the core's `ChatProgressUpdate`, so the same tree-reconstruction rules from [Getting started](getting-started.md#consume-streaming-progress) and the [Progress Board example](examples/progress-board.md#the-progress-board-hierarchy-from-the-event-contract) apply here — this contract just makes them serializable. +Every request-level kind collapses to `Status` with the message passed through — the middleware's detected `Reasoning` and final `RequestCompleted`, and equally any update the application constructs itself with `ChatProgressUpdate.CreateRequestStarted()`/`CreateReasoning()` and prepends via `ToResponseUpdate()` ([Getting started](getting-started.md#emit-request-level-statuses-yourself)); the mapper does not care who emitted it. Note that the middleware no longer opens requests with a synthetic status of its own (since core v0.5), so `AssistantStatus` stays `null` until the first request-level event arrives — a UI that wants a status line the instant the request starts prepends its own, exactly as both sample apps do. + ### `AssistantStatusSnapshot` — the render shape `AssistantStatusSnapshot` is the folded result: an immutable value with the current `AssistantStatus` line, the overall `Phase` (`ActivityState.Running`/`Completed`/`Failed`), the answer `Text` accumulated so far, the final `Usage`, and — the interesting part — `Activities`, an already-nested `IReadOnlyList`: diff --git a/releases/v0.5.0.md b/releases/v0.5.0.md new file mode 100644 index 0000000..0af77a8 --- /dev/null +++ b/releases/v0.5.0.md @@ -0,0 +1,33 @@ +# 0.5.0 + +**Tag:** `v0.5.0` · **Date:** 2026-08-06 + +All changes since [`v0.4.0`](v0.4.0.md). + +The middleware stops guessing: the auto-emitted "Starting request"/"Thinking..." statuses are gone, replaced by a **detection-driven `Reasoning` status** that fires only when the model actually streams reasoning content, plus **public factories** so applications emit request-level statuses themselves — in the exact synthetic shape the middleware uses. A new sample runs the whole pipeline over the Azure OpenAI Responses API with stable packages only. + +## Breaking changes + +- **`ChatProgressKind.Thinking` is renamed to `ChatProgressKind.Reasoning`** (`Andes.Extensions.AI/Progress/ChatProgressKind.cs`; the underlying value `1` is preserved). Consumers switching on the enum rename `Thinking` → `Reasoning`; persisted numeric values are unaffected. +- **The middleware no longer auto-emits request-level statuses.** Previous versions opened every request (both call styles) with `RequestStarted` ("Starting request") and `Thinking` ("Thinking..."), and re-emitted "Thinking..." after each tool round-trip. Those emissions are removed — `RequestTracker.EmitRequestStarted`/`EmitThinking` are deleted, and `AdvanceIteration` now only advances the turn counter and re-arms reasoning detection — so the first in-band event of a tool-calling request is now the `ToolInvoking` header. + - **Migration:** UIs that relied on the automatic opening status prepend their own — `yield return ChatProgressUpdate.CreateRequestStarted().ToResponseUpdate();` ahead of streaming the tracked response (see Added below) — or handle the absence (the UI package's `AssistantStatusSnapshot.AssistantStatus` now stays `null` until the first request-level event arrives). + +## Added + +- **Detection-driven `Reasoning` status.** `ToolTrackingChatClient.Inspect` watches the stream for `Microsoft.Extensions.AI.TextReasoningContent` and calls the new `RequestTracker.OnReasoningDetected()`, which emits **one** `Reasoning` event per model turn (message "Reasoning...", root scope, depth 0), re-armed after each tool round-trip. Updates are inspected before they are forwarded, so the status enters the channel **ahead of** the update carrying the reasoning content. Detection is content-based and therefore provider-agnostic: the OpenAI Responses API produces `TextReasoningContent` today, while plain Chat Completions never streams reasoning — chat pipelines simply never see the status. Non-streaming `GetResponseAsync` mirrors this post-hoc: if any response message contains `TextReasoningContent`, at most one `Reasoning` event is raised (observers only — turns are indistinguishable in an aggregated response). The event never carries the reasoning text itself (privacy invariant unchanged). +- **Developer-owned request statuses.** Public static factories `ChatProgressUpdate.CreateRequestStarted(string? message = null)` (default "Starting request") and `ChatProgressUpdate.CreateReasoning(string? message = null)` (default "Reasoning..."), both stamped with the new public constant `ChatProgressUpdate.ExternalScopeId` (`"scope-external"` — never collides with the middleware's per-request scope identifiers), depth 0, and the current UTC time. The new extension `ChatProgressUpdateExtensions.ToResponseUpdate()` (`Andes.Extensions.AI/Progress/ChatProgressUpdateExtensions.cs`) wraps an update into a role-less `ChatResponseUpdate` carrying a single `ChatProgressContent` — the exact synthetic shape the middleware emits — so apps can prepend or interleave their own statuses into the stream a UI consumes (for example, ahead of `ToStatusSnapshotsAsync()`). Both sample apps demonstrate the prepend pattern in their `StreamTurn` local function. +- **New sample: `samples/Andes.Extensions.AI.Demo.Responses`.** A console chat over the **Azure OpenAI Responses API with stable packages only**: the plain `OpenAIClient` (stable `OpenAI` 2.12.0) targets the OpenAI-v1-compatible endpoint (`{endpoint}/openai/v1`) — the stable `Azure.AI.OpenAI` 2.1.0 has no Responses surface, which is why the plain-client route is used — and `GetResponsesClient().AsIChatClient(deployment)` adapts it to the tracked pipeline. `ChatOptions.Reasoning = new ReasoningOptions { Output = ReasoningOutput.Summary }` makes reasoning summaries stream back as `TextReasoningContent`, lighting up the live `Reasoning` status. Requires a reasoning-capable deployment (gpt-5 family / o-series); builds with `NoWarn` `OPENAI001` because the Responses surface is still `[Experimental]` in OpenAI 2.12.0. Registered in `Andes.Extensions.slnx`; `IsPackable=false` like the existing sample — it never ships to NuGet. +- **New optional integration setting `AzureOpenAI:ResponsesDeployment`**, gating the new `[SkippableFact]` suite `tests/Andes.Extensions.AI.Integration.Test/ResponsesStreamingIntegrationTests.cs`: end to end against the Responses API, it asserts that no `RequestStarted` is auto-emitted, that exactly one "Reasoning..." status is raised for a single-turn request, and that the status precedes the first reasoning content. When the setting is absent those tests skip cleanly while the chat-deployment tests still run. +- **Root README badges** — NuGet version badges for all four packages, the NuGet Publish workflow status, the MIT license, and .NET 10. + +## Changed + +- **Dependency pins** (`Directory.Packages.props`): `Microsoft.Agents.AI` 1.16.0 → **1.17.0** (the Agent satellite's new floor) and `ModelContextProtocol`/`ModelContextProtocol.Core` 2.0.0 → **2.1.0** (the MCP satellite now floors Core `>= 2.1.0`; the full `ModelContextProtocol` package remains test-and-demo-only). A new explicit pin **`OpenAI` 2.12.0** is consumed only by the Responses sample and the core integration test project — nothing shipped to NuGet references it. +- **The interactive demo (`samples/Andes.Extensions.AI.Demo`) prepends `ChatProgressUpdate.CreateRequestStarted().ToResponseUpdate()`** in its `StreamTurn` tee — outside the recording loop, so the Live header lights up immediately while the synthetic update never enters chat history or the usage report. +- **`Andes.Extensions.AI.UI`: doc-comment example strings only** — "Thinking…" → "Reasoning…" in `AssistantStatusSnapshot`, `AssistantUiEventKind`, and the shipped `typescript/andes-assistant-ui.ts`. **Zero behavior changes**: request-level kinds already collapse to `AssistantUiEventKind.Status` with the message passed through, so both the renamed `Reasoning` and developer-prepended `RequestStarted` updates flow through the existing contract unchanged. +- **Docs updated for the new status model**: [Getting started](../docs/getting-started.md) (kinds, transcripts, and a new [Emit request-level statuses yourself](../docs/getting-started.md#emit-request-level-statuses-yourself) section), [Architecture](../docs/architecture.md) (the detection design and the non-streaming post-hoc note), the [MCP](../docs/mcp.md) and [Agent](../docs/agents.md) transcripts, [UI support](../docs/ui.md), and the [Progress Board example](../docs/examples/progress-board.md). The root README gains "Reasoning detection" and "Developer-owned request statuses" bullets, an "Emit your own statuses" section, and the [Responses sample README](../samples/Andes.Extensions.AI.Demo.Responses/README.md). +- **All four packages version in lockstep at 0.5.0**; the satellites depend on core `>= 0.5.0`. + +## Verification + +New unit coverage pins the behavior change: `ReasoningDetectionTests` (one `Reasoning` status per turn, ordered ahead of the reasoning content; re-emission across a tool round-trip; no request-level statuses when nothing streams reasoning; non-streaming observers notified once) and `ChatProgressUpdateFactoryTests` (factory defaults and custom messages, `ExternalScopeId` stamping, the `ToResponseUpdate()` shape, and its `null` guard) in the core suite, plus `ToStatusSnapshotsAsync_DevPrependedRequestStarted_SetsAssistantStatus` in the UI suite proving a developer-prepended status drives `AssistantStatus` through the unchanged contract. The pre-existing streaming, non-streaming, and Azure OpenAI integration tests are updated to assert the **absence** of auto-emitted request-level statuses, and the new `ResponsesStreamingIntegrationTests` exercises detection against a real reasoning-capable deployment. From be673e6f46efed31edcf1130298da82e632a77d2 Mon Sep 17 00:00:00 2001 From: Rodrigo Rojas Date: Thu, 6 Aug 2026 01:25:43 -0400 Subject: [PATCH 7/8] Refactor Responses Demo to Enhance Reasoning Output and Status Management - Updated reasoning output settings to use Full verbosity in Azure OpenAI Responses API. - Improved handling of reasoning status updates, ensuring accurate tracking of reasoning phases. - Enhanced user interface to display reasoning text and duration in final output. - Modified request status handling to use a custom message for better clarity. - Updated README documentation to reflect changes in reasoning behavior and output. - Added unit tests to verify reasoning text accumulation and status emission order. - Refactored integration tests to ensure compliance with new reasoning handling logic. --- .claude/CLAUDE.md | 6 +- .mcp.json | 8 -- .../AssistantStatusReducer.cs | 6 + .../AssistantStatusSnapshot.cs | 7 + Andes.Extensions.AI.UI/AssistantUiEvent.cs | 10 +- .../AssistantUiEventKind.cs | 7 + .../ChatResponseUiExtensions.cs | 15 ++- Andes.Extensions.AI.UI/README.md | 4 +- .../typescript/andes-assistant-ui.ts | 8 +- .../Internal/RequestTracker.cs | 47 +++++++ .../Progress/ChatProgressKind.cs | 21 ++- .../Progress/ChatProgressUpdate.cs | 48 +++---- .../Progress/ChatProgressUpdateExtensions.cs | 2 +- Andes.Extensions.AI/ToolTrackingChatClient.cs | 36 ++--- README.md | 10 +- docs/architecture.md | 8 +- docs/examples/progress-board.md | 6 +- docs/getting-started.md | 15 ++- docs/ui.md | 13 +- releases/v0.5.0.md | 27 ++-- .../Program.cs | 41 ++++-- .../README.md | 17 ++- .../StatusRenderer.cs | 40 +++++- samples/Andes.Extensions.AI.Demo/Program.cs | 13 +- samples/Andes.Extensions.AI.Demo/README.md | 4 +- .../ResponsesStreamingIntegrationTests.cs | 18 ++- .../StreamingIntegrationTests.cs | 4 +- .../AssistantStatusReducerTests.cs | 46 +++++++ .../AssistantUiJsonContextTests.cs | 23 ++++ .../ChatResponseUiExtensionsTests.cs | 94 ++++++++++++- .../ChatProgressUpdateFactoryTests.cs | 38 ++---- .../NonStreamingTests.cs | 2 +- .../ReasoningDetectionTests.cs | 123 +++++++++++++++++- .../StreamingProgressTests.cs | 4 +- 34 files changed, 595 insertions(+), 176 deletions(-) diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 50ea080..bfb1e11 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -8,7 +8,7 @@ Project memory for **Andes.Extensions.AI** — a C#/.NET solution that ships **` - Tracks every `AIFunction` invocation made by the assistant by wrapping tools in an internal `TrackingAIFunction : DelegatingAIFunction` (request-scoped; the caller's `ChatOptions` is cloned, never mutated). - Emits progress statuses ("Calling {Tool} Tool" headers with tool-reported subheaders like "Extracting…") **in-band** as `ChatProgressContent` items merged into the streaming response via a `Channel` pump, and **out-of-band** to `IChatProgressObserver` implementations. Tool authors report subheaders through the ambient `ChatProgress.Report(...)` API (AsyncLocal; safe no-op outside a tracked request). -- **Request-level statuses are detection-driven (v0.5)**: the middleware never auto-emits `RequestStarted` or a synthetic "Thinking" — the first in-band event of a tool request is `ToolInvoking`. `ChatProgressKind.Reasoning` (renamed from `Thinking` in 0.5.0) is emitted **once per model turn** when `TextReasoningContent` is detected in the stream (OpenAI Responses API today; content-based, so any provider surfacing it works), re-armed by `AdvanceIteration()` after each tool round-trip, mirrored post-hoc (once per request, observers-only) for non-streaming responses, and never carries reasoning text. Developers emit their own request-level statuses via the public factories `ChatProgressUpdate.CreateRequestStarted(...)`/`CreateReasoning(...)` (stamped `ChatProgressUpdate.ExternalScopeId`) wrapped with `ToResponseUpdate()` into the same synthetic shape the middleware writes. +- **Request-level statuses are detection-driven (v0.5)**: the middleware never auto-emits a request-start or synthetic "Thinking" status — the first in-band event of a tool request is `ToolInvoking`. `ChatProgressKind.Reasoning` (renamed from `Thinking` in 0.5.0) is emitted **once per model turn** when `TextReasoningContent` is detected in the stream (OpenAI Responses API today; content-based, so any provider surfacing it works), re-armed by `AdvanceIteration()` after each tool round-trip, mirrored post-hoc (once per request, observers-only) for non-streaming responses, and never carries reasoning text; it has **no public factory** — apps can never emit it. A matching `ChatProgressKind.ReasoningCompleted` (value 8, message "Reasoning completed") closes each detected turn at most once — raised when the first answer text or function call follows the reasoning, or at stream end (in-band, before the trailing `RequestCompleted`), with the elapsed reasoning time in `ChatProgressUpdate.Duration` (streaming only; `null` on the post-hoc mirror, where observers get a balanced pair) and never the reasoning text. `ChatProgressKind.Custom` (renamed from `RequestStarted` in 0.5.0; numeric value 0 preserved) is the developer-constructed status the middleware never emits: built via the sole public factory `ChatProgressUpdate.CreateCustom(message)` (message **required** — `ArgumentException.ThrowIfNullOrEmpty`; stamped `ChatProgressUpdate.ExternalScopeId`, depth 0, current UTC time) and wrapped with `ToResponseUpdate()` into the same synthetic shape the middleware writes. The UI package surfaces the reasoning summary *text* separately: `AssistantUiEventKind.ReasoningDelta` events (one per non-empty in-band `TextReasoningContent`; encrypted-only items skipped) accumulate into `AssistantStatusSnapshot.ReasoningText` (verbatim across tool round-trips; TypeScript mirror updated) — sourced only from in-band model content, never from progress metadata. - Records token usage (input/output/total, model id, provider name from `ChatClientMetadata`) per request, per model turn (streaming), and per tool-call scope — including usage reported inside tools (`ChatProgress.ReportUsage`) and totals of nested tracked pipelines (AsyncLocal ambient scope tree) — rolled up into a `ChatUsageReport` (streaming: final `UsageReportContent` update; non-streaming: `ChatResponse.AdditionalProperties["andes.ai.usage_report"]`). - Numeric progress is first-class: `ChatProgress.Report(status, progress, progressTotal)` and `IChatProgressReporter.Report(status, progress, progressTotal)` (default interface method) populate `ChatProgressUpdate.Progress`/`ProgressTotal` (doubles, nullable). - **Nested tool scopes**: `ChatProgress.BeginToolScope(descriptor, owner)` (returns a public `ChatProgressToolScope` handle, `Fail()`/`Dispose()`) opens a child scope on the ambient tracker so nested operations render as child activity cards and appear as child `ToolCallUsage` entries. Dedup is by scope **owner identity** (`ToolScope.IsOwnedBy` — reference equality plus the `GetService` probe chain): when the outer tracker already opened the scope for the same function, the call returns an inactive no-op. Both satellite wrappers (`AgentTrackingAIFunction`, `McpTrackingAIFunction`) call it in `InvokeCoreAsync`, so an agent/MCP tool nested inside another agent or invoked directly inside a tool body gets its own child card; a recursive self-invocation stays flat (documented limitation). CallId is taken from `FunctionInvokingChatClient.CurrentContext` only when the context's `Function` IS the owner. Static-only by design — not on `IChatProgressReporter` (captured reporters may run off-flow). @@ -33,7 +33,7 @@ Privacy invariant: progress events and reports never carry prompt content, tool - `tests\Andes.Extensions.AI.Agent.Integration.Test\` — Agent satellite Azure OpenAI tests; links `AzureOpenAIFixture.cs` and the shared gitignored `appsettings.integration.json`; the inner agent runs over a raw (untracked) chat client built from the fixture settings. - `tests\Andes.Extensions.AI.TestMcpServer\` — stdio MCP console server ("Andes Test MCP": `echo`, `add`, `count_down`) used by the MCP integration tests via `ProjectReference` + `dotnet `. - `samples\Andes.Extensions.AI.Demo\` — interactive Spectre.Console chat exercising all four packages over Azure OpenAI Chat Completions (gitignored `appsettings.json`, copy the sample file; `samples\Directory.Build.props` makes samples non-packable). -- `samples\Andes.Extensions.AI.Demo.Responses\` — sibling demo over the **Azure OpenAI Responses API** with stable packages only: plain `OpenAIClient` against the OpenAI-v1-compatible endpoint (`{endpoint}/openai/v1`), `GetResponsesClient().AsIChatClient(deployment)`, `ChatOptions.Reasoning = Summary`; needs a reasoning-capable deployment and `NoWarn OPENAI001` (the Responses surface is still `[Experimental]` in OpenAI 2.12). +- `samples\Andes.Extensions.AI.Demo.Responses\` — sibling demo over the **Azure OpenAI Responses API** with stable packages only: plain `OpenAIClient` against the OpenAI-v1-compatible endpoint (`{endpoint}/openai/v1`), `GetResponsesClient().AsIChatClient(deployment)`, `ChatOptions.Reasoning` with `Output = ReasoningOutput.Full` (**not** `Summary` — M.E.AI.OpenAI 10.8.3 maps it to summary verbosity "concise", which gpt-5-series deployments reject; `Full` maps to "detailed"); needs a reasoning-capable deployment and `NoWarn OPENAI001` (the Responses surface is still `[Experimental]` in OpenAI 2.12). The demo renders a dim live tail of `AssistantStatusSnapshot.ReasoningText` under the status header. - `docs\` — developer documentation (getting-started, architecture, mcp, agents, ui). - `releases\` — per-release notes (`v{version}.md`, matching the release-tag convention); a new file is required for every version bump. - Build infrastructure: `Directory.Build.props` (warnings as errors, C# 14, deterministic builds, XML docs required), `Directory.Packages.props` (**central package management — all versions live here**), `global.json` (SDK pin), `.editorconfig` (style rules; `CA2007` is an error in the library, off in tests via `tests\.editorconfig`). @@ -129,7 +129,7 @@ The full guidelines live in `.claude/rules/` and **load automatically when you e `.claude/settings.json` sets `enableAllProjectMcpServers: true`, so the servers configured in `@.mcp.json` are available. Use them when relevant: - **`microsoft-learn`** — Ground .NET/Azure answers in official Microsoft Learn docs. Before answering a version-specific .NET, Microsoft.Extensions.AI, Agent Framework, or Azure question, query it (`microsoft_docs_search` → `microsoft_code_sample_search` → `microsoft_docs_fetch`) instead of relying on memory. -- **`terraform`** — infrastructure-as-code, if deployment automation is added. +- **`context7`** — current documentation for libraries and frameworks outside learn.microsoft.com (and for OSS sources like dotnet/extensions); resolve the library id first, then query. ## Delegation rules diff --git a/.mcp.json b/.mcp.json index cfb87fe..b2c3068 100644 --- a/.mcp.json +++ b/.mcp.json @@ -4,14 +4,6 @@ "type": "http", "url": "https://learn.microsoft.com/api/mcp" }, - "terraform": { - "command": "docker", - "args": ["run", "-i", "--rm", "hashicorp/terraform-mcp-server"] - }, - "angular-cli": { - "command": "npx", - "args": ["-y", "@angular/cli", "mcp"] - }, "context7": { "command": "npx", "args": ["-y", "@upstash/context7-mcp"] diff --git a/Andes.Extensions.AI.UI/AssistantStatusReducer.cs b/Andes.Extensions.AI.UI/AssistantStatusReducer.cs index 4d2b13f..cffa14f 100644 --- a/Andes.Extensions.AI.UI/AssistantStatusReducer.cs +++ b/Andes.Extensions.AI.UI/AssistantStatusReducer.cs @@ -28,6 +28,7 @@ public sealed class AssistantStatusReducer private string? _assistantStatus; private ActivityState _phase = ActivityState.Running; private string? _text; + private string? _reasoningText; private UsageSummary? _usage; /// @@ -78,6 +79,10 @@ public AssistantStatusSnapshot Apply(AssistantUiEvent uiEvent) _text = (_text ?? string.Empty) + uiEvent.Text; break; + case AssistantUiEventKind.ReasoningDelta: + _reasoningText = (_reasoningText ?? string.Empty) + uiEvent.Text; + break; + case AssistantUiEventKind.Finished: _phase = ActivityState.Completed; _usage = uiEvent.Usage; @@ -121,6 +126,7 @@ private AssistantStatusSnapshot BuildSnapshot() Phase = _phase, Activities = [.. _roots.Select(root => root.ToImmutable())], Text = _text, + ReasoningText = _reasoningText, Usage = _usage, }; } diff --git a/Andes.Extensions.AI.UI/AssistantStatusSnapshot.cs b/Andes.Extensions.AI.UI/AssistantStatusSnapshot.cs index 1b7810b..ed272d7 100644 --- a/Andes.Extensions.AI.UI/AssistantStatusSnapshot.cs +++ b/Andes.Extensions.AI.UI/AssistantStatusSnapshot.cs @@ -33,6 +33,13 @@ public sealed record AssistantStatusSnapshot /// public string? Text { get; init; } + /// + /// Gets the model's reasoning summary text accumulated so far, when the provider streams + /// reasoning content (for example the OpenAI Responses API); otherwise . + /// Deltas accumulate verbatim across the whole request, including across tool round-trips. + /// + public string? ReasoningText { get; init; } + /// /// Gets the total token usage for the request, set once it finishes. /// diff --git a/Andes.Extensions.AI.UI/AssistantUiEvent.cs b/Andes.Extensions.AI.UI/AssistantUiEvent.cs index 056a3b0..b06ea27 100644 --- a/Andes.Extensions.AI.UI/AssistantUiEvent.cs +++ b/Andes.Extensions.AI.UI/AssistantUiEvent.cs @@ -9,7 +9,9 @@ namespace Andes.Extensions.AI; /// Fold a sequence of these into an with /// , or consume them directly. Project them from a tracked chat /// stream with ChatResponseUiExtensions.ToUiEventsAsync. Like the core progress contract, -/// events never carry prompt content, tool arguments, or tool results. +/// events never carry prompt content, tool arguments, or tool results. Reasoning summary text +/// appears only on events, sourced from in-band +/// model content — never from progress metadata. /// public sealed record AssistantUiEvent { @@ -76,7 +78,8 @@ public sealed record AssistantUiEvent public double? DurationSeconds { get; init; } /// - /// Gets the answer text chunk for a event. + /// Gets the answer text chunk for a event, or the + /// reasoning summary chunk for a event. /// public string? Text { get; init; } @@ -87,7 +90,8 @@ public sealed record AssistantUiEvent /// /// Gets the time at which the underlying progress event was raised. Only meaningful for - /// status and activity events; and + /// status and activity events; , + /// , and /// events, which have no source progress event, /// leave it at its default. Consume events in stream order rather than sorting by this value. /// diff --git a/Andes.Extensions.AI.UI/AssistantUiEventKind.cs b/Andes.Extensions.AI.UI/AssistantUiEventKind.cs index 936dae1..7505835 100644 --- a/Andes.Extensions.AI.UI/AssistantUiEventKind.cs +++ b/Andes.Extensions.AI.UI/AssistantUiEventKind.cs @@ -36,6 +36,13 @@ public enum AssistantUiEventKind /// TextDelta, + /// + /// A chunk of the model's reasoning summary text, carried by . + /// Sourced from in-band on the tracked + /// stream; encrypted-only reasoning items (empty text) are never surfaced. + /// + ReasoningDelta, + /// /// The request finished; the total token is available. /// diff --git a/Andes.Extensions.AI.UI/ChatResponseUiExtensions.cs b/Andes.Extensions.AI.UI/ChatResponseUiExtensions.cs index a7497bc..2547725 100644 --- a/Andes.Extensions.AI.UI/ChatResponseUiExtensions.cs +++ b/Andes.Extensions.AI.UI/ChatResponseUiExtensions.cs @@ -18,8 +18,8 @@ public static class ChatResponseUiExtensions { /// /// Translates a tracked streaming response into a stream of - /// deltas — one per progress flush, per answer-text chunk, and one final - /// event. + /// deltas — one per progress flush, per answer-text chunk, per reasoning-summary chunk, and + /// one final event. /// /// The tracked streaming response. /// A token to cancel enumeration. @@ -54,6 +54,17 @@ public static async IAsyncEnumerable ToUiEventsAsync( case UsageReportContent usage: yield return ToFinishedEvent(usage.Report); break; + + // Reasoning summary text is model content that already flows in-band; surface it + // as its own delta kind. Encrypted-only items (empty text, ProtectedData set) are + // skipped — they carry nothing renderable. + case TextReasoningContent { Text.Length: > 0 } reasoning: + yield return new AssistantUiEvent + { + Kind = AssistantUiEventKind.ReasoningDelta, + Text = reasoning.Text, + }; + break; } } diff --git a/Andes.Extensions.AI.UI/README.md b/Andes.Extensions.AI.UI/README.md index d1dff7b..c7a2365 100644 --- a/Andes.Extensions.AI.UI/README.md +++ b/Andes.Extensions.AI.UI/README.md @@ -2,7 +2,7 @@ UI status contract for [Andes.Extensions.AI](https://www.nuget.org/packages/Andes.Extensions.AI) tool tracking. Turns the tracked chat stream into a serializable, cross-language shape a UI can render — the same contract in C# (console, Blazor WebAssembly) and TypeScript (any SPA). Adds two things on top of the core middleware: -- **A serializable status contract** — flat per-flush `AssistantUiEvent` deltas and a folded `AssistantStatusSnapshot` (the assistant's status line plus a hierarchy of `AssistantActivity` cards — functions, MCP tools, and agents, each with sub-statuses, nested children, and token usage). Each activity carries a clean `DisplayName` plus a separate `Kind` badge, so the kind word is never repeated in the label. +- **A serializable status contract** — flat per-flush `AssistantUiEvent` deltas and a folded `AssistantStatusSnapshot` (the assistant's status line plus a hierarchy of `AssistantActivity` cards — functions, MCP tools, and agents, each with sub-statuses, nested children, and token usage). Each activity carries a clean `DisplayName` plus a separate `Kind` badge, so the kind word is never repeated in the label. When the model streams reasoning summaries, they arrive as `ReasoningDelta` events and accumulate into `AssistantStatusSnapshot.ReasoningText`. - **A mapper and reducer** — `ToUiEventsAsync()`/`ToStatusSnapshotsAsync()` project the in-band `ChatProgressContent`/`UsageReportContent` stream into the contract; `AssistantStatusReducer` folds events into snapshots. A matching TypeScript `foldAssistantEvents` ships in the package (`typescript/andes-assistant-ui.ts`) so a SPA reconstructs the same tree. ## Install @@ -44,6 +44,6 @@ For an HTTP surface, stream `ToUiEventsAsync()` instead and serialize each event - `DisplayName` is the raw function/server/agent name with no "Calling" prefix and no kind word appended; render it once and show `Kind` as a badge. The contract carries no pre-composed header strings, so labels localize cleanly. - `AssistantUiJsonContext` matches the TypeScript interface byte-for-byte: camelCase keys, string enum values (`"McpTool"`, `"Agent"`, …), and omitted `null`s. - Progress values from MCP servers are single-precision floats widened to `double`; format with a rounding specifier such as `"0.#"` before display. -- Privacy posture matches the core package: events and snapshots never carry prompt content, tool arguments, or tool results — only headers, statuses, names, and token counts. +- Privacy posture matches the core package: events and snapshots never carry prompt content, tool arguments, or tool results. Model outputs — the answer text and the reasoning summary — flow as in-band content on the stream itself; progress metadata never carries them. Full documentation lives in the [repository docs](https://github.com/Andes-Software-Solutions/Andes.Extensions.AI/tree/main/docs). diff --git a/Andes.Extensions.AI.UI/typescript/andes-assistant-ui.ts b/Andes.Extensions.AI.UI/typescript/andes-assistant-ui.ts index 912fd1d..d01e818 100644 --- a/Andes.Extensions.AI.UI/typescript/andes-assistant-ui.ts +++ b/Andes.Extensions.AI.UI/typescript/andes-assistant-ui.ts @@ -21,6 +21,7 @@ export type AssistantUiEventKind = | "ActivityCompleted" | "ActivityFailed" | "TextDelta" + | "ReasoningDelta" | "Finished"; /** The lifecycle state of the request or one of its activities. */ @@ -85,6 +86,8 @@ export interface AssistantStatusSnapshot { activities: AssistantActivity[]; /** The assistant's answer text accumulated so far. */ text?: string; + /** The model's reasoning summary text accumulated so far, when streamed. */ + reasoningText?: string; /** The total token usage for the request, set once it finishes. */ usage?: UsageSummary; } @@ -116,7 +119,7 @@ export interface AssistantUiEvent { progressTotal?: number; /** The elapsed seconds for a completion event, or the request duration for "Finished". */ durationSeconds?: number; - /** The answer text chunk for a "TextDelta" event. */ + /** The answer text chunk for a "TextDelta" event, or the reasoning summary chunk for "ReasoningDelta". */ text?: string; /** The token usage for a "Finished" event. */ usage?: UsageSummary; @@ -187,6 +190,9 @@ export function foldAssistantEvents( case "TextDelta": return { ...snapshot, text: (snapshot.text ?? "") + (event.text ?? "") }; + case "ReasoningDelta": + return { ...snapshot, reasoningText: (snapshot.reasoningText ?? "") + (event.text ?? "") }; + case "Finished": return { ...snapshot, phase: "Completed", usage: event.usage }; diff --git a/Andes.Extensions.AI/Internal/RequestTracker.cs b/Andes.Extensions.AI/Internal/RequestTracker.cs index 355e0bb..4eb2722 100644 --- a/Andes.Extensions.AI/Internal/RequestTracker.cs +++ b/Andes.Extensions.AI/Internal/RequestTracker.cs @@ -21,6 +21,8 @@ internal sealed class RequestTracker private int _scopeCounter; private int _iteration; private bool _reasoningAnnounced; + private bool _reasoningCompleted; + private long _reasoningStartTimestamp; private string? _lastResponseId; private string? _lastModelId; @@ -62,6 +64,8 @@ public void OnReasoningDetected() } _reasoningAnnounced = true; + _reasoningCompleted = false; + _reasoningStartTimestamp = Options.TimeProvider.GetTimestamp(); } Emit(new ChatProgressUpdate @@ -74,6 +78,48 @@ public void OnReasoningDetected() }); } + /// + /// Emits a single event closing the current + /// turn's reasoning, the first time non-reasoning content follows a detection (or the stream + /// ends). A no-op when no reasoning was detected this turn or the turn is already closed. + /// The event carries the elapsed reasoning time when is + /// — never the reasoning text. + /// + /// + /// The Reasoning/ReasoningCompleted pair ordering relies on the latch methods being called + /// from a single thread per request (the streaming pump, or the non-streaming request thread): + /// runs outside , so concurrent callers could invert the pair. + /// + /// + /// to stamp the elapsed time since detection on + /// ; for the non-streaming + /// post-hoc mirror, where elapsed time is meaningless and the duration stays . + /// + public void OnReasoningCompleted(bool measured = true) + { + long startTimestamp; + lock (_lock) + { + if (!_reasoningAnnounced || _reasoningCompleted) + { + return; + } + + _reasoningCompleted = true; + startTimestamp = _reasoningStartTimestamp; + } + + Emit(new ChatProgressUpdate + { + Kind = ChatProgressKind.ReasoningCompleted, + Message = "Reasoning completed", + ScopeId = RootScope.ScopeId, + Depth = 0, + Timestamp = Now(), + Duration = measured ? Options.TimeProvider.GetElapsedTime(startTimestamp) : null, + }); + } + public ToolScope BeginToolScope( ToolDescriptor descriptor, string? callId, @@ -228,6 +274,7 @@ public void AdvanceIteration() { _iteration++; _reasoningAnnounced = false; + _reasoningCompleted = false; } } diff --git a/Andes.Extensions.AI/Progress/ChatProgressKind.cs b/Andes.Extensions.AI/Progress/ChatProgressKind.cs index b409bc3..edca183 100644 --- a/Andes.Extensions.AI/Progress/ChatProgressKind.cs +++ b/Andes.Extensions.AI/Progress/ChatProgressKind.cs @@ -6,18 +6,18 @@ namespace Andes.Extensions.AI; public enum ChatProgressKind { /// - /// A request is starting. Never emitted by the middleware; construct one with - /// to announce your own - /// request start outside the tracked pipeline. + /// A developer-constructed status carrying an application-supplied message. Never emitted by + /// the middleware; construct one with to + /// announce your own request-level status outside the tracked pipeline. /// - RequestStarted = 0, + Custom = 0, /// /// The model is producing reasoning output. Emitted once per model turn when reasoning content /// () is detected on the response — /// for example from the OpenAI Responses API. The event never carries the reasoning text - /// itself. Also constructible via - /// for emission outside the middleware. + /// itself; the library provides no factory for it — the middleware raises it when it detects + /// reasoning content. /// Reasoning = 1, @@ -51,4 +51,13 @@ public enum ChatProgressKind /// observers only; the accompanying usage report may be partial. /// RequestFailed = 7, + + /// + /// The model finished producing reasoning output for the current model turn — raised when the + /// first answer text or function call follows detected reasoning, or when the stream ends. + /// Emitted at most once per turn (a later reasoning burst in the same turn is not re-announced); + /// carries the elapsed reasoning time in when the + /// request streams, and never the reasoning text itself. + /// + ReasoningCompleted = 8, } diff --git a/Andes.Extensions.AI/Progress/ChatProgressUpdate.cs b/Andes.Extensions.AI/Progress/ChatProgressUpdate.cs index f3a9d0f..244756e 100644 --- a/Andes.Extensions.AI/Progress/ChatProgressUpdate.cs +++ b/Andes.Extensions.AI/Progress/ChatProgressUpdate.cs @@ -12,7 +12,7 @@ public sealed class ChatProgressUpdate { /// /// The well-known scope identifier stamped on updates created outside a tracked request via - /// and . + /// . /// The middleware's own per-request identifiers ("scope-1", "scope-2", …) never collide with it. /// public const string ExternalScopeId = "scope-external"; @@ -74,7 +74,10 @@ public sealed class ChatProgressUpdate /// /// Gets the elapsed duration for completion events /// (, , - /// and ). + /// , and + /// ). For a reasoning completion it measures + /// first detection to the first non-reasoning content of the turn (or the end of the stream), + /// and is on the non-streaming post-hoc mirror. /// public TimeSpan? Duration { get; init; } @@ -102,49 +105,32 @@ public sealed class ChatProgressUpdate public double? ProgressTotal { get; init; } /// - /// Creates a request-level update for emitting - /// outside the middleware — for example, prepended to the update stream a UI consumes so a - /// status line shows before the first tracked event arrives. + /// Creates a request-level update carrying an + /// application-supplied status message, for emitting outside the middleware — for example, + /// prepended to the update stream a UI consumes so a status line shows before the first + /// tracked event arrives. /// - /// The status text, or to use "Starting request". + /// The status text. /// An update stamped with , depth 0, and the current UTC time. /// /// The middleware never emits this kind itself. Construct the update with an object initializer /// instead when a custom or is needed. /// + /// is . + /// is empty. /// /// - /// ChatResponseUpdate started = ChatProgressUpdate.CreateRequestStarted().ToResponseUpdate(); + /// ChatResponseUpdate started = ChatProgressUpdate.CreateCustom("Starting request").ToResponseUpdate(); /// /// - public static ChatProgressUpdate CreateRequestStarted(string? message = null) + public static ChatProgressUpdate CreateCustom(string message) { - return new ChatProgressUpdate - { - Kind = ChatProgressKind.RequestStarted, - Message = message ?? "Starting request", - ScopeId = ExternalScopeId, - Depth = 0, - Timestamp = TimeProvider.System.GetUtcNow(), - }; - } + ArgumentException.ThrowIfNullOrEmpty(message); - /// - /// Creates a request-level update for emitting outside - /// the middleware, mirroring the event the middleware raises when it detects reasoning content. - /// - /// The status text, or to use "Reasoning...". - /// An update stamped with , depth 0, and the current UTC time. - /// - /// Construct the update with an object initializer instead when a custom - /// or is needed. - /// - public static ChatProgressUpdate CreateReasoning(string? message = null) - { return new ChatProgressUpdate { - Kind = ChatProgressKind.Reasoning, - Message = message ?? "Reasoning...", + Kind = ChatProgressKind.Custom, + Message = message, ScopeId = ExternalScopeId, Depth = 0, Timestamp = TimeProvider.System.GetUtcNow(), diff --git a/Andes.Extensions.AI/Progress/ChatProgressUpdateExtensions.cs b/Andes.Extensions.AI/Progress/ChatProgressUpdateExtensions.cs index ca4354c..d905cb2 100644 --- a/Andes.Extensions.AI/Progress/ChatProgressUpdateExtensions.cs +++ b/Andes.Extensions.AI/Progress/ChatProgressUpdateExtensions.cs @@ -20,7 +20,7 @@ public static class ChatProgressUpdateExtensions /// /// async IAsyncEnumerable<ChatResponseUpdate> StreamTurn() /// { - /// yield return ChatProgressUpdate.CreateRequestStarted().ToResponseUpdate(); + /// yield return ChatProgressUpdate.CreateCustom("Starting request").ToResponseUpdate(); /// /// await foreach (ChatResponseUpdate update in client.GetStreamingResponseAsync(history, options)) /// { diff --git a/Andes.Extensions.AI/ToolTrackingChatClient.cs b/Andes.Extensions.AI/ToolTrackingChatClient.cs index 607b23a..57d260d 100644 --- a/Andes.Extensions.AI/ToolTrackingChatClient.cs +++ b/Andes.Extensions.AI/ToolTrackingChatClient.cs @@ -22,7 +22,13 @@ namespace Andes.Extensions.AI; /// is explicitly enabled. /// /// -public sealed class ToolTrackingChatClient : DelegatingChatClient +/// +/// Initializes a new instance of the class. +/// +/// The inner client to delegate to. +/// The tracking options, or to use defaults. +/// is . +public sealed class ToolTrackingChatClient(IChatClient innerClient, ToolTrackingOptions? options = null) : DelegatingChatClient(innerClient) { /// /// The key under which the is attached to @@ -31,19 +37,7 @@ public sealed class ToolTrackingChatClient : DelegatingChatClient /// public const string UsageReportPropertyName = "andes.ai.usage_report"; - private readonly ToolTrackingOptions _options; - - /// - /// Initializes a new instance of the class. - /// - /// The inner client to delegate to. - /// The tracking options, or to use defaults. - /// is . - public ToolTrackingChatClient(IChatClient innerClient, ToolTrackingOptions? options = null) - : base(innerClient) - { - _options = options ?? new ToolTrackingOptions(); - } + private readonly ToolTrackingOptions _options = options ?? new ToolTrackingOptions(); /// public override async Task GetResponseAsync( @@ -80,10 +74,12 @@ public override async Task GetResponseAsync( } // Post-hoc parity with the streaming path: turns are indistinguishable in an aggregated - // response, so at most one Reasoning event is raised per request, observers-only. + // response, so at most one Reasoning/ReasoningCompleted pair is raised per request, + // observers-only. Unmeasured: elapsed time since a post-hoc detection is meaningless. if (response.Messages.SelectMany(static message => message.Contents).OfType().Any()) { tracker.OnReasoningDetected(); + tracker.OnReasoningCompleted(measured: false); } ChatUsageReport report = tracker.BuildReport(); @@ -216,6 +212,9 @@ private async Task PumpAsync( } } + // A reasoning-only final turn has no answer text or tool call to close it; close it + // here while the channel is still open so the event stays in-band. + tracker.OnReasoningCompleted(); writer.TryComplete(); } catch (Exception ex) @@ -236,6 +235,9 @@ private static bool Inspect(ChatResponseUpdate update, RequestTracker tracker) break; case FunctionCallContent call: + // Completion first: the "reasoning done" event must precede any ToolInvoking + // header an unwrapped tool's sighting emits. + tracker.OnReasoningCompleted(); tracker.OnFunctionCall(call.CallId, call.Name); break; @@ -247,6 +249,10 @@ private static bool Inspect(ChatResponseUpdate update, RequestTracker tracker) tracker.OnReasoningDetected(); break; + case TextContent { Text.Length: > 0 }: + tracker.OnReasoningCompleted(); + break; + default: break; } diff --git a/README.md b/README.md index 45f542c..2a691bb 100644 --- a/README.md +++ b/README.md @@ -14,8 +14,8 @@ Add one line to your pipeline and get: - **Token usage tracking** — input/output/total tokens for the main assistant, per model turn, and attributed to each tool call (including LLM calls nested inside tools), rolled up into a `ChatUsageReport`. - **Streaming progress statuses** — synthetic `ChatProgressContent` updates interleaved into the live stream so your UI can show "Calling GetWeather Tool", sub-statuses reported from inside the tool ("Extracting…", "Processing…"), and completion — while the model and tools are still working. -- **Reasoning detection** — when the model streams reasoning content (`TextReasoningContent`, e.g. via the OpenAI Responses API), a `Reasoning` status is emitted once per model turn — truthful, detection-driven, and never carrying the reasoning text itself. -- **Developer-owned request statuses** — the middleware doesn't invent request-level statuses; construct your own with `ChatProgressUpdate.CreateRequestStarted()` / `CreateReasoning()` and interleave them with `ToResponseUpdate()`. +- **Reasoning detection** — when the model streams reasoning content (`TextReasoningContent`, e.g. via the OpenAI Responses API), a `Reasoning` status is emitted once per model turn and a matching `ReasoningCompleted` closes it when the answer or the next tool call starts, carrying the elapsed reasoning time — truthful, detection-driven, and never carrying the reasoning text itself. +- **Developer-owned request statuses** — the middleware doesn't invent request-level statuses; construct your own with `ChatProgressUpdate.CreateCustom("Starting request")` and interleave them with `ToResponseUpdate()`. - **Out-of-band observers** — implement `IChatProgressObserver` to receive the same events and the final report without parsing the stream. - **Privacy by default** — progress events never carry prompt content, tool arguments, or tool results unless explicitly opted in. @@ -78,13 +78,13 @@ ChatResponse response = updates.ToChatResponse().StripProgressContent(); ### Emit your own statuses -The middleware only reports what it can observe — tool activity, detected reasoning, completion. Request-level statuses like "Starting request" are yours to send: create them with the public factories and interleave them into whatever stream your UI consumes, in the exact shape the middleware itself emits: +The middleware only reports what it can observe — tool activity, detected reasoning, completion. Request-level statuses like "Starting request" are yours to send: create them with `ChatProgressUpdate.CreateCustom(...)` and interleave them into whatever stream your UI consumes, in the exact shape the middleware itself emits: ```csharp async IAsyncEnumerable StreamTurn() { // Shows in the UI before the first tracked event arrives. - yield return ChatProgressUpdate.CreateRequestStarted().ToResponseUpdate(); + yield return ChatProgressUpdate.CreateCustom("Starting request").ToResponseUpdate(); await foreach (ChatResponseUpdate update in client.GetStreamingResponseAsync(history, chatOptions)) { @@ -93,7 +93,7 @@ async IAsyncEnumerable StreamTurn() } ``` -`CreateReasoning()` works the same way, and both accept a custom message. The updates are stamped with the well-known `ChatProgressUpdate.ExternalScopeId`, so they never collide with the middleware's own scopes. +The message is required and entirely yours — the middleware never emits a `Custom` status itself. The updates are stamped with the well-known `ChatProgressUpdate.ExternalScopeId`, so they never collide with the middleware's own scopes. ## MCP tools diff --git a/docs/architecture.md b/docs/architecture.md index eef90ad..8b2fbe1 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -2,7 +2,7 @@ `Andes.Extensions.AI` is a middleware library for [Microsoft.Extensions.AI](https://learn.microsoft.com/dotnet/ai/microsoft-extensions-ai) `IChatClient` pipelines, targeting **net10.0** and built with **C# 14**. Its first deliverable is **tool tracking**: a single `DelegatingChatClient` (`ToolTrackingChatClient`, registered with `UseToolTracking()`) that observes a chat request end to end and produces two things a production application needs but the raw pipeline does not give you: -1. **Progress events** — "Calling GetWeather Tool" headers, sub-statuses reported from inside the tool, a detected "Reasoning..." status when the model streams reasoning content, completion — delivered *while tools are still executing*, both in-band (as synthetic content in the streamed response) and out-of-band (to `IChatProgressObserver` implementations). Every event reports something the middleware actually observed; request-level statuses like "Starting request" are deliberately left to the application (see [Streaming design](#streaming-design-channel-merge)). +1. **Progress events** — "Calling GetWeather Tool" headers, sub-statuses reported from inside the tool, a detected "Reasoning..." status (and its matching "Reasoning completed" close) when the model streams reasoning content, completion — delivered *while tools are still executing*, both in-band (as synthetic content in the streamed response) and out-of-band (to `IChatProgressObserver` implementations). Every event reports something the middleware actually observed; request-level statuses like "Starting request" are deliberately left to the application (see [Streaming design](#streaming-design-channel-merge)). 2. **A usage report** — token usage for the main assistant (per model turn when streaming), attributed per tool call (including LLM calls nested inside tools), rolled up into a `ChatUsageReport`. This document explains why the middleware is shaped the way it is. For hands-on usage, see [Getting started](getting-started.md). @@ -59,18 +59,18 @@ The hard problem in streaming is that **tools execute inside the inner client's after drain: RequestCompleted + UsageReportContent ``` -- A **background pump task** enumerates the inner stream and `TryWrite`s every real update into the channel. While enumerating it records each `UsageContent` as assistant-turn usage, emits best-effort headers for `FunctionCallContent` naming tools it did not wrap, announces a single `Reasoning` event the first time it sees `TextReasoningContent` in a model turn (each update is inspected *before* it is written, so the status enters the channel ahead of the update carrying the reasoning content — and the event never carries the reasoning text), and advances the turn counter when it sees `FunctionResultContent` (a completed tool round-trip), re-arming reasoning detection for the next turn. +- A **background pump task** enumerates the inner stream and `TryWrite`s every real update into the channel. While enumerating it records each `UsageContent` as assistant-turn usage, emits best-effort headers for `FunctionCallContent` naming tools it did not wrap, announces a single `Reasoning` event the first time it sees `TextReasoningContent` in a model turn (each update is inspected *before* it is written, so the status enters the channel ahead of the update carrying the reasoning content — and the event never carries the reasoning text), closes that turn's reasoning with a single `ReasoningCompleted` event ("Reasoning completed", carrying the elapsed reasoning time in `Duration`) the first time non-reasoning content follows the detection — the first non-empty `TextContent`, a `FunctionCallContent` (inspected ahead of the tool header, so the close precedes `ToolInvoking`), or the end of the stream for a reasoning-only final turn (closed before the channel completes, so the event stays in-band ahead of the trailing `RequestCompleted`) — and advances the turn counter when it sees `FunctionResultContent` (a completed tool round-trip), re-arming both reasoning detection and its completion for the next turn. - **Tool wrappers and the ambient reporter** write synthetic updates into the same channel from whatever thread the function-invocation loop runs them on. Synthetic updates carry a single `ChatProgressContent` item and **no `TextContent`**, so text-accumulation helpers (`update.Text`, `ToChatResponse()`) are unaffected. - **Bridged MCP progress notifications** (via the [`Andes.Extensions.AI.Mcp` satellite](mcp.md)) write from yet another thread — the MCP client's receive loop — so those `ToolProgress` events can arrive out of order relative to request-path events. The channel guarantees arrival order, not source order; `IChatProgressObserver` implementations must be thread-safe (already their documented contract); and a late notification racing request completion is dropped best-effort — the in-band write to the completed channel is a no-op, though an observer may see one late event. - The **outer iterator** simply drains the channel with `ReadAllAsync` and yields each update in arrival order. After the channel completes, it builds the report, notifies observers, and appends the final `RequestCompleted` progress update and a `UsageReportContent` update (each independently switchable via options). -**Reasoning is detected, never guessed.** Earlier versions opened every request with synthetic `RequestStarted` ("Starting request") and `Thinking` ("Thinking...") events and re-announced "Thinking..." after each tool round-trip — statuses that asserted more than the middleware could know. Since v0.5 the only request-level status emitted mid-stream is `Reasoning` (the renamed `Thinking`, same underlying value), raised at most once per model turn and only because `TextReasoningContent` was observed on the response. Detection is content-based and therefore provider-agnostic: the OpenAI Responses API streams reasoning summaries as `TextReasoningContent` today, and any future provider producing the same content lights the status up with no middleware changes — while plain Chat Completions never streams reasoning, so chat pipelines simply never see it. Applications that want "Starting request"-style statuses construct them with the public factories (`ChatProgressUpdate.CreateRequestStarted()` / `CreateReasoning()`, stamped with the well-known `ChatProgressUpdate.ExternalScopeId`) and interleave them via `ToResponseUpdate()`, which produces the exact synthetic shape the middleware emits — see [Getting started](getting-started.md#emit-request-level-statuses-yourself). +**Reasoning is detected, never guessed.** Earlier versions opened every request with synthetic `RequestStarted` ("Starting request"; the kind survives as the developer-only `Custom`) and `Thinking` ("Thinking...") events and re-announced "Thinking..." after each tool round-trip — statuses that asserted more than the middleware could know. Since v0.5 the only request-level statuses emitted mid-stream are `Reasoning` (the renamed `Thinking`, same underlying value) and its close `ReasoningCompleted` — each raised at most once per model turn, and only because `TextReasoningContent` was observed on the response: `Reasoning` the moment reasoning is detected, `ReasoningCompleted` when the first answer text or function call follows it (or the stream ends), with the elapsed reasoning time in `Duration`. Detection is content-based and therefore provider-agnostic: the OpenAI Responses API streams reasoning summaries as `TextReasoningContent` today, and any future provider producing the same content lights the status up with no middleware changes — while plain Chat Completions never streams reasoning, so chat pipelines simply never see it. Applications that want "Starting request"-style statuses construct them with the public factory (`ChatProgressUpdate.CreateCustom(message)` — a `ChatProgressKind.Custom` update stamped with the well-known `ChatProgressUpdate.ExternalScopeId`) and interleave them via `ToResponseUpdate()`, which produces the exact synthetic shape the middleware emits — see [Getting started](getting-started.md#emit-request-level-statuses-yourself). There is deliberately no factory for `Reasoning` or `ReasoningCompleted`: those kinds only ever originate from detection. The reasoning *text* respects the same boundary from the other side — the [UI satellite](ui.md) surfaces it from the in-band `TextReasoningContent` the stream already carries, while core progress events stay text-free. **Cancellation and failure.** The pump runs under a CTS linked to the consumer's token. The `finally` around the drain loop cancels that CTS — a no-op on normal completion, but it stops the inner stream promptly if the consumer abandons the iterator early — and then awaits the pump task so it is always observed. If the inner stream throws, the pump completes the channel with that exception, so the failure surfaces to the consumer through the drain loop exactly as it would have without the middleware. **Accounting always lands.** Tokens consumed before a fault, cancellation, or abandonment were still billed, so the middleware never discards them: whenever a request ends without draining to completion (both call styles), observers receive a `ChatProgressKind.RequestFailed` event followed by their once-per-request `OnRequestCompleted` call with the (possibly partial) report, and a nested parent scope still receives the rollup. In-band synthetic updates are yielded on the success path only — on failure there is no stream left to write to. -The non-streaming path (`GetResponseAsync`) needs no channel: there is nowhere to interleave synthetic updates, so progress goes to observers only, and the report is attached to the response (see below). Reasoning detection mirrors the streaming path post-hoc: model turns are indistinguishable in an aggregated response, so if any response message contains `TextReasoningContent`, at most one `Reasoning` event is raised for the whole request — observers only, like every other non-streaming progress event. +The non-streaming path (`GetResponseAsync`) needs no channel: there is nowhere to interleave synthetic updates, so progress goes to observers only, and the report is attached to the response (see below). Reasoning detection mirrors the streaming path post-hoc: model turns are indistinguishable in an aggregated response, so if any response message contains `TextReasoningContent`, at most one `Reasoning`/`ReasoningCompleted` pair is raised for the whole request — observers only, like every other non-streaming progress event, and with a `null` `Duration` on the completion, since elapsed time measured from a post-hoc detection would be meaningless. ## The ambient scope tree diff --git a/docs/examples/progress-board.md b/docs/examples/progress-board.md index 35b641d..32ed317 100644 --- a/docs/examples/progress-board.md +++ b/docs/examples/progress-board.md @@ -121,7 +121,7 @@ public sealed record TextDelta(string Text) : AssistantUiEvent; public sealed record RequestFinished(ChatUsageReport Report) : AssistantUiEvent; ``` -The service's `IAsyncEnumerable` is the **single channel to the UI** — and because it is an ordinary iterator, the app can yield any number of its own "Starting request"-style statuses **before the pipeline streaming even starts**. That matters more than it used to: the middleware never invents request-level statuses (since core v0.5 it emits nothing until it observes something), so app-authored lines like these are the only way the user sees a status before the first tracked event. The first two `yield return`s below run before `GetStreamingResponseAsync` is ever called; the pipeline's in-band events then follow through the same channel. (This service yields its own app-owned records; a consumer reading `ChatResponseUpdate` directly would instead prepend `ChatProgressUpdate.CreateRequestStarted().ToResponseUpdate()` into the stream — see [Getting started](../getting-started.md#emit-request-level-statuses-yourself).) +The service's `IAsyncEnumerable` is the **single channel to the UI** — and because it is an ordinary iterator, the app can yield any number of its own "Starting request"-style statuses **before the pipeline streaming even starts**. That matters more than it used to: the middleware never invents request-level statuses (since core v0.5 it emits nothing until it observes something), so app-authored lines like these are the only way the user sees a status before the first tracked event. The first two `yield return`s below run before `GetStreamingResponseAsync` is ever called; the pipeline's in-band events then follow through the same channel. (This service yields its own app-owned records; a consumer reading `ChatResponseUpdate` directly would instead prepend `ChatProgressUpdate.CreateCustom("Starting request").ToResponseUpdate()` into the stream — see [Getting started](../getting-started.md#emit-request-level-statuses-yourself).) ```csharp using System.Runtime.CompilerServices; @@ -186,7 +186,7 @@ The board holds no middleware state — it reconstructs the tool-call tree from | Event kind | `ScopeId` | `Depth` | Board action | | --- | --- | --- | --- | -| `Reasoning` / `RequestCompleted` — and `RequestStarted`, which only ever arrives [app-emitted](../getting-started.md#emit-request-level-statuses-yourself) | The request root (`ChatProgressUpdate.ExternalScopeId` for app-emitted updates) | 0 | Update the top-level assistant status line | +| `Reasoning` / `ReasoningCompleted` / `RequestCompleted` — and `Custom`, which only ever arrives [app-emitted](../getting-started.md#emit-request-level-statuses-yourself) | The request root (`ChatProgressUpdate.ExternalScopeId` for app-emitted updates) | 0 | Update the top-level assistant status line | | `ToolInvoking` | A fresh scope | parent + 1 | New box (`Title` = the header; `ToolKind`/`ToolName`/`ToolSource` for badges), parented via `ParentScopeId` — a top-level tool's parent is the request root, which has no box, so it becomes a root | | `ToolProgress` | **The owning tool's scope** | tool + 1 | Append a subtitle line (`Message`, plus `Progress`/`ProgressTotal` rendered as "2/3") | | `ToolCompleted` / `ToolFailed` | The tool's scope | tool | Mark the state, set `Duration` | @@ -275,7 +275,7 @@ public sealed class ProgressBoard { switch (update.Kind) { - case ChatProgressKind.RequestStarted or ChatProgressKind.Reasoning or ChatProgressKind.RequestCompleted: + case ChatProgressKind.Custom or ChatProgressKind.Reasoning or ChatProgressKind.ReasoningCompleted or ChatProgressKind.RequestCompleted: AssistantStatus = update.Message; break; diff --git a/docs/getting-started.md b/docs/getting-started.md index 4092caa..67f64b3 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -109,7 +109,7 @@ await foreach (var update in client.GetStreamingResponseAsync( Each `ChatProgressUpdate` carries the fields a UI needs to render a hierarchy without holding extra state: -- **`Kind`** — `ToolInvoking` (the header, e.g. "Calling GetWeather Tool"), `ToolProgress` (a sub-status), `ToolCompleted`, `ToolFailed`, `Reasoning` (emitted at most once per model turn when the middleware detects reasoning content — `TextReasoningContent` — on the stream, as the OpenAI Responses API produces for reasoning-capable models; the event never carries the reasoning text), `RequestCompleted`, `RequestFailed` (out-of-band only: raised to observers when the request faults or is canceled, followed by `OnRequestCompleted` with a possibly partial report), and `RequestStarted` (never emitted by the middleware — reserved for statuses you construct yourself, see [Emit request-level statuses yourself](#emit-request-level-statuses-yourself)). +- **`Kind`** — `ToolInvoking` (the header, e.g. "Calling GetWeather Tool"), `ToolProgress` (a sub-status), `ToolCompleted`, `ToolFailed`, `Reasoning` (emitted at most once per model turn when the middleware detects reasoning content — `TextReasoningContent` — on the stream, as the OpenAI Responses API produces for reasoning-capable models; the event never carries the reasoning text), `ReasoningCompleted` (closes a detected reasoning turn, at most once per model turn — raised when the first answer text or function call follows the reasoning, or when the stream ends; `Duration` carries the elapsed reasoning time when streaming and is `null` on the non-streaming post-hoc mirror), `RequestCompleted`, `RequestFailed` (out-of-band only: raised to observers when the request faults or is canceled, followed by `OnRequestCompleted` with a possibly partial report), and `Custom` (never emitted by the middleware — a developer-constructed status carrying a message you supply, see [Emit request-level statuses yourself](#emit-request-level-statuses-yourself)). - **`ScopeId` / `ParentScopeId`** — `ToolProgress` events share the `ScopeId` of their owning tool call, so group sub-statuses under the header with the matching `ScopeId`; `ParentScopeId` links nested tool calls to their parent. - **`Depth`** — 0 for request-level events, 1 for tool headers, 2 for sub-statuses under a header, deeper for nested tools; ideal for indentation. - **`ToolName` / `ToolKind` / `ToolSource` / `CallId` / `Duration`** — for richer rendering and correlation with the model's function calls. @@ -124,26 +124,28 @@ A typical rendering of the events for one tool call — the middleware emits not [RequestCompleted] Request completed ``` -On a pipeline whose model streams reasoning content — the OpenAI Responses API with a reasoning-capable deployment; plain Chat Completions never streams reasoning, so chat pipelines never see it — a `Reasoning` status additionally announces each model turn the moment reasoning is detected, re-armed after every tool round-trip: +On a pipeline whose model streams reasoning content — the OpenAI Responses API with a reasoning-capable deployment; plain Chat Completions never streams reasoning, so chat pipelines never see it — a `Reasoning` status additionally announces each model turn the moment reasoning is detected, and a matching `ReasoningCompleted` closes it the moment the answer or the next tool call starts (carrying the elapsed reasoning time in `Duration`) — both re-armed after every tool round-trip: ```text [Reasoning] Reasoning... +[ReasoningCompleted] Reasoning completed [ToolInvoking] Calling GetWeather Tool [ToolProgress] Extracting... [ToolCompleted] GetWeather completed [Reasoning] Reasoning... +[ReasoningCompleted] Reasoning completed [RequestCompleted] Request completed ``` ## Emit request-level statuses yourself -The middleware only reports what it can observe — tool activity, detected reasoning, completion. Request-level statuses like "Starting request" are the application's to send: two public factories construct them, and `ToResponseUpdate()` wraps one in the exact synthetic shape the middleware emits — a role-less `ChatResponseUpdate` carrying a single `ChatProgressContent` — so it can be prepended or interleaved into whatever stream your consumer reads, whether that is a rendering loop like the one above or the [UI package](ui.md)'s `ToStatusSnapshotsAsync()`: +The middleware only reports what it can observe — tool activity, detected reasoning, completion. Request-level statuses like "Starting request" are the application's to send: one public factory constructs them, and `ToResponseUpdate()` wraps one in the exact synthetic shape the middleware emits — a role-less `ChatResponseUpdate` carrying a single `ChatProgressContent` — so it can be prepended or interleaved into whatever stream your consumer reads, whether that is a rendering loop like the one above or the [UI package](ui.md)'s `ToStatusSnapshotsAsync()`: ```csharp async IAsyncEnumerable StreamTurn() { // Shows in the UI before the first tracked event arrives. - yield return ChatProgressUpdate.CreateRequestStarted().ToResponseUpdate(); + yield return ChatProgressUpdate.CreateCustom("Starting request").ToResponseUpdate(); await foreach (ChatResponseUpdate update in client.GetStreamingResponseAsync(history, chatOptions)) { @@ -152,10 +154,9 @@ async IAsyncEnumerable StreamTurn() } ``` -- **`ChatProgressUpdate.CreateRequestStarted(message)`** — a `RequestStarted` update; `message` defaults to "Starting request". -- **`ChatProgressUpdate.CreateReasoning(message)`** — a `Reasoning` update; `message` defaults to "Reasoning..." — the same shape the middleware raises on detection, for streams where you announce it yourself. +**`ChatProgressUpdate.CreateCustom(message)`** builds a `Custom` update carrying your message — the message is required (`ArgumentNullException` on `null`, `ArgumentException` on empty) and entirely application-defined: "Starting request", "Warming up…", whatever the UI should show. There is deliberately no factory for `Reasoning` or `ReasoningCompleted`: those statuses are detection-driven and only ever originate from the middleware. -Both stamp the update with depth 0, the current UTC time, and the well-known scope id `ChatProgressUpdate.ExternalScopeId` (`"scope-external"`), which the middleware's own per-request scope identifiers never collide with. The synthetic update carries no text, so `update.Text` accumulation is unaffected; prepend it outside any loop that records updates for chat history (as both sample apps do in their `StreamTurn` iterators — see [`samples/Andes.Extensions.AI.Demo`](../samples/Andes.Extensions.AI.Demo/README.md) and [`samples/Andes.Extensions.AI.Demo.Responses`](../samples/Andes.Extensions.AI.Demo.Responses/README.md)), or strip it with [`StripProgressContent()`](#strip-synthetic-content-before-persisting-history) like any other synthetic content. +The factory stamps the update with depth 0, the current UTC time, and the well-known scope id `ChatProgressUpdate.ExternalScopeId` (`"scope-external"`), which the middleware's own per-request scope identifiers never collide with. The synthetic update carries no text, so `update.Text` accumulation is unaffected; prepend it outside any loop that records updates for chat history (as both sample apps do in their `StreamTurn` iterators — see [`samples/Andes.Extensions.AI.Demo`](../samples/Andes.Extensions.AI.Demo/README.md) and [`samples/Andes.Extensions.AI.Demo.Responses`](../samples/Andes.Extensions.AI.Demo.Responses/README.md)), or strip it with [`StripProgressContent()`](#strip-synthetic-content-before-persisting-history) like any other synthetic content. ## Report from inside a tool diff --git a/docs/ui.md b/docs/ui.md index 071c3ad..ce45e82 100644 --- a/docs/ui.md +++ b/docs/ui.md @@ -69,16 +69,17 @@ AssistantUiEventKind ├── ActivityCompleted — ScopeId targets the activity; DurationSeconds is set ├── ActivityFailed — same as ActivityCompleted, but the activity failed ├── TextDelta — Text is a chunk of the assistant's answer +├── ReasoningDelta — Text is a chunk of the model's reasoning summary └── Finished — Usage carries the final token totals; DurationSeconds is the whole request ``` `ScopeId`/`ParentScopeId`/`Depth` are carried over unchanged from the core's `ChatProgressUpdate`, so the same tree-reconstruction rules from [Getting started](getting-started.md#consume-streaming-progress) and the [Progress Board example](examples/progress-board.md#the-progress-board-hierarchy-from-the-event-contract) apply here — this contract just makes them serializable. -Every request-level kind collapses to `Status` with the message passed through — the middleware's detected `Reasoning` and final `RequestCompleted`, and equally any update the application constructs itself with `ChatProgressUpdate.CreateRequestStarted()`/`CreateReasoning()` and prepends via `ToResponseUpdate()` ([Getting started](getting-started.md#emit-request-level-statuses-yourself)); the mapper does not care who emitted it. Note that the middleware no longer opens requests with a synthetic status of its own (since core v0.5), so `AssistantStatus` stays `null` until the first request-level event arrives — a UI that wants a status line the instant the request starts prepends its own, exactly as both sample apps do. +Every request-level kind collapses to `Status` with the message passed through — the middleware's detected `Reasoning`, its closing `ReasoningCompleted` ("Reasoning completed", whose `DurationSeconds` carries the elapsed reasoning time — `ToUiEvent` maps `Duration` generically, so no special-casing was needed), and final `RequestCompleted`, and equally any update the application constructs itself with `ChatProgressUpdate.CreateCustom(...)` and prepends via `ToResponseUpdate()` ([Getting started](getting-started.md#emit-request-level-statuses-yourself)); the mapper does not care who emitted it. The detected `Reasoning` progress event is no exception — it still collapses to a `Status` line ("Reasoning…"), because the progress event never carries the reasoning text; the *text* arrives separately as `ReasoningDelta` events, sourced from the in-band `TextReasoningContent` itself. Note that the middleware no longer opens requests with a synthetic status of its own (since core v0.5), so `AssistantStatus` stays `null` until the first request-level event arrives — a UI that wants a status line the instant the request starts prepends its own, exactly as both sample apps do. ### `AssistantStatusSnapshot` — the render shape -`AssistantStatusSnapshot` is the folded result: an immutable value with the current `AssistantStatus` line, the overall `Phase` (`ActivityState.Running`/`Completed`/`Failed`), the answer `Text` accumulated so far, the final `Usage`, and — the interesting part — `Activities`, an already-nested `IReadOnlyList`: +`AssistantStatusSnapshot` is the folded result: an immutable value with the current `AssistantStatus` line, the overall `Phase` (`ActivityState.Running`/`Completed`/`Failed`), the answer `Text` accumulated so far, the model's `ReasoningText` accumulated so far (when the provider streams reasoning summaries — verbatim concatenation across the whole request, including tool round-trips), the final `Usage`, and — the interesting part — `Activities`, an already-nested `IReadOnlyList`: ```csharp public sealed record AssistantActivity @@ -105,11 +106,11 @@ Four static members turn the tracked, in-band stream into the contract: | Member | Input | Output | | --- | --- | --- | -| `ToUiEventsAsync(this IAsyncEnumerable, CancellationToken)` | The tracked streaming response | `IAsyncEnumerable` — one event per `ChatProgressContent`, one `TextDelta` per non-empty `update.Text`, one final `Finished` from `UsageReportContent` | +| `ToUiEventsAsync(this IAsyncEnumerable, CancellationToken)` | The tracked streaming response | `IAsyncEnumerable` — one event per `ChatProgressContent`, one `ReasoningDelta` per `TextReasoningContent` with non-empty text (encrypted-only reasoning items carry nothing renderable and are skipped), one `TextDelta` per non-empty `update.Text`, one final `Finished` from `UsageReportContent` | | `ToStatusSnapshotsAsync(this IAsyncEnumerable, CancellationToken)` | The tracked streaming response | `IAsyncEnumerable` — `ToUiEventsAsync` piped through a private `AssistantStatusReducer`, one snapshot per event | | `ToUiEvent(this ChatProgressUpdate)` | A single core progress update | The equivalent `AssistantUiEvent` | | `ToUsageSummary(this UsageDetails)` | A core usage value | The flattened `UsageSummary` | -| `ToSnapshot(this ChatUsageReport)` | A completed usage report (for example the non-streaming `ChatResponse.AdditionalProperties` report) | A `Completed`-phase snapshot built directly from the report's `ToolCalls` tree — useful when all you have is the final report, not the live stream | +| `ToSnapshot(this ChatUsageReport)` | A completed usage report (for example the non-streaming `ChatResponse.AdditionalProperties` report) | A `Completed`-phase snapshot built directly from the report's `ToolCalls` tree — useful when all you have is the final report, not the live stream. It carries no `ReasoningText` (or `Text`): reports contain no model content | The mapper is where the clean-name design lives: `ToUiEvent` sets `DisplayName = update.ToolSource ?? update.ToolName` — the raw server/agent/function name — never `update.Message`, which is the *composed* header text ("Calling GetWeather Tool", "Calling Andes Test MCP"). `ToSnapshot`'s `ToActivity` helper does the same from a `ToolCallUsage`: `DisplayName = call.Source ?? call.ToolName`. It also recurses `ToolCallUsage.Children`, so nested activities — including the v0.3 satellite child scopes — arrive with their own per-node `Usage`, matching the live tree's shape. See [Clean names, not composed headers](#clean-names-not-composed-headers) for why this matters. @@ -126,7 +127,7 @@ await foreach (AssistantUiEvent uiEvent in events) } ``` -It rebuilds the activity tree the same way the [Progress Board example](examples/progress-board.md#the-progress-board-hierarchy-from-the-event-contract) does: `ActivityStarted` opens a scope and either attaches it under `ParentScopeId` (if that scope is already known) or adds it as a root — a top-level activity's parent is the request root, which has no card. `ActivityProgress` appends a `SubStatus` to the owning scope; `ActivityCompleted`/`ActivityFailed` set `State` and `DurationSeconds`. `TextDelta` accumulates `Text`; `Finished` sets `Phase = Completed` and `Usage`. +It rebuilds the activity tree the same way the [Progress Board example](examples/progress-board.md#the-progress-board-hierarchy-from-the-event-contract) does: `ActivityStarted` opens a scope and either attaches it under `ParentScopeId` (if that scope is already known) or adds it as a root — a top-level activity's parent is the request root, which has no card. `ActivityProgress` appends a `SubStatus` to the owning scope; `ActivityCompleted`/`ActivityFailed` set `State` and `DurationSeconds`. `TextDelta` accumulates `Text`; `ReasoningDelta` accumulates `ReasoningText`; `Finished` sets `Phase = Completed` and `Usage`. `ToStatusSnapshotsAsync` already wraps this for you over a live stream — reach for `AssistantStatusReducer` directly only when you're consuming events from somewhere else (deserialized off the wire, replayed from storage, or, in a Blazor WebAssembly app, received over SignalR or fetched as SSE). @@ -163,7 +164,7 @@ The UI contract sidesteps the composition problem entirely instead of patching i ## Privacy posture -Unchanged from the core: events and snapshots **never carry prompt content, tool arguments, or tool results** — only headers-turned-names, statuses, sub-status text, activity metadata (`ScopeId`, `Kind`, `Source`, timing), and token counts. The mapper reads exclusively from `ChatProgressUpdate`/`ChatUsageReport`, which already enforce this at the core layer (the sole opt-in there remains `ToolTrackingOptions.IncludeToolArguments`, default `false`); this package adds no new opt-in and cannot re-introduce content the core never emitted. See [Architecture: Privacy posture](architecture.md#privacy-posture). +Unchanged from the core: events and snapshots **never carry prompt content, tool arguments, or tool results** — only headers-turned-names, statuses, sub-status text, activity metadata (`ScopeId`, `Kind`, `Source`, timing), and token counts. Model *outputs* are the deliberate exception, with a clear boundary: the answer text (`TextDelta`/`Text`) and the reasoning summary (`ReasoningDelta`/`ReasoningText`) are sourced only from the in-band content the tracked stream already carries — never from progress metadata, which stays text-free at the core layer. Everything else the mapper reads comes from `ChatProgressUpdate`/`ChatUsageReport`, which already enforce the posture (the sole opt-in there remains `ToolTrackingOptions.IncludeToolArguments`, default `false`); this package adds no new opt-in and cannot re-introduce content the core never emitted. See [Architecture: Privacy posture](architecture.md#privacy-posture). ## References diff --git a/releases/v0.5.0.md b/releases/v0.5.0.md index 0af77a8..8974ccf 100644 --- a/releases/v0.5.0.md +++ b/releases/v0.5.0.md @@ -4,30 +4,37 @@ All changes since [`v0.4.0`](v0.4.0.md). -The middleware stops guessing: the auto-emitted "Starting request"/"Thinking..." statuses are gone, replaced by a **detection-driven `Reasoning` status** that fires only when the model actually streams reasoning content, plus **public factories** so applications emit request-level statuses themselves — in the exact synthetic shape the middleware uses. A new sample runs the whole pipeline over the Azure OpenAI Responses API with stable packages only. +The middleware stops guessing: the auto-emitted "Starting request"/"Thinking..." statuses are gone, replaced by a **detection-driven `Reasoning`/`ReasoningCompleted` status pair** that fires only when the model actually streams reasoning content — opened the moment reasoning is detected, closed (with the elapsed reasoning time) the moment the answer or the next tool call starts — plus a **public `Custom` status factory** so applications emit their own request-level statuses — in the exact synthetic shape the middleware uses. The UI package gains a **reasoning-text surface** (`ReasoningDelta` events folding into `AssistantStatusSnapshot.ReasoningText`), and a new sample runs the whole pipeline over the Azure OpenAI Responses API with stable packages only. ## Breaking changes - **`ChatProgressKind.Thinking` is renamed to `ChatProgressKind.Reasoning`** (`Andes.Extensions.AI/Progress/ChatProgressKind.cs`; the underlying value `1` is preserved). Consumers switching on the enum rename `Thinking` → `Reasoning`; persisted numeric values are unaffected. -- **The middleware no longer auto-emits request-level statuses.** Previous versions opened every request (both call styles) with `RequestStarted` ("Starting request") and `Thinking` ("Thinking..."), and re-emitted "Thinking..." after each tool round-trip. Those emissions are removed — `RequestTracker.EmitRequestStarted`/`EmitThinking` are deleted, and `AdvanceIteration` now only advances the turn counter and re-arms reasoning detection — so the first in-band event of a tool-calling request is now the `ToolInvoking` header. - - **Migration:** UIs that relied on the automatic opening status prepend their own — `yield return ChatProgressUpdate.CreateRequestStarted().ToResponseUpdate();` ahead of streaming the tracked response (see Added below) — or handle the absence (the UI package's `AssistantStatusSnapshot.AssistantStatus` now stays `null` until the first request-level event arrives). +- **`ChatProgressKind.RequestStarted` is renamed to `ChatProgressKind.Custom`** (same file; the underlying value `0` is preserved): a developer-constructed status carrying any application-supplied message, never emitted by the middleware. Its factory is `ChatProgressUpdate.CreateCustom(string message)` — the message is **required** (`ArgumentException.ThrowIfNullOrEmpty`: `ArgumentNullException` on `null`, `ArgumentException` on empty), replacing the interim `CreateRequestStarted(string? message = null)` from earlier drafts of this release. The interim `CreateReasoning(...)` factory is **removed without replacement**: `Reasoning` is strictly detection-driven (model-only), so applications can never emit it via a factory. +- **The middleware no longer auto-emits request-level statuses.** Previous versions opened every request (both call styles) with a "Starting request" status and a `Thinking` ("Thinking...") status, and re-emitted "Thinking..." after each tool round-trip. Those emissions are removed — `RequestTracker.EmitRequestStarted`/`EmitThinking` are deleted, and `AdvanceIteration` now only advances the turn counter and re-arms reasoning detection — so the first in-band event of a tool-calling request is now the `ToolInvoking` header. + - **Migration:** UIs that relied on the automatic opening status prepend their own — `yield return ChatProgressUpdate.CreateCustom("Starting request").ToResponseUpdate();` ahead of streaming the tracked response (see Added below) — or handle the absence (the UI package's `AssistantStatusSnapshot.AssistantStatus` now stays `null` until the first request-level event arrives). ## Added -- **Detection-driven `Reasoning` status.** `ToolTrackingChatClient.Inspect` watches the stream for `Microsoft.Extensions.AI.TextReasoningContent` and calls the new `RequestTracker.OnReasoningDetected()`, which emits **one** `Reasoning` event per model turn (message "Reasoning...", root scope, depth 0), re-armed after each tool round-trip. Updates are inspected before they are forwarded, so the status enters the channel **ahead of** the update carrying the reasoning content. Detection is content-based and therefore provider-agnostic: the OpenAI Responses API produces `TextReasoningContent` today, while plain Chat Completions never streams reasoning — chat pipelines simply never see the status. Non-streaming `GetResponseAsync` mirrors this post-hoc: if any response message contains `TextReasoningContent`, at most one `Reasoning` event is raised (observers only — turns are indistinguishable in an aggregated response). The event never carries the reasoning text itself (privacy invariant unchanged). -- **Developer-owned request statuses.** Public static factories `ChatProgressUpdate.CreateRequestStarted(string? message = null)` (default "Starting request") and `ChatProgressUpdate.CreateReasoning(string? message = null)` (default "Reasoning..."), both stamped with the new public constant `ChatProgressUpdate.ExternalScopeId` (`"scope-external"` — never collides with the middleware's per-request scope identifiers), depth 0, and the current UTC time. The new extension `ChatProgressUpdateExtensions.ToResponseUpdate()` (`Andes.Extensions.AI/Progress/ChatProgressUpdateExtensions.cs`) wraps an update into a role-less `ChatResponseUpdate` carrying a single `ChatProgressContent` — the exact synthetic shape the middleware emits — so apps can prepend or interleave their own statuses into the stream a UI consumes (for example, ahead of `ToStatusSnapshotsAsync()`). Both sample apps demonstrate the prepend pattern in their `StreamTurn` local function. -- **New sample: `samples/Andes.Extensions.AI.Demo.Responses`.** A console chat over the **Azure OpenAI Responses API with stable packages only**: the plain `OpenAIClient` (stable `OpenAI` 2.12.0) targets the OpenAI-v1-compatible endpoint (`{endpoint}/openai/v1`) — the stable `Azure.AI.OpenAI` 2.1.0 has no Responses surface, which is why the plain-client route is used — and `GetResponsesClient().AsIChatClient(deployment)` adapts it to the tracked pipeline. `ChatOptions.Reasoning = new ReasoningOptions { Output = ReasoningOutput.Summary }` makes reasoning summaries stream back as `TextReasoningContent`, lighting up the live `Reasoning` status. Requires a reasoning-capable deployment (gpt-5 family / o-series); builds with `NoWarn` `OPENAI001` because the Responses surface is still `[Experimental]` in OpenAI 2.12.0. Registered in `Andes.Extensions.slnx`; `IsPackable=false` like the existing sample — it never ships to NuGet. -- **New optional integration setting `AzureOpenAI:ResponsesDeployment`**, gating the new `[SkippableFact]` suite `tests/Andes.Extensions.AI.Integration.Test/ResponsesStreamingIntegrationTests.cs`: end to end against the Responses API, it asserts that no `RequestStarted` is auto-emitted, that exactly one "Reasoning..." status is raised for a single-turn request, and that the status precedes the first reasoning content. When the setting is absent those tests skip cleanly while the chat-deployment tests still run. +- **Detection-driven `Reasoning` status.** `ToolTrackingChatClient.Inspect` watches the stream for `Microsoft.Extensions.AI.TextReasoningContent` and calls the new `RequestTracker.OnReasoningDetected()`, which emits **one** `Reasoning` event per model turn (message "Reasoning...", root scope, depth 0), re-armed after each tool round-trip. Updates are inspected before they are forwarded, so the status enters the channel **ahead of** the update carrying the reasoning content. Detection is content-based and therefore provider-agnostic: the OpenAI Responses API produces `TextReasoningContent` today, while plain Chat Completions never streams reasoning — chat pipelines simply never see the status. Non-streaming `GetResponseAsync` mirrors this post-hoc: if any response message contains `TextReasoningContent`, at most one `Reasoning` event is raised (observers only — turns are indistinguishable in an aggregated response). The event never carries the reasoning text itself (privacy invariant unchanged), and with no public factory for the kind, it only ever originates from this detection. +- **`ChatProgressKind.ReasoningCompleted` closes each detected reasoning turn.** New enum member (`ReasoningCompleted = 8`, `Andes.Extensions.AI/Progress/ChatProgressKind.cs`), raised at most once per model turn by a `RequestTracker.OnReasoningCompleted()` latch — re-armed alongside detection by `AdvanceIteration()` after each tool round-trip — when the first answer text (non-empty `TextContent`) or `FunctionCallContent` follows detected reasoning, or when the stream ends on a reasoning-only final turn. The function-call hook fires before `OnFunctionCall`, so the close precedes any `ToolInvoking` header; the stream-end close happens in `PumpAsync` before the channel completes, so the event stays in-band ahead of the trailing `RequestCompleted`. Message "Reasoning completed", root scope, depth 0. `ChatProgressUpdate.Duration` carries the elapsed reasoning time — first detection to the first completion trigger — when streaming; the non-streaming post-hoc mirror passes `measured: false`, so `Duration` stays `null` there while observers now receive a balanced `Reasoning` + `ReasoningCompleted` pair per request. Like `Reasoning`, the kind has no public factory and never carries reasoning text (privacy invariant unchanged), and the failure path is untouched — a request that faults after detection raises `RequestFailed` with no reasoning completion. The UI package needed **zero code changes**: `MapKind` collapses the new kind to `AssistantUiEventKind.Status` (status line "Reasoning completed") and `ToUiEvent` already maps `Duration` → `DurationSeconds` generically, so UIs receive the reasoning duration on that `Status` event — the shipped TypeScript contract is untouched (`ChatProgressKind` never crosses the wire). Visible in the Responses demo: the header now flips from "Reasoning..." to "Reasoning completed" the moment the answer (or the next tool call) starts, instead of "Reasoning..." lingering until the next event. +- **Developer-owned request statuses.** The public static factory `ChatProgressUpdate.CreateCustom(string message)` constructs a request-level `Custom` update carrying any application-supplied message, stamped with the new public constant `ChatProgressUpdate.ExternalScopeId` (`"scope-external"` — never collides with the middleware's per-request scope identifiers), depth 0, and the current UTC time. The new extension `ChatProgressUpdateExtensions.ToResponseUpdate()` (`Andes.Extensions.AI/Progress/ChatProgressUpdateExtensions.cs`) wraps an update into a role-less `ChatResponseUpdate` carrying a single `ChatProgressContent` — the exact synthetic shape the middleware emits — so apps can prepend or interleave their own statuses into the stream a UI consumes (for example, ahead of `ToStatusSnapshotsAsync()`). Both sample apps demonstrate the prepend pattern with `CreateCustom("Starting request")` in their `StreamTurn` local function. +- **`Andes.Extensions.AI.UI`: the model's reasoning summary text now propagates through the UI contract.** New `AssistantUiEventKind.ReasoningDelta` (between `TextDelta` and `Finished`): `ChatResponseUiExtensions.ToUiEventsAsync` emits one per in-band `TextReasoningContent` with non-empty text — encrypted-only reasoning items (empty text) carry nothing renderable and are skipped — with the chunk in `AssistantUiEvent.Text`. `AssistantStatusReducer` accumulates the deltas into the new `AssistantStatusSnapshot.ReasoningText`: verbatim concatenation across the whole request, including tool round-trips, with no synthetic separators. The shipped TypeScript mirror (`typescript/andes-assistant-ui.ts`) gains the `"ReasoningDelta"` union member, `reasoningText?: string`, and the matching `foldAssistantEvents` case. The privacy boundary holds: reasoning text is sourced only from in-band model content the stream already carries — core progress events remain text-free. +- **New sample: `samples/Andes.Extensions.AI.Demo.Responses`.** A console chat over the **Azure OpenAI Responses API with stable packages only**: the plain `OpenAIClient` (stable `OpenAI` 2.12.0) targets the OpenAI-v1-compatible endpoint (`{endpoint}/openai/v1`) — the stable `Azure.AI.OpenAI` 2.1.0 has no Responses surface, which is why the plain-client route is used — and `GetResponsesClient().AsIChatClient(deployment)` adapts it to the tracked pipeline. `ChatOptions.Reasoning = new ReasoningOptions { Output = ReasoningOutput.Full }` makes reasoning summaries stream back as `TextReasoningContent`, lighting up the live `Reasoning` status (see Fixed for why `Summary` does not work on gpt-5-series deployments), and the demo's `StatusRenderer` shows the accumulating `AssistantStatusSnapshot.ReasoningText` in a dimmed "reasoning" panel — the last six lines while the Live frame streams, and the full text in the persistent final frame (and on failures), with the total reasoning time in the panel header summed from the recorded `ReasoningCompleted` statuses' `Duration`. Requires a reasoning-capable deployment (gpt-5 family / o-series); builds with `NoWarn` `OPENAI001` because the Responses surface is still `[Experimental]` in OpenAI 2.12.0. Registered in `Andes.Extensions.slnx`; `IsPackable=false` like the existing sample — it never ships to NuGet. +- **New optional integration setting `AzureOpenAI:ResponsesDeployment`**, gating the new `[SkippableFact]` suite `tests/Andes.Extensions.AI.Integration.Test/ResponsesStreamingIntegrationTests.cs`: end to end against the Responses API, it asserts that no `Custom` status appears (the middleware never emits one), that exactly one "Reasoning..." status is raised for a single-turn request, that the status precedes the first reasoning content, and that a single `ReasoningCompleted` with a non-`null` `Duration` follows it — pair ordering rather than fixed positions, since real turn structure varies by deployment. When the setting is absent those tests skip cleanly while the chat-deployment tests still run. - **Root README badges** — NuGet version badges for all four packages, the NuGet Publish workflow status, the MIT license, and .NET 10. ## Changed - **Dependency pins** (`Directory.Packages.props`): `Microsoft.Agents.AI` 1.16.0 → **1.17.0** (the Agent satellite's new floor) and `ModelContextProtocol`/`ModelContextProtocol.Core` 2.0.0 → **2.1.0** (the MCP satellite now floors Core `>= 2.1.0`; the full `ModelContextProtocol` package remains test-and-demo-only). A new explicit pin **`OpenAI` 2.12.0** is consumed only by the Responses sample and the core integration test project — nothing shipped to NuGet references it. -- **The interactive demo (`samples/Andes.Extensions.AI.Demo`) prepends `ChatProgressUpdate.CreateRequestStarted().ToResponseUpdate()`** in its `StreamTurn` tee — outside the recording loop, so the Live header lights up immediately while the synthetic update never enters chat history or the usage report. -- **`Andes.Extensions.AI.UI`: doc-comment example strings only** — "Thinking…" → "Reasoning…" in `AssistantStatusSnapshot`, `AssistantUiEventKind`, and the shipped `typescript/andes-assistant-ui.ts`. **Zero behavior changes**: request-level kinds already collapse to `AssistantUiEventKind.Status` with the message passed through, so both the renamed `Reasoning` and developer-prepended `RequestStarted` updates flow through the existing contract unchanged. +- **The interactive demo (`samples/Andes.Extensions.AI.Demo`) prepends `ChatProgressUpdate.CreateCustom("Starting request").ToResponseUpdate()`** in its `StreamTurn` tee — outside the recording loop, so the Live header lights up immediately while the synthetic update never enters chat history or the usage report. +- **`Andes.Extensions.AI.UI`: request-level statuses keep flowing through the existing `Status` contract** — request-level kinds still collapse to `AssistantUiEventKind.Status` with the message passed through, so the middleware's detected `Reasoning` status, its closing `ReasoningCompleted` (whose reasoning duration arrives as the `Status` event's `DurationSeconds` — see Added), and a developer-prepended `Custom` update all drive `AssistantStatusSnapshot.AssistantStatus` unchanged; the reasoning **text** travels separately on the new `ReasoningDelta` surface (see Added). Doc-comment example strings switch "Thinking…" → "Reasoning…" in `AssistantStatusSnapshot`, `AssistantUiEventKind`, and the shipped `typescript/andes-assistant-ui.ts`. - **Docs updated for the new status model**: [Getting started](../docs/getting-started.md) (kinds, transcripts, and a new [Emit request-level statuses yourself](../docs/getting-started.md#emit-request-level-statuses-yourself) section), [Architecture](../docs/architecture.md) (the detection design and the non-streaming post-hoc note), the [MCP](../docs/mcp.md) and [Agent](../docs/agents.md) transcripts, [UI support](../docs/ui.md), and the [Progress Board example](../docs/examples/progress-board.md). The root README gains "Reasoning detection" and "Developer-owned request statuses" bullets, an "Emit your own statuses" section, and the [Responses sample README](../samples/Andes.Extensions.AI.Demo.Responses/README.md). - **All four packages version in lockstep at 0.5.0**; the satellites depend on core `>= 0.5.0`. +## Fixed + +- **Reasoning summaries actually stream on gpt-5-series Azure deployments: `ReasoningOutput.Full`, not `Summary`.** Earlier drafts of this release requested `Output = ReasoningOutput.Summary`, which Microsoft.Extensions.AI.OpenAI 10.8.3 maps to the Responses summary verbosity `"concise"` — a value gpt-5-series Azure deployments do not support (supported: `auto`, `detailed`) — so no summaries streamed and the `Reasoning` status never fired. `Full` maps to `"detailed"`, which streams. No `Effort` override is set either: `ExtraHigh` maps to `"xhigh"`, accepted only by gpt-5.1+. Applied in `samples/Andes.Extensions.AI.Demo.Responses/Program.cs` and `tests/Andes.Extensions.AI.Integration.Test/ResponsesStreamingIntegrationTests.cs`. + ## Verification -New unit coverage pins the behavior change: `ReasoningDetectionTests` (one `Reasoning` status per turn, ordered ahead of the reasoning content; re-emission across a tool round-trip; no request-level statuses when nothing streams reasoning; non-streaming observers notified once) and `ChatProgressUpdateFactoryTests` (factory defaults and custom messages, `ExternalScopeId` stamping, the `ToResponseUpdate()` shape, and its `null` guard) in the core suite, plus `ToStatusSnapshotsAsync_DevPrependedRequestStarted_SetsAssistantStatus` in the UI suite proving a developer-prepended status drives `AssistantStatus` through the unchanged contract. The pre-existing streaming, non-streaming, and Azure OpenAI integration tests are updated to assert the **absence** of auto-emitted request-level statuses, and the new `ResponsesStreamingIntegrationTests` exercises detection against a real reasoning-capable deployment. +New unit coverage pins the behavior change: `ReasoningDetectionTests` (one `Reasoning` status per turn, ordered ahead of the reasoning content; one measured `ReasoningCompleted` closing the turn ahead of the first answer text — an empty text chunk does not close it; a reasoning-only turn closed at stream end, ahead of the trailing `RequestCompleted`; re-emission of the full pair per turn across a tool round-trip, the first close landing before the tool header; no request-level statuses of any kind when nothing streams reasoning; non-streaming observers notified once for detection and with a balanced pair whose completion carries a `null` `Duration`) and `ChatProgressUpdateFactoryTests` (`CreateCustom` populating the well-known fields, its `null`/empty-message guards, the `ToResponseUpdate()` shape, and its `null` guard) in the core suite. The UI suite adds `ToStatusSnapshotsAsync_DevPrependedCustomStatus_SetsAssistantStatus`, proving a developer-prepended `Custom` status drives `AssistantStatus` through the unchanged contract, plus the reasoning-text surface tests: `ToUiEventsAsync_ReasoningContent_EmitsReasoningDeltasInOrder`, `ToUiEventsAsync_EmptyReasoningContent_EmitsNoReasoningDelta`, and `ToStatusSnapshotsAsync_ReasoningAcrossToolRoundTrip_AccumulatesReasoningText` in `ChatResponseUiExtensionsTests`; `Apply_ReasoningDelta_AccumulatesReasoningText` and `Apply_ReasoningDeltaAcrossActivities_KeepsAccumulating` in `AssistantStatusReducerTests`; and `Serialize_SnapshotWithReasoningText_EmitsCamelCaseProperty` in `AssistantUiJsonContextTests`. The pre-existing streaming, non-streaming, and Azure OpenAI integration tests are updated to assert the **absence** of auto-emitted request-level statuses, and the new `ResponsesStreamingIntegrationTests` exercises detection — including the `Reasoning` → `ReasoningCompleted` pair ordering and the non-`null` measured `Duration` — against a real reasoning-capable deployment. diff --git a/samples/Andes.Extensions.AI.Demo.Responses/Program.cs b/samples/Andes.Extensions.AI.Demo.Responses/Program.cs index bf03d60..5be3193 100644 --- a/samples/Andes.Extensions.AI.Demo.Responses/Program.cs +++ b/samples/Andes.Extensions.AI.Demo.Responses/Program.cs @@ -10,7 +10,7 @@ Console.OutputEncoding = Encoding.UTF8; -AzureOpenAISettings settings = AzureOpenAISettings.Load(); +var settings = AzureOpenAISettings.Load(); if (!settings.IsConfigured) { AnsiConsole.Write(new Panel(new Markup( @@ -38,11 +38,15 @@ .UseFunctionInvocation() .Build(); -// Summary output is what streams back as TextReasoningContent — the trigger for the middleware's -// Reasoning status. No Temperature: reasoning models reject non-default values. +// Reasoning summaries stream back as TextReasoningContent — the trigger for the middleware's +// Reasoning status. Full output maps to Responses summary verbosity "detailed". Not Summary: +// M.E.AI maps it to "concise", which gpt-5-series deployments reject (supported: auto, +// detailed) — no summaries stream and the Reasoning status never fires. No Effort override: +// ExtraHigh maps to "xhigh", accepted only by gpt-5.1+. No Temperature: reasoning models +// reject non-default values. var chatOptions = new ChatOptions { - Reasoning = new ReasoningOptions { Output = ReasoningOutput.Summary }, + Reasoning = new ReasoningOptions { Output = ReasoningOutput.Full }, Tools = [ AIFunctionFactory.Create(ResponsesDemoTools.GetWeather), @@ -51,7 +55,8 @@ }; AnsiConsole.Write(new Rule("[bold]Andes.Extensions.AI[/] [dim]responses demo[/]").LeftJustified()); -AnsiConsole.MarkupLine("[dim]The Responses API pipeline: watch the header switch to \"Reasoning...\" as summaries stream.[/]"); +AnsiConsole.MarkupLine("[dim]The Responses API pipeline: the header flips \"Reasoning...\" → \"Reasoning completed\" as summaries stream,[/]"); +AnsiConsole.MarkupLine("[dim]and the final frame keeps the full reasoning (with the measured time), the tool calls, and the answer.[/]"); AnsiConsole.MarkupLine("[dim]Try:[/] [italic]Get the weather in Quito, then convert the high to Fahrenheit.[/]"); AnsiConsole.MarkupLine("[dim] [/] [italic]What is the sum of the first ten prime numbers? Reason it out.[/]"); AnsiConsole.MarkupLine("[dim]Press Enter on an empty line (or type 'exit') to quit.[/]"); @@ -76,10 +81,10 @@ async IAsyncEnumerable StreamTurn( [EnumeratorCancellation] CancellationToken cancellationToken = default) { - // The middleware no longer auto-emits request-start statuses — the app owns them. - // Prepended outside the recording loop, the status drives the Live header immediately - // without ever entering the chat history or the usage report. - yield return ChatProgressUpdate.CreateRequestStarted().ToResponseUpdate(); + // The middleware never emits request-start statuses — the app owns them via the Custom + // kind. Prepended outside the recording loop, the status drives the Live header + // immediately without ever entering the chat history or the usage report. + yield return ChatProgressUpdate.CreateCustom("Starting request").ToResponseUpdate(); await foreach (ChatResponseUpdate update in client.GetStreamingResponseAsync(history, chatOptions, cancellationToken)) { @@ -121,12 +126,22 @@ await AnsiConsole.Live(Text.Empty) await ConsumeAsync(render: null); } - // The last snapshot already carries the completed phase and total usage from the - // trailing Finished event; see the main Demo's FinalSnapshot for the report-merge - // pattern that adds per-activity token usage. + // The last snapshot already carries the completed phase, the full reasoning text, and + // total usage. The recorded raw updates additionally carry the middleware's + // ReasoningCompleted statuses — summing their Duration (one per model turn) gives the + // total time the model spent reasoning, shown on the final frame's reasoning panel. if (last is not null) { - AnsiConsole.Write(StatusRenderer.RenderFinal(last)); + TimeSpan? reasoningDuration = updates + .SelectMany(update => update.Contents) + .OfType() + .Select(content => content.Progress) + .Where(progress => progress.Kind == ChatProgressKind.ReasoningCompleted) + .Aggregate( + default(TimeSpan?), + (total, progress) => progress.Duration is { } duration ? (total ?? TimeSpan.Zero) + duration : total); + + AnsiConsole.Write(StatusRenderer.RenderFinal(last, reasoningDuration)); } // Strip only the synthetic progress/usage content before history re-enters the next diff --git a/samples/Andes.Extensions.AI.Demo.Responses/README.md b/samples/Andes.Extensions.AI.Demo.Responses/README.md index f86a208..c038bbe 100644 --- a/samples/Andes.Extensions.AI.Demo.Responses/README.md +++ b/samples/Andes.Extensions.AI.Demo.Responses/README.md @@ -1,15 +1,16 @@ # Andes.Extensions.AI Responses Demo -An interactive console chat like the [main demo](../Andes.Extensions.AI.Demo/README.md), but built on the **Azure OpenAI Responses API** instead of Chat Completions — the pipeline where the core package's detection-driven **`Reasoning` status** comes alive. Every turn streams through `ToStatusSnapshotsAsync()` and renders live with [Spectre.Console](https://spectreconsole.net/); while the model works through its hidden reasoning, the header switches to "Reasoning..." the moment reasoning summaries start streaming. The project is intentionally not packable (`samples/Directory.Build.props` sets `IsPackable=false`) — it never ships to NuGet. +An interactive console chat like the [main demo](../Andes.Extensions.AI.Demo/README.md), but built on the **Azure OpenAI Responses API** instead of Chat Completions — the pipeline where the core package's detection-driven **`Reasoning` status** comes alive. Every turn streams through `ToStatusSnapshotsAsync()` and renders live with [Spectre.Console](https://spectreconsole.net/); while the model works through its hidden reasoning, the header switches to "Reasoning..." the moment reasoning summaries start streaming — and flips to "Reasoning completed" the moment the answer (or the next tool call) starts. The project is intentionally not packable (`samples/Directory.Build.props` sets `IsPackable=false`) — it never ships to NuGet. ## What it demonstrates | Feature | Where | | --- | --- | | Responses API with **stable packages only**: the stable `Azure.AI.OpenAI` client has no Responses surface, so the plain `OpenAIClient` (stable `OpenAI` 2.12+) targets Azure's OpenAI-v1-compatible endpoint (`https://{resource}.openai.azure.com/openai/v1`) and `GetResponsesClient().AsIChatClient(deployment)` adapts it to `IChatClient` | `Program.cs` | -| Requesting reasoning summaries provider-agnostically with `ChatOptions.Reasoning = new ReasoningOptions { Output = ReasoningOutput.Summary }` — the summaries stream back as `TextReasoningContent` | `Program.cs` | -| The middleware's detection-driven `Reasoning` status: emitted once per model turn when reasoning content is detected, re-armed after each tool round-trip — no synthetic "Thinking" guesses | core `ToolTrackingChatClient` (just observe the header) | -| A developer-emitted request status: the middleware no longer auto-announces request start, so the app prepends `ChatProgressUpdate.CreateRequestStarted().ToResponseUpdate()` to the stream the renderer consumes | `Program.cs` (`StreamTurn`) | +| Requesting reasoning summaries provider-agnostically with `ChatOptions.Reasoning = new ReasoningOptions { Output = ReasoningOutput.Full }` — the summaries stream back as `TextReasoningContent`. `Full` maps to the Responses summary verbosity "detailed"; `Summary` would map to "concise", which gpt-5-series deployments reject (supported: auto, detailed), so no summaries would stream | `Program.cs` | +| The middleware's detection-driven `Reasoning` status and its `ReasoningCompleted` close: emitted once per model turn when reasoning content is detected, closed (with the elapsed reasoning time) when the answer or the next tool call starts, re-armed after each tool round-trip — no synthetic "Thinking" guesses | core `ToolTrackingChatClient` (just observe the header) | +| A developer-emitted request status: the middleware no longer auto-announces request start, so the app prepends `ChatProgressUpdate.CreateCustom("Starting request").ToResponseUpdate()` to the stream the renderer consumes | `Program.cs` (`StreamTurn`) | +| The model's reasoning summary text, live and persistent: `ReasoningDelta` events accumulate into `AssistantStatusSnapshot.ReasoningText`; the Live frame shows the last six lines in a dimmed "reasoning" panel, and the final frame keeps the full text with the total reasoning time (summed from the `ReasoningCompleted` statuses' `Duration`) in the panel header | `StatusRenderer.cs` + `Program.cs` | | Local function tools reporting sub-statuses with numeric progress via `ChatProgress.Report(status, progress, progressTotal)` | `ResponsesDemoTools.cs` | | `ToStatusSnapshotsAsync()` → `AssistantStatusSnapshot` → Spectre.Console `Live` rendering | `StatusRenderer.cs` + `Program.cs` | @@ -56,17 +57,19 @@ Try the prompts printed at startup: > Get the weather in Quito, then convert the high to Fahrenheit. -A tool loop over the Responses API: the header shows "Reasoning..." while the model plans each call, then the `fn` activity cards stream their numeric sub-status progress. +A tool loop over the Responses API: the header shows "Reasoning..." while the model plans each call and flips to "Reasoning completed" as the call starts, then the `fn` activity cards stream their numeric sub-status progress. > What is the sum of the first ten prime numbers? Reason it out. -A pure reasoning turn — no tools, just the detection-driven status followed by the streamed answer. +A pure reasoning turn — no tools, just the detection-driven status pair ("Reasoning..." flips to "Reasoning completed" as the answer begins) followed by the streamed answer. Exit with an empty line, `exit`, or `quit`. In a non-interactive console (piped input or redirected output — scripts, CI) the Spectre `Live` region is skipped and only the persistent final frame of each turn is rendered. ## How it fits together -`Program.cs` builds the pipeline with the one ordering invariant: `UseToolTracking()` **before** `UseFunctionInvocation()`. The stream tee prepends a developer-emitted `RequestStarted` status outside the recording loop, so the Live header lights up immediately while the synthetic update never enters the history or the usage report. +`Program.cs` builds the pipeline with the one ordering invariant: `UseToolTracking()` **before** `UseFunctionInvocation()`. The stream tee prepends a developer-emitted `Custom` status ("Starting request") outside the recording loop, so the Live header lights up immediately while the synthetic update never enters the history or the usage report. + +While the model reasons, the summary text streams in as `ReasoningDelta` events and accumulates into `AssistantStatusSnapshot.ReasoningText`; the Live frame shows its last six lines in a dimmed "reasoning" panel beneath the header, and the persistent final frame keeps the **full** reasoning text alongside the tool-call cards and the answer. The final panel's header also shows the total time the model spent reasoning — summed from the `ChatProgressKind.ReasoningCompleted` statuses (one per model turn) recorded in the raw update stream, each carrying the middleware-measured `Duration`. When history re-enters the next request, only the synthetic progress/usage content is stripped (`StripProgressContent()`). `TextReasoningContent` is deliberately kept: the Responses API expects prior reasoning items to be replayed across tool round-trips and follow-up turns. diff --git a/samples/Andes.Extensions.AI.Demo.Responses/StatusRenderer.cs b/samples/Andes.Extensions.AI.Demo.Responses/StatusRenderer.cs index daeae30..40b2cdb 100644 --- a/samples/Andes.Extensions.AI.Demo.Responses/StatusRenderer.cs +++ b/samples/Andes.Extensions.AI.Demo.Responses/StatusRenderer.cs @@ -12,11 +12,18 @@ internal static class StatusRenderer { public static IRenderable RenderLive(AssistantStatusSnapshot snapshot) { - // The Live region cannot scroll, so only the tail of the streamed answer is shown - // while working; RenderFinal prints the full text once the turn completes. + // The Live region cannot scroll, so only the tails of the streamed reasoning and answer + // are shown while working; RenderFinal prints both in full once the turn completes. const int liveTextTailLines = 10; + const int liveReasoningTailLines = 6; var rows = new List { Header(snapshot) }; + + if (!string.IsNullOrEmpty(snapshot.ReasoningText)) + { + rows.Add(ReasoningPanel(TailLines(snapshot.ReasoningText, liveReasoningTailLines))); + } + AppendActivities(rows, snapshot); if (!string.IsNullOrEmpty(snapshot.Text)) { @@ -26,9 +33,18 @@ public static IRenderable RenderLive(AssistantStatusSnapshot snapshot) return new Rows(rows); } - public static IRenderable RenderFinal(AssistantStatusSnapshot snapshot) + public static IRenderable RenderFinal(AssistantStatusSnapshot snapshot, TimeSpan? reasoningDuration = null) { var rows = new List(); + + // The full reasoning persists in the final frame, first — it happened before the tool + // calls and the answer. The header carries the middleware-measured reasoning time when + // the ReasoningCompleted statuses supplied one. + if (!string.IsNullOrEmpty(snapshot.ReasoningText)) + { + rows.Add(ReasoningPanel(snapshot.ReasoningText, reasoningDuration)); + } + AppendActivities(rows, snapshot); if (!string.IsNullOrEmpty(snapshot.Text)) { @@ -48,6 +64,12 @@ public static IRenderable RenderFailed(AssistantStatusSnapshot? snapshot, Except var rows = new List(); if (snapshot is not null) { + // Whatever the model reasoned before the failure is often the best clue — keep it. + if (!string.IsNullOrEmpty(snapshot.ReasoningText)) + { + rows.Add(ReasoningPanel(snapshot.ReasoningText)); + } + // Request-level failure is reported out-of-band (observers only), so the last // snapshot still says Running — flip the running cards to failed for display. AppendActivities(rows, snapshot, forceRunningToFailed: true); @@ -145,6 +167,18 @@ private static IRenderable TextPanel(string text) .Header("[dim]assistant[/]"); } + private static IRenderable ReasoningPanel(string text, TimeSpan? duration = null) + { + string header = duration is { } elapsed + ? $"[dim]reasoning · {elapsed.TotalSeconds:0.0}s[/]" + : "[dim]reasoning[/]"; + + return new Panel(new Markup($"[dim italic]{Markup.Escape(text)}[/]")) + .Border(BoxBorder.Rounded) + .BorderColor(Color.Grey) + .Header(header); + } + private static string TailLines(string text, int maxLines) { string[] lines = text.Split('\n'); diff --git a/samples/Andes.Extensions.AI.Demo/Program.cs b/samples/Andes.Extensions.AI.Demo/Program.cs index 98f4c16..43ffb7a 100644 --- a/samples/Andes.Extensions.AI.Demo/Program.cs +++ b/samples/Andes.Extensions.AI.Demo/Program.cs @@ -50,6 +50,11 @@ // No Temperature: reasoning-model deployments reject non-default values. var chatOptions = new ChatOptions { + Reasoning = new ReasoningOptions + { + Effort = ReasoningEffort.ExtraHigh, + Output = ReasoningOutput.Full, + }, Tools = [ AIFunctionFactory.Create(DemoTools.GetWeather), @@ -86,10 +91,10 @@ .. mcp.Tools.WithTracking(mcp.Client), async IAsyncEnumerable StreamTurn( [EnumeratorCancellation] CancellationToken cancellationToken = default) { - // The middleware no longer auto-emits request-start statuses — the app owns them. - // Prepended outside the recording loop, the status drives the Live header immediately - // without ever entering the chat history or the usage report. - yield return ChatProgressUpdate.CreateRequestStarted().ToResponseUpdate(); + // The middleware never emits request-start statuses — the app owns them via the Custom + // kind. Prepended outside the recording loop, the status drives the Live header + // immediately without ever entering the chat history or the usage report. + yield return ChatProgressUpdate.CreateCustom("Starting request").ToResponseUpdate(); await foreach (ChatResponseUpdate update in client.GetStreamingResponseAsync(history, chatOptions, cancellationToken)) { diff --git a/samples/Andes.Extensions.AI.Demo/README.md b/samples/Andes.Extensions.AI.Demo/README.md index d0c47ad..4814e29 100644 --- a/samples/Andes.Extensions.AI.Demo/README.md +++ b/samples/Andes.Extensions.AI.Demo/README.md @@ -10,7 +10,7 @@ An interactive, Claude-Code-style console chat that exercises all four packages | `DemoMcpServer.cs` | `Andes.Extensions.AI.Mcp` | A genuine in-process MCP client/server pair over pipe streams; `get_forecast` reports MCP progress notifications that the satellite bridges into chat progress; tools exposed via `WithTracking(client)` | | `DemoAgents.cs` | `Andes.Extensions.AI.Agent` | A "Research Agent" and a "Packing Agent", both over raw (untracked) Azure OpenAI clients. The Research Agent is a top-level tool (`WithTracking(reportFunctionCalls: true)`); the Packing Agent is wrapped once with `WithTracking()` and nests two ways — as a tool of the Research Agent and inside the `PlanTrip` tool body — rendering as a child activity card with its own usage either way. Inner clients stay untracked because `WithTracking`'s usage capture already attributes each agent's tokens — a tracked inner pipeline would double-count them | | `StatusRenderer.cs` + `Program.cs` | `Andes.Extensions.AI.UI` | `ToStatusSnapshotsAsync()` → `AssistantStatusSnapshot` → Spectre.Console `Live` rendering: an activity tree with `fn`/`mcp`/`agent` badges, nested child cards, per-step progress bars, durations, and token usage | -| `Program.cs` (`StreamTurn`) | `Andes.Extensions.AI` (core) | A developer-emitted request status: the middleware no longer auto-announces request start, so the app prepends `ChatProgressUpdate.CreateRequestStarted().ToResponseUpdate()` to the stream the renderer consumes — the header shows "Starting request" before the first tracked event arrives | +| `Program.cs` (`StreamTurn`) | `Andes.Extensions.AI` (core) | A developer-emitted request status: the middleware no longer auto-announces request start, so the app prepends `ChatProgressUpdate.CreateCustom("Starting request").ToResponseUpdate()` to the stream the renderer consumes — the header shows "Starting request" before the first tracked event arrives | | `FinalSnapshot.cs` | `Andes.Extensions.AI.UI` | The persistent end-of-turn frame: `ChatUsageReport.ToSnapshot()`'s report-derived tree (per-activity token usage lives only there) merged positionally with the last live snapshot's answer text and sub-status lines | ## Prerequisites @@ -70,7 +70,7 @@ Exit with an empty line, `exit`, or `quit`. In a non-interactive console (piped The Packing Agent is created once and shared by both nesting scenarios: registered as a tool of the Research Agent, and captured by the `PlanTrip` tool body. Either invocation path opens its own child scope, so the live tree and the final usage report both show it as a child of whatever called it. -Each turn tees the raw `ChatResponseUpdate` stream: one side drives the live renderer through `ToStatusSnapshotsAsync()`, the other is recorded for history. The tee prepends a developer-emitted `RequestStarted` status outside the recording loop, so the Live header lights up immediately while the synthetic update never enters the history or the usage report. After the stream drains, `FinalSnapshot.Merge` builds the persistent frame — the report's `ToSnapshot()` tree (the only place per-activity token usage exists) merged with the live snapshot's text and sub-statuses. Before the next turn, the app calls `StripProgressContent()` on the recorded response so the synthetic in-band progress and usage content never re-enters the request. +Each turn tees the raw `ChatResponseUpdate` stream: one side drives the live renderer through `ToStatusSnapshotsAsync()`, the other is recorded for history. The tee prepends a developer-emitted `Custom` status ("Starting request") outside the recording loop, so the Live header lights up immediately while the synthetic update never enters the history or the usage report. After the stream drains, `FinalSnapshot.Merge` builds the persistent frame — the report's `ToSnapshot()` tree (the only place per-activity token usage exists) merged with the live snapshot's text and sub-statuses. Before the next turn, the app calls `StripProgressContent()` on the recorded response so the synthetic in-band progress and usage content never re-enters the request. ## See also diff --git a/tests/Andes.Extensions.AI.Integration.Test/ResponsesStreamingIntegrationTests.cs b/tests/Andes.Extensions.AI.Integration.Test/ResponsesStreamingIntegrationTests.cs index d7a48b8..d8cc906 100644 --- a/tests/Andes.Extensions.AI.Integration.Test/ResponsesStreamingIntegrationTests.cs +++ b/tests/Andes.Extensions.AI.Integration.Test/ResponsesStreamingIntegrationTests.cs @@ -19,9 +19,13 @@ public async Task GetStreamingResponseAsync_ResponsesApi_DetectsReasoningAndTrac Skip.IfNot(_fixture.IsResponsesConfigured, AzureOpenAIFixture.ResponsesSkipReason); IChatClient client = CreateResponsesPipeline(); + // Full output maps to Responses summary verbosity "detailed". Not Summary: M.E.AI maps it + // to "concise", which gpt-5-series deployments reject (supported: auto, detailed) — no + // summaries would stream and detection would never fire. No Effort override: ExtraHigh + // maps to "xhigh", accepted only by gpt-5.1+. var options = new ChatOptions { - Reasoning = new ReasoningOptions { Output = ReasoningOutput.Summary }, + Reasoning = new ReasoningOptions { Output = ReasoningOutput.Full }, }; var updates = new List(); @@ -32,7 +36,7 @@ public async Task GetStreamingResponseAsync_ResponsesApi_DetectsReasoningAndTrac updates.Add(update); } - List progress = updates + var progress = updates .SelectMany(update => update.Contents) .OfType() .Select(content => content.Progress) @@ -43,7 +47,7 @@ public async Task GetStreamingResponseAsync_ResponsesApi_DetectsReasoningAndTrac .Single() .Report; - Assert.DoesNotContain(progress, update => update.Kind == ChatProgressKind.RequestStarted); + Assert.DoesNotContain(progress, update => update.Kind == ChatProgressKind.Custom); Assert.True(report.AssistantUsage.TotalTokenCount > 0, "The assistant should report token usage."); Assert.NotEmpty(string.Concat(updates.Select(update => update.Text))); @@ -59,6 +63,14 @@ public async Task GetStreamingResponseAsync_ResponsesApi_DetectsReasoningAndTrac int reasoningStatusIndex = IndexOfProgress(updates, ChatProgressKind.Reasoning); Assert.True(reasoningStatusIndex < reasoningContentIndex, $"The Reasoning status (index {reasoningStatusIndex}) must precede the reasoning content (index {reasoningContentIndex})."); + + // Ordering rather than counts: real turn structure varies by deployment. Every detected + // reasoning burst must be closed by a measured completion after it. + ChatProgressUpdate completed = Assert.Single(progress, update => update.Kind == ChatProgressKind.ReasoningCompleted); + Assert.NotNull(completed.Duration); + int completedStatusIndex = IndexOfProgress(updates, ChatProgressKind.ReasoningCompleted); + Assert.True(reasoningStatusIndex < completedStatusIndex, + $"The Reasoning status (index {reasoningStatusIndex}) must precede its completion (index {completedStatusIndex})."); } private IChatClient CreateResponsesPipeline() diff --git a/tests/Andes.Extensions.AI.Integration.Test/StreamingIntegrationTests.cs b/tests/Andes.Extensions.AI.Integration.Test/StreamingIntegrationTests.cs index ecc6970..c9110c0 100644 --- a/tests/Andes.Extensions.AI.Integration.Test/StreamingIntegrationTests.cs +++ b/tests/Andes.Extensions.AI.Integration.Test/StreamingIntegrationTests.cs @@ -29,7 +29,7 @@ public async Task GetStreamingResponseAsync_WithTool_TracksUsageAndProgress() updates.Add(update); } - List progress = updates + var progress = updates .SelectMany(update => update.Contents) .OfType() .Select(content => content.Progress) @@ -40,7 +40,7 @@ public async Task GetStreamingResponseAsync_WithTool_TracksUsageAndProgress() .Single() .Report; - Assert.DoesNotContain(progress, update => update.Kind == ChatProgressKind.RequestStarted); + Assert.DoesNotContain(progress, update => update.Kind == ChatProgressKind.Custom); Assert.Contains(progress, update => update.Kind == ChatProgressKind.ToolInvoking && update.ToolName == "GetCurrentTime"); Assert.Contains(progress, update => update.Kind == ChatProgressKind.ToolProgress && update.Message == "Formatting time..."); Assert.Contains(progress, update => update.Kind == ChatProgressKind.ToolCompleted); diff --git a/tests/Andes.Extensions.AI.UI.Unit.Test/AssistantStatusReducerTests.cs b/tests/Andes.Extensions.AI.UI.Unit.Test/AssistantStatusReducerTests.cs index 86d0bba..c617d57 100644 --- a/tests/Andes.Extensions.AI.UI.Unit.Test/AssistantStatusReducerTests.cs +++ b/tests/Andes.Extensions.AI.UI.Unit.Test/AssistantStatusReducerTests.cs @@ -111,4 +111,50 @@ public void Apply_TextDelta_AccumulatesText() Assert.Equal("Hello world", snapshot.Text); } + + [Fact] + public void Apply_ReasoningDelta_AccumulatesReasoningText() + { + var reducer = new AssistantStatusReducer(); + + reducer.Apply(new AssistantUiEvent { Kind = AssistantUiEventKind.ReasoningDelta, Text = "First part. " }); + AssistantStatusSnapshot snapshot = reducer.Apply(new AssistantUiEvent + { + Kind = AssistantUiEventKind.ReasoningDelta, + Text = "Second part.", + }); + + Assert.Equal("First part. Second part.", snapshot.ReasoningText); + Assert.Null(snapshot.Text); + } + + [Fact] + public void Apply_ReasoningDeltaAcrossActivities_KeepsAccumulating() + { + var reducer = new AssistantStatusReducer(); + + reducer.Apply(new AssistantUiEvent { Kind = AssistantUiEventKind.ReasoningDelta, Text = "planning the call" }); + reducer.Apply(new AssistantUiEvent + { + Kind = AssistantUiEventKind.ActivityStarted, + ScopeId = "scope-1", + DisplayName = "GetWeather", + ToolKind = ToolKind.Function, + }); + reducer.Apply(new AssistantUiEvent + { + Kind = AssistantUiEventKind.ActivityCompleted, + ScopeId = "scope-1", + DurationSeconds = 0.4, + }); + AssistantStatusSnapshot snapshot = reducer.Apply(new AssistantUiEvent + { + Kind = AssistantUiEventKind.ReasoningDelta, + Text = "interpreting the result", + }); + + // Deltas concatenate verbatim across the whole request — no synthetic separators — + // matching the TypeScript foldAssistantEvents counterpart exactly. + Assert.Equal("planning the callinterpreting the result", snapshot.ReasoningText); + } } diff --git a/tests/Andes.Extensions.AI.UI.Unit.Test/AssistantUiJsonContextTests.cs b/tests/Andes.Extensions.AI.UI.Unit.Test/AssistantUiJsonContextTests.cs index b7650bd..c67bb31 100644 --- a/tests/Andes.Extensions.AI.UI.Unit.Test/AssistantUiJsonContextTests.cs +++ b/tests/Andes.Extensions.AI.UI.Unit.Test/AssistantUiJsonContextTests.cs @@ -41,6 +41,29 @@ public void Serialize_Snapshot_UsesCamelCaseStringEnumsAndOmitsNulls() Assert.DoesNotContain("MCP MCP", json); Assert.DoesNotContain("\"usage\"", json); Assert.DoesNotContain("\"text\"", json); + Assert.DoesNotContain("\"reasoningText\"", json); + } + + [Fact] + public void Serialize_SnapshotWithReasoningText_EmitsCamelCaseProperty() + { + var snapshot = new AssistantStatusSnapshot + { + Phase = ActivityState.Running, + ReasoningText = "planning the call", + }; + var uiEvent = new AssistantUiEvent + { + Kind = AssistantUiEventKind.ReasoningDelta, + Text = "planning the call", + }; + + string snapshotJson = JsonSerializer.Serialize(snapshot, AssistantUiJsonContext.Default.AssistantStatusSnapshot); + string eventJson = JsonSerializer.Serialize(uiEvent, AssistantUiJsonContext.Default.AssistantUiEvent); + + Assert.Contains("\"reasoningText\":\"planning the call\"", snapshotJson); + Assert.Contains("\"kind\":\"ReasoningDelta\"", eventJson); + Assert.Contains("\"text\":\"planning the call\"", eventJson); } [Fact] diff --git a/tests/Andes.Extensions.AI.UI.Unit.Test/ChatResponseUiExtensionsTests.cs b/tests/Andes.Extensions.AI.UI.Unit.Test/ChatResponseUiExtensionsTests.cs index de2d609..25672ca 100644 --- a/tests/Andes.Extensions.AI.UI.Unit.Test/ChatResponseUiExtensionsTests.cs +++ b/tests/Andes.Extensions.AI.UI.Unit.Test/ChatResponseUiExtensionsTests.cs @@ -91,14 +91,14 @@ public async Task ToStatusSnapshotsAsync_FunctionTool_FoldsIntoCompletedActivity } [Fact] - public async Task ToStatusSnapshotsAsync_DevPrependedRequestStarted_SetsAssistantStatus() + public async Task ToStatusSnapshotsAsync_DevPrependedCustomStatus_SetsAssistantStatus() { var scripted = new ScriptedChatClient(ScriptedTurn.Text("Done.")); IChatClient client = TestPipeline.Build(scripted); async IAsyncEnumerable StreamWithPrependedStatus() { - yield return ChatProgressUpdate.CreateRequestStarted().ToResponseUpdate(); + yield return ChatProgressUpdate.CreateCustom("Starting request").ToResponseUpdate(); await foreach (ChatResponseUpdate update in client.GetStreamingResponseAsync("prompt")) { @@ -121,6 +121,96 @@ async IAsyncEnumerable StreamWithPrependedStatus() Assert.Contains("Done.", last.Text); } + [Fact] + public async Task ToUiEventsAsync_ReasoningContent_EmitsReasoningDeltasInOrder() + { + var scripted = new ScriptedChatClient(new ScriptedTurn + { + Updates = + [ + new ChatResponseUpdate(ChatRole.Assistant, [new TextReasoningContent("weighing options. ")]), + new ChatResponseUpdate(ChatRole.Assistant, [new TextReasoningContent("choosing an answer.")]), + new ChatResponseUpdate(ChatRole.Assistant, "The answer is 42."), + ], + }); + IChatClient client = TestPipeline.Build(scripted); + + List events = await CollectAsync(client, new ChatOptions()); + + List reasoningDeltas = [.. events.Where(e => e.Kind == AssistantUiEventKind.ReasoningDelta)]; + Assert.Equal(2, reasoningDeltas.Count); + Assert.Equal("weighing options. ", reasoningDeltas[0].Text); + Assert.Equal("choosing an answer.", reasoningDeltas[1].Text); + + AssistantUiEvent reasoningStatus = Assert.Single( + events, + e => e.Kind == AssistantUiEventKind.Status && e.Message == "Reasoning..."); + Assert.True( + events.IndexOf(reasoningStatus) < events.IndexOf(reasoningDeltas[0]), + "The Reasoning status must precede the first reasoning delta."); + + AssistantUiEvent textDelta = Assert.Single(events, e => e.Kind == AssistantUiEventKind.TextDelta); + Assert.Equal("The answer is 42.", textDelta.Text); + } + + [Fact] + public async Task ToUiEventsAsync_EmptyReasoningContent_EmitsNoReasoningDelta() + { + var scripted = new ScriptedChatClient(new ScriptedTurn + { + Updates = + [ + // The encrypted-only shape: empty text, nothing renderable. + new ChatResponseUpdate(ChatRole.Assistant, [new TextReasoningContent(string.Empty)]), + new ChatResponseUpdate(ChatRole.Assistant, "Done."), + ], + }); + IChatClient client = TestPipeline.Build(scripted); + + List events = await CollectAsync(client, new ChatOptions()); + + Assert.DoesNotContain(events, e => e.Kind == AssistantUiEventKind.ReasoningDelta); + } + + [Fact] + public async Task ToStatusSnapshotsAsync_ReasoningAcrossToolRoundTrip_AccumulatesReasoningText() + { + var scripted = new ScriptedChatClient( + new ScriptedTurn + { + Updates = + [ + new ChatResponseUpdate(ChatRole.Assistant, [new TextReasoningContent("planning the call")]), + new ChatResponseUpdate(ChatRole.Assistant, [new FunctionCallContent("call-1", "GetWeather")]), + ], + }, + new ScriptedTurn + { + Updates = + [ + new ChatResponseUpdate(ChatRole.Assistant, [new TextReasoningContent("interpreting the result")]), + new ChatResponseUpdate(ChatRole.Assistant, "It's sunny."), + ], + }); + AIFunction tool = AIFunctionFactory.Create(() => "sunny", "GetWeather"); + IChatClient client = TestPipeline.Build(scripted); + + AssistantStatusSnapshot? last = null; + await foreach (AssistantStatusSnapshot snapshot in client + .GetStreamingResponseAsync("prompt", new ChatOptions { Tools = [tool] }) + .ToStatusSnapshotsAsync()) + { + last = snapshot; + } + + Assert.NotNull(last); + Assert.Equal("planning the callinterpreting the result", last!.ReasoningText); + Assert.Equal(ActivityState.Completed, last.Phase); + AssistantActivity activity = Assert.Single(last.Activities); + Assert.Equal(ActivityState.Completed, activity.State); + Assert.Contains("It's sunny.", last.Text); + } + private static async Task> CollectAsync(IChatClient client, ChatOptions options) { var events = new List(); diff --git a/tests/Andes.Extensions.AI.Unit.Test/ChatProgressUpdateFactoryTests.cs b/tests/Andes.Extensions.AI.Unit.Test/ChatProgressUpdateFactoryTests.cs index 42afe08..72fea1b 100644 --- a/tests/Andes.Extensions.AI.Unit.Test/ChatProgressUpdateFactoryTests.cs +++ b/tests/Andes.Extensions.AI.Unit.Test/ChatProgressUpdateFactoryTests.cs @@ -5,55 +5,35 @@ namespace Andes.Extensions.AI.Unit.Test; public class ChatProgressUpdateFactoryTests { [Fact] - public void CreateRequestStarted_Default_PopulatesWellKnownFields() + public void CreateCustom_Message_PopulatesWellKnownFields() { DateTimeOffset before = DateTimeOffset.UtcNow; - ChatProgressUpdate update = ChatProgressUpdate.CreateRequestStarted(); + ChatProgressUpdate update = ChatProgressUpdate.CreateCustom("Warming up…"); DateTimeOffset after = DateTimeOffset.UtcNow; - Assert.Equal(ChatProgressKind.RequestStarted, update.Kind); - Assert.Equal("Starting request", update.Message); + Assert.Equal(ChatProgressKind.Custom, update.Kind); + Assert.Equal("Warming up…", update.Message); Assert.Equal(ChatProgressUpdate.ExternalScopeId, update.ScopeId); Assert.Equal(0, update.Depth); Assert.InRange(update.Timestamp, before, after); } [Fact] - public void CreateRequestStarted_CustomMessage_UsesIt() - { - ChatProgressUpdate update = ChatProgressUpdate.CreateRequestStarted("Warming up…"); - - Assert.Equal(ChatProgressKind.RequestStarted, update.Kind); - Assert.Equal("Warming up…", update.Message); - } - - [Fact] - public void CreateReasoning_Default_PopulatesWellKnownFields() + public void CreateCustom_NullMessage_Throws() { - DateTimeOffset before = DateTimeOffset.UtcNow; - ChatProgressUpdate update = ChatProgressUpdate.CreateReasoning(); - DateTimeOffset after = DateTimeOffset.UtcNow; - - Assert.Equal(ChatProgressKind.Reasoning, update.Kind); - Assert.Equal("Reasoning...", update.Message); - Assert.Equal(ChatProgressUpdate.ExternalScopeId, update.ScopeId); - Assert.Equal(0, update.Depth); - Assert.InRange(update.Timestamp, before, after); + Assert.Throws(() => ChatProgressUpdate.CreateCustom(null!)); } [Fact] - public void CreateReasoning_CustomMessage_UsesIt() + public void CreateCustom_EmptyMessage_Throws() { - ChatProgressUpdate update = ChatProgressUpdate.CreateReasoning("Pondering deeply…"); - - Assert.Equal(ChatProgressKind.Reasoning, update.Kind); - Assert.Equal("Pondering deeply…", update.Message); + Assert.Throws(() => ChatProgressUpdate.CreateCustom(string.Empty)); } [Fact] public void ToResponseUpdate_Always_WrapsSingleProgressContent() { - ChatProgressUpdate update = ChatProgressUpdate.CreateRequestStarted(); + ChatProgressUpdate update = ChatProgressUpdate.CreateCustom("Starting request"); ChatResponseUpdate wrapped = update.ToResponseUpdate(); diff --git a/tests/Andes.Extensions.AI.Unit.Test/NonStreamingTests.cs b/tests/Andes.Extensions.AI.Unit.Test/NonStreamingTests.cs index 7149b84..6622a96 100644 --- a/tests/Andes.Extensions.AI.Unit.Test/NonStreamingTests.cs +++ b/tests/Andes.Extensions.AI.Unit.Test/NonStreamingTests.cs @@ -25,7 +25,7 @@ public async Task GetResponseAsync_ToolLoop_NotifiesObserversInOrder() List kinds = observer.Updates.Select(update => update.Kind).ToList(); Assert.Equal(ChatProgressKind.ToolInvoking, kinds[0]); - Assert.DoesNotContain(ChatProgressKind.RequestStarted, kinds); + Assert.DoesNotContain(ChatProgressKind.Custom, kinds); Assert.DoesNotContain(ChatProgressKind.Reasoning, kinds); Assert.Contains(ChatProgressKind.ToolProgress, kinds); Assert.Contains(ChatProgressKind.ToolCompleted, kinds); diff --git a/tests/Andes.Extensions.AI.Unit.Test/ReasoningDetectionTests.cs b/tests/Andes.Extensions.AI.Unit.Test/ReasoningDetectionTests.cs index 2e4ece7..7a074b5 100644 --- a/tests/Andes.Extensions.AI.Unit.Test/ReasoningDetectionTests.cs +++ b/tests/Andes.Extensions.AI.Unit.Test/ReasoningDetectionTests.cs @@ -34,6 +34,94 @@ public async Task GetStreamingResponseAsync_ReasoningContent_EmitsSingleReasonin $"The Reasoning status (index {reasoningIndex}) must precede the reasoning content (index {contentIndex})."); } + [Fact] + public async Task GetStreamingResponseAsync_ReasoningThenText_EmitsSingleReasoningCompleted() + { + var scripted = new ScriptedChatClient(new ScriptedTurn + { + Updates = + [ + new ChatResponseUpdate(ChatRole.Assistant, [new TextReasoningContent("secret chain of thought")]), + new ChatResponseUpdate(ChatRole.Assistant, [new TextReasoningContent("more hidden reasoning")]), + new ChatResponseUpdate(ChatRole.Assistant, string.Empty), + new ChatResponseUpdate(ChatRole.Assistant, "The answer is 42."), + ], + }); + IChatClient client = TestPipeline.Build(scripted); + + List updates = await TestPipeline.CollectAsync(client); + List progress = TestPipeline.ProgressOf(updates); + + ChatProgressUpdate completed = Assert.Single(progress, update => update.Kind == ChatProgressKind.ReasoningCompleted); + Assert.Equal("Reasoning completed", completed.Message); + Assert.Equal(0, completed.Depth); + Assert.NotNull(completed.Duration); + Assert.True(completed.Duration >= TimeSpan.Zero); + Assert.DoesNotContain("chain of thought", completed.Message); + + // The empty text chunk must not close the turn; completion coincides with the first + // non-empty answer text, landing after the Reasoning status and before that text update. + int reasoningIndex = TestPipeline.IndexOfProgress(updates, ChatProgressKind.Reasoning); + int completedIndex = TestPipeline.IndexOfProgress(updates, ChatProgressKind.ReasoningCompleted); + int textIndex = updates.FindIndex(update => update.Text == "The answer is 42."); + Assert.True(reasoningIndex < completedIndex && completedIndex < textIndex, + $"Expected Reasoning ({reasoningIndex}) < ReasoningCompleted ({completedIndex}) < answer text ({textIndex})."); + int emptyTextIndex = updates.FindIndex(update => + update.Contents.OfType().Any(text => text.Text.Length == 0)); + Assert.True(emptyTextIndex >= 0 && emptyTextIndex < completedIndex, + "The empty text chunk must not close the reasoning turn."); + } + + [Fact] + public async Task GetStreamingResponseAsync_ReasoningThenUnwrappedToolCall_CompletesBeforeToolHeader() + { + var scripted = new ScriptedChatClient( + new ScriptedTurn + { + Updates = + [ + new ChatResponseUpdate(ChatRole.Assistant, [new TextReasoningContent("planning the call")]), + new ChatResponseUpdate(ChatRole.Assistant, [new FunctionCallContent("call-1", "SomeHostedTool")]), + ], + }, + ScriptedTurn.Text("Handled elsewhere.")); + AIFunction tool = AIFunctionFactory.Create(() => "sunny", "GetWeather"); + IChatClient client = TestPipeline.Build(scripted); + + List updates = await TestPipeline.CollectAsync(client, new ChatOptions { Tools = [tool] }); + List progress = TestPipeline.ProgressOf(updates); + + // "SomeHostedTool" is not a wrapped tool, so its sighting emits the best-effort + // ToolInvoking header from within the same inspection pass — the completion must win. + List kinds = progress.Select(update => update.Kind).ToList(); + int completedIndex = kinds.IndexOf(ChatProgressKind.ReasoningCompleted); + int toolInvokingIndex = kinds.IndexOf(ChatProgressKind.ToolInvoking); + Assert.True(completedIndex >= 0 && toolInvokingIndex >= 0 && completedIndex < toolInvokingIndex, + $"ReasoningCompleted ({completedIndex}) must precede the unwrapped tool header ({toolInvokingIndex})."); + } + + [Fact] + public async Task GetStreamingResponseAsync_ReasoningOnlyTurn_ClosesAtStreamEnd() + { + var scripted = new ScriptedChatClient(new ScriptedTurn + { + Updates = + [ + new ChatResponseUpdate(ChatRole.Assistant, [new TextReasoningContent("thinking without answering")]), + ], + }); + IChatClient client = TestPipeline.Build(scripted); + + List updates = await TestPipeline.CollectAsync(client); + List progress = TestPipeline.ProgressOf(updates); + + ChatProgressUpdate completed = Assert.Single(progress, update => update.Kind == ChatProgressKind.ReasoningCompleted); + Assert.NotNull(completed.Duration); + Assert.Equal(ChatProgressKind.RequestCompleted, progress[^1].Kind); + Assert.True(progress.IndexOf(completed) < progress.Count - 1, + "The stream-end close must precede the trailing RequestCompleted."); + } + [Fact] public async Task GetStreamingResponseAsync_ReasoningAcrossToolRoundTrip_ReemitsPerTurn() { @@ -62,15 +150,22 @@ public async Task GetStreamingResponseAsync_ReasoningAcrossToolRoundTrip_Reemits List kinds = progress.Select(update => update.Kind).ToList(); Assert.Equal(2, kinds.Count(kind => kind == ChatProgressKind.Reasoning)); + Assert.Equal(2, kinds.Count(kind => kind == ChatProgressKind.ReasoningCompleted)); int firstReasoning = kinds.IndexOf(ChatProgressKind.Reasoning); + int firstCompleted = kinds.IndexOf(ChatProgressKind.ReasoningCompleted); int toolInvoking = kinds.IndexOf(ChatProgressKind.ToolInvoking); int toolCompleted = kinds.IndexOf(ChatProgressKind.ToolCompleted); int secondReasoning = kinds.LastIndexOf(ChatProgressKind.Reasoning); + int secondCompleted = kinds.LastIndexOf(ChatProgressKind.ReasoningCompleted); Assert.True(firstReasoning < toolInvoking, "The first turn's Reasoning must precede the tool header."); + Assert.True(firstReasoning < firstCompleted && firstCompleted < toolInvoking, + "The first turn's ReasoningCompleted must close before the tool header."); Assert.True(toolCompleted < secondReasoning, "The second turn's Reasoning must follow the tool completion."); + Assert.True(secondReasoning < secondCompleted, + "The second turn's ReasoningCompleted must follow its Reasoning."); } [Fact] @@ -86,8 +181,9 @@ public async Task GetStreamingResponseAsync_NoReasoningContent_EmitsNoRequestLev List progress = TestPipeline.ProgressOf(updates); Assert.Equal(ChatProgressKind.ToolInvoking, progress[0].Kind); - Assert.DoesNotContain(progress, update => update.Kind == ChatProgressKind.RequestStarted); + Assert.DoesNotContain(progress, update => update.Kind == ChatProgressKind.Custom); Assert.DoesNotContain(progress, update => update.Kind == ChatProgressKind.Reasoning); + Assert.DoesNotContain(progress, update => update.Kind == ChatProgressKind.ReasoningCompleted); } [Fact] @@ -112,4 +208,29 @@ public async Task GetResponseAsync_ReasoningContent_NotifiesObserversOnce() Assert.DoesNotContain("weighing options", reasoning.Message); Assert.Contains("Done.", response.Text); } + + [Fact] + public async Task GetResponseAsync_ReasoningContent_NotifiesObserversWithCompletedPair() + { + var observer = new CollectingProgressObserver(); + var scripted = new ScriptedChatClient(new ScriptedTurn + { + Updates = + [ + new ChatResponseUpdate(ChatRole.Assistant, [new TextReasoningContent("weighing options")]), + new ChatResponseUpdate(ChatRole.Assistant, "Done."), + ], + }); + IChatClient client = TestPipeline.Build(scripted, options => options.Observers.Add(observer)); + + await client.GetResponseAsync("prompt"); + + List notified = [.. observer.Updates]; + ChatProgressUpdate reasoning = Assert.Single(notified, update => update.Kind == ChatProgressKind.Reasoning); + ChatProgressUpdate completed = Assert.Single(notified, update => update.Kind == ChatProgressKind.ReasoningCompleted); + Assert.Equal("Reasoning completed", completed.Message); + Assert.Null(completed.Duration); + Assert.True(notified.IndexOf(reasoning) < notified.IndexOf(completed), + "The post-hoc pair must arrive in detection-then-completion order."); + } } diff --git a/tests/Andes.Extensions.AI.Unit.Test/StreamingProgressTests.cs b/tests/Andes.Extensions.AI.Unit.Test/StreamingProgressTests.cs index aa34a23..507ca04 100644 --- a/tests/Andes.Extensions.AI.Unit.Test/StreamingProgressTests.cs +++ b/tests/Andes.Extensions.AI.Unit.Test/StreamingProgressTests.cs @@ -39,7 +39,7 @@ public async Task GetStreamingResponseAsync_ToolLoop_EmitsEventsInExpectedOrder( Assert.Equal("Calling GetWeather Tool", progress[toolInvoking].Message); Assert.Equal("Extracting...", progress[toolProgress].Message); - Assert.DoesNotContain(ChatProgressKind.RequestStarted, kinds); + Assert.DoesNotContain(ChatProgressKind.Custom, kinds); Assert.DoesNotContain(ChatProgressKind.Reasoning, kinds); Assert.Equal(ChatProgressKind.RequestCompleted, progress[^1].Kind); Assert.IsType(updates[^1].Contents.Single()); @@ -63,7 +63,7 @@ public async Task GetStreamingResponseAsync_ProgressContentDisabled_ObserversSti Assert.Empty(updates.SelectMany(update => update.Contents).OfType()); Assert.NotEmpty(updates.SelectMany(update => update.Contents).OfType()); - Assert.DoesNotContain(observer.Updates, update => update.Kind == ChatProgressKind.RequestStarted); + Assert.DoesNotContain(observer.Updates, update => update.Kind == ChatProgressKind.Custom); Assert.Contains(observer.Updates, update => update.Kind == ChatProgressKind.RequestCompleted); Assert.NotNull(observer.Report); } From b20cc84719b288225f3c453d567434183351743a Mon Sep 17 00:00:00 2001 From: Rodrigo Rojas Date: Thu, 6 Aug 2026 01:28:56 -0400 Subject: [PATCH 8/8] Remove ExtraHigh effort setting from chat options reasoning --- samples/Andes.Extensions.AI.Demo/Program.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/samples/Andes.Extensions.AI.Demo/Program.cs b/samples/Andes.Extensions.AI.Demo/Program.cs index 43ffb7a..a200df0 100644 --- a/samples/Andes.Extensions.AI.Demo/Program.cs +++ b/samples/Andes.Extensions.AI.Demo/Program.cs @@ -52,7 +52,6 @@ { Reasoning = new ReasoningOptions { - Effort = ReasoningEffort.ExtraHigh, Output = ReasoningOutput.Full, }, Tools =