Files
novelly/tests/Novelly.Api.Tests/NovelAgentToolsetTests.cs
T
James Wampler 897fb442a1 Unify MCP and agent tool surfaces onto one registry in NovelAgentToolset
Fixes NovelAgentService continuing a conversation under the wrong
novel's route, since FindConversationAsync matched by id alone. Then
extends NovelAgentToolset to all 45 tools the stdio MCP server offered
(tag/location CRUD, character relationships, arc-stage beat pinning,
question editing, cross-novel novel listing/creation), tagging each
with whether it needs an explicit novel scope so a later MCP adapter
can inject it. Renames the toolset's 33 existing schemas from
snake_case to camelCase to match .NET/REST convention, since nothing
external consumes them.

Lays the groundwork to serve this same registry over MCP at /mcp and
retire the separate stdio Novelly.Mcp project (docs/plans/api/mcp_http_merge_plan.md).
2026-08-21 10:52:50 -07:00

227 lines
9.6 KiB
C#

using System.Text.Json;
using Microsoft.Extensions.Logging.Abstractions;
using Novelly.Api.Agent;
using Novelly.Api.Beats;
using Novelly.Api.Characters;
using Novelly.Api.Novels;
namespace Novelly.Api.Tests;
[TestFixture]
public class NovelAgentToolsetTests : ServiceTestFixture
{
private NovelAgentToolset _toolset = null!;
protected override void OnSetUp() =>
_toolset = new NovelAgentToolset(Novels, Characters, Arcs, Chapters, ChapterLabels, Beats, Tags, Locations, Questions, NullLogger<NovelAgentToolset>.Instance);
private static JsonElement Input(object value) => JsonSerializer.SerializeToElement(value);
[Test]
public void The_toolset_offers_every_tool_the_stdio_server_offered()
{
var expected = new[]
{
"list_novels", "create_novel", "get_novel_brief", "update_novel_brief",
"list_characters", "get_character", "create_character", "update_character",
"get_character_beats", "get_character_arc", "add_arc_stage", "update_arc_stage",
"delete_arc_stage", "reorder_arc_stages", "set_arc_stage_beats", "relate_characters",
"link_character_identity", "unlink_character_identity",
"get_chapter_outline", "create_beat", "update_beat", "delete_beat",
"assign_character_to_beats", "reorder_beats", "move_beats",
"list_chapters", "get_chapter", "create_chapter", "update_chapter",
"list_tags", "get_tag_references", "create_tag", "update_tag", "delete_tag",
"list_locations", "get_location_references", "create_location", "update_location", "delete_location",
"list_open_questions", "raise_open_question", "update_open_question",
"resolve_open_question", "reopen_question", "delete_open_question"
};
Assert.That(_toolset.Definitions.Select(t => t.Name), Is.EquivalentTo(expected));
}
[Test]
public void No_novel_scoped_tools_schema_already_declares_a_novel_id()
{
Assert.Multiple(() =>
{
foreach (var tool in _toolset.Definitions.Where(t => t.RequiresNovelId))
{
Assert.That(tool.InputSchema.TryGetProperty("novelId", out _), Is.False, tool.Name);
}
});
}
[Test]
public async Task Creating_a_novel_through_a_tool_makes_it_appear_in_the_novel_list()
{
var result = await _toolset.ExecuteAsync(
"create_novel", Guid.Empty, Input(new { title = "The Ash Ledger" }));
var novels = await Novels.ListAsync();
Assert.Multiple(() =>
{
Assert.That(result.IsError, Is.False);
Assert.That(novels.Select(n => n.Title), Has.Member("The Ash Ledger"));
});
}
[Test]
public async Task Reading_a_character_by_id_returns_its_dossier()
{
var novelId = (await Novels.CreateAsync(new CreateNovelRequest("The Salt Road"))).Id;
var characterId = (await Characters.CreateAsync(novelId, new CreateCharacterRequest("Ines"))).Id;
var result = await _toolset.ExecuteAsync(
"get_character", novelId, Input(new { characterId }));
Assert.Multiple(() =>
{
Assert.That(result.IsError, Is.False);
Assert.That(result.Content, Does.Contain("Ines"));
});
}
[Test]
public async Task Relating_two_characters_shows_the_pair_on_both_dossiers()
{
var novelId = (await Novels.CreateAsync(new CreateNovelRequest("The Salt Road"))).Id;
var inesId = (await Characters.CreateAsync(novelId, new CreateCharacterRequest("Ines"))).Id;
var marcoId = (await Characters.CreateAsync(novelId, new CreateCharacterRequest("Marco"))).Id;
var result = await _toolset.ExecuteAsync(
"relate_characters", novelId,
Input(new { characterId = inesId, relatedCharacterId = marcoId, relationshipType = "sister" }));
var ines = await Characters.GetAsync(inesId);
var marco = await Characters.GetAsync(marcoId);
Assert.Multiple(() =>
{
Assert.That(result.IsError, Is.False);
Assert.That(ines!.Relationships.Select(r => r.RelatedCharacterId), Has.Member(marcoId));
Assert.That(marco!.Relationships.Select(r => r.RelatedCharacterId), Has.Member(inesId));
});
}
[Test]
public async Task Setting_an_arc_stages_beats_replaces_whatever_was_there()
{
var novelId = (await Novels.CreateAsync(new CreateNovelRequest("The Salt Road"))).Id;
var characterId = (await Characters.CreateAsync(novelId, new CreateCharacterRequest("Ines", Importance: CharacterImportance.Main))).Id;
var chapterId = (await Chapters.CreateAsync(novelId, new Novelly.Api.Chapters.CreateChapterRequest("Chapter One"))).Id;
var beatId = (await Beats.CreateAsync(chapterId, new CreateBeatRequest("Arrival", CharacterIds: [characterId]))).Id;
var stageId = (await Arcs.CreateAsync(characterId, new CreateArcStageRequest("Doubt"))).Id;
var result = await _toolset.ExecuteAsync(
"set_arc_stage_beats", novelId, Input(new { arcStageId = stageId, beatIds = new[] { beatId.ToString() } }));
Assert.That(result.IsError, Is.False);
}
[Test]
public async Task Creating_a_tag_with_a_colour_then_renaming_it_keeps_it_on_what_carried_it()
{
var novelId = (await Novels.CreateAsync(new CreateNovelRequest("The Salt Road"))).Id;
var created = await _toolset.ExecuteAsync(
"create_tag", novelId, Input(new { name = "Foreshadowing", color = "#9a4a2f" }));
var tagsAfterCreate = await Tags.ListAsync(novelId);
var tagId = tagsAfterCreate.Single(t => t.Name == "Foreshadowing").Id;
var renamed = await _toolset.ExecuteAsync(
"update_tag", novelId, Input(new { tagId, name = "Setup" }));
var tagsAfterRename = await Tags.ListAsync(novelId);
Assert.Multiple(() =>
{
Assert.That(created.IsError, Is.False);
Assert.That(renamed.IsError, Is.False);
Assert.That(tagsAfterRename.Select(t => t.Name), Has.Member("Setup"));
Assert.That(tagsAfterRename.Select(t => t.Name), Has.No.Member("Foreshadowing"));
});
}
[Test]
public async Task Deleting_a_tag_leaves_the_characters_that_carried_it_alone()
{
var novelId = (await Novels.CreateAsync(new CreateNovelRequest("The Salt Road"))).Id;
var characterId = (await Characters.CreateAsync(
novelId, new CreateCharacterRequest("Ines", Tags: ["Foreshadowing"]))).Id;
var tagId = (await Tags.ListAsync(novelId)).Single(t => t.Name == "Foreshadowing").Id;
var result = await _toolset.ExecuteAsync("delete_tag", novelId, Input(new { tagId }));
var character = await Characters.GetAsync(characterId);
var tagsAfter = await Tags.ListAsync(novelId);
Assert.Multiple(() =>
{
Assert.That(result.IsError, Is.False);
Assert.That(tagsAfter, Is.Empty);
Assert.That(character, Is.Not.Null);
});
}
[Test]
public async Task Creating_and_renaming_a_location_updates_it_everywhere()
{
var novelId = (await Novels.CreateAsync(new CreateNovelRequest("The Salt Road"))).Id;
await _toolset.ExecuteAsync("create_location", novelId, Input(new { name = "The Salt Flats" }));
var locationId = (await Locations.ListAsync(novelId)).Single(l => l.Name == "The Salt Flats").Id;
var renamed = await _toolset.ExecuteAsync(
"update_location", novelId, Input(new { locationId, name = "The Great Salt Flats" }));
var locationsAfter = await Locations.ListAsync(novelId);
Assert.Multiple(() =>
{
Assert.That(renamed.IsError, Is.False);
Assert.That(locationsAfter.Select(l => l.Name), Has.Member("The Great Salt Flats"));
});
}
[Test]
public async Task Updating_an_open_question_can_detach_it_from_its_chapter()
{
var novelId = (await Novels.CreateAsync(new CreateNovelRequest("The Salt Road"))).Id;
var chapterId = (await Chapters.CreateAsync(novelId, new Novelly.Api.Chapters.CreateChapterRequest("Chapter One"))).Id;
var questionId = (await Questions.CreateAsync(
novelId, new Novelly.Api.Questions.CreateOpenQuestionRequest("Who sent the letter?", ChapterId: chapterId)))!.Id;
var result = await _toolset.ExecuteAsync(
"update_open_question", novelId, Input(new { questionId, clearChapter = true }));
var question = await Questions.GetAsync(questionId);
Assert.Multiple(() =>
{
Assert.That(result.IsError, Is.False);
Assert.That(question!.ChapterId, Is.Null);
});
}
[Test]
public async Task Asking_for_an_unknown_id_comes_back_as_an_error_result()
{
var novelId = (await Novels.CreateAsync(new CreateNovelRequest("The Salt Road"))).Id;
var missingId = Guid.NewGuid();
Assert.Multiple(async () =>
{
var character = await _toolset.ExecuteAsync("get_character", novelId, Input(new { characterId = missingId }));
Assert.That(character.IsError, Is.True);
var tag = await _toolset.ExecuteAsync("update_tag", novelId, Input(new { tagId = missingId, name = "x" }));
Assert.That(tag.IsError, Is.True);
var location = await _toolset.ExecuteAsync("delete_location", novelId, Input(new { locationId = missingId }));
Assert.That(location.IsError, Is.True);
var question = await _toolset.ExecuteAsync("update_open_question", novelId, Input(new { questionId = missingId }));
Assert.That(question.IsError, Is.True);
});
}
}