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)