diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f71e16..098b219 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## 0.17.0 (unreleased) +- Add an explicit name + CheckBox selector mode to checkbox-audit for controls without AutomationIds. Validate only task-relevant known properties while retaining raw gaps and refusing uncertain identity, ambiguity, unknown state and incomplete coverage. Preserve the existing ID-based parameter, result and validation contracts; named results return exact selectors in request order. No UI actions or default configuration changes. - Reorganize the four managed Skill documents by task. Keep the entry short, place executable UI recipe examples inside the installed Skill, and remove duplicated instructions and historical iteration notes. - Add the readonly sibling-range client recipe for flat lists whose headings and items are siblings. Read one explicit container, validate unique ordered boundaries and readable row names, and retain native identities and property gaps. Results describe only the observed logical range; missing/ambiguous boundaries and incomplete reads stop without inventing an empty or complete business group. No scrolling or item actions are performed. diff --git a/README.md b/README.md index 41d9210..55e4cd1 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,10 @@ The persistent JSON Skill session also supports explicit readonly workflows, checkbox summaries and one-parent expansion with observed ancestor paths. See [client workflows and validation boundaries / 客户端工作流与验证边界](docs/hybrid-readonly-orchestration.md). 这些流程通过显式调用启用;支持按实际父路径定位、一次展开与子控件状态读回,不自动递归导航或调用模型 API。 +Checkbox audits accept either AutomationIds or exact name + CheckBox selectors. Real-application coverage and performance remain subject to validation; merging these capabilities does not change client defaults. 复选框统计可使用 ID 或精确名称与类型;真实应用覆盖与性能仍须验收,合并不改变客户端默认配置。 + +For repository maintenance, start with [current work and acceptance criteria / 当前任务与验收条件](docs/next-iteration.md), [architecture / 架构与数据流](WinCode-架构与数据流说明.md), or [contributing and verification / 开发与验证](CONTRIBUTING.md). Keep private screenshots and raw diagnostic results in ignored local directories; publish only sanitized findings. 本机截图和原始诊断材料保留在忽略目录,公开内容仅包含去标识后的结论。 + ## English WinCode is a local server implementing the Model Context Protocol (MCP) for AI coding agents. It combines Windows UI Automation (UIA), code navigation and .NET project analysis, so an agent can inspect a running application and investigate its source code through the same connection. diff --git a/docs/hybrid-readonly-orchestration.md b/docs/hybrid-readonly-orchestration.md index 792bca2..004c2ad 100644 --- a/docs/hybrid-readonly-orchestration.md +++ b/docs/hybrid-readonly-orchestration.md @@ -24,7 +24,9 @@ {"id":"audit1","action":"readonly-ui","recipe":"checkbox-audit","target":{"pid":1234,"hwnd":"0x123456"},"parameters":{"scopePath":[{"automationId":"voiceSettings"}],"summaryAutomationId":"summary","regionAutomationId":"checks","checkboxAutomationIds":["optionA","optionB"],"maxDepth":4,"maxNodes":40},"timeoutMs":15000} ``` -- checkboxAutomationIds:1–64 个互不重复的 ID。每个目标必须在指定区域内唯一,为 CheckBox,isEnabled 已知,toggle 为 On 或 Off。 +- checkboxAutomationIds:1–64 个互不重复的 ID。每个目标必须在指定区域内唯一,为 CheckBox,isEnabled 已知,toggle 为 On 或 Off。原有 ID 模式的参数、结果形状和属性检查保持不变。 +- 没有 AutomationId 的控件可改用 checkboxSelectors,例如 `[{"name":"24 小时制","controlType":"CheckBox"}]`;与 checkboxAutomationIds 二选一。接受 1–64 个互不重复的精确名称,controlType 必须为 CheckBox,不接受其他选择字段。区域仍用 regionAutomationId,摘要仍用可选 summaryAutomationId。 +- 名称模式的 unchecked、disabled 返回对应选择器对象,顺序与请求一致;checkedCount 仍统计所有 On,包括禁用目标。原始节点身份、状态和属性缺口保留在 steps.evidence。 - summaryAutomationId 可省略。提供时先读摘要:On 才读详情,Off 返回 detailsRequired:false;未知或缺失停止。 - scopePath 可省略;提供时包含 1–50 个仅使用 automationId/name/controlType 的精确父选择器,摘要和详情共用该祖先范围。每次读取重新解析,不复用摘要的定位对象。不提供路径时沿用原查询范围和调用数。 - maxDepth 默认 4,允许 1–50;maxNodes 默认 300,允许 1–5000。详情区域一次读取后在客户端统计,禁用且 On 的控件仍计入 checkedCount。 @@ -34,7 +36,9 @@ 带路径的读取要求 inspectionVersion 5,并确认 scopeResult 已解析全部父级;旧版本返回 VERSION_MISMATCH,缺少范围证据停止为 INCOMPLETE_OBSERVATION。原生 SCOPE_NOT_FOUND/SCOPE_AMBIGUOUS/SCOPE_SEARCH_INCOMPLETE 原样保留,步骤证据包含 scopeResult,不回退整窗。路径不会自动展开隐藏区域,也不裁剪窗口截图;摘要和详情没有合适共同祖先时使用显式单次读取。 -checkbox-audit 允许 className、bounds、isOffscreen 的辅助属性缺口,仍在步骤证据中保留原始 propertyIssues 和数量;不因这些缺口丢弃已观察到的状态。身份、启用状态、toggle、搜索/遍历完整性、歧义和未分类缺口仍按原规则判断。可信 TypeScript 调用方可在 reader.inspect 中显式设置 allowAuxiliaryPropertyGaps:true 使用同一规则;此选项默认关闭,只在客户端解释,不发送给 MCP,不属于 JSON 配方参数。reader.review 的严格行为不变。 +ID 模式允许 className、bounds、isOffscreen 的辅助属性缺口,仍在步骤证据中保留原始 propertyIssues 和数量;身份、启用状态、toggle、搜索/遍历完整性、歧义和未分类缺口仍按原规则判断。可信 TypeScript 调用方可在 reader.inspect 中显式设置 allowAuxiliaryPropertyGaps:true 使用同一规则;此选项默认关闭,只在客户端解释,不发送给 MCP,不属于 JSON 配方参数。reader.review 的严格行为不变。 + +名称模式在区域后代中检查精确名称与 CheckBox 类型;同名 Text 不计入。仅当某个可读字段明确不匹配时才排除节点,身份不全且可能匹配的节点仍阻断唯一性。所选控件必须有可读名称、类型、isEnabled 和确定的 On/Off,区域 ID 也必须可读。已知且无关的属性缺口保留但不阻断;未分类缺口和无法解释的缺口总数仍阻断。该规则只由名称配方通过客户端 allowPropertyGaps 执行,不改变默认 reader、ID 模式或原生工具的行为。 ## 同级标题之间的条目 diff --git a/docs/next-iteration.md b/docs/next-iteration.md index 2fcab26..b8c643d 100644 --- a/docs/next-iteration.md +++ b/docs/next-iteration.md @@ -1,114 +1,70 @@ # WinCode 效率与准确性迭代 -更新:2026-10-08。本文是唯一的后续任务入口,只保留未完成工作。接手时核对当前分支、远端差异和工作区改动。待办包含尚未实施和验收未闭合的项目;当前接口以使用说明为准,尚未取得新的性能比较结果。 +更新:2026-10-10。本文是后续任务入口,只保留当前限制、验收条件和有证据触发的候选工作。接口以[客户端使用说明](hybrid-readonly-orchestration.md)为准;实现分层见[架构与数据流](../WinCode-架构与数据流说明.md),已完成变更见 [CHANGELOG](../CHANGELOG.md)。 -## 目标与下一步 +## 当前状态与优先事项 -优先提高一次任务得到正确结果的概率,再减少无效读取、模型往返和等待。不能靠忽略必要证据提高成功率,也不能靠少返回关键信息降低 token。 +`checkbox-audit` 已支持精确 name + CheckBox 选择器,兼容旧 AutomationId 模式;针对性回归及核心检查通过。混合流程仍由调用方显式启用,不自动调用模型或切换客户端默认配置。 -**C 的设置复选框实验仍待实际设置页。** 先确认具体控件与独立预期,再进入配对试验;当前没有性能比较结论,不一次实现整张路线表。 +真实应用的名称模式验收尚未闭合:在一个 CEF 设置页面中,原生 UIA 未稳定暴露业务控件。Control/Raw View 对照、MSAA 接口读取及一次授权重启均未恢复可验证的复选框集合,因此未进入原生与配方的配对试验。不能据此宣称真实适配通过、稳定性能收益或默认化验收通过,也没有证据把原生取证失败归因于名称配方。 -日常只读本文;调用客户端时查[使用说明](hybrid-readonly-orchestration.md),定位实现时再查[架构](../WinCode-架构与数据流说明.md)。历史计划与过程日志已从工作树移除,需要追查时按 PR 或 Git 历史读取,不默认加载旧版全文。 +优先定位原生取证缺口;只有新证据支持具体假设时才继续诊断,不重复重开窗口、重启应用或放宽完整性要求。CEF 的[可访问性接口说明](https://github.com/chromiumembedded/cef/blob/6478/include/cef_browser.h#L839-L865)仅提供机制线索,不证明目标应用的具体根因。 -## 需求依据 +## C.真实任务验收与效率基线 -| 用户任务 | 已知事实或需求假设 | 优先处理 | -|---|---|---| -| “实际软件能否像测试窗口一样可靠使用” | 真实应用已有单候选/单控件证据,多候选和条件统计主要在夹具验证 | C:迁移一个真实任务 | -| “能不能少等、少读、少花 token” | 存在小样本记录,尚无代表性任务的稳定收益证据 | C 先测完整成本,再决定 D | -| “检查设置值、列表选择或另一层页面” | 合理但尚未确认频率的需求;当前展开编排最终只接受 toggle 状态 | 先收集具体目标,按一个类型扩展 | +### 范围与准备 -表中后两项属于需求假设,不自动启动新功能或外部模型实验。其他无关节点是否造成误拒绝,须用具体场景复现后再决定改法。 +- 只测试用户授权的目标应用和页面。导航按实际观察到的唯一语义控件执行;不更改业务设置。重启应用、改启动参数或环境须另获授权,不自动添加坐标或浏览器后端。 +- 核对被测源码、工作区改动、磁盘交付和实际会话身份。测试期间保持实现不变,只复用一个会话;旧连接不能代替当前构建。 +- 选取 2–6 个实际存在的复选框,从独立截图或用户明确陈述建立预期。无法确认的字段记 unknown,不复制配方结果作为预期。 +- 冻结目标、父范围、区域 ID、名称或 ID 选择器、预期和预算。仅在页面实际存在业务摘要开关时测试条件分支;没有摘要时明确条件分支未覆盖。 +- 缺少唯一身份、共同父范围、独立预期或完整原生观察时停止准备,记录 blocked/unsupported,不开始计分。 -## 借鉴与取舍 - -| 官方资料与成熟实现 | 可借鉴的做法 | 在本项目的应用判断 | -|---|---|---| -| [Playwright Locators](https://playwright.dev/docs/locators) | 逐步缩小定位范围,动作时重新解析目标,单目标操作保留唯一性检查 | 复用现有父路径与新鲜定位;不靠 first/nth 消除歧义,不保存跨请求 COM 元素 | -| [Playwright Actionability](https://playwright.dev/docs/actionability) | 不同操作检查不同前置条件 | 按“读取、统计、操作”判断必要字段;UIA 与浏览器机制不同,不直接移植浏览器等待或 force 行为 | -| [Anthropic 工具设计](https://www.anthropic.com/engineering/writing-tools-for-agents) | 依据真实任务评估,返回相关信息和可操作错误,避免无关上下文 | 优先改已有配方和 compact 输出;保留错误、范围及截断标识,不再增加通用工具目录 | -| [Anthropic Agent 评估](https://www.anthropic.com/engineering/demystifying-evals-for-ai-agents) | 区分能力探索和回归,用可验证结果评估任务 | C 同时核对业务结果和执行轨迹;允许不同合法调用路径,失败成本不能排除 | -| [Microsoft UIA 批量获取属性](https://learn.microsoft.com/en-us/windows/win32/winauto/uiauto-cachingforclients) | 一次取得所需属性,减少逐属性跨进程调用;缓存需要刷新 | 仅当 D 证明属性读取是主要瓶颈时,评估单次请求内批量读取;不直接新增全局缓存 | - -真实 issue 的适用边界:[FlaUI #357](https://github.com/FlaUI/FlaUI/issues/357) 中维护者复现了 ScrollViewer 子控件 IsOffscreen 不反映实际可见范围,说明可见性与语义状态不能混为一谈;[FlaUI #552](https://github.com/FlaUI/FlaUI/issues/552) 报告重启应用后属性不受支持,评论中的旧状态猜测没有形成已验证修复,不能据此改 WinCode 生命周期。属性不受支持也不能用 false 代替真实状态,见 [Microsoft 属性读取规则](https://learn.microsoft.com/en-us/windows/win32/winauto/uiauto-propertiesforclients)。 - -[FlaUI #216](https://github.com/FlaUI/FlaUI/issues/216) 报告同 ID 的多个视图中 FindFirstDescendant 读到错误元素,报告者改用 FindAllDescendants 后关闭 issue。这里采纳的是“首个匹配不代表目标正确”的教训;本项目继续要求明确父范围内唯一匹配,不能用遍历顺序消除歧义。 - -资料于本次联网查阅。上表最后一列是针对 WinCode 的设计判断,外部成功经验不构成本地验证。无需为采纳这些原则升级 SDK、引入 Playwright、修改运行环境或增加服务。 - -## C.实际任务验收与效率基线 - -### 实验问题与范围 - -由 GPT-6.1 Sol、high 推理强度独立测试:当前未提交构建能否在 Steam 实际设置页面正确读取指定复选框,且配方相对原生读取是否减少调用方负担。测试者重新取得观察和预期,不沿用开发者的成功结论。这里比较两种客户端执行方式,不比较模型能力,不调用外部模型 API。 - -只使用用户已打开的 Steam。允许按实际观察到的唯一可操作控件打开设置和一个相关页签;导航属于准备阶段,至多 3 个语义动作,不更改设置、账号、下载或交易状态。无法通过已支持的语义动作到达时报告需用户打开的具体页面,不新增坐标、键盘或浏览器后端,不重启应用、改启动参数或扫描其他窗口。后台截图保持 backgroundOnly:true;不为取图恢复或激活窗口。 - -当前阻断:独立测试在商店首页未取得复选框案例;一次菜单 Invoke 虽被受理,约 33 秒后的读回未确认导航成功,不能排除菜单曾短暂出现。需用户手动打开相关设置页后重新准备;设置页的 UIA 兼容性仍未知。测试者不得用 WPF 夹具代替 Steam,也不把成功读取 Document 当作条件统计通过。 - -### 准备与独立预期 - -1. 记录实际分支、HEAD、dirty 状态;保存工作区 diff 和本轮涉及文件 SHA-256 到忽略目录。核对磁盘构建、delivery:verify、实际会话 buildId 与 Host inspectionVersion。测试包含本地未提交改动,不能只凭 HEAD 宣称版本相同;不切分支、不重建或修改被测实现。身份不匹配则报告阻断。 -2. 只读接口说明和必要使用手册后开始黑盒测试。重新列出 Steam 窗口,明确 PID/HWND;只用一个当前构建的 WinCodeSession/CLI,会话前后核对身份。父任务和其他测试不得同时操作 UI;旧 MCP 连接不能代替该构建。 -3. 从实际页面选 2–6 个相关复选框,尽量包含当前自然存在的 On、Off、禁用状态。先从清晰截图或用户明确陈述记录每个标签的预期,再将标签与唯一 AutomationId 对应;视觉无法确认的字段保持 unknown,不能复制配方输出作为预期。 -4. 冻结窗口、scopePath、regionAutomationId、所选 IDs、可选 summaryAutomationId、预期和预算至 case.json。只有实际存在有业务意义的摘要开关时才测条件分支;没有摘要则仅测无条件统计,并将条件验收列为未覆盖。缺少稳定 ID、唯一公共父范围或独立预期时不进入计分试验,报告具体能力缺口,不更改产品或造假数据。 - -### 配对执行 +### 配对执行与通过条件 | 路径 | 执行方式 | |---|---| -| N:原生基线 | 通过同一会话调用标准 scoped inspect,单次读取必要区域并独立核对所选 IDs 与状态。若有摘要,先只读摘要,On 才读详情,Off 不读详情。不得人为拆成逐控件 N 次读取;不调用配方实现完成统计 | -| R:客户端配方 | 同一会话调用 readonlyUiRecipe(target, 'checkbox-audit', parameters) 或 CLI readonly-ui,读取完整 steps、findings、isError 和 scopeResult,不修改配方 | - -同一冻结案例顺序为 N1 → R1 → R2 → N2(两对,交换先后);每次重新读取,禁止复用前次结果冒充试验。初始准备和连接预热单列,两条路径均使用暖会话。同一组 query/tree 预算与总 deadline,默认每次 15 秒;原生多调用共享剩余 deadline,不能给每一步各 15 秒。预算若不足,只能在冻结案例前基于实际节点调整。 +| N:原生基线 | 同一会话单次 scoped inspect 读取必要区域并独立核对控件;有摘要时先读摘要,Off 不读详情,不人为拆成逐控件读取 | +| R:客户端配方 | 调用 `readonlyUiRecipe` 或 CLI `readonly-ui`,检查完整 steps、findings、isError 和 scopeResult | -每次核对逐项身份、On/Off、isEnabled、checkedCount、unchecked、disabled;禁用且 On 仍计入数量。带摘要时同时核对 detailsRequired 和 Off 零详情读取。前后截图/独立读回若发现页面或状态变化,标记 affected,不进入相同状态的耗时比较,保留已花成本。不会为了补齐 On/Off 组合修改真实设置;未出现的分支明确未覆盖。 +顺序为 N1 → R1 → R2 → N2。每次重新观察,采用相同 query/tree 预算和总 deadline,默认每次 15 秒;准备与预热单列,多调用共享剩余时间。 -正例可执行后,额外做一次只读范围反例:在已观察路径后追加明确标注为测试用且本次观察不存在的随机父选择器,预期范围失败、无 findings、无整窗回退。真实布局若有同名旁支可检查歧义;没有则记未覆盖,不构造 Steam 控件。本轮不重跑全部夹具或完整回归。 +逐项检查身份、On/Off、isEnabled、checkedCount、unchecked、disabled;禁用且 On 仍计数。摘要 Off 应零详情读取。未自然出现的状态明确未覆盖,不为补齐组合修改应用设置。 -### 记录与判断 - -| 测量项 | 口径 | -|---|---| -| 正确性 | 每个已启动 trial 单独记 pass/fail/affected;未进入试验记 blocked 或 unsupported。N、R 分别给首次成功数/尝试数,记录误报成功、误拒绝、越界及未覆盖项;不得删除失败重算分母 | -| 调用成本 | 分开统计调用方请求、内部 MCP 读取、动作、Helper 启动、结果文件读取和实际模型往返。两路径可能有相同 MCP 次数;一次配方入口不等于一次原生读取 | -| 字节与时间 | 记录原始文本块 UTF-8 字节、配方最终文本字节、图片字节及每次墙钟耗时;内部证据、最终输出不要重复相加当模型输入。准备/冷启动、试验、失败/纠错、人工等待、清理分别列账;完整 token usage 缺失就写 unknown | -| 干扰与清理 | 记录前台变化是否实际观察、设置前后状态、实际动作;close 后验证自有 Gateway/Helper/测试进程退出,保留 Steam。未知项不能填“零干扰” | +正例完成后做一次缺失父范围反例,要求范围失败、无 findings、无整窗回退;最后检查页面状态保持及自有进程清理。四次正例、范围反例、状态保持和清理全部成立,才判定该案例通过。截图仅辅助独立预期,不能替代原生状态。 -发现结果与独立预期不一致、未经请求的动作、目标改变或清理失败,即停止后续性能试验并保留证据;不边改实现边验收。AUDIT_BUSY 或外部状态变化只有原因已消失且仍在预算内才可重新准备,原失败不删除;超时不自动重放。总量最多 20 次原生 UI 请求(含发现、导航、取图、失败),主动操作时间最多 20 分钟;达到限制就报告实际进展,不继续扩张。 +### 成本与停止条件 -仅在四次正例、范围反例、状态保持和清理都成立时,判定该案例通过。无摘要时只能判定无条件案例通过,C 的条件分支仍待验。这是一项适用性与粗略成本试验,不给 P95、统计显著性或稳定收益百分比;发现没有收益时保留更简单的原生路径。实现细节与原始返回只在解释失败时读取,测试者不修改源码、安装 Skill 或本计划。 - -产物集中写到 test-tmp/c-steam-independent-<时间>/:case.json、包含所有尝试的 trials.jsonl、完整原始回执/必要截图、一个 report.md。报告只写版本、任务与独立预期、N/R 逐次结果、成本、失败与限制、清理证据,以及至多 3 条有证据支持的最小建议。个人内容只留本地,公开摘要去标识。父任务审阅后只将仍未解决的问题保留在本文,不新增历史工作日志。 +- 保留全部尝试、失败和人工等待。分别记录调用方请求、内部读取、Helper 启动、结果文件读取、墙钟时间和输出字节;缺少 token usage 就记 unknown。 +- 准备阶段被阻断与已启动试验失败分开计数。两对观察只支持适用性和粗略成本判断,不报告 P95、显著性或稳定收益比例。 +- 结果不符、未经请求的动作、目标变化或清理失败即停止性能试验。超时不自动重放;不边修改实现边验收。 +- 一轮最多 20 次原生 UI 请求、20 分钟主动操作;达到上限后保留证据并停止。后续轮次须有新证据和明确范围。 +- 原始截图、窗口内容、账号信息、机器路径和完整日志只保存在被忽略的本地 `test-tmp/`,不提交或作为 PR 附件上传。公开报告仅保留去标识后的行为、结果和限制。 ## D.测量后才决定的优化 -| 观察到的实际瓶颈/需求 | 最小候选改动 | 进入条件与边界 | -|---|---|---| -| 原生逐属性读取占主要时间 | UiTreeReader 内按需批量取得属性/状态 | 先分离 Host 启动、定位、属性读取耗时;核对当前 FlaUI API、取消与故障语义。只在请求内用缓存,动作后刷新 | -| 首次发现被无关 Group 的未知状态阻断 | 区分“列出部分候选”和“足以安全执行唯一目标” | 先有误拒绝复现;保留缺口并让调用方明确选择,不能把部分搜索的单个候选当全局唯一 | -| 多层区域迫使 AI 重复拼装已观察路径 | 返回更易使用的局部候选/路径建议 | 路径来自实际观察,下一请求重新验证;先优化反馈,再决定是否需要短路径执行 | -| 真实任务要读选择项或文本数值 | 在现有原生能力上增加一种明确结果读取 | 先定义控件、字段与独立预期;不默认读取输入内容,不同时扩展所有控件类型 | -| 同名按钮/输入框确实妨碍已授权操作 | 给 click/type 补可选父范围 | 独立任务验收:范围贯穿、旧 Host 拒绝、旁支不受影响、结果未知不重放 | - -不预建递归导航引擎、累计配额/授权状态机、全局 COM 缓存或新服务。异步显示问题先复现,再评估有截止时间的只读等待;不增加固定 sleep,不自动重试未知结果的写动作。 +| 已确认的瓶颈或需求 | 最小候选改动与进入条件 | +|---|---| +| 原生逐属性读取占主要时间 | 先分离启动、定位、属性读取耗时,再评估单请求内批量读取;不新增全局 COM 缓存 | +| 首次发现被无关节点缺口误拒绝 | 先取得具体误拒绝复现;保留缺口,不把部分搜索的单候选当全局唯一 | +| 多层区域导致重复拼装路径 | 候选路径必须来自实际观察,下一请求重新验证;不预建递归导航引擎 | +| 真实任务需要选择项或文本数值 | 先定义控件、字段与独立预期,再按一种结果类型扩展 | +| 同名按钮或输入框妨碍授权操作 | 单独验收 click/type 父范围、旧 Host 拒绝、旁支保持和未知结果不重放 | ## 其他未完成项 -以下按真实需要分别执行,不绑成大改造: - | 事项 | 当前边界与触发条件 | |---|---| -| Roslyn 会话刷新间歇失败 | 曾在快照刷新后缺少 references,原测试以 TypeError 掩盖业务响应;测试现已保留完整 MCP 错误。独立、三组并行和相关会话检查未复现,最新完整检查 498 项通过、1 项跳过;底层根因仍未确认。再次出现时先按报告中的实际错误定位,不自动重试、不放宽快照有效性检查 | -| Roslyn 生成源码 | Code Host 仍排除分析器/生成器。真实项目需要生成符号定位时,单独评估受控加载、定位和快照失效;不能用局部零引用证明删除安全 | -| Codex 冷启动 | 尚缺正常刷新宿主后“首次调用前无新 WinCode 进程”的观察;在正常重启机会验收,不打断当前任务 | -| 录制提示窗/托盘间歇失败 | Steam 新配方独立验收首次列窗遇到 HOST_ERROR:Recording indicator could not be displayed,审计耗时约 2.5 秒且未进入 UI 读取。后续主任务只读验收通过;未改提示窗,根因仍未确认。需取得底层异常或初始化阶段耗时再修复,不绕过提示窗或仅增加盲重试 | -| SDK 突发监听器警告 | 历史突发负载后监听器恢复为零,未证明泄漏;普通负载复现时再调查背压与释放,不抬高阈值遮掩 | -| 特殊环境 | 大项目长时运行、动态 MSBuild 输入、异常退出文件清理、跨会话/权限/DPI/Explorer 重启按用户环境触发验证 | -| 发布与部署 | GitHub Release 尚未发布;源码、磁盘产物、安装 Skill 和当前连接分别验收,合并不代表已部署 | +| Roslyn 会话刷新间歇失败 | 曾在刷新后缺少 references,测试已保留完整业务错误;后续未复现,底层根因未确认。再次出现时先定位实际错误,不放宽快照有效性检查 | +| Roslyn 生成源码 | Code Host 仍排除分析器和生成器;有真实需求时评估受控加载、定位及快照失效 | +| 宿主冷启动 | 尚缺正常刷新宿主后“首次调用前无新 WinCode 进程”的观察;利用正常重启机会验收 | +| 录制提示窗或托盘间歇失败 | 曾出现 `Recording indicator could not be displayed`,根因未确认;先取得底层异常或初始化耗时,不绕过提示窗或盲重试 | +| SDK 突发监听器警告 | 历史负载后监听器恢复,未证明泄漏;普通负载复现后再调查,不抬高阈值遮掩 | +| 特殊环境 | 长时运行、动态 MSBuild 输入、异常退出清理、权限、DPI 和 Explorer 重启按实际环境触发验证 | +| 发布与部署 | 源码合并、磁盘构建、安装 Skill 和当前连接分别验收;合并不代表已部署或已发布 Release | -## 执行与维护 +## 维护方式 -每次选一个工作包,先查当前分支、真实源码和相关测试。优先最小复现/相关检查;涉及 UI 的改动必须走实际入口。普通局部任务至多新增 3 项自动化测试;完整回归按影响运行一次,失败后遵守仓库的诊断与重试限制。不得因本文存在待验项就自动安装依赖、运行外部付费实验或扩展任务。 +每次只处理一个明确任务,先核对源码、Git 和现有证据;不因待办存在而自动安装依赖、启动付费实验或扩展范围。验证方式与实际行为匹配,遵守仓库测试和失败重试限制。 -任务完成后从本文删除对应任务,把用户可见变化写入 CHANGELOG、验证及失败写入该次 PR;仅保留仍成立的限制。更新使用说明中实际发生变化的契约,不追加“第几轮”流水账,不另建重复路线图。文档改动检查链接、命令、字段和新旧能力边界;文档检查不替代运行时验收。 +完成项从本文移除,用户可见变化写入 CHANGELOG,实际验证与限制写入对应 PR。保持入口简短,不追加本机诊断流水账或重复路线图;文档检查不替代运行时验收。 diff --git a/skills/wincode/references/ui.md b/skills/wincode/references/ui.md index 6de7381..4cdb8ad 100644 --- a/skills/wincode/references/ui.md +++ b/skills/wincode/references/ui.md @@ -61,11 +61,19 @@ UI 请求原始 JSON 总计上限 64 KiB,hwnd 最长 32 字符。query 搜索 {"id":"audit1","action":"readonly-ui","recipe":"checkbox-audit","target":{"pid":1234,"hwnd":"0x123456"},"parameters":{"scopePath":[{"automationId":"settings"}],"regionAutomationId":"checks","checkboxAutomationIds":["optionA","optionB"]},"timeoutMs":15000} ``` -- 必填 regionAutomationId 和 1–64 个不重复的 checkboxAutomationIds。目标须在区域内唯一,类型为 CheckBox,isEnabled 已知,toggle 为 On/Off。 +- 必填 regionAutomationId,另选 checkboxAutomationIds 或 checkboxSelectors,不能同时提供。ID 模式接受 1–64 个不重复的 ID;目标须在区域内唯一,类型为 CheckBox,isEnabled 已知,toggle 为 On/Off。 - 可选 summaryAutomationId:On 才读详情,Off 返回 detailsRequired:false,未知停止。省略则直接统计。 - 可选 scopePath 同时限定摘要和详情,每次重新解析。maxDepth 默认 4、范围 1–50;maxNodes 默认 300、范围 1–5000。 - 返回 checkedCount、unchecked、disabled;带摘要时详情位于 details。禁用且 On 仍计入数量。className/bounds/isOffscreen 缺口保留为证据,不阻断已知状态;身份、必要状态和读取完整性仍需成立。 +没有 AutomationId 时,用明确的名称与类型: + +```json +{"id":"audit-names","action":"readonly-ui","recipe":"checkbox-audit","target":{"pid":1234,"hwnd":"0x123456"},"parameters":{"regionAutomationId":"checks","checkboxSelectors":[{"name":"24 小时制","controlType":"CheckBox"},{"name":"以大屏幕模式启动 Steam","controlType":"CheckBox"}]},"timeoutMs":15000} +``` + +名称模式接受 1–64 个互不重复的精确选择器,只允许 name 和固定的 controlType:"CheckBox";ID、名称、PID/HWND 均需来自实际观察。unchecked、disabled 返回选择器对象,保留请求顺序;旧 ID 模式仍返回排序后的 ID 字符串。所选控件的名称、类型、isEnabled、On/Off 与区域 ID 必须可读。同名 Text 可排除,但身份未知且可能匹配的节点会阻断唯一性。非必要的已知属性缺口保留为证据;未分类缺口、搜索/父范围/遍历不完整仍停止。不把未知状态当 Off,也不更改设置。 + ### sibling-range 先从局部 UIA 树和必要截图确认容器及两个连续分组标题,再调用: diff --git a/src/Client/ReadonlyUiRecipes.ts b/src/Client/ReadonlyUiRecipes.ts index 7d410c8..7287c59 100644 --- a/src/Client/ReadonlyUiRecipes.ts +++ b/src/Client/ReadonlyUiRecipes.ts @@ -18,10 +18,17 @@ export interface CheckboxAuditParameters { summaryAutomationId?: string; regionAutomationId: string; checkboxAutomationIds: string[]; + checkboxSelectors?: never; maxDepth?: number; maxNodes?: number; } +export interface NamedCheckboxAuditParameters extends Omit { + checkboxAutomationIds?: never; + /** Exact name and CheckBox type; mutually exclusive with checkboxAutomationIds. */ + checkboxSelectors: Array<{ name: string; controlType: 'CheckBox' }>; +} + /** Known preflight rejection only. Messages and fields never contain rejected input values. */ export class RecipeInputError extends Error { readonly workStarted = false; @@ -54,19 +61,34 @@ export function createReadonlyUiRecipe(recipe: unknown, parameters: unknown) { function createCheckboxAudit(parameters: unknown) { if (!parameters || typeof parameters !== 'object' || Array.isArray(parameters) || - Object.keys(parameters).some(key => !['scopePath', 'summaryAutomationId', 'regionAutomationId', 'checkboxAutomationIds', 'maxDepth', 'maxNodes'].includes(key))) + Object.keys(parameters).some(key => !['scopePath', 'summaryAutomationId', 'regionAutomationId', 'checkboxAutomationIds', 'checkboxSelectors', 'maxDepth', 'maxNodes'].includes(key))) throw new RecipeInputError('INVALID_RECIPE_PARAMETERS', 'parameters', - 'Supply an object with only scopePath, summaryAutomationId, regionAutomationId, checkboxAutomationIds, maxDepth and maxNodes.'); - const input = parameters as CheckboxAuditParameters; + 'Supply only scopePath, summaryAutomationId, regionAutomationId, checkboxAutomationIds or checkboxSelectors, maxDepth and maxNodes.'); + const input = parameters as CheckboxAuditParameters | NamedCheckboxAuditParameters; try { validateUiScope(input.scopePath); } catch { throw new RecipeInputError('INVALID_RECIPE_PARAMETERS', 'scopePath', 'scopePath requires 1–50 exact parent selectors using only automationId, name and controlType.'); } const scopePath = input.scopePath === undefined ? undefined : structuredClone(input.scopePath); const region = identifier(input.regionAutomationId, 'regionAutomationId'); const summary = input.summaryAutomationId === undefined ? undefined : identifier(input.summaryAutomationId, 'summaryAutomationId'); - if (!Array.isArray(input.checkboxAutomationIds) || input.checkboxAutomationIds.length < 1 || input.checkboxAutomationIds.length > 64) + const named = input.checkboxSelectors !== undefined; + let selectors: NamedCheckboxAuditParameters['checkboxSelectors'] = []; + if (named) { + if (input.checkboxAutomationIds !== undefined || !Array.isArray(input.checkboxSelectors) || + input.checkboxSelectors.length < 1 || input.checkboxSelectors.length > 64) + throw new RecipeInputError('INVALID_RECIPE_PARAMETERS', 'checkboxSelectors', 'Supply 1–64 exact name/CheckBox selectors instead of checkboxAutomationIds.'); + selectors = input.checkboxSelectors.map(selector => { + if (!selector || typeof selector !== 'object' || Array.isArray(selector) || + Object.keys(selector).some(key => !['name', 'controlType'].includes(key)) || selector.controlType !== 'CheckBox' || + typeof selector.name !== 'string' || !selector.name.trim() || selector.name.length > 256 || /[\x00-\x1f]/.test(selector.name)) + throw new RecipeInputError('INVALID_RECIPE_PARAMETERS', 'checkboxSelectors', 'Each selector requires a nonempty name of at most 256 characters without control characters and controlType CheckBox.'); + return { name: selector.name, controlType: 'CheckBox' }; + }); + if (new Set(selectors.map(selector => selector.name)).size !== selectors.length) + throw new RecipeInputError('INVALID_RECIPE_PARAMETERS', 'checkboxSelectors', 'Checkbox selectors must be distinct.'); + } else if (!Array.isArray(input.checkboxAutomationIds) || input.checkboxAutomationIds.length < 1 || input.checkboxAutomationIds.length > 64) throw new RecipeInputError('INVALID_RECIPE_PARAMETERS', 'checkboxAutomationIds', 'Supply 1–64 explicitly selected checkbox AutomationIds.'); - const ids = input.checkboxAutomationIds.map((value, index) => identifier(value, `checkboxAutomationIds[${index}]`)); + const ids = named ? [] : input.checkboxAutomationIds!.map((value, index) => identifier(value, `checkboxAutomationIds[${index}]`)); if (new Set(ids).size !== ids.length) throw new RecipeInputError('INVALID_RECIPE_PARAMETERS', 'checkboxAutomationIds', 'Checkbox AutomationIds must be distinct.'); const maxDepth = budget(input.maxDepth, 4, 50, 'maxDepth'); const maxNodes = budget(input.maxNodes, 300, 5000, 'maxNodes'); @@ -81,8 +103,35 @@ function createCheckboxAudit(parameters: unknown) { if (state !== 'On') throw new Error('Summary toggle state is unknown; details were not read.'); } const value = await reader.inspect({ scopePath, query: { automationId: region }, readStates: true, maxDepth, maxNodes, - allowAuxiliaryPropertyGaps: true }); + ...(named ? { allowPropertyGaps: true } : { allowAuxiliaryPropertyGaps: true }) }); if (value.tree?.automationId !== region) throw new Error('Region observation does not match its AutomationId.'); + if (named) { + const available = (node: UiNode, field: string) => !node.propertyIssues?.some(issue => issue.split(':')[0].toLowerCase() === field.toLowerCase()); + if (!available(value.tree, 'automationId')) throw new Error('Region identity is unreadable.'); + const nodes: UiNode[] = []; + const visit = (node: UiNode) => { + // Unclassified gaps remain blocking even when known, irrelevant properties can be omitted. + if (node.propertyIssues?.some(issue => !['name', 'automationid', 'controltype', 'isenabled', 'classname', 'bounds', 'isoffscreen'] + .includes(issue.split(':')[0].toLowerCase()))) throw new Error('Unclassified property gap in checkbox observation.'); + for (const child of node.children) { nodes.push(child); visit(child); } + }; + visit(value.tree); + const rows = selectors.map(selector => { + // A node is excluded only by a readable mismatch; an unknown identity may hide a duplicate. + const possible = nodes.filter(node => (['name', 'controlType'] as const).every(field => + !available(node, field) || node[field] === undefined || node[field] === selector[field])); + if (possible.length !== 1) throw new Error('Each checkbox selector must identify one complete, unambiguous control.'); + const node = possible[0]; + if (node.name !== selector.name || node.controlType !== 'CheckBox' || !available(node, 'name') || + !available(node, 'controlType') || !available(node, 'isEnabled') || typeof node.isEnabled !== 'boolean' || + !['On', 'Off'].includes(node.states?.toggle ?? '')) throw new Error('Selected checkbox identity or state is incomplete.'); + return { node, selector }; + }); + const details = { checkedCount: rows.filter(row => row.node.states!.toggle === 'On').length, + unchecked: rows.filter(row => row.node.states!.toggle === 'Off').map(row => row.selector), + disabled: rows.filter(row => row.node.isEnabled === false).map(row => row.selector) }; + return summary === undefined ? details : { detailsRequired: true, details }; + } const selected = new Map(ids.map(id => [id, [] as UiNode[]])); const visit = (node: UiNode) => { if (node.automationId) selected.get(node.automationId)?.push(node); diff --git a/src/Client/SkillSession.ts b/src/Client/SkillSession.ts index 9544431..cd14be1 100644 --- a/src/Client/SkillSession.ts +++ b/src/Client/SkillSession.ts @@ -5,7 +5,7 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { withTimeout } from '../Core/ResourceManager.js'; import { runReadonlyUiWorkflow, type UiReader, type UiTarget, type UiWorkflowOptions } from './ReadonlyUiWorkflow.js'; -import { createReadonlyUiRecipe, type CheckboxAuditParameters, type SiblingRangeParameters } from './ReadonlyUiRecipes.js'; +import { createReadonlyUiRecipe, type CheckboxAuditParameters, type NamedCheckboxAuditParameters, type SiblingRangeParameters } from './ReadonlyUiRecipes.js'; import { runExpandUiWorkflow, type ExpandUiParameters } from './ExpandUiWorkflow.js'; export interface SkillSessionOptions { @@ -89,7 +89,7 @@ export class WinCodeSession { } /** Installed client recipe; compile/validate parameters before the first tool read. */ - readonlyUiRecipe(target: UiTarget, recipe: 'checkbox-audit' | 'sibling-range', parameters: CheckboxAuditParameters | SiblingRangeParameters, options: UiWorkflowOptions = {}) { + readonlyUiRecipe(target: UiTarget, recipe: 'checkbox-audit' | 'sibling-range', parameters: CheckboxAuditParameters | NamedCheckboxAuditParameters | SiblingRangeParameters, options: UiWorkflowOptions = {}) { return this.readonlyUiWorkflow(target, createReadonlyUiRecipe(recipe, parameters), options); } diff --git a/tests/hybrid-ui-workflow.test.ts b/tests/hybrid-ui-workflow.test.ts index a0645d8..a644a8d 100644 --- a/tests/hybrid-ui-workflow.test.ts +++ b/tests/hybrid-ui-workflow.test.ts @@ -593,6 +593,82 @@ test('checkbox audit still rejects required property gaps, incomplete coverage a } }); +function namedCheckboxRegion(): UiInspectResult { + return { ...observation(), tree: { id: 1, parentId: null, automationId: 'region', name: 'Settings', + propertyIssues: ['controlType:unsupported'], children: [ + { id: 2, parentId: 1, name: 'Clock', controlType: 'Text', propertyIssues: ['automationId:unsupported'], children: [] }, + { id: 3, parentId: 1, name: 'Clock', controlType: 'CheckBox', isEnabled: true, + states: { toggle: 'Off', selection: 'unsupported', expandCollapse: 'unsupported' }, propertyIssues: ['automationId:unsupported', 'className:unsupported'], children: [] }, + { id: 4, parentId: 1, name: 'Scale', controlType: 'CheckBox', isEnabled: false, + states: { toggle: 'On', selection: 'unsupported', expandCollapse: 'unsupported' }, propertyIssues: ['automationId:unsupported'], children: [] }, + { id: 5, parentId: 1, name: 'Unrelated', controlType: 'Button', propertyIssues: ['isEnabled:unsupported'], children: [] }, + ] } }; +} +const namedCheckboxParameters = { regionAutomationId: 'region', checkboxSelectors: [ + { name: 'Clock', controlType: 'CheckBox' }, { name: 'Scale', controlType: 'CheckBox' }, +] }; + +test('named checkbox audit reads ID-less controls, preserves evidence and keeps conditional and legacy readers bounded', async () => { + for (const mode of ['unconditional', 'on', 'off']) { + const calls: string[] = []; + const checked = await runReadonlyUiWorkflow(async (_tool, args) => { + const id = (args.query as any).automationId; calls.push(id); + if (id === 'summary') return result({ ...observation(), tree: { ...observation().tree!, automationId: 'summary', + states: { toggle: mode === 'off' ? 'Off' : 'On', selection: 'unsupported', expandCollapse: 'unsupported' } } }); + return result(namedCheckboxRegion()); + }, target, createReadonlyUiRecipe('checkbox-audit', { ...namedCheckboxParameters, + ...(mode === 'unconditional' ? {} : { summaryAutomationId: 'summary' }) })); + const details = { checkedCount: 1, unchecked: [{ name: 'Clock', controlType: 'CheckBox' }], + disabled: [{ name: 'Scale', controlType: 'CheckBox' }] }; + assert.equal(checked.report.success, true, JSON.stringify(checked.report)); + assert.deepEqual(checked.report.findings, mode === 'off' ? { detailsRequired: false } : + mode === 'on' ? { detailsRequired: true, details } : details); + assert.deepEqual(calls, mode === 'off' ? ['summary'] : mode === 'on' ? ['summary', 'region'] : ['region']); + if (mode !== 'off') assert.deepEqual((checked.report.steps.at(-1)!.evidence!.nodes as any[])[2].propertyIssues, + ['automationId:unsupported', 'className:unsupported']); + } + const strict = await runReadonlyUiWorkflow(async () => result(namedCheckboxRegion()), target, r => r.inspect({})); + assert.equal(strict.report.errorCode, 'INCOMPLETE_OBSERVATION'); +}); + +test('named checkbox audit refuses hidden ambiguity, required state gaps and incomplete scope evidence', async () => { + for (const scenario of ['duplicate', 'missing', 'unknown-name', 'unknown-type', 'name-error', 'enabled-error', + 'unknown-toggle', 'unknown-issue', 'region-id-error', 'unexplained-count', 'truncated', 'search', 'scope']) { + const value = namedCheckboxRegion(), rows = value.tree!.children; + if (scenario === 'duplicate') rows.push(structuredClone(rows[1])); + if (scenario === 'missing') rows.splice(1, 1); + if (scenario === 'unknown-name') rows.push({ id: 9, parentId: 1, controlType: 'CheckBox', propertyIssues: ['name:unsupported'], children: [] }); + if (scenario === 'unknown-type') rows.push({ id: 9, parentId: 1, name: 'Clock', propertyIssues: ['controlType:unsupported'], children: [] }); + if (scenario === 'name-error') rows[1].propertyIssues!.push('name:error'); + if (scenario === 'enabled-error') rows[1].propertyIssues!.push('isEnabled:error'); + if (scenario === 'unknown-toggle') rows[1].states!.toggle = 'unknown'; + if (scenario === 'unknown-issue') rows[3].propertyIssues!.push('futureField:error'); + if (scenario === 'region-id-error') value.tree!.propertyIssues!.push('automationId:error'); + if (scenario === 'unexplained-count') value.propertyIssueCount = 100; + if (scenario === 'truncated') { value.treeComplete = false; value.truncated = true; } + if (scenario === 'search') value.queryResult!.searchComplete = false; + const checked = await runReadonlyUiWorkflow(async () => result(value), target, createReadonlyUiRecipe('checkbox-audit', { + ...namedCheckboxParameters, ...(scenario === 'scope' ? { scopePath: [{ name: 'Settings' }] } : {}), + })); + assert.equal(checked.report.success, false, scenario); + assert.equal(checked.report.findings, undefined, scenario); + assert.equal(checked.report.steps.length, 1, scenario); + assert.ok(checked.report.steps[0].evidence, scenario); + } +}); + +test('named checkbox selectors reject mixed, duplicate and nonexact inputs before reading', () => { + for (const extra of [ { checkboxAutomationIds: ['a'] }, { checkboxSelectors: [] }, + { checkboxSelectors: [{ name: 'Clock', controlType: 'Button' }] }, + { checkboxSelectors: [{ name: 'Clock' }] }, + { checkboxSelectors: [{ name: 'Clock', controlType: 'CheckBox', automationId: 'a' }] }, + { checkboxSelectors: [namedCheckboxParameters.checkboxSelectors[0], namedCheckboxParameters.checkboxSelectors[0]] }, + { checkboxSelectors: [{ name: 'private\n', controlType: 'CheckBox' }] } ]) { + assert.throws(() => createReadonlyUiRecipe('checkbox-audit', { ...namedCheckboxParameters, ...extra }), + (e: any) => e.code === 'INVALID_RECIPE_PARAMETERS' && e.workStarted === false); + } +}); + test('readonly workflow serializes concurrent requests, evaluates actual observations and retains native image evidence', async () => { let active = 0; const calls: string[] = [];