diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 941ba5b..bfb1e11 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 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). @@ -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` 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`). @@ -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) @@ -126,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.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.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 687ea6c..ed272d7 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; } @@ -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 fec2314..7505835 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, @@ -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 ad36bb7..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. */ @@ -77,7 +78,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; @@ -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/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/Andes.Extensions.AI/Internal/RequestTracker.cs b/Andes.Extensions.AI/Internal/RequestTracker.cs index 47faf8a..4eb2722 100644 --- a/Andes.Extensions.AI/Internal/RequestTracker.cs +++ b/Andes.Extensions.AI/Internal/RequestTracker.cs @@ -20,6 +20,9 @@ internal sealed class RequestTracker private readonly long _startTimestamp; private int _scopeCounter; private int _iteration; + private bool _reasoningAnnounced; + private bool _reasoningCompleted; + private long _reasoningStartTimestamp; private string? _lastResponseId; private string? _lastModelId; @@ -45,27 +48,75 @@ 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() { + lock (_lock) + { + if (_reasoningAnnounced) + { + return; + } + + _reasoningAnnounced = true; + _reasoningCompleted = false; + _reasoningStartTimestamp = Options.TimeProvider.GetTimestamp(); + } + Emit(new ChatProgressUpdate { - Kind = ChatProgressKind.RequestStarted, - Message = "Starting request", + Kind = ChatProgressKind.Reasoning, + Message = "Reasoning...", ScopeId = RootScope.ScopeId, Depth = 0, Timestamp = Now(), }); } - public void EmitThinking() + /// + /// 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.Thinking, - Message = "Thinking...", + Kind = ChatProgressKind.ReasoningCompleted, + Message = "Reasoning completed", ScopeId = RootScope.ScopeId, Depth = 0, Timestamp = Now(), + Duration = measured ? Options.TimeProvider.GetElapsedTime(startTimestamp) : null, }); } @@ -214,16 +265,17 @@ 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; + _reasoningCompleted = 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..edca183 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 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, /// - /// 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; the library provides no factory for it — the middleware raises it when it detects + /// reasoning content. /// - Thinking = 1, + Reasoning = 1, /// /// A tool invocation is starting; the message carries the display header (for example, "Calling GetWeather Tool"). @@ -45,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 3de5c96..244756e 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 + /// . + /// 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. /// @@ -67,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; } @@ -93,4 +103,37 @@ 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 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. + /// 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.CreateCustom("Starting request").ToResponseUpdate(); + /// + /// + public static ChatProgressUpdate CreateCustom(string message) + { + ArgumentException.ThrowIfNullOrEmpty(message); + + return new ChatProgressUpdate + { + 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 new file mode 100644 index 0000000..d905cb2 --- /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.CreateCustom("Starting request").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..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( @@ -57,9 +51,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 +73,15 @@ 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/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(); tracker.EmitRequestCompleted(report.Duration); tracker.NotifyRequestCompleted(report); @@ -113,9 +113,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; @@ -215,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) @@ -235,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; @@ -242,6 +245,14 @@ private static bool Inspect(ChatResponseUpdate update, RequestTracker tracker) sawFunctionResult = true; break; + case TextReasoningContent: + tracker.OnReasoningDetected(); + break; + + case TextContent { Text.Length: > 0 }: + tracker.OnReasoningCompleted(); + break; + default: break; } 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/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 @@ - - + + + diff --git a/README.md b/README.md index 5a899d7..2a691bb 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 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. @@ -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 `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.CreateCustom("Starting request").ToResponseUpdate(); + + await foreach (ChatResponseUpdate update in client.GetStreamingResponseAsync(history, chatOptions)) + { + yield return update; + } +} +``` + +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 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 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..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** — "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 (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,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), 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"; 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). +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 270e7dc..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 "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.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 | | --- | --- | --- | --- | -| `RequestStarted` / `Thinking` / `RequestCompleted` | The request root | 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` | @@ -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.Custom or ChatProgressKind.Reasoning or ChatProgressKind.ReasoningCompleted 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..67f64b3 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -109,24 +109,55 @@ 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), `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. - **`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, 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: 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.CreateCustom("Starting request").ToResponseUpdate(); + + await foreach (ChatResponseUpdate update in client.GetStreamingResponseAsync(history, chatOptions)) + { + yield return update; + } +} +``` + +**`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. + +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 Tools report sub-statuses and attribute token usage through the static `ChatProgress` ambient reporter — no changes to tool signatures: @@ -343,6 +374,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..ce45e82 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,20 +63,23 @@ 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 ├── 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`, 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 @@ -103,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. @@ -124,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). @@ -161,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 new file mode 100644 index 0000000..8974ccf --- /dev/null +++ b/releases/v0.5.0.md @@ -0,0 +1,40 @@ +# 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`/`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. +- **`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), 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.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; 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/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..310e8b9 --- /dev/null +++ b/samples/Andes.Extensions.AI.Demo.Responses/AzureOpenAISettings.cs @@ -0,0 +1,32 @@ +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 => + 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('<'); + + 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..5be3193 --- /dev/null +++ b/samples/Andes.Extensions.AI.Demo.Responses/Program.cs @@ -0,0 +1,172 @@ +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; + +var 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(); + +// 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.Full }, + 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: 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.[/]"); +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 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)) + { + 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, 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) + { + 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 + // 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..c038bbe --- /dev/null +++ b/samples/Andes.Extensions.AI.Demo.Responses/README.md @@ -0,0 +1,82 @@ +# 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 — 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.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` | + +## 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 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 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 `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. + +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..40b2cdb --- /dev/null +++ b/samples/Andes.Extensions.AI.Demo.Responses/StatusRenderer.cs @@ -0,0 +1,208 @@ +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 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)) + { + rows.Add(TextPanel(TailLines(snapshot.Text, liveTextTailLines))); + } + + return new Rows(rows); + } + + 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)) + { + 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) + { + // 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); + } + + 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 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'); + 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/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/samples/Andes.Extensions.AI.Demo/Program.cs b/samples/Andes.Extensions.AI.Demo/Program.cs index a204229..a200df0 100644 --- a/samples/Andes.Extensions.AI.Demo/Program.cs +++ b/samples/Andes.Extensions.AI.Demo/Program.cs @@ -50,6 +50,10 @@ // No Temperature: reasoning-model deployments reject non-default values. var chatOptions = new ChatOptions { + Reasoning = new ReasoningOptions + { + Output = ReasoningOutput.Full, + }, Tools = [ AIFunctionFactory.Create(DemoTools.GetWeather), @@ -86,6 +90,11 @@ .. mcp.Tools.WithTracking(mcp.Client), async IAsyncEnumerable StreamTurn( [EnumeratorCancellation] CancellationToken cancellationToken = default) { + // 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)) { updates.Add(update); diff --git a/samples/Andes.Extensions.AI.Demo/README.md b/samples/Andes.Extensions.AI.Demo/README.md index 4f19cee..4814e29 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.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 @@ -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 `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/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..d8cc906 --- /dev/null +++ b/tests/Andes.Extensions.AI.Integration.Test/ResponsesStreamingIntegrationTests.cs @@ -0,0 +1,120 @@ +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(); + // 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.Full }, + }; + + 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); + } + + var 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.Custom); + 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})."); + + // 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() + { + 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..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.Equal(ChatProgressKind.RequestStarted, progress[0].Kind); + 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.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..c617d57 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); @@ -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 4738f3e..25672ca 100644 --- a/tests/Andes.Extensions.AI.UI.Unit.Test/ChatResponseUiExtensionsTests.cs +++ b/tests/Andes.Extensions.AI.UI.Unit.Test/ChatResponseUiExtensionsTests.cs @@ -90,6 +90,127 @@ public async Task ToStatusSnapshotsAsync_FunctionTool_FoldsIntoCompletedActivity Assert.Equal(ActivityState.Completed, last.Phase); } + [Fact] + public async Task ToStatusSnapshotsAsync_DevPrependedCustomStatus_SetsAssistantStatus() + { + var scripted = new ScriptedChatClient(ScriptedTurn.Text("Done.")); + IChatClient client = TestPipeline.Build(scripted); + + async IAsyncEnumerable StreamWithPrependedStatus() + { + yield return ChatProgressUpdate.CreateCustom("Starting request").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); + } + + [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 new file mode 100644 index 0000000..72fea1b --- /dev/null +++ b/tests/Andes.Extensions.AI.Unit.Test/ChatProgressUpdateFactoryTests.cs @@ -0,0 +1,51 @@ +using Microsoft.Extensions.AI; + +namespace Andes.Extensions.AI.Unit.Test; + +public class ChatProgressUpdateFactoryTests +{ + [Fact] + public void CreateCustom_Message_PopulatesWellKnownFields() + { + DateTimeOffset before = DateTimeOffset.UtcNow; + ChatProgressUpdate update = ChatProgressUpdate.CreateCustom("Warming up…"); + DateTimeOffset after = DateTimeOffset.UtcNow; + + 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 CreateCustom_NullMessage_Throws() + { + Assert.Throws(() => ChatProgressUpdate.CreateCustom(null!)); + } + + [Fact] + public void CreateCustom_EmptyMessage_Throws() + { + Assert.Throws(() => ChatProgressUpdate.CreateCustom(string.Empty)); + } + + [Fact] + public void ToResponseUpdate_Always_WrapsSingleProgressContent() + { + ChatProgressUpdate update = ChatProgressUpdate.CreateCustom("Starting request"); + + 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..6622a96 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.Custom, 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..7a074b5 --- /dev/null +++ b/tests/Andes.Extensions.AI.Unit.Test/ReasoningDetectionTests.cs @@ -0,0 +1,236 @@ +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_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() + { + 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)); + 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] + 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.Custom); + Assert.DoesNotContain(progress, update => update.Kind == ChatProgressKind.Reasoning); + Assert.DoesNotContain(progress, update => update.Kind == ChatProgressKind.ReasoningCompleted); + } + + [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); + } + + [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 533c826..507ca04 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.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.Contains(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); } 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]);