From a768bc416356016552d78576de6d363e7c4a3bde Mon Sep 17 00:00:00 2001 From: mika kuns Date: Wed, 5 Aug 2026 20:39:35 +0200 Subject: [PATCH] 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. --- docs/explore-notes/external-mcp.md | 13 +- docs/explore-notes/worker-task-pipeline.md | 9 +- .../External/ConfigMcpTools.cs | 67 +++++++- .../Runner/EffectiveRunConfigResolver.cs | 53 +++++++ src/ClaudeDo.Worker/Runner/TaskRunner.cs | 32 ++-- .../External/ConfigMcpToolsTests.cs | 2 +- .../External/EffectiveRunConfigTests.cs | 144 ++++++++++++++++++ .../External/ExternalMcpServiceTests.cs | 2 +- .../Runner/EffectiveRunConfigParityTests.cs | 81 ++++++++++ 9 files changed, 383 insertions(+), 20 deletions(-) create mode 100644 src/ClaudeDo.Worker/Runner/EffectiveRunConfigResolver.cs create mode 100644 tests/ClaudeDo.Worker.Tests/External/EffectiveRunConfigTests.cs create mode 100644 tests/ClaudeDo.Worker.Tests/Runner/EffectiveRunConfigParityTests.cs diff --git a/docs/explore-notes/external-mcp.md b/docs/explore-notes/external-mcp.md index c475a2a5..8eae3b09 100644 --- a/docs/explore-notes/external-mcp.md +++ b/docs/explore-notes/external-mcp.md @@ -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 diff --git a/docs/explore-notes/worker-task-pipeline.md b/docs/explore-notes/worker-task-pipeline.md index 4111b508..a6241b0c 100644 --- a/docs/explore-notes/worker-task-pipeline.md +++ b/docs/explore-notes/worker-task-pipeline.md @@ -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: diff --git a/src/ClaudeDo.Worker/External/ConfigMcpTools.cs b/src/ClaudeDo.Worker/External/ConfigMcpTools.cs index d19ebbff..5556d26c 100644 --- a/src/ClaudeDo.Worker/External/ConfigMcpTools.cs +++ b/src/ClaudeDo.Worker/External/ConfigMcpTools.cs @@ -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 Sources); +public sealed record EffectiveRunConfigDto( + string TaskId, + EffectiveModelDto Model, + EffectiveMaxTurnsDto MaxTurns, + string Effort, + string PermissionMode, + EffectiveAgentPathDto AgentPath, + EffectiveSystemPromptDto SystemPrompt, + IReadOnlyList SkillNames); + [McpServerToolType] public sealed class ConfigMcpTools { private readonly ListRepository _lists; private readonly TaskRepository _tasks; private readonly HubBroadcaster _broadcaster; + private readonly IDbContextFactory _dbFactory; - public ConfigMcpTools(ListRepository lists, TaskRepository tasks, HubBroadcaster broadcaster) + public ConfigMcpTools( + ListRepository lists, TaskRepository tasks, HubBroadcaster broadcaster, + IDbContextFactory 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 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 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); + } } diff --git a/src/ClaudeDo.Worker/Runner/EffectiveRunConfigResolver.cs b/src/ClaudeDo.Worker/Runner/EffectiveRunConfigResolver.cs new file mode 100644 index 00000000..8936ed13 --- /dev/null +++ b/src/ClaudeDo.Worker/Runner/EffectiveRunConfigResolver.cs @@ -0,0 +1,53 @@ +using ClaudeDo.Data.Models; + +namespace ClaudeDo.Worker.Runner; + +/// The task/list/preset/global resolution 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. +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 SystemPromptSources); + +/// Single source of truth for "which value wins" — shared by 's +/// actual run path and any read-only reporting of the same resolution (e.g. the +/// get_effective_run_config MCP tool), so the two can never drift apart. +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(); + 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); + } +} diff --git a/src/ClaudeDo.Worker/Runner/TaskRunner.cs b/src/ClaudeDo.Worker/Runner/TaskRunner.cs index 1d7808a6..abfdc3ce 100644 --- a/src/ClaudeDo.Worker/Runner/TaskRunner.cs +++ b/src/ClaudeDo.Worker/Runner/TaskRunner.cs @@ -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> 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; } + /// Shared with get_effective_run_config so reported skill names match what a run + /// would actually filter down to. + internal static IReadOnlyList FilterToInstalled( + IReadOnlyList requestedSkills, IReadOnlySet installedNames) + => requestedSkills.Where(installedNames.Contains).ToList(); + internal static IReadOnlyList UnionSkillNames(params string?[] jsonArrays) { var names = new List(); diff --git a/tests/ClaudeDo.Worker.Tests/External/ConfigMcpToolsTests.cs b/tests/ClaudeDo.Worker.Tests/External/ConfigMcpToolsTests.cs index 5b414697..7b80edf4 100644 --- a/tests/ClaudeDo.Worker.Tests/External/ConfigMcpToolsTests.cs +++ b/tests/ClaudeDo.Worker.Tests/External/ConfigMcpToolsTests.cs @@ -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(); } diff --git a/tests/ClaudeDo.Worker.Tests/External/EffectiveRunConfigTests.cs b/tests/ClaudeDo.Worker.Tests/External/EffectiveRunConfigTests.cs new file mode 100644 index 00000000..c31b0f20 --- /dev/null +++ b/tests/ClaudeDo.Worker.Tests/External/EffectiveRunConfigTests.cs @@ -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; + +/// +/// 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". +/// +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 SeedListAsync() + { + var id = Guid.NewGuid().ToString(); + await _lists.AddAsync(new ListEntity { Id = id, Name = "L", CreatedAt = DateTime.UtcNow }); + return id; + } + + private async Task SeedTaskAsync(string listId, Action? 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( + () => _sut.GetEffectiveRunConfig("nope", CancellationToken.None)); + } +} diff --git a/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs b/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs index 146539c7..c3107bf3 100644 --- a/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs +++ b/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs @@ -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() diff --git a/tests/ClaudeDo.Worker.Tests/Runner/EffectiveRunConfigParityTests.cs b/tests/ClaudeDo.Worker.Tests/Runner/EffectiveRunConfigParityTests.cs new file mode 100644 index 00000000..54c13728 --- /dev/null +++ b/tests/ClaudeDo.Worker.Tests/Runner/EffectiveRunConfigParityTests.cs @@ -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; + +/// +/// 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. +/// +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.Instance); + IReadOnlyList? 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.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); + } +}