Files
ClaudeDo/tests/ClaudeDo.Ui.Tests/ViewModels/AgentConfigEditorViewModelTests.cs
T
Mika Kuns fde9615b34 feat(settings): per-model effort and turn presets
ClaudeDo never passed --effort, so every session inherited whatever effortLevel
the user's Claude Code config happened to carry. Settings -> General now holds one
row per model alias (haiku medium/20, sonnet high/30, opus high/40, fable high/25)
supplying the global effort and turn defaults; list- and task-level max-turns
overrides still win, and the agent editor's inherited badge follows the model.

--effort is applied to autonomous runs and to every ConPTY spec (task session,
planning start/resume, ad-hoc, list handler). The model itself is deliberately not
forced on interactive sessions. The single global 'Max turns' field is replaced by
the table, and 'fable' joins ModelRegistry.Aliases.

The migration also adds the is_manual columns used by the next commit.
2026-07-27 15:02:51 +02:00

294 lines
12 KiB
C#

using ClaudeDo.Data.Models;
using ClaudeDo.Ui.Localization;
using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.ViewModels.Agent;
using Xunit;
using TaskEntity = ClaudeDo.Data.Models.TaskEntity;
namespace ClaudeDo.Ui.Tests.ViewModels;
public class AgentConfigEditorViewModelTests
{
private sealed class FakeWorker : StubWorkerClient
{
public AppSettingsDto? App;
public ListConfigDto? ListCfg;
public List<AgentInfo> AgentList = new();
public List<SessionSkillDto> InstalledSkills = new();
public UpdateListConfigDto? SavedListConfig;
public UpdateTaskAgentSettingsDto? SavedTaskSettings;
public override Task<AppSettingsDto?> GetAppSettingsAsync() => Task.FromResult(App);
public override Task<ListConfigDto?> GetListConfigAsync(string listId) => Task.FromResult(ListCfg);
public override Task<List<AgentInfo>> GetAgentsAsync() => Task.FromResult(AgentList);
public override Task<List<SessionSkillDto>> GetSessionSkillsAsync() => Task.FromResult(InstalledSkills);
public override Task UpdateListConfigAsync(UpdateListConfigDto dto) { SavedListConfig = dto; return Task.CompletedTask; }
public override Task UpdateTaskAgentSettingsAsync(UpdateTaskAgentSettingsDto dto) { SavedTaskSettings = dto; return Task.CompletedTask; }
}
// The global turn default is per-model now, so the preset row for `model` is what carries
// `turns` — DefaultMaxTurns is no longer consulted.
private static AppSettingsDto AppWith(string model, int turns) =>
new(DefaultClaudeInstructions: "", DefaultModel: model, DefaultMaxTurns: turns,
DefaultPermissionMode: "auto", MaxParallelExecutions: 1, WorktreeStrategy: "sibling",
CentralWorktreeRoot: null, WorktreeAutoCleanupEnabled: false, WorktreeAutoCleanupDays: 30,
ReportExcludedPaths: null, StandupWeekday: 3, DailyPrepMaxTasks: 5,
SessionSkills: null,
ModelPresets: new List<ModelPresetDto> { new(model, "high", turns) });
private static TaskEntity TaskWith(string? model, int? turns, string? sp, string? agentPath) =>
new() { Id = "t1", ListId = "l1", Title = "t", CreatedAt = DateTime.UtcNow,
Model = model, MaxTurns = turns, SystemPrompt = sp, AgentPath = agentPath };
private static string Override => Loc.T("settings.inherit.overrideBadge");
private static string FromList => Loc.T("settings.inherit.inheritedFromList");
private static string FromGlobal => Loc.T("settings.inherit.inheritedFromGlobal");
// ── List scope ──────────────────────────────────────────────────────────
[Fact]
public async Task List_loads_config_and_global_defaults()
{
var w = new FakeWorker { App = AppWith("haiku", 50), ListCfg = new ListConfigDto("opus", "sp", null, 80) };
var vm = new AgentConfigEditorViewModel(w, AgentConfigScope.List);
await vm.LoadForListAsync("l1");
Assert.Equal("opus", vm.Model);
Assert.Equal(80m, vm.MaxTurns);
Assert.Equal("sp", vm.SystemPrompt);
Assert.Equal(Override, vm.ModelBadge);
}
[Fact]
public async Task List_unset_model_inherits_from_global_only()
{
var w = new FakeWorker { App = AppWith("haiku", 50), ListCfg = new ListConfigDto(null, null, null, null) };
var vm = new AgentConfigEditorViewModel(w, AgentConfigScope.List);
await vm.LoadForListAsync("l1");
Assert.Null(vm.Model);
Assert.Equal(FromGlobal, vm.ModelBadge); // never "from list" at list scope
Assert.Equal("haiku", vm.ModelInheritedHint);
Assert.Equal(FromGlobal, vm.TurnsBadge);
Assert.Equal("50", vm.TurnsInheritedHint);
Assert.Equal("", vm.EffectiveSystemPromptHint); // list scope shows no inherited prompt
}
[Fact]
public async Task List_reset_clears_to_inherited()
{
var w = new FakeWorker { App = AppWith("haiku", 50), ListCfg = new ListConfigDto("opus", "sp", null, 80) };
var vm = new AgentConfigEditorViewModel(w, AgentConfigScope.List);
await vm.LoadForListAsync("l1");
vm.ResetModelCommand.Execute(null);
vm.ResetTurnsCommand.Execute(null);
Assert.Null(vm.Model);
Assert.Null(vm.MaxTurns);
Assert.Equal(FromGlobal, vm.ModelBadge);
}
[Fact]
public async Task List_save_builds_list_config_dto_and_does_not_auto_save()
{
var w = new FakeWorker { App = AppWith("haiku", 50), ListCfg = new ListConfigDto("opus", "sp", null, 80) };
var vm = new AgentConfigEditorViewModel(w, AgentConfigScope.List);
await vm.LoadForListAsync("l1");
vm.Model = "sonnet";
Assert.Null(w.SavedListConfig); // list scope never auto-saves
await vm.SaveAsync();
Assert.NotNull(w.SavedListConfig);
Assert.Equal("l1", w.SavedListConfig!.ListId);
Assert.Equal("sonnet", w.SavedListConfig.Model);
Assert.Equal("sp", w.SavedListConfig.SystemPrompt);
Assert.Null(w.SavedListConfig.AgentPath);
Assert.Equal(80, w.SavedListConfig.MaxTurns);
}
// ── Task scope ──────────────────────────────────────────────────────────
[Fact]
public async Task Task_override_model_shows_override_badge()
{
var w = new FakeWorker { App = AppWith("haiku", 50), ListCfg = new ListConfigDto("opus", "list-sp", "/x/list-agent.md", 80) };
var vm = new AgentConfigEditorViewModel(w, AgentConfigScope.Task);
await vm.LoadForTaskAsync(TaskWith("sonnet", null, "", null));
Assert.Equal("sonnet", vm.Model);
Assert.Equal(Override, vm.ModelBadge);
}
[Fact]
public async Task Task_unset_falls_through_to_list_then_global()
{
var w = new FakeWorker { App = AppWith("haiku", 50), ListCfg = new ListConfigDto("opus", "list-sp", null, 80) };
var vm = new AgentConfigEditorViewModel(w, AgentConfigScope.Task);
await vm.LoadForTaskAsync(TaskWith(null, null, "", null));
// model + turns inherit from the LIST tier
Assert.Equal(FromList, vm.ModelBadge);
Assert.Equal("opus", vm.ModelInheritedHint);
Assert.Equal(FromList, vm.TurnsBadge);
Assert.Equal("80", vm.TurnsInheritedHint);
Assert.Equal(80, vm.EffectiveMaxTurns);
Assert.Equal("list-sp", vm.EffectiveSystemPromptHint);
}
[Fact]
public async Task Task_unset_with_no_list_tier_inherits_global()
{
var w = new FakeWorker { App = AppWith("haiku", 50), ListCfg = new ListConfigDto(null, null, null, null) };
var vm = new AgentConfigEditorViewModel(w, AgentConfigScope.Task);
await vm.LoadForTaskAsync(TaskWith(null, null, "", null));
Assert.Equal(FromGlobal, vm.ModelBadge);
Assert.Equal("haiku", vm.ModelInheritedHint);
Assert.Equal(50, vm.EffectiveMaxTurns);
Assert.Equal("", vm.EffectiveSystemPromptHint);
}
[Fact]
public async Task Task_save_builds_task_agent_settings_dto()
{
var w = new FakeWorker { App = AppWith("haiku", 50), ListCfg = new ListConfigDto(null, null, null, null) };
var vm = new AgentConfigEditorViewModel(w, AgentConfigScope.Task);
await vm.LoadForTaskAsync(TaskWith("opus", 120, "task-sp", null));
await vm.SaveAsync();
Assert.NotNull(w.SavedTaskSettings);
Assert.Equal("t1", w.SavedTaskSettings!.TaskId);
Assert.Equal("opus", w.SavedTaskSettings.Model);
Assert.Equal("task-sp", w.SavedTaskSettings.SystemPrompt);
Assert.Null(w.SavedTaskSettings.AgentPath);
Assert.Equal(120, w.SavedTaskSettings.MaxTurns);
}
[Fact]
public async Task Clear_resets_state_and_target()
{
var w = new FakeWorker { App = AppWith("haiku", 50), ListCfg = new ListConfigDto(null, null, null, null) };
var vm = new AgentConfigEditorViewModel(w, AgentConfigScope.Task);
await vm.LoadForTaskAsync(TaskWith("opus", 120, "task-sp", null));
vm.Clear();
Assert.Null(vm.Model);
Assert.Null(vm.MaxTurns);
Assert.Equal("", vm.SystemPrompt);
Assert.Equal("", vm.EffectiveSystemPromptHint);
// After Clear, SaveAsync is a no-op (no target).
w.SavedTaskSettings = null;
await vm.SaveAsync();
Assert.Null(w.SavedTaskSettings);
}
// ── Session skills ──────────────────────────────────────────────────────
private static SessionSkillDto Skill(string name) => new(name, "desc-" + name, "url-" + name, "ref", DateTimeOffset.UtcNow);
[Fact]
public async Task List_load_reflects_selection_from_list_config()
{
var w = new FakeWorker
{
App = AppWith("haiku", 50),
ListCfg = new ListConfigDto(null, null, null, null, new List<string> { "skill-b" }),
InstalledSkills = new() { Skill("skill-a"), Skill("skill-b") },
};
var vm = new AgentConfigEditorViewModel(w, AgentConfigScope.List);
await vm.LoadForListAsync("l1");
Assert.Equal(2, vm.SessionSkills.Count);
Assert.False(vm.SessionSkills.Single(s => s.Name == "skill-a").IsSelected);
Assert.True(vm.SessionSkills.Single(s => s.Name == "skill-b").IsSelected);
}
[Fact]
public async Task List_save_includes_selected_skill_names()
{
var w = new FakeWorker
{
App = AppWith("haiku", 50),
ListCfg = new ListConfigDto(null, null, null, null, null),
InstalledSkills = new() { Skill("skill-a"), Skill("skill-b") },
};
var vm = new AgentConfigEditorViewModel(w, AgentConfigScope.List);
await vm.LoadForListAsync("l1");
vm.SessionSkills.Single(s => s.Name == "skill-a").IsSelected = true;
await vm.SaveAsync();
Assert.NotNull(w.SavedListConfig);
Assert.Equal(new List<string> { "skill-a" }, w.SavedListConfig!.SessionSkills);
}
[Fact]
public async Task Task_load_parses_session_skills_json_and_reflects_selection()
{
var w = new FakeWorker
{
App = AppWith("haiku", 50),
ListCfg = new ListConfigDto(null, null, null, null),
InstalledSkills = new() { Skill("skill-a"), Skill("skill-b") },
};
var vm = new AgentConfigEditorViewModel(w, AgentConfigScope.Task);
var entity = TaskWith(null, null, "", null);
entity.SessionSkills = "[\"skill-a\"]";
await vm.LoadForTaskAsync(entity);
Assert.True(vm.SessionSkills.Single(s => s.Name == "skill-a").IsSelected);
Assert.False(vm.SessionSkills.Single(s => s.Name == "skill-b").IsSelected);
}
[Fact]
public async Task Task_toggling_a_skill_auto_saves_selection()
{
var w = new FakeWorker
{
App = AppWith("haiku", 50),
ListCfg = new ListConfigDto(null, null, null, null),
InstalledSkills = new() { Skill("skill-a") },
};
var vm = new AgentConfigEditorViewModel(w, AgentConfigScope.Task);
await vm.LoadForTaskAsync(TaskWith(null, null, "", null));
vm.SessionSkills.Single(s => s.Name == "skill-a").IsSelected = true;
await Task.Delay(500);
Assert.NotNull(w.SavedTaskSettings);
Assert.Equal(new List<string> { "skill-a" }, w.SavedTaskSettings!.SessionSkills);
}
[Fact]
public async Task Clear_deselects_all_skills()
{
var w = new FakeWorker
{
App = AppWith("haiku", 50),
ListCfg = new ListConfigDto(null, null, null, null),
InstalledSkills = new() { Skill("skill-a") },
};
var vm = new AgentConfigEditorViewModel(w, AgentConfigScope.Task);
var entity = TaskWith(null, null, "", null);
entity.SessionSkills = "[\"skill-a\"]";
await vm.LoadForTaskAsync(entity);
vm.Clear();
Assert.All(vm.SessionSkills, s => Assert.False(s.IsSelected));
}
}