界面没变,别再按一次:桌面 Agent 的第三种结果

一次桌面动作验证器的修正:没看见效果,不等于看见了失败。
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 最该少做的一件事,就是因为自己没看懂,便再按一次。
不知道可以保留;重复动作不能撤回到第一次之前。