Reorganise by feature, rename to Novelly, add Aspire and a pre-push hook
The layered split into Domain/Application/Infrastructure/Api was forcing organisation by layer: adding one capability meant touching four projects and four folders that each held a slice of it. Those four projects are now one feature-organised Novelly.Api, where each folder — Projects, Characters, Chapters, Beats, Scenes, Tags, Agent — holds its entity, DTOs, service and endpoints together. Common/ holds what genuinely crosses features (the patch semantics, the two exception types, DraftStatus) and Data/ holds the DbContext and migrations. Six .NET projects become five: the three layer projects are gone, and Novelly.AppHost and Novelly.ServiceDefaults are new. - Namespaces move from NovelSoftware.* to Novelly.*, including the entity type names recorded in the EF model snapshots. The migration ids are untouched, so an existing novel.db still migrates cleanly — verified against a fresh file. - Aspire orchestration mirrors the mic-check setup: the AppHost starts the API on :5080 and the Vite dev server on :5173, and the API picks up OpenTelemetry, health checks and service discovery from ServiceDefaults. /health and /alive now answer in development. - A Husky pre-push hook runs scripts/ci/prepush.sh: build, test, then a web build. The scripts are plain bash so CI can run the same steps. - The MCP server's env var is now NOVELLY_API_URL. Verified beyond the build: 44 tests pass, the web client builds, the API was exercised over curl (project/chapter/beat/tag round trip, tag cross-reference, 503 on the agent without a key while conversation listing still returns 200), the MCP server was driven over stdio JSON-RPC (26 tools, errors still surface the API's own message rather than being flattened), and the AppHost was run to confirm both resources come up and Vite proxies /api through to the API. 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
30e0c6926e
commit
725758ccd9
@@ -0,0 +1,236 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Novelly.Api.Agent;
|
||||
using Novelly.Api.Projects;
|
||||
|
||||
namespace Novelly.Api.Tests;
|
||||
|
||||
[TestFixture]
|
||||
public class NovelAgentServiceTests : ServiceTestFixture
|
||||
{
|
||||
private NovelAgentToolset _toolset = null!;
|
||||
|
||||
protected override void OnSetUp() =>
|
||||
_toolset = new NovelAgentToolset(Projects, Characters, Chapters, Beats, Scenes, Tags);
|
||||
|
||||
private NovelAgentService BuildAgent(ScriptedModelClient model) => new(
|
||||
Db.Context,
|
||||
model,
|
||||
_toolset,
|
||||
Options.Create(new AgentOptions { MaxIterations = 4 }),
|
||||
NullLogger<NovelAgentService>.Instance);
|
||||
|
||||
[Test]
|
||||
public async Task A_plain_reply_is_persisted_as_a_conversation()
|
||||
{
|
||||
var projectId = (await Projects.CreateAsync(new CreateProjectRequest("The Salt Road"))).Id;
|
||||
var model = new ScriptedModelClient([[new AgentTextBlock("Tell me about the ending.")]]);
|
||||
var agent = BuildAgent(model);
|
||||
|
||||
var turn = await agent.SendMessageAsync(projectId, new SendAgentMessageRequest("Where do I start?"));
|
||||
|
||||
Assert.That(turn.Message.Content, Is.EqualTo("Tell me about the ending."));
|
||||
|
||||
var conversation = await agent.GetConversationAsync(turn.ConversationId);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(conversation.Messages, Has.Count.EqualTo(2));
|
||||
Assert.That(conversation.Messages[0].Content, Is.EqualTo("Where do I start?"));
|
||||
Assert.That(conversation.Title, Is.EqualTo("Where do I start?"));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Tool_calls_are_executed_against_real_project_data()
|
||||
{
|
||||
var projectId = (await Projects.CreateAsync(new CreateProjectRequest("The Salt Road"))).Id;
|
||||
|
||||
var model = new ScriptedModelClient([
|
||||
[ToolUse("t1", "create_character", new { name = "Ines", role = "Protagonist" })],
|
||||
[new AgentTextBlock("Added Ines as the protagonist.")]
|
||||
]);
|
||||
|
||||
var turn = await BuildAgent(model).SendMessageAsync(
|
||||
projectId, new SendAgentMessageRequest("Add a protagonist called Ines."));
|
||||
|
||||
var characters = await Characters.ListAsync(projectId);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
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"));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Every_tool_result_comes_back_in_a_single_user_turn()
|
||||
{
|
||||
var projectId = (await Projects.CreateAsync(new CreateProjectRequest("The Salt Road"))).Id;
|
||||
|
||||
var model = new ScriptedModelClient([
|
||||
[
|
||||
ToolUse("t1", "create_character", new { name = "Ines" }),
|
||||
ToolUse("t2", "create_character", new { name = "Mara" })
|
||||
],
|
||||
[new AgentTextBlock("Both added.")]
|
||||
]);
|
||||
|
||||
await BuildAgent(model).SendMessageAsync(projectId, new SendAgentMessageRequest("Add two characters."));
|
||||
|
||||
var resultTurn = model.Transcripts[1][^1];
|
||||
var listed = await Characters.ListAsync(projectId);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(resultTurn.Role, Is.EqualTo("user"));
|
||||
Assert.That(resultTurn.Content.OfType<AgentToolResultBlock>().Count(), Is.EqualTo(2));
|
||||
Assert.That(listed, Has.Count.EqualTo(2));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task A_failing_tool_is_reported_back_rather_than_thrown()
|
||||
{
|
||||
var projectId = (await Projects.CreateAsync(new CreateProjectRequest("The Salt Road"))).Id;
|
||||
|
||||
var model = new ScriptedModelClient([
|
||||
[ToolUse("t1", "update_character", new { character_id = Guid.NewGuid().ToString(), name = "Ines" })],
|
||||
[new AgentTextBlock("That character does not exist yet — shall I create her?")]
|
||||
]);
|
||||
|
||||
var turn = await BuildAgent(model).SendMessageAsync(
|
||||
projectId, new SendAgentMessageRequest("Rename her."));
|
||||
|
||||
var errorResult = model.Transcripts[1][^1].Content.OfType<AgentToolResultBlock>().Single();
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
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"));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Unknown_tools_are_reported_without_breaking_the_loop()
|
||||
{
|
||||
var projectId = (await Projects.CreateAsync(new CreateProjectRequest("The Salt Road"))).Id;
|
||||
|
||||
var model = new ScriptedModelClient([
|
||||
[ToolUse("t1", "summon_muse", new { })],
|
||||
[new AgentTextBlock("Sorry — I do not have that tool.")]
|
||||
]);
|
||||
|
||||
await BuildAgent(model).SendMessageAsync(projectId, new SendAgentMessageRequest("Summon the muse."));
|
||||
|
||||
var result = model.Transcripts[1][^1].Content.OfType<AgentToolResultBlock>().Single();
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(result.IsError, Is.True);
|
||||
Assert.That(result.Content, Does.Contain("No such tool"));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task The_loop_stops_at_the_iteration_ceiling()
|
||||
{
|
||||
var projectId = (await Projects.CreateAsync(new CreateProjectRequest("The Salt Road"))).Id;
|
||||
|
||||
// A model that only ever asks for more tools would otherwise loop forever.
|
||||
var model = new ScriptedModelClient(
|
||||
Enumerable.Repeat<IReadOnlyList<AgentContentBlock>>(
|
||||
[ToolUse("t", "list_characters", new { })], 20).ToList());
|
||||
|
||||
var turn = await BuildAgent(model).SendMessageAsync(
|
||||
projectId, new SendAgentMessageRequest("Keep going forever."));
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(model.Transcripts, Has.Count.EqualTo(4));
|
||||
Assert.That(turn.Message.Content, Does.Contain("tool-call limit"));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Follow_up_messages_continue_the_same_conversation()
|
||||
{
|
||||
var projectId = (await Projects.CreateAsync(new CreateProjectRequest("The Salt Road"))).Id;
|
||||
var model = new ScriptedModelClient([
|
||||
[new AgentTextBlock("First answer.")],
|
||||
[new AgentTextBlock("Second answer.")]
|
||||
]);
|
||||
var agent = BuildAgent(model);
|
||||
|
||||
var first = await agent.SendMessageAsync(projectId, new SendAgentMessageRequest("Question one."));
|
||||
var second = await agent.SendMessageAsync(
|
||||
projectId, new SendAgentMessageRequest("Question two.", first.ConversationId));
|
||||
|
||||
var conversation = await agent.GetConversationAsync(first.ConversationId);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(second.ConversationId, Is.EqualTo(first.ConversationId));
|
||||
|
||||
// The second request replays the earlier turns so the model has the history.
|
||||
Assert.That(model.Transcripts[1], Has.Count.EqualTo(3));
|
||||
Assert.That(
|
||||
model.Transcripts[1].Select(m => m.Role),
|
||||
Is.EqualTo(new[] { "user", "assistant", "user" }));
|
||||
Assert.That(conversation.Messages, Has.Count.EqualTo(4));
|
||||
});
|
||||
}
|
||||
|
||||
[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(tool.InputSchema.TryGetProperty("properties", out _), Is.True, tool.Name);
|
||||
}
|
||||
|
||||
Assert.That(_toolset.Definitions.Select(t => t.Name), Is.Unique);
|
||||
});
|
||||
}
|
||||
|
||||
private static AgentToolUseBlock ToolUse(string id, string name, object input) =>
|
||||
new(id, name, JsonSerializer.SerializeToElement(input));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A model stand-in that returns a fixed script of turns and records every transcript it
|
||||
/// was sent, so tests can assert on what the loop actually put in front of the model.
|
||||
/// </summary>
|
||||
internal class ScriptedModelClient(IReadOnlyList<IReadOnlyList<AgentContentBlock>> script)
|
||||
: IAgentModelClient
|
||||
{
|
||||
private int _turn;
|
||||
|
||||
public List<IReadOnlyList<AgentChatMessage>> Transcripts { get; } = [];
|
||||
|
||||
public Task<AgentModelResponse> CompleteAsync(
|
||||
string systemPrompt,
|
||||
IReadOnlyList<AgentChatMessage> messages,
|
||||
IReadOnlyList<AgentToolDefinition> tools,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
Transcripts.Add([.. messages]);
|
||||
|
||||
var content = _turn < script.Count ? script[_turn] : [new AgentTextBlock("(no more script)")];
|
||||
_turn++;
|
||||
|
||||
var stopReason = content.OfType<AgentToolUseBlock>().Any() ? "tool_use" : "end_turn";
|
||||
return Task.FromResult(new AgentModelResponse(content, stopReason));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user