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
12 changes: 12 additions & 0 deletions AuthorAgent.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,16 @@ public AuthorAgent(AIAgent agent, ILogger<AuthorAgent> logger)
_logger.LogInformation("AuthorAgent initialized.");
}

// Compatibility overload for callers that supply an in-process test client.
public AuthorAgent(IChatClient llm, ChatOptions chatOptions, ILogger<AuthorAgent> logger)
: this(new ChatClientAgent(llm, new ChatClientAgentOptions
{
Name = "Author",
ChatOptions = chatOptions,
}), logger)
{
}

public async Task<string?> InvokeAsync(ResearchState state, CancellationToken cancellationToken = default)
{
using Activity? activity = s_activitySource.StartActivity("Author.Invoke");
Expand All @@ -48,6 +58,8 @@ public AuthorAgent(AIAgent agent, ILogger<AuthorAgent> logger)

Review Notes: {(string.IsNullOrEmpty(state.ReviewNotes) ? "(none)" : state.ReviewNotes)}

User Follow-Up: {(string.IsNullOrEmpty(state.CurrentSubTask) ? "(none)" : state.CurrentSubTask)}

Target Word Count: {state.MinWords} to {state.MaxWords} words
""";

Expand Down
10 changes: 10 additions & 0 deletions BlogSession.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
namespace BlogWriter;

/// <summary>Persisted state for one user's blog-writing conversation.</summary>
public sealed class BlogSession
{
public required string Id { get; init; }
public DateTimeOffset CreatedAt { get; init; }
public DateTimeOffset UpdatedAt { get; set; }
public required ResearchState State { get; set; }
}
50 changes: 50 additions & 0 deletions BlogWriter.Tests/FileBlogSessionStoreTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
using BlogWriter;
using Xunit;

namespace BlogWriter.Tests;

public class FileBlogSessionStoreTests
{
[Fact]
public async Task CreateAndGetAsync_RoundTripsCompletedWorkflowState()
{
string directory = Path.Combine(Path.GetTempPath(), $"BlogWriterTests-{Guid.NewGuid():N}");

try
{
var store = new FileBlogSessionStore(directory);
var state = new ResearchState
{
MainTask = "session memory",
ResearchFindings = ["finding"],
Draft = "draft",
ReviewNotes = ResearchState.ApprovedMarker,
};

BlogSession created = await store.CreateAsync(state);
BlogSession? loaded = await store.GetAsync(created.Id);

Assert.NotNull(loaded);
Assert.Equal(created.Id, loaded.Id);
Assert.Equal("session memory", loaded.State.MainTask);
Assert.Equal(["finding"], loaded.State.ResearchFindings);
Assert.Equal("draft", loaded.State.Draft);
}
finally
{
if (Directory.Exists(directory))
{
Directory.Delete(directory, recursive: true);
}
}
}

[Fact]
public async Task GetAsync_ReturnsNullForUnknownOrInvalidSessionId()
{
var store = new FileBlogSessionStore(Path.Combine(Path.GetTempPath(), $"BlogWriterTests-{Guid.NewGuid():N}"));

Assert.Null(await store.GetAsync("not-a-session-id"));
Assert.Null(await store.GetAsync(Guid.NewGuid().ToString("N")));
}
}
23 changes: 23 additions & 0 deletions BlogWriter.Tests/ResearchStateTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -64,4 +64,27 @@ public void RevisionLimitReached_RequiresUnapprovedReviewAtCap(

Assert.Equal(expected, state.RevisionLimitReached);
}

[Fact]
public void StartFollowUp_PreservesDraftAndResearchButResetsReviewCycle()
{
var state = new ResearchState
{
MainTask = "topic",
ResearchFindings = ["finding"],
Draft = "draft",
ReviewNotes = ResearchState.ApprovedMarker,
RevisionNumber = ResearchState.MaxRevisions,
NextStep = "END",
};

state.StartFollowUp("Add a caching section.");

Assert.Equal("Add a caching section.", state.CurrentSubTask);
Assert.Equal("draft", state.Draft);
Assert.Equal(["finding"], state.ResearchFindings);
Assert.Empty(state.ReviewNotes);
Assert.Equal(0, state.RevisionNumber);
Assert.Empty(state.NextStep);
}
}
68 changes: 68 additions & 0 deletions FileBlogSessionStore.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
using System.Text.Json;

namespace BlogWriter;

/// <summary>Stores each session as a JSON document on the local machine.</summary>
public sealed class FileBlogSessionStore(string directoryPath) : IBlogSessionStore
{
private static readonly JsonSerializerOptions s_jsonOptions = new() { WriteIndented = true };
private readonly string _directoryPath = directoryPath;

public async Task<BlogSession> CreateAsync(ResearchState state, CancellationToken cancellationToken = default)
{
DateTimeOffset now = DateTimeOffset.UtcNow;
var session = new BlogSession
{
Id = Guid.NewGuid().ToString("N"),
CreatedAt = now,
UpdatedAt = now,
State = state,
};

await SaveAsync(session, cancellationToken);
return session;
}

public async Task<BlogSession?> GetAsync(string sessionId, CancellationToken cancellationToken = default)
{
if (!IsValidId(sessionId))
{
return null;
}

string path = GetPath(sessionId);
if (!File.Exists(path))
{
return null;
}

await using FileStream stream = File.OpenRead(path);
return await JsonSerializer.DeserializeAsync<BlogSession>(stream, s_jsonOptions, cancellationToken);
}

public async Task SaveAsync(BlogSession session, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(session);
if (!IsValidId(session.Id))
{
throw new ArgumentException("Session ID must be a 32-character hexadecimal GUID.", nameof(session));
}
Comment on lines +45 to +49

Directory.CreateDirectory(_directoryPath);
session.UpdatedAt = DateTimeOffset.UtcNow;
string path = GetPath(session.Id);
string temporaryPath = $"{path}.{Guid.NewGuid():N}.tmp";

await using (FileStream stream = File.Create(temporaryPath))
{
await JsonSerializer.SerializeAsync(stream, session, s_jsonOptions, cancellationToken);
}

File.Move(temporaryPath, path, overwrite: true);
}

private string GetPath(string sessionId) => Path.Combine(_directoryPath, $"{sessionId}.json");

private static bool IsValidId(string sessionId) =>
Guid.TryParseExact(sessionId, "N", out _);
}
4 changes: 3 additions & 1 deletion HostedAgents/Author/AgentPrompt.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,13 @@ public static class Prompts
You are a professional blogger.

The user message contains the main task, the research findings, the current
draft (if any), any reviewer notes, and the target word count range.
draft (if any), any reviewer notes, an optional user follow-up, and the target
word count range.

Instructions:
- If this is the first draft (no current draft), create a comprehensive post based on the findings
- If there is a current draft and review notes, revise the draft to address all feedback
- If a user follow-up is present, revise the current draft to fulfill it while retaining relevant research
- Use a professional tone
- Aim for the target word count range given in the user message.

Expand Down
9 changes: 9 additions & 0 deletions IBlogSessionStore.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
namespace BlogWriter;

/// <summary>Persists completed workflow state so a user can continue a draft.</summary>
public interface IBlogSessionStore
Comment on lines +3 to +4
{
Task<BlogSession> CreateAsync(ResearchState state, CancellationToken cancellationToken = default);
Task<BlogSession?> GetAsync(string sessionId, CancellationToken cancellationToken = default);
Task SaveAsync(BlogSession session, CancellationToken cancellationToken = default);
}
143 changes: 91 additions & 52 deletions Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -79,16 +79,9 @@ AIAgent BuildFoundryAgent(string hostedAgentName)
Console.WriteLine($"[trace] \u2190 {activity.DisplayName} ({activity.Duration.TotalMilliseconds:F0} ms)")
});

Console.Write("Enter your topic: ");
string topic = Console.ReadLine() ?? string.Empty;

int minWords = ReadWordCount(
$"Enter minimum word count [{ResearchState.DefaultMinWords}]: ",
ResearchState.DefaultMinWords);
int maxWords = ReadWordCount(
$"Enter maximum word count [{ResearchState.DefaultMaxWords}]: ",
ResearchState.DefaultMaxWords,
minimum: minWords);
string sessionDirectory = config["BLOG_SESSION_STORE_PATH"]
?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "BlogWriter", "sessions");
IBlogSessionStore sessionStore = new FileBlogSessionStore(sessionDirectory);

// Prompts for a positive word count, re-asking until a valid value (or blank
// for the default) is entered. `minimum`, when set, enforces max >= min.
Expand All @@ -114,14 +107,6 @@ int ReadWordCount(string prompt, int defaultValue, int? minimum = null)
}
}

// Run the workflow for the entered topic
var initialState = new ResearchState
{
MainTask = topic,
MinWords = minWords,
MaxWords = maxWords
};

// Ctrl+C requests a graceful cancellation of the in-flight run instead of an
// abrupt process kill.
using var cts = new CancellationTokenSource();
Expand All @@ -131,46 +116,100 @@ int ReadWordCount(string prompt, int defaultValue, int? minimum = null)
cts.Cancel();
};

ResearchState result;
try
while (!cts.IsCancellationRequested)
{
using Activity? runActivity = appActivitySource.StartActivity("BlogWriter.Run");
runActivity?.SetTag("blog.topic", topic);
result = await app.RunAsync(initialState, cts.Token);
}
catch (TokenCapExceededException ex)
{
// Graceful shutdown: the exception unwinds the call stack so every `using`
// (logger factory, HTTP clients, etc.) is disposed before we exit.
Console.Error.WriteLine($"{ex.Message} Exiting application.");
Environment.ExitCode = 1;
return;
}
catch (OperationCanceledException)
{
Console.Error.WriteLine("Run cancelled. Exiting application.");
Environment.ExitCode = 1;
return;
}
Console.Write("Enter a topic, 'resume <session-id>', or press Enter to exit: ");
string? input = Console.ReadLine();
if (string.IsNullOrWhiteSpace(input))
{
break;
}

BlogSession? session = null;
const string resumePrefix = "resume ";
if (input.StartsWith(resumePrefix, StringComparison.OrdinalIgnoreCase))
{
string sessionId = input[resumePrefix.Length..].Trim();
session = await sessionStore.GetAsync(sessionId, cts.Token);
if (session is null)
{
Console.Error.WriteLine("Session not found. Check the session ID and configured session store path.");
continue;
}
}
else
{
int minWords = ReadWordCount(
$"Enter minimum word count [{ResearchState.DefaultMinWords}]: ",
ResearchState.DefaultMinWords);
int maxWords = ReadWordCount(
$"Enter maximum word count [{ResearchState.DefaultMaxWords}]: ",
ResearchState.DefaultMaxWords,
minimum: minWords);

session = await sessionStore.CreateAsync(new ResearchState
{
MainTask = input,
MinWords = minWords,
MaxWords = maxWords
}, cts.Token);
}

Console.WriteLine("\n========== RESULTS ==========");
Console.WriteLine($"Task: {result.MainTask}");
while (!cts.IsCancellationRequested)
{
try
{
using Activity? runActivity = appActivitySource.StartActivity("BlogWriter.Run");
runActivity?.SetTag("blog.topic", session.State.MainTask);
session.State = await app.RunAsync(session.State, cts.Token);
await sessionStore.SaveAsync(session, cts.Token);
}
catch (TokenCapExceededException ex)
{
Console.Error.WriteLine($"{ex.Message} Exiting application.");
Environment.ExitCode = 1;
return;
}
catch (OperationCanceledException)
{
Console.Error.WriteLine("Run cancelled. Exiting application.");
Environment.ExitCode = 1;
return;
}

Console.WriteLine($"\nResearch Findings ({result.ResearchFindings.Count}):");
foreach (string finding in result.ResearchFindings)
{
Console.WriteLine($"- {finding}");
PrintResults(session);
Console.Write("Follow-up request, or press Enter for a new topic: ");
string? followUp = Console.ReadLine();
if (string.IsNullOrWhiteSpace(followUp))
{
break;
}

session!.State.StartFollowUp(followUp);
await sessionStore.SaveAsync(session, cts.Token);
}
}

Console.WriteLine($"\nDraft:\n{result.Draft}");
Console.WriteLine($"\nReview Notes: {result.ReviewNotes}");
Console.WriteLine($"Revision Number: {result.RevisionNumber}");
if (result.RevisionLimitReached)
void PrintResults(BlogSession session)
{
// The revision cap terminates the loop even if the reviewer never approved —
// call that out so the draft above isn't mistaken for a reviewer-approved one.
Console.WriteLine("Note: Maximum revision limit reached; draft above printed as-is.");
ResearchState result = session.State;
Console.WriteLine("\n========== RESULTS ==========");
Console.WriteLine($"Session: {session.Id}");
Console.WriteLine($"Task: {result.MainTask}");
Console.WriteLine($"\nResearch Findings ({result.ResearchFindings.Count}):");
foreach (string finding in result.ResearchFindings)
{
Console.WriteLine($"- {finding}");
}

Console.WriteLine($"\nDraft:\n{result.Draft}");
Console.WriteLine($"\nReview Notes: {result.ReviewNotes}");
Console.WriteLine($"Revision Number: {result.RevisionNumber}");
if (result.RevisionLimitReached)
{
Console.WriteLine("Note: Maximum revision limit reached; draft above printed as-is.");
}
Console.WriteLine("=============================");
}
Console.WriteLine("=============================");


4 changes: 3 additions & 1 deletion Prompts.cs
Original file line number Diff line number Diff line change
Expand Up @@ -56,11 +56,13 @@ focused on .NET and AI with examples in C# and Python.
You are a professional blogger.

The user message contains the main task, the research findings, the current
draft (if any), any reviewer notes, and the target word count range.
draft (if any), any reviewer notes, an optional user follow-up, and the target
word count range.

Instructions:
- If this is the first draft (no current draft), create a comprehensive post based on the findings
- If there is a current draft and review notes, revise the draft to address all feedback
- If a user follow-up is present, revise the current draft to fulfill it while retaining relevant research
- Use a professional tone
- Aim for the target word count range given in the user message.

Expand Down
Loading