Files
Wiki/test/Wiki.Contracts/AnswerModelContract.cs
T
claude-botandClaude Sonnet 5 44c48bde71 feat(answer): add Anthropic Messages API answer model adapter
Implements IAnswerModel over Anthropic's streaming Messages API: SSE
parsing (text_delta/usage/error), 429/529 retry with Retry-After or
exponential backoff, and a pure AnswerPrompt.Build for the evidence
and judgements prompt shape. Health check hits GET /v1/models.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-24 16:37:55 +02:00

64 lines
2.4 KiB
C#

using Wiki.Core.Model;
using Wiki.Core.Ports;
namespace Wiki.Contracts;
/// <summary>
/// Every <see cref="IAnswerModel"/> adapter derives from this. The adapter under test may talk to
/// a fake server that echoes the evidence back; the contract checks the streaming shape, the
/// usage record and that the model sees nothing but the request.
/// </summary>
public abstract class AnswerModelContract
{
protected abstract Task<IAnswerModel> CreateModelAsync();
protected static AnswerRequest Request() => new(
"Does the UDM intercept DNS?",
[
new EvidenceParagraph(new Paragraph("udm-dns#p1", "udm-dns", "UDM intercepts DNS", "The UDM answers every UDP/53 query itself."), "UDM intercepts DNS", 0.84),
new EvidenceParagraph(new Paragraph("udm-dns#p2", "udm-dns", "Details", "Even dig @1.1.1.1 is answered locally."), "UDM intercepts DNS", 0.41),
],
new Dictionary<string, Judgement> { ["answered_1"] = new Judgement.Noul(0.93) },
0.88);
[Fact]
public async Task StreamsTextThenExactlyOneDoneWithUsage()
{
var model = await CreateModelAsync();
var chunks = new List<AnswerChunk>();
await foreach (var c in model.AnswerAsync(Request(), CancellationToken.None))
chunks.Add(c);
Assert.NotEmpty(chunks);
Assert.IsType<AnswerChunk.Done>(chunks[^1]);
Assert.Single(chunks.OfType<AnswerChunk.Done>());
var text = string.Concat(chunks.OfType<AnswerChunk.Text>().Select(t => t.Delta));
Assert.False(string.IsNullOrWhiteSpace(text));
var done = (AnswerChunk.Done)chunks[^1];
Assert.Equal(model.ModelId, done.ModelId);
Assert.True(done.InputTokens > 0);
Assert.True(done.OutputTokens > 0);
}
[Fact]
public async Task CancellationStopsTheStream()
{
var model = await CreateModelAsync();
using var cts = new CancellationTokenSource();
cts.Cancel();
await Assert.ThrowsAnyAsync<OperationCanceledException>(async () =>
{
await foreach (var _ in model.AnswerAsync(Request(), cts.Token)) { }
});
}
[Fact]
public async Task ModelIdAndHealth()
{
var model = await CreateModelAsync();
Assert.False(string.IsNullOrWhiteSpace(model.ModelId));
var health = await model.CheckAsync(CancellationToken.None);
Assert.True(health.Ok, health.Detail);
}
}