using ClawdDotNet.App.Services; using ClawdDotNet.Core.Config; using Shouldly; namespace ClawdDotNet.App.Tests.Services; /// /// The rules that until 2026-09-06 lived in the Avalonia view models. /// /// That they lived there was the reason for these tests: a view model has /// no test coverage — the UI guide says so explicitly —, and with the /// second user interface the same rules would have been written a second time. Now /// they exist once, and from here on it is checked that both user interfaces get the same /// thing. /// public sealed class AgentAdministrationTests { [Fact] public void A_duplicate_agent_ID_is_rejected() { using var host = new TestHost(TestHost.Agent("scout")); var result = host.Host.AgentAdmin.Add(new NewAgent("scout", "Scout", "modell")); result.Ok.ShouldBeFalse(); result.Message.ShouldContain("existiert bereits"); host.Host.Instance.Agents.Count.ShouldBe(1); } [Fact] public void The_check_ignores_case() { // Two agents differing only in that would be two folders on the target platform // and the same agent to every human. using var host = new TestHost(TestHost.Agent("scout")); host.Host.AgentAdmin.Add(new NewAgent("SCOUT", "Scout", "modell")).Ok.ShouldBeFalse(); } [Fact] public void Without_ID_no_agent_is_created() { using var host = new TestHost(); host.Host.AgentAdmin.Add(new NewAgent(" ", "Scout", "modell")).Ok.ShouldBeFalse(); host.Host.Instance.Agents.ShouldBeEmpty(); } [Fact] public void A_created_agent_survives_the_restart() { // The core of the finding: LoadInstanceConfig clears the agent list and rebuilds it // from the folders Agents/Agent-*. An agent that only stood in // InstanceSettings.json was gone after the next start — exactly what // the user interface did until 2026-09-06. using var host = new TestHost(); host.Host.AgentAdmin.Add(new NewAgent("scout", "Scout", "modell", "Späher")).Ok.ShouldBeTrue(); var afterRestart = host.Host.Directories.LoadInstanceConfig(host.Host.InstancePath); afterRestart.Agents.ShouldContain(a => a.AgentId == "scout" && a.DisplayName == "Scout"); } [Fact] public void Changes_to_an_agent_survive_the_restart() { // The same cause from the other direction: until 2026-09-06 nobody called // SaveAgentSettings during operation. Model, prompt and budget only stood in // InstanceSettings.json — it looked like saving and wasn't. using var host = new TestHost(); host.Host.AgentAdmin.Add(new NewAgent("scout", "Scout", "altes-modell")); var agent = host.Host.AgentAdmin.Find("scout")!; agent.Model = "neues-modell"; agent.Budget.DailyTokens = 12_345; agent.Identity = "# Identity: geändert"; host.Host.AgentAdmin.Save(); var afterRestart = host.Host.Directories .LoadInstanceConfig(host.Host.InstancePath).Agents .Single(a => a.AgentId == "scout"); afterRestart.Model.ShouldBe("neues-modell"); afterRestart.Budget.DailyTokens.ShouldBe(12_345); afterRestart.Identity.ShouldBe("# Identity: geändert"); } [Fact] public void A_tool_stays_allowed_across_the_restart() { using var host = new TestHost(); host.Host.AgentAdmin.Add(new NewAgent("scout", "Scout", "modell")); host.Host.AgentAdmin.SetToolEnabled("scout", "FileRW", enabled: true); host.Host.Directories .LoadInstanceConfig(host.Host.InstancePath).Agents .Single(a => a.AgentId == "scout") .Tools.ShouldContainKey("FileRW"); } [Fact] public void Every_change_reports_itself() { // Without this notification an agent created in the web UI would not appear in the // running window — the whole reason the event exists. using var host = new TestHost(); host.Host.AgentAdmin.Add(new NewAgent("scout", "Scout", "modell")); host.Changes.ShouldHaveSingleItem(); host.Changes[0].Kind.ShouldBe(InstanceChangeKind.Agents); host.Changes[0].Id.ShouldBe("scout"); } [Fact] public void A_removed_agent_does_not_return_after_the_restart() { // The opposite direction of the same finding: merely striking it from the list was not // enough — the folder stayed behind, and at the next start the agent was // back. using var host = new TestHost(); host.Host.AgentAdmin.Add(new NewAgent("scout", "Scout", "modell")); host.Host.AgentAdmin.Remove("scout").Ok.ShouldBeTrue(); host.Host.Directories .LoadInstanceConfig(host.Host.InstancePath).Agents .ShouldNotContain(a => a.AgentId == "scout"); } [Fact] public void A_removed_agent_keeps_its_work() { // Renamed, not deleted. Its work is in the working folder, and a click // in a list is no decision to throw it away. using var host = new TestHost(); host.Host.AgentAdmin.Add(new NewAgent("scout", "Scout", "modell")); var workFile = Path.Combine( host.Host.AgentAdmin.Find("scout")!.AgentDir, "Workspace", "ergebnis.txt"); File.WriteAllText(workFile, "wichtig"); host.Host.AgentAdmin.Remove("scout").Ok.ShouldBeTrue(); var agentsDir = Path.Combine(host.Host.InstancePath, "Agents"); Directory.GetDirectories(agentsDir, "Entfernt-*").ShouldHaveSingleItem(); Directory.GetFiles(agentsDir, "ergebnis.txt", SearchOption.AllDirectories) .ShouldHaveSingleItem(); } [Fact] public void Allowing_a_tool_creates_an_empty_configuration() { using var host = new TestHost(TestHost.Agent("scout")); host.Host.AgentAdmin.SetToolEnabled("scout", "FileRW", enabled: true).Ok.ShouldBeTrue(); host.Host.AgentAdmin.Find("scout")!.Tools.ShouldContainKey("FileRW"); host.Host.AgentAdmin.Find("scout")!.Tools["FileRW"].ShouldBeEmpty(); } // ─── #641: permissions in the tool configuration ─── [Fact] public void Rights_are_stored_normalised_and_survive_the_restart() { using var host = new TestHost(); host.Host.AgentAdmin.Add(new NewAgent("scout", "Scout", "modell")); host.Host.AgentAdmin.SetToolEnabled("scout", "Database", enabled: true); host.Host.AgentAdmin.SetToolRights("scout", "Database", ["SELECT", "Insert", "drop"], ["drop"]).Ok.ShouldBeTrue(); var rights = ClawdDotNet.Core.Staging.ToolRights.From( host.Host.Directories.LoadInstanceConfig(host.Host.InstancePath).Agents .Single(a => a.AgentId == "scout").Tools["Database"].AsReadOnly()); rights.Erlaubt.ShouldNotBeNull().OrderBy(x => x).ToArray().ShouldBe(["insert", "select"], "verboten schlaegt erlaubt"); rights.IsForbidden("DROP").ShouldBeTrue(); } [Fact] public void Saving_settings_does_not_delete_the_rights() { // The settings forms write a fresh dictionary without "erlaubt". using var host = new TestHost(TestHost.Agent("scout")); host.Host.AgentAdmin.SetToolEnabled("scout", "Database", enabled: true); host.Host.AgentAdmin.SetToolRights("scout", "Database", ["select"], []); host.Host.AgentAdmin.SetToolSettings("scout", "Database", new Dictionary { ["type"] = "mysql" }); host.Host.AgentAdmin.Find("scout")!.Tools["Database"].ShouldContainKey("erlaubt"); } // ─── #660: prompt history retention ─── [Fact] public void Prompt_history_retention_is_stored_per_agent_and_for_the_instance() { using var host = new TestHost(); host.Host.AgentAdmin.Add(new NewAgent("tresor", "Tresor", "modell")); host.Host.AgentAdmin.SetPromptHistoryRetention("tresor", 0).Ok.ShouldBeTrue(); host.Host.AgentAdmin.SetDefaultPromptHistoryRetention(30).Ok.ShouldBeTrue(); var reloaded = host.Host.Directories.LoadInstanceConfig(host.Host.InstancePath); reloaded.PromptHistory.RetentionDays.ShouldBe(30); reloaded.Agents.Single(a => a.AgentId == "tresor").PromptHistory.RetentionDays.ShouldBe(0); host.Host.AgentAdmin.SetPromptHistoryRetention("tresor", null).Ok.ShouldBeTrue(); host.Host.AgentAdmin.Find("tresor")!.PromptHistory.RetentionDays.ShouldBeNull("back to the instance default"); } [Fact] public void Rights_only_for_assigned_tools() { using var host = new TestHost(TestHost.Agent("scout")); host.Host.AgentAdmin.SetToolRights("scout", "Database", ["select"], []).Ok.ShouldBeFalse(); } [Fact] public void Removing_a_tool_also_removes_its_settings() { using var host = new TestHost(TestHost.Agent("scout")); host.Host.AgentAdmin.SetToolSettings( "scout", "Mail", new Dictionary { ["host"] = "mail.example" }); host.Host.AgentAdmin.SetToolEnabled("scout", "Mail", enabled: false); host.Host.AgentAdmin.Find("scout")!.Tools.ShouldNotContainKey("Mail"); } [Fact] public void Writing_settings_means_wanting_the_tool() { using var host = new TestHost(TestHost.Agent("scout")); host.Host.AgentAdmin.SetToolSettings( "scout", "Mail", new Dictionary { ["host"] = "mail.example" }); host.Host.AgentAdmin.Find("scout")!.Tools.ShouldContainKey("Mail"); } [Fact] public void An_unknown_agent_returns_an_answer_and_no_exception() { using var host = new TestHost(); host.Host.AgentAdmin.Remove("gibtsnicht").Ok.ShouldBeFalse(); host.Host.AgentAdmin.SetToolEnabled("gibtsnicht", "FileRW", true).Ok.ShouldBeFalse(); host.Changes.ShouldBeEmpty(); } } public sealed class JobAdministrationTests { [Fact] public void An_unreadable_schedule_is_rejected() { using var host = new TestHost(TestHost.Agent("scout")); var result = host.Host.JobAdmin.Add(new JobDefinition( "scout", JobKind.AgentWakeup, Cron: "jeden zweiten Dienstag")); result.Ok.ShouldBeFalse(); result.Message.ShouldContain("nicht lesbar"); host.Host.AgentAdmin.Find("scout")!.Scheduler.ShouldBeNull(); } [Fact] public void An_empty_schedule_is_not_an_error() { // Empty means "never" and is a valid statement. Only unreadable is an error. using var host = new TestHost(TestHost.Agent("scout")); host.Host.JobAdmin.Add(new JobDefinition("scout", JobKind.AgentWakeup)).Ok.ShouldBeTrue(); } [Fact] public void A_wakeup_cannot_be_switched_off() { // SchedulerConfig has no switch, and inventing one here would mean that // the scanner would first have to read it. Otherwise a "disabled" // wakeup would happily keep running. using var host = new TestHost(TestHost.Agent("scout")); host.Host.JobAdmin.Add(new JobDefinition("scout", JobKind.AgentWakeup, Cron: "0 * * * *")); var result = host.Host.JobAdmin.SetEnabled( "scout", JobAdministration.WakeupId("scout"), enabled: false); result.Ok.ShouldBeFalse(); result.Message.ShouldContain("Nur Tool-Jobs"); } [Fact] public void A_tool_job_can_be_switched_off_without_losing_it() { using var host = new TestHost(TestHost.Agent("scout")); host.Host.JobAdmin.Add(new JobDefinition( "scout", JobKind.ToolJob, ToolName: "Mail", JobTypeId: "PollJob", Cron: "0 * * * *")); var job = host.Host.JobAdmin.List().Single(); host.Host.JobAdmin.SetEnabled("scout", job.JobId, enabled: false).Ok.ShouldBeTrue(); var after = host.Host.JobAdmin.List().Single(); after.Enabled.ShouldBeFalse(); after.ToolName.ShouldBe("Mail"); } [Fact] public void Without_scanner_a_wakeup_counts_as_not_active() { // It runs as long as the scanner runs — that is the only periodic // driver. Without it, it stands still without anyone having disabled it. using var host = new TestHost(TestHost.Agent("scout")); host.Host.JobAdmin.Add(new JobDefinition("scout", JobKind.AgentWakeup, Cron: "0 * * * *")); host.Host.Scanner.ShouldBeNull(); host.Host.JobAdmin.List().Single().Enabled.ShouldBeFalse(); } [Fact] public void The_next_run_is_calculated_from_the_schedule() { var (next, valid) = JobAdministration.NextRun("0 * * * *"); valid.ShouldBeTrue(); next.ShouldNotBeNull(); next!.Value.ShouldBeGreaterThan(DateTime.UtcNow); } [Fact] public void An_unreadable_schedule_is_distinguishable_from_a_missing_one() { JobAdministration.NextRun("").ShouldBe((null, true)); JobAdministration.NextRun("kaputt").ShouldBe((null, false)); } [Fact] public void A_removed_wakeup_is_gone_and_the_agent_stays() { using var host = new TestHost(TestHost.Agent("scout")); host.Host.JobAdmin.Add(new JobDefinition("scout", JobKind.AgentWakeup, Cron: "0 * * * *")); host.Host.JobAdmin.Remove("scout", JobAdministration.WakeupId("scout"), JobKind.AgentWakeup) .Ok.ShouldBeTrue(); host.Host.AgentAdmin.Find("scout").ShouldNotBeNull(); host.Host.JobAdmin.List().ShouldBeEmpty(); } } public sealed class ServiceAdministrationTests { [Fact] public void The_built_in_services_are_added_exactly_once() { using var host = new TestHost(); host.Host.ServiceAdmin.EnsureBuiltIns(); var afterFirst = host.Host.Instance.Services.Count; host.Host.ServiceAdmin.EnsureBuiltIns(); afterFirst.ShouldBe(BuiltInServices.CreateDefaults().Count); host.Host.Instance.Services.Count.ShouldBe(afterFirst); } [Fact] public void A_disabled_built_in_service_stays_disabled() { using var host = new TestHost(); host.Host.ServiceAdmin.EnsureBuiltIns(); var service = host.Host.Instance.Services.First(s => s.BuiltIn); host.Host.ServiceAdmin.SetEnabled(service.ServiceId, enabled: false); host.Host.ServiceAdmin.EnsureBuiltIns(); host.Host.ServiceAdmin.Find(service.ServiceId)!.Enabled.ShouldBeFalse(); } [Fact] public void A_service_without_name_is_rejected() { using var host = new TestHost(); host.Host.ServiceAdmin.Add(new NewService(" ", "HTTP", 8080)).Ok.ShouldBeFalse(); host.Host.Instance.Services.ShouldBeEmpty(); } }