fix(ui): suppress auto-open conflict resolver during MCP-driven merges

review_task/continue_merge on a planning parent always leaves conflicts in
the tree, and the UI auto-opened the resolver on every PlanningMergeConflict
broadcast regardless of who started the merge -- so a running Claude session
resolving a unit-merge conflict could race a human editing the same shared
checkout in a resolver window neither of them asked for.

PlanningMergeOrchestrator.StartAsync now takes an externallyDriven flag (set
by ExternalMcpService's MCP-driven review_task path, left false for the UI's
ApproveReview) that rides along on the PlanningMergeConflict broadcast. The
UI only auto-opens the resolver when it's false; otherwise it shows a
persistent banner with a manual "Open resolver" button, cleared on
PlanningMergeAborted/PlanningCompleted. A new GetActiveExternalConflictsAsync
query (checked against GitService.IsMidMergeAsync rather than the in-memory
flag alone) lets the UI resync the banner on reconnect instead of trusting a
one-shot broadcast that isn't replayed after a restart.

The childless single-task conflict path was checked and needed no change --
it only broadcasts the generic TaskUpdated, never PlanningMergeConflict.
This commit is contained in:
mika kuns
2026-08-06 12:04:52 +02:00
parent 8247a749a0
commit 5d1d2d89d0
15 changed files with 379 additions and 15 deletions
@@ -0,0 +1,101 @@
using System;
using System.Threading.Tasks;
using ClaudeDo.Ui.ViewModels;
using Xunit;
namespace ClaudeDo.Ui.Tests;
// Covers the MCP-driven-merge popup fix: a unit-merge conflict started by an MCP session
// (review_task/continue_merge) must not auto-open the in-app resolver — two parties editing the
// same shared checkout at once is how the original bug manifested. The UI-driven path (a human
// clicking Approve) keeps opening the resolver as before.
public class IslandsShellViewModelExternalMergeTests
{
[Fact]
public void ExternallyDriven_ShowsBanner()
{
var vm = new IslandsShellViewModel();
vm.OnPlanningMergeConflict("plan1", "sub1", new[] { "file.txt" }, externallyDriven: true);
Assert.True(vm.IsExternalMergeBannerVisible);
}
[Fact]
public void UiDriven_DoesNotShowExternalBanner()
{
var vm = new IslandsShellViewModel();
vm.OnPlanningMergeConflict("plan1", "sub1", new[] { "file.txt" }, externallyDriven: false);
Assert.False(vm.IsExternalMergeBannerVisible);
}
[Fact]
public void Aborted_ClearsBanner()
{
var vm = new IslandsShellViewModel();
vm.OnPlanningMergeConflict("plan1", "sub1", Array.Empty<string>(), externallyDriven: true);
Assert.True(vm.IsExternalMergeBannerVisible);
vm.OnPlanningMergeAborted("plan1");
Assert.False(vm.IsExternalMergeBannerVisible);
}
[Fact]
public void Completed_ClearsBanner()
{
var vm = new IslandsShellViewModel();
vm.OnPlanningMergeConflict("plan1", "sub1", Array.Empty<string>(), externallyDriven: true);
vm.OnPlanningMergeCompleted("plan1");
Assert.False(vm.IsExternalMergeBannerVisible);
}
[Fact]
public void UnrelatedPlanningTaskEnding_LeavesBannerVisible()
{
var vm = new IslandsShellViewModel();
vm.OnPlanningMergeConflict("plan1", "sub1", Array.Empty<string>(), externallyDriven: true);
vm.OnPlanningMergeCompleted("some-other-plan");
Assert.True(vm.IsExternalMergeBannerVisible);
}
[Fact]
public void LaterConflictOnSamePlanningTask_UpdatesInPlaceWithoutDuplicating()
{
var vm = new IslandsShellViewModel();
vm.OnPlanningMergeConflict("plan1", "subA", Array.Empty<string>(), externallyDriven: true);
vm.OnPlanningMergeConflict("plan1", "subB", Array.Empty<string>(), externallyDriven: true);
Assert.True(vm.IsExternalMergeBannerVisible);
// A single terminal event for that planning task clears it entirely — proves the second
// conflict updated the same entry rather than piling up a second one.
vm.OnPlanningMergeCompleted("plan1");
Assert.False(vm.IsExternalMergeBannerVisible);
}
[Fact]
public async Task OpenExternalMergeConflictCommand_NoPendingConflict_DoesNotThrow()
{
var vm = new IslandsShellViewModel();
await vm.OpenExternalMergeConflictCommand.ExecuteAsync(null);
}
[Fact]
public async Task OpenExternalMergeConflictCommand_NoDialogsWired_DoesNotThrow()
{
var vm = new IslandsShellViewModel();
vm.OnPlanningMergeConflict("plan1", "sub1", Array.Empty<string>(), externallyDriven: true);
// Dialogs/ConflictResolverFactory aren't wired in the test-only ctor — a deliberate
// click must degrade gracefully rather than throw, same as the existing UI-driven path.
await vm.OpenExternalMergeConflictCommand.ExecuteAsync(null);
}
}
+7 -1
View File
@@ -33,7 +33,7 @@ public abstract class StubWorkerClient : IWorkerClient
public event Action<string, bool, string?>? RefineFinishedEvent;
public event Action<string, string>? PlanningMergeStartedEvent;
public event Action<string, string>? PlanningSubtaskMergedEvent;
public event Action<string, string, IReadOnlyList<string>>? PlanningMergeConflictEvent;
public event Action<string, string, IReadOnlyList<string>, bool>? PlanningMergeConflictEvent;
public event Action<string>? PlanningMergeAbortedEvent;
public event Action<string>? PlanningCompletedEvent;
public event Action<PrimeFiredEvent>? PrimeFired;
@@ -53,6 +53,10 @@ public abstract class StubWorkerClient : IWorkerClient
public void RaiseTaskQuestionAsked(string taskId, string questionId, string question) => TaskQuestionAskedEvent?.Invoke(taskId, questionId, question);
public void RaiseTaskQuestionResolved(string taskId, string questionId) => TaskQuestionResolvedEvent?.Invoke(taskId, questionId);
public void RaiseHandoffRequested(string taskId, IReadOnlyList<string> survivingTaskIds) => HandoffRequestedEvent?.Invoke(taskId, survivingTaskIds);
public void RaisePlanningMergeConflict(string planningTaskId, string subtaskId, IReadOnlyList<string> files, bool externallyDriven)
=> PlanningMergeConflictEvent?.Invoke(planningTaskId, subtaskId, files, externallyDriven);
public void RaisePlanningMergeAborted(string planningTaskId) => PlanningMergeAbortedEvent?.Invoke(planningTaskId);
public void RaisePlanningCompleted(string planningTaskId) => PlanningCompletedEvent?.Invoke(planningTaskId);
public void RaisePrepStarted() => PrepStartedEvent?.Invoke();
public void RaisePrepLine(string line) => PrepLineEvent?.Invoke(line);
@@ -129,6 +133,8 @@ public abstract class StubWorkerClient : IWorkerClient
=> Task.FromResult<CombinedDiffResultDto?>(null);
public virtual Task ContinuePlanningMergeAsync(string planningTaskId) => Task.CompletedTask;
public virtual Task AbortPlanningMergeAsync(string planningTaskId) => Task.CompletedTask;
public virtual Task<IReadOnlyList<PlanningMergeConflictStateDto>> GetActiveExternalPlanningMergeConflictsAsync()
=> Task.FromResult<IReadOnlyList<PlanningMergeConflictStateDto>>(Array.Empty<PlanningMergeConflictStateDto>());
public virtual Task QueuePlanningSubtasksAsync(string parentTaskId, CancellationToken ct = default) => Task.CompletedTask;
public virtual Task<string?> GetWeekReportAsync(DateOnly start, DateOnly end) => Task.FromResult<string?>(null);
public virtual Task<string> GenerateWeekReportAsync(DateOnly start, DateOnly end) => Task.FromResult("");