Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` 永久挂死:表现为整套测试**无任何失败输出地超时**,而单跑新测试却是秒级全绿。
Original file line number Diff line number Diff line change
Expand Up @@ -1208,6 +1208,9 @@ Create the corresponding Markdown file in:
<data name="NewSessionDraft_InvalidRemoteCwd" xml:space="preserve">
<value>The remote working directory must be an absolute path.</value>
</data>
<data name="NewSessionDraft_MissingRemoteCwd" xml:space="preserve">
<value>Select a remote directory before creating a remote session.</value>
</data>
<data name="ChatBinding_UpdateFailedWithStatus" xml:space="preserve">
<value>Failed to update conversation binding ({0}): {1}</value>
</data>
Expand Down
3 changes: 3 additions & 0 deletions src/SalmonEgg.Presentation.Core/Resources/CoreStrings.en.resx
Original file line number Diff line number Diff line change
Expand Up @@ -1208,6 +1208,9 @@ Create the corresponding Markdown file in:
<data name="NewSessionDraft_InvalidRemoteCwd" xml:space="preserve">
<value>The remote working directory must be an absolute path.</value>
</data>
<data name="NewSessionDraft_MissingRemoteCwd" xml:space="preserve">
<value>Select a remote directory before creating a remote session.</value>
</data>
<data name="ChatBinding_UpdateFailedWithStatus" xml:space="preserve">
<value>Failed to update conversation binding ({0}): {1}</value>
</data>
Expand Down
3 changes: 3 additions & 0 deletions src/SalmonEgg.Presentation.Core/Resources/CoreStrings.resx
Original file line number Diff line number Diff line change
Expand Up @@ -1214,6 +1214,9 @@
<data name="NewSessionDraft_InvalidRemoteCwd" xml:space="preserve">
<value>The remote working directory must be an absolute path.</value>
</data>
<data name="NewSessionDraft_MissingRemoteCwd" xml:space="preserve">
<value>请先选择一个远程目录,然后再创建远程会话。</value>
</data>
<data name="ChatBinding_UpdateFailedWithStatus" xml:space="preserve">
<value>Failed to update conversation binding ({0}): {1}</value>
</data>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1214,6 +1214,9 @@
<data name="NewSessionDraft_InvalidRemoteCwd" xml:space="preserve">
<value>远程工作目录必须是绝对路径。</value>
</data>
<data name="NewSessionDraft_MissingRemoteCwd" xml:space="preserve">
<value>请先选择一个远程目录,然后再创建远程会话。</value>
</data>
<data name="ChatBinding_UpdateFailedWithStatus" xml:space="preserve">
<value>更新会话绑定失败({0}):{1}</value>
</data>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,10 +75,10 @@ public async Task<ChatLaunchCompletion> 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;
}
Expand Down

This file was deleted.

35 changes: 25 additions & 10 deletions src/SalmonEgg.Presentation.Core/ViewModels/Start/StartViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -610,7 +621,7 @@ private async Task StartSessionAndSendAsync()
new ChatLaunchRequest(
promptText,
NormalizeProjectSelectionValue(SelectedStartProjectId),
ResolveDefaultCwd()))
cwdResolution.Cwd))
.ConfigureAwait(true);
switch (completion)
{
Expand Down Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ internal sealed class TestCoreStringLocalizer : IStringLocalizer<CoreStrings>
["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}",
Expand Down

This file was deleted.

23 changes: 21 additions & 2 deletions tests/SalmonEgg.Presentation.Core.Tests/NavigationCoreTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down Expand Up @@ -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");
Expand Down
Loading
Loading