← writings

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.

PRProjectIssueIn 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.

Four layers in a row: the agent, the tool, the browser, and the page. The tool puts a green checkmark after it hands a message to the browser. The page, two layers further right, shows nothing happened. The checkmark is at the wrong layer. AI agent "submit the form" the tool driver / pane Chrome the transport the page nothing happened ✓ "done" is reported here truth lives here all three bugs: the checkmark is two layers to the left of the truth
The shape of all three bugs. "I sent it" was being reported as "it happened."

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:

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.

A funnel. Start with about 420 unassigned issues. Ask: referenced by any open PR? If yes, skip. Platform I can verify locally? If no, skip. Concrete repro and bounded fix? If no, skip. If yes: read the issue, comments and code, post a scope comment, then branch, failing test, fix, manual run, PR. gh issue list ~420 unassigned any open PR references it? yes no can I verify it on my machine? no yes concrete repro + bounded fix? skip yes read issue + comments + the code it names scope comment on the issue "here's what I plan to change" branch → failing test → fix → manual run → PR with Fixes #N
The filter. Most of the ~420 issues fell out at the first two gates.

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:

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:

ModeWhat the tool saidWhat 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.

Two before scenarios. Top: insert_text mode. The agent sends Buy milk newline. The driver calls Input.insertText with the whole string. Chrome drops the newline and fires only an input event. Chrome replies with an empty object. The driver tells the agent typed 9 chars, no error. No keydown ever fires. Bottom: keystrokes mode, last character. The driver dispatches keyDown with only key Enter. The page gets keydown with key Enter, code empty, keyCode 0. The page's check for keyCode 13 is false. BEFORE · insert_text mode agent driver Chrome page JS browser_type "Buy milk\n" Input.insertText "Buy milk\n" input event, value="Buy milk" newline dropped · no keydown ever {} typed 9 char(s), isError: false BEFORE · keystrokes mode, last character dispatchKeyEvent keyDown {key:"Enter"} keydown {key:"Enter", code:"", keyCode:0} if (e.keyCode === 13) → false
Before. Two different ways for "typed 9 chars" to be true at the transport and false on the page.

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."

After, insert_text mode on a single-line input. Agent sends browser_type Buy milk newline. Driver asks Chrome the single-line probe, Chrome says true. Driver inserts Buy milk without the newline. Driver dispatches keyDown, char with text carriage return, and keyUp, each with key Enter, code Enter, keyCode 13. Chrome delivers keydown with full identity to the page. The submit handler runs. Driver tells the agent typed 8 chars and pressed Enter, enter_pressed true. AFTER · insert_text mode on a single-line input agent driver Chrome page JS browser_type "Buy milk\n" is the focused field single-line? true Input.insertText "Buy milk" keyDown {key, code: "Enter", keyCode: 13} char {text: "\r", keyCode: 13} keyUp {key, code: "Enter", keyCode: 13} keydown {key, code: "Enter", keyCode: 13} submit handler runs typed 8 and pressed Enter enter_pressed: true, delivered 8
After. One question to the page, three properly-labelled key events, and a result that says what actually happened.

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:

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.

FieldTextModeTool resultPage after 1 s
key input"Buy milk\n"insert_texttyped 8, pressed Entersubmitted, keydown code=Enter keyCode=13
keyCode input"hello\n"insert_texttyped 5, pressed Entersubmitted
key input"Buy eggs\n"keystrokestyped 8, pressed Entersubmitted
keyCode input"hi there\n"keystrokestyped 8, pressed Entersubmitted
textarea"line one\n"insert_texttyped 9value "line one\n", no Enter
textarea"a\nb"keystrokestyped 3value "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:

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 eventAnswered byBlocked by a modal dialog?
Runtime.callFunctionOnrenderer main threadyes
Page.getFrameTreerendereryes (this bit me later, see below)
Page.enable (first time)renderer agentyes
Page.javascriptDialogOpening eventbrowser processno
Page.handleJavaScriptDialogbrowser processno

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.

Chrome drawn as two boxes. Left: the browser process, which owns windows, tabs and dialogs, and answers dialog events and handleJavaScriptDialog. Right: the renderer process for one tab, which runs page JavaScript and answers callFunctionOn, getFrameTree and Page.enable. A confirm dialog is shown freezing the renderer box only. The browser process keeps answering. Chrome is two processes, not one browser process owns windows, tabs, dialogs Page.javascriptDialogOpening (event) Page.handleJavaScriptDialog target lifecycle, window list keeps answering during a dialog renderer process (one tab) runs the page's JavaScript Runtime.callFunctionOn Page.getFrameTree Page.enable (first time) frozen while confirm() is open Delete? Cancel OK rule: subscribe on the left before you do anything on the right that might freeze it
Anything that runs JavaScript or walks the page is dead while a dialog is up. Anything the browser process owns is fine.
Before sequence. Agent calls browser_click dom_event on the confirm button. Driver sends callFunctionOn this.click to the renderer. The renderer calls confirm and its main thread blocks. The driver awaits the reply for 20 seconds and times out, reporting DOM click failed. The agent calls browser_dialog inspect. The driver sends Page.enable to the renderer, which is still blocked, another 20 second timeout. The driver reports that the tab does not expose dialog events. BEFORE agent driver browser proc. renderer browser_click dom_event Runtime.callFunctionOn this.click() confirm('Delete?') main thread blocks awaiting reply … 20 s … DOM click failed: timed out browser_dialog inspect Page.enable (renderer still blocked … 20 s) tab does not expose dialog events
Before. Forty seconds of waiting on a thread that was never going to answer.

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.

After sequence. Agent calls browser_click dom_event. Driver sends Page.enable to the browser process once per tab, which replies immediately. Driver sends callFunctionOn this.click to the renderer. The renderer calls confirm and blocks. The browser process emits the javascriptDialogOpening event with type confirm. The driver's dialog state is set and the race ends in about 60 milliseconds. Driver tells the agent ok, dialog present, kind confirm, id dialog-1. Agent calls browser_dialog accept dialog-1. Driver sends handleJavaScriptDialog accept true to the browser process. The browser resolves the dialog and the renderer resumes. The renderer's late reply to callFunctionOn is dropped harmlessly. Driver tells the agent accepted confirm dialog. AFTER agent driver browser proc. renderer browser_click dom_event Page.enable (once per tab) Runtime.callFunctionOn this.click() confirm() → blocks event javascriptDialogOpening {confirm} dialog_state set → race ends (~60 ms) ok, dialog: confirm, dialog-1 browser_dialog accept dialog-1 Page.handleJavaScriptDialog {accept} dialog resolved, thread resumes late reply to callFunctionOn → dropped, harmless accepted confirm dialog
After. Subscribe on the browser side first, race the click against the dialog, hand the agent an id it can act on.

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:

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.

StepBefore (from the report)After
quiet button, dom_eventok, 1439 msok, 64 ms
confirm button, trustedrefusedrefused (unchanged, by design)
confirm button, dom_eventtimeout, 21268 msok in 61 ms, dialog-1
second dom_event click while openn/arefused in 98 ms
browser_dialog inspectPage.enable timed out"confirm dialog is open", 35 ms
browser_dialog accept dialog-1unreachableaccepted; page shows "confirm accepted"
click again, dismiss dialog-2unreachabledismissed; 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:

EventFires forTells you which frame?
did-start-loading / did-stop-loadingthe whole webview: any frame starting or stoppingno
did-navigatemain frame onlyn/a
did-frame-navigateany frameyes, isMainFrame
did-navigate-in-pageany frame (pushState, hash change)yes, isMainFrame
did-fail-loadany frameyes, isMainFrame
page-favicon-updatedthe main document's icon linksn/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.

  1. The tab's favicon turns into the generic globe once a late iframe mounts (#8039). History also ends up storing favicon_url = NULL for the page.
  2. The address bar switches to the iframe's URL while the real page hasn't moved (#8060).
  3. 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)
  });
};
Left: a browser pane showing a page with a small iframe inside it. The iframe is highlighted. Right: before, the handlers react to the iframe's events as if they were the page's. did-start-loading clears the favicon. did-navigate-in-page from the iframe overwrites the current URL. did-stop-loading saves the iframe URL and a null favicon to history. The tab shows a globe, the address bar shows the iframe URL, the next rebuild loads the iframe as the page. BEFORE · a late iframe on an already-loaded page example.com/pricing iframe demo.app/inner pushState() main document: unchanged webview events pane state did-start-loading faviconUrl = null did-navigate-in-page {isMainFrame: false} currentUrl = demo.app/inner did-stop-loading history ← inner, null icon persist ← demo.app/inner what the user sees demo.app/inner globe instead of the favicon · wrong URL next restart loads the iframe as the page a refused embed shows "Connection Refused"
Before. The iframe did its own thing, and three handlers rewrote the page's state to match.

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.

After, same late iframe. did-start-loading sets isLoading true and keeps the favicon. did-navigate-in-page with isMainFrame false is ignored. did-fail-load with isMainFrame false is ignored. did-stop-loading saves the real page URL and the real icon to history. The tab shows the right favicon and URL. AFTER · same late iframe webview events pane state did-start-loading isLoading = true · favicon kept did-navigate-in-page {isMainFrame: false} ignored did-fail-load {isMainFrame: false} ignored did-stop-loading history ← example.com/pricing, icon.svg persist ← example.com/pricing example.com/pricing right icon, right URL, right page on restart
After. Three one-line checks, and the iframe can do whatever it wants in its box.

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:

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

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.

Three columns, one per bug. Each shows the layer that said done and the layer where the truth was. Enter: insertText returned OK, but no keydown reached the page. Dialog: the click was sent, but the renderer was frozen and the reply never came. Iframe: the webview event arrived, but it was about an iframe, not the page. the Enter the dialog the iframe "done" said by insertText returned {} "done" said by the click was sent "done" said by a webview event arrived truth no keydown reached the page truth renderer frozen, no reply ever truth it was about an iframe, not the page same gap, three costumes
The three bugs side by side. Different code, identical mistake.

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:

Eight steps in a snake layout. browser_prepare with an isolated throwaway profile. list_windows and wait for an on-screen window. get_browser_state to get the target and tab ids. browser_navigate to the local fixture page. get_browser_state semantic snapshot to get element refs by accessible name. browser_type or browser_click. sleep about one second. get_browser_state again and read the outcome text. 1 · browser_prepare isolated throwaway profile 2 · list_windows wait for on-screen window 3 · get_browser_state → target_id, tab_id 4 · navigate to local fixture 5 · snapshot refs by accessible name 6 · act browser_type / browser_click 7 · sleep ~1 s let the page react 8 · re-snapshot read the outcome step 8 is the one that matters: the page, not the tool, says whether it worked
The manual loop. The tool's result is step 6. The truth is step 8.

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

← more writings