From a987a9a11d8858a514f6d8eecb419da64ed6345b Mon Sep 17 00:00:00 2001 From: Shangxin Date: Tue, 1 Sep 2026 08:40:32 +0000 Subject: [PATCH 1/3] fix(start): resolve the launch cwd through the single transport-aware owner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Starting a session with the Unclassified project always failed: StartViewModel resolved the launch cwd via ProjectSelectionCwdResolver, which returns null for Unclassified because it is not a real project, and ChatLaunchWorkflow then rejected the launch with "no working directory resolved for the launch". The generic "please try again" message was misleading — no retry could ever produce a root. Meanwhile the new-session draft resolved its cwd through AcpSessionNewCwdResolver, which owns the transport-aware fallback (stdio defaults to the user profile directory, remote transports require an explicitly selected directory). So the draft succeeded via that fallback while the launch failed on the same selection: two owners for one value. Route the launch through AcpSessionNewCwdResolver as well, reusing ResolvePreviewCwd as the request source the draft already uses, and surface the resolver's own reason instead of the generic retry message when a launch has no resolvable root. Delete SessionCwdResolver, whose only remaining call site passed a constant null second argument, leaving it a Trim wrapper. Verified: Presentation.Core suite 3276 passed, 0 failed, 0 warnings. Reverse verified by reintroducing the null-cwd path (two new tests fail) and by reverting StartViewModel against the new architecture gate (gate fails). Co-Authored-By: Claude Opus 5 (1M context) --- .../Resources/CoreStrings.en-US.resx | 3 + .../Resources/CoreStrings.en.resx | 3 + .../Resources/CoreStrings.resx | 3 + .../Resources/CoreStrings.zh-Hans.resx | 3 + .../Services/Chat/ChatLaunchWorkflow.cs | 8 +- .../Navigation/SessionCwdResolver.cs | 26 -- .../ViewModels/Start/StartViewModel.cs | 35 ++- .../Localization/CoreStringResourceTests.cs | 1 + .../Localization/TestCoreStringLocalizer.cs | 1 + .../Navigation/SessionCwdResolverTests.cs | 31 --- .../NavigationCoreTests.cs | 20 ++ .../Start/StartViewModelTests.cs | 241 +++++++++++++++++- 12 files changed, 303 insertions(+), 72 deletions(-) delete mode 100644 src/SalmonEgg.Presentation.Core/ViewModels/Navigation/SessionCwdResolver.cs delete mode 100644 tests/SalmonEgg.Presentation.Core.Tests/Navigation/SessionCwdResolverTests.cs diff --git a/src/SalmonEgg.Presentation.Core/Resources/CoreStrings.en-US.resx b/src/SalmonEgg.Presentation.Core/Resources/CoreStrings.en-US.resx index 3808fee5b..12ede6dfc 100644 --- a/src/SalmonEgg.Presentation.Core/Resources/CoreStrings.en-US.resx +++ b/src/SalmonEgg.Presentation.Core/Resources/CoreStrings.en-US.resx @@ -1208,6 +1208,9 @@ Create the corresponding Markdown file in: The remote working directory must be an absolute path. + + Select a remote directory before creating a remote session. + Failed to update conversation binding ({0}): {1} diff --git a/src/SalmonEgg.Presentation.Core/Resources/CoreStrings.en.resx b/src/SalmonEgg.Presentation.Core/Resources/CoreStrings.en.resx index a60c011b8..a2fcfdbde 100644 --- a/src/SalmonEgg.Presentation.Core/Resources/CoreStrings.en.resx +++ b/src/SalmonEgg.Presentation.Core/Resources/CoreStrings.en.resx @@ -1208,6 +1208,9 @@ Create the corresponding Markdown file in: The remote working directory must be an absolute path. + + Select a remote directory before creating a remote session. + Failed to update conversation binding ({0}): {1} diff --git a/src/SalmonEgg.Presentation.Core/Resources/CoreStrings.resx b/src/SalmonEgg.Presentation.Core/Resources/CoreStrings.resx index bfc0ff0fd..4bd00e969 100644 --- a/src/SalmonEgg.Presentation.Core/Resources/CoreStrings.resx +++ b/src/SalmonEgg.Presentation.Core/Resources/CoreStrings.resx @@ -1214,6 +1214,9 @@ The remote working directory must be an absolute path. + + 请先选择一个远程目录,然后再创建远程会话。 + Failed to update conversation binding ({0}): {1} diff --git a/src/SalmonEgg.Presentation.Core/Resources/CoreStrings.zh-Hans.resx b/src/SalmonEgg.Presentation.Core/Resources/CoreStrings.zh-Hans.resx index 877cf867d..83df1827d 100644 --- a/src/SalmonEgg.Presentation.Core/Resources/CoreStrings.zh-Hans.resx +++ b/src/SalmonEgg.Presentation.Core/Resources/CoreStrings.zh-Hans.resx @@ -1214,6 +1214,9 @@ 远程工作目录必须是绝对路径。 + + 请先选择一个远程目录,然后再创建远程会话。 + 更新会话绑定失败({0}):{1} diff --git a/src/SalmonEgg.Presentation.Core/Services/Chat/ChatLaunchWorkflow.cs b/src/SalmonEgg.Presentation.Core/Services/Chat/ChatLaunchWorkflow.cs index 48e959a0e..6575d34ea 100644 --- a/src/SalmonEgg.Presentation.Core/Services/Chat/ChatLaunchWorkflow.cs +++ b/src/SalmonEgg.Presentation.Core/Services/Chat/ChatLaunchWorkflow.cs @@ -75,10 +75,10 @@ public async Task StartSessionAndSendAsync( var cwd = request.Cwd; if (string.IsNullOrWhiteSpace(cwd)) { - // A session cannot be established without a working directory (the session manager - // rejects an empty one, and a launch with no resolved root is not a transient hiccup - // worth retrying — it needs the caller to surface a project picker). Fail the launch - // cleanly rather than letting CreateSessionAsync throw ArgumentException on both tries. + // Defense in depth: callers resolve the cwd through AcpSessionNewCwdResolver and reject + // an unresolved one with the resolver's own reason before reaching here. This guard only + // keeps the public contract honest for a caller that skips that step, since the session + // manager rejects an empty cwd and retrying cannot make a root appear. _logger.LogWarning("Start workflow stopped: no working directory resolved for the launch."); return ChatLaunchCompletion.Failed; } diff --git a/src/SalmonEgg.Presentation.Core/ViewModels/Navigation/SessionCwdResolver.cs b/src/SalmonEgg.Presentation.Core/ViewModels/Navigation/SessionCwdResolver.cs deleted file mode 100644 index 5b04bdf2e..000000000 --- a/src/SalmonEgg.Presentation.Core/ViewModels/Navigation/SessionCwdResolver.cs +++ /dev/null @@ -1,26 +0,0 @@ -namespace SalmonEgg.Presentation.ViewModels.Navigation; - -public static class SessionCwdResolver -{ - public static string? Resolve(string? pendingProjectRootPath, string? lastSelectedProjectRootPath) - { - var pending = Normalize(pendingProjectRootPath); - if (!string.IsNullOrWhiteSpace(pending)) - { - return pending; - } - - var lastSelected = Normalize(lastSelectedProjectRootPath); - return string.IsNullOrWhiteSpace(lastSelected) ? null : lastSelected; - } - - private static string? Normalize(string? path) - { - if (string.IsNullOrWhiteSpace(path)) - { - return null; - } - - return path.Trim(); - } -} diff --git a/src/SalmonEgg.Presentation.Core/ViewModels/Start/StartViewModel.cs b/src/SalmonEgg.Presentation.Core/ViewModels/Start/StartViewModel.cs index 7956cd22f..acfae2b4c 100644 --- a/src/SalmonEgg.Presentation.Core/ViewModels/Start/StartViewModel.cs +++ b/src/SalmonEgg.Presentation.Core/ViewModels/Start/StartViewModel.cs @@ -600,6 +600,17 @@ private async Task StartSessionAndSendAsync() } ClearStartLaunchFailure(); + var cwdResolution = ResolveLaunchCwdResolution(); + if (!cwdResolution.IsSuccess || string.IsNullOrWhiteSpace(cwdResolution.Cwd)) + { + // Surface the resolver's own reason (for example "select a remote directory") instead of + // the generic retry message: a launch with no resolvable root never succeeds on retry. + SetStartLaunchFailure( + ResolveLaunchCwdFailureResourceKey(cwdResolution.ErrorMessage), + cwdResolution.ErrorMessage ?? AcpSessionNewCwdResolver.MissingRemoteCwdMessage); + return; + } + IsStarting = true; StartSessionAndSendCommand.NotifyCanExecuteChanged(); var promptDispatched = false; @@ -610,7 +621,7 @@ private async Task StartSessionAndSendAsync() new ChatLaunchRequest( promptText, NormalizeProjectSelectionValue(SelectedStartProjectId), - ResolveDefaultCwd())) + cwdResolution.Cwd)) .ConfigureAwait(true); switch (completion) { @@ -966,19 +977,23 @@ private void OnAgentRemoteDirectoriesChanged(object? sender, System.Collections. QueueEnsureNewSessionDraft(); } - private string? ResolveDefaultCwd() + // Launch and draft must agree on the working directory, so both go through + // AcpSessionNewCwdResolver: it owns the transport-aware fallback (stdio defaults to the + // user profile directory, remote transports require an explicitly selected directory). + // Resolving the launch cwd any other way reintroduces the divergence where the draft + // succeeded via the fallback but the launch failed on a null cwd for Unclassified. + private AcpSessionNewCwdResolution ResolveLaunchCwdResolution() { - var selectedOption = ResolveSelectedProjectOption(); - if (!string.IsNullOrWhiteSpace(selectedOption?.RemoteCwd)) - { - return selectedOption.RemoteCwd; - } - - var selectedRoot = _projectPreferences.TryGetProjectCwd(SelectedStartProjectId); + var requestedCwd = ResolvePreviewCwd(); _ = _nav.ConsumePendingProjectRootPath(); - return SessionCwdResolver.Resolve(selectedRoot, null); + return AcpSessionNewCwdResolver.Resolve(requestedCwd, Chat.SelectedAcpProfile); } + private static string ResolveLaunchCwdFailureResourceKey(string? errorMessage) + => string.Equals(errorMessage, AcpSessionNewCwdResolver.InvalidRemoteCwdMessage, StringComparison.Ordinal) + ? "NewSessionDraft_InvalidRemoteCwd" + : "NewSessionDraft_MissingRemoteCwd"; + private void RefreshStartProjectOptions() { var options = BuildStartProjectOptions(); diff --git a/tests/SalmonEgg.Presentation.Core.Tests/Localization/CoreStringResourceTests.cs b/tests/SalmonEgg.Presentation.Core.Tests/Localization/CoreStringResourceTests.cs index 8de21e155..7bf07451c 100644 --- a/tests/SalmonEgg.Presentation.Core.Tests/Localization/CoreStringResourceTests.cs +++ b/tests/SalmonEgg.Presentation.Core.Tests/Localization/CoreStringResourceTests.cs @@ -161,6 +161,7 @@ public sealed class CoreStringResourceTests [InlineData("VoiceDiagnostics_SignalMonitoringFailed")] [InlineData("NewSessionDraft_LoadConfigFailed")] [InlineData("NewSessionDraft_InvalidRemoteCwd")] + [InlineData("NewSessionDraft_MissingRemoteCwd")] [InlineData("ChatBinding_UpdateFailedWithStatus")] [InlineData("ChatBinding_BindNewFailedWithStatus")] [InlineData("ChatBinding_PromoteDraftFailedWithStatus")] diff --git a/tests/SalmonEgg.Presentation.Core.Tests/Localization/TestCoreStringLocalizer.cs b/tests/SalmonEgg.Presentation.Core.Tests/Localization/TestCoreStringLocalizer.cs index 41c4ed7c2..c732f8612 100644 --- a/tests/SalmonEgg.Presentation.Core.Tests/Localization/TestCoreStringLocalizer.cs +++ b/tests/SalmonEgg.Presentation.Core.Tests/Localization/TestCoreStringLocalizer.cs @@ -93,6 +93,7 @@ internal sealed class TestCoreStringLocalizer : IStringLocalizer ["VoiceDiagnostics_SignalMonitoringFailed"] = "Signal monitoring failed.", ["NewSessionDraft_LoadConfigFailed"] = "Unable to load session configuration. Check the connection and try again.", ["NewSessionDraft_InvalidRemoteCwd"] = "The remote working directory must be an absolute path.", + ["NewSessionDraft_MissingRemoteCwd"] = "Select a remote directory before creating a remote session.", ["ChatBinding_UpdateFailedWithStatus"] = "Failed to update conversation binding ({0}): {1}", ["ChatBinding_BindNewFailedWithStatus"] = "Failed to bind new conversation ({0}): {1}", ["ChatBinding_PromoteDraftFailedWithStatus"] = "Failed to promote ACP new-session draft ({0}): {1}", diff --git a/tests/SalmonEgg.Presentation.Core.Tests/Navigation/SessionCwdResolverTests.cs b/tests/SalmonEgg.Presentation.Core.Tests/Navigation/SessionCwdResolverTests.cs deleted file mode 100644 index 3d34e5c49..000000000 --- a/tests/SalmonEgg.Presentation.Core.Tests/Navigation/SessionCwdResolverTests.cs +++ /dev/null @@ -1,31 +0,0 @@ -using SalmonEgg.Presentation.ViewModels.Navigation; -using Xunit; - -namespace SalmonEgg.Presentation.Core.Tests.Navigation; - -public sealed class SessionCwdResolverTests -{ - [Fact] - public void Resolve_Prioritizes_PendingProjectRoot() - { - var result = SessionCwdResolver.Resolve("C:\\Repo\\ProjectA", "C:\\Repo\\ProjectB"); - - Assert.Equal("C:\\Repo\\ProjectA", result); - } - - [Fact] - public void Resolve_FallsBack_To_LastSelectedProjectRoot() - { - var result = SessionCwdResolver.Resolve(null, "C:\\Repo\\ProjectB"); - - Assert.Equal("C:\\Repo\\ProjectB", result); - } - - [Fact] - public void Resolve_ReturnsNull_When_NoRootsProvided() - { - var result = SessionCwdResolver.Resolve(" ", null); - - Assert.Null(result); - } -} diff --git a/tests/SalmonEgg.Presentation.Core.Tests/NavigationCoreTests.cs b/tests/SalmonEgg.Presentation.Core.Tests/NavigationCoreTests.cs index 8df5d1949..9fb1507a5 100644 --- a/tests/SalmonEgg.Presentation.Core.Tests/NavigationCoreTests.cs +++ b/tests/SalmonEgg.Presentation.Core.Tests/NavigationCoreTests.cs @@ -358,6 +358,26 @@ public void StartLaunch_DoesNotRetainLegacyProfileAgnosticCwdResolver() Assert.DoesNotContain("StartSessionCwdResolver", chatLaunchWorkflow, StringComparison.Ordinal); } + [Fact] + public void StartLaunch_ResolvesCwdThroughTheTransportAwareResolverOnly() + { + var root = FindRepoRoot(); + var legacySessionResolver = Path.Combine( + root, + NormalizeRelativePath(@"src\SalmonEgg.Presentation.Core\ViewModels\Navigation\SessionCwdResolver.cs")); + var startViewModel = LoadFile(@"src\SalmonEgg.Presentation.Core\ViewModels\Start\StartViewModel.cs"); + + // The launch cwd has exactly one owner: AcpSessionNewCwdResolver, which the new-session + // draft already uses. A second resolver made the draft succeed via the stdio fallback while + // the launch failed on a null cwd for Unclassified. + Assert.False(File.Exists(legacySessionResolver)); + Assert.DoesNotContain("SessionCwdResolver.Resolve(", startViewModel, StringComparison.Ordinal); + Assert.Contains( + "AcpSessionNewCwdResolver.Resolve(requestedCwd, Chat.SelectedAcpProfile)", + startViewModel, + StringComparison.Ordinal); + } + [Fact] public void DependencyInjection_AcpEvictionOptions_DoesNotLoadAppSettingsInSingletonFactory() { diff --git a/tests/SalmonEgg.Presentation.Core.Tests/Start/StartViewModelTests.cs b/tests/SalmonEgg.Presentation.Core.Tests/Start/StartViewModelTests.cs index 11e2e2a25..b61fd5844 100644 --- a/tests/SalmonEgg.Presentation.Core.Tests/Start/StartViewModelTests.cs +++ b/tests/SalmonEgg.Presentation.Core.Tests/Start/StartViewModelTests.cs @@ -92,6 +92,7 @@ public async Task StartSessionAndSendAsync_DelegatesTrimmedPromptToWorkflow() using var nav = CreateNavigationViewModel(chat, Mock.Of(), preferences); var startViewModel = CreateStartViewModel(chat, preferences, nav, workflow.Object); + SelectStdioProfile(chat); await MakeStartDraftReadyAsync(chat, startViewModel); startViewModel.StartPrompt = " hello "; @@ -136,6 +137,7 @@ public async Task StartSessionAndSendAsync_LocalProfile_DoesNotLeakPersistedRemo using var nav = CreateNavigationViewModel(chat, Mock.Of(), preferences); var startViewModel = CreateStartViewModel(chat, preferences, nav, workflow.Object); + SelectStdioProfile(chat); await MakeStartDraftReadyAsync(chat, startViewModel); startViewModel.StartPrompt = "hello"; @@ -144,7 +146,12 @@ public async Task StartSessionAndSendAsync_LocalProfile_DoesNotLeakPersistedRemo Assert.NotNull(capturedRequest); Assert.Equal(NavigationProjectIds.Unclassified, capturedRequest!.ProjectId); - Assert.Null(capturedRequest.Cwd); + // The persisted remote path must not leak into a local launch; the local launch instead + // gets the stdio transport's own fallback root. + Assert.NotEqual("/remote/workspace", capturedRequest.Cwd); + Assert.Equal( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + capturedRequest.Cwd); } finally { @@ -168,6 +175,7 @@ public async Task StartSessionAndSendAsync_ResetsBusyState_WhenWorkflowThrows() using var nav = CreateNavigationViewModel(chat, Mock.Of(), preferences); var startViewModel = CreateStartViewModel(chat, preferences, nav, workflow.Object); + SelectStdioProfile(chat); await MakeStartDraftReadyAsync(chat, startViewModel); startViewModel.StartPrompt = "hello"; @@ -359,6 +367,214 @@ public async Task LanguageChanged_RebuildsCachedQuickSuggestions() } } + [Fact] + public async Task StartSession_WhenUnclassifiedOnStdioProfile_LaunchesWithTransportFallbackCwd() + { + var originalContext = SynchronizationContext.Current; + var syncContext = new ImmediateSynchronizationContext(); + SynchronizationContext.SetSynchronizationContext(syncContext); + try + { + var preferences = CreatePreferences(); + await using var chat = CreateChatViewModel(syncContext, preferences, Mock.Of()); + SelectStdioProfile(chat); + + using var nav = CreateNavigationViewModel(chat, Mock.Of(), preferences); + var requests = new List(); + var workflow = new Mock(); + workflow + .Setup(w => w.StartSessionAndSendAsync(It.IsAny(), It.IsAny())) + .Callback((request, _) => requests.Add(request)) + .ReturnsAsync(ChatLaunchCompletion.PromptDispatched); + + var startViewModel = CreateStartViewModel( + chat, + preferences, + nav, + workflow.Object, + localizer: new TestCoreStringLocalizer()); + await MakeStartDraftReadyAsync(chat, startViewModel); + + startViewModel.SelectedStartProjectId = NavigationProjectIds.Unclassified; + startViewModel.StartPrompt = "hello"; + await startViewModel.StartSessionAndSendCommand.ExecuteAsync(null); + + var request = Assert.Single(requests); + Assert.Equal(NavigationProjectIds.Unclassified, request.ProjectId); + Assert.Equal( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + request.Cwd); + Assert.False(startViewModel.HasStartSessionDraftError); + } + finally + { + SynchronizationContext.SetSynchronizationContext(originalContext); + } + } + + [Fact] + public async Task StartSession_WhenRemoteProfileHasNoSelectedDirectory_ReportsMissingCwdWithoutInvokingWorkflow() + { + var originalContext = SynchronizationContext.Current; + var syncContext = new ImmediateSynchronizationContext(); + SynchronizationContext.SetSynchronizationContext(syncContext); + try + { + var preferences = CreatePreferences(); + await using var chat = CreateChatViewModel(syncContext, preferences, Mock.Of()); + chat.ViewModel.AcpProfileList.Add(new ServerConfiguration + { + Id = "profile-1", + Name = "Remote Agent", + Transport = TransportType.WebSocket, + ServerUrl = "ws://127.0.0.1:3010/" + }); + chat.ViewModel.SelectedAcpProfile = chat.ViewModel.AcpProfileList[0]; + + using var nav = CreateNavigationViewModel(chat, Mock.Of(), preferences); + var workflow = new Mock(); + workflow + .Setup(w => w.StartSessionAndSendAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(ChatLaunchCompletion.PromptDispatched); + + var startViewModel = CreateStartViewModel( + chat, + preferences, + nav, + workflow.Object, + localizer: new TestCoreStringLocalizer()); + await MakeStartDraftReadyAsync(chat, startViewModel); + + startViewModel.StartPrompt = "hello"; + await startViewModel.StartSessionAndSendCommand.ExecuteAsync(null); + + // The project selector already blocks submit for a remote profile with no selected + // directory, so the launch must never reach the workflow with an unresolved cwd. + Assert.True(startViewModel.StartProjectSelectorProjection.IsSubmitBlocked); + Assert.False(startViewModel.CanStartSessionAndSendUi); + workflow.Verify( + w => w.StartSessionAndSendAsync(It.IsAny(), It.IsAny()), + Times.Never); + Assert.Equal("hello", startViewModel.StartPrompt); + } + finally + { + SynchronizationContext.SetSynchronizationContext(originalContext); + } + } + + [Fact] + public async Task StartSession_WhenRemoteDirectoryPathIsRelative_ReportsResolverReasonWithoutInvokingWorkflow() + { + var originalContext = SynchronizationContext.Current; + var syncContext = new ImmediateSynchronizationContext(); + SynchronizationContext.SetSynchronizationContext(syncContext); + try + { + var preferences = CreatePreferences(); + // A configured-but-relative remote path passes the selector (the directory is selected) + // yet fails the protocol rule, so it is the case that must reach the launch guard. + preferences.AgentRemoteDirectories.Add(new AgentRemoteDirectory + { + DirectoryId = "dir-relative", + DisplayName = "Relative", + RemotePath = "agent/worktree" + }); + + await using var chat = CreateChatViewModel(syncContext, preferences, Mock.Of()); + chat.ViewModel.AcpProfileList.Add(new ServerConfiguration + { + Id = "profile-1", + Name = "Remote Agent", + Transport = TransportType.WebSocket, + ServerUrl = "ws://127.0.0.1:3010/" + }); + chat.ViewModel.SelectedAcpProfile = chat.ViewModel.AcpProfileList[0]; + + using var nav = CreateNavigationViewModel(chat, Mock.Of(), preferences); + var workflow = new Mock(); + workflow + .Setup(w => w.StartSessionAndSendAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(ChatLaunchCompletion.PromptDispatched); + + var startViewModel = CreateStartViewModel( + chat, + preferences, + nav, + workflow.Object, + localizer: new TestCoreStringLocalizer()); + await MakeStartDraftReadyAsync(chat, startViewModel); + + startViewModel.SelectedStartProjectId = "remote-directory:dir-relative"; + startViewModel.StartPrompt = "hello"; + await WaitForConditionAsync(() => startViewModel.CanStartSessionAndSendUi); + await startViewModel.StartSessionAndSendCommand.ExecuteAsync(null); + + workflow.Verify( + w => w.StartSessionAndSendAsync(It.IsAny(), It.IsAny()), + Times.Never); + Assert.True(startViewModel.HasStartSessionDraftError); + Assert.Equal( + AcpSessionNewCwdResolver.InvalidRemoteCwdMessage, + startViewModel.StartSessionDraftErrorMessage); + Assert.Equal("hello", startViewModel.StartPrompt); + } + finally + { + SynchronizationContext.SetSynchronizationContext(originalContext); + } + } + + [Fact] + public async Task StartSession_WhenLocalProjectSelected_LaunchesWithConfiguredProjectRoot() + { + var originalContext = SynchronizationContext.Current; + var syncContext = new ImmediateSynchronizationContext(); + SynchronizationContext.SetSynchronizationContext(syncContext); + try + { + var preferences = CreatePreferences(); + preferences.Projects.Add(new ProjectDefinition + { + ProjectId = "project-alpha", + Name = "Alpha", + RootPath = @"C:\Repo\Alpha" + }); + + await using var chat = CreateChatViewModel(syncContext, preferences, Mock.Of()); + SelectStdioProfile(chat); + + using var nav = CreateNavigationViewModel(chat, Mock.Of(), preferences); + var requests = new List(); + var workflow = new Mock(); + workflow + .Setup(w => w.StartSessionAndSendAsync(It.IsAny(), It.IsAny())) + .Callback((request, _) => requests.Add(request)) + .ReturnsAsync(ChatLaunchCompletion.PromptDispatched); + + var startViewModel = CreateStartViewModel( + chat, + preferences, + nav, + workflow.Object, + localizer: new TestCoreStringLocalizer()); + await MakeStartDraftReadyAsync(chat, startViewModel); + + startViewModel.SelectedStartProjectId = "project-alpha"; + startViewModel.StartPrompt = "hello"; + await startViewModel.StartSessionAndSendCommand.ExecuteAsync(null); + + var request = Assert.Single(requests); + Assert.Equal("project-alpha", request.ProjectId); + Assert.Equal(@"C:\Repo\Alpha", request.Cwd); + Assert.False(startViewModel.HasStartSessionDraftError); + } + finally + { + SynchronizationContext.SetSynchronizationContext(originalContext); + } + } + [Fact] public async Task LanguageChanged_ReprojectsOpenStartLaunchFailureMessage() { @@ -369,6 +585,9 @@ public async Task LanguageChanged_ReprojectsOpenStartLaunchFailureMessage() { var preferences = CreatePreferences(); await using var chat = CreateChatViewModel(syncContext, preferences, Mock.Of()); + // A resolvable cwd is a precondition for reaching the workflow, so this test needs a + // stdio profile: it asserts the workflow-failure message, not the cwd guard. + SelectStdioProfile(chat); using var nav = CreateNavigationViewModel(chat, Mock.Of(), preferences); var languageService = new Mock(); var languagePrefix = "zh"; @@ -2861,6 +3080,7 @@ public async Task StartSessionAndSendAsync_ProjectsSubmitting_ThenClearsBusyStat using var nav = CreateNavigationViewModel(chat, Mock.Of(), preferences); var startViewModel = CreateStartViewModel(chat, preferences, nav, workflow.Object); + SelectStdioProfile(chat); await MakeStartDraftReadyAsync(chat, startViewModel); startViewModel.StartPrompt = "launch"; @@ -2909,6 +3129,7 @@ public async Task StartSessionAndSendAsync_FailedWorkflow_PreservesDraft() workflow.Object, loggerMock.Object, localizer: new TestCoreStringLocalizer()); + SelectStdioProfile(chat); await MakeStartDraftReadyAsync(chat, startViewModel); startViewModel.StartPrompt = "launch"; @@ -2958,6 +3179,7 @@ public async Task StartSessionAndSendAsync_WhenWorkflowFails_PreservesDraftAndSu nav, workflow.Object, localizer: new TestCoreStringLocalizer()); + SelectStdioProfile(chat); await MakeStartDraftReadyAsync(chat, startViewModel); startViewModel.StartPrompt = "launch"; @@ -2998,6 +3220,7 @@ public async Task StartSessionAndSendAsync_WhenWorkflowIncomplete_PreservesDraft nav, workflow.Object, localizer: new TestCoreStringLocalizer()); + SelectStdioProfile(chat); await MakeStartDraftReadyAsync(chat, startViewModel); startViewModel.StartPrompt = "launch"; @@ -3425,6 +3648,22 @@ private static void VerifyNoProfileConnection(Mock comma Times.Never); } + // A launch needs a resolvable cwd, and AcpSessionNewCwdResolver derives the fallback from the + // selected profile's transport. Tests that expect to reach IChatLaunchWorkflow therefore need a + // selected profile; without one the resolver treats the launch as remote and requires a + // directory, which is what the remote-specific tests assert instead. + private static void SelectStdioProfile(ChatViewModelHarness chat, string profileId = "profile-1") + { + chat.ViewModel.AcpProfileList.Add(new ServerConfiguration + { + Id = profileId, + Name = "Local Agent", + Transport = TransportType.Stdio, + StdioCommand = "agent" + }); + chat.ViewModel.SelectedAcpProfile = chat.ViewModel.AcpProfileList[^1]; + } + private static async Task MakeStartDraftReadyAsync( ChatViewModelHarness chat, StartViewModel startViewModel) From 82a58f52d71568bee94ba07d6617806a5010e14b Mon Sep 17 00:00:00 2001 From: Shangxin Date: Tue, 1 Sep 2026 08:45:25 +0000 Subject: [PATCH 2/3] docs(agents): distill the single-owner fallback and failure-attribution rule Documentation-only change; no tests run (docs-only per AGENTS section 5.4). Co-Authored-By: Claude Opus 5 (1M context) --- AGENTS.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index b46074b05..642ee9c94 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -106,3 +106,5 @@ - 跨持久化边界的补偿事务:当一次用户可见配置变更同时触及独立 durable store(例如 YAML 与 secure storage)时,必须以可恢复 transaction/backup 维护旧状态直到所有 store 明确完成;任一可捕获的 apply/flush/secret mutation 失败都必须恢复并 flush 两侧,或明确报告 recovery-required/indeterminate,绝不可把“写入抛异常”当作“目标一定未变”后只回滚其中一侧。禁止先提交一侧、丢弃另一侧 rollback material,再以 best-effort 日志宣称成功;也禁止用 whole-root 同步备份替代单 profile 操作的所有权边界。验证覆盖候选文件已替换后 flush 抛错、首写后 flush 抛错、部分 secret mutation、restore flush 再失败、delete 中途失败与释放故障后安全重试。 - `ItemContainerStyle` 与跨端基样式接缝:`ItemContainerStyle` **替换**框架的隐式 `ListViewItem` 样式而非与之合并,而 WinUI 的隐式样式自身不声明 setter、全部内容就是 `BasedOn="{StaticResource DefaultListViewItemStyle}"`,所以不重新 chain 的容器样式会连 `Template` 一起丢掉,随之丢掉画圆角、左侧选中竖条与 hover/pressed/selected 填充的 `ListViewItemPresenter`——**表现为行完全没有原生 chrome,但编译、单元测试与静态检查全绿**。触发条件:为 `ListView`/`GridView` 家族声明任何 `ItemContainerStyle`(含 inline 与 `VisualState` 换 style 的密度切换)。原生期望行为:**chrome 归容器、布局归模板**——容器样式只覆写 metrics,状态视觉留给继承来的行模板;`DataTemplate` 根必须是无 `Background` 的透明容器,不透明卡片 `Border` 会盖死其下的原生填充。**基样式必须走应用自有接缝且用官方条件 XAML 表达平台差异**:两平台机制相反——Uno 的 `ApplyStyles` 会在 explicit style 之后按更低的 `ImplicitStyle` 优先级再套默认样式,故省略 `Template` 也能拿到模板;而它的 keyed `DefaultListViewItemStyle` 包着一个从未实现的 `ListViewItemPresenter`(unoplatform/uno#1444)、模板内零个 `VisualStateGroup`,直接 chain 过去反而会把空模板提到 `ExplicitStyle` 优先级、盖掉 Uno 默认装上的可用 `ListViewItemExpanded`。正解是单一接缝 key,其 Windows 专属基样式用 Uno 官方条件前缀 `win:` 声明(`Uno.UI.SourceGenerators.props` 无条件把 `win` 列入 `ExcludeXamlNamespaces`,故 Uno 生成器整条剥掉该属性;在 Windows 上该前缀映射到默认 presentation 命名空间,`win:BasedOn` 就是 `BasedOn`),一份声明两端皆对。`CornerRadius` 声明在接缝上一次即可(`BasedOn` 链会传播 setter):Uno 行模板从容器 template-bind 圆角且**不定义** `ListViewItemCornerRadius`,省略即在 Skia/WASM 渲染成直角;WinUI 的 presenter 读 `ThemeResource ListViewItemCornerRadius`、这个 setter 在那边惰性。必须用 `ControlCornerRadius`(两端都有,均为 4),不可用看起来更专指的 `ListViewItemCornerRadius`(Uno 无此键,会静默失效)。禁止做法:容器样式不写 `BasedOn`,或直接 chain 到 `DefaultListViewItemStyle`(Windows 对、Skia 错);用 `DataTemplate` 里的不透明 `Border` 自绘选中/hover 视觉;**用 C# 运行时覆盖(加载平台字典再 `Resources[key] = style`)表达本可声明式表达的平台差异**——条件前缀能表达时,运行时覆盖是纯粹的技术债:它把差异藏进构造期、需要 `ms-appx` 加载与 best-effort catch、还得靠实跑探针才能证明生效;**跨端做框架默认值的算术**——`DefaultListViewItemStyle.Padding` 在 WinUI 是 `16,0,12,0`、Uno 实测是 `12,0,12,0`,任何「框架默认 + 模板内距 = 某常量」的对齐在另一端必然偏移,把该常量硬编码进资源并写门禁断言它,等于让门禁永久绿着守一个假不变量(若两个列表确需同一内距,让它们引用同一个资源 key,别各自推算)。**已经没有容器样式的列表不要为此新增样式**:它继承的隐式样式本就完整,加一个(即便走接缝)会替掉框架 `Padding` 并改变参照渲染。验证覆盖:fail-closed 门禁穷举全仓 `ListViewItem` 样式并要求每个都指向接缝(接缝定义文件与显式豁免除外,豁免必须由**属性**证明而非文件名白名单——如 transcript 行须同时满足 `Padding=0`、`MinHeight=0` 与所属列表 `SelectionMode="None"`,否则同文件里未来新增的真实行选择列表会静默继承豁免);门禁须断言接缝的 `BasedOn` 落在 **Windows presentation 命名空间**下且前缀字面量为 `win`(无前缀的 `BasedOn` 正是要抓的回归),并断言圆角资源不是 Uno 缺失的那个键;必须列名而非计数(计数会因无关增删漂移);逐条反向验证并记录失败输出;平台专属渲染结论只能由**该平台实跑**确认——生成代码可证明属性被剥离,但框架默认值这类事实必须运行时探针读取,静态推断与"编译过"都不算证据。 - `x:Uid` 本地化属性与控件属性面:resw 的 `Uid.Property` 键是数据——XAML 编译器看不到、构建期不解析、ViewModel 测试也碰不到,WinUI 只在 `Application.LoadComponent` 里解析它;控件不拥有的属性会在那里抛 `XamlParseException`,而这发生在 `InitializeComponent` 内,页面根本无法构造、`Frame.Navigate` 直接重抛,shell 首帧就绪后 `UnhandledException` 又会把它标 handled,用户侧表现为「点按钮完全没反应」且界面上没有任何痕迹(只有日志与遥测里的 fatal 记录)。触发条件:为元素新增 `x:Uid`、改动已带 `x:Uid` 的元素类型,或新增/重命名 resw 键(最易犯的是把 `Expander.Header` 里的 `TextBlock` 当按钮写成 `.Content`)。原生期望行为:属性名只能取自该元素自身的属性面——`TextBlock` / `Run` / `MenuFlyoutItem` 是 `Text`,`ContentControl` 家族是 `Content`,文本输入与 `ComboBox` 是 `Header` / `PlaceholderText`,`ToggleSwitch` 是 `OnContent` / `OffContent`,`InfoBar` 是 `Title` / `Message`,`AppBarButton` 另有 `Label`;附加属性只能用 `AutomationProperties.*` / `ToolTipService.ToolTip` 这类真实存在且取字符串的路径(`[using:…]` 限定形式与裸形式等价);应用自有控件以其自身 string 型 `DependencyProperty` 声明为准,不另立表。禁止做法:用「构建成功 + 单元测试全绿」判定页面能打开(两者都不解析 resw);把校验写成「未知组合放行」的宽松扫描(正是本次漏网原因,门禁必须 fail-closed);只改当前语言的 resw 而漏掉其他语言目录;给不拥有任何字符串属性的元素(`FontIcon` 等)挂文本键。验证覆盖:fail-closed 静态门禁交叉核对全仓 `x:Uid` 元素类型与**所有**语言 resw 键(未知控件类型、未知附加属性一律失败);该门禁必须反向验证——插回错误属性名、未知附加属性、未描述控件三条分支各自见红并记录失败输出;以及在目标平台真实安装包上把新页面导航一次的 GUI smoke,纯单元测试不构成安全网。 + + - 兜底值的单一 owner 与失败归因:当同一个业务值(工作目录、根路径、身份、超时)既被**预备阶段**(草稿 / 预览 / 校验)消费、又被**执行阶段**(提交 / 启动 / 发送)消费时,它必须由同一个 resolver 从同一个请求源解析。触发条件:两阶段各自算一遍,且其中一个带兜底(如 stdio 缺目录时退到用户主目录)而另一个不带。原生期望行为是兜底规则连同其**分类依据**(此处为传输类型)一起收进那个 resolver,调用方只传原始请求值与分类依据。**此类缺陷最隐蔽的形态是「预备阶段成功、执行阶段失败」**:草稿能建、按钮亮着、选择器认为选择合法,用户点下去才失败,且失败文案是「请稍后重试」——而重试永远不会让缺失的值长出来。因此不可重试的失败必须呈现 resolver 自己的原因(「请先选择远程目录」而非「请稍后重试」),并且**不得只靠一道守卫**:既有的选择器/命令门禁拦住的是它能判定的子集,resolver 守卫是第二道,二者断言不同契约、不可互相替代。禁止做法:为「不是真实实体」的合法哨兵值(未归类 / 全部 / 默认)返回 null 再让下游当错误;在执行阶段新写一份兜底;把不可重试失败呈现为可重试;留下第二个 resolver 并让其唯一调用点传常量参数(它退化成 Trim 包装,看起来仍是「架构层」)。验证覆盖:哨兵值 + 各传输类型的组合各跑一遍(本地兜底必须成功启动、远程必须拒绝并给出原因);**反向验证必须把缺陷种回去**并确认新增测试见红(仅新测试全绿不构成证据);删除第二个 owner 时以 fail-closed 门禁断言其文件不存在且执行阶段只出现唯一 resolver 调用;以及审计**所有**触达执行阶段的既有测试——它们可能把缺陷行为写成断言(如 `Assert.Null(cwd)`),也可能因为夹具从未配置分类依据而在守卫前 return,使 `await 某个只在下游置位的 TaskCompletionSource` 永久挂死:表现为整套测试**无任何失败输出地超时**,而单跑新测试却是秒级全绿。 From e61a1fe1ce351094b2abf6287d329dc45de4cc18 Mon Sep 17 00:00:00 2001 From: Shangxin Date: Tue, 1 Sep 2026 09:12:51 +0000 Subject: [PATCH 3/3] style(tests): fix whitespace around the new start-launch cwd gate Inserting the gate left the following [Fact] indented by 8 spaces and added a stray blank line, which failed the Code Quality formatting gate. Verified with the gate's own command (dotnet format --verify-no-changes over the six touched files) and by rerunning NavigationCoreTests + StartViewModelTests: 153 passed, 0 failed. Co-Authored-By: Claude Opus 5 (1M context) --- tests/SalmonEgg.Presentation.Core.Tests/NavigationCoreTests.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/SalmonEgg.Presentation.Core.Tests/NavigationCoreTests.cs b/tests/SalmonEgg.Presentation.Core.Tests/NavigationCoreTests.cs index 9fb1507a5..5b3279d7e 100644 --- a/tests/SalmonEgg.Presentation.Core.Tests/NavigationCoreTests.cs +++ b/tests/SalmonEgg.Presentation.Core.Tests/NavigationCoreTests.cs @@ -951,8 +951,7 @@ public void ChatViewCodeBehind_UsesFollowControllerWithoutProjectionEpoch() Assert.DoesNotContain("ProjectionEpoch", controllerCode, StringComparison.Ordinal); } - - [Fact] + [Fact] public void ChatViewCodeBehind_DelegatesUserDetachIntentToViewportController() { var code = LoadFile(@"SalmonEgg\SalmonEgg\Presentation\Views\Chat\ChatView.xaml.cs");