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; public sealed class TokenAccountingTests { // ═══════════════════════════════════════════════════════════ // B4 — Prompt und Completion getrennt erfassen // ═══════════════════════════════════════════════════════════ [Fact] public async Task Prompt_und_Completion_werden_getrennt_aufsummiert() { // Bisher schätzte die UI 50/50. Real liegt das Verhältnis eher bei 95:5 — // und da Ausgabe-Tokens ein Vielfaches kosten, war die Kostenanzeige // um ein Mehrfaches daneben. var fixture = new EngineFixture().WithTool(FakeTool.Returning("ok")); var agent = fixture.AddAgent("agent-tokens", "TestTool"); fixture.Client .RespondsWithText("Fertig", new Usage { PromptTokens = 9_500, CompletionTokens = 500, TotalTokens = 10_000 }); var result = await fixture.Engine.ChatAsync(agent, "Frage", "test-instance", default); result.PromptTokens.ShouldBe(9_500); result.CompletionTokens.ShouldBe(500); result.TokensUsed.ShouldBe(10_000); } [Fact] public async Task Ueber_mehrere_Schritte_wird_korrekt_summiert() { var fixture = new EngineFixture().WithTool(FakeTool.Returning("ok")); var agent = fixture.AddAgent("agent-sum", "TestTool"); fixture.Client .RespondsWithToolCall("TestTool") // 100 / 20 / 120 laut Fake .RespondsWithToolCall("TestTool") .RespondsWithText("Fertig"); var result = await fixture.Engine.ChatAsync(agent, "Frage", "test-instance", default); result.StepCount.ShouldBe(3); result.PromptTokens.ShouldBe(300); result.CompletionTokens.ShouldBe(60); result.TokensUsed.ShouldBe(360); } // ═══════════════════════════════════════════════════════════ // T9 — Cache-Wirkung messbar machen // ═══════════════════════════════════════════════════════════ [Fact] public async Task Gecachte_Tokens_werden_durchgereicht() { // Ohne diese Zahl liesse sich nicht belegen, ob das Prompt-Caching greift — // ein still wirkungsloser Cache wäre sonst nicht zu bemerken. var fixture = new EngineFixture(); var agent = fixture.AddAgent("agent-cached"); fixture.Client.RespondsWithText("Fertig", new Usage { PromptTokens = 20_000, CompletionTokens = 300, TotalTokens = 20_300, PromptTokensDetails = new PromptTokensDetails { CachedTokens = 18_000 } }); var result = await fixture.Engine.ChatAsync(agent, "Frage", "test-instance", default); result.CachedTokens.ShouldBe(18_000); } [Fact] public void Fehlende_Cache_Angaben_ergeben_null_statt_eines_Fehlers() { var usage = new Usage { PromptTokens = 100, CompletionTokens = 10, TotalTokens = 110 }; usage.CachedTokens.ShouldBe(0); } // ═══════════════════════════════════════════════════════════ // T3 — Compaction läuft mit dem günstigen Modell // ═══════════════════════════════════════════════════════════ [Fact] public async Task Die_Zusammenfassung_nutzt_das_guenstige_Modell() { var client = new FakeChatClient().AlwaysRespondsWithText("- Zusammenfassung."); var compactor = new ContextCompactor(client, TestLogging.Factory); var guard = new LoopGuardConfig { MaxContextTokens = 1_000, CompactionThreshold = 0.5, SummaryModel = "google/gemini-2.5-flash" }; var messages = Conversation.Start().Repeat(10).Build(); await compactor.CompactIfNeededAsync(messages, 50_000, guard, "anthropic/claude-opus-4", default); client.ReceivedRequests.ShouldNotBeEmpty(); client.ReceivedRequests[0].Model.ShouldBe("google/gemini-2.5-flash", "Zusammenfassen ist anspruchslos und darf nicht das teure Agentenmodell belegen"); } [Fact] public async Task Ohne_konfiguriertes_SummaryModel_wird_das_Agentenmodell_verwendet() { var client = new FakeChatClient().AlwaysRespondsWithText("- Zusammenfassung."); var compactor = new ContextCompactor(client, TestLogging.Factory); var guard = new LoopGuardConfig { MaxContextTokens = 1_000, CompactionThreshold = 0.5, SummaryModel = "" }; var messages = Conversation.Start().Repeat(10).Build(); await compactor.CompactIfNeededAsync(messages, 50_000, guard, "anthropic/claude-opus-4", default); client.ReceivedRequests[0].Model.ShouldBe("anthropic/claude-opus-4"); } // ═══════════════════════════════════════════════════════════ // Nur der wegfallende Teil wird zusammengefasst // ═══════════════════════════════════════════════════════════ [Fact] public async Task Der_erhaltene_Tail_geht_nicht_in_den_Zusammenfassungs_Aufruf() { // Der Tail bleibt wörtlich erhalten — ihn zusätzlich zusammenzufassen // wäre doppelt bezahlter Kontext. var client = new FakeChatClient().AlwaysRespondsWithText("- Zusammenfassung."); var compactor = new ContextCompactor(client, TestLogging.Factory); var guard = new LoopGuardConfig { MaxContextTokens = 1_000, CompactionThreshold = 0.5 }; var messages = Conversation.Start() .Repeat(8) .User("EINZIGARTIGE-LETZTE-NACHRICHT") .Build(); await compactor.CompactIfNeededAsync(messages, 50_000, guard, "test/model", default); var summaryPrompt = client.ReceivedRequests[0].Messages.Last().Content!; summaryPrompt.ShouldNotContain("EINZIGARTIGE-LETZTE-NACHRICHT"); } }