feat(worker): add get_effective_run_config MCP tool

Adds a read-only get_effective_run_config(taskId) tool that reports the
model/max-turns/effort/permission-mode/agent-path/system-prompt/skills a
task will actually run with, each tagged with its source (task/list/
preset/global), plus max-turns' raw requested value and clamp status.

Extracted the model/max-turns/agent-path resolution out of
TaskRunner.ResolveConfigAsync into EffectiveRunConfigResolver so the run
path and the new reporting tool share one codepath instead of risking
drift, per docs/explore-notes/worker-task-pipeline.md's max-turns trap.
This commit is contained in:
mika kuns
2026-08-05 20:39:35 +02:00
parent bdee731376
commit a768bc4163
9 changed files with 383 additions and 20 deletions
+12 -1
View File
@@ -51,7 +51,7 @@ Daily prep: `GetDailyPrepCandidates`, `SetMyDay`.
|---|---|
| `BatchMcpTools` | `BatchGetTasks`, `BatchAddTasks`, `BatchUpdateTaskStatus`, `BatchCancelTasks`, `BatchDeleteTasks`, `BatchSetMyDay`, `BatchCleanupTaskWorktrees` |
| `ListMcpTools` | `CreateList`, `UpdateList`, `DeleteList` |
| `ConfigMcpTools` | `GetListConfig`, `SetListConfig`, `GetTaskConfig`, `SetTaskConfig` |
| `ConfigMcpTools` | `GetListConfig`, `SetListConfig`, `GetTaskConfig`, `SetTaskConfig`, `GetEffectiveRunConfig` |
| `RunHistoryMcpTools` | `ListRuns`, `GetRun`, `GetTaskLog` |
| `AgentMcpTools` | `ListAgents` |
| `LifecycleMcpTools` | `ResetFailedTask` |
@@ -127,6 +127,17 @@ helper in the same file.
**`SetMyDay`** — sets `IsMyDay` (+ optional `SortOrder`). A server-side cap-guard rejects
turning on MyDay beyond `DailyPrepMaxTasks` open (Idle) MyDay tasks.
**`GetEffectiveRunConfig`** — read-only report of what a task will *actually* run with (model,
max turns, effort, permission mode, agent path, whether a system prompt is set, skill names),
each with its source (`task`/`list`/`preset`/`global`); max turns additionally reports the raw
requested value and whether it was clamped to `AppSettings.MaxTurnsCeiling`. Unlike
`GetAppSettings`/`GetTaskConfig` (raw, possibly-unused config values), this goes through the same
`EffectiveRunConfigResolver.Resolve` that `TaskRunner` itself runs with — see
[worker-task-pipeline](./worker-task-pipeline.md)'s model/effort/max-turns section — so it can't
drift from the real run. Reads (not writes) `AppSettingsRepository.GetAsync`, which backfills
`model_presets` on first read after a null column; that backfill is pre-existing shared behavior,
not a new side effect introduced by this tool.
## Model / max-turns on task creation
Task-generating tools (`AddTask`, planning `CreateChildTask`, `SuggestImprovement`) accept an
+8 -1
View File
@@ -55,7 +55,14 @@ How a task moves Queued → Running → terminal, across `src/ClaudeDo.Worker`
## Model, effort & max-turns resolution
*(section added and verified at commit `f6cb825`, 2026-08-05)*
*(section added at commit `f6cb825`, 2026-08-05; resolver extraction added same day)*
The resolution below lives in `Runner/EffectiveRunConfigResolver.Resolve` (not inlined in
`TaskRunner` anymore) so `TaskRunner.ResolveConfigAsync` and the read-only
`get_effective_run_config` MCP tool (`External/ConfigMcpTools.cs`) share one codepath and can't
report different numbers for the same task. The tool additionally surfaces, per field, whether
it came from the task/list/preset/global layer, and — for max turns — the raw requested value
plus whether it was clamped.
Step 6 builds the CLI args. Model and turn budget resolve like this:
+66 -1
View File
@@ -1,7 +1,10 @@
using System.ComponentModel;
using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Hub;
using ClaudeDo.Worker.Runner;
using Microsoft.EntityFrameworkCore;
using ModelContextProtocol.Server;
namespace ClaudeDo.Worker.External;
@@ -11,18 +14,36 @@ public sealed record TaskConfigResult(bool Found, TaskConfigDto? Config);
public sealed record SetListConfigResult(bool Ok, string ListId, TaskConfigDto? Config);
public sealed record SetTaskConfigResult(bool Ok, string TaskId, TaskConfigDto? Config);
public sealed record EffectiveModelDto(string Value, string Source);
public sealed record EffectiveMaxTurnsDto(int Effective, string Source, int Requested, bool Clamped);
public sealed record EffectiveAgentPathDto(string? Value, string? Source);
public sealed record EffectiveSystemPromptDto(bool Set, IReadOnlyList<string> Sources);
public sealed record EffectiveRunConfigDto(
string TaskId,
EffectiveModelDto Model,
EffectiveMaxTurnsDto MaxTurns,
string Effort,
string PermissionMode,
EffectiveAgentPathDto AgentPath,
EffectiveSystemPromptDto SystemPrompt,
IReadOnlyList<string> SkillNames);
[McpServerToolType]
public sealed class ConfigMcpTools
{
private readonly ListRepository _lists;
private readonly TaskRepository _tasks;
private readonly HubBroadcaster _broadcaster;
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
public ConfigMcpTools(ListRepository lists, TaskRepository tasks, HubBroadcaster broadcaster)
public ConfigMcpTools(
ListRepository lists, TaskRepository tasks, HubBroadcaster broadcaster,
IDbContextFactory<ClaudeDoDbContext> dbFactory)
{
_lists = lists;
_tasks = tasks;
_broadcaster = broadcaster;
_dbFactory = dbFactory;
}
[McpServerTool, Description("Get a list's default config (model, system prompt, agent path). Returns { found: false, config: null } if no config is set.")]
@@ -97,4 +118,48 @@ public sealed class ConfigMcpTools
return new TaskConfigResult(false, null);
return new TaskConfigResult(true, new TaskConfigDto(task.Model, task.SystemPrompt, task.AgentPath, task.MaxTurns));
}
[McpServerTool, Description(
"Get the config a task will ACTUALLY run with — model, max turns, effort, permission mode, agent path, " +
"whether a system prompt is set, and skill names — with each field's source (task/list/preset/global). " +
"Uses the exact same resolution TaskRunner runs with, so this never drifts from get_app_settings/" +
"get_task_config's raw, possibly-unused values. maxTurns also reports the raw requested value and " +
"whether it was clamped to the global ceiling. Read-only, no side effects.")]
public async Task<EffectiveRunConfigDto> GetEffectiveRunConfig(string taskId, CancellationToken cancellationToken)
{
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
?? throw new InvalidOperationException($"Task {taskId} not found.");
var listConfig = await _lists.GetConfigAsync(task.ListId, cancellationToken);
AppSettingsEntity global;
using (var ctx = await _dbFactory.CreateDbContextAsync(cancellationToken))
global = await new AppSettingsRepository(ctx).GetAsync(cancellationToken);
var systemFile = PromptFiles.ReadOrDefault(PromptKind.System);
var isImprovementChild = task.ParentTaskId is not null && task.CreatedBy == task.ParentTaskId;
var improvementPrompt = isImprovementChild ? PromptFiles.ReadOrDefault(PromptKind.ImprovementChild) : null;
var effective = EffectiveRunConfigResolver.Resolve(task, listConfig, global, systemFile, improvementPrompt);
var requestedSkills = TaskRunner.UnionSkillNames(task.SessionSkills, listConfig?.SessionSkills, global.SessionSkills);
var skillNames = requestedSkills;
if (requestedSkills.Count > 0)
{
List<SessionSkillEntity> installed;
using (var ctx = await _dbFactory.CreateDbContextAsync(cancellationToken))
installed = (await new SessionSkillRepository(ctx).ListAsync(cancellationToken)).ToList();
var installedNames = installed.Select(s => s.Name).ToHashSet(StringComparer.Ordinal);
skillNames = TaskRunner.FilterToInstalled(requestedSkills, installedNames);
}
return new EffectiveRunConfigDto(
taskId,
new EffectiveModelDto(effective.Model, effective.ModelSource),
new EffectiveMaxTurnsDto(effective.MaxTurns, effective.MaxTurnsSource, effective.RequestedMaxTurns, effective.MaxTurnsClamped),
effective.Effort,
effective.PermissionMode,
new EffectiveAgentPathDto(effective.AgentPath, effective.AgentPathSource),
new EffectiveSystemPromptDto(effective.SystemPromptSet, effective.SystemPromptSources),
skillNames);
}
}
@@ -0,0 +1,53 @@
using ClaudeDo.Data.Models;
namespace ClaudeDo.Worker.Runner;
/// <summary>The task/list/preset/global resolution <see cref="TaskRunner"/> actually runs with,
/// plus where each value came from — so a caller can tell a genuine default from an override
/// without re-deriving the resolution order itself.</summary>
public sealed record EffectiveRunConfig(
string Model, string ModelSource,
int MaxTurns, string MaxTurnsSource, int RequestedMaxTurns, bool MaxTurnsClamped,
string Effort,
string? AgentPath, string? AgentPathSource,
string PermissionMode,
bool SystemPromptSet, IReadOnlyList<string> SystemPromptSources);
/// <summary>Single source of truth for "which value wins" — shared by <see cref="TaskRunner"/>'s
/// actual run path and any read-only reporting of the same resolution (e.g. the
/// <c>get_effective_run_config</c> MCP tool), so the two can never drift apart.</summary>
public static class EffectiveRunConfigResolver
{
public static EffectiveRunConfig Resolve(
TaskEntity task, ListConfigEntity? listConfig, AppSettingsEntity global,
string? systemFile, string? improvementPrompt)
{
var model = task.Model ?? listConfig?.Model ?? global.DefaultModel;
var modelSource = task.Model is not null ? "task" : listConfig?.Model is not null ? "list" : "global";
var preset = ModelPresets.For(global.ModelPresets, model, global.DefaultMaxTurns);
var requestedMaxTurns = task.MaxTurns ?? listConfig?.MaxTurns ?? preset.MaxTurns;
var maxTurnsSource = task.MaxTurns is not null ? "task" : listConfig?.MaxTurns is not null ? "list" : "preset";
// ResolveMaxTurns is declared int? but always returns a value (Math.Min of non-null inputs).
var maxTurns = TaskRunner.ResolveMaxTurns(task.MaxTurns, listConfig?.MaxTurns, preset.MaxTurns, global.MaxTurnsCeiling)!.Value;
var agentPath = task.AgentPath ?? listConfig?.AgentPath;
var agentPathSource = task.AgentPath is not null ? "task" : listConfig?.AgentPath is not null ? "list" : null;
var systemPromptSources = new List<string>();
if (!string.IsNullOrWhiteSpace(systemFile)) systemPromptSources.Add("systemFile");
if (!string.IsNullOrWhiteSpace(improvementPrompt)) systemPromptSources.Add("improvementPrompt");
if (!string.IsNullOrWhiteSpace(global.DefaultClaudeInstructions)) systemPromptSources.Add("global");
if (!string.IsNullOrWhiteSpace(listConfig?.SystemPrompt)) systemPromptSources.Add("list");
if (!string.IsNullOrWhiteSpace(task.SystemPrompt)) systemPromptSources.Add("task");
return new EffectiveRunConfig(
model, modelSource,
maxTurns, maxTurnsSource, requestedMaxTurns, maxTurns < requestedMaxTurns,
preset.Effort,
agentPath, agentPathSource,
global.DefaultPermissionMode,
systemPromptSources.Count > 0, systemPromptSources);
}
}
+17 -15
View File
@@ -559,29 +559,25 @@ public sealed class TaskRunner
var requestedSkills = UnionSkillNames(task.SessionSkills, listConfig?.SessionSkills, global.SessionSkills);
var skillNames = await FilterToInstalledSkillsAsync(task.Id, requestedSkills, ct);
// The model decides the global effort/turn defaults: one preset row per model alias
// (Settings → General). List- and task-level max-turns overrides still win.
var model = task.Model ?? listConfig?.Model ?? global.DefaultModel;
var preset = Data.Models.ModelPresets.For(global.ModelPresets, model, global.DefaultMaxTurns);
var requestedMaxTurns = task.MaxTurns ?? listConfig?.MaxTurns ?? preset.MaxTurns;
var maxTurns = ResolveMaxTurns(task.MaxTurns, listConfig?.MaxTurns, preset.MaxTurns, global.MaxTurnsCeiling);
if (maxTurns < requestedMaxTurns)
// Model/max-turns/effort/agent-path resolution is shared with get_effective_run_config
// so the two can never report different numbers for the same task.
var effective = EffectiveRunConfigResolver.Resolve(task, listConfig, global, systemFile, improvementPrompt);
if (effective.MaxTurnsClamped)
{
_logger.LogWarning(
"Task {TaskId}: max turns clamped to ceiling (requested={Requested}, effective={Effective}, ceiling={Ceiling})",
task.Id, requestedMaxTurns, maxTurns, global.MaxTurnsCeiling);
task.Id, effective.RequestedMaxTurns, effective.MaxTurns, global.MaxTurnsCeiling);
}
return new ClaudeRunConfig(
Model: model,
Model: effective.Model,
SystemPrompt: string.IsNullOrWhiteSpace(instructions) ? null : instructions,
AgentPath: task.AgentPath ?? listConfig?.AgentPath,
AgentPath: effective.AgentPath,
ResumeSessionId: resumeSessionId,
MaxTurns: maxTurns,
PermissionMode: global.DefaultPermissionMode,
MaxTurns: effective.MaxTurns,
PermissionMode: effective.PermissionMode,
SkillNames: skillNames,
Effort: preset.Effort);
Effort: effective.Effort);
}
private async Task<IReadOnlyList<string>> FilterToInstalledSkillsAsync(
@@ -597,7 +593,7 @@ public sealed class TaskRunner
}
var installedNames = installed.Select(s => s.Name).ToHashSet(StringComparer.Ordinal);
var resolved = requestedSkills.Where(installedNames.Contains).ToList();
var resolved = FilterToInstalled(requestedSkills, installedNames);
var dropped = requestedSkills.Where(n => !installedNames.Contains(n)).ToList();
if (dropped.Count > 0)
{
@@ -609,6 +605,12 @@ public sealed class TaskRunner
return resolved;
}
/// <summary>Shared with get_effective_run_config so reported skill names match what a run
/// would actually filter down to.</summary>
internal static IReadOnlyList<string> FilterToInstalled(
IReadOnlyList<string> requestedSkills, IReadOnlySet<string> installedNames)
=> requestedSkills.Where(installedNames.Contains).ToList();
internal static IReadOnlyList<string> UnionSkillNames(params string?[] jsonArrays)
{
var names = new List<string>();
@@ -20,7 +20,7 @@ public sealed class ConfigMcpToolsTests : IDisposable
_ctx = _db.CreateContext();
_lists = new ListRepository(_ctx);
_tasks = new TaskRepository(_ctx);
_sut = new ConfigMcpTools(_lists, _tasks, new HubBroadcaster(new CapturingHubContext()));
_sut = new ConfigMcpTools(_lists, _tasks, new HubBroadcaster(new CapturingHubContext()), _db.CreateFactory());
}
public void Dispose() { _ctx.Dispose(); _db.Dispose(); }
@@ -0,0 +1,144 @@
using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.External;
using ClaudeDo.Worker.Hub;
using ClaudeDo.Worker.Tests.Infrastructure;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Worker.Tests.External;
/// <summary>
/// get_effective_run_config must report exactly what TaskRunner will actually run with, plus
/// where each value came from — task/list/preset/global — so a caller can tell a real default
/// from an override before queuing a run. See docs task "MCP: get_effective_run_config".
/// </summary>
public sealed class EffectiveRunConfigTests : IDisposable
{
private readonly DbFixture _db = new();
private readonly ClaudeDoDbContext _ctx;
private readonly ListRepository _lists;
private readonly TaskRepository _tasks;
private readonly ConfigMcpTools _sut;
public EffectiveRunConfigTests()
{
_ctx = _db.CreateContext();
_lists = new ListRepository(_ctx);
_tasks = new TaskRepository(_ctx);
_sut = new ConfigMcpTools(_lists, _tasks, new HubBroadcaster(new CapturingHubContext()), _db.CreateFactory());
}
public void Dispose() { _ctx.Dispose(); _db.Dispose(); }
private async Task<string> SeedListAsync()
{
var id = Guid.NewGuid().ToString();
await _lists.AddAsync(new ListEntity { Id = id, Name = "L", CreatedAt = DateTime.UtcNow });
return id;
}
private async Task<TaskEntity> SeedTaskAsync(string listId, Action<TaskEntity>? configure = null)
{
var task = new TaskEntity
{
Id = Guid.NewGuid().ToString(),
ListId = listId,
Title = "t",
Status = TaskStatus.Idle,
CreatedAt = DateTime.UtcNow,
CommitType = "chore",
};
configure?.Invoke(task);
await _tasks.AddAsync(task);
return task;
}
[Fact]
public async Task No_overrides_reports_preset_and_global_sources()
{
var listId = await SeedListAsync();
var task = await SeedTaskAsync(listId);
var result = await _sut.GetEffectiveRunConfig(task.Id, CancellationToken.None);
Assert.Equal("sonnet", result.Model.Value);
Assert.Equal("global", result.Model.Source);
Assert.Equal("preset", result.MaxTurns.Source);
Assert.Equal(30, result.MaxTurns.Effective);
Assert.Equal(30, result.MaxTurns.Requested);
Assert.False(result.MaxTurns.Clamped);
Assert.Equal("high", result.Effort);
Assert.Equal("auto", result.PermissionMode);
Assert.Null(result.AgentPath.Value);
Assert.Null(result.AgentPath.Source);
Assert.Empty(result.SkillNames);
}
[Fact]
public async Task Task_override_beats_list_override_beats_preset()
{
var listId = await SeedListAsync();
await _sut.SetListConfig(listId, "opus", null, "list-agent.md", 50, CancellationToken.None);
var task = await SeedTaskAsync(listId, t => { t.Model = "haiku"; t.MaxTurns = 12; });
var result = await _sut.GetEffectiveRunConfig(task.Id, CancellationToken.None);
Assert.Equal("haiku", result.Model.Value);
Assert.Equal("task", result.Model.Source);
Assert.Equal(12, result.MaxTurns.Effective);
Assert.Equal("task", result.MaxTurns.Source);
Assert.Equal("list-agent.md", result.AgentPath.Value);
Assert.Equal("list", result.AgentPath.Source);
}
[Fact]
public async Task List_override_wins_when_no_task_override()
{
var listId = await SeedListAsync();
await _sut.SetListConfig(listId, "opus", null, null, 50, CancellationToken.None);
var task = await SeedTaskAsync(listId);
var result = await _sut.GetEffectiveRunConfig(task.Id, CancellationToken.None);
Assert.Equal("opus", result.Model.Value);
Assert.Equal("list", result.Model.Source);
Assert.Equal(50, result.MaxTurns.Effective);
Assert.Equal("list", result.MaxTurns.Source);
}
[Fact]
public async Task Requested_max_turns_above_ceiling_is_clamped_and_both_values_reported()
{
var listId = await SeedListAsync();
var task = await SeedTaskAsync(listId, t => t.MaxTurns = 999);
var result = await _sut.GetEffectiveRunConfig(task.Id, CancellationToken.None);
Assert.Equal(999, result.MaxTurns.Requested);
Assert.Equal(80, result.MaxTurns.Effective); // AppSettingsEntity.MaxTurnsCeiling default
Assert.True(result.MaxTurns.Clamped);
Assert.Equal("task", result.MaxTurns.Source);
}
[Fact]
public async Task System_prompt_reports_set_and_contributing_layers()
{
var listId = await SeedListAsync();
var task = await SeedTaskAsync(listId, t => t.SystemPrompt = "be terse");
var result = await _sut.GetEffectiveRunConfig(task.Id, CancellationToken.None);
Assert.True(result.SystemPrompt.Set);
Assert.Contains("task", result.SystemPrompt.Sources);
}
[Fact]
public async Task Unknown_task_throws()
{
await Assert.ThrowsAsync<InvalidOperationException>(
() => _sut.GetEffectiveRunConfig("nope", CancellationToken.None));
}
}
@@ -848,7 +848,7 @@ public sealed class ExternalMcpServiceTests : IDisposable
// ── GetTaskConfig ─────────────────────────────────────────────────────────
private ConfigMcpTools BuildConfigSut() => new(_lists, _tasks, _broadcaster);
private ConfigMcpTools BuildConfigSut() => new(_lists, _tasks, _broadcaster, _db.CreateFactory());
[Fact]
public async Task GetTaskConfig_NotFound_Throws()
@@ -0,0 +1,81 @@
using ClaudeDo.Data;
using ClaudeDo.Data.Git;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Config;
using ClaudeDo.Worker.External;
using ClaudeDo.Worker.Hub;
using ClaudeDo.Worker.Runner;
using ClaudeDo.Worker.Tests.Infrastructure;
using Microsoft.Extensions.Logging.Abstractions;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
using Xunit;
namespace ClaudeDo.Worker.Tests.Runner;
/// <summary>
/// get_effective_run_config (ConfigMcpTools) must report exactly what TaskRunner actually
/// dispatches with — both go through EffectiveRunConfigResolver.Resolve, but this test proves
/// it end to end via TaskRunner's real CLI args instead of trusting the shared code path alone.
/// </summary>
public sealed class EffectiveRunConfigParityTests : IDisposable
{
private readonly DbFixture _db = new();
private readonly string _tempDir;
private readonly WorkerConfig _cfg;
public EffectiveRunConfigParityTests()
{
_tempDir = Path.Combine(Path.GetTempPath(), $"cd_effparity_{Guid.NewGuid():N}");
Directory.CreateDirectory(_tempDir);
_cfg = new WorkerConfig { SandboxRoot = _tempDir, LogRoot = _tempDir };
}
public void Dispose() { _db.Dispose(); try { Directory.Delete(_tempDir, true); } catch { } }
[Fact]
public async Task Reported_model_and_max_turns_match_what_TaskRunner_dispatches_with()
{
var dbFactory = _db.CreateFactory();
using (var ctx = _db.CreateContext())
{
ctx.Lists.Add(new ListEntity { Id = "l1", Name = "L", WorkingDir = null, CreatedAt = DateTime.UtcNow });
ctx.ListConfigs.Add(new ListConfigEntity { ListId = "l1", Model = "opus", MaxTurns = 999 });
ctx.Tasks.Add(new TaskEntity
{
Id = "t1", ListId = "l1", Title = "Task", Status = TaskStatus.Idle, CreatedAt = DateTime.UtcNow,
});
await ctx.SaveChangesAsync();
}
var state = TaskStateServiceBuilder.Build(dbFactory).State;
var wt = new WorktreeManager(new GitService(), dbFactory, _cfg, NullLogger<WorktreeManager>.Instance);
IReadOnlyList<string>? capturedArgs = null;
var fake = new FakeClaudeProcess((_, _, args, _, _) =>
{
capturedArgs = args;
return Task.FromResult(new RunResult { ExitCode = 0, ResultMarkdown = "ok" });
});
var runner = new TaskRunner(fake, dbFactory, new HubBroadcaster(new CapturingHubContext()), wt,
new ClaudeArgsBuilder(), _cfg, NullLogger<TaskRunner>.Instance, state, new TaskRunTokenRegistry(),
new AttachmentStore(), new FakeSessionSkillSeeder(), new FakeTranscriptUsageReader());
using (var ctx = _db.CreateContext())
await runner.RunAsync((await new TaskRepository(ctx).GetByIdAsync("t1"))!, "slot-1", CancellationToken.None);
var args = capturedArgs!.ToList();
var actualModel = args[args.IndexOf("--model") + 1];
var actualMaxTurns = args[args.IndexOf("--max-turns") + 1];
using var reportCtx = _db.CreateContext();
var tools = new ConfigMcpTools(
new ListRepository(reportCtx), new TaskRepository(reportCtx),
new HubBroadcaster(new CapturingHubContext()), dbFactory);
var effective = await tools.GetEffectiveRunConfig("t1", CancellationToken.None);
Assert.Equal(actualModel, effective.Model.Value);
Assert.Equal(actualMaxTurns, effective.MaxTurns.Effective.ToString());
Assert.Equal(999, effective.MaxTurns.Requested);
Assert.True(effective.MaxTurns.Clamped);
}
}