feat(ui): triage model picker (opus/fable) in the merge helper start modal

This commit is contained in:
mika kuns
2026-09-10 09:42:55 +02:00
parent 5477ae5302
commit 1177463a82
14 changed files with 84 additions and 36 deletions
+1 -1
View File
@@ -128,7 +128,7 @@ public abstract class StubWorkerClient : IWorkerClient
=> Task.FromResult(new LaunchSpec(".", "claude", Array.Empty<string>(), new Dictionary<string, string>()));
public virtual Task<LaunchSpec> GetAdHocLaunchSpecAsync(string directory, CancellationToken ct = default)
=> Task.FromResult(new LaunchSpec(directory, "claude", Array.Empty<string>(), new Dictionary<string, string>()));
public virtual Task<LaunchSpec> GetMergeHelperLaunchSpecAsync(IReadOnlyList<string> taskIds, string listId, string handlerTaskId, CancellationToken ct = default)
public virtual Task<LaunchSpec> GetMergeHelperLaunchSpecAsync(IReadOnlyList<string> taskIds, string listId, string handlerTaskId, string? model = null, CancellationToken ct = default)
=> Task.FromResult(new LaunchSpec(".", "claude", Array.Empty<string>(), new Dictionary<string, string>()));
public virtual Task<string> CreateMergeHelperTaskAsync(
IReadOnlyList<string> taskIds, string listId, string title, string descriptionHeader, CancellationToken ct = default)
@@ -556,7 +556,7 @@ public class MissionControlViewModelTests : IDisposable
private sealed class ThrowingMergeHelperLaunchSpecWorker : StubWorkerClient
{
public override Task<LaunchSpec> GetMergeHelperLaunchSpecAsync(IReadOnlyList<string> taskIds, string listId, string handlerTaskId, CancellationToken ct = default)
public override Task<LaunchSpec> GetMergeHelperLaunchSpecAsync(IReadOnlyList<string> taskIds, string listId, string handlerTaskId, string? model = null, CancellationToken ct = default)
=> throw new InvalidOperationException("spec failed");
}
@@ -721,7 +721,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
{
var svc = BuildService(now: now);
var spec = await svc.BuildForMergeHelperAsync(
new[] { taskId }, listId, Guid.NewGuid().ToString(), CancellationToken.None);
new[] { taskId }, listId, Guid.NewGuid().ToString(), null, CancellationToken.None);
var sessionDir = TrackSessionDir(spec);
return await File.ReadAllTextAsync(Path.Combine(sessionDir, "system-prompt.md"));
}
@@ -851,7 +851,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
var listId = await SeedListAsync(workingDir: _tempDir);
var svc = BuildService();
await Assert.ThrowsAsync<InvalidOperationException>(
() => svc.BuildForMergeHelperAsync(Array.Empty<string>(), listId, Guid.NewGuid().ToString(), CancellationToken.None));
() => svc.BuildForMergeHelperAsync(Array.Empty<string>(), listId, Guid.NewGuid().ToString(), null, CancellationToken.None));
}
[Fact]
@@ -863,7 +863,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
var svc = BuildService();
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => svc.BuildForMergeHelperAsync(new[] { taskId }, listId, Guid.NewGuid().ToString(), CancellationToken.None));
() => svc.BuildForMergeHelperAsync(new[] { taskId }, listId, Guid.NewGuid().ToString(), null, CancellationToken.None));
Assert.Contains("working directory", ex.Message);
}
@@ -876,7 +876,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
var svc = BuildService();
await Assert.ThrowsAsync<KeyNotFoundException>(
() => svc.BuildForMergeHelperAsync(new[] { taskId }, "no-such-list", Guid.NewGuid().ToString(), CancellationToken.None));
() => svc.BuildForMergeHelperAsync(new[] { taskId }, "no-such-list", Guid.NewGuid().ToString(), null, CancellationToken.None));
}
[Fact]
@@ -892,7 +892,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
await SeedTaskAsync(t2, listId, TaskStatus.Idle, title: "Second task");
var svc = BuildService();
var spec = await svc.BuildForMergeHelperAsync(new[] { t1, t2 }, listId, Guid.NewGuid().ToString(), CancellationToken.None);
var spec = await svc.BuildForMergeHelperAsync(new[] { t1, t2 }, listId, Guid.NewGuid().ToString(), null, CancellationToken.None);
var sessionDir = TrackSessionDir(spec);
Assert.Equal(repo, spec.Cwd);
@@ -934,6 +934,27 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
Assert.Equal(InteractiveLaunchSpecService.McpToolTimeoutMs, spec.Env["MCP_TOOL_TIMEOUT"]);
}
[Theory]
[InlineData("fable", "fable")]
[InlineData("opus", "opus")]
[InlineData("not-a-model", ModelRegistry.HandlerTriageAlias)] // unknown → default, never blocks the launch
public async Task BuildForMergeHelperAsync_ModelOverride_DrivesModelAndEffortArgs(string requested, string expected)
{
var repo = Path.Combine(_tempDir, $"repoModel-{requested}");
Directory.CreateDirectory(repo);
var listId = await SeedListAsync(workingDir: repo, name: "Alpha");
var t1 = Guid.NewGuid().ToString();
await SeedTaskAsync(t1, listId, TaskStatus.Idle, title: "First task");
var svc = BuildService();
var spec = await svc.BuildForMergeHelperAsync(new[] { t1 }, listId, Guid.NewGuid().ToString(), requested, CancellationToken.None);
TrackSessionDir(spec);
var args = spec.Args.ToList();
Assert.Equal(expected, args[args.IndexOf("--model") + 1]);
Assert.Equal(ModelPresets.For(ModelPresets.Defaults, expected).Effort, args[args.IndexOf("--effort") + 1]);
}
// A directory argument that keeps its trailing separator escapes its own closing quote once the
// ConPTY host flattens Args into a single Windows command line ("C:\repo\" -> \" is a literal
// quote), so --add-dir's variadic list swallows every following argument -- including the
@@ -949,7 +970,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
await SeedTaskAsync(t1, listId, TaskStatus.WaitingForReview, title: "First task");
var svc = BuildService();
var spec = await svc.BuildForMergeHelperAsync(new[] { t1 }, listId, Guid.NewGuid().ToString(), CancellationToken.None);
var spec = await svc.BuildForMergeHelperAsync(new[] { t1 }, listId, Guid.NewGuid().ToString(), null, CancellationToken.None);
var sessionDir = TrackSessionDir(spec);
var args = spec.Args.ToList();
@@ -972,7 +993,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
await SeedTaskAsync(t2, listId, TaskStatus.Idle, title: "Second task");
var svc = BuildService();
var spec = await svc.BuildForMergeHelperAsync(new[] { t1, t2 }, listId, Guid.NewGuid().ToString(), CancellationToken.None);
var spec = await svc.BuildForMergeHelperAsync(new[] { t1, t2 }, listId, Guid.NewGuid().ToString(), null, CancellationToken.None);
var sessionDir = TrackSessionDir(spec);
var brief = File.ReadAllText(Path.Combine(sessionDir, "brief.md"));
@@ -999,7 +1020,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
var handlerTaskId = Guid.NewGuid().ToString();
var svc = BuildService();
var spec = await svc.BuildForMergeHelperAsync(new[] { t1 }, listId, handlerTaskId, CancellationToken.None);
var spec = await svc.BuildForMergeHelperAsync(new[] { t1 }, listId, handlerTaskId, null, CancellationToken.None);
var sessionDir = TrackSessionDir(spec);
var brief = File.ReadAllText(Path.Combine(sessionDir, "brief.md"));
@@ -1018,7 +1039,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
description: "Do the thing carefully and report back.");
var svc = BuildService();
var spec = await svc.BuildForMergeHelperAsync(new[] { t1 }, listId, Guid.NewGuid().ToString(), CancellationToken.None);
var spec = await svc.BuildForMergeHelperAsync(new[] { t1 }, listId, Guid.NewGuid().ToString(), null, CancellationToken.None);
var sessionDir = TrackSessionDir(spec);
var brief = File.ReadAllText(Path.Combine(sessionDir, "brief.md"));
@@ -1036,7 +1057,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
await SeedTaskAsync(t1, listId, TaskStatus.Idle, title: "No description task", description: null);
var svc = BuildService();
var spec = await svc.BuildForMergeHelperAsync(new[] { t1 }, listId, Guid.NewGuid().ToString(), CancellationToken.None);
var spec = await svc.BuildForMergeHelperAsync(new[] { t1 }, listId, Guid.NewGuid().ToString(), null, CancellationToken.None);
var sessionDir = TrackSessionDir(spec);
var brief = File.ReadAllText(Path.Combine(sessionDir, "brief.md"));
@@ -1067,7 +1088,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
await SeedTaskAsync(t2, listId, TaskStatus.WaitingForReview, title: "Second task", description: "plain description");
var svc = BuildService();
var spec = await svc.BuildForMergeHelperAsync(new[] { t1, t2 }, listId, Guid.NewGuid().ToString(), CancellationToken.None);
var spec = await svc.BuildForMergeHelperAsync(new[] { t1, t2 }, listId, Guid.NewGuid().ToString(), null, CancellationToken.None);
var sessionDir = TrackSessionDir(spec);
var brief = File.ReadAllText(Path.Combine(sessionDir, "brief.md"));
@@ -1402,7 +1423,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
var svc = BuildService();
var triageSpec = await svc.BuildForMergeHelperAsync(new[] { survivor }, listId, handlerTaskId, CancellationToken.None);
var triageSpec = await svc.BuildForMergeHelperAsync(new[] { survivor }, listId, handlerTaskId, null, CancellationToken.None);
var triageDir = TrackSessionDir(triageSpec);
var executeSpec = await svc.BuildForMergeHelperHandoffAsync(handlerTaskId, new[] { survivor }, "merge", CancellationToken.None);
var executeDir = TrackSessionDir(executeSpec);
@@ -1453,7 +1474,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
ClaudeSessionId: "sess-42", Token: "tok-2", WorktreePath: _worktreeDir);
var svc = BuildService();
var mergeHelperSpec = await svc.BuildForMergeHelperAsync(new[] { survivor }, listId, handlerTaskId, CancellationToken.None);
var mergeHelperSpec = await svc.BuildForMergeHelperAsync(new[] { survivor }, listId, handlerTaskId, null, CancellationToken.None);
TrackSessionDir(mergeHelperSpec);
var handoffSpec = await svc.BuildForMergeHelperHandoffAsync(handlerTaskId, new[] { survivor }, "wait", CancellationToken.None);
TrackSessionDir(handoffSpec);