Mit Roslyn umbenannt (Verweise folgen, Zeichenketten bleiben unberuehrt). Nicht umbenannt, weil gespeichert oder ueber die API gebunden (Stufe 4e): Properties, Record-Parameter, Enum-Member sowie die Parameter der Endpunkt-Handler (Routen-/Query-Bindung ueber den Namen). Die JSON-Felder anonymer Objekte bleiben ausdruecklich benannt (JsonFilter: pfad). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
240 lines
9.9 KiB
C#
240 lines
9.9 KiB
C#
using ClawdDotNet.Core.Api.Models;
|
|
using ClawdDotNet.Core.Config;
|
|
using ClawdDotNet.Core.Engine;
|
|
using ClawdDotNet.Core.Tests.Infrastructure;
|
|
using Shouldly;
|
|
|
|
namespace ClawdDotNet.Core.Tests.Engine;
|
|
|
|
/// <summary>
|
|
/// Regression tests for bug B1: compaction blindly keeps the last N messages.
|
|
/// If this boundary lies in the middle of a tool sequence, a tool answer without
|
|
/// the corresponding assistant tool_call results — the API rejects that with HTTP 400.
|
|
/// </summary>
|
|
public sealed class ContextCompactorTests
|
|
{
|
|
private const string Model = "test/model";
|
|
|
|
/// <summary>Threshold set so that compaction is surely triggered.</summary>
|
|
private static LoopGuardConfig AlwaysCompact => new()
|
|
{
|
|
MaxContextTokens = 1_000,
|
|
CompactionThreshold = 0.5 // threshold = 500 tokens
|
|
};
|
|
|
|
private static LoopGuardConfig NeverCompact => new()
|
|
{
|
|
MaxContextTokens = 10_000_000,
|
|
CompactionThreshold = 0.9
|
|
};
|
|
|
|
private static (ContextCompactor Compactor, FakeChatClient Client) CreateCompactor(
|
|
string summary = "- Der Agent hat Daten geprüft.\n- Ergebnis war unauffällig.")
|
|
{
|
|
var client = new FakeChatClient().AlwaysRespondsWithText(summary);
|
|
return (new ContextCompactor(client, TestLogging.Factory), client);
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════
|
|
// C1/C2 — the actual bug reproduction
|
|
// ═══════════════════════════════════════════════════════════
|
|
|
|
[Theory]
|
|
[InlineData(1)]
|
|
[InlineData(2)]
|
|
[InlineData(3)]
|
|
[InlineData(4)]
|
|
[InlineData(5)]
|
|
[InlineData(6)]
|
|
[InlineData(7)]
|
|
[InlineData(8)]
|
|
public async Task Compaction_keeps_a_valid_sequence_for_any_tool_cycle_length(int toolCallsPerCycle)
|
|
{
|
|
// Depending on the number of tool calls the "last 6 messages" boundary falls
|
|
// at a different place in the cycle. At least one variant hits right in the middle.
|
|
var (compactor, _) = CreateCompactor();
|
|
var messages = Conversation.Start()
|
|
.Repeat(times: 4, toolCallsPerCycle: toolCallsPerCycle, resultLength: 200)
|
|
.Build();
|
|
|
|
ContextInvariants.AssertValid(messages); // Precondition: the initial state is valid
|
|
|
|
await compactor.CompactIfNeededAsync(messages, AlwaysCompact, Model, default);
|
|
|
|
ContextInvariants.AssertValid(messages);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Compaction_never_separates_a_tool_answer_from_its_call()
|
|
{
|
|
// Constructed on purpose: the tail of 6 messages begins exactly at a tool answer.
|
|
var (compactor, _) = CreateCompactor();
|
|
var messages = Conversation.Start()
|
|
.User("Erste Anfrage")
|
|
.ToolCycle(count: 3, resultLength: 300)
|
|
.Assistant("Zwischenergebnis")
|
|
.User("Zweite Anfrage")
|
|
.ToolCycle(count: 3, resultLength: 300)
|
|
.Build();
|
|
|
|
await compactor.CompactIfNeededAsync(messages, AlwaysCompact, Model, default);
|
|
|
|
ContextInvariants.AssertValid(messages);
|
|
|
|
// No orphaned tool answer
|
|
for (var i = 0; i < messages.Count; i++)
|
|
{
|
|
if (messages[i].Role != "tool") continue;
|
|
|
|
var hasParent = messages
|
|
.Take(i)
|
|
.Any(m => m.ToolCalls?.Any(tc => tc.Id == messages[i].ToolCallId) == true);
|
|
|
|
hasParent.ShouldBeTrue(
|
|
$"tool-Antwort an Position {i} (id={messages[i].ToolCallId}) hat keinen Aufruf mehr.\n" +
|
|
ContextInvariants.Describe(messages));
|
|
}
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════
|
|
// C3/C4 — System-Prompt
|
|
// ═══════════════════════════════════════════════════════════
|
|
|
|
[Fact]
|
|
public async Task Compaction_keeps_the_SystemPrompt_at_position_0()
|
|
{
|
|
const string systemPrompt = "Du bist ein sehr spezifischer Testagent.";
|
|
var (compactor, _) = CreateCompactor();
|
|
var messages = Conversation.Start(systemPrompt).Repeat(6).Build();
|
|
|
|
await compactor.CompactIfNeededAsync(messages, AlwaysCompact, Model, default);
|
|
|
|
messages[0].Role.ShouldBe("system");
|
|
messages[0].Content.ShouldBe(systemPrompt);
|
|
messages.Count(m => m.Role == "system").ShouldBe(1);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Compaction_does_not_invent_a_SystemPrompt_when_there_was_none()
|
|
{
|
|
var (compactor, _) = CreateCompactor();
|
|
var messages = Conversation.Start(systemPrompt: null).Repeat(6).Build();
|
|
|
|
await compactor.CompactIfNeededAsync(messages, AlwaysCompact, Model, default);
|
|
|
|
messages.ShouldNotContain(m => m.Role == "system");
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════
|
|
// C5/C6 — failure cases of the summary
|
|
// ═══════════════════════════════════════════════════════════
|
|
|
|
[Fact]
|
|
public async Task Compaction_leaves_messages_unchanged_when_the_summary_fails()
|
|
{
|
|
var client = new FakeChatClient().Throws(new HttpRequestException("API nicht erreichbar"));
|
|
var compactor = new ContextCompactor(client, TestLogging.Factory);
|
|
var messages = Conversation.Start().Repeat(6).Build();
|
|
var before = messages.Count;
|
|
|
|
await compactor.CompactIfNeededAsync(messages, AlwaysCompact, Model, default);
|
|
|
|
ContextInvariants.AssertValid(messages);
|
|
messages.Count.ShouldBe(before, "bei fehlgeschlagener Zusammenfassung darf nichts verloren gehen");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Compaction_leaves_messages_unchanged_when_the_summary_is_empty()
|
|
{
|
|
var client = new FakeChatClient().AlwaysRespondsWithText(" ");
|
|
var compactor = new ContextCompactor(client, TestLogging.Factory);
|
|
var messages = Conversation.Start().Repeat(6).Build();
|
|
var before = messages.Count;
|
|
|
|
await compactor.CompactIfNeededAsync(messages, AlwaysCompact, Model, default);
|
|
|
|
ContextInvariants.AssertValid(messages);
|
|
messages.Count.ShouldBe(before);
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════
|
|
// C7/C8 — tool result pruning
|
|
// ═══════════════════════════════════════════════════════════
|
|
|
|
[Fact]
|
|
public async Task Pruning_shortens_long_tool_results_outside_the_protected_end()
|
|
{
|
|
var (compactor, _) = CreateCompactor();
|
|
var messages = Conversation.Start()
|
|
.Repeat(times: 5, toolCallsPerCycle: 1, resultLength: 8_000)
|
|
.Build();
|
|
|
|
await compactor.CompactIfNeededAsync(messages, AlwaysCompact, Model, default);
|
|
|
|
ContextInvariants.AssertValid(messages);
|
|
messages.Where(m => m.Role == "tool")
|
|
.ShouldAllBe(m => m.Content!.Length <= 8_000,
|
|
"kein Tool-Ergebnis darf nach dem Pruning gewachsen sein");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Pruning_leaves_short_tool_results_unchanged()
|
|
{
|
|
var (compactor, _) = CreateCompactor();
|
|
var messages = Conversation.Start()
|
|
.Repeat(times: 5, toolCallsPerCycle: 1, resultLength: 100)
|
|
.Build();
|
|
|
|
await compactor.CompactIfNeededAsync(messages, AlwaysCompact, Model, default);
|
|
|
|
foreach (var content in messages.Where(m => m.Role == "tool").Select(m => m.Content))
|
|
(content ?? "").ShouldNotContain("gekürzt", Case.Insensitive);
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════
|
|
// C9/C10 — estimate and threshold
|
|
// ═══════════════════════════════════════════════════════════
|
|
|
|
[Fact]
|
|
public void EstimateTokens_grows_monotonically_with_the_content()
|
|
{
|
|
var small = Conversation.Start().Repeat(1).Build();
|
|
var average = Conversation.Start().Repeat(5).Build();
|
|
var large = Conversation.Start().Repeat(20).Build();
|
|
|
|
var a = ContextCompactor.EstimateTokens(small);
|
|
var b = ContextCompactor.EstimateTokens(average);
|
|
var c = ContextCompactor.EstimateTokens(large);
|
|
|
|
a.ShouldBeLessThan(b);
|
|
b.ShouldBeLessThan(c);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Below_the_threshold_no_ApiCall_is_triggered()
|
|
{
|
|
// Important for the budget: compaction must not fire up a model unnecessarily.
|
|
var (compactor, client) = CreateCompactor();
|
|
var messages = Conversation.Start().Repeat(2).Build();
|
|
|
|
var compacted = await compactor.CompactIfNeededAsync(messages, NeverCompact, Model, default);
|
|
|
|
compacted.ShouldBeFalse();
|
|
client.CallCount.ShouldBe(0);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Compaction_reduces_the_message_count_clearly()
|
|
{
|
|
var (compactor, _) = CreateCompactor();
|
|
var messages = Conversation.Start().Repeat(15).Build();
|
|
var before = messages.Count;
|
|
|
|
var compacted = await compactor.CompactIfNeededAsync(messages, AlwaysCompact, Model, default);
|
|
|
|
compacted.ShouldBeTrue();
|
|
messages.Count.ShouldBeLessThan(before);
|
|
ContextInvariants.AssertValid(messages);
|
|
}
|
|
}
|