Skip to content

Read a half-ticked box as not ticked, not as ticked - #475

Open
kevin9327 wants to merge 2 commits into
CopilotKit:mainfrom
kevin9327:fix/aria-mixed-checkbox
Open

Read a half-ticked box as not ticked, not as ticked#475
kevin9327 wants to merge 2 commits into
CopilotKit:mainfrom
kevin9327:fix/aria-mixed-checkbox

Conversation

@kevin9327

@kevin9327 kevin9327 commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Ask a Bot to tick every row on a page whose "select all" box is half-ticked — some rows already
selected, some not — and it clicks nothing and tells the person the rows are selected. They are not.
The same happens on any tri-state checkbox: a consent tree, a permissions matrix, a partly expanded
filter list.

Why

The snapshot a Bot reads before it acts carries checked per control. Here is what
ariaSnapshot({ mode: "ai" }) actually produces, captured against Chromium 151 (playwright-core
1.62.1's own build) for a fieldset of three boxes with the first one's indeterminate set:

- group "Toppings" [ref=e2]:
  - generic [ref=e4]:
    - checkbox "Select all" [checked=mixed] [ref=e5]
    - text: Select all
  - generic [ref=e6]:
    - checkbox "Bacon" [checked] [ref=e7]
    - text: Bacon
  - generic [ref=e8]:
    - checkbox "Extra Cheese" [ref=e9]
    - text: Extra Cheese

An ordinary tick is the bare [checked]; the third state is [checked=mixed] — the only flag in
that output carrying a value. Playwright's renderer says the same thing in two lines:

if (ariaNode.checked === "mixed")
  key += ` [checked=mixed]`;
if (ariaNode.checked === true)
  key += ` [checked]`;

toElement read the flag like this:

element.checked = descriptor.flags.get("checked") !== "false";

"false" is a spelling Playwright does not emit. "mixed", the one it does, is not "false", so a
half-ticked box arrived as checked: true and the Bot went on from a snapshot that said the job was
done.

The fix

const state = descriptor.flags.get("checked");
element.checked = state !== "mixed" && state !== "false";

The defensive "false" branch stays, since removing it would be a second change in one line.

Why false and not "leave it off": the published contract is checked?: boolean
(server/src/computer/schema.ts), and for a checkable role this file always fills it in on purpose —
its own comment says absence would otherwise be ambiguous between an unticked box and a control that
does not tick. false is the true half of a yes-or-no answer about a box that is not ticked, and it
is also the answer that produces the right action, because clicking a half-ticked box ticks it.

Measured

bun test agent-computer/tests/aria-snapshot.test.ts

  • Against origin/main (1c7bd92) with only the three new tests applied: 26 pass, 1 fail.
expect(byName.get("Select all")?.checked).toBe(false);
error: expect(received).toBe(expected)
Expected: false
Received: true
(fail) parseAriaSnapshot, against captured output > a half-ticked box is not reported as ticked
  • With the change: 27 pass, 0 fail.

The fixture is the captured output above rather than a hand-written entry, for the reason the note at
the top of that file gives: a guess loses the generic wrapper each box sits inside and the text
node beside it, and would pass while the shipped parser walked something else.

The other two new tests are the guard against over-correcting and pass before and after: the fixture
is valid YAML, and a half-ticked box is still a control with a usable ref — its flag carries a value
and ref comes after it, which is the parse that would break if this were done with a pattern. The
existing case, an ordinary [checked] and an ordinary empty box, is asserted in the same test and is
unchanged.

bun test agent-computer/tests234 pass, 15 skip, 20 fail. The same 20 fail on origin/main
(231 pass there, so the three new ones are the whole difference): 11 in shell.test.ts, which spawns
/bin/bash, and 9 in workspace.test.ts, which creates symlinks. Neither is available on the Windows
machine these were run on and neither is touched here.

Also run, clean: bunx biome check on both changed files, and bun run typecheck in
agent-computer.

Note on the changelog

A Bot behaves differently afterwards, so there is an entry. It goes at the top of ## Unreleased,
which is the line every open PR touching the changelog inserts at, so it will conflict with any other
that lands first. Happy to rebase.

Playwright renders `aria-checked="mixed"` as `[checked=mixed]` and an ordinary
tick as the bare `[checked]` -- the one flag it gives a value to. `toElement`
treated any value but the string "false" as checked, so the box above a
partly-ticked list arrived in the snapshot as `checked: true`.

A Bot asked to select everything therefore read the "select all" as already
done, clicked nothing, and reported rows as chosen that were not. The contract
is `checked?: boolean`, so mixed is reported as false: the true half of a
yes-or-no answer, and the one that produces the right action, since clicking a
half-ticked box ticks it.
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

The note at the top of this file says why: an invented entry loses the
`generic` wrapper each box sits inside and the `text` node beside it, so a
guess can pass while the shipped parser walks something else. Taken from
`ariaSnapshot({ mode: "ai" })` against a fieldset of three boxes in
Chromium 151, the first with `indeterminate` set.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant