Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
165 changes: 139 additions & 26 deletions src/browser/actions/modelSelection.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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 '';
Expand Down Expand Up @@ -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());
Expand All @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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.
Expand All @@ -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) {
Expand All @@ -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[];
Expand Down Expand Up @@ -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);
Expand All @@ -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");
Expand Down
1 change: 1 addition & 0 deletions src/browser/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down
45 changes: 45 additions & 0 deletions tests/browser/modelSelection.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import {
buildComposerSignalMatchersForTest,
buildModelMatchersLiteralForTest,
buildModelSelectionExpressionForTest,
} from "../../src/browser/actions/modelSelection.js";
Expand Down Expand Up @@ -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);
Expand All @@ -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");
});
});