using ClawdDotNet.Core.Engine; using ClawdDotNet.Core.Tasks; using ClawdDotNet.Core.Tests.Infrastructure; using Microsoft.Extensions.Logging.Abstractions; using Shouldly; namespace ClawdDotNet.Core.Tests.Engine; /// /// Regression tests for the findings from test operation on the Linux VM (#572). /// public sealed class TestOperationRegressionTests { // ═══════════════════════════════════════════════════════════ // #578 — aborted runs carry the tokens they paid for // ═══════════════════════════════════════════════════════════ [Fact] public async Task RunAsync_at_the_step_limit_reports_the_tokens_paid_so_far() { var fixture = new EngineFixture().WithTool(FakeTool.Slow(TimeSpan.FromMilliseconds(1))); var agent = fixture.AddAgent("agent-grenze", "TestTool"); agent.LoopGuard.MaxSteps = 3; // Every answer is a tool call with 100 prompt and 20 completion tokens. fixture.Client.AlwaysRespondsWithToolCall("TestTool"); var result = await fixture.Engine.RunAsync(agent, "Arbeite", "test-instance", default); result.Status.ShouldBe(AgentRunStatus.LoopLimitExceeded); fixture.Client.CallCount.ShouldBe(3); result.PromptTokens.ShouldBe(300, "drei bezahlte Aufrufe - vorher stand hier 0"); result.CompletionTokens.ShouldBe(60); } [Fact] public async Task ChatAsync_at_the_step_limit_reports_the_tokens_paid_so_far() { var fixture = new EngineFixture().WithTool(FakeTool.Slow(TimeSpan.FromMilliseconds(1))); var agent = fixture.AddAgent("agent-chat-grenze", "TestTool"); agent.LoopGuard.MaxSteps = 2; fixture.Client.AlwaysRespondsWithToolCall("TestTool"); var result = await fixture.Engine.ChatAsync(agent, "Arbeite", "test-instance", default); result.Status.ShouldBe(AgentRunStatus.LoopLimitExceeded); result.PromptTokens.ShouldBe(200); result.CompletionTokens.ShouldBe(40); } [Fact] public async Task RunAsync_with_a_provider_error_reports_the_tokens_paid_so_far() { var fixture = new EngineFixture().WithTool(FakeTool.Slow(TimeSpan.FromMilliseconds(1))); var agent = fixture.AddAgent("agent-fehler", "TestTool"); fixture.Client .RespondsWithToolCall("TestTool") .Throws(new HttpRequestException("502 vom Anbieter")); var result = await fixture.Engine.RunAsync(agent, "Arbeite", "test-instance", default); result.Status.ShouldBe(AgentRunStatus.Failed); result.PromptTokens.ShouldBe(100); } // ═══════════════════════════════════════════════════════════ // #581 — incomplete argument JSON // ═══════════════════════════════════════════════════════════ [Theory] [InlineData("{\"action\": \"write\", \"workspace\": \"personal\", \"path\": \"gross.txt\"", false)] [InlineData("{\"action\": \"send_message\", \"targetAgentId\": \"netzpruefer\"", false)] [InlineData("{\"action\": \"list\"}", true)] public void IsCompleteJson_recognises_truncated_arguments(string arguments, bool expected) => AgentEngine.IsCompleteJson(arguments).ShouldBe(expected); [Fact] public async Task Truncated_arguments_do_not_reach_the_tool_and_the_model_learns_why() { var fixture = new EngineFixture().WithTool(FakeTool.Slow(TimeSpan.FromMilliseconds(1))); var agent = fixture.AddAgent("agent-abgeschnitten", "TestTool"); // Exactly the shape from the test bench: the JSON breaks off before the large value. fixture.Client .RespondsWithToolCall("TestTool", "{\"action\": \"write\", \"path\": \"gross.txt\"") .RespondsWithText("verstanden"); await fixture.Engine.RunAsync(agent, "Schreibe viel", "test-instance", default); var answerToModel = fixture.Client.ReceivedRequests[1].Messages.Last(); answerToModel.Role.ShouldBe("tool"); answerToModel.Content.ShouldNotBeNull(); answerToModel.Content.ShouldContain("kein vollstaendiges JSON"); answerToModel.Content.ShouldContain("mehrere kleinere Aufrufe"); answerToModel.Content.ShouldNotContain("Expected depth"); } // ═══════════════════════════════════════════════════════════ // #589 — time and schedule in the assignment message // ═══════════════════════════════════════════════════════════ [Fact] public void Time_header_names_task_local_time_UTC_and_schedule() { var task = new TaskItem { Id = "t-1", Title = "Wetter", When = new TaskWhen { Kind = TaskWhenKind.Cron, Value = "5 * * * *", TimeZone = "Europe/Berlin" } }; var text = EngineTaskDispatcher.TimeHeaderFor(task, new DateTimeOffset(2026, 9, 14, 13, 5, 0, TimeSpan.Zero)); text.ShouldContain("14.09.2026 15:05 Uhr"); text.ShouldContain("Europe/Berlin"); text.ShouldContain("2026-09-14T13:05Z"); text.ShouldContain("cron \"5 * * * *\""); } [Fact] public void Assignment_message_contains_the_time_header() { var fixture = new EngineFixture(); var dispatcher = new EngineTaskDispatcher( fixture.Engine, () => fixture.Agents, "test-instance", fixture.Registry, fixture.StateStore, NullLoggerFactory.Instance, new FixedTime(new DateTimeOffset(2026, 1, 5, 7, 15, 0, TimeSpan.Zero))); var message = dispatcher.BuildMessage(new TaskItem { Id = "t-2", Title = "Morgenbericht", Body = "Schreibe den Bericht.", When = new TaskWhen { Kind = TaskWhenKind.Cron, Value = "15 7 * * *", TimeZone = "Europe/Berlin" } }); message.ShouldStartWith("[Aufgabe t-2] Morgenbericht"); message.ShouldContain("Aktuelle Zeit: 05.01.2026 08:15 Uhr (Europe/Berlin, UTC+01:00)"); message.ShouldContain("Schreibe den Bericht."); } private sealed class FixedTime(DateTimeOffset now) : TimeProvider { public override DateTimeOffset GetUtcNow() => now; } }