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
20 changes: 11 additions & 9 deletions BlogWorkflow.cs
Original file line number Diff line number Diff line change
Expand Up @@ -44,19 +44,21 @@ public async Task<ResearchState> RunAsync(
Workflow workflow = new WorkflowBuilder(bloggerExecutor)
.AddEdge(bloggerExecutor, researcherExecutor)
.AddEdge(researcherExecutor, authorExecutor)
.AddEdge(authorExecutor, reviewerExecutor)
// Review only the initial author pass. A rejected review can route back
// to the author once, but the capped revision is terminal output.
.AddEdge<ResearchState>(authorExecutor, reviewerExecutor,
condition: s => s?.RevisionNumber < ResearchState.MaxRevisions)
// Bounded revision loop: route back to the author only while the draft
// still needs work and the revision cap has not been reached. When the
// condition is false the reviewer instead yields the final output.
// still needs work and the revision cap has not been reached.
.AddEdge<ResearchState>(reviewerExecutor, authorExecutor, condition: s => s?.NeedsRevision == true)
.WithOutputFrom(reviewerExecutor)
.WithOutputFrom(reviewerExecutor, authorExecutor)
.Build();

// Stream execution instead of running to completion in one shot. The
// topology is identical to before (proven terminating, MAF-Doctor grade A);
// streaming simply surfaces each executor's lifecycle as it happens, giving
// live progress. The final ResearchState is captured from the
// WorkflowOutputEvent emitted by the reviewer.
// topology is proven terminating; streaming simply surfaces each
// executor's lifecycle as it happens, giving live progress. The final
// ResearchState is captured from the WorkflowOutputEvent emitted by the
// reviewer after approval or the author after the single revision.
StreamingRun run = await InProcessExecution.RunStreamingAsync(workflow, state, cancellationToken: cancellationToken);

ResearchState? result = null;
Expand Down Expand Up @@ -90,7 +92,7 @@ public async Task<ResearchState> RunAsync(
new InvalidOperationException($"Workflow executor '{failed.ExecutorId}' failed.");

case WorkflowOutputEvent { Data: ResearchState finalState }:
// The reviewer yielded the final, approved (or revision-capped) state.
// Reviewer approval or the capped author revision yields the final state.
result = finalState;
publisher.PublishLifecycle(WorkflowOutputOutcome.Success, "Writing workflow completed.");
break;
Expand Down
79 changes: 71 additions & 8 deletions BlogWriter.Tests/BlogWorkflowTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,13 @@ public sealed class BlogWorkflowTests
[Fact]
public async Task RunAsync_EmitsLifecycleAndReviewerUpdatesWithoutChangingFinalState()
{
var author = new TestAuthor("draft");
var reviewer = new TestReviewer("APPROVED");
var workflow = new BlogWorkflow(
new TestBlogger(),
new TestResearcher(),
new TestAuthor(),
new TestReviewer(),
author,
reviewer,
NullLogger<BlogWorkflow>.Instance);
var output = new WorkflowOutputCollector();

Expand All @@ -32,6 +34,53 @@ public async Task RunAsync_EmitsLifecycleAndReviewerUpdatesWithoutChangingFinalS
update.Kind == WorkflowOutputKind.Lifecycle &&
update.Outcome == WorkflowOutputOutcome.Success);
Assert.Equal(output.Updates.Count, output.Updates.Select(update => update.Sequence).Distinct().Count());
Assert.Equal(1, author.Calls);
Assert.Equal(1, reviewer.Calls);
}

[Fact]
public async Task RunAsync_RejectedInitialDraftGetsOneRevisionAndNoSecondReview()
{
var author = new TestAuthor("draft-1", "draft-2");
var reviewer = new TestReviewer("Please revise the introduction.");
Comment on lines +44 to +45
var workflow = new BlogWorkflow(
new TestBlogger(),
new TestResearcher(),
author,
reviewer,
NullLogger<BlogWorkflow>.Instance);
var output = new WorkflowOutputCollector();

var service = new BlogWriterSessionService(workflow, new RecordingStore());
BlogSession session = await service.StartAsync("topic", output: output);

Assert.Equal("draft-2", session.State.Draft);
Assert.Equal("Please revise the introduction.", session.State.ReviewNotes);
Assert.Equal(2, author.Calls);
Assert.Equal(1, reviewer.Calls);
Assert.Contains(output.Updates, update =>
update.Kind == WorkflowOutputKind.Lifecycle &&
update.Outcome == WorkflowOutputOutcome.Success);
}

[Fact]
public async Task RunAsync_RejectedRevisionWithoutReplacementKeepsLatestDraftAndNoSecondReview()
{
var author = new TestAuthor("draft-1", null);
var reviewer = new TestReviewer("Please revise the introduction.");
var workflow = new BlogWorkflow(
new TestBlogger(),
new TestResearcher(),
author,
reviewer,
NullLogger<BlogWorkflow>.Instance);

var service = new BlogWriterSessionService(workflow, new RecordingStore());
BlogSession session = await service.StartAsync("topic");

Assert.Equal("draft-1", session.State.Draft);
Assert.Equal(2, author.Calls);
Assert.Equal(1, reviewer.Calls);
Comment on lines +78 to +83
}

private sealed class TestBlogger : IBloggerAgent
Expand Down Expand Up @@ -59,26 +108,40 @@ public Task<ResearchState> ResearchNodeAsync(ResearchState state, CancellationTo
}
}

private sealed class TestAuthor : IAuthorAgent
private sealed class TestAuthor(params string?[] drafts) : IAuthorAgent
{
private readonly IReadOnlyList<string?> _drafts = drafts;

public int Calls { get; private set; }

public Task<string?> InvokeAsync(ResearchState state, CancellationToken cancellationToken = default) =>
Task.FromResult<string?>("draft");
Task.FromResult(_drafts[Math.Min(Calls, _drafts.Count - 1)]);

public Task<ResearchState> AuthorNodeAsync(ResearchState state, CancellationToken cancellationToken = default)
{
state.Draft = "draft";
string? draft = _drafts[Math.Min(Calls, _drafts.Count - 1)];
Calls++;
state.RevisionNumber++;
if (!string.IsNullOrEmpty(draft))
{
state.Draft = draft;
}

return Task.FromResult(state);
}
}

private sealed class TestReviewer : IReviewerAgent
private sealed class TestReviewer(string reviewNotes) : IReviewerAgent
{
public int Calls { get; private set; }

public Task<string> InvokeAsync(ResearchState state, CancellationToken cancellationToken = default) =>
Task.FromResult("APPROVED");
Task.FromResult(reviewNotes);

public Task<ResearchState> ReviewerNodeAsync(ResearchState state, CancellationToken cancellationToken = default)
{
state.ReviewNotes = "APPROVED";
Calls++;
state.ReviewNotes = reviewNotes;
return Task.FromResult(state);
}
}
Expand Down
2 changes: 1 addition & 1 deletion ResearchState.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ namespace BlogWriter;
/// <summary>State for the research workflow.</summary>
public class ResearchState
{
/// <summary>Hard upper bound on author/review revision cycles. Guarantees the workflow terminates.</summary>
/// <summary>Hard upper bound on author passes: the initial draft plus one revision. Guarantees the workflow terminates.</summary>
public const int MaxRevisions = 2;

/// <summary>The single source-of-truth marker written to <see cref="ReviewNotes"/> on approval.</summary>
Expand Down
21 changes: 15 additions & 6 deletions Workflows/BlogExecutors.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,21 @@ internal sealed partial class AuthorExecutor(IAuthorAgent author) : Executor("Au
{
[MessageHandler]
private async ValueTask<ResearchState> HandleAsync(ResearchState state, IWorkflowContext context, CancellationToken cancellationToken)
=> await author.AuthorNodeAsync(state, cancellationToken);
{
state = await author.AuthorNodeAsync(state, cancellationToken);

if (state.RevisionNumber >= ResearchState.MaxRevisions)
{
await context.YieldOutputAsync(state);
}

return state;
}
}

/// <summary>
/// Reviews the draft and records approval / revision notes. Acts as the terminal
/// output node: when no further revision is needed it yields the final state.
/// Reviews the initial draft and records approval / revision notes. It yields
/// approved output; a rejected draft routes to the author for one revision.
/// </summary>
internal sealed partial class ReviewerExecutor(
IReviewerAgent reviewer,
Expand All @@ -50,12 +59,12 @@ private async ValueTask<ResearchState> HandleAsync(ResearchState state, IWorkflo

if (!state.NeedsRevision)
{
// Approved, or the revision cap was hit — emit the final result.
// Approval is terminal here. A capped revision is emitted by the author.
await context.YieldOutputAsync(state);
}

// Returned state is routed back to the author only when the loop edge
// condition (NeedsRevision) is satisfied; otherwise it goes nowhere.
// Rejected initial state is routed back to the author only when the loop
// edge condition (NeedsRevision) is satisfied.
return state;
}
}
35 changes: 35 additions & 0 deletions specs/013-single-review-round/checklists/requirements.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Specification Quality Checklist: Single Review Round

**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-09-27
**Feature**: [spec.md](../spec.md)

## Content Quality

- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed

## Requirement Completeness

- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified

## Feature Readiness

- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification

## Notes

- Validation passed on the initial specification review.
- The feature is ready for `/speckit-plan`.
34 changes: 34 additions & 0 deletions specs/013-single-review-round/data-model.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Data Model: Single Review Round

## ResearchState

Existing workflow state remains the single source of truth.

| Field | Role in this feature | Rules |
|---|---|---|
| `Draft` | Latest draft displayed to the user | Initial author output is replaced only by the one permitted revision when replacement content exists. |
| `ReviewNotes` | Reviewer decision and suggested changes | Approval uses the existing `APPROVED` marker; rejection notes are passed to the one revision and remain available for final display. |
| `RevisionNumber` | Number of author passes | Initial draft is `1`; one revision may reach `2`; no author pass beyond `2` is allowed for this workflow run. |
| `MaxRevisions` | Existing upper bound | Remains `2`, representing the initial draft plus one revision. |
| `NextStep` | Existing workflow status marker | Existing values remain unchanged unless current stage behavior requires the terminal revised-author path to mark completion. |

## State transitions

```text
Initial state
-> Author pass 1: Draft populated, RevisionNumber = 1
-> Reviewer pass 1
-> APPROVED: yield final state from ReviewerExecutor
-> Rejected: retain ReviewNotes and route to AuthorExecutor
-> Author pass 2: revise using ReviewNotes, RevisionNumber = 2
-> yield final state from AuthorExecutor

No transition from Author pass 2 to ReviewerExecutor exists.
```

## Validation rules

- Reviewer call count is at most one per workflow run.
- Author call count is one for approval and two for rejection followed by revision.
- The final state is emitted after initial approval or after the single revision attempt.
- If the revision produces no replacement content, the existing latest draft remains the displayed final draft, consistent with current author fallback behavior.
105 changes: 105 additions & 0 deletions specs/013-single-review-round/plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
# Implementation Plan: Single Review Round

**Branch**: `013-single-review-round` | **Date**: 2026-09-27 | **Spec**: [spec.md](spec.md)

**Input**: Feature specification from `/specs/013-single-review-round/spec.md`

**Note**: This template is filled in by the `/speckit-plan` command; its definition describes the execution workflow.

## Summary

Bound the blog workflow to one reviewer pass and, when needed, one author revision. The existing revision counter remains the state boundary: the initial draft is author pass 1, a rejected draft may return to the author for pass 2, and pass 2 becomes a terminal output without a second reviewer invocation.

## Technical Context

<!--
ACTION REQUIRED: Replace the content in this section with the technical details
for the project. The structure here is presented in advisory capacity to guide
the iteration process.
-->

**Language/Version**: C# on .NET 10 with nullable reference types enabled

**Primary Dependencies**: Microsoft Agent Framework Workflows 1.10.0, workflow generators 1.10.0, Microsoft.Extensions.AI 10.9.0, xUnit test projects

**Storage**: Existing session stores persist `ResearchState`; no schema or storage format change is planned

**Testing**: Focused xUnit tests in `BlogWriter.Tests`, followed by the affected project suite and MAF topology validation

**Target Platform**: Existing console and hosted web application workflow runtime

**Project Type**: .NET console workflow service with a hosted web presentation and independently deployed Foundry agents

**Performance Goals**: Rejected drafts must complete with no more than one revision and one reviewer call; approved drafts must complete without a revision

**Constraints**: Preserve the existing `ResearchState` persistence contract, token-budget handling, cancellation, output publishing, and bounded termination. Do not add credentials, raw HTTP, retained agent session state, or new message-handler shapes.

**Scale/Scope**: One workflow run at a time per session; changes are limited to the workflow topology, terminal output routing, and focused regression tests

## Constitution Check

*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*

- **Hosted-Agent Boundaries**: PASS. The change only routes existing Blogger, Researcher, Author, and Reviewer stages; it does not move model logic or add transport calls.
- **MAF-Native Workflow Composition**: PASS. Routing remains explicit through `ResearchState`, `WorkflowBuilder` edges, executor handlers, and workflow output events.
- **Identity, Secrets, and Budget Control**: PASS. No authentication or secret changes are introduced; existing cancellation and token-cap propagation remain intact.
- **Testable and Observable Behavior**: PASS. Add call-count and final-output tests using existing test doubles and preserve lifecycle/reviewer output publishing.
- **Simple, Compatible Evolution**: PASS. Reuse `RevisionNumber` and `MaxRevisions`; do not add a persisted flag or change session serialization.
- **MAF baseline**: MAF Doctor currently reports four pre-existing credential errors, three observability warnings, and six heuristic uncapped-call findings. These are outside this feature's scope and are not worsened by the planned changes.

## Project Structure

### Documentation (this feature)

```text
specs/013-single-review-round/
├── plan.md # This file (/speckit-plan command output)
├── research.md # Phase 0 output (/speckit-plan command)
├── data-model.md # Phase 1 output (/speckit-plan command)
├── quickstart.md # Phase 1 output (/speckit-plan command)
└── tasks.md # Phase 2 output (/speckit-tasks command - NOT created by /speckit-plan)
```

### Source Code (repository root)

```text
BlogWorkflow.cs # Conditional author/reviewer edges and output sources
Workflows/BlogExecutors.cs # Terminal output after the single revised author pass
ResearchState.cs # Existing revision-boundary semantics and documentation
BlogWriter.Tests/BlogWorkflowTests.cs # Approval/rejection call-count and output regressions
BlogWriter.Tests/ResearchStateTests.cs # Revision-boundary state assertions if semantics change
```

**Structure Decision**: Keep the existing root workflow implementation and focused unit-test project. No new source project, API contract, persistence model, or frontend component is needed.


## Phase 0: Research Summary

Research findings are recorded in [research.md](research.md). The design uses the existing counter and explicit conditional edges. The installed MAF API supports multiple output sources through `WithOutputFrom(ExecutorBinding[])`, and the relevant APIs are safe according to the current registry.

## Phase 1: Design Summary

- [data-model.md](data-model.md) defines the existing state fields and the author/reviewer lifecycle.
- No `contracts/` artifact is required because this is an internal workflow topology change with no external interface.
- [quickstart.md](quickstart.md) defines focused approval, rejection/revision, fallback, and broader test validation.

## Implementation Design

1. In `BlogWorkflow.cs`, keep the reviewer-to-author edge conditioned on `NeedsRevision`, make the author-to-reviewer edge conditional on `RevisionNumber < MaxRevisions`, and register both `ReviewerExecutor` and `AuthorExecutor` as output sources.
2. In `Workflows/BlogExecutors.cs`, have `AuthorExecutor` yield the state when its author pass reaches `MaxRevisions`; preserve the existing reviewer yield for initial approval and existing reviewer feedback publishing.
3. In `ResearchState.cs`, retain the value `MaxRevisions = 2` and clarify that it bounds total author passes for this workflow, if the current wording is misleading. Preserve `NeedsRevision` and `RevisionLimitReached` behavior unless focused tests show a necessary adjustment.
4. In `BlogWriter.Tests/BlogWorkflowTests.cs`, add counting test doubles and assertions for initial approval, initial rejection followed by one revision, and no second reviewer call. Verify final draft and output events.
5. Update `ResearchStateTests.cs` only for any directly affected boundary wording or state invariant; do not broaden unrelated state tests.

## Validation Plan

- Run the focused `BlogWorkflowTests` command in [quickstart.md](quickstart.md).
- Run the full `BlogWriter.Tests` project suite.
- Run MAF workflow topology simulation and confirm both terminal-capable paths complete without silent starvation.
- Build the affected projects and confirm nullable/compiler diagnostics remain clean.

## Complexity Tracking

| Violation | Why Needed | Simpler Alternative Rejected Because |
|-----------|------------|-------------------------------------|
| None | N/A | The existing workflow and test projects are sufficient. |
Loading
Loading