diff --git a/src/browser/actions/modelSelection.ts b/src/browser/actions/modelSelection.ts index a779d727..a1ed8942 100644 --- a/src/browser/actions/modelSelection.ts +++ b/src/browser/actions/modelSelection.ts @@ -1,5 +1,6 @@ import type { ChromeClient, BrowserLogger, BrowserModelStrategy } from "../types.js"; import { + COMPOSER_MODEL_SIGNAL_SELECTOR, MENU_CONTAINER_SELECTOR, MENU_ITEM_SELECTOR, MODEL_BUTTON_SELECTOR, @@ -67,23 +68,33 @@ function buildModelSelectionExpression( strategy: BrowserModelStrategy, ): string { const matchers = buildModelMatchersLiteral(targetModel); + const composerSignalMatchers = buildComposerSignalMatchers(targetModel); const labelLiteral = JSON.stringify(matchers.labelTokens); const idLiteral = JSON.stringify(matchers.testIdTokens); const primaryLabelLiteral = JSON.stringify(targetModel); const strategyLiteral = JSON.stringify(strategy); + const composerSignalSelectorLiteral = JSON.stringify(COMPOSER_MODEL_SIGNAL_SELECTOR); + const composerIncludesLiteral = JSON.stringify(composerSignalMatchers.includesAny); + const composerExcludesLiteral = JSON.stringify(composerSignalMatchers.excludesAny); + const composerAllowBlankLiteral = JSON.stringify(composerSignalMatchers.allowBlank); const menuContainerLiteral = JSON.stringify(MENU_CONTAINER_SELECTOR); const menuItemLiteral = JSON.stringify(MENU_ITEM_SELECTOR); return `(() => { ${buildClickDispatcher()} // Capture the selectors and matcher literals up front so the browser expression stays pure. const BUTTON_SELECTOR = '${MODEL_BUTTON_SELECTOR}'; + const COMPOSER_MODEL_SIGNAL_SELECTOR = ${composerSignalSelectorLiteral}; const LABEL_TOKENS = ${labelLiteral}; const TEST_IDS = ${idLiteral}; const PRIMARY_LABEL = ${primaryLabelLiteral}; const MODEL_STRATEGY = ${strategyLiteral}; + const COMPOSER_SIGNAL_INCLUDES = ${composerIncludesLiteral}; + const COMPOSER_SIGNAL_EXCLUDES = ${composerExcludesLiteral}; + const COMPOSER_SIGNAL_ALLOW_BLANK = ${composerAllowBlankLiteral}; const INITIAL_WAIT_MS = 150; const REOPEN_INTERVAL_MS = 400; const MAX_WAIT_MS = 20000; + const SETTLE_WAIT_MS = 1500; const normalizeText = (value) => { if (!value) { return ''; @@ -139,8 +150,12 @@ function buildModelSelectionExpression( }; const getButtonLabel = () => (button.textContent ?? '').trim(); + const getComposerModelLabel = () => + (document.querySelector(COMPOSER_MODEL_SIGNAL_SELECTOR)?.textContent ?? '').trim(); + const readComposerModelSignal = () => normalizeText(getComposerModelLabel()); + const getResolvedLabel = (fallback) => getComposerModelLabel() || getButtonLabel() || fallback; if (MODEL_STRATEGY === 'current') { - return { status: 'already-selected', label: getButtonLabel() }; + return { status: 'already-selected', label: getResolvedLabel(PRIMARY_LABEL) }; } const buttonMatchesTarget = () => { const normalizedLabel = normalizeText(getButtonLabel()); @@ -160,9 +175,47 @@ function buildModelSelectionExpression( if (!wantsThinking && normalizedLabel.includes('thinking')) return false; return true; }; + const buttonHasGenericLabel = () => { + const normalizedLabel = normalizeText(getButtonLabel()); + return !normalizedLabel || normalizedLabel === 'chatgpt'; + }; + const composerSignalMatchesTarget = () => { + const signal = readComposerModelSignal(); + if (!signal) { + return COMPOSER_SIGNAL_ALLOW_BLANK; + } + if (COMPOSER_SIGNAL_EXCLUDES.some((token) => token && signal.includes(token))) { + return false; + } + if (COMPOSER_SIGNAL_INCLUDES.length === 0) { + return true; + } + return COMPOSER_SIGNAL_INCLUDES.some((token) => token && signal.includes(token)); + }; + const activeSelectionMatchesTarget = () => { + if (buttonMatchesTarget()) { + return true; + } + if (!buttonHasGenericLabel()) { + return false; + } + return composerSignalMatchesTarget(); + }; + const selectionStateChanged = (previousButtonLabel, previousComposerSignal) => { + const currentButtonLabel = normalizeText(getButtonLabel()); + const currentComposerSignal = readComposerModelSignal(); + if ( + currentButtonLabel && + currentButtonLabel !== previousButtonLabel && + !buttonHasGenericLabel() + ) { + return true; + } + return currentComposerSignal !== previousComposerSignal; + }; - if (buttonMatchesTarget()) { - return { status: 'already-selected', label: getButtonLabel() }; + if (activeSelectionMatchesTarget()) { + return { status: 'already-selected', label: getResolvedLabel(PRIMARY_LABEL) }; } let lastPointerClick = 0; @@ -189,7 +242,11 @@ function buildModelSelectionExpression( if (dataSelected === 'true' || selectedStates.includes(dataState)) { return true; } - if (node.querySelector('[data-testid*="check"], [role="img"][data-icon="check"], svg[data-icon="check"]')) { + if ( + node.querySelector( + '[data-testid*="check"], [role="img"][data-icon="check"], svg[data-icon="check"], .trailing svg', + ) + ) { return true; } return false; @@ -230,8 +287,13 @@ function buildModelSelectionExpression( normalizedTestId.includes('gpt-5.0') || normalizedTestId.includes('gpt50'); const candidateVersion = has54 ? '5-4' : has52 ? '5-2' : has51 ? '5-1' : has50 ? '5-0' : null; + const isInternalThinkingAlias = + wantsThinking && + desiredVersion === '5-4' && + candidateVersion === '5-2' && + normalizedTestId.includes('thinking'); // If a candidate advertises a different version, ignore it entirely. - if (candidateVersion && candidateVersion !== desiredVersion) { + if (candidateVersion && candidateVersion !== desiredVersion && !isInternalThinkingAlias) { return 0; } // When targeting an explicit version, avoid selecting submenu wrappers that can contain legacy models. @@ -330,6 +392,24 @@ function buildModelSelectionExpression( } return bestMatch; }; + const waitForTargetSelection = (previousButtonLabel, previousComposerSignal) => new Promise((resolve) => { + const waitStart = performance.now(); + const check = () => { + if ( + activeSelectionMatchesTarget() || + selectionStateChanged(previousButtonLabel, previousComposerSignal) + ) { + resolve(true); + return; + } + if (performance.now() - waitStart > SETTLE_WAIT_MS) { + resolve(false); + return; + } + setTimeout(check, 100); + }; + check(); + }); return new Promise((resolve) => { const start = performance.now(); @@ -374,11 +454,13 @@ function buildModelSelectionExpression( ensureMenuOpen(); const match = findBestOption(); if (match) { - if (optionIsSelected(match.node)) { + if (optionIsSelected(match.node) || activeSelectionMatchesTarget()) { closeMenu(); - resolve({ status: 'already-selected', label: getButtonLabel() || match.label }); + resolve({ status: 'already-selected', label: getResolvedLabel(match.label) }); return; } + const previousButtonLabel = normalizeText(getButtonLabel()); + const previousComposerSignal = readComposerModelSignal(); dispatchClickSequence(match.node); // Submenus (e.g. "Legacy models") need a second pass to pick the actual model option. // Keep scanning once the submenu opens instead of treating the submenu click as a final switch. @@ -387,15 +469,15 @@ function buildModelSelectionExpression( setTimeout(attempt, REOPEN_INTERVAL_MS / 2); return; } - // Wait for the top bar label to reflect the requested model; otherwise keep scanning. - setTimeout(() => { - if (buttonMatchesTarget()) { + // Wait for the selected model signal to settle before reopening the picker. + waitForTargetSelection(previousButtonLabel, previousComposerSignal).then((selectionSettled) => { + if (selectionSettled) { closeMenu(); - resolve({ status: 'switched', label: getButtonLabel() || match.label }); + resolve({ status: 'switched', label: getResolvedLabel(match.label) }); return; } attempt(); - }, Math.max(120, INITIAL_WAIT_MS)); + }); return; } if (performance.now() - start > MAX_WAIT_MS) { @@ -416,6 +498,36 @@ export function buildModelMatchersLiteralForTest(targetModel: string) { return buildModelMatchersLiteral(targetModel); } +type ComposerSignalMatchers = { + includesAny: string[]; + excludesAny: string[]; + allowBlank: boolean; +}; + +function buildComposerSignalMatchers(targetModel: string): ComposerSignalMatchers { + const normalized = targetModel + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, " ") + .replace(/\s+/g, " ") + .trim(); + + if (normalized.includes("pro")) { + return { includesAny: ["pro"], excludesAny: ["thinking"], allowBlank: false }; + } + if (normalized.includes("thinking")) { + return { includesAny: ["thinking"], excludesAny: ["pro"], allowBlank: false }; + } + if (normalized.includes("instant")) { + return { includesAny: [], excludesAny: ["thinking", "pro"], allowBlank: true }; + } + return { includesAny: [], excludesAny: ["thinking", "pro"], allowBlank: true }; +} + +export function buildComposerSignalMatchersForTest(targetModel: string): ComposerSignalMatchers { + return buildComposerSignalMatchers(targetModel); +} + function buildModelMatchersLiteral(targetModel: string): { labelTokens: string[]; testIdTokens: string[]; @@ -483,6 +595,21 @@ function buildModelMatchersLiteral(targetModel: string): { testIdTokens.add("gpt5-0"); testIdTokens.add("gpt50"); } + if (base.includes("thinking")) { + push("thinking", labelTokens); + testIdTokens.add("model-switcher-gpt-5-4-thinking"); + testIdTokens.add("gpt-5-4-thinking"); + testIdTokens.add("gpt-5.4-thinking"); + testIdTokens.add("model-switcher-gpt-5-2-thinking"); + testIdTokens.add("gpt-5-2-thinking"); + testIdTokens.add("gpt-5.2-thinking"); + } + if (base.includes("instant")) { + push("instant", labelTokens); + testIdTokens.add("model-switcher-gpt-5-2-instant"); + testIdTokens.add("gpt-5-2-instant"); + testIdTokens.add("gpt-5.2-instant"); + } // Numeric variations (5.2 ↔ 52 ↔ gpt-5-2) if (base.includes("5.2") || base.includes("5-2") || base.includes("52")) { push("5.2", labelTokens); @@ -492,20 +619,6 @@ function buildModelMatchersLiteral(targetModel: string): { push("gpt5-2", labelTokens); push("gpt52", labelTokens); push("chatgpt 5.2", labelTokens); - // Thinking variant: explicit testid for "Thinking" picker option - if (base.includes("thinking")) { - push("thinking", labelTokens); - testIdTokens.add("model-switcher-gpt-5-2-thinking"); - testIdTokens.add("gpt-5-2-thinking"); - testIdTokens.add("gpt-5.2-thinking"); - } - // Instant variant: explicit testid for "Instant" picker option - if (base.includes("instant")) { - push("instant", labelTokens); - testIdTokens.add("model-switcher-gpt-5-2-instant"); - testIdTokens.add("gpt-5-2-instant"); - testIdTokens.add("gpt-5.2-instant"); - } // Base 5.2 testids (for "Auto" mode when no suffix specified) if (!base.includes("thinking") && !base.includes("instant") && !base.includes("pro")) { testIdTokens.add("model-switcher-gpt-5-2"); diff --git a/src/browser/constants.ts b/src/browser/constants.ts index e2e417a7..febb6e4d 100644 --- a/src/browser/constants.ts +++ b/src/browser/constants.ts @@ -79,6 +79,7 @@ export const SEND_BUTTON_SELECTORS = [ ]; export const SEND_BUTTON_SELECTOR = SEND_BUTTON_SELECTORS[0]; export const MODEL_BUTTON_SELECTOR = '[data-testid="model-switcher-dropdown-button"]'; +export const COMPOSER_MODEL_SIGNAL_SELECTOR = '[data-testid="composer-footer-actions"]'; export const COPY_BUTTON_SELECTOR = 'button[data-testid="copy-turn-action-button"]'; // Action buttons that only appear once a turn has finished rendering. export const FINISHED_ACTIONS_SELECTOR = diff --git a/tests/browser/modelSelection.test.ts b/tests/browser/modelSelection.test.ts index 1c718045..99ee0b67 100644 --- a/tests/browser/modelSelection.test.ts +++ b/tests/browser/modelSelection.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { + buildComposerSignalMatchersForTest, buildModelMatchersLiteralForTest, buildModelSelectionExpressionForTest, } from "../../src/browser/actions/modelSelection.js"; @@ -56,6 +57,17 @@ describe("browser model selection matchers", () => { expect(testIdTokens).toContain("gpt-5.2-thinking"); }); + it("includes language-independent thinking test ids for Thinking 5.4", () => { + const { labelTokens, testIdTokens } = buildModelMatchersLiteralForTest("Thinking 5.4"); + expect(labelTokens).toContain("thinking"); + expect(testIdTokens).toContain("model-switcher-gpt-5-4-thinking"); + expect(testIdTokens).toContain("gpt-5-4-thinking"); + expect(testIdTokens).toContain("gpt-5.4-thinking"); + expect(testIdTokens).toContain("model-switcher-gpt-5-2-thinking"); + expect(testIdTokens).toContain("gpt-5-2-thinking"); + expect(testIdTokens).toContain("gpt-5.2-thinking"); + }); + it("includes instant tokens for gpt-5.2-instant", () => { const { labelTokens, testIdTokens } = buildModelMatchersLiteralForTest("gpt-5.2-instant"); expect(labelTokens.some((t) => t.includes("instant"))).toBe(true); @@ -70,4 +82,37 @@ describe("browser model selection matchers", () => { expect(expression).toContain("key: 'Escape'"); expect(expression).toContain("closeMenu();"); }); + + it("builds composer footer matchers for generic ChatGPT header states", () => { + expect(buildComposerSignalMatchersForTest("GPT-5.4 Pro")).toEqual({ + includesAny: ["pro"], + excludesAny: ["thinking"], + allowBlank: false, + }); + expect(buildComposerSignalMatchersForTest("Thinking 5.4")).toEqual({ + includesAny: ["thinking"], + excludesAny: ["pro"], + allowBlank: false, + }); + expect(buildComposerSignalMatchersForTest("GPT-5.2 Instant")).toEqual({ + includesAny: [], + excludesAny: ["thinking", "pro"], + allowBlank: true, + }); + }); + + it("waits for composer/footer state when the header button stays generic", () => { + const expression = buildModelSelectionExpressionForTest("GPT-5.4 Pro"); + expect(expression).toContain("const readComposerModelSignal = () =>"); + expect(expression).toContain("const activeSelectionMatchesTarget = () =>"); + expect(expression).toContain("const waitForTargetSelection = (previousButtonLabel, previousComposerSignal) =>"); + }); + + it("accepts a post-click state change even when the footer text is localized", () => { + const expression = buildModelSelectionExpressionForTest("Thinking 5.4"); + expect(expression).toContain("const selectionStateChanged = (previousButtonLabel, previousComposerSignal) =>"); + expect(expression).toContain("const previousComposerSignal = readComposerModelSignal();"); + expect(expression).toContain("const previousButtonLabel = normalizeText(getButtonLabel());"); + expect(expression).toContain(".trailing svg"); + }); });