From 200d4842b3e13b9f8e10218f39ffdf228586f6fd Mon Sep 17 00:00:00 2001 From: Jesse Liberty Date: Thu, 10 Sep 2026 10:34:27 -0400 Subject: [PATCH] feat: implement session management for blog writing, including session persistence and follow-up handling --- AuthorAgent.cs | 12 ++ BlogSession.cs | 10 ++ BlogWriter.Tests/FileBlogSessionStoreTests.cs | 50 ++++++ BlogWriter.Tests/ResearchStateTests.cs | 23 +++ FileBlogSessionStore.cs | 68 +++++++++ HostedAgents/Author/AgentPrompt.cs | 4 +- IBlogSessionStore.cs | 9 ++ Program.cs | 143 +++++++++++------- Prompts.cs | 4 +- ResearchState.cs | 10 ++ docs/configuration.md | 1 + docs/deployment.md | 4 + 12 files changed, 284 insertions(+), 54 deletions(-) create mode 100644 BlogSession.cs create mode 100644 BlogWriter.Tests/FileBlogSessionStoreTests.cs create mode 100644 FileBlogSessionStore.cs create mode 100644 IBlogSessionStore.cs diff --git a/AuthorAgent.cs b/AuthorAgent.cs index ee8b294..97403a5 100644 --- a/AuthorAgent.cs +++ b/AuthorAgent.cs @@ -29,6 +29,16 @@ public AuthorAgent(AIAgent agent, ILogger logger) _logger.LogInformation("AuthorAgent initialized."); } + // Compatibility overload for callers that supply an in-process test client. + public AuthorAgent(IChatClient llm, ChatOptions chatOptions, ILogger logger) + : this(new ChatClientAgent(llm, new ChatClientAgentOptions + { + Name = "Author", + ChatOptions = chatOptions, + }), logger) + { + } + public async Task InvokeAsync(ResearchState state, CancellationToken cancellationToken = default) { using Activity? activity = s_activitySource.StartActivity("Author.Invoke"); @@ -48,6 +58,8 @@ public AuthorAgent(AIAgent agent, ILogger 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 """; diff --git a/BlogSession.cs b/BlogSession.cs new file mode 100644 index 0000000..9582ab3 --- /dev/null +++ b/BlogSession.cs @@ -0,0 +1,10 @@ +namespace BlogWriter; + +/// Persisted state for one user's blog-writing conversation. +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; } +} \ No newline at end of file diff --git a/BlogWriter.Tests/FileBlogSessionStoreTests.cs b/BlogWriter.Tests/FileBlogSessionStoreTests.cs new file mode 100644 index 0000000..dbe4f36 --- /dev/null +++ b/BlogWriter.Tests/FileBlogSessionStoreTests.cs @@ -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"))); + } +} \ No newline at end of file diff --git a/BlogWriter.Tests/ResearchStateTests.cs b/BlogWriter.Tests/ResearchStateTests.cs index 45ca925..d090371 100644 --- a/BlogWriter.Tests/ResearchStateTests.cs +++ b/BlogWriter.Tests/ResearchStateTests.cs @@ -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); + } } diff --git a/FileBlogSessionStore.cs b/FileBlogSessionStore.cs new file mode 100644 index 0000000..88cb48c --- /dev/null +++ b/FileBlogSessionStore.cs @@ -0,0 +1,68 @@ +using System.Text.Json; + +namespace BlogWriter; + +/// Stores each session as a JSON document on the local machine. +public sealed class FileBlogSessionStore(string directoryPath) : IBlogSessionStore +{ + private static readonly JsonSerializerOptions s_jsonOptions = new() { WriteIndented = true }; + private readonly string _directoryPath = directoryPath; + + public async Task 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 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(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)); + } + + 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 _); +} \ No newline at end of file diff --git a/HostedAgents/Author/AgentPrompt.cs b/HostedAgents/Author/AgentPrompt.cs index 35662c4..abcda88 100644 --- a/HostedAgents/Author/AgentPrompt.cs +++ b/HostedAgents/Author/AgentPrompt.cs @@ -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. diff --git a/IBlogSessionStore.cs b/IBlogSessionStore.cs new file mode 100644 index 0000000..ddb8c6b --- /dev/null +++ b/IBlogSessionStore.cs @@ -0,0 +1,9 @@ +namespace BlogWriter; + +/// Persists completed workflow state so a user can continue a draft. +public interface IBlogSessionStore +{ + Task CreateAsync(ResearchState state, CancellationToken cancellationToken = default); + Task GetAsync(string sessionId, CancellationToken cancellationToken = default); + Task SaveAsync(BlogSession session, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/Program.cs b/Program.cs index 2c53e93..cd60f32 100644 --- a/Program.cs +++ b/Program.cs @@ -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. @@ -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(); @@ -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 ', 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("============================="); diff --git a/Prompts.cs b/Prompts.cs index 86fbb89..9c621ed 100644 --- a/Prompts.cs +++ b/Prompts.cs @@ -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. diff --git a/ResearchState.cs b/ResearchState.cs index 603e4ed..29514ec 100644 --- a/ResearchState.cs +++ b/ResearchState.cs @@ -29,6 +29,16 @@ public class ResearchState public string NextStep { get; set; } = ""; public string CurrentSubTask { get; set; } = ""; + /// Prepares an approved or revision-capped draft for a user-requested follow-up. + public void StartFollowUp(string followUp) + { + ArgumentException.ThrowIfNullOrWhiteSpace(followUp); + CurrentSubTask = followUp.Trim(); + ReviewNotes = ""; + RevisionNumber = 0; + NextStep = ""; + } + /// True when the given review text contains the approval marker (case-insensitive). public static bool IsApproved(string? reviewNotes) => !string.IsNullOrEmpty(reviewNotes) && reviewNotes.Contains(ApprovedMarker, StringComparison.OrdinalIgnoreCase); diff --git a/docs/configuration.md b/docs/configuration.md index 24b8750..6d6fd1b 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -17,6 +17,7 @@ keys — every credential is Microsoft Entra ID (`AzureCliCredential` locally, | `AUTHOR_AGENT_NAME` | no | `Author` | | | `REVIEWER_AGENT_NAME` | no | `Reviewer` | | | `MAX_TOTAL_TOKENS` | no | `40000` | Cumulative cross-agent token cap (`TokenCapChatClient`); parse failures fall back to the default | +| `BLOG_SESSION_STORE_PATH` | no | `%LOCALAPPDATA%\BlogWriter\sessions` | Local directory where completed conversations are stored as JSON files | Set with, e.g.: diff --git a/docs/deployment.md b/docs/deployment.md index c79a491..c563e6f 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -60,6 +60,10 @@ dotnet run --project . It will prompt for a topic and a min/max word count, then stream workflow progress (`[trace] → ...` / `[trace] ← ...` lines) before printing the final approved draft. +After a run, enter a follow-up request to revise the same draft; the console app +persists the session locally under `%LOCALAPPDATA%\BlogWriter\sessions` by default. +The result prints the session ID; use `resume ` at the next topic prompt +to continue it after restarting the console app. ## 4. Verifying a deployment