一次桌面动作验证器的修正:没看见效果,不等于看见了失败。

1. 验证器也会制造事故⌗

设想一个抽象的文本编辑器。Agent 按下保存快捷键,读回编辑框的文字,发现内容和之前完全一样。

于是工具报告:没有变化,动作失败。

Agent 再按一次。

这个判断的问题不在于截图不够清晰,而在于保存本来就不需要改变正文。观察的是正文,想证明的却是持久化结果;两个问题根本不是一回事。

换成 Tab,效果可能在焦点上;换成全选,目标状态可能早就成立。一个统一的“前后不同才算成功”,会同时误伤跨控件动作和幂等动作。

更危险的是,失败标记不是静态日志。它会进入 Agent 的下一轮决策,诱发重试。对导航、提交、切换状态之类的操作,第二次执行不一定是在补救第一次,也可能是在破坏第一次的结果。

验证器不只是观察者。它的判断,会改变系统接下来做什么。

2. 从二选一变成三个判断⌗

这次修正没有重做整个桌面控制系统。元素引用、动作后证据、必要时临时借用前台并归还的机制都保留了,改的是证据如何成为结论。

判定被抽成一个纯逻辑模块 PostConditions.cs,有三个结果:

结果含义工具层处理
Confirmed支持当前判据的正面证据正常返回
Absent读回结果与预期效果矛盾报 no_change,批次停止
Unknown现有证据不足以判定返回 verifiable:false,不因这一点重发

关键不是多一个枚举,而是把两件过去混在一起的事拆开:

执行出了问题                     → 处理错误
执行后缺少足够的验证证据         → 保留未知

工具能正常返回,但无法证明动作的最终效果,这不是自相矛盾。它只意味着“执行路径没有在这里报错”和“目标已经完成”不是同一个承诺。

相比统一比较前后状态,这个设计的进步是:失败需要反证,不再只需要缺少正证。

3. 后置条件必须跟着动作走⌗

同样是“没有变化”,动作不同,结论也不同。

全选:看现在是不是全选,不看刚才有没有变化⌗

AfterChord 对严格的 Ctrl+A 单独处理:读回值与选区都可用时,检查选区是否从零开始并覆盖文本。已经全选,再按一次,仍然满足条件。

判据不再是:

selection_after != selection_before

而是:

selection_after covers the text

实现还处理了文本模式对末尾换行的计数差异:计算覆盖长度时去掉末尾 CR/LF。这里不是追求抽象优雅,而是在适配观测接口的具体表示差异。

保存与导航:编辑框不是万能传感器⌗

普通快捷键没有一律套用“状态未变即失败”。前后值和选区都没变化时,判为未知;读不到值时也一样。

这不证明保存成功,也不证明焦点已经转移。它只拒绝用错误的传感器作出失败判决。

纯文字输入:仍然保留失败路径⌗

纯文字输入的判据较严格:读回值须包含输入的文字片段,而且值须发生变化。读回值可用,却不满足条件,仍然失败。

如果输入序列还包含 Tab、Enter 等按键,它就不再只是“往当前框里加文字”。这些按键可能导航或提交,值不变不能直接判失败;当前实现把这类不变结果归为未知。

点击:只检查确实能读到的相关状态⌗

对于可读的开关、展开或选择状态,点击前后相同可以成为失败证据;动作后读不到状态,则是未知。

这不是对所有按钮作出的承诺。普通按钮可能没有可读的状态变化;调用层对已经选中的元素也作了特殊处理,不要求再点击就必须变成另一个状态。

以上规则来自 PostConditions.cs 与 Act.cs。它们说明一个原则:先确定动作承诺改变什么,再决定观察什么。

4. 不可读不是空字符串⌗

这次改动里很容易被忽略的一笔,是把读值失败时的返回值从空字符串改成 null。

读回值实际知道的事
""成功读到了一个空值
null没有读到值

把两者合并,会凭空制造事实:控件明明不可读,验证器却宣称它是空的,然后依据“空值里没有输入文字”报告失败。

新的 EditSnapshot 让值和选区各自保留未知。日志也将不可读标成 (unreadable),而不是伪装成空文本。

这件事很朴素,但比再加一轮截图更根本:先别在采集证据时把“不知道”洗成“知道没有”。

5. 批次继续,不等于可以宣布完成⌗

在接口上,有两个名字很像、语义却必须分开的字段:

  • verified:false:原生 helper 用它携带验证失败及证据,适配层转成 ok:false。
  • verifiable:false:当前证据不足以验证,不被适配层转成失败。

这个区别决定批次是否中止。未知步骤可以继续,明确失败则停止,失败时仍保留证据。

但继续执行不是盲目信任。技能文档明确要求:后续步骤如果依赖保存或提交的实际结果,就针对那个结果观察一次,而不是重发按键。

一个合理的抽象流程是:

发送保存快捷键
    ↓
正文没变,效果未知
    ↓
观察与保存相关的状态
    ↓
依据该状态决定是否继续依赖保存结果的操作

不必为每个键都截图,也不能把一整串依赖未知结果的动作塞进批次后闭眼执行。观察点应该放在依赖边界上,而不是机械地放在每次动作后面。

6. 测试证明的范围⌗

落地版本的原生判定测试定义了 21 个断言案例;测试驱动要求得到 21 行 PASS。这里引用的是测试规格,不是一次新的真实桌面实测报告。

覆盖包括:

测试场景期望
保存快捷键后正文未变未知,不失败
Tab 后编辑值未变未知,不失败
对已全选文本再次全选条件成立
全选后仍为局部选区失败
纯文字没有写入失败
动作后值不可读未知
开关点击后状态未变失败

另一项批次测试通过模拟 helper 返回值,验证保存、Tab、两次全选和含 Tab 的输入能连续通过;它还检查发出的命令序列,没有因为未知而额外重发。另一路模拟文字写入失败,断言后面的 Enter 不会执行。

这些测试的价值很明确:原生判据与批次传播语义都有约束。边界也很明确:它们没有证明任意桌面应用都会及时、准确地暴露控件状态,更没有证明快捷键触发的业务结果已经完成。

证据位置:native/input-helper-win-tests/PostConditionTests.cs、skills/desktop-control/backends/post-conditions.test.ts 与 skills/desktop-control/desktop.test.ts,对应本文讨论的落地版本。

7. 三态不是万能药,判据仍有债⌗

这份实现并没有彻底解决动作语义。

第一,普通快捷键只要读到值或选区变化,当前逻辑就会返回 Confirmed。这至多说明观测到了变化,未必说明该快捷键的完整意图已经实现。含控制键的输入,也存在类似边界。不能因为枚举叫“已确认”,就把局部证据升级成业务成功。

第二,纯文字输入要求前后值不同。从代码可以推导:若先选中一段文字,再输入完全相同的内容,即使替换确实发生,也会被这个判据判为失败。它适合检验通常的新增输入,却不能完整表达“把字段设成某值”。后者应检查目标值是否成立,而不是强制要求发生变化。

第三,可读不等于及时。一次立即读回的旧状态,仍可能只是应用尚未更新。本文这些测试没有覆盖真实应用的异步时序,不能据此宣称所有 Absent 都是现实中的最终失败。

所以这次修正值得保留的,不是“验证已经完美”,而是一个更好的落点:把判据集中起来,让未知可以原样穿过工具协议,并让错误仍然停止批次。剩下的语义缺口由具体动作逐个解决,而不是再发明一个万能的前后差分。

桌面 Agent 最该少做的一件事,就是因为自己没看懂,便再按一次。

不知道可以保留;重复动作不能撤回到第一次之前。