feat(worker): wire list-handler phase parameter through handoff chain

Threads a nextPhase parameter (wait/merge/wait_final/merge_final, validated
by the new MergeHelperPhase) from handoff_list_handler through
HubBroadcaster/WorkerHub into InteractiveLaunchSpecService, which now picks
the next session's system prompt (MergeHelperWait/MergeHelperMerge) and
model (HandlerWaitAlias/HandlerMergeAlias) from it instead of hardcoding the
old two-phase Execute prompt -- this also fixes a build break left by the
prior prompt-split task, which removed PromptKind.MergeHelperExecute without
updating its only caller.

Also sets --model/--effort/--permission-mode explicitly for every list-handler
session (Triage included) via PermissionModeResolver instead of inheriting the
CLI's ambient model and hardcoding "auto", and adds Task to the merge-helper
allowlist so the Merge phase can delegate diff reviews to subagents.
This commit is contained in:
mika kuns
2026-08-11 09:27:16 +02:00
parent 1657a70962
commit f2609d186a
11 changed files with 240 additions and 63 deletions
@@ -53,15 +53,60 @@ public sealed class HandoffMcpToolsTests : IDisposable
var survivor = await SeedTaskAsync(listId, TaskStatus.WaitingForReview, title: "Survivor");
var sut = BuildSut();
var result = await sut.HandoffListHandler(handlerTask.Id, new[] { survivor.Id }, CancellationToken.None);
var result = await sut.HandoffListHandler(handlerTask.Id, new[] { survivor.Id }, cancellationToken: CancellationToken.None);
Assert.True(result.Requested);
Assert.Equal(handlerTask.Id, result.TaskId);
Assert.Equal(1, result.SurvivingCount);
Assert.Equal("wait", result.NextPhase);
var call = Assert.Single(_hubContext.Proxy.Calls);
Assert.Equal("HandoffRequested", call.Method);
Assert.Equal(handlerTask.Id, call.Args[0]);
Assert.Equal("wait", call.Args[2]);
}
[Fact]
public async Task HandoffListHandler_NextPhaseDefaultsToWait()
{
var listId = await SeedListAsync();
var handlerTask = await SeedTaskAsync(listId);
var survivor = await SeedTaskAsync(listId, TaskStatus.WaitingForReview, title: "Survivor");
var sut = BuildSut();
var result = await sut.HandoffListHandler(handlerTask.Id, new[] { survivor.Id }, cancellationToken: CancellationToken.None);
Assert.Equal("wait", result.NextPhase);
}
[Theory]
[InlineData("wait")]
[InlineData("merge")]
[InlineData("wait_final")]
[InlineData("merge_final")]
public async Task HandoffListHandler_AcceptsEveryKnownPhase(string phase)
{
var listId = await SeedListAsync();
var handlerTask = await SeedTaskAsync(listId);
var survivor = await SeedTaskAsync(listId, TaskStatus.WaitingForReview, title: "Survivor");
var sut = BuildSut();
var result = await sut.HandoffListHandler(handlerTask.Id, new[] { survivor.Id }, phase, CancellationToken.None);
Assert.Equal(phase, result.NextPhase);
}
[Fact]
public async Task HandoffListHandler_UnknownPhase_Throws()
{
var listId = await SeedListAsync();
var handlerTask = await SeedTaskAsync(listId);
var survivor = await SeedTaskAsync(listId, TaskStatus.WaitingForReview, title: "Survivor");
var sut = BuildSut();
var ex = await Assert.ThrowsAsync<ArgumentException>(() =>
sut.HandoffListHandler(handlerTask.Id, new[] { survivor.Id }, "phase-9", CancellationToken.None));
Assert.Contains("phase-9", ex.Message);
}
[Fact]
@@ -72,7 +117,7 @@ public sealed class HandoffMcpToolsTests : IDisposable
var sut = BuildSut();
await Assert.ThrowsAsync<InvalidOperationException>(() =>
sut.HandoffListHandler(handlerTask.Id, Array.Empty<string>(), CancellationToken.None));
sut.HandoffListHandler(handlerTask.Id, Array.Empty<string>(), cancellationToken: CancellationToken.None));
}
[Fact]
@@ -80,7 +125,7 @@ public sealed class HandoffMcpToolsTests : IDisposable
{
var sut = BuildSut();
await Assert.ThrowsAsync<InvalidOperationException>(() =>
sut.HandoffListHandler("missing", new[] { "x" }, CancellationToken.None));
sut.HandoffListHandler("missing", new[] { "x" }, cancellationToken: CancellationToken.None));
}
[Fact]
@@ -91,6 +136,6 @@ public sealed class HandoffMcpToolsTests : IDisposable
var sut = BuildSut();
await Assert.ThrowsAsync<InvalidOperationException>(() =>
sut.HandoffListHandler(handlerTask.Id, new[] { "missing" }, CancellationToken.None));
sut.HandoffListHandler(handlerTask.Id, new[] { "missing" }, cancellationToken: CancellationToken.None));
}
}