Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 31 additions & 3 deletions AuthorAgent.cs
Original file line number Diff line number Diff line change
Expand Up @@ -105,9 +105,20 @@ public async Task<ResearchState> 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))
{
Comment on lines 106 to +109
// 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
{
Expand All @@ -118,4 +129,21 @@ public async Task<ResearchState> 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}
""";
}
}
43 changes: 43 additions & 0 deletions BlogWriter.Tests/AuthorAgentTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Logging.Abstractions;
using Xunit;

namespace BlogWriter.Tests;

/// <summary>Verifies the Author stage always leaves the workflow with a non-empty draft.</summary>
public class AuthorAgentTests
{
private static AuthorAgent CreateAgent(IChatClient chatClient) =>
new(chatClient, new ChatOptions { Temperature = 0, MaxOutputTokens = 100 }, NullLogger<AuthorAgent>.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);
}
}
29 changes: 29 additions & 0 deletions BlogWriter.Tests/TestChatClients.cs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,35 @@ public void Dispose()
}
}

/// <summary>An <see cref="IChatClient"/> test double that always returns an empty response, simulating a model that produced no content.</summary>
internal sealed class EmptyChatClient : IChatClient
{
public Task<ChatResponse> GetResponseAsync(
IEnumerable<ChatMessage> 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<ChatResponseUpdate> GetStreamingResponseAsync(
IEnumerable<ChatMessage> 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()
{
}
}

/// <summary>An <see cref="IChatClient"/> that fails the test if it is ever invoked.</summary>
internal sealed class ThrowingChatClient : IChatClient
{
Expand Down