Add novel-writing app: .NET 10 API, React front end, agent and MCP server
Builds out the vertical slice for planning and writing a novel. Three front ends — the React UI, an embedded Claude agent, and an MCP stdio server — all go through one REST API, so an edit made from Claude Code and one made in the browser are the same edit. Layout: Domain entities and enums, no dependencies Application services, DTOs, the agent tool-use loop and its 15 tools Infrastructure EF Core 10 + SQLite, Anthropic SDK client Api ASP.NET Core 10 minimal APIs, OpenAPI, ProblemDetails Mcp MCP stdio server, 21 tools over the same REST API Web React 19 + Vite + TanStack Query + Tailwind v4 Data model is Project > Characters / OutlineNodes / Chapters > Scenes, plus agent conversations. The outline is a self-nesting tree so acts, sequences and beats can be arranged however the book wants; scenes carry goal/conflict/outcome because that is what the agent drafts prose from. Notes on a few choices: - Conversation history replays to the model as text only. The agent re-reads current state through its tools rather than trusting a record of edits that may since have changed in the UI. - The user's turn is persisted before the tool loop runs, so a question is recorded even when the model call fails. Turn order uses an explicit sequence column; timestamps tie when a turn completes inside one tick. - Tool failures return is_error results rather than throwing, so the model can read the message and correct itself. MCP tools do the same via CallToolResult, which keeps the API's own message instead of a generic SDK error. - The Anthropic client is constructed lazily. It is injected into the agent service, which also serves read-only endpoints, and those should keep working on an install with no key. Sending without one returns 503, not 400. - DateTimeOffset is stored as UTC ticks. SQLite refuses to ORDER BY the default text form, which every "recently updated first" listing depends on. Tests run against real in-memory SQLite rather than the EF in-memory provider so they exercise the cascade deletes and query translation that actually ship. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S56bfZMGe1hnhpWP4CjjNw
This commit is contained in:
co-authored by
Claude Opus 5
parent
3c85bab4a4
commit
0d7b7a6f30
@@ -0,0 +1,131 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using NovelSoftware.Application.Agent;
|
||||
using NovelSoftware.Application.Dtos;
|
||||
using NovelSoftware.Application.Services;
|
||||
|
||||
namespace NovelSoftware.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Covers the list endpoints, which sort and aggregate in SQL rather than in memory.
|
||||
/// SQLite is fussier than the in-memory provider about what it will translate — ordering
|
||||
/// by a DateTimeOffset, for one — so these have to run against real SQLite to be worth anything.
|
||||
/// </summary>
|
||||
public class ListingTests : IDisposable
|
||||
{
|
||||
private readonly TestDatabase _db = new();
|
||||
private readonly ProjectService _projects;
|
||||
private readonly ChapterService _chapters;
|
||||
private readonly SceneService _scenes;
|
||||
private readonly CharacterService _characters;
|
||||
|
||||
public ListingTests()
|
||||
{
|
||||
_projects = new ProjectService(_db.Context);
|
||||
_chapters = new ChapterService(_db.Context);
|
||||
_scenes = new SceneService(_db.Context);
|
||||
_characters = new CharacterService(_db.Context);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Projects_are_listed_most_recently_updated_first()
|
||||
{
|
||||
var older = await _projects.CreateAsync(new CreateProjectRequest("Older Book"));
|
||||
var newer = await _projects.CreateAsync(new CreateProjectRequest("Newer Book"));
|
||||
|
||||
// Touching the older project should float it to the top.
|
||||
await _projects.UpdateAsync(older.Id, new UpdateProjectRequest(Logline: "Revised."));
|
||||
|
||||
var listed = await _projects.ListAsync();
|
||||
|
||||
listed.Select(p => p.Title).Should().Equal("Older Book", "Newer Book");
|
||||
listed.Select(p => p.Id).Should().Contain(newer.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Project_summaries_aggregate_counts_and_words_across_chapters()
|
||||
{
|
||||
var project = await _projects.CreateAsync(new CreateProjectRequest("The Salt Road"));
|
||||
await _characters.CreateAsync(project.Id, new CreateCharacterRequest("Ines"));
|
||||
await _characters.CreateAsync(project.Id, new CreateCharacterRequest("Mara"));
|
||||
|
||||
var first = await _chapters.CreateAsync(project.Id, new CreateChapterRequest("Landfall"));
|
||||
var second = await _chapters.CreateAsync(project.Id, new CreateChapterRequest("The Harbour"));
|
||||
await _scenes.CreateAsync(first.Id, new CreateSceneRequest("Dawn", Prose: "One two three"));
|
||||
await _scenes.CreateAsync(second.Id, new CreateSceneRequest("Dusk", Prose: "Four five"));
|
||||
|
||||
var summary = (await _projects.ListAsync()).Single();
|
||||
|
||||
summary.CharacterCount.Should().Be(2);
|
||||
summary.ChapterCount.Should().Be(2);
|
||||
summary.WordCount.Should().Be(5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_project_with_no_chapters_reports_zero_words_rather_than_failing()
|
||||
{
|
||||
await _projects.CreateAsync(new CreateProjectRequest("Empty"));
|
||||
|
||||
var summary = (await _projects.ListAsync()).Single();
|
||||
|
||||
summary.WordCount.Should().Be(0);
|
||||
summary.ChapterCount.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Chapters_are_listed_in_manuscript_order_with_scene_totals()
|
||||
{
|
||||
var project = await _projects.CreateAsync(new CreateProjectRequest("The Salt Road"));
|
||||
var second = await _chapters.CreateAsync(project.Id, new CreateChapterRequest("Second", Number: 2));
|
||||
var first = await _chapters.CreateAsync(project.Id, new CreateChapterRequest("First", Number: 1));
|
||||
await _scenes.CreateAsync(second.Id, new CreateSceneRequest("A", Prose: "One two"));
|
||||
await _scenes.CreateAsync(second.Id, new CreateSceneRequest("B", Prose: "Three"));
|
||||
|
||||
var listed = await _chapters.ListAsync(project.Id);
|
||||
|
||||
listed.Select(c => c.Title).Should().Equal("First", "Second");
|
||||
listed.Single(c => c.Id == second.Id).SceneCount.Should().Be(2);
|
||||
listed.Single(c => c.Id == second.Id).WordCount.Should().Be(3);
|
||||
listed.Single(c => c.Id == first.Id).WordCount.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Conversations_are_listed_most_recently_updated_first()
|
||||
{
|
||||
var project = await _projects.CreateAsync(new CreateProjectRequest("The Salt Road"));
|
||||
var agent = new NovelAgentService(
|
||||
_db.Context,
|
||||
new ScriptedModelClient([[new AgentTextBlock("Reply.")]]),
|
||||
new NovelAgentToolset(_projects, _characters, new OutlineService(_db.Context), _chapters, _scenes),
|
||||
Options.Create(new AgentOptions()),
|
||||
NullLogger<NovelAgentService>.Instance);
|
||||
|
||||
await agent.SendMessageAsync(project.Id, new SendAgentMessageRequest("First question."));
|
||||
await agent.SendMessageAsync(project.Id, new SendAgentMessageRequest("Second question."));
|
||||
|
||||
var listed = await agent.ListConversationsAsync(project.Id);
|
||||
|
||||
listed.Should().HaveCount(2);
|
||||
listed[0].Title.Should().Be("Second question.");
|
||||
listed[0].MessageCount.Should().Be(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Characters_are_listed_by_role_then_name()
|
||||
{
|
||||
var project = await _projects.CreateAsync(new CreateProjectRequest("The Salt Road"));
|
||||
await _characters.CreateAsync(project.Id, new CreateCharacterRequest(
|
||||
"Zeno", NovelSoftware.Domain.CharacterRole.Supporting));
|
||||
await _characters.CreateAsync(project.Id, new CreateCharacterRequest(
|
||||
"Ines", NovelSoftware.Domain.CharacterRole.Protagonist));
|
||||
await _characters.CreateAsync(project.Id, new CreateCharacterRequest(
|
||||
"Anders", NovelSoftware.Domain.CharacterRole.Supporting));
|
||||
|
||||
var listed = await _characters.ListAsync(project.Id);
|
||||
|
||||
listed.Select(c => c.Name).Should().Equal("Ines", "Anders", "Zeno");
|
||||
}
|
||||
|
||||
public void Dispose() => _db.Dispose();
|
||||
}
|
||||
Reference in New Issue
Block a user