diff --git a/Directory.Packages.props b/Directory.Packages.props
index ee657fe1a..666de522d 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -32,6 +32,7 @@
+
diff --git a/src/Netclaw.Actors.Tests/Sessions/LlmSessionIntegrationTests.cs b/src/Netclaw.Actors.Tests/Sessions/LlmSessionIntegrationTests.cs
index 4270b88b6..8c6702d8e 100644
--- a/src/Netclaw.Actors.Tests/Sessions/LlmSessionIntegrationTests.cs
+++ b/src/Netclaw.Actors.Tests/Sessions/LlmSessionIntegrationTests.cs
@@ -1500,71 +1500,11 @@ await retryWatcher.ExpectMsgAsync(
Assert.DoesNotContain(sessionId.Value, _lifecycleObserver.DeactivatedSessionIds);
}
- [Fact]
- public async Task Streaming_retry_recovers_from_transient_502()
- {
- // First 2 calls throw 502, third succeeds
- _fakeChatClient.PlannedExceptions.Enqueue(
- new ProviderException("server error (502)", "HTTP 502", statusCode: 502));
- _fakeChatClient.PlannedExceptions.Enqueue(
- new ProviderException("server error (502)", "HTTP 502", statusCode: 502));
-
- var sessionId = new SessionId("test-channel/streaming-retry-502");
- var sessionManager = ActorRegistry.Get();
- var subscriber = CreateTestProbe("retry-502-sub");
-
- await sessionManager.Ask(new JoinSession(subscriber)
- {
- SessionId = sessionId,
- Filter = OutputFilter.Full
- }, cancellationToken: TestContext.Current.CancellationToken);
- await subscriber.ExpectMsgAsync(cancellationToken: TestContext.Current.CancellationToken);
-
- await sessionManager.Ask(new SendUserMessage
- {
- SessionId = sessionId,
- Content = "Message that should succeed after retries"
- }, cancellationToken: TestContext.Current.CancellationToken);
-
- // Should succeed after retries — response arrives
- var text = await subscriber.ExpectMsgAsync(TimeSpan.FromSeconds(15), cancellationToken: TestContext.Current.CancellationToken);
- Assert.Contains("fake", text.Text, StringComparison.OrdinalIgnoreCase);
- var completed = await subscriber.ExpectMsgAsync(TimeSpan.FromSeconds(3), cancellationToken: TestContext.Current.CancellationToken);
- Assert.Equal(TurnOutcome.Completed, completed.Outcome);
- }
-
- [Fact]
- public async Task Streaming_retry_exhaustion_fails_turn()
- {
- // All calls throw 502 — retries exhaust and turn fails.
- // Default RetryPolicy.MaxRetries=3, so we need initial + 3 retries = 4 exceptions.
- for (var i = 0; i < 5; i++)
- _fakeChatClient.PlannedExceptions.Enqueue(
- new ProviderException("server error (502)", "HTTP 502", statusCode: 502));
-
- var sessionId = new SessionId("test-channel/streaming-retry-exhaust");
- var sessionManager = ActorRegistry.Get();
- var subscriber = CreateTestProbe("retry-exhaust-sub");
-
- await sessionManager.Ask(new JoinSession(subscriber)
- {
- SessionId = sessionId,
- Filter = OutputFilter.Full
- }, cancellationToken: TestContext.Current.CancellationToken);
- await subscriber.ExpectMsgAsync(cancellationToken: TestContext.Current.CancellationToken);
-
- await sessionManager.Ask(new SendUserMessage
- {
- SessionId = sessionId,
- Content = "Message that will fail after exhausting retries"
- }, cancellationToken: TestContext.Current.CancellationToken);
-
- // Should fail with error after exhausting retries
- var error = await subscriber.ExpectMsgAsync(TimeSpan.FromSeconds(20), cancellationToken: TestContext.Current.CancellationToken);
- Assert.Equal(ErrorCategory.ProviderFailure, error.Category);
- var completed = await subscriber.ExpectMsgAsync(TimeSpan.FromSeconds(3), cancellationToken: TestContext.Current.CancellationToken);
- Assert.Equal(TurnOutcome.Failed, completed.Outcome);
- }
+ // NOTE: transient-failure retry (including ProviderException 5xx recover/exhaust) moved
+ // from this actor to the transport RetryingChatClient and is covered by
+ // RetryingChatClientTests; the actor no longer retries, so the former
+ // Streaming_retry_recovers_from_transient_502 / _exhaustion_fails_turn integration tests
+ // were removed.
[Fact]
public async Task Reminder_redelivery_is_deduped_in_Ready_phase()
diff --git a/src/Netclaw.Actors/Memory/MemoryCurationActor.cs b/src/Netclaw.Actors/Memory/MemoryCurationActor.cs
index 4615c8845..ae7b9fda9 100644
--- a/src/Netclaw.Actors/Memory/MemoryCurationActor.cs
+++ b/src/Netclaw.Actors/Memory/MemoryCurationActor.cs
@@ -6,6 +6,7 @@
using Akka.Actor;
using Akka.Event;
using Microsoft.Extensions.AI;
+using Netclaw.Actors.Sessions.Pipelines;
using Netclaw.Configuration;
using SessionId = Netclaw.Actors.Protocol.SessionId;
@@ -315,8 +316,8 @@ private async Task EvaluateSingleAsync(
MaxOutputTokens = 512
};
- var response = await llmClient.GetResponseAsync(messages, options, cts.Token);
- var responseText = response.Text?.Trim();
+ var result = await StreamingResponseReader.ReadAsync(llmClient, messages, options, cts.Token);
+ var responseText = result.Response.Text?.Trim();
var decision = string.IsNullOrWhiteSpace(responseText)
? null
: CurationPromptBuilder.ParseResponse(responseText);
diff --git a/src/Netclaw.Actors/Sessions/LlmFailureClassifier.cs b/src/Netclaw.Actors/Sessions/LlmFailureClassifier.cs
index 06ecf70cd..627c9c6dc 100644
--- a/src/Netclaw.Actors/Sessions/LlmFailureClassifier.cs
+++ b/src/Netclaw.Actors/Sessions/LlmFailureClassifier.cs
@@ -107,20 +107,6 @@ public static bool IsContextOverflow(Exception? ex)
return false;
}
- public static bool IsTransientStreaming(Exception? ex)
- {
- if (ex is null)
- return false;
-
- var providerEx = FindException(ex);
- if (providerEx?.StatusCode is >= 500)
- return true;
- if (providerEx?.StatusCode is 429)
- return true;
-
- return ex is HttpRequestException { StatusCode: null };
- }
-
private static T? FindException(Exception? ex) where T : Exception
{
while (ex is not null)
diff --git a/src/Netclaw.Actors/Sessions/LlmMessages.cs b/src/Netclaw.Actors/Sessions/LlmMessages.cs
index a03a63e23..ccd1aff8f 100644
--- a/src/Netclaw.Actors/Sessions/LlmMessages.cs
+++ b/src/Netclaw.Actors/Sessions/LlmMessages.cs
@@ -220,9 +220,3 @@ internal sealed record PassivationTimeout : INoSerializationVerificationNeeded;
/// signal. See LlmSessionActor.CompletePassivation.
///
internal sealed record PassivationFinalStop : INoSerializationVerificationNeeded;
-
-///
-/// Timer-fired message that triggers an LLM call retry after exponential backoff.
-/// Carries the attempt number for observability logging.
-///
-internal sealed record RetryLlmCallAfterBackoff(int Attempt) : INoSerializationVerificationNeeded;
diff --git a/src/Netclaw.Actors/Sessions/LlmSessionActor.cs b/src/Netclaw.Actors/Sessions/LlmSessionActor.cs
index 82d3f0890..9cfa94554 100644
--- a/src/Netclaw.Actors/Sessions/LlmSessionActor.cs
+++ b/src/Netclaw.Actors/Sessions/LlmSessionActor.cs
@@ -143,8 +143,9 @@ public sealed class LlmSessionActor : ReceivePersistentActor, IWithTimers
// from cancelled calls are ignored when their CallId doesn't match.
private long _activeCallId;
- // Tracks whether any content was streamed this call — used to gate transient retry logic
- // (mid-stream failures can't be retried because partial output was already emitted)
+ // Tracks whether any content was streamed this call — selects the watchdog timeout
+ // (the generous prefill budget before the first token vs the tighter inter-delta
+ // budget after).
private bool _anyContentStreamed;
// Per-turn diagnostic correlation (ephemeral)
@@ -161,10 +162,6 @@ public sealed class LlmSessionActor : ReceivePersistentActor, IWithTimers
// overflows again, fail the turn. Reset at the start of each new user turn.
private int _compactionOverflowRetryCount;
- // Per-turn retry counter for transient streaming failures (5xx, 429)
- private int _streamingRetryAttempt;
- private static readonly object StreamingRetryTimerKey = new();
-
// Skill registry for slash-command dispatch
private readonly Skills.SkillRegistry? _skillRegistry;
private readonly SubAgentDefinitionRegistry? _subAgentRegistry;
@@ -603,33 +600,14 @@ private void Processing()
// Use the configured context window as the token count estimate since
// the provider rejected the request without returning usage stats.
- Timers.Cancel(StreamingRetryTimerKey);
Self.Tell(new CompactionTriggered(_model.ContextWindowTokens));
TransitionTo(SessionPhase.Compacting);
return;
}
- // Pre-stream transient failure: retry with backoff if no data was streamed yet.
- // IsTransientStreamingError handles ProviderException (which wraps HTTP status codes)
- // while RetryPolicy only provides the attempt limit and backoff delay.
- if (!_anyContentStreamed && IsTransientStreamingError(msg.Cause))
- {
- var policy = _config.Tuning.StreamingRetryPolicy;
- if (_streamingRetryAttempt < policy.MaxRetries)
- {
- var delay = policy.GetDelay(_streamingRetryAttempt);
- _streamingRetryAttempt++;
- TurnLog().Warning(msg.Cause,
- "turn_llm_transient_failure — retrying in {DelayMs:F0}ms (attempt {Attempt}/{Max})",
- delay.TotalMilliseconds, _streamingRetryAttempt, policy.MaxRetries);
- Timers.StartSingleTimer(
- StreamingRetryTimerKey,
- new RetryLlmCallAfterBackoff(_streamingRetryAttempt),
- delay);
- return; // Stay in Processing, watchdog is already stopped
- }
- }
-
+ // Transient-failure retry is owned entirely by the transport
+ // (RetryingChatClient, pre-first-chunk) and is already exhausted by the time
+ // the failure reaches here, so a failed turn is terminal.
TurnLog().Error(msg.Cause, "turn_llm_call_failed");
// Evict discovered tools to prevent a poisoned tool set from cascading
@@ -642,12 +620,6 @@ private void Processing()
FailCurrentTurn(errorMessage, msg.Cause, category);
});
- Command(msg =>
- {
- TurnLog().Info("turn_streaming_retry attempt={Attempt}", msg.Attempt);
- FireLlmCall();
- });
-
Command(msg =>
{
if (!_watchdog.IsCurrent(msg))
@@ -1369,7 +1341,6 @@ private void DrainBufferOrReady()
if (resumeToolLoop || hadBufferedMessages)
{
- _streamingRetryAttempt = 0;
FireLlmCall();
TransitionTo(SessionPhase.Processing);
}
@@ -1641,9 +1612,9 @@ internal static async Task InvokeMemoryExtractionCoreAsync(
new(Microsoft.Extensions.AI.ChatRole.User,
CompactionPromptBuilder.BuildMemoryExtractionUserPrompt(history))
};
- var extractionResponse = await client.GetResponseAsync(extractionMessages,
- cancellationToken: cts.Token);
- var extractedText = extractionResponse.Messages[^1].Text ?? string.Empty;
+ var extractionResult = await StreamingResponseReader.ReadAsync(
+ client, extractionMessages, options: null, cts.Token);
+ var extractedText = extractionResult.Response.Text ?? string.Empty;
self.Tell(new MemoryExtractionCompleted { ExtractedMemories = extractedText });
}
catch (Exception ex)
@@ -2007,7 +1978,6 @@ private void DrainBufferedMessagesOrBecomeReady()
_buffer.Clear();
_recallManager.ResetForNewTurn(); // New user input — resolve recall fresh
- _streamingRetryAttempt = 0;
FireLlmCall();
// Already in Processing — no transition needed, just fired a new LLM call
return;
@@ -2213,7 +2183,6 @@ private void ContinueIncomingUserMessage(SendUserMessage cmd)
_state = _state.AddUserMessage(userContent, mediaRefs.Count > 0 ? mediaRefs : null);
TryReplyAck();
_recallManager.ResetForNewTurn();
- _streamingRetryAttempt = 0;
_compactionOverflowRetryCount = 0;
FireInitialTurnLlmCall(executableUserContent);
TransitionTo(SessionPhase.Processing);
@@ -2481,13 +2450,6 @@ private string ExtractLlmErrorMessage(Exception? cause)
internal static bool IsContextOverflowError(Exception? ex)
=> LlmFailureClassifier.IsContextOverflow(ex);
- ///
- /// Detect transient streaming errors that are safe to retry when no data
- /// has been streamed yet (5xx server errors, 429 rate limits, network failures).
- ///
- internal static bool IsTransientStreamingError(Exception? ex)
- => LlmFailureClassifier.IsTransientStreaming(ex);
-
private string GetSessionDirectory() =>
SessionDirectoryHelper.GetSessionDirectory(_sessionId, _sessionsBasePath);
@@ -4153,7 +4115,6 @@ private void FailCurrentTurn(string errorMessage, Exception cause, ErrorCategory
_pendingToolInteractions.Clear();
_resolvedToolApprovals.Clear();
ClearApprovalTurnState();
- Timers.Cancel(StreamingRetryTimerKey);
_state = _state.AddErrorReply(errorMessage);
var correlationId = Guid.NewGuid();
diff --git a/src/Netclaw.Actors/Sessions/Pipelines/SessionCompactionPipeline.cs b/src/Netclaw.Actors/Sessions/Pipelines/SessionCompactionPipeline.cs
index 4fc8aa90a..660bd8cc7 100644
--- a/src/Netclaw.Actors/Sessions/Pipelines/SessionCompactionPipeline.cs
+++ b/src/Netclaw.Actors/Sessions/Pipelines/SessionCompactionPipeline.cs
@@ -182,9 +182,9 @@ public static async Task ExecuteAsync(
ObservationPromptBuilder.BuildObservationUserPrompt(remainingDiscarded))
};
- var response = await client.GetResponseAsync(
- observerMessages, cancellationToken: cts.Token);
- var text = response.Messages[^1].Text;
+ var result = await StreamingResponseReader.ReadAsync(
+ client, observerMessages, options: null, cts.Token);
+ var text = result.Response.Text;
if (string.IsNullOrWhiteSpace(text))
{
diff --git a/src/Netclaw.Actors/Sessions/Pipelines/SessionTitleGenerator.cs b/src/Netclaw.Actors/Sessions/Pipelines/SessionTitleGenerator.cs
index dd4648fe4..022e6e24f 100644
--- a/src/Netclaw.Actors/Sessions/Pipelines/SessionTitleGenerator.cs
+++ b/src/Netclaw.Actors/Sessions/Pipelines/SessionTitleGenerator.cs
@@ -49,8 +49,8 @@ public static async Task GenerateAsync(
new(Microsoft.Extensions.AI.ChatRole.User,
CompactionPromptBuilder.BuildTitleGenerationPrompt(history))
};
- var response = await client.GetResponseAsync(messages, cancellationToken: cts.Token);
- var title = response.Messages[^1].Text ?? string.Empty;
+ var result = await StreamingResponseReader.ReadAsync(client, messages, options: null, cts.Token);
+ var title = result.Response.Text ?? string.Empty;
if (string.IsNullOrWhiteSpace(title))
{
diff --git a/src/Netclaw.Actors/Sessions/Pipelines/StreamingResponseReader.cs b/src/Netclaw.Actors/Sessions/Pipelines/StreamingResponseReader.cs
index 4d4840977..a999f0aea 100644
--- a/src/Netclaw.Actors/Sessions/Pipelines/StreamingResponseReader.cs
+++ b/src/Netclaw.Actors/Sessions/Pipelines/StreamingResponseReader.cs
@@ -56,6 +56,23 @@ internal readonly record struct StreamReadResult(
///
internal static class StreamingResponseReader
{
+ private static readonly Action NoOp =
+ static (_, _, _) => { };
+
+ ///
+ /// Convenience overload for callers that only want the aggregated response with
+ /// no per-update dispatch — title generation, memory distillation, compaction
+ /// observation, memory curation. Streams under the hood (the only transport
+ /// Netclaw issues) and aggregates with the same empty-response fallback, so the
+ /// caller can read result.Response.Text without an empty-Messages guard.
+ ///
+ public static Task ReadAsync(
+ IChatClient client,
+ IEnumerable messages,
+ ChatOptions? options,
+ CancellationToken ct)
+ => ReadAsync(client, messages, options, NoOp, ct);
+
public static async Task ReadAsync(
IChatClient client,
IEnumerable messages,
diff --git a/src/Netclaw.Actors/Sessions/SessionMemoryObserverActor.cs b/src/Netclaw.Actors/Sessions/SessionMemoryObserverActor.cs
index fcbfe4f39..fc12b30c5 100644
--- a/src/Netclaw.Actors/Sessions/SessionMemoryObserverActor.cs
+++ b/src/Netclaw.Actors/Sessions/SessionMemoryObserverActor.cs
@@ -12,6 +12,7 @@
using Netclaw.Actors.Memory;
using Netclaw.Actors.Protocol;
using Netclaw.Actors.Serialization;
+using Netclaw.Actors.Sessions.Pipelines;
using Netclaw.Actors.SubAgents;
using Netclaw.Configuration;
@@ -354,8 +355,8 @@ internal static async Task RunDistillationAsync(
sessionId, turnCount, transcript, existingProposals))
};
- var response = await client.GetResponseAsync(messages, cancellationToken: cts.Token);
- var text = response.Messages[^1].Text ?? string.Empty;
+ var response = (await StreamingResponseReader.ReadAsync(client, messages, options: null, cts.Token)).Response;
+ var text = response.Text ?? string.Empty;
inputTokens = response.Usage?.InputTokenCount;
outputTokens = response.Usage?.OutputTokenCount;
diff --git a/src/Netclaw.Configuration/ChatRoutingContext.cs b/src/Netclaw.Configuration/ChatRoutingContext.cs
new file mode 100644
index 000000000..731333836
--- /dev/null
+++ b/src/Netclaw.Configuration/ChatRoutingContext.cs
@@ -0,0 +1,24 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (C) 2026 - 2026 Petabridge, LLC
+//
+// -----------------------------------------------------------------------
+namespace Netclaw.Configuration;
+
+///
+/// The inputs a chat-client router uses to select which composed pipeline to invoke
+/// for a call. Minimal today — only is consulted — but shaped so
+/// per-session / per-provider routing slots in later as a new router policy without
+/// changing this type's shape or any caller.
+///
+public sealed record ChatRoutingContext
+{
+ /// The model role being requested (today's only routing signal).
+ public required ModelRole Role { get; init; }
+
+ ///
+ /// Owning session id. Unused today; the explicit seam for a future per-session
+ /// router policy that routes a session's chats to a specific model/provider.
+ ///
+ public string? SessionId { get; init; }
+}
diff --git a/src/Netclaw.Configuration/RetryPolicy.cs b/src/Netclaw.Configuration/RetryPolicy.cs
index d272bb30b..a8f7a3beb 100644
--- a/src/Netclaw.Configuration/RetryPolicy.cs
+++ b/src/Netclaw.Configuration/RetryPolicy.cs
@@ -18,14 +18,23 @@ public sealed record RetryPolicy
///
/// Determines whether the given exception is transient and should be retried.
- /// Retries on: status-less network failures, 408/429/5xx responses,
- /// and timeout-style cancellations.
+ /// Retries on: status-less network failures, 408/429/5xx responses (whether they
+ /// surface as a raw or are curated into a
+ /// by a provider transport layer), and
+ /// timeout-style cancellations.
///
public bool ShouldRetry(Exception ex, int attempt)
{
if (attempt >= MaxRetries)
return false;
+ // Curated provider errors (e.g. the self-hosted OpenAI-compatible client) carry
+ // the HTTP status on a ProviderException rather than a raw HttpRequestException,
+ // and it may be nested under an inner exception. Without this, the retry layer
+ // would miss the provider 429/5xx it most needs to retry.
+ if (FindInner(ex) is { StatusCode: 408 or 429 or (>= 500 and <= 599) })
+ return true;
+
return ex switch
{
HttpRequestException { StatusCode: null } => true,
@@ -42,6 +51,18 @@ HttpStatusCode.ServiceUnavailable or
};
}
+ private static T? FindInner(Exception? ex) where T : Exception
+ {
+ while (ex is not null)
+ {
+ if (ex is T match)
+ return match;
+ ex = ex.InnerException;
+ }
+
+ return null;
+ }
+
///
/// Returns the delay before the next retry attempt using exponential backoff with jitter.
///
diff --git a/src/Netclaw.Daemon.Tests/Configuration/FailoverChatClientTests.cs b/src/Netclaw.Daemon.Tests/Configuration/FailoverChatClientTests.cs
deleted file mode 100644
index eb2dedcfd..000000000
--- a/src/Netclaw.Daemon.Tests/Configuration/FailoverChatClientTests.cs
+++ /dev/null
@@ -1,194 +0,0 @@
-// -----------------------------------------------------------------------
-//
-// Copyright (C) 2026 - 2026 Petabridge, LLC
-//
-// -----------------------------------------------------------------------
-using Microsoft.Extensions.AI;
-using Microsoft.Extensions.Logging.Abstractions;
-using Netclaw.Configuration;
-using Netclaw.Daemon.Configuration;
-using Xunit;
-
-namespace Netclaw.Daemon.Tests.Configuration;
-
-public sealed class FailoverChatClientTests
-{
- [Fact]
- public async Task UsesPrimary_WhenHealthy()
- {
- var primary = new FakeChatClient((_,_,_) =>
- Task.FromResult(new ChatResponse([new ChatMessage(ChatRole.Assistant, "primary")])));
- var fallback = new FakeChatClient((_,_,_) =>
- Task.FromResult(new ChatResponse([new ChatMessage(ChatRole.Assistant, "fallback")])));
-
- var client = new FailoverChatClient(primary, fallback, NullLogger.Instance, NullNotificationSink.Instance, TimeProvider.System);
- var response = await client.GetResponseAsync([new ChatMessage(ChatRole.User, "hi")], cancellationToken: TestContext.Current.CancellationToken);
-
- Assert.Equal("primary", response.Messages[0].Text);
- }
-
- [Fact]
- public async Task FallsBack_WhenPrimaryFails()
- {
- var primary = new FakeChatClient((_,_,_) =>
- throw new HttpRequestException("primary down"));
- var fallback = new FakeChatClient((_,_,_) =>
- Task.FromResult(new ChatResponse([new ChatMessage(ChatRole.Assistant, "fallback")])));
-
- var client = new FailoverChatClient(primary, fallback, NullLogger.Instance, NullNotificationSink.Instance, TimeProvider.System);
- var response = await client.GetResponseAsync([new ChatMessage(ChatRole.User, "hi")], cancellationToken: TestContext.Current.CancellationToken);
-
- Assert.Equal("fallback", response.Messages[0].Text);
- }
-
- [Fact]
- public async Task PropagatesException_WhenBothFail()
- {
- var primary = new FakeChatClient((_,_,_) =>
- throw new HttpRequestException("primary down"));
- var fallback = new FakeChatClient((_,_,_) =>
- throw new HttpRequestException("fallback down"));
-
- var client = new FailoverChatClient(primary, fallback, NullLogger.Instance, NullNotificationSink.Instance, TimeProvider.System);
-
- var ex = await Assert.ThrowsAsync(() =>
- client.GetResponseAsync([new ChatMessage(ChatRole.User, "hi")], cancellationToken: TestContext.Current.CancellationToken));
- Assert.Contains("fallback down", ex.Message);
- }
-
- [Fact]
- public async Task DoesNotFallback_OnCancellation()
- {
- var cts = new CancellationTokenSource();
- cts.Cancel();
-
- var primary = new FakeChatClient((_,_,ct) =>
- throw new OperationCanceledException(ct));
- var fallback = new FakeChatClient((_,_,_) =>
- Task.FromResult(new ChatResponse([new ChatMessage(ChatRole.Assistant, "fallback")])));
-
- var client = new FailoverChatClient(primary, fallback, NullLogger.Instance, NullNotificationSink.Instance, TimeProvider.System);
-
- await Assert.ThrowsAnyAsync(() =>
- client.GetResponseAsync([new ChatMessage(ChatRole.User, "hi")],
- cancellationToken: cts.Token));
- }
-
- [Fact]
- public async Task Streaming_UsesPrimary_WhenHealthy()
- {
- var primary = new FakeChatClient(streaming: true);
- var fallback = new FakeChatClient(streaming: true);
-
- var client = new FailoverChatClient(primary, fallback, NullLogger.Instance, NullNotificationSink.Instance, TimeProvider.System);
-
- var updates = new List();
- await foreach (var u in client.GetStreamingResponseAsync(
- [new ChatMessage(ChatRole.User, "hi")], cancellationToken: TestContext.Current.CancellationToken))
- {
- updates.Add(u);
- }
-
- Assert.Single(updates);
- Assert.Equal("streamed", updates[0].Text);
- }
-
- [Fact]
- public async Task Streaming_FallsBack_WhenPrimaryFailsBeforeFirstChunk()
- {
- var fallbackStreamCalls = 0;
-
- var primary = new FakeChatClient(streamHandler: (_, _, ct) =>
- ThrowBeforeFirstChunkAsync(ct));
- var fallback = new FakeChatClient(streamHandler: (_, _, ct) =>
- {
- fallbackStreamCalls++;
- return SingleTextUpdateAsync("fallback", ct);
- });
-
- var client = new FailoverChatClient(primary, fallback, NullLogger.Instance, NullNotificationSink.Instance, TimeProvider.System);
-
- var updates = new List();
- await foreach (var u in client.GetStreamingResponseAsync(
- [new ChatMessage(ChatRole.User, "hi")], cancellationToken: TestContext.Current.CancellationToken))
- {
- updates.Add(u);
- }
-
- Assert.Single(updates);
- Assert.Equal("fallback", updates[0].Text);
- Assert.Equal(1, fallbackStreamCalls);
- }
-
- [Fact]
- public async Task Streaming_DoesNotFallback_AfterPrimaryAlreadyYielded()
- {
- var fallbackStreamCalls = 0;
-
- var primary = new FakeChatClient(streamHandler: (_, _, ct) =>
- YieldThenThrowAsync(ct));
- var fallback = new FakeChatClient(streamHandler: (_, _, ct) =>
- {
- fallbackStreamCalls++;
- return SingleTextUpdateAsync("fallback", ct);
- });
-
- var client = new FailoverChatClient(primary, fallback, NullLogger.Instance, NullNotificationSink.Instance, TimeProvider.System);
- var updates = new List();
-
- var ex = await Assert.ThrowsAsync(async () =>
- {
- await foreach (var u in client.GetStreamingResponseAsync(
- [new ChatMessage(ChatRole.User, "hi")], cancellationToken: TestContext.Current.CancellationToken))
- {
- updates.Add(u);
- }
- });
-
- Assert.Contains("primary stream failed", ex.Message);
- Assert.Single(updates);
- Assert.Equal("primary", updates[0].Text);
- Assert.Equal(0, fallbackStreamCalls);
- }
-
- private static async IAsyncEnumerable ThrowBeforeFirstChunkAsync(
- [System.Runtime.CompilerServices.EnumeratorCancellation]
- CancellationToken cancellationToken)
- {
- await Task.Yield();
- cancellationToken.ThrowIfCancellationRequested();
- throw new HttpRequestException("primary stream failed before first chunk");
-#pragma warning disable CS0162 // Unreachable code
- yield break;
-#pragma warning restore CS0162 // Unreachable code
- }
-
- private static async IAsyncEnumerable YieldThenThrowAsync(
- [System.Runtime.CompilerServices.EnumeratorCancellation]
- CancellationToken cancellationToken)
- {
- await Task.Yield();
- cancellationToken.ThrowIfCancellationRequested();
- yield return new ChatResponseUpdate
- {
- Role = ChatRole.Assistant,
- Contents = [new TextContent("primary")]
- };
-
- throw new HttpRequestException("primary stream failed after first chunk");
- }
-
- private static async IAsyncEnumerable SingleTextUpdateAsync(
- string text,
- [System.Runtime.CompilerServices.EnumeratorCancellation]
- CancellationToken cancellationToken)
- {
- await Task.Yield();
- cancellationToken.ThrowIfCancellationRequested();
- yield return new ChatResponseUpdate
- {
- Role = ChatRole.Assistant,
- Contents = [new TextContent(text)]
- };
- }
-}
diff --git a/src/Netclaw.Daemon.Tests/Configuration/LoggingChatClientTests.cs b/src/Netclaw.Daemon.Tests/Configuration/LoggingChatClientTests.cs
index 541cd079c..9a7ec0c0e 100644
--- a/src/Netclaw.Daemon.Tests/Configuration/LoggingChatClientTests.cs
+++ b/src/Netclaw.Daemon.Tests/Configuration/LoggingChatClientTests.cs
@@ -1,91 +1,67 @@
-// -----------------------------------------------------------------------
+// -----------------------------------------------------------------------
//
// Copyright (C) 2026 - 2026 Petabridge, LLC
//
// -----------------------------------------------------------------------
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Logging;
-using Microsoft.Extensions.Logging.Abstractions;
+using Netclaw.Configuration;
using Netclaw.Daemon.Configuration;
using Xunit;
+// Netclaw's LoggingChatClient collides with Microsoft.Extensions.AI.LoggingChatClient
+// (the core package's own logging decorator) now that the core package is referenced.
+using LoggingChatClient = Netclaw.Daemon.Configuration.LoggingChatClient;
namespace Netclaw.Daemon.Tests.Configuration;
public sealed class LoggingChatClientTests
{
[Fact]
- public async Task LogsCompletionTime()
+ public async Task Streaming_LogsCompletion()
{
var logs = new List();
- var logger = new CapturingLogger(logs);
- var fake = new FakeChatClient();
+ var client = new LoggingChatClient(new FakeChatClient(streaming: true), new CapturingLogger(logs));
- var client = new LoggingChatClient(fake, logger);
- var response = await client.GetResponseAsync([new ChatMessage(ChatRole.User, "hi")], cancellationToken: TestContext.Current.CancellationToken);
+ await Drain(client);
- Assert.NotNull(response);
- Assert.Contains(logs, l => l.Contains("LLM call completed"));
+ Assert.Contains(logs, l => l.Contains("LLM streaming call completed"));
}
[Fact]
- public async Task LogsTokenUsage()
+ public async Task Streaming_LogsTokenUsageWithoutDeltaOrCumulative()
{
var logs = new List();
- var logger = new CapturingLogger(logs);
- var fake = new FakeChatClient((_,_,_) =>
- {
- var response = new ChatResponse([new ChatMessage(ChatRole.Assistant, "ok")])
- {
- Usage = new UsageDetails { InputTokenCount = 10, OutputTokenCount = 20 }
- };
- return Task.FromResult(response);
- });
+ var client = new LoggingChatClient(
+ new FakeChatClient(streamHandler: (_, _, _) => StreamWithUsage(100, 20)),
+ new CapturingLogger(logs));
- var client = new LoggingChatClient(fake, logger);
- await client.GetResponseAsync([new ChatMessage(ChatRole.User, "hi")], cancellationToken: TestContext.Current.CancellationToken);
+ await Drain(client);
- Assert.Contains(logs, l => l.Contains("input: 10") && l.Contains("output: 20"));
+ Assert.Contains(logs, l => l.Contains("input: 100") && l.Contains("output: 20"));
+ // The cross-call delta/cumulative counters were removed (stateless client).
+ Assert.DoesNotContain(logs, l => l.Contains("delta:") || l.Contains("cumulative:"));
}
[Fact]
- public async Task LogsErrorOnFailure()
+ public async Task Streaming_LogsErrorOnInitFailure()
{
var logs = new List();
- var logger = new CapturingLogger(logs);
- var fake = new FakeChatClient((_,_,_) =>
- throw new HttpRequestException("boom"));
+ var client = new LoggingChatClient(
+ new FakeChatClient(streamHandler: (_, _, _) => throw new HttpRequestException("boom")),
+ new CapturingLogger(logs));
- var client = new LoggingChatClient(fake, logger);
+ await Assert.ThrowsAsync(() => Drain(client));
- await Assert.ThrowsAsync(() =>
- client.GetResponseAsync([new ChatMessage(ChatRole.User, "hi")], cancellationToken: TestContext.Current.CancellationToken));
-
- Assert.Contains(logs, l => l.Contains("LLM call failed"));
+ Assert.Contains(logs, l => l.Contains("LLM streaming call failed"));
}
[Fact]
- public async Task Streaming_LogsCompletion()
+ public async Task Streaming_LogsPromptSummaryInDebugMode()
{
var logs = new List();
- var logger = new CapturingLogger(logs);
- var fake = new FakeChatClient(streaming: true);
+ var client = new LoggingChatClient(new FakeChatClient(streaming: true), new CapturingLogger(logs));
- var client = new LoggingChatClient(fake, logger);
await foreach (var _ in client.GetStreamingResponseAsync(
- [new ChatMessage(ChatRole.User, "hi")], cancellationToken: TestContext.Current.CancellationToken)) { }
-
- Assert.Contains(logs, l => l.Contains("LLM streaming call completed"));
- }
-
- [Fact]
- public async Task LogsPromptSummaryInDebugMode()
- {
- var logs = new List();
- var logger = new CapturingLogger(logs);
- var fake = new FakeChatClient();
-
- var client = new LoggingChatClient(fake, logger);
- await client.GetResponseAsync(
[
new ChatMessage(ChatRole.System, "sys"),
new ChatMessage(ChatRole.User, "hello")
@@ -97,7 +73,9 @@ await client.GetResponseAsync(
AIFunctionFactory.Create((string query) => query, "search_tools"),
AIFunctionFactory.Create((string url) => url, "browser_playwright/browser_navigate")
]
- }, cancellationToken: TestContext.Current.CancellationToken);
+ }, TestContext.Current.CancellationToken))
+ {
+ }
Assert.Contains(logs, l => l.Contains("LLM prompt summary"));
Assert.Contains(logs, l => l.Contains("promptSha256="));
@@ -105,125 +83,55 @@ await client.GetResponseAsync(
}
[Fact]
- public async Task LogsPromptDumpWhenTraceEnabled()
+ public async Task Streaming_LogsPromptDumpWhenTraceEnabled()
{
var logs = new List();
- var logger = new CapturingLogger(logs);
- var fake = new FakeChatClient();
+ var client = new LoggingChatClient(new FakeChatClient(streaming: true), new CapturingLogger(logs));
- var client = new LoggingChatClient(fake, logger);
- await client.GetResponseAsync([new ChatMessage(ChatRole.User, "hello")], cancellationToken: TestContext.Current.CancellationToken);
+ await Drain(client);
Assert.Contains(logs, l => l.Contains("LLM prompt dump:"));
Assert.Contains(logs, l => l.Contains("role=user"));
}
[Fact]
- public async Task LogsTokenDeltaAcrossMultipleCalls()
+ public async Task Streaming_attaches_SessionId_scope_from_diagnostics_context()
{
- var logs = new List();
- var logger = new CapturingLogger(logs);
- var callCount = 0;
+ var logger = new ScopeCapturingLogger();
+ var client = new LoggingChatClient(new FakeChatClient(streaming: true), logger);
- var fake = new FakeChatClient((_, _, _) =>
+ using (SessionDiagnosticsContext.Push("ch/thread"))
{
- callCount++;
- // Return increasing input tokens: 100, 150, 200
- var inputTokens = callCount * 50;
- return Task.FromResult(new ChatResponse([new ChatMessage(ChatRole.Assistant, "ok")])
- {
- Usage = new UsageDetails { InputTokenCount = inputTokens, OutputTokenCount = 10 }
- });
- });
-
- var client = new LoggingChatClient(fake, logger);
-
- // First call: 50 tokens
- await client.GetResponseAsync([new ChatMessage(ChatRole.User, "hi")], cancellationToken: TestContext.Current.CancellationToken);
- // Second call: 100 tokens
- await client.GetResponseAsync([new ChatMessage(ChatRole.User, "hi")], cancellationToken: TestContext.Current.CancellationToken);
- // Third call: 150 tokens
- await client.GetResponseAsync([new ChatMessage(ChatRole.User, "hi")], cancellationToken: TestContext.Current.CancellationToken);
-
- // First call has no previous, so delta is N/A
- Assert.Contains(logs, l => l.Contains("delta: N/A"));
- // Second and third calls have delta of +50 each
- Assert.Equal(2, logs.Count(l => l.Contains("delta: +50")));
+ await Drain(client);
+ }
+
+ Assert.True(logger.HasSessionScope("ch/thread"));
}
[Fact]
- public async Task LogsCumulativeTokensAcrossMultipleCalls()
+ public async Task Streaming_without_session_context_attaches_no_scope()
{
- var logs = new List();
- var logger = new CapturingLogger(logs);
- var callCount = 0;
+ Assert.Null(SessionDiagnosticsContext.SessionId);
+ var logger = new ScopeCapturingLogger();
+ var client = new LoggingChatClient(new FakeChatClient(streaming: true), logger);
- var fake = new FakeChatClient((_, _, _) =>
- {
- callCount++;
- // Return input tokens: 50, 100, 150
- var inputTokens = callCount * 50;
- return Task.FromResult(new ChatResponse([new ChatMessage(ChatRole.Assistant, "ok")])
- {
- Usage = new UsageDetails { InputTokenCount = inputTokens, OutputTokenCount = 10 }
- });
- });
-
- var client = new LoggingChatClient(fake, logger);
-
- // First call: 50 tokens
- await client.GetResponseAsync([new ChatMessage(ChatRole.User, "hi")], cancellationToken: TestContext.Current.CancellationToken);
- // Second call: 100 tokens
- await client.GetResponseAsync([new ChatMessage(ChatRole.User, "hi")], cancellationToken: TestContext.Current.CancellationToken);
- // Third call: 150 tokens
- await client.GetResponseAsync([new ChatMessage(ChatRole.User, "hi")], cancellationToken: TestContext.Current.CancellationToken);
-
- // Verify cumulative: 50, 150, 300
- Assert.Contains(logs, l => l.Contains("cumulative: 50"));
- Assert.Contains(logs, l => l.Contains("cumulative: 150"));
- Assert.Contains(logs, l => l.Contains("cumulative: 300"));
+ await Drain(client);
+
+ Assert.False(logger.HasAnySessionScope());
}
- [Fact]
- public async Task Streaming_LogsTokenDeltaAndCumulative()
+ private static async Task Drain(IChatClient client)
{
- var logs = new List();
- var logger = new CapturingLogger(logs);
- var callCount = 0;
-
- var fake = new FakeChatClient(streaming: true, streamHandler: (messages, options, ct) =>
+ await foreach (var _ in client.GetStreamingResponseAsync(
+ [new ChatMessage(ChatRole.User, "hi")], cancellationToken: TestContext.Current.CancellationToken))
{
- callCount++;
- // Return 100 input tokens on each call
- return StreamWithUsage(messages, 100, 10);
- });
-
- var client = new LoggingChatClient(fake, logger);
-
- // First streaming call
- await foreach (var _ in client.GetStreamingResponseAsync([new ChatMessage(ChatRole.User, "hi")], cancellationToken: TestContext.Current.CancellationToken)) { }
- // Second streaming call
- await foreach (var _ in client.GetStreamingResponseAsync([new ChatMessage(ChatRole.User, "hi")], cancellationToken: TestContext.Current.CancellationToken)) { }
-
- // Verify delta and cumulative appear in streaming logs
- Assert.Contains(logs, l => l.Contains("delta:") && l.Contains("cumulative:"));
- // First call should have delta N/A (no previous), cumulative 100
- Assert.Contains(logs, l => l.Contains("delta: N/A") && l.Contains("cumulative: 100"));
- // Second call should have delta 0, cumulative 200
- Assert.Contains(logs, l => l.Contains("delta: 0") && l.Contains("cumulative: 200"));
+ }
}
- private static async IAsyncEnumerable StreamWithUsage(
- IEnumerable messages, int inputTokens, int outputTokens)
+ private static async IAsyncEnumerable StreamWithUsage(int inputTokens, int outputTokens)
{
await Task.CompletedTask;
- // Yield a text update
- yield return new ChatResponseUpdate
- {
- Role = ChatRole.Assistant,
- Contents = [new TextContent("response")]
- };
- // Yield usage details
+ yield return new ChatResponseUpdate { Role = ChatRole.Assistant, Contents = [new TextContent("response")] };
yield return new ChatResponseUpdate
{
Contents = [new UsageContent(new UsageDetails { InputTokenCount = inputTokens, OutputTokenCount = outputTokens })]
diff --git a/src/Netclaw.Daemon.Tests/Configuration/PipelineChatClientFactoryTests.cs b/src/Netclaw.Daemon.Tests/Configuration/PipelineChatClientFactoryTests.cs
new file mode 100644
index 000000000..b92516be7
--- /dev/null
+++ b/src/Netclaw.Daemon.Tests/Configuration/PipelineChatClientFactoryTests.cs
@@ -0,0 +1,73 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (C) 2026 - 2026 Petabridge, LLC
+//
+// -----------------------------------------------------------------------
+using Microsoft.Extensions.AI;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+using Netclaw.Configuration;
+using Netclaw.Daemon.Configuration;
+using Xunit;
+using LoggingChatClient = Netclaw.Daemon.Configuration.LoggingChatClient;
+
+namespace Netclaw.Daemon.Tests.Configuration;
+
+public sealed class PipelineChatClientFactoryTests
+{
+ private readonly RetryPolicy _policy = new()
+ {
+ MaxRetries = 3,
+ BaseDelay = TimeSpan.FromMilliseconds(1),
+ MaxDelay = TimeSpan.FromMilliseconds(10)
+ };
+
+ [Fact]
+ public void Compose_puts_Logging_outermost()
+ {
+ var pipeline = PipelineChatClientFactory.Compose(
+ new FakeChatClient(streaming: true), _policy, NullLoggerFactory.Instance, TimeProvider.System);
+
+ // ChatClientBuilder applies the first-registered factory outermost. Logging must
+ // wrap Retry so a single completion log spans the whole retried operation — guard
+ // against the .Use() ordering silently flipping on a package bump.
+ Assert.IsType(pipeline);
+ }
+
+ [Fact]
+ public async Task Compose_streams_through_and_logs_completion()
+ {
+ var logs = new List();
+ var pipeline = PipelineChatClientFactory.Compose(
+ new FakeChatClient(streaming: true), _policy, new ListLoggerFactory(logs), TimeProvider.System);
+
+ var updates = new List();
+ await foreach (var u in pipeline.GetStreamingResponseAsync(
+ [new ChatMessage(ChatRole.User, "hi")], cancellationToken: TestContext.Current.CancellationToken))
+ {
+ updates.Add(u);
+ }
+
+ Assert.Single(updates); // leaf reached, output flows through
+ Assert.Contains(logs, l => l.Contains("LLM streaming call completed")); // Logging middleware wired
+ }
+
+ private sealed class ListLoggerFactory : ILoggerFactory
+ {
+ private readonly List _logs;
+ public ListLoggerFactory(List logs) => _logs = logs;
+ public ILogger CreateLogger(string categoryName) => new ListLogger(_logs);
+ public void AddProvider(ILoggerProvider provider) { }
+ public void Dispose() { }
+
+ private sealed class ListLogger(List logs) : ILogger
+ {
+ public IDisposable? BeginScope(TState state) where TState : notnull => null;
+ public bool IsEnabled(LogLevel logLevel) => true;
+ public void Log(
+ LogLevel logLevel, EventId eventId, TState state,
+ Exception? exception, Func formatter)
+ => logs.Add(formatter(state, exception));
+ }
+ }
+}
diff --git a/src/Netclaw.Daemon.Tests/Configuration/ResilientChatClientProviderDecoratorTests.cs b/src/Netclaw.Daemon.Tests/Configuration/ResilientChatClientProviderDecoratorTests.cs
deleted file mode 100644
index 6a18cf6a3..000000000
--- a/src/Netclaw.Daemon.Tests/Configuration/ResilientChatClientProviderDecoratorTests.cs
+++ /dev/null
@@ -1,128 +0,0 @@
-// -----------------------------------------------------------------------
-//
-// Copyright (C) 2026 - 2026 Petabridge, LLC
-//
-// -----------------------------------------------------------------------
-using Microsoft.Extensions.Logging.Abstractions;
-using Netclaw.Configuration;
-using Netclaw.Daemon.Configuration;
-using Xunit;
-
-namespace Netclaw.Daemon.Tests.Configuration;
-
-public sealed class ResilientChatClientProviderDecoratorTests
-{
- private static readonly RetryPolicy Policy = new()
- {
- MaxRetries = 3,
- BaseDelay = TimeSpan.FromMilliseconds(1),
- MaxDelay = TimeSpan.FromMilliseconds(10)
- };
-
- [Fact]
- public void MainRole_UsesFailover_WhenDistinctFallbackConfigured()
- {
- var rawMain = new FakeChatClient();
- var rawFallback = new FakeChatClient();
- var inner = new StubChatClientProvider(rawMain, rawFallback, rawMain);
-
- var models = new ModelSelection
- {
- Main = new ModelReference { Provider = "main", ModelId = "main-model" },
- Fallback = new ModelReference { Provider = "fallback", ModelId = "fallback-model" }
- };
-
- var decorated = new ResilientChatClientProviderDecorator(
- inner,
- Policy,
- models,
- NullLoggerFactory.Instance,
- NullNotificationSink.Instance);
-
- var main = decorated.GetClient(ModelRole.Main);
- var fallbackRole = decorated.GetClient(ModelRole.Fallback);
- var compaction = decorated.GetClient(ModelRole.Compaction);
-
- Assert.IsType(main);
- Assert.Same(main, fallbackRole);
- Assert.Same(main, compaction);
- }
-
- [Fact]
- public void MainRole_SkipsFailover_WhenFallbackResolvesToSameRawClient()
- {
- var rawMain = new FakeChatClient();
- var inner = new StubChatClientProvider(rawMain, rawMain, rawMain);
-
- var models = new ModelSelection
- {
- Main = new ModelReference { Provider = "main", ModelId = "main-model" },
- Fallback = new ModelReference { Provider = "fallback", ModelId = "fallback-model" }
- };
-
- var decorated = new ResilientChatClientProviderDecorator(
- inner,
- Policy,
- models,
- NullLoggerFactory.Instance,
- NullNotificationSink.Instance);
-
- var main = decorated.GetClient(ModelRole.Main);
-
- Assert.IsType(main);
- Assert.IsNotType(main);
- Assert.Same(main, decorated.GetClient(ModelRole.Fallback));
- }
-
- [Fact]
- public void CompactionRole_UsesSeparateDecoratedClient_WhenRawCompactionIsDistinct()
- {
- var rawMain = new FakeChatClient();
- var rawCompaction = new FakeChatClient();
- var inner = new StubChatClientProvider(rawMain, rawMain, rawCompaction);
-
- var models = new ModelSelection
- {
- Main = new ModelReference { Provider = "main", ModelId = "main-model" },
- Compaction = new ModelReference { Provider = "compaction", ModelId = "compaction-model" }
- };
-
- var decorated = new ResilientChatClientProviderDecorator(
- inner,
- Policy,
- models,
- NullLoggerFactory.Instance,
- NullNotificationSink.Instance);
-
- var main = decorated.GetClient(ModelRole.Main);
- var compaction = decorated.GetClient(ModelRole.Compaction);
-
- Assert.IsType(main);
- Assert.IsType(compaction);
- Assert.NotSame(main, compaction);
- }
-
- private sealed class StubChatClientProvider : IChatClientProvider
- {
- private readonly Microsoft.Extensions.AI.IChatClient _main;
- private readonly Microsoft.Extensions.AI.IChatClient _fallback;
- private readonly Microsoft.Extensions.AI.IChatClient _compaction;
-
- public StubChatClientProvider(
- Microsoft.Extensions.AI.IChatClient main,
- Microsoft.Extensions.AI.IChatClient fallback,
- Microsoft.Extensions.AI.IChatClient compaction)
- {
- _main = main;
- _fallback = fallback;
- _compaction = compaction;
- }
-
- public Microsoft.Extensions.AI.IChatClient GetClient(ModelRole role) => role switch
- {
- ModelRole.Fallback => _fallback,
- ModelRole.Compaction => _compaction,
- _ => _main
- };
- }
-}
diff --git a/src/Netclaw.Daemon.Tests/Configuration/RetryingChatClientTests.cs b/src/Netclaw.Daemon.Tests/Configuration/RetryingChatClientTests.cs
index e938b87d0..6441ff682 100644
--- a/src/Netclaw.Daemon.Tests/Configuration/RetryingChatClientTests.cs
+++ b/src/Netclaw.Daemon.Tests/Configuration/RetryingChatClientTests.cs
@@ -4,6 +4,7 @@
//
// -----------------------------------------------------------------------
using System.Net;
+using System.Runtime.CompilerServices;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Logging.Abstractions;
using Netclaw.Configuration;
@@ -157,9 +158,108 @@ await Assert.ThrowsAsync(() =>
}
[Fact]
- public async Task StreamingIsPassedThrough_NoRetry()
+ public async Task StreamingRetriesPreFirstChunk_ThenSucceeds()
{
- var fake = new FakeChatClient(streaming: true);
+ var attempts = 0;
+ var fake = new FakeChatClient(streamHandler: (_, _, ct) =>
+ {
+ attempts++;
+ return ThrowBeforeChunkThenYield(attempts, failUntil: 3, ct);
+ });
+ var client = new RetryingChatClient(fake, _policy, NullLogger.Instance);
+
+ var updates = new List();
+ await foreach (var u in client.GetStreamingResponseAsync(
+ [new ChatMessage(ChatRole.User, "hi")], cancellationToken: TestContext.Current.CancellationToken))
+ {
+ updates.Add(u);
+ }
+
+ Assert.Single(updates); // only the successful attempt yields
+ Assert.Equal(3, attempts); // 2 pre-chunk failures + 1 success, each re-initiates the stream
+ }
+
+ [Fact]
+ public async Task StreamingDoesNotRetryAfterFirstChunk()
+ {
+ var attempts = 0;
+ var fake = new FakeChatClient(streamHandler: (_, _, ct) =>
+ {
+ attempts++;
+ return YieldThenThrow(ct);
+ });
+ var client = new RetryingChatClient(fake, _policy, NullLogger.Instance);
+
+ var updates = new List();
+ await Assert.ThrowsAsync(async () =>
+ {
+ await foreach (var u in client.GetStreamingResponseAsync(
+ [new ChatMessage(ChatRole.User, "hi")], cancellationToken: TestContext.Current.CancellationToken))
+ {
+ updates.Add(u);
+ }
+ });
+
+ // The 500 is retryable by policy, but a chunk was already emitted, so the
+ // failure propagates instead of restarting (no duplicate output).
+ Assert.Single(updates);
+ Assert.Equal(1, attempts);
+ }
+
+ [Fact]
+ public async Task StreamingStopsAfterMaxRetries()
+ {
+ var attempts = 0;
+ var fake = new FakeChatClient(streamHandler: (_, _, ct) =>
+ {
+ attempts++;
+ return ThrowBeforeChunkThenYield(attempts, failUntil: 100, ct);
+ });
+ var client = new RetryingChatClient(fake, _policy, NullLogger.Instance);
+
+ await Assert.ThrowsAsync(async () =>
+ {
+ await foreach (var _ in client.GetStreamingResponseAsync(
+ [new ChatMessage(ChatRole.User, "hi")], cancellationToken: TestContext.Current.CancellationToken)) { }
+ });
+
+ Assert.Equal(4, attempts); // 1 initial + 3 retries
+ }
+
+ [Fact]
+ public async Task StreamingDoesNotRetry_WhenCancelled()
+ {
+ var cts = new CancellationTokenSource();
+ cts.Cancel();
+
+ var attempts = 0;
+ var fake = new FakeChatClient(streamHandler: (_, _, ct) =>
+ {
+ attempts++;
+ return ThrowBeforeChunkThenYield(attempts, failUntil: 100, ct);
+ });
+ var client = new RetryingChatClient(fake, _policy, NullLogger.Instance);
+
+ await Assert.ThrowsAnyAsync(async () =>
+ {
+ await foreach (var _ in client.GetStreamingResponseAsync(
+ [new ChatMessage(ChatRole.User, "hi")], cancellationToken: cts.Token)) { }
+ });
+
+ Assert.Equal(1, attempts); // cancellation is not retried
+ }
+
+ [Fact]
+ public async Task StreamingRetries_ProviderException5xx_ThenSucceeds()
+ {
+ // Curated provider errors carry the status on a ProviderException, not a raw
+ // HttpRequestException — the transport must still recognize them as transient.
+ var attempts = 0;
+ var fake = new FakeChatClient(streamHandler: (_, _, ct) =>
+ {
+ attempts++;
+ return ThrowProviderExceptionThenYield(attempts, failUntil: 3, ct);
+ });
var client = new RetryingChatClient(fake, _policy, NullLogger.Instance);
var updates = new List();
@@ -170,6 +270,44 @@ [new ChatMessage(ChatRole.User, "hi")], cancellationToken: TestContext.Current.C
}
Assert.Single(updates);
+ Assert.Equal(3, attempts); // 2 ProviderException(502) failures + 1 success
+ }
+
+ // Throws a retryable 429 before yielding any chunk while attemptNumber < failUntil,
+ // otherwise yields one chunk. The runtime-dependent condition keeps the yield
+ // reachable (no CS0162) so no warning suppression is needed.
+ private static async IAsyncEnumerable ThrowBeforeChunkThenYield(
+ int attemptNumber, int failUntil,
+ [EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ await Task.Yield();
+ cancellationToken.ThrowIfCancellationRequested();
+ if (attemptNumber < failUntil)
+ throw new HttpRequestException("rate limited", null, HttpStatusCode.TooManyRequests);
+
+ yield return new ChatResponseUpdate { Role = ChatRole.Assistant, Contents = [new TextContent("ok")] };
+ }
+
+ // Same shape as ThrowBeforeChunkThenYield but throws a curated ProviderException(502).
+ private static async IAsyncEnumerable ThrowProviderExceptionThenYield(
+ int attemptNumber, int failUntil,
+ [EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ await Task.Yield();
+ cancellationToken.ThrowIfCancellationRequested();
+ if (attemptNumber < failUntil)
+ throw new ProviderException("server error (502)", "HTTP 502", statusCode: 502);
+
+ yield return new ChatResponseUpdate { Role = ChatRole.Assistant, Contents = [new TextContent("ok")] };
+ }
+
+ private static async IAsyncEnumerable YieldThenThrow(
+ [EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ await Task.Yield();
+ cancellationToken.ThrowIfCancellationRequested();
+ yield return new ChatResponseUpdate { Role = ChatRole.Assistant, Contents = [new TextContent("partial")] };
+ throw new HttpRequestException("mid-stream failure", null, HttpStatusCode.InternalServerError);
}
}
diff --git a/src/Netclaw.Daemon.Tests/Configuration/RoutingChatClientProviderTests.cs b/src/Netclaw.Daemon.Tests/Configuration/RoutingChatClientProviderTests.cs
new file mode 100644
index 000000000..631a30f11
--- /dev/null
+++ b/src/Netclaw.Daemon.Tests/Configuration/RoutingChatClientProviderTests.cs
@@ -0,0 +1,82 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (C) 2026 - 2026 Petabridge, LLC
+//
+// -----------------------------------------------------------------------
+using Microsoft.Extensions.AI;
+using Microsoft.Extensions.Logging.Abstractions;
+using Netclaw.Configuration;
+using Netclaw.Daemon.Configuration;
+using Xunit;
+
+namespace Netclaw.Daemon.Tests.Configuration;
+
+public sealed class RoutingChatClientProviderTests
+{
+ [Fact]
+ public void GetClient_caches_per_role_and_shares_Main_and_Fallback()
+ {
+ var provider = new RoutingChatClientProvider(
+ new StubRouter(), NullNotificationSink.Instance, NullLoggerFactory.Instance);
+
+ var main = provider.GetClient(ModelRole.Main);
+
+ Assert.Same(main, provider.GetClient(ModelRole.Main)); // cached per role
+ Assert.Same(main, provider.GetClient(ModelRole.Fallback)); // Fallback shares the Main routing client
+ Assert.NotSame(main, provider.GetClient(ModelRole.Compaction));// Compaction is its own routing client
+ Assert.Same(provider.GetClient(ModelRole.Compaction), provider.GetClient(ModelRole.Compaction));
+ }
+
+ private sealed class StubRouter : IChatClientRouter
+ {
+ private readonly IReadOnlyList _candidates = [new FakeChatClient()];
+ public IReadOnlyList Route(ChatRoutingContext context) => _candidates;
+ }
+}
+
+public sealed class RoleBasedFailoverRouterTests
+{
+ [Fact]
+ public void Main_has_two_candidates_when_fallback_configured()
+ {
+ var models = new ModelSelection
+ {
+ Main = new ModelReference { Provider = "p", ModelId = "main" },
+ Fallback = new ModelReference { Provider = "p", ModelId = "fb" }
+ };
+ var router = new RoleBasedFailoverRouter(_ => new FakeChatClient(), models);
+
+ var main = router.Route(new ChatRoutingContext { Role = ModelRole.Main });
+
+ Assert.Equal(2, main.Count);
+ Assert.Same(main, router.Route(new ChatRoutingContext { Role = ModelRole.Fallback }));
+ // No distinct compaction model → compaction reuses the main candidate list.
+ Assert.Same(main, router.Route(new ChatRoutingContext { Role = ModelRole.Compaction }));
+ }
+
+ [Fact]
+ public void Main_has_one_candidate_when_no_fallback()
+ {
+ var models = new ModelSelection { Main = new ModelReference { Provider = "p", ModelId = "main" } };
+ var router = new RoleBasedFailoverRouter(_ => new FakeChatClient(), models);
+
+ Assert.Single(router.Route(new ChatRoutingContext { Role = ModelRole.Main }));
+ }
+
+ [Fact]
+ public void Compaction_is_a_distinct_single_pipeline_when_configured()
+ {
+ var models = new ModelSelection
+ {
+ Main = new ModelReference { Provider = "p", ModelId = "main" },
+ Compaction = new ModelReference { Provider = "p", ModelId = "comp" }
+ };
+ var router = new RoleBasedFailoverRouter(_ => new FakeChatClient(), models);
+
+ var main = router.Route(new ChatRoutingContext { Role = ModelRole.Main });
+ var compaction = router.Route(new ChatRoutingContext { Role = ModelRole.Compaction });
+
+ Assert.Single(compaction);
+ Assert.NotSame(main, compaction);
+ }
+}
diff --git a/src/Netclaw.Daemon.Tests/Configuration/RoutingChatClientTests.cs b/src/Netclaw.Daemon.Tests/Configuration/RoutingChatClientTests.cs
new file mode 100644
index 000000000..4670c0904
--- /dev/null
+++ b/src/Netclaw.Daemon.Tests/Configuration/RoutingChatClientTests.cs
@@ -0,0 +1,265 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (C) 2026 - 2026 Petabridge, LLC
+//
+// -----------------------------------------------------------------------
+using System.Runtime.CompilerServices;
+using Microsoft.Extensions.AI;
+using Microsoft.Extensions.Logging.Abstractions;
+using Netclaw.Configuration;
+using Netclaw.Daemon.Configuration;
+using Xunit;
+
+namespace Netclaw.Daemon.Tests.Configuration;
+
+public sealed class RoutingChatClientTests
+{
+ private static RoutingChatClient Client(IOperationalNotificationSink sink, params IChatClient[] candidates) =>
+ new(new StubRouter(candidates), new ChatRoutingContext { Role = ModelRole.Main },
+ sink, NullLogger.Instance, TimeProvider.System);
+
+ [Fact]
+ public async Task UsesPrimary_WhenHealthy()
+ {
+ var sink = new CapturingSink();
+ var primary = new FakeChatClient((_, _, _) =>
+ Task.FromResult(new ChatResponse([new ChatMessage(ChatRole.Assistant, "primary")])));
+ var fallback = new FakeChatClient((_, _, _) =>
+ Task.FromResult(new ChatResponse([new ChatMessage(ChatRole.Assistant, "fallback")])));
+
+ var response = await Client(sink, primary, fallback)
+ .GetResponseAsync([new ChatMessage(ChatRole.User, "hi")], cancellationToken: TestContext.Current.CancellationToken);
+
+ Assert.Equal("primary", response.Messages[0].Text);
+ Assert.Empty(sink.Alerts);
+ }
+
+ [Fact]
+ public async Task FailsOverToNextCandidate_WhenPrimaryFails()
+ {
+ var sink = new CapturingSink();
+ var primary = new FakeChatClient((_, _, _) => throw new HttpRequestException("primary down"));
+ var fallback = new FakeChatClient((_, _, _) =>
+ Task.FromResult(new ChatResponse([new ChatMessage(ChatRole.Assistant, "fallback")])));
+
+ var response = await Client(sink, primary, fallback)
+ .GetResponseAsync([new ChatMessage(ChatRole.User, "hi")], cancellationToken: TestContext.Current.CancellationToken);
+
+ Assert.Equal("fallback", response.Messages[0].Text);
+ Assert.Contains(sink.Alerts, a => a.Category == AlertType.ProviderFailover);
+ Assert.DoesNotContain(sink.Alerts, a => a.Category == AlertType.ProviderUnreachable);
+ }
+
+ [Fact]
+ public async Task EmitsUnreachable_WhenAllCandidatesFail()
+ {
+ var sink = new CapturingSink();
+ var primary = new FakeChatClient((_, _, _) => throw new HttpRequestException("primary down"));
+ var fallback = new FakeChatClient((_, _, _) => throw new HttpRequestException("fallback down"));
+
+ var ex = await Assert.ThrowsAsync(() =>
+ Client(sink, primary, fallback)
+ .GetResponseAsync([new ChatMessage(ChatRole.User, "hi")], cancellationToken: TestContext.Current.CancellationToken));
+
+ Assert.Contains("fallback down", ex.Message);
+ Assert.Contains(sink.Alerts, a => a.Category == AlertType.ProviderFailover);
+ Assert.Contains(sink.Alerts, a => a.Category == AlertType.ProviderUnreachable);
+ }
+
+ [Fact]
+ public async Task SingleCandidateFailure_EmitsUnreachable_NotFailover()
+ {
+ var sink = new CapturingSink();
+ var only = new FakeChatClient((_, _, _) => throw new HttpRequestException("down"));
+
+ await Assert.ThrowsAsync(() =>
+ Client(sink, only)
+ .GetResponseAsync([new ChatMessage(ChatRole.User, "hi")], cancellationToken: TestContext.Current.CancellationToken));
+
+ Assert.Contains(sink.Alerts, a => a.Category == AlertType.ProviderUnreachable);
+ Assert.DoesNotContain(sink.Alerts, a => a.Category == AlertType.ProviderFailover);
+ }
+
+ [Fact]
+ public async Task DoesNotFailover_OnCancellation()
+ {
+ var cts = new CancellationTokenSource();
+ cts.Cancel();
+ var sink = new CapturingSink();
+ var fallbackCalls = 0;
+ var primary = new FakeChatClient((_, _, ct) => throw new OperationCanceledException(ct));
+ var fallback = new FakeChatClient((_, _, _) =>
+ {
+ fallbackCalls++;
+ return Task.FromResult(new ChatResponse([new ChatMessage(ChatRole.Assistant, "fallback")]));
+ });
+
+ await Assert.ThrowsAnyAsync(() =>
+ Client(sink, primary, fallback)
+ .GetResponseAsync([new ChatMessage(ChatRole.User, "hi")], cancellationToken: cts.Token));
+
+ Assert.Equal(0, fallbackCalls);
+ Assert.Empty(sink.Alerts);
+ }
+
+ [Fact]
+ public async Task Streaming_UsesPrimary_WhenHealthy()
+ {
+ var sink = new CapturingSink();
+ var primary = new FakeChatClient(streamHandler: (_, _, ct) => SingleTextUpdateAsync("primary", ct));
+ var fallback = new FakeChatClient(streamHandler: (_, _, ct) => SingleTextUpdateAsync("fallback", ct));
+
+ var texts = await CollectText(Client(sink, primary, fallback));
+
+ Assert.Equal(["primary"], texts);
+ Assert.Empty(sink.Alerts);
+ }
+
+ [Fact]
+ public async Task Streaming_FailsOver_WhenPrimaryFailsBeforeFirstChunk()
+ {
+ var sink = new CapturingSink();
+ var fallbackCalls = 0;
+ var primary = new FakeChatClient(streamHandler: (_, _, ct) => ThrowBeforeFirstChunkAsync(true, ct));
+ var fallback = new FakeChatClient(streamHandler: (_, _, ct) =>
+ {
+ fallbackCalls++;
+ return SingleTextUpdateAsync("fallback", ct);
+ });
+
+ var texts = await CollectText(Client(sink, primary, fallback));
+
+ Assert.Equal(["fallback"], texts);
+ Assert.Equal(1, fallbackCalls);
+ Assert.Contains(sink.Alerts, a => a.Category == AlertType.ProviderFailover);
+ }
+
+ [Fact]
+ public async Task Streaming_DoesNotFailover_AfterPrimaryAlreadyYielded()
+ {
+ var sink = new CapturingSink();
+ var fallbackCalls = 0;
+ var primary = new FakeChatClient(streamHandler: (_, _, ct) => YieldThenThrowAsync(ct));
+ var fallback = new FakeChatClient(streamHandler: (_, _, ct) =>
+ {
+ fallbackCalls++;
+ return SingleTextUpdateAsync("fallback", ct);
+ });
+ var texts = new List();
+
+ var ex = await Assert.ThrowsAsync(async () =>
+ {
+ await foreach (var u in Client(sink, primary, fallback).GetStreamingResponseAsync(
+ [new ChatMessage(ChatRole.User, "hi")], cancellationToken: TestContext.Current.CancellationToken))
+ {
+ foreach (var c in u.Contents)
+ if (c is TextContent t) texts.Add(t.Text);
+ }
+ });
+
+ Assert.Contains("after first chunk", ex.Message);
+ Assert.Equal(["primary"], texts);
+ Assert.Equal(0, fallbackCalls);
+ }
+
+ [Fact]
+ public async Task Streaming_EmitsUnreachable_WhenAllCandidatesFailBeforeFirstChunk()
+ {
+ var sink = new CapturingSink();
+ var primary = new FakeChatClient(streamHandler: (_, _, ct) => ThrowBeforeFirstChunkAsync(true, ct));
+ var fallback = new FakeChatClient(streamHandler: (_, _, ct) => ThrowBeforeFirstChunkAsync(true, ct));
+
+ await Assert.ThrowsAsync(() => CollectText(Client(sink, primary, fallback)));
+
+ Assert.Contains(sink.Alerts, a => a.Category == AlertType.ProviderFailover);
+ Assert.Contains(sink.Alerts, a => a.Category == AlertType.ProviderUnreachable);
+ }
+
+ [Fact]
+ public async Task Streaming_SingleCandidateFailure_EmitsUnreachable_NotFailover()
+ {
+ var sink = new CapturingSink();
+ var only = new FakeChatClient(streamHandler: (_, _, ct) => ThrowBeforeFirstChunkAsync(true, ct));
+
+ await Assert.ThrowsAsync(() => CollectText(Client(sink, only)));
+
+ Assert.Contains(sink.Alerts, a => a.Category == AlertType.ProviderUnreachable);
+ Assert.DoesNotContain(sink.Alerts, a => a.Category == AlertType.ProviderFailover);
+ }
+
+ [Fact]
+ public async Task Streaming_DoesNotFailover_OnCancellation()
+ {
+ var cts = new CancellationTokenSource();
+ cts.Cancel();
+ var sink = new CapturingSink();
+ var fallbackCalls = 0;
+ var primary = new FakeChatClient(streamHandler: (_, _, ct) => ThrowBeforeFirstChunkAsync(true, ct));
+ var fallback = new FakeChatClient(streamHandler: (_, _, ct) =>
+ {
+ fallbackCalls++;
+ return SingleTextUpdateAsync("fallback", ct);
+ });
+
+ await Assert.ThrowsAnyAsync(async () =>
+ {
+ await foreach (var _ in Client(sink, primary, fallback).GetStreamingResponseAsync(
+ [new ChatMessage(ChatRole.User, "hi")], cancellationToken: cts.Token)) { }
+ });
+
+ Assert.Equal(0, fallbackCalls);
+ Assert.Empty(sink.Alerts);
+ }
+
+ private static async Task> CollectText(IChatClient client)
+ {
+ var texts = new List();
+ await foreach (var u in client.GetStreamingResponseAsync(
+ [new ChatMessage(ChatRole.User, "hi")], cancellationToken: TestContext.Current.CancellationToken))
+ {
+ foreach (var c in u.Contents)
+ if (c is TextContent t) texts.Add(t.Text);
+ }
+
+ return texts;
+ }
+
+ private static async IAsyncEnumerable ThrowBeforeFirstChunkAsync(
+ bool shouldThrow, [EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ await Task.Yield();
+ cancellationToken.ThrowIfCancellationRequested();
+ if (shouldThrow)
+ throw new HttpRequestException("primary stream failed before first chunk");
+
+ yield return new ChatResponseUpdate { Role = ChatRole.Assistant, Contents = [new TextContent("unused")] };
+ }
+
+ private static async IAsyncEnumerable YieldThenThrowAsync(
+ [EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ await Task.Yield();
+ cancellationToken.ThrowIfCancellationRequested();
+ yield return new ChatResponseUpdate { Role = ChatRole.Assistant, Contents = [new TextContent("primary")] };
+ throw new HttpRequestException("primary stream failed after first chunk");
+ }
+
+ private static async IAsyncEnumerable SingleTextUpdateAsync(
+ string text, [EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ await Task.Yield();
+ cancellationToken.ThrowIfCancellationRequested();
+ yield return new ChatResponseUpdate { Role = ChatRole.Assistant, Contents = [new TextContent(text)] };
+ }
+
+ private sealed class StubRouter(IReadOnlyList candidates) : IChatClientRouter
+ {
+ public IReadOnlyList Route(ChatRoutingContext context) => candidates;
+ }
+
+ private sealed class CapturingSink : IOperationalNotificationSink
+ {
+ public List Alerts { get; } = [];
+ public void Emit(OperationalAlert alert) => Alerts.Add(alert);
+ }
+}
diff --git a/src/Netclaw.Daemon.Tests/Configuration/ScopeCapturingLogger.cs b/src/Netclaw.Daemon.Tests/Configuration/ScopeCapturingLogger.cs
new file mode 100644
index 000000000..2b93e2bba
--- /dev/null
+++ b/src/Netclaw.Daemon.Tests/Configuration/ScopeCapturingLogger.cs
@@ -0,0 +1,45 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (C) 2026 - 2026 Petabridge, LLC
+//
+// -----------------------------------------------------------------------
+using Microsoft.Extensions.Logging;
+
+namespace Netclaw.Daemon.Tests.Configuration;
+
+///
+/// Test logger that records the state objects passed to
+/// so tests can assert which scopes were opened around a logging call. Returns
+/// null from BeginScope (a valid no-op for using var); only the
+/// captured state matters.
+///
+internal sealed class ScopeCapturingLogger : ILogger
+{
+ private const string SessionIdKey = "SessionId";
+
+ public List