Three bugs where the tool said "done" and nothing happened
Last week I shipped three fixes to two open-source projects that sit right in the middle of the thing I care most about: AI agents that use a computer the way a person does. Two went into Cua, whose "driver" is a small program written in Rust that lets an agent type, click, and read a browser. One went into Superset, a desktop app for running coding agents that has a built-in browser pane.
The three bugs look unrelated. One was about the Enter key, one about a pop-up dialog, one about a browser tab's icon and address bar. But they're the same bug three times, and that's why I wanted to write them up together. In every case, a layer reported success based on what it sent, not on what the other side actually did. The tool said "done." Nothing happened. And an AI agent, unlike a person, has no way to notice.
| PR | Project | Issue | In one line |
|---|---|---|---|
| cua#4499 | Cua | #4482 | Typing text ending in a newline claimed it pressed Enter. The page never saw an Enter. |
| cua#4618 | Cua | #3982 | Clicking a button that opens a "Are you sure?" dialog hung for 20 seconds, then broke the dialog tool too. |
| superset#8149 | Superset | #8039, #8060 | The browser pane lost its favicon and address bar to embedded iframes. |
I'm going to go through each one the same way: what the user saw, how I found the cause, what I changed, and how I proved it worked. I'll explain the moving parts as I go, because the whole point of this post is that the interesting part isn't the code. It's the gap between layers, and you don't need to know Rust to understand a gap.
A quick vocabulary
Four terms come up constantly below, so here they are once.
A computer-use agent is an AI model that gets things done by operating a computer: it looks at the screen or the page, decides what to do, and asks for a click or a keystroke. I wrote about what that actually consists of a while back. The short version is that the model never touches the computer directly. It asks a tool to do it.
MCP (Model Context Protocol) is the standard way a model asks for those tools. The tool program advertises a menu like browser_type, browser_click, browser_dialog, and the model calls them with arguments and reads back a result. The result is all the model knows. If the result says "typed 9 characters, no error," the model believes it typed 9 characters with no error.
CDP (Chrome DevTools Protocol) is the remote control socket into Chrome. It's the same thing the DevTools panel uses when you press F12. Over CDP you can say "insert this text," "dispatch a key event," "run this JavaScript on that element," and Chrome does it. Automation tools like Puppeteer and Playwright are built on it, and so is Cua's driver.
Electron is the framework that lets you build a desktop app out of web technology. Superset is an Electron app. Its browser pane is an Electron <webview>, which is a real Chromium browser embedded inside the app's window that fires events as pages load.
How I picked the issues
Both projects have hundreds of open issues, and the easy mistake is to grab the first one that looks fun. I wanted issues that were:
- Unassigned, with no open PR already working on them. I pulled every open issue and every open PR as JSON with the GitHub CLI, then searched PR bodies for
#<issue number>to find competing work. In Superset's backlog, roughly 30 of the 40 recent bugs already had a PR, often within a day. This filter mattered. - High-impact for the actual user. For an agent that drives a browser, "type text and press Enter" and "click a button that pops a confirm" are not edge cases. They're the top of the funnel. A silent false success there makes an agent think a form was submitted when it wasn't.
- Diagnosable from the code and verifiable on my machine. I have macOS, Chrome, a Rust toolchain, and Bun. I skipped Windows-only and Wayland-only reports because I couldn't honestly say "I tested this."
- Bounded. Something I could land in one PR with unit tests and a manual reproduction, not something that needed a design discussion first.
Cua's MAINTAINERS.md actually has a formal "polling ladder" for triage (gate, order by impact, classify readiness, check execution fit), and its CONTRIBUTING.md asks you to comment on the issue with your intended scope before starting. I did exactly that: scope comment first, then a branch, then a PR with Fixes #N.
Bug 1: the Enter that never arrived
PR: trycua/cua#4499 · Issue: #4482 · Where: the browser tools file in cua-driver-core
How the driver types into a browser
When an agent calls browser_type on a tab the driver already controls, the text gets to the page over CDP in one of two modes:
insert_text(the default): oneInput.insertTextcall with the whole string. Fast, and it behaves like pasting or like a phone keyboard committing a word.keystrokes: for each character, threeInput.dispatchKeyEventcalls, one each for key down, the character itself, and key up. Slower, but it looks like real typing to the page.
Either way, the tool reports back something like typed 9 char(s) along with a count of how many it delivered.
What the user saw
The normal agent idiom for "type this and submit it" is to end the text with a newline: "Buy milk\n". That's how a person thinks about it, and it's how every model writes it. On TodoMVC, a tiny to-do app that people use as a standard test page, here's what happened:
| Mode | What the tool said | What the page saw |
|---|---|---|
insert_text |
typed 9 char(s), no error |
Text sits in the box. No to-do added. No key event at all. |
keystrokes |
typed 9 char(s), no error |
Worked on TodoMVC. On a page that checks event.keyCode === 13, nothing. The keydown arrived as {key: "Enter", code: "", keyCode: 0}. |
So in both modes the tool counted the newline as delivered, and in both modes a big class of pages never submitted. The agent gets back "success" and moves on, believing a to-do exists that doesn't.
Root cause
Two separate bugs, one per mode.
In insert_text mode, Chrome's Input.insertText on a single-line text box simply throws away the \n. From the browser's point of view that's correct: you asked it to insert text, and a single-line field can't hold a newline, so there's nothing to insert. It's text insertion, not a key press. The browser is behaving exactly to spec, and that spec-correct behaviour is the bug from the agent's point of view.
In keystrokes mode, the code that sent the Enter looked like this:
// before
let (key, key_text) = if ch == '\n' { ("Enter", "\r") } else { (ch, ch) };
conn.call(cdp, "Input.dispatchKeyEvent", json!({ "type": "keyDown", "key": key })).await;
conn.call(cdp, "Input.dispatchKeyEvent", json!({ "type": "char", "key": key, "text": key_text, ... })).await;
conn.call(cdp, "Input.dispatchKeyEvent", json!({ "type": "keyUp", "key": key })).await;
Here's the thing you have to know about CDP: a key event has several identity fields, and CDP does not fill in the missing ones for you. A real Enter press carries key: "Enter", code: "Enter", and keyCode: 13. If you send only key: "Enter", the page's event has key === "Enter" but code === "" and keyCode === 0. Modern code that checks event.key works fine. But an enormous amount of code in the wild, chat boxes, search bars, older React apps, still checks keyCode === 13, and to that code, nothing was pressed.
The fix
Three parts, all in the shared cross-platform CDP code, so macOS, Linux, and Windows get the same behaviour.
1. Decide whether a newline means "submit" or "content." A newline in a multi-line text area or a rich editor is content; the user wanted a line break. A newline at the end of text going into a single-line box is a submit. So only when the text ends in \n, the driver asks the page one question about the focused element, using a single Runtime.callFunctionOn call on the element it already resolved:
const SINGLE_LINE_FIELD_CHECK: &str = "function() { \
return !this.isContentEditable && this.tagName === 'INPUT'; \
}";
If it's a <textarea> or a contenteditable region, the newline stays in the text and nothing changes. If it's an <input>, one trailing \n is split off to be pressed as a key. If the probe itself fails for any reason, the driver falls back to the old behaviour rather than guessing.
2. Press a real Enter. A helper that emits all three phases with the full key identity, so both kinds of page handler see it:
fn enter_key_event(phase: &str) -> Value {
let mut event = json!({
"type": phase,
"key": "Enter",
"code": "Enter",
"windowsVirtualKeyCode": 13,
"nativeVirtualKeyCode": 13,
});
if phase == "char" {
event["text"] = json!("\r");
event["unmodifiedText"] = json!("\r");
}
event
}
async fn press_enter(conn: &CdpConnection, cdp: &str) -> anyhow::Result<()> {
for phase in ["keyDown", "char", "keyUp"] {
conn.call(Some(cdp), "Input.dispatchKeyEvent", enter_key_event(phase)).await?;
}
Ok(())
}
In insert_text mode this runs after the remaining text is inserted. In keystrokes mode, every \n in the loop now uses this event, so even a newline typed inside a textarea carries keyCode 13 like a real one would.
3. Report honestly. The newline is no longer counted as a typed character, because it wasn't one. The result gains a field enter_pressed: true|false, and the summary reads typed 8 char(s) into tab-… and pressed Enter. And if the text landed but the Enter dispatch failed, the tool now returns a browser_input_incomplete refusal with the delivered count equal to the requested count and enter_pressed: false, instead of a clean success. The agent can tell the difference between "typed and submitted" and "typed, but you still need to submit."
Tests
Cua has a mock CDP server in its test suite: a real WebSocket server that pretends to be Chrome, records every command it receives, and answers from a fixture. The browser tool tests drive it end to end. I added a multiline_field flag to the fixture state so the mock could answer the single-line probe, then wrote five cases:
- Trailing newline on a single-line field, insert_text mode:
insertTextreceives"Buy milk", then exactly threedispatchKeyEventcalls withcode: "Enter"andwindowsVirtualKeyCode: 13, and thecharevent carries"\r". - Trailing newline on a multi-line field, insert_text mode:
insertTextreceives"line\n"intact, no key events,enter_pressed: false. - Trailing newline, keystrokes mode: six key events, the last three carrying the full Enter identity.
- Newline in the middle, keystrokes mode, multi-line field:
"a\nb"produces nine events and the middle triple haskeyCode 13. - A failed Enter is reported, not counted: the mock fails the first
keyDown; the result is abrowser_input_incompleterefusal withdelivered_chars: 2andenter_pressed: false.
I wrote the tests first, ran them to watch all five fail, committed that on its own, and then implemented the fix.
Manual verification
Mocks prove the code does what I think it does. They don't prove Chrome does what I think it does. So I made a tiny local page served with python3 -m http.server with three fields:
<input id="key" aria-label="Key box"> <!-- submits on e.key === "Enter" -->
<input id="code" aria-label="Code box"> <!-- submits on e.keyCode === 13 -->
<textarea id="ta" aria-label="Notes"></textarea>
Then I drove the freshly built binary exactly the way an agent would, over MCP, with a 40-line Python client (the code is at the bottom of this post). Launch Chrome with a throwaway profile, take a snapshot of the page, call browser_type, wait a second, take another snapshot and read what changed.
| Field | Text | Mode | Tool result | Page after 1 s |
|---|---|---|---|---|
| key input | "Buy milk\n" | insert_text | typed 8, pressed Enter | submitted, keydown code=Enter keyCode=13 |
| keyCode input | "hello\n" | insert_text | typed 5, pressed Enter | submitted |
| key input | "Buy eggs\n" | keystrokes | typed 8, pressed Enter | submitted |
| keyCode input | "hi there\n" | keystrokes | typed 8, pressed Enter | submitted |
| textarea | "line one\n" | insert_text | typed 9 | value "line one\n", no Enter |
| textarea | "a\nb" | keystrokes | typed 3 | value "line one\na\nb" |
The first two rows are exactly the ones that failed in the issue.
Outcome
A maintainer approved within a day. Their review, roughly: "this is a really clean fix … the single-line probe falls back to the old behavior if it fails, the Enter carries code + keyCode 13 in both modes, and a dropped Enter now surfaces as browser_input_incomplete instead of being counted." They asked me to mark it ready so the full CI matrix could run.
Bug 2: the click that blocked its own reply
PR: trycua/cua#4618 · Issue: #3982 · Where: same file, browser_click and browser_dialog
Two ways to click, and a tool for dialogs
browser_click has two routes to the page:
trusted:Input.dispatchMouseEvent, which Chrome treats like a real mouse click. On macOS this brings the Chrome window to the front, so when the driver is running "in the background" (so the human can keep using their computer), it refuses this route on purpose.dom_event:Runtime.callFunctionOnwithfunction() { this.click(); }on the element. Fully background, but synthetic, so some security-sensitive controls ignore it.
Separately there's browser_dialog, whose job is to inspect, accept, or dismiss a JavaScript dialog the page opened: alert(), confirm(), prompt(), or the "leave this page?" prompt. It works by enabling the CDP Page domain, listening for the Page.javascriptDialogOpening event, and then calling Page.handleJavaScriptDialog to resolve it.
What the user saw
The issue came with a self-contained reproduction: a page with two buttons, one that does nothing special and one that calls confirm('Delete?').
control: no dialog, dom_event 1439 ms ok
confirm(), trusted 62 ms refused (expected)
confirm(), dom_event 21268 ms "DOM click failed: CDP Runtime.callFunctionOn timed out after 20s"
browser_dialog inspect "the exact tab does not expose JavaScript dialog events: CDP Page.enable timed out"
Read that bottom line again. The dialog did open, right there on the screen. The driver had a tool built specifically to handle it. And there was no route at all from the click to that tool. Click a delete button, wait 20 seconds, get an error, and then the tool for dealing with the result also errors.
Root cause
confirm() is synchronous and modal. When a page calls it, the page's JavaScript freezes on that line until the human clicks OK or Cancel. The driver sent its click via Runtime.callFunctionOn, which runs on that same JavaScript thread. So the reply to "please click this" couldn't be sent until the dialog closed, and the dialog couldn't close until something handled it, and the thing that handles it was waiting on the reply. The driver sat there for its 20-second per-call timeout and gave up.
Then the second failure. browser_dialog tried to enable Page events after the dialog was already up. Page.enable on a frozen page also stalls, so it timed out too. The event that would have told us about the dialog was never subscribed to in the first place.
The key insight, and the thing I'd most want someone to take from this post, is which part of Chrome answers which command. Chrome isn't one program. There's a browser process that owns windows, tabs, and dialogs, and a separate renderer process per tab that runs the page's JavaScript. A modal dialog freezes the renderer. It does not freeze the browser process.
| Command or event | Answered by | Blocked by a modal dialog? |
|---|---|---|
Runtime.callFunctionOn | renderer main thread | yes |
Page.getFrameTree | renderer | yes (this bit me later, see below) |
Page.enable (first time) | renderer agent | yes |
Page.javascriptDialogOpening event | browser process | no |
Page.handleJavaScriptDialog | browser process | no |
If Page is already enabled before the click, the browser process emits javascriptDialogOpening the instant the dialog appears, regardless of the renderer being stuck. That's exactly how Puppeteer and Playwright handle dialogs: subscribe first, and never sit waiting on the blocked call.
The fix
1. Observe before you act. Before a dom_event click goes out, register the tab's CDP session as the dialog session and send Page.enable once. It's idempotent: later calls see the registration and skip it. This reuses the exact mechanism browser_dialog already had, pulled out into a helper:
async fn ensure_dialog_observation(conn: &CdpConnection, tab_session: &str, cdp_target_id: &str) {
if conn.dialog_state(cdp_target_id).is_some() || conn.has_dialog_session(cdp_target_id) {
return;
}
conn.register_dialog_session(tab_session, cdp_target_id);
if conn.call(Some(tab_session), "Page.enable", json!({})).await.is_err()
&& conn.dialog_state(cdp_target_id).is_none()
{
conn.unregister_dialog_session(tab_session, cdp_target_id);
}
}
2. Race the click against the dialog. Instead of waiting only for the click's reply, wait for either the reply or a dialog appearing, whichever comes first. In Rust's async world that's a select! loop: pin the click future, and every 25 milliseconds check whether the connection has seen a dialog event.
let click = conn.call(Some(cdp), "Runtime.callFunctionOn", json!({
"objectId": object_id,
"functionDeclaration": "function() { this.click(); }",
}));
tokio::pin!(click);
let outcome = loop {
tokio::select! {
replied = &mut click => break replied.map(|_| ()),
_ = tokio::time::sleep(Duration::from_millis(25)) => {
if conn.dialog_state(cdp_target_id).is_some() {
break Ok(());
}
}
}
};
When the dialog wins, the click future is dropped. The pending reply slot stays registered in the connection's message router until Chrome eventually answers (after the dialog is resolved), at which point the late reply is routed to a channel nobody is listening on and quietly discarded. No leak, no stray error.
3. Tell the agent what happened. The success payload gains a dialog block, and the summary names the dialog's id:
{
"status": "ok",
"effect": "unverifiable",
"route": "dom_event",
"dialog": { "present": true, "kind": "confirm", "dialog_id": "dialog-1" },
"escalation": {
"recommended": "page",
"reason": "the click opened a page-owned JavaScript dialog; resolve it with browser_dialog, then refresh page state"
}
}
The dialog_id is minted by the same function browser_dialog uses, so the agent can pass it straight to accept or dismiss without an extra round trip.
4. Don't click into a frozen page. If a dialog is already open on the tab, refuse immediately with browser_action_unavailable and the same dialog block in the refusal, so the agent knows what to do instead of waiting 20 seconds to find out.
The gap the manual run found
My first manual run passed the main scenario and then exposed one more hang. A second dom_event click, sent while the dialog was still open, took 20 seconds. It was failing in Page.getFrameTree, a call the driver makes before every click to make sure it still knows the page's frame layout. That call is answered by the renderer, so it hangs too.
The open-dialog refusal I'd added was positioned after that frame revalidation. I moved it to right after the (browser-side, so safe) target check and before any frame work, and added a test assertion that no Page.getFrameTree call is made for the second click. After that the second click refused in 98 milliseconds.
This is the kind of thing a mock cannot find. The mock answered getFrameTree instantly, like it answers everything. Only a real frozen renderer tells you which commands actually stall.
Tests
The mock needed one new ability it had never needed before: a reply that emits events and then never answers. That's what a frozen renderer looks like from the outside.
pub fn hang() -> Self { Self { events: vec![], result: Ok(json!({})), hang: true } }
// server loop:
for event in reply.events { ws.send(event).await; }
if reply.hang { continue; } // no response frame
Plus a fixture flag click_opens_dialog: when set, any callFunctionOn whose function contains this.click() emits Page.javascriptDialogOpening on the tab session and then hangs. Three tests on top of that:
- A dom_event click whose handler opens a dialog returns. Well under 5 seconds, with
dialog.present: true.Page.enableis recorded before the click call. A followingbrowser_dialog inspectfinds the same id without a secondPage.enable.acceptsendsPage.handleJavaScriptDialog {accept: true}. - A dom_event click with no dialog reports none. Unchanged behaviour,
dialog.present: false, no dialog commands sent. - A dom_event click refuses while a dialog is already open. The second click returns
browser_action_unavailable, exactly onethis.click()was ever sent, and thePage.getFrameTreecount did not grow.
Manual verification
Same Python harness as before, with the issue's two-button page plus an <output> element the handler writes to, so the outcome shows up in a page snapshot.
| Step | Before (from the report) | After |
|---|---|---|
| quiet button, dom_event | ok, 1439 ms | ok, 64 ms |
| confirm button, trusted | refused | refused (unchanged, by design) |
| confirm button, dom_event | timeout, 21268 ms | ok in 61 ms, dialog-1 |
| second dom_event click while open | n/a | refused in 98 ms |
browser_dialog inspect | Page.enable timed out | "confirm dialog is open", 35 ms |
browser_dialog accept dialog-1 | unreachable | accepted; page shows "confirm accepted" |
click again, dismiss dialog-2 | unreachable | dismissed; page shows "confirm dismissed" |
One thing worth knowing if you go reproduce this: Cua's public ActionResult envelope, the shape every tool result has to fit, is a fixed contract with no slot for a dialog object. So over MCP the agent sees the dialog id in the summary string, and inside the daemon it's in the structured payload. I called that out in the PR rather than widening the public contract, which would rightly need a design discussion first.
Bug 3: the iframe that stole the address bar
PR: superset-sh/superset#8149 · Issues: #8039, #8060 · Where: the browser pane's runtime registry (v2) and the older persistent-webview hook (v1)
An Electron webview and its events
Superset's browser pane is an Electron <webview>. The pane keeps its own idea of the current URL, page title, favicon, loading state, and any error, and it keeps that state up to date by listening to events the webview fires. It also saves the current URL to disk, so the pane can be rebuilt after the app restarts or after a hidden webview gets evicted from a small cache.
Now the fact that's easy to miss. A web page is often not one document. It's a main document plus any number of iframes: embedded pages inside it, like a YouTube video, a payment form, a live demo, a chat widget. Each iframe is its own little page with its own URL that loads and navigates on its own. And the webview's events don't all distinguish between the main document and these embedded ones:
| Event | Fires for | Tells you which frame? |
|---|---|---|
did-start-loading / did-stop-loading | the whole webview: any frame starting or stopping | no |
did-navigate | main frame only | n/a |
did-frame-navigate | any frame | yes, isMainFrame |
did-navigate-in-page | any frame (pushState, hash change) | yes, isMainFrame |
did-fail-load | any frame | yes, isMainFrame |
page-favicon-updated | the main document's icon links | n/a |
What the user saw
Two issues filed separately, by different people, adding up to three visible bugs. All of them on pages that embed iframes, which is most landing pages with a live demo, most dashboards with widgets, and anything with a third-party embed.
- The tab's favicon turns into the generic globe once a late iframe mounts (#8039). History also ends up storing
favicon_url = NULLfor the page. - The address bar switches to the iframe's URL while the real page hasn't moved (#8060).
- Refresh or restore loads the iframe as the whole page (#8060), and a refused embed shows the full-pane "Connection Refused" screen as if the page itself had failed.
Root cause
Three event handlers each trusted a per-frame event as if it were about the page:
// before (v2 registry)
const handleDidStartLoading = () => {
this.setState(paneId, { isLoading: true, error: null, faviconUrl: null }); // (1)
};
const handleDidNavigateInPage = (e) => {
this.setState(paneId, { currentUrl: e.url ?? "" }); // (2)
};
const handleDidFailLoad = (e) => {
if (e.errorCode === -3) return;
this.setState(paneId, {
isLoading: false,
...(e.isMainFrame ? { currentUrl: e.validatedURL, pageTitle: "" } : {}),
error: { code: e.errorCode, ... }, // (3)
});
};
- (1) A late iframe fires
did-start-loading, so the favicon gets cleared. But the main document's icon never changed, so Chromium sends no newpage-favicon-updated, and nothing ever restores it. Then thedid-stop-loadingthat follows writesfaviconUrl: nullinto history, overwriting the real one. - (2) An embedded single-page app calls
history.pushState, which firesdid-navigate-in-pagewithisMainFrame: false. The handler never checked, so the pane's URL became the iframe's URL.did-stop-loadingthen persisted it. Reload itself is fine, because it callswebview.reload()on the real page. But the next rebuild of the pane, after eviction, restart, or reopen, setswebview.src = persistedUrl, and that's now the iframe. - (3) This handler already took the URL only from main-frame failures, but it set
errorfor any frame. So one broken embed raised the full-pane error overlay.
The fix
Applied identically to the v2 registry and the older v1 pane hook, since both were live:
// after (v2 registry)
const handleDidStartLoading = () => {
this.setState(paneId, { isLoading: true, error: null }); // favicon untouched
};
// did-navigate is main-frame only: a new document starts here with no
// title and no favicon until its own page-*-updated events arrive.
const handleDidNavigate = (e) => {
this.setState(paneId, { currentUrl: e.url ?? "", pageTitle: "", faviconUrl: null, isLoading: false });
...
};
const handleDidNavigateInPage = (e) => {
if (!e.isMainFrame) return; // embedded SPA stays in its box
this.setState(paneId, { currentUrl: e.url ?? "" });
...
};
const handleDidFailLoad = (e) => {
if (e.errorCode === -3) return; // ERR_ABORTED
if (!e.isMainFrame) return; // broken embed ≠ broken page
this.setState(paneId, {
isLoading: false,
currentUrl: e.validatedURL ?? "",
pageTitle: "",
faviconUrl: null, // Chromium commits its error page without a did-navigate
error: { ... },
});
};
The favicon moves from "cleared whenever anything loads" to "cleared when the main frame commits a new document." That is the only moment the icon can legitimately change, so that's the only moment to clear it.
Tests
The v2 registry already had a bun:test lifecycle block with a fake webview (an EventTarget plus a fire(type, props) helper), so I extended it. Four cases, three of which failed on main with exactly the reported values:
- Keeps the favicon when a subframe starts loading: favicon set, then
did-start-loadinganddid-stop-loadingwith nodid-navigatein between. The favicon is stillicon.svg(wasnull). - Ignores in-page navigations from subframes:
did-navigate-in-page {isMainFrame: false, url: localhost:4000/inner}. The current URL is still the page, and that's what gets persisted (was the iframe URL). - Records the attempted URL of a failed main-frame load only, extended: a subframe
did-fail-loadduring a fresh load leaveserrornull (was{code: -105, ...}). - Clears the favicon when the main frame commits a new document: a
did-navigateto another origin, and a failed main-frame load, both reset it.
The existing in-page navigation test fired did-navigate-in-page without an isMainFrame at all. I made it explicit (isMainFrame: true) so the handler can be strict instead of treating "undefined" as "main frame."
The v1 pane has no test harness, so its change is review-only. It mirrors v2 line for line, and I said so in the PR.
Verification
bun test --isolateon the registry file: 14 pass.- Biome on the changed directories: clean. CI fails on warnings, so this matters.
tsc --noEmitdirectly inapps/desktop: no errors in the touched files. The repo's fulltypecheckscript runs a route generator first that fails under Node 20 on my machine with an unrelatedERR_REQUIRE_ESM, and I noted that in the PR.- No screenshots, which Superset's PR template prefers for UI changes. I was explicit about that gap rather than pretending it wasn't there.
What I'd take away
1. "Sent" is not "happened." All three bugs reported success at the transport layer. Input.insertText returned an empty object, callFunctionOn eventually returned, the webview events arrived. The fix in each case was to look one layer further and ask: what did the page actually see? A tool for AI agents is only as useful as the honesty of its result, because the agent will believe it. A person would glance at the screen and notice the to-do didn't appear. The agent reads isError: false and moves on.
2. Know which process answers your command. In Chromium, the browser process and the renderer are different worlds. Dialogs are the sharpest example: anything that runs JavaScript or walks the frame tree is dead while a modal is up, and anything the browser process owns (dialog events, dialog handling, the list of targets) is fine. Subscribe to events before you trigger the thing that might block.
3. Per-frame events need a per-frame check. Electron is consistent about this: did-navigate is main-frame only, and almost everything else fires for every frame and tells you which one via isMainFrame. If a handler mutates page-level state and doesn't read that flag, it's a bug waiting for an iframe.
4. Tests first, then the real thing. Each PR started with failing tests committed on their own. The mocks caught regressions and actually shaped the design: needing MockReply::hang() is what made me think carefully about which calls can stall. But the only reason the dialog fix handles a second click correctly is that I ran the actual binary against actual Chrome. The mock would never have shown that getFrameTree stalls.
5. Follow the repo's contract to the letter. Cua wanted a scope comment before work, a draft PR with a specific template, Conventional Commit titles (because the squash title becomes the release note), and explicit platform evidence with the tested commit hash. Superset wanted a conventional title, Biome-clean code, and Fixes #N. Doing exactly that is cheap, and it's why the first PR got an approval within a day.
6. Fix the pair, not the symptom. #8039 and #8060 were filed separately by different people, and the second reporter guessed they might be related. Reading the handlers made it obvious they were one bug family in one file. One PR with one mental model is easier to review than two.
I'll end with the reason I think this matters more than three small PRs. I spend most of my time on infrastructure for agents that use computers, and the thing that keeps showing up is that an agent's whole picture of the world is the results its tools hand back. There is no second channel. If the tool layer says "typed and submitted" when it only typed, the agent will plan its next ten steps on a form that was never sent. Honest results aren't a nice-to-have in this stack. They're the stack.
Appendix: the test and verification toolkit
Driving an MCP server from Python over stdio. This is the whole client I used for both Cua PRs. It launches the driver binary, speaks JSON-RPC over its stdin and stdout, and gives you a call(name, args) that behaves like an agent calling a tool:
import json, os, subprocess, threading, queue
class Mcp:
def __init__(self, binary):
env = dict(os.environ,
CUA_DRIVER_PERMISSION_MODE="unrestricted",
CUA_DRIVER_DANGEROUSLY_BYPASS_APPROVALS="1")
self.p = subprocess.Popen([binary, "mcp", "--direct"],
stdin=subprocess.PIPE, stdout=subprocess.PIPE, env=env)
self.q, self.n = queue.Queue(), 0
threading.Thread(target=self._reader, daemon=True).start()
self.req("initialize", {"protocolVersion": "2025-06-18", "capabilities": {},
"clientInfo": {"name": "t", "version": "0"}})
self._send({"jsonrpc": "2.0", "method": "notifications/initialized"})
def _reader(self):
for line in self.p.stdout:
try: self.q.put(json.loads(line))
except Exception: pass
def _send(self, m):
self.p.stdin.write((json.dumps(m) + "\n").encode()); self.p.stdin.flush()
def req(self, method, params, timeout=120):
self.n += 1; i = self.n
self._send({"jsonrpc": "2.0", "id": i, "method": method, "params": params})
while True:
m = self.q.get(timeout=timeout)
if m.get("id") == i: return m
def call(self, name, args):
return self.req("tools/call", {"name": name, "arguments": args}).get("result")
The flow for one manual row. Every row in the tables above went through the same eight steps:
A mock-CDP pattern worth stealing (from Cua): a real WebSocket server whose handler is a closure over shared fixture state. Every incoming command is recorded as (sessionId, method, params) so tests can assert on exact call order and payloads. Replies can attach events to emit before the response, or, as of this week, emit events and then send no response at all.
Commands run before each PR:
# cua (from libs/cua-driver/rust)
cargo test -p cua-driver-core --locked
cargo test -p cua-driver --test protocol_schema_test --locked
cargo fmt --all -- --check
cargo clippy -p cua-driver-core --locked --all-targets -- -D warnings
cargo build -p cua-driver --locked # then the manual run
# superset (from apps/desktop)
bun test --isolate <file>.test.ts
bunx biome check <changed dirs>
bunx tsc --noEmit