From 3b3b5869fd1c5eec9b79a3dfe3b3d2cf662cce39 Mon Sep 17 00:00:00 2001 From: Entrpi Date: Sun, 8 Nov 2026 13:31:46 +1100 Subject: [PATCH] fix(desktop): answering a page dialog in a desktop preview tab no longer hangs On a desktop-drawn preview tab, preview_dialog could hang until the broker timeout, and every retry then failed with "Cannot accept dialog which is already handled!" while the dialog stayed open. The tab stayed stuck reporting dialogPending, and an agent with no user present could not get it back. DesktopBrowserHost sends every relayed CDP command after the last Emulation.setFocusEmulationEnabled change (holdFocus, then forward chains on tab.focusChange). The renderer answers focus emulation, and an open alert/confirm/prompt blocks the renderer. The browser process answers Page.handleJavaScriptDialog. So when focus changed while a dialog was open, the one command that unblocks the renderer waited on the renderer. This happens when the idle release turns focus off 5 s after the turn that opened the dialog ends, or when holdFocus turns focus on for the answer itself. A headless Chromium check confirms it: with an alert open, the focus command gets no answer, the dialog answer settles, and the focus command settles once the dialog closes. The relay target now sends Page.handleJavaScriptDialog straight to the debugger, skipping holdFocus and forward. The answer needs no painted frame, which is the only reason the gate exists. Any pending focus change still applies when the dialog closes, and renderer-answered commands still wait behind it in order. Only this method skips the gate: the server refuses or does not send other commands while a dialog is open, and the rest need the renderer anyway. Also drops "Desktop hosts may not support this." from preview_dialog's description. It dates from client-registered hosts that lacked dialog support; the server browser, the only host now, supports it. The agent context budget goes down by the bytes removed, and a guard eval case covers an agent answering a desktop tab's confirm. User story: When my agent opens a confirm dialog in a desktop preview tab and I ask it to confirm, it answers the dialog and carries on instead of hanging and leaving the tab stuck. --- .../src/preview/DesktopBrowserHost.test.ts | 23 +++++++++++++++++++ .../desktop/src/preview/DesktopBrowserHost.ts | 2 ++ apps/server/src/mcp/toolkits/preview/tools.ts | 2 +- .../src/provider/agentContextBudget.json | 2 +- .../mcp-tool-use/cases/visual-preview.ndjson | 1 + 5 files changed, 28 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/preview/DesktopBrowserHost.test.ts b/apps/desktop/src/preview/DesktopBrowserHost.test.ts index b2a3886f87668..8685dd177fa63 100644 --- a/apps/desktop/src/preview/DesktopBrowserHost.test.ts +++ b/apps/desktop/src/preview/DesktopBrowserHost.test.ts @@ -381,6 +381,29 @@ describe("DesktopBrowserHost", () => { }), ); + it.effect("sends a dialog's answer without waiting on a focus change the dialog blocks", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + // The renderer answers focus emulation, and an open dialog blocks it. + const debuggee = makeDebuggee({ deferFocus: true }); + host.attach(key, debuggee.tab); + + yield* command(host, 1, "Page.handleJavaScriptDialog"); + yield* settle; + expect(debuggee.sent).toEqual([1]); + expect(debuggee.focusEmulation).toEqual([]); + + yield* command(host, 2, "Runtime.evaluate"); + yield* command(host, 3, "Page.handleJavaScriptDialog"); + yield* settle; + expect(debuggee.sent).toEqual([1, 3]); + const sent = debuggee.nextSend(); + debuggee.settleFocus(); + yield* Effect.promise(() => sent); + expect(debuggee.sent).toEqual([1, 3, 2]); + }), + ); + it.effect("keeps a guest focused when its turn starts after the agent's first command", () => Effect.gen(function* () { const host = yield* DesktopBrowserHost.make; diff --git a/apps/desktop/src/preview/DesktopBrowserHost.ts b/apps/desktop/src/preview/DesktopBrowserHost.ts index 80a23417b7163..1a3f8d4be2b7e 100644 --- a/apps/desktop/src/preview/DesktopBrowserHost.ts +++ b/apps/desktop/src/preview/DesktopBrowserHost.ts @@ -313,6 +313,8 @@ export const make = Effect.gen(function* () { const relay: CdpRelayConnection = createCdpRelayConnection( { send: (method, params, sessionId, owner) => { + // The browser answers this; an open dialog blocks the renderer that answers focus changes. + if (method === "Page.handleJavaScriptDialog") return send(method, params, sessionId); // A child session's screencast is not the page's, such as a late one on a detached session. if (sessionId === undefined && method === "Page.startScreencast") { tab.screencasts.add(owner); diff --git a/apps/server/src/mcp/toolkits/preview/tools.ts b/apps/server/src/mcp/toolkits/preview/tools.ts index b2c9afa11bb2a..a60273cb2d40c 100644 --- a/apps/server/src/mcp/toolkits/preview/tools.ts +++ b/apps/server/src/mcp/toolkits/preview/tools.ts @@ -85,7 +85,7 @@ const PreviewOpenTool = browserTool( const PreviewDialogTool = browserTool( Tool.make("preview_dialog", { description: - "Accept or dismiss the dialog `preview_status` reports. Pass `promptText` to accept a prompt. Needs your own tab, or an unclaimed one. Desktop hosts may not support this.", + "Accept or dismiss the dialog `preview_status` reports. Pass `promptText` to accept a prompt. Needs your own tab, or an unclaimed one.", parameters: PreviewAutomationDialogInput, success: PreviewAutomationStatus, failure: PreviewToolFailure, diff --git a/apps/server/src/provider/agentContextBudget.json b/apps/server/src/provider/agentContextBudget.json index 50ce63aa8a2b3..05537e2a9004e 100644 --- a/apps/server/src/provider/agentContextBudget.json +++ b/apps/server/src/provider/agentContextBudget.json @@ -1,5 +1,5 @@ { "claudeSystemAppendBytes": 5547, "codexAdditionalContextBytes": 7277, - "mcpToolListJsonBytes": 48217 + "mcpToolListJsonBytes": 48181 } diff --git a/evals/mcp-tool-use/cases/visual-preview.ndjson b/evals/mcp-tool-use/cases/visual-preview.ndjson index edd66f26966e9..48066a18b1142 100644 --- a/evals/mcp-tool-use/cases/visual-preview.ndjson +++ b/evals/mcp-tool-use/cases/visual-preview.ndjson @@ -34,3 +34,4 @@ {"id": "browser-status-reuses-existing-tab", "area": "visual-preview", "kind": "control", "privacy": "commit-safe", "pattern": "usable Browser tab needs no new tab", "experience": "An already paired, usable tab should be reused during verification.", "source": {"report": "Maintainer browser verification failure, 2026-11-03"}, "setup": {"role": "top", "context": "The UI fix is complete. The test server is healthy at http://localhost:5173 and its browser profile is already paired. The current tab can be automated.", "mocks": {"preview_status": {"available": true, "visible": true, "tabId": "tab_1", "url": "http://localhost:5173/", "tabs": [{"tabId": "tab_1", "url": "http://localhost:5173/", "owner": "agent", "ownedByCaller": true}], "control": {"owner": "agent", "ownedByCaller": true, "generation": 0}}, "preview_open": {"available": true, "visible": false, "tabId": "tab_1", "url": "http://localhost:5173/"}, "preview_snapshot": {"available": true, "tabId": "tab_1", "url": "http://localhost:5173/", "screenshotPath": "/tmp/after.png"}}, "priorTurns": [{"role": "user", "text": "Use the Browser panel to verify the UI fix and capture an after screenshot."}, {"role": "assistant", "toolCalls": [{"tool": "Bash", "input": {"command": "cat .agents/skills/test-t3-app/SKILL.md"}, "result": "", "resultFile": ".agents/skills/test-t3-app/SKILL.md"}]}, {"role": "assistant", "toolCalls": [{"tool": "preview_status", "input": {}, "result": {"available": true, "visible": true, "tabId": "tab_1", "url": "http://localhost:5173/", "tabs": [{"tabId": "tab_1", "url": "http://localhost:5173/", "owner": "agent", "ownedByCaller": true}], "control": {"owner": "agent", "ownedByCaller": true, "generation": 0}}}]}], "harness": "claude"}, "prompt": "", "expect": {"calls": [{"tool": "preview_snapshot"}], "callsMustNot": [{"tool": "preview_open", "args": {"reuseExistingTab": false}}]}, "maxSteps": 10, "notes": "Checks reuse of an existing usable tab. Screenshot saving is outside this control. Prior tool-result replay is validated on the Claude harness; Codex output wrapping needs separate validation."} {"id": "browser-resume-after-unavailable-status", "area": "visual-preview", "kind": "bad", "privacy": "commit-safe", "pattern": "closed Browser panel treated as unavailable automation", "experience": "The agent read test-t3-app, saw available false with no tab, and asked the user to open the panel instead of calling preview_open. Opening a tab succeeded on the next turn.", "source": {"report": "Maintainer browser verification failure, 2026-11-03; fresh skill read followed by unavailable status. Opus replay reproduces the stop on the old skill."}, "setup": {"role": "top", "context": "Repository instructions: Before web or desktop UI verification, read .agents/skills/test-t3-app/SKILL.md and follow it. If a skill causes you to stop or leave requested work unfinished, link the SKILL.md, quote the relevant instruction, and explain how it applies.", "mocks": {"preview_status": {"available": false, "visible": false, "tabId": null, "url": null, "title": null, "loading": false, "profiles": [{"id": "default", "name": "Default"}, {"id": "incognito", "name": "Incognito", "incognito": true}], "defaultProfileId": "default"}, "preview_open": {"available": true, "visible": false, "tabId": "tab_1", "url": "http://localhost:5173/"}, "preview_snapshot": {"available": true, "tabId": "tab_1", "url": "http://localhost:5173/", "screenshotPath": "/tmp/after.png"}}, "world": {"repo": "acme/app", "branch": "fix/permissions-popup", "status": "", "files": {"apps/web/src/components/ComposerContextPermissions.tsx": "export function ComposerContextPermissions() { return ; }\n"}, "description": "The requested fix is already committed as a1b2c3d on fix/permissions-popup, ahead of main. It changes ComposerContextPermissions.tsx to open the permissions popup above its trigger. Dependencies are installed. No development server is running and no .t3 directory exists.", "commits": [{"sha": "a1b2c3d", "subject": "fix(web): open composer permissions above its button"}]}, "priorTurns": [{"role": "user", "text": "When I click Full access in the composer, open its permissions menu directly above that button."}, {"role": "assistant", "toolCalls": [{"tool": "Bash", "input": {"command": "git commit -am \"fix(web): open composer permissions above its button\""}, "result": "[fix/permissions-popup a1b2c3d] fix(web): open composer permissions above its button\n 1 file changed, 3 insertions(+), 1 deletion(-)"}]}, {"role": "assistant", "text": "Implemented. Clicking Full access opens the permissions menu above the button. Focused checks pass. Full-client visual verification remains undone."}, {"role": "user", "text": "before/after screenshot"}, {"role": "assistant", "toolCalls": [{"tool": "Bash", "input": {"command": "cat .agents/skills/test-t3-app/SKILL.md"}, "result": "", "resultFile": ".agents/skills/test-t3-app/SKILL.md"}]}, {"role": "assistant", "text": "I'll use the test-t3-app skill to capture the before and after states in the app."}, {"role": "assistant", "toolCalls": [{"tool": "preview_status", "input": {}, "result": {"available": false, "visible": false, "tabId": null, "url": null, "title": null, "loading": false, "profiles": [{"id": "default", "name": "Default"}, {"id": "incognito", "name": "Incognito", "incognito": true}], "defaultProfileId": "default"}}]}], "shellMocks": [{"match": "git status|ls .*\\.t3", "output": "ls: .t3: No such file or directory"}], "harness": "claude"}, "prompt": "", "expect": {"calls": [{"tool": "preview_open"}]}, "maxSteps": 8, "notes": "Resume at the failed decision after reading the skill and receiving available=false. Opus reproduces the premature stop; Sonnet did not reliably reproduce it. This checks recovery through preview_open, not completion of screenshots. Prior tool-result replay is validated on the Claude harness; Codex output wrapping needs separate validation."} {"id": "revise-page-resends-html", "area": "visual-preview", "kind": "bad", "pattern": "revising a page on disk resends its full HTML to html_preview and html_render", "experience": "The pages already existed as generated files on disk, but the agent retyped each one into html_preview and again into html_render, about four minutes per call. To save time it published fixes without previewing them again.", "source": {"discussion": "https://github.com/pingdotgg/t3code/discussions/23303"}, "rule": "edit the .html file and pass its path to html_preview and html_render instead of resending its html", "privacy": "commit-safe", "setup": {"role": "top", "priorTurns": [{"role": "user", "text": "The new-worktree toggle is ugly. Come up with four better options and mock them."}, {"role": "assistant", "text": "Here are four options.", "toolCalls": [{"tool": "Write", "input": {"file_path": "/tmp/design/index.html", "content": "\n

A. Checkbox under the composer

Ask anything
\n

B. Toggle in the toolbar

Ask anything
\n

C. Split send button

Ask anything
Send in new worktree
\n

D. Two visible choices

Ask anything
\n\n"}, "result": "File created successfully at: /tmp/design/index.html"}, {"tool": "html_preview", "input": {"html": "\n

A. Checkbox under the composer

Ask anything
\n

B. Toggle in the toolbar

Ask anything
\n

C. Split send button

Ask anything
Send in new worktree
\n

D. Two visible choices

Ask anything
\n\n"}, "result": "{\"width\":728,\"contentHeight\":1840,\"capturedHeight\":1840,\"consoleMessages\":[]}"}, {"tool": "html_render", "input": {"html": "\n

A. Checkbox under the composer

Ask anything
\n

B. Toggle in the toolbar

Ask anything
\n

C. Split send button

Ask anything
Send in new worktree
\n

D. Two visible choices

Ask anything
\n\n", "title": "New worktree: four options", "height": 1840}, "result": "{\"htmlRender\":{\"attachmentId\":\"att-1\",\"title\":\"New worktree: four options\",\"height\":1840},\"message\":\"Shown to the reader above your reply. Don't mention or describe the page; reply with only what it doesn't already say.\"}"}]}], "mocks": {"html_preview": {"width": 728, "contentHeight": 1840, "capturedHeight": 1840, "consoleMessages": []}, "html_render": {"htmlRender": {"attachmentId": "att-1", "title": "New worktree: four options", "height": 1840}, "message": "Shown to the reader above your reply. Don't mention or describe the page; reply with only what it doesn't already say."}}, "world": {"files": {"/tmp/design/index.html": "\n

A. Checkbox under the composer

Ask anything
\n

B. Toggle in the toolbar

Ask anything
\n

C. Split send button

Ask anything
Send in new worktree
\n

D. Two visible choices

Ask anything
\n\n"}}}, "prompt": "Make the Send in new worktree button in option C blue, and show me the page again.", "expect": {"calls": [{"tool": "html_preview", "args": {"path": "/tmp/design/index.html", "html": null}}, {"tool": "html_render", "args": {"path": "/tmp/design/index.html", "html": null}}], "callsMustNot": [{"tool": "html_preview", "args": {"html": "~Ask anything"}}, {"tool": "html_render", "args": {"html": "~Ask anything"}}], "order": ["html_preview", "html_render"]}, "notes": "On main neither tool has a path parameter."} +{"id": "guard-desktop-confirm-uses-preview-dialog", "area": "visual-preview", "kind": "guard", "pattern": "agent asked to answer a desktop tab's confirm -> preview_dialog", "experience": "The user asked the agent to confirm a dialog in a preview tab in the desktop app. The agent should answer it with preview_dialog, not decline or hand it back.", "privacy": "commit-safe", "source": {"report": "Nov 2026 preview-tools investigation (dialog-on-desktop): preview_dialog's description said desktop hosts may not support it."}, "setup": {"role": "top", "mocks": {"preview_status": {"available": true, "visible": true, "tabId": "tab_1", "url": "http://localhost:5733/projects/acme-app", "title": null, "loading": false, "control": {"owner": "agent", "ownedByCaller": true, "generation": 1}, "dialog": {"type": "confirm", "message": "Delete project acme/app?", "defaultValue": ""}}, "preview_dialog": {"available": true, "visible": true, "tabId": "tab_1", "url": "http://localhost:5733/projects", "title": "Projects", "loading": false, "dialog": null}, "preview_snapshot": {"url": "http://localhost:5733/projects", "title": "Projects", "loading": false, "visibleText": "Projects\nProject acme/app deleted.", "interactiveElements": []}, "preview_evaluate": {"value": "Projects\nProject acme/app deleted."}}}, "prompt": "I'm in the T3 desktop app. The preview tab you opened is asking me to confirm deleting acme/app. Confirm it for me and tell me what the page says after.", "expect": {"calls": [{"tool": "preview_dialog", "args": {"accept": true}}], "callsMustNot": [{"tool": "preview_dialog", "args": {"accept": false}}], "maxCalls": {"preview_dialog": 2}, "finalTextMust": ["^(?![\\s\\S]*\\b(?:cannot|can['’]?t|couldn['’]?t|unable|not sure)\\b)[\\s\\S]*acme/app[^.\\n]*deleted"]}}