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` 永久挂死:表现为整套测试**无任何失败输出地超时**,而单跑新测试却是秒级全绿。 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..5b3279d7e 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() { @@ -931,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"); 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)