diff --git a/AuthorAgent.cs b/AuthorAgent.cs index b1d8c52..8c27975 100644 --- a/AuthorAgent.cs +++ b/AuthorAgent.cs @@ -105,9 +105,20 @@ public async Task AuthorNodeAsync(ResearchState state, Cancellati if (string.IsNullOrEmpty(draft)) { - // Keep whatever draft already exists rather than clobbering it with a - // placeholder — an empty/failed generation shouldn't erase real content. - _logger.LogWarning("Author agent produced no draft; keeping the previous draft (if any)."); + if (string.IsNullOrEmpty(state.Draft)) + { + // No prior draft to keep either — the workflow must always end with + // some draft, so assemble one from the research findings rather than + // sending the reviewer (and ultimately the user) an empty draft. + _logger.LogWarning("Author agent produced no draft and none exists yet; using a fallback draft built from research findings."); + state.Draft = BuildFallbackDraft(state); + } + else + { + // Keep whatever draft already exists rather than clobbering it with a + // placeholder — an empty/failed generation shouldn't erase real content. + _logger.LogWarning("Author agent produced no draft; keeping the previous draft."); + } } else { @@ -118,4 +129,21 @@ public async Task AuthorNodeAsync(ResearchState state, Cancellati state.RevisionNumber += 1; return state; } + + // Last-resort content used only when the model never manages to produce a + // draft at all, so the workflow still always yields something reviewable. + private static string BuildFallbackDraft(ResearchState state) + { + string researchText = state.ResearchFindings.Count > 0 + ? string.Join("\n\n", state.ResearchFindings) + : "No research findings were available."; + + return $""" + # {state.MainTask} + + _The author agent could not generate content for this topic; this fallback draft was assembled automatically from the raw research findings._ + + {researchText} + """; + } } diff --git a/BlogWriter.Tests/AuthorAgentTests.cs b/BlogWriter.Tests/AuthorAgentTests.cs new file mode 100644 index 0000000..8c5ac71 --- /dev/null +++ b/BlogWriter.Tests/AuthorAgentTests.cs @@ -0,0 +1,43 @@ +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace BlogWriter.Tests; + +/// Verifies the Author stage always leaves the workflow with a non-empty draft. +public class AuthorAgentTests +{ + private static AuthorAgent CreateAgent(IChatClient chatClient) => + new(chatClient, new ChatOptions { Temperature = 0, MaxOutputTokens = 100 }, NullLogger.Instance); + + [Fact] + public async Task ModelReturnsEmpty_AndNoPriorDraft_UsesFallbackDraft() + { + var state = new ResearchState { MainTask = "topic", ResearchFindings = ["some findings"] }; + + state = await CreateAgent(new EmptyChatClient()).AuthorNodeAsync(state); + + Assert.False(string.IsNullOrEmpty(state.Draft)); + Assert.Contains("some findings", state.Draft); + } + + [Fact] + public async Task ModelReturnsEmpty_ButPriorDraftExists_KeepsPriorDraft() + { + var state = new ResearchState { MainTask = "topic", Draft = "existing draft" }; + + state = await CreateAgent(new EmptyChatClient()).AuthorNodeAsync(state); + + Assert.Equal("existing draft", state.Draft); + } + + [Fact] + public async Task ModelReturnsContent_UsesModelDraft() + { + var state = new ResearchState { MainTask = "topic" }; + + state = await CreateAgent(new FakeChatClient(10)).AuthorNodeAsync(state); + + Assert.Equal("ok", state.Draft); + } +} diff --git a/BlogWriter.Tests/TestChatClients.cs b/BlogWriter.Tests/TestChatClients.cs index 9d3c50e..09deeaa 100644 --- a/BlogWriter.Tests/TestChatClients.cs +++ b/BlogWriter.Tests/TestChatClients.cs @@ -39,6 +39,35 @@ public void Dispose() } } +/// An test double that always returns an empty response, simulating a model that produced no content. +internal sealed class EmptyChatClient : IChatClient +{ + public Task GetResponseAsync( + IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) + { + var response = new ChatResponse(new ChatMessage(ChatRole.Assistant, "")) + { + Usage = new UsageDetails { TotalTokenCount = 10 }, + }; + return Task.FromResult(response); + } + + public async IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) + { + await Task.Yield(); + yield return new ChatResponseUpdate(ChatRole.Assistant, ""); + } + + public object? GetService(Type serviceType, object? serviceKey = null) => null; + + public void Dispose() + { + } +} + /// An that fails the test if it is ever invoked. internal sealed class ThrowingChatClient : IChatClient {