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.
This commit is contained in:
Mika Kuns
2026-07-27 15:02:51 +02:00
parent c93a20f20c
commit fde9615b34
27 changed files with 1318 additions and 40 deletions
@@ -0,0 +1,91 @@
using ClaudeDo.Data.Models;
namespace ClaudeDo.Data.Tests;
public class ModelPresetsTests
{
[Fact]
public void Defaults_cover_every_known_alias()
{
Assert.Equal(
ModelRegistry.Aliases.OrderBy(a => a),
ModelPresets.Defaults.Select(p => p.Model).OrderBy(m => m));
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("not json at all")]
public void Parse_falls_back_to_defaults_when_unusable(string? json)
{
Assert.Equal(ModelPresets.Defaults, ModelPresets.Parse(json));
}
[Fact]
public void Serialize_roundtrips()
{
var edited = new[] { new ModelPreset("opus", "max", 55) };
var parsed = ModelPresets.Parse(ModelPresets.Serialize(edited));
Assert.Equal("max", ModelPresets.For(parsed, "opus").Effort);
Assert.Equal(55, ModelPresets.For(parsed, "opus").MaxTurns);
}
[Fact]
public void Serialize_fills_missing_aliases_from_defaults()
{
var parsed = ModelPresets.Parse(ModelPresets.Serialize(new[] { new ModelPreset("opus", "max", 55) }));
Assert.Equal(ModelRegistry.Aliases.Count, parsed.Count);
Assert.Equal(ModelPresets.For(ModelPresets.Defaults, "haiku"), ModelPresets.For(parsed, "haiku"));
}
[Theory]
[InlineData("nonsense", 30)] // unknown effort → the default row's effort
[InlineData("high", 0)] // turns out of range → the default row's turns
[InlineData("high", 999)]
public void Serialize_rejects_out_of_range_values(string effort, int turns)
{
var fallback = ModelPresets.For(ModelPresets.Defaults, "sonnet");
var parsed = ModelPresets.Parse(ModelPresets.Serialize(new[] { new ModelPreset("sonnet", effort, turns) }));
var row = ModelPresets.For(parsed, "sonnet");
if (effort == "nonsense") Assert.Equal(fallback.Effort, row.Effort);
if (turns is 0 or 999) Assert.Equal(fallback.MaxTurns, row.MaxTurns);
}
[Fact]
public void For_unknown_model_yields_a_usable_row()
{
var row = ModelPresets.For(ModelPresets.Defaults, "some-future-model");
Assert.Equal("some-future-model", row.Model);
Assert.Contains(row.Effort, EffortRegistry.Levels);
Assert.True(row.MaxTurns > 0);
}
[Fact]
public void For_null_model_uses_the_default_alias()
{
Assert.Equal(
ModelPresets.For(ModelPresets.Defaults, ModelRegistry.DefaultAlias),
ModelPresets.For(ModelPresets.Defaults, null));
}
[Theory]
[InlineData("low", "low")]
[InlineData("XHIGH", "xhigh")]
[InlineData(" max ", "max")]
public void NormalizeLevel_canonicalizes(string input, string expected)
{
Assert.Equal(expected, EffortRegistry.NormalizeLevel(input));
}
[Fact]
public void NormalizeLevel_blank_means_unset_and_unknown_throws()
{
Assert.Null(EffortRegistry.NormalizeLevel(null));
Assert.Throws<ArgumentException>(() => EffortRegistry.NormalizeLevel("gigantic"));
}
}
@@ -26,11 +26,15 @@ public class AgentConfigEditorViewModelTests
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);
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,
@@ -22,6 +22,25 @@ public sealed class ClaudeArgsBuilderTests
Assert.DoesNotContain("--append-system-prompt", args);
Assert.DoesNotContain("--agents", args);
Assert.DoesNotContain("--resume", args);
Assert.DoesNotContain("--effort", args);
}
[Fact]
public void Effort_Adds_Effort_Flag()
{
var args = _builder.Build(new ClaudeRunConfig(null, null, null, null, Effort: "xhigh"));
Assert.Contains("--effort", args);
Assert.Equal("xhigh", args[args.ToList().IndexOf("--effort") + 1]);
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void Blank_Effort_Omits_The_Flag(string? effort)
{
var args = _builder.Build(new ClaudeRunConfig(null, null, null, null, Effort: effort));
Assert.DoesNotContain("--effort", args);
}
[Fact]
@@ -184,7 +184,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
Assert.True(Directory.Exists(wtRow.Path));
Assert.Equal(wtRow.Path, spec.Cwd);
Assert.Equal(new[] { "T" }, spec.Args); // fresh session seeds the task title as the prompt
Assert.Equal(new[] { "T" }, ArgsAfterEffort(spec)); // fresh session seeds the task title as the prompt
Assert.Equal(_claudeStubPath, spec.Exe);
}
@@ -230,7 +230,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
Assert.Equal(_worktreeDir, spec.Cwd);
Assert.Equal(_claudeStubPath, spec.Exe);
Assert.Equal(new[] { "--resume", "sess-123" }, spec.Args);
Assert.Equal(new[] { "--resume", "sess-123" }, ArgsAfterEffort(spec));
Assert.Equal("200000", spec.Env["MCP_TOOL_TIMEOUT"]);
}
@@ -246,7 +246,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
var svc = BuildService();
var spec = await svc.BuildForTaskAsync(taskId, CancellationToken.None);
Assert.Equal(new[] { "--resume", "sess-kept" }, spec.Args);
Assert.Equal(new[] { "--resume", "sess-kept" }, ArgsAfterEffort(spec));
}
[Fact]
@@ -261,7 +261,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
var svc = BuildService();
var spec = await svc.BuildForTaskAsync(taskId, CancellationToken.None);
Assert.Equal(new[] { "T" }, spec.Args); // fresh: seeds the task title as the prompt
Assert.Equal(new[] { "T" }, ArgsAfterEffort(spec)); // fresh: seeds the task title as the prompt
Assert.Equal(_worktreeDir, spec.Cwd);
}
@@ -277,7 +277,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
var svc = BuildService();
var spec = await svc.BuildForTaskAsync(taskId, CancellationToken.None);
Assert.Equal(new[] { "T" }, spec.Args); // fresh: seeds the task title as the prompt
Assert.Equal(new[] { "T" }, ArgsAfterEffort(spec)); // fresh: seeds the task title as the prompt
}
[Fact]
@@ -297,7 +297,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
var svc = BuildService();
var spec = await svc.BuildForTaskAsync(taskId, CancellationToken.None);
Assert.Equal(new[] { "T\n\nDo the thing" }, spec.Args);
Assert.Equal(new[] { "T\n\nDo the thing" }, ArgsAfterEffort(spec));
}
[Fact]
@@ -333,7 +333,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
Assert.Equal(_tempDir, spec.Cwd);
Assert.Equal(_claudeStubPath, spec.Exe);
Assert.Empty(spec.Args);
Assert.Empty(ArgsAfterEffort(spec));
Assert.Equal("200000", spec.Env["MCP_TOOL_TIMEOUT"]);
Assert.Empty(_seeder.Calls);
}
@@ -348,6 +348,17 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
// ── Merge helper ──
/// Every ConPTY spec now leads with `--effort <level>` from the per-model preset (the seeded
/// settings row has no overrides, so the shipped default for the default model applies).
/// Asserts that pair and returns the rest of the args for the test's own comparison.
private static string[] ArgsAfterEffort(LaunchSpec spec)
{
var args = spec.Args.ToList();
Assert.Equal("--effort", args[0]);
Assert.Equal(ModelPresets.For(ModelPresets.Defaults, ModelRegistry.DefaultAlias).Effort, args[1]);
return args.Skip(2).ToArray();
}
private readonly List<string> _mergeHelperSessionDirs = new();
/// The session dir is the value right after --add-dir; register it for cleanup
@@ -498,11 +509,12 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
var spec = BuildService().BuildPlanningResume(ctx);
Assert.Equal("--permission-mode", spec.Args[0]);
Assert.Equal("default", spec.Args[1]);
Assert.Equal("--allowedTools", spec.Args[2]);
Assert.Contains("mcp__claudedo__", spec.Args[3]);
Assert.Equal(new[] { "--resume", "sess-42" }, spec.Args.Skip(4).ToArray());
var args = ArgsAfterEffort(spec);
Assert.Equal("--permission-mode", args[0]);
Assert.Equal("default", args[1]);
Assert.Equal("--allowedTools", args[2]);
Assert.Contains("mcp__claudedo__", args[3]);
Assert.Equal(new[] { "--resume", "sess-42" }, args.Skip(4).ToArray());
Assert.Equal("tok-2", spec.Env["CLAUDEDO_PLANNING_TOKEN"]);
Assert.Equal(_worktreeDir, spec.Cwd);
}