Merge claudedo/123b0241b5e94b69bfa592fde89c51aa
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -574,29 +574,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(
|
||||
@@ -612,7 +608,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)
|
||||
{
|
||||
@@ -624,6 +620,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));
|
||||
}
|
||||
}
|
||||
@@ -883,7 +883,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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user