From 634f48872a8e1205404bf764cd45a10660677ff3 Mon Sep 17 00:00:00 2001 From: Benjamin Michaelis Date: Mon, 1 Jun 2026 11:31:20 -0700 Subject: [PATCH 1/4] fix(chat): set StreamingEnabled=true before calling CreateResponseStreamingAsync The OpenAI SDK requires StreamingEnabled to be set to true on CreateResponseOptions when calling CreateResponseStreamingAsync. Also propagate StreamingEnabled in CloneOptionsWithPreviousResponseId so tool-call continuation legs behave consistently. --- EssentialCSharp.Chat.Shared/Services/AIChatService.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/EssentialCSharp.Chat.Shared/Services/AIChatService.cs b/EssentialCSharp.Chat.Shared/Services/AIChatService.cs index 6c384a76..c927dfa0 100644 --- a/EssentialCSharp.Chat.Shared/Services/AIChatService.cs +++ b/EssentialCSharp.Chat.Shared/Services/AIChatService.cs @@ -102,6 +102,7 @@ public AIChatService(IOptions options, AISearchService searchService, // Create the streaming response using the Responses API #pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. + responseOptions.StreamingEnabled = true; responseOptions.InputItems.Clear(); responseOptions.InputItems.Add(ResponseItem.CreateUserMessageItem(enrichedPrompt)); var streamingUpdates = _ResponseClient.CreateResponseStreamingAsync(responseOptions, cancellationToken); @@ -560,6 +561,7 @@ private static CreateResponseOptions CloneOptionsWithPreviousResponseId( Temperature = source.Temperature, TopP = source.TopP, ServiceTier = source.ServiceTier, + StreamingEnabled = source.StreamingEnabled, }; foreach (var tool in source.Tools) clone.Tools.Add(tool); From 5446e42b82cdf1c29dbbb5049908e951945b7056 Mon Sep 17 00:00:00 2001 From: Benjamin Michaelis Date: Mon, 1 Jun 2026 12:01:33 -0700 Subject: [PATCH 2/4] fix(chat): emit text when SDK sends only OutputTextDone events Handle StreamingResponseOutputTextDoneUpdate in the streaming loop. Some SDK/server combinations emit completed text parts without delta events, which previously produced only responseId + [DONE] on the SSE endpoint. Add per-part delta tracking to avoid duplicate text when both delta and done updates are present. --- EssentialCSharp.Chat.Shared/Services/AIChatService.cs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/EssentialCSharp.Chat.Shared/Services/AIChatService.cs b/EssentialCSharp.Chat.Shared/Services/AIChatService.cs index c927dfa0..efcbc6c1 100644 --- a/EssentialCSharp.Chat.Shared/Services/AIChatService.cs +++ b/EssentialCSharp.Chat.Shared/Services/AIChatService.cs @@ -192,6 +192,7 @@ private static string SanitizeForXmlContext(string? input) => // Track this leg's response ID so tool-call continuations chain from it, // ensuring the model's context includes the user's message + reasoning. string? currentLegResponseId = null; + var textPartsWithDelta = new HashSet(StringComparer.Ordinal); #pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. List? pendingFunctionCalls = null; @@ -223,8 +224,17 @@ private static string SanitizeForXmlContext(string? input) => } else if (update is StreamingResponseOutputTextDeltaUpdate deltaUpdate) { + textPartsWithDelta.Add($"{deltaUpdate.ItemId}:{deltaUpdate.OutputIndex}:{deltaUpdate.ContentIndex}"); yield return (deltaUpdate.Delta.ToString(), null); } + else if (update is StreamingResponseOutputTextDoneUpdate doneUpdate) + { + // Some SDK/server combinations emit only TextDone (no deltas) for a content part. + // Emit Done text when no delta was seen for that same part to avoid duplicates. + string textPartKey = $"{doneUpdate.ItemId}:{doneUpdate.OutputIndex}:{doneUpdate.ContentIndex}"; + if (!textPartsWithDelta.Contains(textPartKey) && !string.IsNullOrEmpty(doneUpdate.Text)) + yield return (doneUpdate.Text, null); + } // StreamingResponseCompletedUpdate: ResponseId already emitted above — no-op. } From c38f62ee4ad139bd960b0a3d03bac71883f0815c Mon Sep 17 00:00:00 2001 From: Benjamin Michaelis Date: Mon, 1 Jun 2026 12:07:13 -0700 Subject: [PATCH 3/4] fix(chat): stream refusal updates when text deltas are absent Handle StreamingResponseRefusalDeltaUpdate and StreamingResponseRefusalDoneUpdate in the streaming loop. This prevents empty SSE responses when the model returns refusal content via refusal events instead of output-text deltas. --- .../Services/AIChatService.cs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/EssentialCSharp.Chat.Shared/Services/AIChatService.cs b/EssentialCSharp.Chat.Shared/Services/AIChatService.cs index efcbc6c1..5400036d 100644 --- a/EssentialCSharp.Chat.Shared/Services/AIChatService.cs +++ b/EssentialCSharp.Chat.Shared/Services/AIChatService.cs @@ -193,6 +193,7 @@ private static string SanitizeForXmlContext(string? input) => // ensuring the model's context includes the user's message + reasoning. string? currentLegResponseId = null; var textPartsWithDelta = new HashSet(StringComparer.Ordinal); + var refusalPartsWithDelta = new HashSet(StringComparer.Ordinal); #pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. List? pendingFunctionCalls = null; @@ -235,6 +236,18 @@ private static string SanitizeForXmlContext(string? input) => if (!textPartsWithDelta.Contains(textPartKey) && !string.IsNullOrEmpty(doneUpdate.Text)) yield return (doneUpdate.Text, null); } + else if (update is StreamingResponseRefusalDeltaUpdate refusalDeltaUpdate) + { + refusalPartsWithDelta.Add($"{refusalDeltaUpdate.ItemId}:{refusalDeltaUpdate.OutputIndex}:{refusalDeltaUpdate.ContentIndex}"); + yield return (refusalDeltaUpdate.Delta.ToString(), null); + } + else if (update is StreamingResponseRefusalDoneUpdate refusalDoneUpdate) + { + // Refusal content can also arrive as done-only events. + string refusalPartKey = $"{refusalDoneUpdate.ItemId}:{refusalDoneUpdate.OutputIndex}:{refusalDoneUpdate.ContentIndex}"; + if (!refusalPartsWithDelta.Contains(refusalPartKey) && !string.IsNullOrEmpty(refusalDoneUpdate.Refusal)) + yield return (refusalDoneUpdate.Refusal, null); + } // StreamingResponseCompletedUpdate: ResponseId already emitted above — no-op. } From c59e2b275f27a59025b9d54a700a1579a1a07cfb Mon Sep 17 00:00:00 2001 From: Benjamin Michaelis Date: Mon, 1 Jun 2026 12:12:12 -0700 Subject: [PATCH 4/4] fix(chat): handle failed and incomplete streaming terminal updates Capture StreamingResponseErrorUpdate, StreamingResponseFailedUpdate, and StreamingResponseIncompleteUpdate so terminal stream failures are not silently treated as successful [DONE] completions. --- .../Services/AIChatService.cs | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/EssentialCSharp.Chat.Shared/Services/AIChatService.cs b/EssentialCSharp.Chat.Shared/Services/AIChatService.cs index 5400036d..bf308e37 100644 --- a/EssentialCSharp.Chat.Shared/Services/AIChatService.cs +++ b/EssentialCSharp.Chat.Shared/Services/AIChatService.cs @@ -248,6 +248,21 @@ private static string SanitizeForXmlContext(string? input) => if (!refusalPartsWithDelta.Contains(refusalPartKey) && !string.IsNullOrEmpty(refusalDoneUpdate.Refusal)) yield return (refusalDoneUpdate.Refusal, null); } + else if (update is StreamingResponseErrorUpdate errorUpdate) + { + throw new ChatBackendUnavailableException( + $"Streaming response error: {errorUpdate.Code ?? "unknown"} - {errorUpdate.Message ?? "no message provided"}"); + } + else if (update is StreamingResponseFailedUpdate failedUpdate) + { + throw new ChatBackendUnavailableException( + BuildStreamingTerminalFailureMessage(failedUpdate.Response, "failed")); + } + else if (update is StreamingResponseIncompleteUpdate incompleteUpdate) + { + throw new ChatBackendUnavailableException( + BuildStreamingTerminalFailureMessage(incompleteUpdate.Response, "incomplete")); + } // StreamingResponseCompletedUpdate: ResponseId already emitted above — no-op. } @@ -623,6 +638,22 @@ private static CreateResponseOptions CloneOptionsWithPreviousResponseId( return arguments; } +#pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. + private static string BuildStreamingTerminalFailureMessage(ResponseResult response, string terminalStatus) + { + if (!string.IsNullOrWhiteSpace(response.Error?.Message)) + return $"Streaming response {terminalStatus}: {response.Error.Message}"; + + if (response.IncompleteStatusDetails?.Reason is { } reason) + return $"Streaming response {terminalStatus}: {reason}"; + + if (response.Status is { } status) + return $"Streaming response ended with status '{status}'."; + + return $"Streaming response ended with status '{terminalStatus}'."; + } +#pragma warning restore OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. + [LoggerMessage(Level = LogLevel.Information, Message = "AI tool call invoked: tool={ToolName} iteration={Iteration} user={EndUserId}")] private static partial void LogMcpToolCallInvoked(ILogger logger, string toolName, int iteration, string? endUserId);