diff --git a/apps/desktop/src/preview/DesktopBrowserHost.test.ts b/apps/desktop/src/preview/DesktopBrowserHost.test.ts index cf6c7736f81d3..e725ceca4326c 100644 --- a/apps/desktop/src/preview/DesktopBrowserHost.test.ts +++ b/apps/desktop/src/preview/DesktopBrowserHost.test.ts @@ -482,6 +482,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 e0326b7b76ec1..062c1a7b96e4d 100644 --- a/apps/desktop/src/preview/DesktopBrowserHost.ts +++ b/apps/desktop/src/preview/DesktopBrowserHost.ts @@ -341,6 +341,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. const stream = sessionId === undefined && method === "Page.startScreencast" 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 1d3d3a9179c0b..bbf660b4e1dad 100644 --- a/apps/server/src/provider/agentContextBudget.json +++ b/apps/server/src/provider/agentContextBudget.json @@ -1,5 +1,5 @@ { "claudeSystemAppendBytes": 5930, "codexAdditionalContextBytes": 7324, - "mcpToolListJsonBytes": 49790 + "mcpToolListJsonBytes": 49754 } 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"]}}