fix(cua): bound Linux observations and prepare SDK 0.20.9 (#11910)

* fix(cua): prevent Linux input replay and redundant observations

* fix(cua): bound Linux accessibility collection for SDK 0.20.9
This commit is contained in:
顾盼 authored and GitHub committed 2026-09-15 05:17:23 +00:00
1 parent 1ea66cf6d4
commit d47a8fdbae
28 files changed
+1895 -442

No files matched your search

+1 -1
View File
@@ -30,7 +30,7 @@ on:
version:
description: 'Version to release (without v prefix)'
required: true
default: '0.20.8'
default: '0.20.9'
notarize:
description:
'Codesign + notarize the macOS artifacts (false to skip during
+103
View File
@@ -0,0 +1,103 @@
# Linux CUA input recovery
[简体中文](linux-cua-input-recovery.zh-CN.md)
Linux's semantic-input fallback currently treats failure after dispatch as if
the semantic route were unavailable. A native action can mutate the application
and then lose its D-Bus reply; the click tool subsequently sends a pixel click.
The same fallback exists for scrolling. Some click paths also ignore a false
AT-SPI acknowledgement and report success.
The reproduction uses the released 0.20.8 source: an action effect followed by
an error or the existing 25-second timeout produces two fixture effects, one
semantic and one pixel. A false acknowledgement with no effect still returns
"Clicked element". These are source-extraction reproductions with controlled
transport endpoints; they are not claims about the causes of every historical
zero-score task.
Port the macOS recovery principle into the existing Linux implementation:
fallback is possible before input dispatch, but a possible dispatch must stop
automatic replay. Track dispatch locally in each AT-SPI attempt, including
timeouts and partially completed scroll batches. Return an unconfirmed-input
error after false, failed, or timed-out acknowledgements. The caller must
observe before deciding whether to retry. Preserve safe pre-dispatch fallback,
exact target identity and the current foreground guard.
The existing Node REPL → Computer Use facade → native Linux driver topology,
processes, installation and permissions remain unchanged. No new public API,
service, global retry policy or provider-specific workaround is introduced.
The canonical fixed Computer Use skill remains the model guidance source.
The same dispatch boundary applies to text insertion and value replacement.
An uncertain insert must not fall through to a whole-field replacement, and a
rejected replacement must not fall through to an append. Successful writes
without readback remain unverified; they must not automatically emit a
foreground escalation that the shared core interprets as failed delivery.
Preserve editable text independently of the accessible label, including an
empty value when clearing a field. Compact observation rows omit duplicate
label/value text, default states and the primary activation action, while the
structured elements retain all actions. Keep nondefault state and secondary
actions visible so actionable differences survive revision comparison.
Pixel fallbacks retain the exact window and element identity. Intersect the
element bounds with that window and recompute the point after foreground
preparation, preventing oversized controls from targeting a sibling window.
GTK3 already supplies correct screen coordinates with client-side decorations;
avoid applying GTK4's shadow reconstruction to that case. Preserve and verify
the GTK4 path separately. Its X11 top-level Screen extents are window-local,
so correlate a multi-window GTK4 application only through titles that are
unique in both its native and accessibility window lists. Missing or duplicate
titles continue to refuse. Semantic primary activation applies only to an
unmodified single left click, preserving requested right and double clicks.
The owned Linux desktop also reproduced active-window and core-focus readiness
taking 644–734 ms. Allow one second for the existing focus confirmation loop,
which still returns immediately when ready and retries only preparation.
The action callback remains single-shot; this does not replay uncertain input
or establish the cause of historical benchmark focus failures.
Linux continues to use its existing exact-window public API. Port only the
applicable macOS batching guidance into the canonical skill: combine known
actions and emit final verification with cleanup in one cell, while observing
new dialogs and menus before acting.
Observation retries also share one native 25-second budget. Previously four
native attempts each received a fresh budget; the SDK's existing read-failure
retry could then repeat all four. Retry complete root-only or empty trees for
lazy toolkit registration, but return failed or incomplete reads immediately.
Preserve the native partial tree and diagnostic, or the X11 fallback when no
tree exists. Direct native consumers receive the first failure rather than
implicitly retrying it. The SDK keeps one retry for short read failures, but
returns deadline-exhausted partial captures without starting another full walk.
The companion [observation design](linux-observation-memory-and-compaction.md)
covers structural compaction and bounded LibreOffice collection.
## Validation
Reproduce the released code before editing. Verify true/false acknowledgement,
failure before dispatch, failure after an externally observed effect, timeout,
and partial scroll progress. Assert actual effect counts and absence of a
second actuator, alongside the model-visible error. Exercise element and
coordinate routes and preserve exact native identity on fallback. Then repeat
MCP → Node REPL → SDK input, focus restoration and screenshot tests on the
owned Linux VM, with independent application readback.
Compare right and double clicks against coordinate controls. Verify named text
set, insertion and clearing through both the observation and an independent
application state file. Exercise oversized controls, two windows from one
process, GTK3 decorations and GTK4 geometry separately. These GUI checks do not
prove transport-failure semantics: the GTK accessibility bridge acknowledges
its queued callback independently of that callback's return value.
Exercise complete and incomplete trees, unavailable and failed reads, cold
registration recovery, and budget exhaustion. Check both the native wrapper
and actual SDK facade with counted endpoints. Verify that the last valid
minimal tree survives retry exhaustion and each attempt receives only the
remaining budget. Measure actual model waits separately from allocated native
time budgets and provider latency.
Historical task terminal status, evaluator score, unique model responses,
uncached/cached input and output tokens are separate metrics. Fewer native
calls or text characters do not establish lower model cost. Paired real-task
evidence is required before claiming an efficiency or score improvement.
@@ -0,0 +1,72 @@
# Linux CUA 输入恢复
[English](linux-cua-input-recovery.md)
Linux 的语义输入回退此前会将分发后的失败视为语义通道不可用。原生动作可能已经
修改应用状态,随后丢失 D-Bus 回应,而点击工具继续发送像素点击。滚动也存在相同
回退;部分点击路径还会忽略 AT-SPI 的 false 确认并报告成功。
使用已发布的 0.20.8 源码复现:动作产生效果后返回错误或触发原有 25 秒超时,
夹具会记录两次效果,分别来自语义动作和像素动作。未产生效果且返回 false 的确认
仍被报告为 “Clicked element”。这是使用受控传输端点、提取实际源码的复现,
不能据此认定所有历史零分任务的原因。
将 macOS 的恢复原则迁移到现有 Linux 实现:输入分发之前可以回退,一旦可能已经
分发,就必须停止自动重放。每次 AT-SPI 尝试局部记录分发状态,覆盖超时和只完成
部分步骤的滚动批次。确认返回 false、失败或超时时,返回输入结果未确认错误;
调用方必须先观察,再决定是否重试。保留安全的分发前回退、精确目标身份和现有
前台保护。
Node REPL → Computer Use facade → Linux 原生驱动的现有调用结构、进程、安装和
权限保持不变。不引入新的公共 API、服务、全局重试策略或特定 provider 的绕过。
模型指引仍来自固定的 Computer Use 主 skill 文件。
文本插入和整体值替换采用同一分发边界。结果不确定的插入不能继续回退为整字段
替换,被拒绝的替换也不能回退为追加。成功写入但没有回读时仍属于未验证结果;
不能自动报告前台升级要求,否则共享 core 会把它解释为投递失败。
可编辑文本独立于 accessible label 保存,清空字段时也保留空值。紧凑观察行省略
重复标签和值、默认状态及主激活动作,结构化元素仍保留全部动作。非默认状态和
次要动作继续可见,确保 revision 比较保留有意义的操作差异。
像素回退保留精确窗口与元素身份。元素边界与窗口取交集,并在前台准备后重新计算
点击点,避免过大的控件误点同进程的其他窗口。GTK3 已提供含客户端装饰的正确屏幕
坐标,不再套用 GTK4 的阴影重建。GTK4 路径单独保留并验证:其 X11 顶层 Screen
范围实际上是窗口局部坐标,因此同进程多窗口只能通过在原生窗口列表和可访问性
窗口列表中均唯一的标题关联。缺失或重复标题继续拒绝。语义主激活仅适用于无修饰
键的单次左击,保留用户请求的右击与双击语义。
独占 Linux 桌面还复现了活动窗口与 core 焦点就绪需要 644–734 毫秒。将现有焦点
确认循环的预算设为一秒;就绪后立即返回,只重试准备过程。动作回调仍只执行一次,
不会重放结果不确定的输入,也不能据此认定历史 benchmark 的焦点失败原因。
Linux 继续使用精确窗口公共 API。仅将适用的 macOS 批处理指引加入固定 skill:
合并已知动作,在一个 cell 中完成最终验证和清理,遇到新对话框或菜单时先观察。
观察重试共享一个原生 25 秒预算。此前四次原生尝试各自获得完整预算,SDK 的
读取失败重试还可能再执行一轮。为处理工具包延迟注册,只对完整但为空或仅有根
节点的树重试;失败或不完整读取立即返回。保留原生部分树和诊断,无树时保留
X11 回退。直接原生调用方收到第一次失败,不再隐式重试。SDK 对短暂读取失败
仍保留一次重试,但耗尽截止时间的部分捕获直接返回,不再启动完整遍历。
配套的[观察设计](linux-observation-memory-and-compaction.zh-CN.md)说明结构
紧凑化和 LibreOffice 有界采集。
## 验证
修改前先复现已发布代码。覆盖 true/false 确认、分发前失败、独立观察到效果后的
失败、超时和滚动部分进度。除模型可见错误外,还断言真实效果次数和第二执行器
未触发。覆盖元素与坐标路径,确认回退保留精确原生身份。随后在独占 Linux VM
重跑 MCP → Node REPL → SDK 输入、焦点恢复和截图测试,并独立回读应用状态。
将右击和双击与坐标控制组比较。通过观察结果及独立应用状态文件验证有标签文本框
的设置、插入、清空。分别覆盖超大控件、同进程双窗口、GTK3 装饰和 GTK4 几何。
这些 GUI 验证不能证明传输故障语义:GTK 可访问性桥会在回调排队后确认,不依赖
回调本身的返回值。
覆盖完整与不完整树、不可用与失败读取、延迟注册恢复及预算耗尽。用计数端点验证
原生包装和实际 SDK facade。确认重试耗尽后保留最后一份有效最小树,每次尝试
只能使用剩余预算。模型实际等待、分配的原生时间预算和 provider 延迟分别度量。
历史任务终态、评测得分、唯一模型响应数、未缓存/缓存输入和输出 token 是独立
指标。原生调用数或文本字符数减少不能证明模型成本下降;宣称效率或得分提升前
必须取得成对真实任务证据。
@@ -0,0 +1,144 @@
# Linux observation memory and compaction
[简体中文](linux-observation-memory-and-compaction.zh-CN.md)
## Scope
Extend the existing Linux recovery candidate in exactly two areas: compact
accessibility presentation and bounded LibreOffice observation resource use.
App APIs, clipboard and OSWorld code are outside this change. Prepare SDK/driver
0.20.9 with synchronized version pins; publishing remains a separate action.
The SDK-to-native AT-SPI topology, permissions and installation remain unchanged.
## Compact presentation
The previous candidate shortened individual rows, but did not migrate the
structural projection present in macOS 0.20.6. Linux also built revision text
from actionable nodes alone, losing passive status and instruction text.
Retain observed passive context, then flatten empty layout containers, remove
duplicate parent/child labels, and merge adjacent passive text under the same
parent. Preserve window, dialog, menu, list, table and document boundaries,
web-content boundaries, focus, selection, disabled state and all actionable
nodes. Use iterative projection so deep trees do not consume the call stack.
Native indices, identities and the structured action list remain intact.
Revision comparison includes the retained static text and editable empty values.
Apply projection at the model observation boundary; internal browser setup and
consent matching retain original static-label boundaries.
## Memory investigation
The original Task035 workbook exposes 1,073,741,824 table children and the
`manages-descendants` state. A controlled single `GetChildren` request increased
LibreOffice RSS by 107.75 MiB in 24.14 seconds (4.45 MiB/s), while the requesting
process stayed flat. The supervisor stopped the owned process at its soft limit;
no OOM was needed to reproduce the growth. This is a direct AT-SPI reproduction,
not proof of the historical sandbox reboot cause.
The instrumented b30 candidate also reproduced growth through real SDK calls:
all three observations returned X11 fallback, while the trace stopped at the
table GetChildren with no reply. During the third call LibreOffice grew at
4.52 MiB/s and Node RSS stayed flat. Returning from the caller does not cancel
provider-side enumeration.
For ordinary nodes, replace whole-child arrays with `ChildCount` and lazy
`GetChildAtIndex`. Keep one next-sibling cursor per ancestor; check node, depth
and time limits before requesting a child. Preserve the existing preorder and
shared actionable-index calculation in snapshots and actuators. A depth limit
reads metadata without expanding children; a failed child request marks the capture incomplete. The fix does not
disable Calc accessibility or allocate a complete array and truncate afterward.
Indexed access alone proved insufficient: three passes over the same 512 real
cells each completed in about 2.8 seconds, but each retained another 3.3–3.4 MiB
in LibreOffice and some cells received new AT-SPI object paths on every pass.
AT-SPI explicitly says clients should not enumerate `ManagesDescendants` nodes.
Do not expand these virtual children; retain the table and the rest of the UI,
and report `managed_descendants_omitted` as a bounded capture. This limitation
preserves identities for captured controls without claiming complete sheet
content. Use screenshots for omitted content. No application-specific process
restart or event-driven accessibility subsystem is introduced.
## Ordinary-layout collection
The ordinary Calc window also exposed a large hidden menu tree. An independent
bounded census found 1,861 nodes, of which 1,638 were not showing, and 11,188
explicit D-Bus calls without errors. The diagnostic final3 SDK completed 770
nodes before its 25-second deadline; 752 were not showing. No individual call
timed out. This was cumulative traversal work, not a stuck LibreOffice method.
Native timed wrappers include peer-to-peer traffic and are not a count of bus
messages; the independent census has a separate sequential-call timing scope.
Read role, state and supported interfaces together, then omit hidden native
menu nodes and their subtrees before reading names, details or children. Keep
showing, focused, selected or expanded menus, and do not apply the rule within
web content or when visibility is unknown. A menu opened by the user is read
on the next observation. Report `hidden_menu_subtrees_omitted` as an explicit
bounded capture, preserving identities only for controls actually captured.
Omitted commands consume no actionable index; tokens resolve by captured AT-SPI
identity so opening a menu does not retarget an existing token by row number.
Collection now receives the snapshot's absolute deadline, including setup and
the subsequent bounds phase. Keep accumulated nodes and bounds outside the
cancelled futures so a slow later request cannot discard all prior work.
Deadline exhaustion remains an incomplete read and cannot retain stable
revision tokens. The native 25-second maximum is unchanged. The facade returns
`walk_deadline_reached` and `element_bounds_timeout` captures without its usual
automatic read retry; short read failures retain one retry. Real fault injection
showed that retrying an exhausted capture otherwise takes about 51 seconds for
two native reads. A later explicit observation can recover normally.
## Validation
Compare compact output against the same complete captured tree, checking
retained content and actionable identities as well as row/character reduction.
Exercise static-text changes, no-change revisions, field clearing, table rows,
web boundaries, current tokens and deep layouts. Run real SDK observations and
independent application readback on the owned Linux desktop.
Measure LibreOffice and the SDK process before, during and after single and
repeated observations; retain raw samples and operation status. Compare the
unchanged baseline and candidate against the same document. Do not infer
provider token costs or historical OOM causality from shorter text or a timeout.
The memory-only candidate (`6babbd96`) passed four full-screen observations,
but three ordinary-layout observations timed out at 25.762, 25.042 and 25.048
seconds. This exact-binary comparison, followed by the instrumentation above,
established the separate hidden-menu traversal problem.
The ordinary-layout repair (`d4ba57b6`) returned useful Task035 trees in 11.570,
10.989, 13.454 and 10.815 seconds, with 157/157/190/157 actionable elements.
All four reached the real table and retained its token; the unchanged second
read returned `no_change`. File activation exposed 33 additional menu labels,
and Escape restored the initial menu-label set. Both intentional omission
reasons were reported, with `captureReadComplete=true` and `captureComplete=false`.
Continuous samples over 68.986 seconds showed LibreOffice RSS between 340644
and 340652 KiB, with a maximum sampling interval of 0.163 seconds. This is
bounded real-application acceptance, not a long-duration leak or benchmark test.
The same native passed a real GTK comparison: legacy tree text stayed
byte-identical, control depth fell from 10 to 2, static instructions survived,
and token click, field replacement/readback and no-change/token stability all
passed. Revision text grew from 352 to 554 characters because the baseline
omitted passive information; this is not a measured token-cost reduction.
Controlled AT-SPI providers verified completed node and bounds preservation,
continued traversal after a failed child, and no descendant enumeration after
a failed state read. The old facade made two reads in 50.785 seconds for the
metadata deadline and 50.965 seconds for the bounds deadline; these are
before-fix retry measurements, not the intended latency of 0.20.9.
The final 0.20.9 package (`3554fe81` native, `f0ba1e38` facade) repeated the
metadata deadline in 25.833 seconds with exactly one native snapshot, retaining
26 controls and issuing no bounds requests after collection expired. A fresh
ordinary maximized Calc run returned 157 controls in 11.084 seconds, then
`no_change` in 10.492 seconds, with the same table token and both bounded-capture
reasons. LibreOffice RSS stayed at 340228 KiB over 23.471 seconds of samples,
with a maximum interval of 0.153 seconds. This final smoke used the packed
0.20.9 SDK and verified native, JavaScript, package and canonical skill hashes.
Final version checks: Linux unit tests 294 passed/4 ignored; SDK tests 164
passed/6 environment-dependent cases skipped; canonical skill 7 passed;
release workflow tests 68 passed/1 skipped. The release build and package
verifier passed pack, publish dry-run, checksum-based clean installation,
canonical skill byte comparison and native loading (60 tools, revision support).
Node REPL remains 0.1.5. No release was published by these checks.
@@ -0,0 +1,112 @@
# Linux 观察内存与紧凑化
[English](linux-observation-memory-and-compaction.md)
## 范围
在现有 Linux 第二轮候选上只补两项:可访问性树紧凑展示、LibreOffice 观察资源限制。
App API、剪贴板和 OSWorld 代码不在范围内。补齐 SDK/driver 0.20.9 及其版本引用;
正式发布另行执行。SDK 到原生 AT-SPI 的调用结构、权限和安装方式保持不变。
## 紧凑展示
此前候选只缩短了单行字段,没有迁移 macOS 0.20.6 已包含的结构投影。
Linux 的 revision 还只包含可操作节点,导致静态状态和说明文本丢失。
保留观察到的静态上下文,再折叠空布局容器、去掉父子重复标签、合并同一父节点下
相邻的静态文本。保留窗口、对话框、菜单、列表、表格和文档边界,区分网页正文与
应用界面,保留焦点、选择、禁用状态及全部可操作节点。投影采用迭代实现,避免深树
消耗调用栈。原生索引、身份和结构化动作列表不变。revision 比较包含保留下来的
静态文字和可编辑字段清空后的空值。紧凑化仅用于模型观察输出;浏览器内部设置和
授权提示的精确匹配仍使用原始静态标签边界。
## 内存调查
Task035 原始工作簿的表格暴露 1,073,741,824 个子项,并声明 `manages-descendants`。
受控单次 `GetChildren` 请求使 LibreOffice RSS 在 24.14 秒内增加 107.75 MiB,
拟合速率 4.45 MiB/s,请求进程内存不变。达到软限制后主动停止独立实例,无须真正
触发 OOM 即可复现增长。这是直接 AT-SPI 请求的复现,不是历史沙箱重启根因的证明。
增加请求日志的原 b30 候选也通过实际 SDK 调用复现了增长:三次观察均退回 X11,
日志停在表格 GetChildren 且未返回;第三次调用期间 LibreOffice 约增长 4.52 MiB/s,
Node RSS 不变。调用方返回并不能取消应用已经开始的全量枚举。
普通节点改用 `ChildCount` 和惰性的 `GetChildAtIndex`,每层只保存下一兄弟游标;在请求
子节点之前检查节点、深度和时间预算。快照与动作共用前序遍历及可操作索引计算。达到
深度限制时只读元数据,不展开子项;子项请求失败时标记捕获不完整。修复不禁用
Calc 可访问性,也不会先分配完整数组再截断。
实测证明仅按索引读取仍不够:对相同 512 个真实单元格连续读三轮,每轮约 2.8 秒
返回,但 LibreOffice 每轮仍保留约 3.3–3.4 MiB 内存,部分单元格每次产生新的 AX
对象路径。AT-SPI 明确说明不应枚举 `ManagesDescendants` 节点的子项。因此不展开
这类虚拟子项,保留表格和其他界面,并以 `managed_descendants_omitted` 标记有界
观察;已捕获控件仍保留稳定身份,但不声称包含完整工作表。缺失内容用截图查看。
不引入应用重启或新的可访问性事件处理系统。
## 普通布局采集
普通 Calc 窗口还暴露了庞大的隐藏菜单树。独立有界普查读到 1,861 个节点,其中
1,638 个未显示;11,188 次明确的 D-Bus 请求均无错误。带计时的 final3 SDK 在
25 秒截止前完成 770 个节点,其中 752 个未显示,没有单次调用超时。因此剩余
问题是大量正常遍历工作累积,不能归因为 LibreOffice 的某个方法卡死。
原生计时包含点对点调用,计时包装次数不是总线消息数;独立普查的串行请求统计
也不能直接当作原生并发遍历耗时。
并发读取角色、状态和接口后,在名称、详情和子项读取之前跳过隐藏原生菜单及其
子树。保留已显示、聚焦、选中或展开的菜单;不对网页正文或未知可见性套用规则。
打开菜单后的下一次观察会读取其内容。以 `hidden_menu_subtrees_omitted` 明确标记
有界捕获,仅对实际读到的控件保留身份。
省略的命令不占可操作索引;token 按捕获的 AT-SPI 身份解析,打开菜单不会因行号
变化把已有 token 指向其他控件。
采集共享整个快照的绝对截止时间,包含准备和后续坐标阶段。累计节点和坐标保存在
可取消 future 之外,后续慢请求超时不会抹掉先前结果。时间预算耗尽仍是读取不完整,
不能保留稳定 revision token。原生 25 秒上限保持不变。facade 遇到
`walk_deadline_reached` 或 `element_bounds_timeout` 时直接返回,不再自动重读;
短暂读取失败仍保留一次重试。真实故障注入发现,对耗尽预算的捕获自动重试会产生
两次原生读取、约 51 秒等待。后续显式观察仍可正常恢复。
## 验证
将紧凑输出与同一份完整捕获树比较,同时检查必要内容、可操作身份以及行数和字符
变化。覆盖静态文本变化、无变化 revision、字段清空、表格行、网页边界、当前 token
和深层布局。在独占的 Linux 测试桌面执行真实 SDK 观察和独立应用状态回读。
采集单次及重复观察之前、期间、之后的 LibreOffice 与 SDK 进程内存,保留原始采样
及操作状态;基线和候选使用相同文档。不能从文本变短或调用超时推断模型计费下降
或历史 OOM 根因。
只修复内存的候选(`6babbd96`)通过四次全屏观察,但三次普通布局观察分别在
25.762、25.042、25.048 秒超时。这组精确二进制对照及上文计时验证,确认了
独立的隐藏菜单遍历问题。
普通布局修复版(`d4ba57b6`)在同一 Task035 文档上四次分别耗时 11.570、10.989、
13.454、10.815 秒,返回 157/157/190/157 个可操作元素。每次均到达真实表格,表格
token 保持不变;第二次未变化读取返回 `no_change`。点击 File 后出现 33 个新增
菜单标签,Escape 后恢复初始菜单标签集合。两项主动省略均明确报告,
`captureReadComplete=true`、`captureComplete=false`。连续 68.986 秒采样中,
LibreOffice RSS 在 340644–340652 KiB 之间,最大采样间隔 0.163 秒。
这是有界的真实应用验收,不是长期泄漏压测或完整 benchmark。
同一 native 的真实 GTK 对照也通过:legacy 树逐字一致,控件深度从 10 降到 2,
静态说明保留,token 点击、字段替换读回、无变化及 token 稳定性均正常。
revision 字符从 352 增至 554,因为基线遗漏了静态信息;这不是 token 成本下降
的测量结论。
受控 AT-SPI provider 验证了已完成节点和坐标保留、单个子项失败后继续读取后续
兄弟节点、状态读取失败后不枚举子项。旧 facade 在元数据超时场景读取两次,耗时
50.785 秒;坐标超时场景读取两次,耗时 50.965 秒。这些是修复前的重试测量,
不是 0.20.9 的预期延迟。
最终 0.20.9 包(native `3554fe81`、facade `f0ba1e38`)在同一元数据超时场景
耗时 25.833 秒,仅产生一次原生快照,保留 26 个控件,采集超时后没有发起坐标
请求。新的普通最大化 Calc 验证在 11.084 秒返回 157 个控件,随后在 10.492 秒
返回 `no_change`;表格 token 相同,两项有界捕获原因均明确报告。23.471 秒采样
中 LibreOffice RSS 固定为 340228 KiB,最大采样间隔 0.153 秒。该最终 smoke
使用打包后的 0.20.9 SDK,并核验了 native、JavaScript、包和固定 skill 的 hash。
最终版本检查:Linux 单元测试 294 通过、4 忽略;SDK 测试 164 通过、6 项依赖环境
的测试跳过;固定 skill 7 通过;发布流程测试 68 通过、1 跳过。release build 和
包验证通过,覆盖 pack、publish dry-run、checksum 校验的全新安装、固定 skill
逐字比较和原生加载(60 个工具、支持 revision)。Node REPL 保持 0.1.5;这些
检查没有执行正式发布。
+1 -1
View File
@@ -26,7 +26,7 @@ When first used, the skill runs these commands itself:
```bash
qwen mcp add --scope user node-repl npx -y @qwen-code/[email protected]
npm install --no-save --package-lock=false @qwen-code/[email protected].8
npm install --no-save --package-lock=false @qwen-code/[email protected].9
```
Restart Qwen Code after the MCP server is first added. The skill then resumes
@@ -47,7 +47,7 @@ If `node_repl` is unavailable, run:
```bash
qwen mcp add --scope user node-repl npx -y @qwen-code/[email protected]
npm install --no-save --package-lock=false @qwen-code/[email protected].8
npm install --no-save --package-lock=false @qwen-code/[email protected].9
```
Tell the user to restart Qwen Code, then stop. If only the SDK import is missing,
@@ -245,6 +245,21 @@ captured element list even when `.text` reports a diff or no change. Use tokens
from that list; unchanged tokens remain usable across observations. After a
capture read failure, use only tokens issued by the latest observation.
Text rows omit default enabled/unselected states and the primary click action.
`disabled` and `selected` mark non-default states; `actions` lists secondary
actions. Editable content appears separately from its label as `value`, including
an empty value after clearing a field. The element list retains all actions.
Empty layout containers and duplicate labels are omitted; adjacent static text
may share a row. Window, dialog, list and table context, focused/selected state,
and actionable elements remain visible. Use element tokens, not row positions.
Linux observations omit virtual children when an app manages their lifetime
(`managed_descendants_omitted`). The returned tree is bounded; use a screenshot
for sheet or canvas content that is not present in the tree.
Linux also omits hidden native menu branches (`hidden_menu_subtrees_omitted`).
Open a menu and observe again to read its displayed commands. A collection
timeout returns the completed portion with an incomplete-capture indication;
it does not establish that missing controls are absent.
Text is limited to 12,000 characters by default. Filter the elements for controls
you need, or request `disableDiff: true` with a larger `maxTextChars` (minimum
512). An omitted row does not prove absence. If you discarded earlier text,
@@ -265,6 +280,10 @@ await computer.typeText({ ...target, text: 'hello' });
nodeRepl.write((await computer.observeWindow(target)).text);
```
When the controls and next actions are already known, combine the actions and
saving in the same cell, then observe. A new observation is a decision boundary;
do not split a known sequence into one call per action.
End the batch when opening a dialog or menu. For a dialog, list windows and
observe the matching window before typing. For a menu in the same window,
refresh its observation and use current menu tokens. Never guess a window ID.
@@ -299,5 +318,7 @@ Do not stringify the whole observation or a raw driver result containing image
bytes. In outer code mode, also forward each returned image block with `image()`
as shown in the shared entrypoint, including images from `node_repl_wait`.
When all Computer Use work is complete, call `await computer.close()` and clear
`globalThis.computer`. Reset the REPL only when no other persistent state is needed.
Include `await computer.close()` and clearing `globalThis.computer` at the end
of the cell that emits final verification. Inspect that result before reporting
success; reconnect if it reveals unfinished work. Avoid a separate cleanup-only
call. Reset the REPL only when no other persistent state is needed.
+3 -3
View File
@@ -14,18 +14,18 @@ documented in [`.vendored-patches.md`](.vendored-patches.md) and
macOS and Linux:
```bash
CUA_DRIVER_RS_VERSION=0.20.8 \
CUA_DRIVER_RS_VERSION=0.20.9 \
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/QwenLM/qwen-code/main/packages/cua-driver/scripts/install.sh)"
```
Windows PowerShell:
```powershell
$env:CUA_DRIVER_RS_VERSION = "0.20.8"
$env:CUA_DRIVER_RS_VERSION = "0.20.9"
irm https://raw.githubusercontent.com/QwenLM/qwen-code/main/packages/cua-driver/scripts/install.ps1 | iex
```
Expected: qwen-cua-driver 0.20.8.
Expected: qwen-cua-driver 0.20.9.
The released product uses Qwen-owned identities throughout:
+13 -13
View File
@@ -981,7 +981,7 @@ dependencies = [
[[package]]
name = "cua-driver"
version = "0.20.8"
version = "0.20.9"
dependencies = [
"anyhow",
"async-trait",
@@ -1017,7 +1017,7 @@ dependencies = [
[[package]]
name = "cua-driver-bindgen"
version = "0.20.8"
version = "0.20.9"
dependencies = [
"cbindgen",
"uniffi",
@@ -1025,7 +1025,7 @@ dependencies = [
[[package]]
name = "cua-driver-contract"
version = "0.20.8"
version = "0.20.9"
dependencies = [
"schemars",
"serde",
@@ -1035,7 +1035,7 @@ dependencies = [
[[package]]
name = "cua-driver-core"
version = "0.20.8"
version = "0.20.9"
dependencies = [
"anyhow",
"async-trait",
@@ -1064,7 +1064,7 @@ dependencies = [
[[package]]
name = "cua-driver-sdk"
version = "0.20.8"
version = "0.20.9"
dependencies = [
"async-trait",
"core-foundation",
@@ -1086,7 +1086,7 @@ dependencies = [
[[package]]
name = "cua-driver-testkit"
version = "0.20.8"
version = "0.20.9"
dependencies = [
"core-foundation",
"libc",
@@ -1100,7 +1100,7 @@ dependencies = [
[[package]]
name = "cua-driver-uia"
version = "0.20.8"
version = "0.20.9"
dependencies = [
"anyhow",
"cua-driver-core",
@@ -1115,7 +1115,7 @@ dependencies = [
[[package]]
name = "cursor-overlay"
version = "0.20.8"
version = "0.20.9"
dependencies = [
"anyhow",
"cua-driver-contract",
@@ -1132,7 +1132,7 @@ dependencies = [
[[package]]
name = "cursor-theme-cli"
version = "0.20.8"
version = "0.20.9"
dependencies = [
"anyhow",
"cursor-overlay",
@@ -2900,7 +2900,7 @@ checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "pip-preview"
version = "0.20.8"
version = "0.20.9"
dependencies = [
"anyhow",
"serde_json",
@@ -2960,7 +2960,7 @@ checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
[[package]]
name = "platform-linux"
version = "0.20.8"
version = "0.20.9"
dependencies = [
"anyhow",
"ashpd",
@@ -3006,7 +3006,7 @@ dependencies = [
[[package]]
name = "platform-macos"
version = "0.20.8"
version = "0.20.9"
dependencies = [
"anyhow",
"async-trait",
@@ -3042,7 +3042,7 @@ dependencies = [
[[package]]
name = "platform-windows"
version = "0.20.8"
version = "0.20.9"
dependencies = [
"anyhow",
"async-trait",
+1 -1
View File
@@ -17,7 +17,7 @@ members = [
]
[workspace.package]
version = "0.20.8"
version = "0.20.9"
edition = "2021"
authors = ["trycua"]
license = "MIT"
@@ -1,7 +1,7 @@
---
name: cua-driver
description: Drive a native GUI app (macOS, Windows, Linux) via the Qwen Cua Driver CLI (default) or MCP server; snapshot its accessibility tree, act through snapshot-bound element tokens, native menu paths, exact window geometry, or pixel coordinates, and verify from fresh state. Use when the user asks you to operate, drive, automate, or perform a GUI task in a real application on the host.
version: 0.20.8 # x-release-please-version
version: 0.20.9 # x-release-please-version
metadata:
openclaw:
requires:
+1 -1
View File
@@ -1 +1 @@
0.20.8
0.20.9
@@ -122,6 +122,7 @@ mod tests {
checked: None,
enabled: Some(true),
selected: None,
focused: None,
description: None,
depth: 0,
parent_element_index: None,
@@ -13,6 +13,7 @@ use anyhow::Result;
pub mod cache;
pub mod native;
pub(crate) mod projection;
pub mod revision;
pub use cache::ElementCache;
pub use native::ensure_listener_active;
@@ -23,7 +24,7 @@ pub struct AtspiIdentity {
pub object_path: String,
}
#[derive(Clone, Debug)]
#[derive(Clone, Debug, Default)]
pub struct AtspiNode {
pub element_index: Option<usize>,
pub role: String,
@@ -35,6 +36,7 @@ pub struct AtspiNode {
pub enabled: Option<bool>,
/// Toggle/selection state for selectable controls.
pub selected: Option<bool>,
pub focused: Option<bool>,
pub description: Option<String>,
pub actions: Vec<String>,
/// For AT-SPI: element_key = element_index as u64.
@@ -87,7 +89,13 @@ impl AtspiTreeResult {
|| (self.truncated
&& !self.incomplete_notes.is_empty()
&& self.incomplete_notes.iter().all(|note| {
matches!(note.as_str(), "max_elements_reached" | "max_depth_reached")
matches!(
note.as_str(),
"max_elements_reached"
| "max_depth_reached"
| "managed_descendants_omitted"
| "hidden_menu_subtrees_omitted"
)
})))
}
}
@@ -140,50 +148,31 @@ pub fn walk_tree_bounded(
max_elements: Option<usize>,
max_depth: Option<usize>,
) -> AtspiTreeResult {
// Native AT-SPI (most complete). On a COLD launch the Qt6 (and some GTK)
// AT-SPI bridge registers lazily — the first walk against a freshly
// launched app can come back with just the root window (element_count=1,
// no children) because `org.a11y.atspi.Registry` hasn't finished
// enumerating the app's tree yet. Retry a few times with a short backoff
// while the tree is suspiciously root-only, so the first get_window_state
// after launch returns the real tree instead of an empty one. See #1927.
const MAX_ATTEMPTS: usize = 4;
let mut native_failure = None;
for attempt in 0..MAX_ATTEMPTS {
match native::walk_tree_bounded(pid, xid, max_elements, max_depth) {
Ok(Some(walked)) => {
// `nodes.len() <= 1` == only the root window resolved: the
// cold-registry symptom. Accept any real tree immediately; only
// keep waiting on the degenerate case, and accept it anyway on the
// final attempt rather than discarding a (minimal) valid result.
if !walked.markdown.is_empty()
&& (walked.nodes.len() > 1 || attempt == MAX_ATTEMPTS - 1)
{
let md = if let Some(q) = query {
filter_tree(&walked.markdown, q)
} else {
walked.markdown
};
return AtspiTreeResult {
tree_markdown: md,
nodes: walked.nodes,
bounds: walked.bounds,
trusted: true,
degraded_reason: None,
window_scoped: walked.window_scoped,
backend: AtspiBackend::Atspi,
complete: walked.complete && walked.window_scoped,
truncated: walked.truncated,
incomplete_notes: walked.incomplete_notes,
};
}
}
Ok(None) => {}
Err(error) => native_failure = Some(error.to_string()),
}
if attempt < MAX_ATTEMPTS - 1 {
std::thread::sleep(std::time::Duration::from_millis(150));
match walk_with_cold_start_retries(native::OP_TIMEOUT, |remaining| {
native::walk_tree_bounded_with_timeout(pid, xid, max_elements, max_depth, remaining)
}) {
Ok(Some(walked)) if !walked.markdown.is_empty() => {
let md = if let Some(q) = query {
filter_tree(&walked.markdown, q)
} else {
walked.markdown
};
return AtspiTreeResult {
tree_markdown: md,
nodes: walked.nodes,
bounds: walked.bounds,
trusted: true,
degraded_reason: None,
window_scoped: walked.window_scoped,
backend: AtspiBackend::Atspi,
complete: walked.complete && walked.window_scoped,
truncated: walked.truncated,
incomplete_notes: walked.incomplete_notes,
};
}
Ok(_) => {}
Err(error) => native_failure = Some(error.to_string()),
}
// Fallback: X11 window properties as minimal tree.
@@ -192,6 +181,35 @@ pub fn walk_tree_bounded(
fallback
}
fn walk_with_cold_start_retries(
timeout: std::time::Duration,
mut walk: impl FnMut(std::time::Duration) -> Result<Option<native::WalkedTree>>,
) -> Result<Option<native::WalkedTree>> {
const MAX_ATTEMPTS: usize = 4;
const BACKOFF: std::time::Duration = std::time::Duration::from_millis(150);
let deadline = std::time::Instant::now() + timeout;
for attempt in 0..MAX_ATTEMPTS {
let result = walk(deadline.saturating_duration_since(std::time::Instant::now()))?;
// Qt/GTK can register a complete but root-only tree on cold launch.
// Retry only that lazy-registration case, sharing the snapshot budget.
// Incomplete reads must reach the caller, not multiply its own retries.
let retry = result.as_ref().is_some_and(|walked| {
walked.complete && (walked.nodes.len() <= 1 || walked.markdown.is_empty())
});
if !retry
|| attempt == MAX_ATTEMPTS - 1
|| deadline.saturating_duration_since(std::time::Instant::now()) <= BACKOFF
{
return Ok(result);
}
std::thread::sleep(BACKOFF);
if std::time::Instant::now() >= deadline {
return Ok(result);
}
}
unreachable!()
}
/// Perform the first advertised action on element `idx` within pid's app tree.
/// Returns `Ok((action_name, suspected_noop))` on success — `suspected_noop`
/// is true when the actuated node looked like a silent no-op (a passive
@@ -383,6 +401,7 @@ fn walk_via_x11_properties(xid: u64, query: Option<&str>) -> AtspiTreeResult {
checked: None,
enabled: None,
selected: None,
focused: None,
description: if wm_class.is_empty() {
None
} else {
@@ -436,22 +455,33 @@ pub(crate) fn format_revision_body(node: &AtspiNode) -> String {
format!("<{}>", node.role),
serde_json::to_string(label).expect("string labels serialize"),
];
if let Some(value) = node.value.as_deref().filter(|value| !value.is_empty()) {
if let Some(value) = node.value.as_deref().filter(|value| *value != label) {
fields.push(format!(
"value={}",
serde_json::to_string(value).expect("string values serialize")
));
}
if let Some(enabled) = node.enabled {
fields.push(format!("enabled={enabled}"));
if node.enabled == Some(false) {
fields.push("disabled".into());
}
if let Some(selected) = node.selected.or(node.checked) {
fields.push(format!("selected={selected}"));
if node.focused == Some(true) {
fields.push("focused".into());
}
if !node.actions.is_empty() {
if node.selected.or(node.checked) == Some(true) {
fields.push("selected".into());
}
let primary = native::activation_index(&node.role, &node.actions);
let secondary = node
.actions
.iter()
.enumerate()
.filter(|(index, action)| Some(*index) != primary && !action.is_empty())
.map(|(_, action)| action)
.collect::<Vec<_>>();
if !secondary.is_empty() {
fields.push(format!(
"actions={}",
serde_json::to_string(&node.actions).expect("string actions serialize")
serde_json::to_string(&secondary).expect("string actions serialize")
));
}
if node.in_web_content {
@@ -543,3 +573,126 @@ fn filter_tree(markdown: &str, query: &str) -> String {
r.push('\n');
r
}
#[cfg(test)]
mod retry_tests {
use super::*;
use std::time::Duration;
fn tree(count: usize, complete: bool) -> native::WalkedTree {
native::WalkedTree {
markdown: "root".into(),
nodes: (0..count)
.map(|index| AtspiNode {
element_index: Some(index),
role: "frame".into(),
name: None,
value: None,
checked: None,
enabled: None,
selected: None,
focused: None,
description: None,
actions: Vec::new(),
element_key: index as u64,
depth: 0,
parent_element_index: None,
in_web_content: false,
identity: None,
})
.collect(),
bounds: Vec::new(),
window_scoped: true,
complete,
truncated: false,
incomplete_notes: if complete {
Vec::new()
} else {
vec!["interfaces_unavailable".into()]
},
}
}
#[test]
fn complete_trees_and_incomplete_reads_return_immediately() {
for (count, complete) in [(2, true), (0, false), (1, false), (2, false)] {
let mut calls = 0;
let result = walk_with_cold_start_retries(native::OP_TIMEOUT, |_| {
calls += 1;
Ok(Some(tree(count, complete)))
})
.unwrap()
.unwrap();
assert_eq!(calls, 1);
assert_eq!(result.complete, complete);
assert_eq!(result.nodes.len(), count);
assert_eq!(
result.incomplete_notes,
tree(count, complete).incomplete_notes
);
}
}
#[test]
fn unavailable_and_failed_reads_return_immediately() {
let mut calls = 0;
assert!(walk_with_cold_start_retries(native::OP_TIMEOUT, |_| {
calls += 1;
Ok(None)
})
.unwrap()
.is_none());
assert_eq!(calls, 1);
calls = 0;
let error = walk_with_cold_start_retries(native::OP_TIMEOUT, |_| {
calls += 1;
Err(anyhow::anyhow!("transport unavailable"))
})
.err()
.unwrap();
assert_eq!(calls, 1);
assert_eq!(error.to_string(), "transport unavailable");
}
#[test]
fn cold_root_can_recover_within_the_same_budget() {
let mut budgets = Vec::new();
let result = walk_with_cold_start_retries(native::OP_TIMEOUT, |budget| {
budgets.push(budget);
Ok(Some(tree(budgets.len(), true)))
})
.unwrap()
.unwrap();
assert_eq!(budgets.len(), 2);
assert!(budgets[0] - budgets[1] >= Duration::from_millis(150));
assert_eq!(result.nodes.len(), 2);
}
#[test]
fn cold_root_is_preserved_after_last_attempt() {
let mut calls = 0;
let result = walk_with_cold_start_retries(native::OP_TIMEOUT, |_| {
calls += 1;
Ok(Some(tree(1, true)))
})
.unwrap()
.unwrap();
assert_eq!(calls, 4);
assert_eq!(result.markdown, "root");
assert!(result.complete);
}
#[test]
fn exhausted_budget_preserves_root_without_another_attempt() {
let mut calls = 0;
let result = walk_with_cold_start_retries(Duration::ZERO, |budget| {
calls += 1;
assert!(budget.is_zero());
Ok(Some(tree(1, true)))
})
.unwrap()
.unwrap();
assert_eq!(calls, 1);
assert_eq!(result.markdown, "root");
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,268 @@
use super::AtspiNode;
fn label(node: &AtspiNode) -> &str {
node.name
.as_deref()
.or(node.value.as_deref())
.or(node.description.as_deref())
.unwrap_or_default()
}
fn protected(node: &AtspiNode) -> bool {
node.element_index.is_some()
|| node.focused == Some(true)
|| node.selected == Some(true)
|| node.checked == Some(true)
|| !node.actions.is_empty()
}
fn label_only(node: &AtspiNode) -> bool {
node.enabled != Some(false)
&& [
node.name.as_deref(),
node.value.as_deref(),
node.description.as_deref(),
]
.into_iter()
.flatten()
.all(|value| value == label(node))
}
fn text_only(node: &AtspiNode, leaf: bool) -> bool {
leaf && !protected(node)
&& label_only(node)
&& matches!(node.role.as_str(), "label" | "static" | "static text")
}
fn context(node: &AtspiNode) -> bool {
matches!(
node.role.as_str(),
"frame"
| "window"
| "dialog"
| "alert"
| "menu"
| "menu bar"
| "table"
| "table row"
| "table cell"
| "list"
| "list item"
| "tree"
| "tree item"
| "page tab list"
) || node.role.contains("document")
}
/// Project presentation only: native actionable indices and identities never change.
pub(crate) fn compact(nodes: Vec<AtspiNode>) -> Vec<AtspiNode> {
let mut ends = vec![nodes.len(); nodes.len()];
let mut ancestry: Vec<usize> = Vec::new();
for (index, node) in nodes.iter().enumerate() {
while ancestry
.last()
.is_some_and(|&parent| nodes[parent].depth >= node.depth)
{
ends[ancestry.pop().expect("checked ancestor")] = index;
}
ancestry.push(index);
}
let mut skip = vec![false; nodes.len()];
for (index, node) in nodes.iter().enumerate() {
if protected(node) || context(node) || !label_only(node) {
continue;
}
let leaf = ends[index] == index + 1;
let layout = matches!(
node.role.as_str(),
"filler" | "panel" | "section" | "scroll pane" | "unknown"
);
skip[index] = (label(node).is_empty() && (layout || leaf))
|| (layout
&& !leaf
&& ends[index + 1] == ends[index]
&& nodes[index + 1].in_web_content == node.in_web_content
&& label(&nodes[index + 1]) == label(node));
}
let mut result: Vec<AtspiNode> = Vec::new();
let mut parents: Vec<(usize, usize)> = Vec::new();
let mut previous_text_parent = None;
let mut previous_was_text = false;
for (index, mut node) in nodes.into_iter().enumerate() {
while parents
.last()
.is_some_and(|(depth, _)| *depth >= node.depth)
{
parents.pop();
}
if skip[index] {
continue;
}
let source_depth = node.depth;
let parent = parents.last().map(|&(_, index)| index);
let text = text_only(&node, ends[index] == index + 1);
if text
&& parent.is_some_and(|parent| {
let parent = &result[parent];
parent.in_web_content == node.in_web_content
&& !label(parent).is_empty()
&& label(parent) == label(&node)
})
{
continue;
}
if text && previous_was_text && previous_text_parent == parent {
if let Some(previous) = result
.last_mut()
.filter(|previous| previous.in_web_content == node.in_web_content)
{
let combined = format!("{}\n{}", label(previous), label(&node));
previous.name = Some(combined);
previous.value = None;
previous.description = None;
continue;
}
}
node.depth = parents.len();
node.parent_element_index = parent.and_then(|parent| {
result[parent]
.element_index
.or(result[parent].parent_element_index)
});
previous_was_text = text;
previous_text_parent = parent;
parents.push((source_depth, result.len()));
result.push(node);
}
result
}
#[cfg(test)]
mod tests {
use super::*;
use crate::atspi::AtspiIdentity;
fn node(role: &str, text: &str, depth: usize, index: Option<usize>) -> AtspiNode {
AtspiNode {
role: role.into(),
name: Some(text.into()),
depth,
element_index: index,
identity: Some(AtspiIdentity {
unique_owner: ":1.2".into(),
object_path: format!("/{role}/{depth}/{index:?}"),
}),
..Default::default()
}
}
#[test]
fn folds_layout_and_duplicate_labels_without_changing_controls() {
let button = node("push button", "Save", 3, Some(7));
let projected = compact(vec![
node("frame", "Document", 0, None),
node("panel", "", 1, None),
node("panel", "Save", 2, None),
button.clone(),
node("label", "Save", 4, None),
]);
assert_eq!(projected.len(), 2);
assert_eq!(projected[1].element_index, Some(7));
assert_eq!(projected[1].identity, button.identity);
assert_eq!(projected[1].depth, 1);
}
#[test]
fn merges_static_siblings_but_keeps_table_and_web_boundaries() {
let mut web = node("label", "web", 1, None);
web.in_web_content = true;
let projected = compact(vec![
node("frame", "Document", 0, None),
node("label", "first", 1, None),
node("static", "second", 1, None),
web,
node("table row", "", 1, None),
node("label", "row one", 2, None),
node("table row", "", 1, None),
node("label", "row two", 2, None),
]);
assert_eq!(projected.len(), 7);
assert_eq!(label(&projected[1]), "first\nsecond");
assert_eq!(label(&projected[2]), "web");
assert_eq!(label(&projected[4]), "row one");
assert_eq!(label(&projected[6]), "row two");
}
#[test]
fn preserves_empty_values_states_actions_and_action_ancestry() {
let mut field = node("entry", "Name", 0, Some(4));
field.value = Some(String::new());
let mut focused = node("panel", "", 1, None);
focused.focused = Some(true);
let mut disabled = node("push button", "", 1, None);
disabled.enabled = Some(false);
disabled.actions = vec!["click".into()];
let mut selected = node("list item", "", 1, None);
selected.selected = Some(true);
let projected = compact(vec![
field,
focused,
disabled,
selected,
node("entry", "Child", 1, Some(9)),
]);
assert_eq!(projected.len(), 5);
assert_eq!(projected[0].value.as_deref(), Some(""));
assert_eq!(projected[1].focused, Some(true));
assert_eq!(projected[2].actions, ["click"]);
assert_eq!(projected[3].selected, Some(true));
assert_eq!(projected[4].parent_element_index, Some(4));
}
#[test]
fn deep_empty_layout_is_iterative() {
let mut nodes = (0..5000)
.map(|depth| node("panel", "", depth, None))
.collect::<Vec<_>>();
nodes.push(node("entry", "End", 5000, Some(0)));
let projected = compact(nodes);
assert_eq!(projected.len(), 1);
assert_eq!(projected[0].depth, 0);
assert_eq!(projected[0].element_index, Some(0));
}
#[test]
fn redundant_form_shrinks_without_losing_field_values_or_tokens() {
let mut nodes = vec![node("frame", "Profile", 0, None)];
for index in 0..8 {
nodes.push(node("panel", "", 1, None));
let label = format!("Field {index}");
let mut field = node("entry", &label, 2, Some(index));
field.value = Some(format!("value {index}"));
nodes.push(field);
nodes.push(node("label", &label, 3, None));
}
let render = |nodes: &[AtspiNode]| {
nodes
.iter()
.map(super::super::format_revision_body)
.collect::<Vec<_>>()
.join("\n")
};
let full = render(&nodes);
let projected = compact(nodes.clone());
let compact_text = render(&projected);
assert!(compact_text.len() < full.len());
assert_eq!(projected.len(), 9);
for original in nodes.iter().filter(|node| node.element_index.is_some()) {
let retained = projected
.iter()
.find(|node| node.element_index == original.element_index)
.unwrap();
assert_eq!(retained.identity, original.identity);
assert_eq!(retained.value, original.value);
assert_eq!(retained.actions, original.actions);
}
}
}
@@ -271,6 +271,7 @@ mod tests {
checked: None,
enabled: Some(true),
selected: None,
focused: None,
description: None,
actions: vec!["click".into()],
element_key: 0,
@@ -301,7 +302,12 @@ mod tests {
#[test]
fn budget_captures_retain_lineage_but_failures_and_coverage_changes_invalidate_it() {
for reason in ["max_elements_reached", "max_depth_reached"] {
for reason in [
"max_elements_reached",
"max_depth_reached",
"managed_descendants_omitted",
"hidden_menu_subtrees_omitted",
] {
let revisions = LinuxObservationRevisions::new();
let mut bounded = tree(vec![node("/button", "Before")]);
bounded.complete = false;
@@ -417,6 +423,139 @@ mod tests {
assert_eq!(changed.nodes[0].element_id, initial.nodes[0].element_id);
}
#[test]
fn named_editable_changes_and_clearing_remain_visible() {
let revisions = LinuxObservationRevisions::new();
let mut captured = tree(
(0..8)
.map(|index| {
let mut item = node(&format!("/field/{index}"), "Name");
item.element_index = Some(index);
item.role = "entry".into();
item.value = Some(String::new());
item
})
.collect(),
);
let mut base = None;
let mut element_id = None;
for (value, mode) in [
("", ObservationMode::Full),
("prefix|X", ObservationMode::Diff),
("prefix|X", ObservationMode::NoChange),
("", ObservationMode::Diff),
] {
captured.nodes[0].value = Some(value.into());
let observation = revisions
.observe(
session(),
10,
20,
5000,
usize::MAX,
&captured,
&request(base),
)
.unwrap();
assert_eq!(observation.mode, mode);
assert!(observation.nodes[0]
.body
.contains(&format!("value={value:?}")));
if let Some(id) = &element_id {
assert_eq!(&observation.nodes[0].element_id, id);
}
element_id = Some(observation.nodes[0].element_id.clone());
base = Some(observation.revision_id);
}
}
#[test]
fn compact_rows_preserve_text_state_and_secondary_actions() {
let mut control = node("/entry", "Line 1\n\"Line 2\"");
control.role = "entry".into();
control.value = control.name.clone();
control.enabled = Some(false);
control.selected = Some(true);
control.actions = vec!["activate".into(), "showContextMenu".into()];
let body = format_revision_body(&control);
assert_eq!(
body,
"<entry> \"Line 1\\n\\\"Line 2\\\"\" disabled selected actions=[\"showContextMenu\"]"
);
let disabled = body.clone();
control.enabled = Some(true);
assert_ne!(format_revision_body(&control), disabled);
let selected = format_revision_body(&control);
control.selected = Some(false);
assert_ne!(format_revision_body(&control), selected);
let secondary = format_revision_body(&control);
control.actions.pop();
assert_ne!(format_revision_body(&control), secondary);
assert_eq!(control.actions, ["activate"]);
}
#[test]
fn compact_static_text_changes_are_visible_without_remapping_controls() {
let revisions = LinuxObservationRevisions::new();
let capture = |message: &str| {
let mut root = node("/window", "Dialog");
root.role = "dialog".into();
root.element_index = None;
root.actions.clear();
let mut text = node("/message", message);
text.role = "label".into();
text.element_index = None;
text.actions.clear();
text.depth = 1;
let mut button = node("/ok", "OK");
button.element_index = Some(17);
button.depth = 1;
tree(super::super::projection::compact(vec![root, text, button]))
};
let first = revisions
.observe(session(), 10, 20, 50, 5, &capture("Saved"), &request(None))
.unwrap();
let unchanged = revisions
.observe(
session(),
10,
20,
50,
5,
&capture("Saved"),
&request(Some(first.revision_id.clone())),
)
.unwrap();
assert_eq!(unchanged.mode, ObservationMode::NoChange);
let changed = revisions
.observe(
session(),
10,
20,
50,
5,
&capture("Failed to save"),
&request(Some(unchanged.revision_id)),
)
.unwrap();
assert!(changed.text.contains("Failed to save"));
let control =
|result: &cua_driver_core::observation_revision::ObservationRevisionResult| {
result
.nodes
.iter()
.find(|node| node.actionable_index == Some(17))
.unwrap()
.element_id
};
assert_eq!(control(&first), control(&changed));
assert!(changed
.nodes
.iter()
.filter(|node| node.actionable_index.is_some())
.all(|node| node.actionable_index == Some(17)));
}
#[test]
fn owner_change_and_incomplete_capture_fail_closed() {
let revisions = LinuxObservationRevisions::new();
@@ -319,6 +319,7 @@ mod tests {
checked: None,
enabled: None,
selected: None,
focused: None,
description: None,
actions: actions.iter().map(|value| (*value).to_owned()).collect(),
element_key: 0,
@@ -662,6 +662,7 @@ mod tests {
checked: None,
enabled: None,
selected: None,
focused: None,
description: None,
actions: actions.iter().map(|value| (*value).to_owned()).collect(),
element_key: 0,
@@ -1010,7 +1010,7 @@ pub fn with_x11_foreground<T>(
unsafe {
x11::xlib::XGetInputFocus(display, &mut prior_core_focus, &mut prior_revert);
}
let timeout = std::time::Duration::from_millis(settle_ms.max(400));
let timeout = std::time::Duration::from_millis(settle_ms.max(1000));
let started = std::time::Instant::now();
let deadline = started + timeout;
let retry_at = started + timeout / 2;
@@ -841,13 +841,19 @@ impl Tool for GetWindowStateTool {
let query_for_walk = query.clone();
let result = tokio::task::spawn_blocking(move || -> anyhow::Result<_> {
let tree_result = Some(crate::atspi::walk_tree_bounded(
let mut tree = crate::atspi::walk_tree_bounded(
pid,
xid,
query_for_walk.as_deref(),
max_elements,
max_depth,
));
);
// Compact model observations only. Internal browser setup/consent
// matching needs the original label boundaries from the native tree.
if revision_request_for_capture.is_some() {
tree.nodes = crate::atspi::projection::compact(tree.nodes);
}
let tree_result = Some(tree);
// Bounds and element indices come from the same captured AT-SPI
// traversal. Joining two live walks by ordinal mis-associated
// Chromium controls when its lazy subtree changed between walks.
@@ -1051,7 +1057,7 @@ impl Tool for GetWindowStateTool {
// the text from a caller reading the structured side,
// leaving it only in tree_markdown. See the macOS
// get_window_state builder for the rationale.
if let Some(value) = n.value.clone().filter(|v| !v.is_empty()) {
if let Some(value) = n.value.clone() {
entry["value"] = json!(value);
}
if let Some(enabled) = n.enabled {
@@ -1060,6 +1066,9 @@ impl Tool for GetWindowStateTool {
if !n.actions.is_empty() {
entry["actions"] = json!(n.actions);
}
if let Some(focused) = n.focused {
entry["focused"] = json!(focused);
}
if let Some(selected) = n.selected {
entry["selected"] = json!(selected);
}
@@ -1653,7 +1662,7 @@ fn chromium_family_program(program: &str) -> bool {
// ── shared helpers ────────────────────────────────────────────────────────────
/// Resolve an AT-SPI element's center in window-local coordinates.
/// Resolve a visible AT-SPI element point in window-local coordinates.
///
/// Returns `(xid, window_local_x, window_local_y)`.
/// Looks up element bounds via native AT-SPI, finds the owning window
@@ -1665,10 +1674,7 @@ fn resolve_element_local_coords(
idx: usize,
xid_hint: Option<u64>,
) -> anyhow::Result<(u64, f64, f64)> {
let (bx, by, bw, bh) = crate::atspi::get_element_bounds(pid, idx)?;
let screen_cx = bx as f64 + bw as f64 / 2.0;
let screen_cy = by as f64 + bh as f64 / 2.0;
screen_point_to_window_local(pid, xid_hint, screen_cx, screen_cy)
resolve_element_local_coords_exact(pid, idx, xid_hint, None)
}
fn resolve_element_local_coords_exact(
@@ -1676,21 +1682,6 @@ fn resolve_element_local_coords_exact(
idx: usize,
xid_hint: Option<u64>,
identity: Option<crate::atspi::AtspiIdentity>,
) -> anyhow::Result<(u64, f64, f64)> {
let (bx, by, bw, bh) = match identity {
Some(identity) => crate::atspi::get_element_bounds_exact(pid, idx, identity)?,
None => crate::atspi::get_element_bounds(pid, idx)?,
};
let screen_cx = bx as f64 + bw as f64 / 2.0;
let screen_cy = by as f64 + bh as f64 / 2.0;
screen_point_to_window_local(pid, xid_hint, screen_cx, screen_cy)
}
fn screen_point_to_window_local(
pid: u32,
xid_hint: Option<u64>,
screen_cx: f64,
screen_cy: f64,
) -> anyhow::Result<(u64, f64, f64)> {
let xid = if let Some(x) = xid_hint {
x
@@ -1707,14 +1698,14 @@ fn screen_point_to_window_local(
.map(|w| w.xid)
.ok_or_else(|| anyhow::anyhow!("No windows for pid {pid}"))?
};
let bounds = crate::atspi::native::get_element_bounds_in_window(pid, idx, xid, identity)?;
if crate::wayland::wayland_input_enabled() {
let (window_x, window_y, window_width, window_height) =
crate::wayland::window_geometry(xid)
.ok_or_else(|| anyhow::anyhow!("No Wayland geometry for window {xid}"))?;
if window_width == 0 || window_height == 0 {
anyhow::bail!("Wayland window {xid} has no usable geometry");
}
let (screen_cx, screen_cy) =
visible_element_point(bounds, (window_x, window_y, window_width, window_height))?;
return Ok((
xid,
screen_cx - window_x as f64,
@@ -1727,24 +1718,80 @@ fn screen_point_to_window_local(
use x11rb::rust_connection::RustConnection;
let (conn, screen_num) = RustConnection::connect(None)?;
let root = conn.setup().roots[screen_num].root;
if !crate::x11::window_belongs_to_pid(xid, pid) {
anyhow::bail!("window {xid} no longer belongs to pid {pid}");
}
let reply = conn
.translate_coordinates(xid as u32, root, 0, 0)?
.reply()?;
let geometry = conn.get_geometry(xid as u32)?.reply()?;
let (screen_cx, screen_cy) = visible_element_point(
bounds,
(
reply.dst_x.into(),
reply.dst_y.into(),
geometry.width.into(),
geometry.height.into(),
),
)?;
let local_x = screen_cx - reply.dst_x as f64;
let local_y = screen_cy - reply.dst_y as f64;
Ok((xid, local_x, local_y))
}
fn visible_element_point(
element: (i32, i32, u32, u32),
window: (i32, i32, u32, u32),
) -> anyhow::Result<(f64, f64)> {
let left = i64::from(element.0).max(i64::from(window.0));
let top = i64::from(element.1).max(i64::from(window.1));
let right = (i64::from(element.0) + i64::from(element.2))
.min(i64::from(window.0) + i64::from(window.2));
let bottom = (i64::from(element.1) + i64::from(element.3))
.min(i64::from(window.1) + i64::from(window.3));
if right <= left || bottom <= top {
anyhow::bail!("element has no visible area inside the target window");
}
Ok((
(left + (right - left) / 2) as f64,
(top + (bottom - top) / 2) as f64,
))
}
#[cfg(test)]
#[test]
fn element_pointer_stays_inside_the_requested_window() {
let window = (70, 102, 440, 300);
assert_eq!(
visible_element_point((90, 160, 700, 700), window).unwrap(),
(300.0, 281.0)
);
assert!(visible_element_point((520, 410, 50, 50), window).is_err());
assert!(visible_element_point((90, 160, 0, 40), window).is_err());
assert_eq!(
visible_element_point((509, 401, 100, 100), window).unwrap(),
(509.0, 401.0)
);
assert_eq!(
visible_element_point((-200, -200, 300, 300), (-100, -100, 50, 50)).unwrap(),
(-75.0, -75.0)
);
}
fn element_screen_center_exact(
pid: u32,
idx: usize,
xid_hint: Option<u64>,
identity: Option<crate::atspi::AtspiIdentity>,
) -> anyhow::Result<(f64, f64)> {
let (bx, by, bw, bh) = match identity {
Some(identity) => crate::atspi::get_element_bounds_exact(pid, idx, identity)?,
None => crate::atspi::get_element_bounds(pid, idx)?,
};
Ok((bx as f64 + bw as f64 / 2.0, by as f64 + bh as f64 / 2.0))
let (xid, x, y) = resolve_element_local_coords_exact(pid, idx, xid_hint, identity)?;
if crate::wayland::wayland_input_enabled() {
let (ox, oy, _, _) = crate::wayland::window_geometry(xid)
.ok_or_else(|| anyhow::anyhow!("No Wayland geometry for window {xid}"))?;
Ok((x + f64::from(ox), y + f64::from(oy)))
} else {
window_local_to_screen(xid, x, y)
}
}
fn cached_atspi_identity(
@@ -1846,27 +1893,15 @@ fn non_ax_escalation() -> Value {
/// `effect` tri-state. Linux's AT-SPI `insertText` return value acknowledges
/// the method call but does not read the widget value back, so it and every
/// keystroke / XSendEvent / XTest / Wayland rung are `"unverifiable"` (the
/// caller confirms through a separate observation) and
/// carries a `foreground` escalation, because the field IS in the AT-SPI tree —
/// it's a delivery/focus problem, not a missing element. The foreground rung
/// itself (`key_events_fg`) is already the last resort, so it emits no
/// escalation. Mirrors the macOS `type_text` contract.
/// caller confirms through a separate observation). An acknowledgement without
/// readback does not establish failed delivery or require another input.
fn type_text_structured(path: &str, characters: usize, verified: bool) -> Value {
let mut s = json!({
json!({
"path": path,
"characters": characters,
"verified": verified,
"effect": if verified { "confirmed" } else { "unverifiable" },
});
if !verified && path != "key_events_fg" {
s["escalation"] = json!({
"recommended": "foreground",
"reason": "background insert could not be confirmed — re-call with \
delivery_mode:\"foreground\" if a screenshot shows the text \
didn't land."
});
}
s
})
}
/// Structured payload for the Electron/Chromium AX-echo case: the AT-SPI
@@ -2194,7 +2229,13 @@ fn x11_pixel_click_no_focus_steal(
}
fn linux_input_error(error: anyhow::Error) -> ToolResult {
if crate::input::is_uinput_unavailable(&error) {
if crate::atspi::native::input_was_dispatched(&error) {
ToolResult::error(format!("{error:#}")).with_structured(json!({
"code": "atspi_input_unconfirmed",
"effect": "unverifiable",
"input_dispatched": true,
}))
} else if crate::input::is_uinput_unavailable(&error) {
ToolResult::error(error.to_string()).with_structured(json!({
"code": crate::input::UINPUT_UNAVAILABLE_CODE,
}))
@@ -2746,7 +2787,7 @@ impl Tool for ClickTool {
// Previously perform_action ran inside this spawn_blocking, so the
// app updated before the cursor visibly arrived.
let (xid, sx, sy) = tokio::task::spawn_blocking(move || -> (u64, f64, f64) {
let (cx, cy) = element_screen_center_exact(pid, idx, identity_for_center)
let (cx, cy) = element_screen_center_exact(pid, idx, xid_hint, identity_for_center)
.unwrap_or((0.0, 0.0));
let xid = xid_hint
.or_else(|| {
@@ -2774,12 +2815,22 @@ impl Tool for ClickTool {
// Chromium can execute a genuine AT-SPI action without focus. Try
// that route before applying its background synthetic-input gate.
if modifiers.is_empty() {
let ax_result = tokio::task::spawn_blocking(move || match exact_identity {
if button == 1 && count == 1 && modifiers.is_empty() {
let identity_for_action = exact_identity.clone();
let ax_result = tokio::task::spawn_blocking(move || match identity_for_action {
Some(identity) => crate::atspi::perform_action_exact(pid, idx, identity),
None => crate::atspi::perform_action(pid, idx),
})
.await;
let ax_result = match ax_result {
Ok(Err(error)) if crate::atspi::native::input_was_dispatched(&error) => {
return linux_input_error(error);
}
Err(error) => {
return ToolResult::error(format!("AT-SPI action task failed: {error}"))
}
result => result,
};
if let Ok(Ok((_action, suspected_noop))) = ax_result {
let mut structured = json!({
"path": "ax",
@@ -2800,7 +2851,8 @@ impl Tool for ClickTool {
// The AX route was unavailable. Fall back to a target-addressed
// X11 event for toolkits that accept it.
let result = tokio::task::spawn_blocking(move || -> anyhow::Result<()> {
let (xid2, lx, ly) = resolve_element_local_coords(pid, idx, xid_hint)?;
let (xid2, lx, ly) =
resolve_element_local_coords_exact(pid, idx, xid_hint, exact_identity.clone())?;
let modifier_refs: Vec<&str> = modifiers.iter().map(String::as_str).collect();
if crate::wayland::wayland_input_enabled() && !modifier_refs.is_empty() {
anyhow::bail!(
@@ -2815,6 +2867,8 @@ impl Tool for ClickTool {
// clicks made those rows addressable but not selectable.
if delivery.is_foreground() && !crate::wayland::wayland_input_enabled() {
crate::input::with_x11_foreground(xid2, 80, || {
let (sx, sy) =
element_screen_center_exact(pid, idx, Some(xid2), exact_identity)?;
crate::input::send_click_xtest_desktop_with_modifiers(
sx.round() as i32,
sy.round() as i32,
@@ -2940,10 +2994,13 @@ impl Tool for ClickTool {
// working. (x,y) are screen coords here, matching the frames in
// `get_window_state`. Miss → fall through to the injection paths.
if !delivery.is_foreground() && button == 1 && count == 1 {
if let Ok(Some(_)) =
crate::atspi::perform_action_at_screen_point(pid, xid, output_x, output_y)
match crate::atspi::perform_action_at_screen_point(pid, xid, output_x, output_y)
{
return Ok("wayland_atspi");
Ok(Some(_)) => return Ok("wayland_atspi"),
Err(error) if crate::atspi::native::input_was_dispatched(&error) => {
return Err(error)
}
_ => {}
}
}
if crate::wayland::is_inject_mode() {
@@ -2968,8 +3025,12 @@ impl Tool for ClickTool {
// click (the agent's escalation when background didn't land).
let inject = |fg: bool| -> anyhow::Result<&'static str> {
if !fg && button == 1 && count == 1 && modifiers_for_task.is_empty() {
if let Ok(Some(_)) = crate::atspi::perform_action_at_point(pid, xi, yi) {
return Ok("x11_atspi");
match crate::atspi::perform_action_at_point(pid, xi, yi) {
Ok(Some(_)) => return Ok("x11_atspi"),
Err(error) if crate::atspi::native::input_was_dispatched(&error) => {
return Err(error)
}
_ => {}
}
}
if fg {
@@ -3291,7 +3352,7 @@ impl Tool for TypeTextTool {
);
let identity_for_center = exact_identity.clone();
if let Ok(Ok((screen_x, screen_y))) = tokio::task::spawn_blocking(move || {
element_screen_center_exact(pid, idx, identity_for_center)
element_screen_center_exact(pid, idx, Some(xid), identity_for_center)
})
.await
{
@@ -3319,8 +3380,17 @@ impl Tool for TypeTextTool {
type_into_atspi_target(pid, idx, identity_for_type, &text_at)
})
.await;
if let Ok(Ok(())) = targeted {
return type_text_ax_result(pid, text_len, "via targeted AT-SPI");
match targeted {
Ok(Ok(())) => {
return type_text_ax_result(pid, text_len, "via targeted AT-SPI");
}
Ok(Err(error)) if crate::atspi::native::input_was_dispatched(&error) => {
return linux_input_error(error);
}
Err(error) => {
return ToolResult::error(format!("AT-SPI typing task failed: {error}"))
}
_ => {}
}
}
// The private nested compositor can target the owning Wayland client
@@ -3443,13 +3513,20 @@ impl Tool for TypeTextTool {
Ok(Ok(())) => {
return type_text_ax_result(pid, text_len, "via targeted AT-SPI");
}
Ok(Err(_)) | Err(_)
Ok(Err(error)) if crate::atspi::native::input_was_dispatched(&error) => {
return linux_input_error(error);
}
Err(error) => {
return ToolResult::error(format!("AT-SPI typing task failed: {error}"))
}
Ok(Err(_))
if !delivery.is_foreground() && crate::wayland::wayland_input_enabled() =>
{
return crate::input::delivery::background_unavailable_error(
crate::input::delivery::BackgroundUnavailable::FocusedInputOnly,
);
}
Ok(Err(error)) if !delivery.is_foreground() => return linux_input_error(error),
_ => {}
}
}
@@ -3545,7 +3622,11 @@ impl Tool for TypeTextTool {
// and WebKitGTK can acknowledge an accessibility write without
// producing the renderer input event, so web embedders use real XTest
// key events. Native toolkits keep their verifiable AT-SPI path below.
if delivery.is_foreground() && (is_chromium_embedder(pid) || is_webkitgtk_embedder(pid)) {
if delivery.is_foreground()
&& (resolved_elem_idx.is_some()
|| is_chromium_embedder(pid)
|| is_webkitgtk_embedder(pid))
{
let text_f = text.clone();
let idx = resolved_elem_idx;
let identity_for_focus = exact_identity.clone();
@@ -3637,6 +3718,10 @@ impl Tool for TypeTextTool {
// observing it, so the confirm is suppressed there (mirrors macOS).
return type_text_ax_result(pid, text_len, "via AT-SPI");
}
Ok(Err(error)) if crate::atspi::native::input_was_dispatched(&error) => {
return linux_input_error(error);
}
Err(error) => return ToolResult::error(format!("AT-SPI typing task failed: {error}")),
_ => {
// AT-SPI failed (no editable exposed). Qt5 doesn't expose widgets
// when unfocused, so try the synthetic-focus workaround.
@@ -3656,16 +3741,25 @@ impl Tool for TypeTextTool {
let result = crate::atspi::type_into_editable(pid, &text_clone2);
// Restore state with FocusOut
crate::input::send_focus_out(xid)?;
let restore = crate::input::send_focus_out(xid);
result
Ok::<_, anyhow::Error>((result, restore))
})
.await;
match qt5_result {
Ok(Ok(())) => {
Ok(Ok((Ok(()), Ok(())))) => {
return type_text_ax_result(pid, text_len, "via AT-SPI with focus workaround");
}
Ok(Ok((Err(error), _))) if crate::atspi::native::input_was_dispatched(&error) => {
return linux_input_error(error);
}
Ok(Ok((_, Err(error)))) => {
return ToolResult::error(format!(
"AT-SPI typing focus restoration failed: {error}; observe before retrying"
));
}
Err(error) => return ToolResult::error(format!("AT-SPI typing task failed: {error}")),
_ => {
// AT-SPI still didn't work. Fall back to X11 XSendEvent.
}
@@ -3688,8 +3782,12 @@ impl Tool for TypeTextTool {
// focused widget, so background XSendEvent typing doesn't land. Fill
// the editable field via AT-SPI instead — focus-free and toolkit-
// agnostic. Fall back to Tk send or XSendEvent when no a11y field is exposed.
if crate::atspi::insert_text(pid, &text).unwrap_or(false) {
return Ok("ax");
match crate::atspi::insert_text(pid, &text) {
Ok(true) => return Ok("ax"),
Err(error) if crate::atspi::native::input_was_dispatched(&error) => {
return Err(error)
}
_ => {}
}
// Tk apps: use Tk's `send` command (no AT-SPI bridge, so AT-SPI above
// returned false). This is the Tk-specific override, like CDP for Chromium.
@@ -3729,7 +3827,7 @@ impl Tool for TypeTextTool {
"Typed {text_len} character(s) (via X11, delivery_mode={mode_label})."
))
.with_structured(type_text_structured(path, text_len, false)),
Ok(Err(e)) => ToolResult::error(e.to_string()),
Ok(Err(e)) => linux_input_error(e),
Err(e) => ToolResult::error(format!("Task error: {e}")),
}
}
@@ -4537,7 +4635,7 @@ impl Tool for SetValueTool {
// be resolved or the overlay is disabled.
let identity_for_center = exact_identity.clone();
if let Ok(Ok((sx, sy))) = tokio::task::spawn_blocking(move || {
element_screen_center_exact(pid, idx, identity_for_center)
element_screen_center_exact(pid, idx, Some(xid), identity_for_center)
})
.await
{
@@ -4557,7 +4655,7 @@ impl Tool for SetValueTool {
.await;
match result {
Ok(Ok(())) => ToolResult::text(format!("Set value of element [{idx}] to '{value}'.")),
Ok(Err(e)) => ToolResult::error(e.to_string()),
Ok(Err(e)) => linux_input_error(e),
Err(e) => ToolResult::error(format!("Task error: {e}")),
}
}
@@ -4857,6 +4955,15 @@ impl Tool for ScrollTool {
None => crate::atspi::scroll_element(pid, idx, &direction_for_ax, amount),
})
.await;
let ax_result = match ax_result {
Ok(Err(error)) if crate::atspi::native::input_was_dispatched(&error) => {
return linux_input_error(error);
}
Err(error) => {
return ToolResult::error(format!("AT-SPI scroll task failed: {error}"))
}
result => result,
};
if matches!(ax_result, Ok(Ok(()))) {
let mode = if delivery.is_foreground() {
"foreground"
@@ -5024,6 +5131,12 @@ impl Tool for ScrollTool {
let cursor_id_for_task = cursor_id.clone();
let direction_for_wayland = direction.clone();
let amount_u32 = amount as u32;
let element_index = match &resolved {
cua_driver_core::element_token::ResolvedElement::Element { element_index, .. } => {
Some(*element_index)
}
_ => None,
};
let result = tokio::task::spawn_blocking(move || -> anyhow::Result<()> {
if crate::wayland::wayland_input_enabled() {
return crate::wayland::scroll(xid, &direction_for_wayland, amount_u32);
@@ -5072,15 +5185,19 @@ impl Tool for ScrollTool {
crate::input::send_click(xid, local_x as i32, local_y as i32, amount, button)
};
if delivery.is_foreground() {
let point = element_point
.and_then(|(_, screen)| screen)
.or_else(|| {
window_screen_center(xid)
.ok()
.map(|(x, y)| (x as f64, y as f64))
})
.ok_or_else(|| anyhow::anyhow!("could not resolve foreground scroll point"))?;
crate::input::with_x11_foreground(xid, 80, || {
let point = match element_index {
Some(idx) => {
element_screen_center_exact(pid, idx, Some(xid), exact_identity)?
}
None => match element_point {
Some(((x, y), _)) => window_local_to_screen(xid, x, y)?,
None => {
let (x, y) = window_screen_center(xid)?;
(f64::from(x), f64::from(y))
}
},
};
crate::input::send_click_xtest_desktop(
point.0 as i32,
point.1 as i32,
@@ -5215,8 +5332,9 @@ impl Tool for DoubleClickTool {
.await;
return match result {
Ok(Ok((xid, lx, ly))) => {
let identity_for_overlay = exact_identity.clone();
if let Ok(Ok((sx, sy))) = tokio::task::spawn_blocking(move || {
element_screen_center_exact(pid, idx, exact_identity)
element_screen_center_exact(pid, idx, Some(xid), identity_for_overlay)
})
.await
{
@@ -5245,7 +5363,12 @@ impl Tool for DoubleClickTool {
}
if delivery.is_foreground() {
return crate::input::with_x11_foreground(xid, 80, || {
let (sx, sy) = window_local_to_screen(xid, lxi as f64, lyi as f64)?;
let (sx, sy) = element_screen_center_exact(
pid,
idx,
Some(xid),
exact_identity,
)?;
crate::input::send_click_xtest_desktop(
sx.round() as i32,
sy.round() as i32,
@@ -5469,8 +5592,9 @@ impl Tool for RightClickTool {
.await;
return match result {
Ok(Ok((xid, lx, ly))) => {
let identity_for_overlay = exact_identity.clone();
if let Ok(Ok((sx, sy))) = tokio::task::spawn_blocking(move || {
element_screen_center_exact(pid, idx, exact_identity)
element_screen_center_exact(pid, idx, Some(xid), identity_for_overlay)
})
.await
{
@@ -5499,7 +5623,12 @@ impl Tool for RightClickTool {
}
if delivery.is_foreground() {
return crate::input::with_x11_foreground(xid, 80, || {
let (sx, sy) = window_local_to_screen(xid, lxi as f64, lyi as f64)?;
let (sx, sy) = element_screen_center_exact(
pid,
idx,
Some(xid),
exact_identity,
)?;
crate::input::send_click_xtest_desktop(
sx.round() as i32,
sy.round() as i32,
+1 -1
View File
@@ -523,7 +523,7 @@ done
# asset — see the recovery at the download step below.
#
# ~~~ BAKED_VERSION: must match the release version in this change ~~~
CUA_DRIVER_RS_BAKED_VERSION="0.20.8"
CUA_DRIVER_RS_BAKED_VERSION="0.20.9"
# ~~~ END_BAKED_VERSION ~~~
# Run API requests with an optional token. Keep the header construction here
+1 -1
View File
@@ -118,7 +118,7 @@ $ThemeBinaryName = "cua-cursor-theme.exe"
# where the baked line hasn't been updated yet.
#
# ~~~ BAKED_VERSION: must match the release version in this change ~~~
$Script:CuaDriverRsBakedVersion = "0.20.8"
$Script:CuaDriverRsBakedVersion = "0.20.9"
# ~~~ END_BAKED_VERSION ~~~
$CursorThemeRequiredFrom = [version]"0.12.7"
$LegacyUiaRequiredFrom = [version]"0.2.8"
+2 -2
View File
@@ -16,8 +16,8 @@
# --backend=swift retired Swift backend (no-op; accepted for compat)
#
# Env overrides:
# CUA_DRIVER_RS_VERSION=0.20.8 pin a specific Rust release tag
# CUA_DRIVER_VERSION=0.20.8 legacy alias for CUA_DRIVER_RS_VERSION
# CUA_DRIVER_RS_VERSION=0.20.9 pin a specific Rust release tag
# CUA_DRIVER_VERSION=0.20.9 legacy alias for CUA_DRIVER_RS_VERSION
# CUA_DRIVER_RS_INSTALL_DIR=PATH same as --bin-dir
# CUA_DRIVER_BIN_DIR=PATH legacy alias for --bin-dir
# CUA_DRIVER_NO_MODIFY_PATH=1 same as --no-modify-path
@@ -1031,6 +1031,8 @@ export class ComputerUse {
}
if (
!retriedIncompleteCapture &&
!capture.incompleteDetails.some((detail) =>
detail === "walk_deadline_reached" || detail === "element_bounds_timeout") &&
capture.complete === false &&
(!capture.truncated || capture.readComplete === false) &&
envelope?.stable_element_ids !== true &&
@@ -450,7 +450,6 @@ for (const [readComplete, details] of [
[undefined, ["AXTitle: ax_error -25204", "walk: max_elements truncated"]],
[false, ["walk: max_elements truncated"]],
[undefined, ["provider_unresponsive", "max_elements_reached"]],
[undefined, ["walk_deadline_reached", "max_depth_reached"]],
]) {
test(`mixed read failure and truncation retries once (read flag ${readComplete})`, async () => {
let reads = 0;
@@ -487,6 +486,49 @@ for (const [readComplete, details] of [
});
}
for (const reason of ["walk_deadline_reached", "element_bounds_timeout"]) {
for (const nested of [false, true]) {
test(`deadline capture returns its prefix without another walk (${reason}, nested ${nested})`, async () => {
const complete = (id) => toolResult({ structured: {
tree_markdown: "Ready",
elements: [{ element_token: `rv1:${id}:0` }],
capture_complete: true,
observation_revision: {
mode: "full", lineage_id: id, revision_id: `${id}:r1`, stable_element_ids: true,
},
} });
const capture = {
capture_complete: false,
capture_truncated: true,
capture_incomplete_details: [reason, "max_depth_reached"],
};
const results = [complete("old"), toolResult({ structured: {
...(nested ? {} : capture),
tree_markdown: "Completed prefix",
elements: [{ element_index: 0, element_token: "s00000001:0" }],
observation_revision: {
...(nested ? capture : {}),
mode: "full", revision_id: "transient:r0", stable_element_ids: false,
resync_reason: "capture_incomplete",
},
} }), complete("recovered")];
const driver = fakeDriver({ results: { getWindowState: () => results.shift() } });
const computer = new ComputerUse(driver, { sdk: fakeSdk });
await computer.observeWindow({ pid: 42, windowId: 7 });
const partial = await computer.observeWindow({ pid: 42, windowId: 7 });
assert.equal(driver.calls.length, 2);
assert.match(partial.text, /Completed prefix/);
assert.equal(partial.elements[0].element_token, "s00000001:0");
assert.equal(partial.diagnostics.captureReadComplete, false);
assert.equal(partial.diagnostics.stableElementIds, false);
const recovered = await computer.observeWindow({ pid: 42, windowId: 7 });
assert.equal(driver.calls.length, 3);
assert.equal(driver.calls[2].input.observationRevision.baseRevisionId, undefined);
assert.equal(recovered.diagnostics.stableElementIds, true);
});
}
}
for (const detail of ["max_elements_reached", "max_depth_reached"]) {
test(`legacy Linux budget capture is not retried: ${detail}`, async () => {
const driver = fakeDriver({ results: {
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@qwen-code/cua-sdk",
"version": "0.20.8",
"version": "0.20.9",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@qwen-code/cua-sdk",
"version": "0.20.8",
"version": "0.20.9",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@qwen-code/cua-sdk",
"version": "0.20.8",
"version": "0.20.9",
"description": "Typed CUA Driver SDK and Computer Use API for Node.js",
"type": "module",
"license": "MIT",