Add outline import feature; drop Dto naming, map entities at the API boundary

Services now return entities; endpoints (and the agent toolsets) map to
*Response records instead of services building wire DTOs themselves.
Also brings in the outline-import agent, MCP tool, ledger and web dialog
that were already in progress on disk.
This commit is contained in:
James Wampler
2026-08-06 18:36:40 -07:00
parent 40f93e40a8
commit 189ebf3237
66 changed files with 3310 additions and 364 deletions
+23 -2
View File
@@ -91,8 +91,8 @@ public class BeatServiceTests : ServiceTestFixture
Assert.Multiple(() =>
{
Assert.That(beat.CharacterName, Is.EqualTo("Ines"));
Assert.That(beat.SceneTitle, Is.EqualTo("The dock at dawn"));
Assert.That(beat.Character!.Name, Is.EqualTo("Ines"));
Assert.That(beat.Scene!.Title, Is.EqualTo("The dock at dawn"));
Assert.That(beat.WhatHappened, Does.Contain("faster than she expected"));
});
}
@@ -164,4 +164,25 @@ public class BeatServiceTests : ServiceTestFixture
Assert.That(cleared.WhatHappened, Is.EqualTo("Behind the lining of the case."));
});
}
[Test]
public async Task ClearCharacter_and_ClearScene_detach_the_reference_since_a_null_id_means_leave_it_alone()
{
var ines = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Ines"));
var scene = await Scenes.CreateAsync(_chapterId, new CreateSceneRequest("The dock at dawn"));
var beat = await Beats.CreateAsync(_chapterId, new CreateBeatRequest(
"She burns the atlas", CharacterId: ines.Id, SceneId: scene.Id));
var untouched = (await Beats.UpdateAsync(beat.Id, new UpdateBeatRequest(Title: "She burns it")))!;
Assert.That(untouched.Character!.Name, Is.EqualTo("Ines"));
var cleared = (await Beats.UpdateAsync(
beat.Id, new UpdateBeatRequest(ClearCharacter: true, ClearScene: true)))!;
Assert.Multiple(() =>
{
Assert.That(cleared.Character, Is.Null);
Assert.That(cleared.Scene, Is.Null);
});
}
}
+4 -4
View File
@@ -136,8 +136,8 @@ public class CharacterArcTests : ServiceTestFixture
Assert.Multiple(() =>
{
Assert.That(stage.ChapterTitle, Is.EqualTo("Landfall"));
Assert.That(stage.ChapterNumber, Is.EqualTo(1));
Assert.That(stage.Chapter!.Title, Is.EqualTo("Landfall"));
Assert.That(stage.Chapter!.Number, Is.EqualTo(1));
});
}
@@ -206,8 +206,8 @@ public class CharacterArcTests : ServiceTestFixture
Assert.Multiple(() =>
{
Assert.That(beats.Select(b => b.Title), Is.EqualTo(new[] { "She finds the map", "She boards anyway" }));
Assert.That(beats[0].ChapterNumber, Is.EqualTo(1));
Assert.That(beats[0].ChapterTitle, Is.EqualTo("First"));
Assert.That(beats[0].Chapter!.Number, Is.EqualTo(1));
Assert.That(beats[0].Chapter!.Title, Is.EqualTo("First"));
Assert.That(beats[1].ChapterId, Is.EqualTo(second.Id));
});
}
@@ -0,0 +1,131 @@
using System.Text.Json;
using Novelly.Api.Imports;
namespace Novelly.Api.Tests;
[TestFixture]
public class ImportAgentToolsetTests : ServiceTestFixture
{
private string _root = null!;
private ImportAgentToolset _toolset = null!;
protected override void OnSetUp()
{
_root = Directory.CreateTempSubdirectory("novelly-import-toolset-test-").FullName;
_toolset = new ImportAgentToolset(
Projects, Characters, Arcs, Chapters, Beats, new CapturingLogger<ImportAgentToolset>());
_toolset.Initialize(_root, existingProjectId: null);
}
[TearDown]
public void CleanUpTempFolder()
{
if (Directory.Exists(_root))
{
Directory.Delete(_root, recursive: true);
}
}
[Test]
public async Task Read_source_file_refuses_a_path_that_escapes_the_source_root()
{
var outsideFile = Path.Combine(Path.GetTempPath(), "novelly-outside-root.md");
await File.WriteAllTextAsync(outsideFile, "secret");
try
{
var result = await _toolset.ExecuteAsync("read_source_file", Input(new { path = "../novelly-outside-root.md" }));
Assert.Multiple(() =>
{
Assert.That(result.IsError, Is.True);
Assert.That(result.Content, Does.Contain("escapes"));
});
}
finally
{
File.Delete(outsideFile);
}
}
[Test]
public async Task Read_source_file_reads_a_file_inside_the_source_root()
{
await File.WriteAllTextAsync(Path.Combine(_root, "outline.md"), "# The Blade Itself");
var result = await _toolset.ExecuteAsync("read_source_file", Input(new { path = "outline.md" }));
Assert.Multiple(() =>
{
Assert.That(result.IsError, Is.False);
Assert.That(result.Content, Does.Contain("The Blade Itself"));
});
}
[Test]
public async Task Write_ledger_can_only_ever_touch_the_ledger_file_no_matter_what_path_is_asked_for()
{
// The tool takes no path argument at all — this is the enforcement, not a check
// against a supplied path. Confirm the write always lands at exactly the ledger name.
await _toolset.ExecuteAsync("write_ledger", Input(new { json = """{"completedPasses": ["project"]}""" }));
Assert.Multiple(() =>
{
Assert.That(File.Exists(Path.Combine(_root, ".novelly-import.json")), Is.True);
Assert.That(Directory.GetFiles(_root), Has.Length.EqualTo(1));
});
}
[Test]
public async Task Write_ledger_rejects_malformed_json_without_touching_the_file()
{
var result = await _toolset.ExecuteAsync("write_ledger", Input(new { json = "{not valid json" }));
Assert.Multiple(() =>
{
Assert.That(result.IsError, Is.True);
Assert.That(File.Exists(Path.Combine(_root, ".novelly-import.json")), Is.False);
});
}
[Test]
public async Task Create_project_binds_the_toolsets_project_id_for_later_calls()
{
await _toolset.ExecuteAsync("create_project", Input(new { title = "The Blade Itself", author = "Joe Abercrombie" }));
Assert.That(_toolset.ProjectId, Is.Not.Null);
var project = await Projects.GetAsync(_toolset.ProjectId!.Value);
Assert.That(project!.Title, Is.EqualTo("The Blade Itself"));
}
[Test]
public async Task Domain_tools_refuse_to_run_before_a_project_exists()
{
var result = await _toolset.ExecuteAsync("create_character", Input(new { name = "Logen" }));
Assert.Multiple(() =>
{
Assert.That(result.IsError, Is.True);
Assert.That(result.Content, Does.Contain("create_project first"));
});
}
[Test]
public void Every_tool_declares_an_object_schema_and_a_description()
{
Assert.That(_toolset.Definitions, Is.Not.Empty);
Assert.Multiple(() =>
{
foreach (var tool in _toolset.Definitions)
{
Assert.That(string.IsNullOrWhiteSpace(tool.Description), Is.False, tool.Name);
Assert.That(tool.InputSchema.GetProperty("type").GetString(), Is.EqualTo("object"), tool.Name);
}
Assert.That(_toolset.Definitions.Select(t => t.Name), Is.Unique);
});
}
private static JsonElement Input(object value) => JsonSerializer.SerializeToElement(value);
}
@@ -0,0 +1,140 @@
using System.Threading.Channels;
using Novelly.Api.Imports;
using Novelly.Api.Projects;
namespace Novelly.Api.Tests;
[TestFixture]
public class ImportServiceTests : ServiceTestFixture
{
private string _root = null!;
private Channel<Guid> _queue = null!;
private ImportService _imports = null!;
protected override void OnSetUp()
{
_root = Directory.CreateTempSubdirectory("novelly-import-test-").FullName;
_queue = Channel.CreateUnbounded<Guid>();
_imports = new ImportService(
Db.Context,
Projects,
_queue,
new CapturingLogger<ImportService>(),
new InspectImportRequestValidator(),
new StartImportRequestValidator());
}
[TearDown]
public void CleanUpTempFolder()
{
if (Directory.Exists(_root))
{
Directory.Delete(_root, recursive: true);
}
}
[Test]
public async Task Inspecting_a_folder_with_no_ledger_reports_fresh()
{
WriteChapterFiles(3);
var inspection = await _imports.InspectAsync(new InspectImportRequest(_root));
Assert.Multiple(() =>
{
Assert.That(inspection.Readiness, Is.EqualTo(ImportReadiness.Fresh));
Assert.That(inspection.ProjectId, Is.Null);
Assert.That(inspection.ChaptersTotal, Is.EqualTo(3));
Assert.That(inspection.ChaptersCompleted, Is.EqualTo(0));
});
}
[Test]
public async Task Inspecting_a_folder_with_an_incomplete_ledger_reports_resumable()
{
WriteChapterFiles(3);
WriteLedger("""{"projectId": "11111111-1111-1111-1111-111111111111", "completedPasses": ["project"], "completedChapters": [1]}""");
var inspection = await _imports.InspectAsync(new InspectImportRequest(_root));
Assert.Multiple(() =>
{
Assert.That(inspection.Readiness, Is.EqualTo(ImportReadiness.Resumable));
Assert.That(inspection.ChaptersCompleted, Is.EqualTo(1));
Assert.That(inspection.ChaptersTotal, Is.EqualTo(3));
});
}
[Test]
public async Task Inspecting_a_folder_whose_ledger_covers_every_pass_and_chapter_reports_complete()
{
WriteChapterFiles(2);
WriteLedger("""
{
"projectId": "11111111-1111-1111-1111-111111111111",
"completedPasses": ["project", "characters", "chapters", "arcs"],
"completedChapters": [1, 2]
}
""");
var inspection = await _imports.InspectAsync(new InspectImportRequest(_root));
Assert.That(inspection.Readiness, Is.EqualTo(ImportReadiness.Complete));
}
[Test]
public async Task Starting_an_import_enqueues_a_pending_job()
{
var job = await _imports.StartOrResumeAsync(new StartImportRequest(_root));
Assert.Multiple(() =>
{
Assert.That(job.Status, Is.EqualTo(ImportJobStatus.Pending));
Assert.That(job.SourceRoot, Is.EqualTo(_root));
});
Assert.That(_queue.Reader.TryRead(out var queued), Is.True);
Assert.That(queued, Is.EqualTo(job.Id));
}
[Test]
public async Task Starting_an_import_a_second_time_reuses_the_pending_job_instead_of_duplicating_it()
{
var first = await _imports.StartOrResumeAsync(new StartImportRequest(_root));
var second = await _imports.StartOrResumeAsync(new StartImportRequest(_root));
Assert.That(second.Id, Is.EqualTo(first.Id));
// Only one job was ever queued.
Assert.That(_queue.Reader.TryRead(out _), Is.True);
Assert.That(_queue.Reader.TryRead(out _), Is.False);
}
[Test]
public async Task Force_restarting_a_completed_import_deletes_the_ledger_and_its_project()
{
var project = await Projects.CreateAsync(new CreateProjectRequest("The Blade Itself"));
WriteLedger($$"""{"projectId": "{{project.Id}}", "completedPasses": ["project", "characters", "chapters", "arcs"], "completedChapters": [1]}""");
await _imports.StartOrResumeAsync(new StartImportRequest(_root, ForceRestart: true));
Assert.Multiple(() =>
{
Assert.That(File.Exists(Path.Combine(_root, ".novelly-import.json")), Is.False);
Assert.That(Projects.GetAsync(project.Id).Result, Is.Null);
});
}
private void WriteChapterFiles(int count)
{
var outlines = Path.Combine(_root, "outlines");
Directory.CreateDirectory(outlines);
for (var i = 1; i <= count; i++)
{
File.WriteAllText(Path.Combine(outlines, $"{i:D2}-chapter.md"), $"# Chapter {i}");
}
}
private void WriteLedger(string json) =>
File.WriteAllText(Path.Combine(_root, ".novelly-import.json"), json);
}
+3 -3
View File
@@ -84,9 +84,9 @@ public class ListingTests : ServiceTestFixture
Assert.Multiple(() =>
{
Assert.That(listed.Select(c => c.Title), Is.EqualTo(new[] { "First", "Second" }));
Assert.That(listed.Single(c => c.Id == second.Id).SceneCount, Is.EqualTo(2));
Assert.That(listed.Single(c => c.Id == second.Id).WordCount, Is.EqualTo(3));
Assert.That(listed.Single(c => c.Id == first.Id).WordCount, Is.EqualTo(0));
Assert.That(listed.Single(c => c.Id == second.Id).Scenes, Has.Count.EqualTo(2));
Assert.That(listed.Single(c => c.Id == second.Id).Scenes.Sum(s => s.WordCount), Is.EqualTo(3));
Assert.That(listed.Single(c => c.Id == first.Id).Scenes.Sum(s => s.WordCount), Is.EqualTo(0));
});
}
@@ -31,7 +31,7 @@ public class NovelAgentServiceTests : ServiceTestFixture
var turn = await agent.SendMessageAsync(projectId, new SendAgentMessageRequest("Where do I start?"));
Assert.That(turn.Message.Content, Is.EqualTo("Tell me about the ending."));
Assert.That(turn.Content, Is.EqualTo("Tell me about the ending."));
var conversation = (await agent.GetConversationAsync(turn.ConversationId))!;
@@ -62,9 +62,9 @@ public class NovelAgentServiceTests : ServiceTestFixture
{
Assert.That(characters, Has.Count.EqualTo(1));
Assert.That(characters[0].Name, Is.EqualTo("Ines"));
Assert.That(turn.Message.Content, Is.EqualTo("Added Ines as the protagonist."));
Assert.That(turn.Message.ToolCalls, Has.Count.EqualTo(1));
Assert.That(turn.Message.ToolCalls[0].Name, Is.EqualTo("create_character"));
Assert.That(turn.Content, Is.EqualTo("Added Ines as the protagonist."));
Assert.That(turn.ToResponse().ToolCalls, Has.Count.EqualTo(1));
Assert.That(turn.ToResponse().ToolCalls[0].Name, Is.EqualTo("create_character"));
});
}
@@ -113,7 +113,7 @@ public class NovelAgentServiceTests : ServiceTestFixture
{
Assert.That(errorResult.IsError, Is.True);
Assert.That(errorResult.Content, Does.Contain("was not found"));
Assert.That(turn.Message.Content, Does.Contain("does not exist yet"));
Assert.That(turn.Content, Does.Contain("does not exist yet"));
});
}
@@ -154,7 +154,7 @@ public class NovelAgentServiceTests : ServiceTestFixture
Assert.Multiple(() =>
{
Assert.That(model.Transcripts, Has.Count.EqualTo(4));
Assert.That(turn.Message.Content, Does.Contain("tool-call limit"));
Assert.That(turn.Content, Does.Contain("tool-call limit"));
});
}
+3 -3
View File
@@ -30,9 +30,9 @@ public class OpenQuestionTests : ServiceTestFixture
Assert.Multiple(() =>
{
Assert.That(question.ChapterTitle, Is.EqualTo("Landfall"));
Assert.That(question.ChapterNumber, Is.EqualTo(1));
Assert.That(question.CharacterName, Is.EqualTo("Ines"));
Assert.That(question.Chapter!.Title, Is.EqualTo("Landfall"));
Assert.That(question.Chapter!.Number, Is.EqualTo(1));
Assert.That(question.Character!.Name, Is.EqualTo("Ines"));
Assert.That(question.IsResolved, Is.False);
});
}
+1 -1
View File
@@ -144,7 +144,7 @@ public class ProjectDataTests : ServiceTestFixture
Assert.Multiple(() =>
{
Assert.That(updated.Relationships, Has.Count.EqualTo(1));
Assert.That(updated.Relationships[0].RelatedCharacterName, Is.EqualTo("Mara"));
Assert.That(updated.Relationships[0].RelatedCharacter!.Name, Is.EqualTo("Mara"));
});
}
+2 -2
View File
@@ -98,8 +98,8 @@ public class TagServiceTests : ServiceTestFixture
Assert.That(references.Chapters[0].Title, Is.EqualTo("Landfall"));
Assert.That(references.Beats, Has.Count.EqualTo(1));
Assert.That(references.Beats[0].Title, Is.EqualTo("She burns the atlas"));
Assert.That(references.Beats[0].ChapterTitle, Is.EqualTo("Landfall"));
Assert.That(references.Beats[0].ChapterNumber, Is.EqualTo(1));
Assert.That(references.Beats[0].Chapter!.Title, Is.EqualTo("Landfall"));
Assert.That(references.Beats[0].Chapter!.Number, Is.EqualTo(1));
});
}