From 757672662d7f1f07cc05b8111114deab0ae573cb Mon Sep 17 00:00:00 2001 From: codex Date: Sat, 29 Aug 2026 10:16:24 +0900 Subject: [PATCH] fix: activate effort control in background turns --- launcher/package.json | 2 +- package.json | 2 +- scripts/install.sh | 2 +- src/adapters/chatgpt-web/browser-worker.ts | 41 ++++++++++++++++++---- src/chatgpt-session.ts | 4 +++ src/version.ts | 2 +- tests/browser-worker-contract.test.ts | 10 ++++-- tests/chatgpt-session.test.ts | 2 +- 8 files changed, 52 insertions(+), 13 deletions(-) diff --git a/launcher/package.json b/launcher/package.json index 4533eb89c..2615d85d6 100644 --- a/launcher/package.json +++ b/launcher/package.json @@ -1,6 +1,6 @@ { "name": "codex-web-gpt-launcher", - "version": "3.0.4", + "version": "3.0.6", "private": true, "description": "Desktop control center for Codex ChatGPT Web", "author": "miuuyy", diff --git a/package.json b/package.json index ba56ed262..d46b0c77a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codex-chatgpt-web", - "version": "3.0.4", + "version": "3.0.6", "private": true, "description": "A focused local Responses bridge that runs Codex tasks through a user-authenticated ChatGPT web session.", "repository": { diff --git a/scripts/install.sh b/scripts/install.sh index 49ed8a530..b88692df8 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -2,7 +2,7 @@ set -eu REPOSITORY="${CODEX_CHATGPT_WEB_REPOSITORY:-miuuyy/codex-chatgpt-web}" -VERSION="${CODEX_CHATGPT_WEB_VERSION:-3.0.4}" +VERSION="${CODEX_CHATGPT_WEB_VERSION:-3.0.6}" BIN_DIR="${CODEX_CHATGPT_WEB_BIN_DIR:-$HOME/.local/bin}" LIB_DIR="${CODEX_CHATGPT_WEB_LIB_DIR:-$HOME/.local/lib/codex-chatgpt-web}" DOC_DIR="${CODEX_CHATGPT_WEB_DOC_DIR:-$HOME/.local/share/doc/codex-chatgpt-web}" diff --git a/src/adapters/chatgpt-web/browser-worker.ts b/src/adapters/chatgpt-web/browser-worker.ts index 29c42700d..71403fdb6 100644 --- a/src/adapters/chatgpt-web/browser-worker.ts +++ b/src/adapters/chatgpt-web/browser-worker.ts @@ -1203,6 +1203,20 @@ export class ChatGptBrowserWorker { return await context.newPage(); } + private async activateEffortControl(control: Locator, failureMessage: string): Promise { + const activated = await control.evaluate((element) => { + if (!(element instanceof HTMLButtonElement)) return false; + if (element.getAttribute("aria-haspopup") !== "menu") return false; + element.click(); + return true; + }); + if (activated) return; + throw new ChatGptWebAdapterError( + failureMessage, + { status: 502, errorType: "server_error", code: "upstream_server_error", retryable: false }, + ); + } + private async selectModelAndEffort( page: Page, modelId: string, @@ -1226,7 +1240,8 @@ export class ChatGptBrowserWorker { await captureDiagnostic?.("luna-default-confirmed"); return mode; } - const currentEffort = composerForm.locator(CHATGPT_EFFORT_CONTROL_SELECTOR).last(); + const effortControls = composerForm.locator(CHATGPT_EFFORT_CONTROL_SELECTOR); + const currentEffort = effortControls.last(); const effortWaitAbort = new AbortController(); try { const ready = await Promise.race([ @@ -1243,16 +1258,27 @@ export class ChatGptBrowserWorker { } await settleChatGptUi(); await throwIfChatGptRateLimitDialog(page); + const effortControlCount = await effortControls.filter({ visible: true }).count(); + if (effortControlCount !== 1) { + throw new ChatGptWebAdapterError( + `ChatGPT exposed ${effortControlCount} visible model/effort controls; refusing ambiguous activation`, + { status: 502, errorType: "server_error", code: "upstream_server_error", retryable: false }, + ); + } await captureDiagnostic?.("effort-control-ready"); const effortMenu = page.locator(CHATGPT_EFFORT_MENU_SELECTOR).last(); const menuVisible = await effortMenu.isVisible().catch(() => false); const menuExpanded = await currentEffort.getAttribute("aria-expanded").catch(() => null); if (!menuVisible && menuExpanded !== "true") { await throwIfChatGptRateLimitDialog(page); - // ChatGPT's current Radix trigger no longer responds to synthetic Enter/Space on background - // Electron surfaces. Force only the exact, visible effort control; the menu/slider state - // below remains the authoritative postcondition, so this cannot become an unproved click. - await currentEffort.click({ force: true }); + // Electron can report a 1x1 viewport for a composited background WebContentsView. Playwright + // then rejects even a forced click as outside the viewport. Activate the one unambiguous + // visible button in the document and keep the opened menu/slider as the authoritative + // postcondition, so background geometry cannot turn into an unproved selection. + await this.activateEffortControl( + currentEffort, + "ChatGPT model/effort control was not an activatable menu button", + ); } await captureDiagnostic?.("effort-menu-open-requested"); const effortChoices = effortMenu.locator(CHATGPT_EFFORT_ITEM_SELECTOR); @@ -1351,7 +1377,10 @@ export class ChatGptBrowserWorker { const expanded = await currentEffort.getAttribute("aria-expanded").catch(() => null); if (expanded !== "true") { await throwIfChatGptRateLimitDialog(page); - await currentEffort.click({ force: true }); + await this.activateEffortControl( + currentEffort, + "ChatGPT model/effort control could not reopen its menu", + ); } await effortChoice.waitFor({ state: "visible", diff --git a/src/chatgpt-session.ts b/src/chatgpt-session.ts index b43901141..b8fc46a20 100644 --- a/src/chatgpt-session.ts +++ b/src/chatgpt-session.ts @@ -11,6 +11,10 @@ export const CHATGPT_COMPOSER_SELECTOR = [ export const CHATGPT_EFFORT_CONTROL_SELECTOR = [ 'button[aria-haspopup="menu"][data-tone="neutral"]:has([data-animated-slider-trigger="true"])', 'button[data-testid="model-switcher-dropdown-button"][aria-haspopup="menu"]', + // Current ChatGPT builds expose the model/effort trigger as a neutral composer pill without + // either of the older identifying descendants. Selection code requires one unambiguous visible + // match and verifies the opened effort menu before it can submit anything. + 'button[aria-haspopup="menu"][data-tone="neutral"]', ].join(", "); export const CHATGPT_EFFORT_MENU_SELECTOR = [ '[data-testid="composer-intelligence-picker-content"]:has([role="menuitemradio"], [data-model-reasoning-effort-slider])', diff --git a/src/version.ts b/src/version.ts index 74b1b9336..94a9e1804 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1 +1 @@ -export const VERSION = "3.0.4"; +export const VERSION = "3.0.6"; diff --git a/tests/browser-worker-contract.test.ts b/tests/browser-worker-contract.test.ts index 45ed3ea92..dea2d965e 100644 --- a/tests/browser-worker-contract.test.ts +++ b/tests/browser-worker-contract.test.ts @@ -997,14 +997,18 @@ test("effort selection handles the known ChatGPT rate-limit dialog before backgr const selectionEnd = workerSource.indexOf("private async activeComposer", selectionStart); const selectionSource = workerSource.slice(selectionStart, selectionEnd); const guard = selectionSource.indexOf("throwIfChatGptRateLimitDialog(page)"); - const activation = selectionSource.indexOf("currentEffort.click({ force: true })"); + const activation = selectionSource.indexOf("this.activateEffortControl("); expect(workerSource).toContain("Too many requests"); expect(workerSource).toContain("making requests too quickly"); expect(guard).toBeGreaterThan(-1); expect(activation).toBeGreaterThan(guard); expect(selectionSource).not.toContain('currentEffort.press("Enter")'); - expect(selectionSource).not.toContain("currentEffort.evaluate("); + expect(selectionSource).not.toContain("currentEffort.click("); + expect(selectionSource).toContain("effortControlCount !== 1"); + expect(workerSource).toContain("element instanceof HTMLButtonElement"); + expect(workerSource).toContain('element.getAttribute("aria-haspopup") !== "menu"'); + expect(workerSource).toContain("element.click()"); expect(selectionSource).toContain('effortChoice.press("Enter")'); expect(selectionSource).not.toContain("effortChoice.click("); expect(selectionSource).not.toContain("is unavailable"); @@ -1257,7 +1261,9 @@ test("effort selection stops as soon as ChatGPT reports an expired session", asy test("effort menu waiting stops when ChatGPT reports an expired session", async () => { const neverVisible = new Promise(() => {}); const effortControl = { + filter() { return this; }, last() { return this; }, + count: async () => 1, waitFor: async () => {}, getAttribute: async () => "true", }; diff --git a/tests/chatgpt-session.test.ts b/tests/chatgpt-session.test.ts index a1a397d39..0a113b7e6 100644 --- a/tests/chatgpt-session.test.ts +++ b/tests/chatgpt-session.test.ts @@ -38,7 +38,7 @@ test("the effort selector identifies the model slider instead of any composer me expect(selectors).toContain( 'button[data-testid="model-switcher-dropdown-button"][aria-haspopup="menu"]', ); - expect(selectors).not.toContain('button[aria-haspopup="menu"][data-tone="neutral"]'); + expect(selectors).toContain('button[aria-haspopup="menu"][data-tone="neutral"]'); expect(selectors).not.toContain('button[aria-haspopup="menu"]'); });