diff --git a/CHANGELOG.md b/CHANGELOG.md index 034b761d2..c4ca2e1b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,8 @@ ### Fixed -- Browser: keep `--browser-thinking-time extra-high` as Extra High (non-Pro) on GPT-5.6 Sol; reserve `heavy` for explicit Pro selection. Fixes #353. +- Browser: keep `--browser-thinking-time extra-high` as Extra High (non-Pro) on GPT-5.6 Sol instead of selecting Pro. Fixes #353. +- Browser: match German Intelligence effort labels with whole-word Latin matching, and keep the currently selected effort when a requested tier has no matching row. Thanks @Jonasdero! ## 0.17.0 — 2026-08-02 diff --git a/docs/browser-mode.md b/docs/browser-mode.md index 48d7f9540..0d2d125e9 100644 --- a/docs/browser-mode.md +++ b/docs/browser-mode.md @@ -101,7 +101,7 @@ Notes: - If an assistant response still times out (common with long Pro runs), Oracle marks the session as an incomplete capture, stores reattach/runtime diagnostics, and keeps enough browser metadata for `oracle session ` to recover the final answer. Visible ChatGPT rate-limit, temporary-unavailable, and authentication/challenge warnings are included in the error and session metadata instead of being reduced to a generic timeout. Increase `--browser-timeout` only when the browser session is truly unrecoverable. - `--browser-model-strategy `: control ChatGPT model selection. `select` (default) switches to the requested model; `current` keeps the active model and logs its label; `ignore` skips the picker entirely. (Ignored for Gemini web runs.) - Temporary Chat can reduce account-sidebar clutter for one-shot browser consults, but it is a different ChatGPT workflow: Oracle skips archive attempts there and the local transcript/artifacts are the durable record. Verify live behavior before relying on Project Sources, Deep Research reports, or multi-turn persistence. -- `--browser-thinking-time `: set the ChatGPT thinking-time intensity (Thinking/Pro models only). On GPT-5.6 Sol, `extra-high` selects Extra High and `heavy` selects Pro. You can also set a default in `~/.oracle/config.json` via `browser.thinkingTime`. +- `--browser-thinking-time `: set the ChatGPT thinking-time intensity (Thinking/Pro models only). On GPT-5.6 Sol, `extra-high` selects Extra High; a `heavy` request accepts an already-selected Pro pill but otherwise selects only a matching Heavy row. Effort rows are matched in English, German (`Sofort`/`Mittel`/`Hoch`/`Sehr hoch`), and Chinese; when the requested tier has no row in the current UI language, Oracle keeps the effort already selected in the tab instead of switching the model. You can also set a default in `~/.oracle/config.json` via `browser.thinkingTime`. - GPT-5.5 Pro Extended is verified from the selected item in ChatGPT's standalone Pro/Thinking effort pill or compatible Intelligence/model-picker menu. A run **fails closed** if Extended cannot be confirmed rather than silently submitting at a weaker effort. Detection failures write a bounded, redacted model-picker diagnostic to the normal session log. - `--browser-research deep`: activate ChatGPT Deep Research before submitting the prompt. Use this for broad public-web research and final cited reports, not as a replacement for GPT-5.x Pro Heavy code review or pure reasoning. - `--browser-follow-up `: submit another prompt in the same ChatGPT conversation after the initial answer. Repeat the flag for multi-turn reviews such as “challenge your recommendation”, “compare against this constraint”, then “give the final decision”. Deep Research has its own report lifecycle, so browser follow-ups are rejected when `--browser-research deep` is enabled. diff --git a/src/browser/actions/thinkingTime.ts b/src/browser/actions/thinkingTime.ts index 521b11cd0..eda51081c 100644 --- a/src/browser/actions/thinkingTime.ts +++ b/src/browser/actions/thinkingTime.ts @@ -92,7 +92,11 @@ export async function ensureThinkingTime( if (strictProEffort) { throw new Error(`${message}; refusing to submit without confirmed Pro Extended.`); } - logger(formatBrowserThinkingLog(`${message}; continuing with ChatGPT default.`)); + // Nothing was clicked, so the tab keeps whatever effort it already had — + // which is not necessarily ChatGPT's default. + logger( + formatBrowserThinkingLog(`${message}; keeping the effort already selected in ChatGPT.`), + ); return; } default: { @@ -201,13 +205,13 @@ function buildThinkingTimeExpression( const TARGET_MODEL_KIND = ${targetModelKindLiteral}; const TARGET_IS_GPT56_MODEL = ${targetIsGpt56ModelLiteral}; - // Bilingual matchers: English level token + observed Chinese variants. + // Multilingual matchers: English level token + observed German/Chinese variants. const LEVEL_TOKENS = { - light: ['light', 'instant', '轻', '极速'], - standard: ['standard', 'medium', '标准', '中'], - extended: ['extended', 'high', '扩展', '深度', '加强', '高'], - 'extra-high': ['extra high', '极高'], - heavy: ['heavy', '重度', '加重'], + light: ['light', 'instant', 'sofort', 'leicht', '轻', '极速'], + standard: ['standard', 'medium', 'mittel', '标准', '中'], + extended: ['extended', 'high', 'hoch', 'erweitert', '扩展', '深度', '加强', '高'], + 'extra-high': ['extra high', 'sehr hoch', '极高'], + heavy: ['heavy', 'schwer', '重度', '加重'], }; const targetTokens = LEVEL_TOKENS[TARGET_LEVEL] || [TARGET_LEVEL]; @@ -222,19 +226,43 @@ function buildThinkingTimeExpression( const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); // Keep CJK characters so we can match Chinese labels against LEVEL_TOKENS. const normalize = (value) => (value || '') + // Compose first so NFD umlauts fold too, then map them onto ASCII before + // the strip below would drop them (and split the token in half). + .normalize('NFC') .toLowerCase() + .replace(/ä/g, 'a') + .replace(/ö/g, 'o') + .replace(/ü/g, 'u') + .replace(/ß/g, 'ss') .replace(/[^a-z0-9\\u4e00-\\u9fa5]+/g, ' ') .replace(/\\s+/g, ' ') .trim(); const hasToken = (text, token) => normalize(text).split(' ').includes(token); - const matchesLevel = (text) => { + // Whole-word/phrase containment. Latin effort labels are short words that also + // occur inside unrelated UI text ("Hochladen", "Ermitteln") and inside their own + // row descriptions ("Hoch – für sehr komplexe Aufgaben"), so plain substring + // matching misclassifies rows. CJK labels have no word separators, so they keep + // substring semantics. + const hasPhrase = (text, phrase) => { + const haystack = ' ' + normalize(text) + ' '; + const needle = normalize(phrase); + if (!needle) return false; + return /^[a-z0-9 ]+$/.test(needle) + ? haystack.includes(' ' + needle + ' ') + : haystack.includes(needle); + }; + // ChatGPT's Pro effort tiers are "Pro Extended"/"Pro Erweitert" per UI language. + const hasExtendedWord = (text) => hasPhrase(text, 'extended') || hasPhrase(text, 'erweitert'); + const matchesTokens = (text, tokens) => { const t = normalize(text); if (!t) return false; - return targetTokens.some((tok) => { + return tokens.some((tok) => { const token = normalize(tok); if (!token) return false; - if (token === 'high') return hasToken(t, 'high') && !hasToken(t, 'extra'); - if (token === 'extra high') return hasToken(t, 'extra') && hasToken(t, 'high'); + if (token === 'high') return hasPhrase(t, 'high') && !hasPhrase(t, 'extra high'); + if (token === 'extra high') return hasPhrase(t, 'extra high'); + if (token === 'hoch') return hasPhrase(t, 'hoch') && !hasPhrase(t, 'sehr hoch'); + if (token === 'sehr hoch') return hasPhrase(t, 'sehr hoch'); if (token === '极速') { const suffix = t.slice(token.length); return t === token || hasToken(t, token) || /^[0-9]/.test(suffix); @@ -242,27 +270,15 @@ function buildThinkingTimeExpression( if (['中', '高', '极高'].includes(token)) { return t === token || hasToken(t, token); } + if (/^[a-z0-9 ]+$/.test(token)) { + return hasPhrase(t, token); + } return t === token || hasToken(t, token) || t.includes(token); }); }; - const matchesAnyEffortLevel = (text) => { - const normalizedText = normalize(text); - if (!normalizedText) return false; - for (const tokens of Object.values(LEVEL_TOKENS)) { - for (const rawToken of tokens) { - const token = normalize(rawToken); - if (!token) continue; - if (token.includes(' ')) { - if (token.split(' ').every((part) => hasToken(normalizedText, part))) return true; - } else if (/^[a-z0-9]+$/.test(token)) { - if (hasToken(normalizedText, token)) return true; - } else if (normalizedText.includes(token)) { - return true; - } - } - } - return false; - }; + const matchesLevel = (text) => matchesTokens(text, targetTokens); + const matchesAnyEffortLevel = (text) => + Object.values(LEVEL_TOKENS).some((tokens) => matchesTokens(text, tokens)); const optionIsSelected = (node) => { if (!(node instanceof HTMLElement)) return false; const ariaChecked = node.getAttribute('aria-checked'); @@ -414,7 +430,8 @@ function buildThinkingTimeExpression( return true; } const label = menu?.querySelector?.('.__menu-label, [class*="menu-label"]'); - return normalize(label?.textContent ?? '').includes('intelligence'); + // 'intelligen' matches both "Intelligence" and German "Intelligenz". + return normalize(label?.textContent ?? '').includes('intelligen'); }; const failure = (status, extra = {}) => ({ status, @@ -457,24 +474,10 @@ function buildThinkingTimeExpression( return null; } } - if ( - TARGET_IS_GPT56_MODEL && - TARGET_LEVEL === 'heavy' && - isIntelligenceEffortMenu(menu) - ) { - for (const item of items) { - const itemText = normalize( - (item.textContent ?? '') + ' ' + (item.getAttribute?.('aria-label') ?? ''), - ); - if ( - hasToken(itemText, 'pro') && - !itemText.includes('gpt') && - !/(?:^|\\s)5[ .-]?6(?:\\s|$)/.test(itemText) - ) { - return item; - } - } - } + // Generic effort-label match for every model/level. GPT-5.6 heavy used to + // short-circuit to the Pro row before reaching here; it no longer does, so + // a UI without a matching tier (e.g. German, which has no "heavy") falls + // through to null and the caller keeps the current selection. for (const item of items) { const itemText = normalize( (item.textContent ?? '') + ' ' + (item.getAttribute?.('aria-label') ?? ''), @@ -501,11 +504,15 @@ function buildThinkingTimeExpression( } return null; }; + // Menu-shape heuristic only. This reads the whole menu's textContent, where + // adjacent row labels concatenate without a separator ("Pro StandardPro + // Extended"), so word-boundary matching does not apply here — substring is + // deliberate. Row-level classification uses matchesLevel/matchesTokens. const countEffortLevels = (menu) => { const text = normalize(menu?.textContent ?? ''); let hits = 0; for (const tokens of Object.values(LEVEL_TOKENS)) { - if (tokens.some((token) => text.includes(String(token).toLowerCase()))) hits += 1; + if (tokens.some((token) => text.includes(normalize(token)))) hits += 1; } return hits; }; @@ -516,16 +523,22 @@ function buildThinkingTimeExpression( const label = menu.querySelector?.('.__menu-label, [class*="menu-label"]'); const labelText = normalize(label?.textContent ?? ''); return ( - labelText.includes('intelligence') || + labelText.includes('intelligen') || labelText.includes('thinking time') || labelText.includes('thinking effort') || + labelText.includes('denkdauer') || + labelText.includes('denkzeit') || countEffortLevels(menu) >= 2 ); }; const isProEffortMenu = (menu) => { if (!isVisible(menu)) return false; const text = normalize(menu?.textContent ?? ''); - return text.includes('pro standard') && text.includes('pro extended'); + // Aggregate menu text, so plain substring only (see countEffortLevels). + return ( + text.includes('pro standard') && + (text.includes('pro extended') || text.includes('pro erweitert')) + ); }; const controlledMenu = (trigger) => { const id = trigger?.getAttribute?.('aria-controls'); @@ -560,10 +573,10 @@ function buildThinkingTimeExpression( (node?.textContent ?? '') + ' ' + (node?.getAttribute?.('aria-label') ?? ''), ); if (TARGET_LEVEL === 'standard') { - return text.includes('pro') && text.includes('standard'); + return hasPhrase(text, 'pro') && hasPhrase(text, 'standard'); } if (TARGET_LEVEL === 'extended') { - return text.includes('pro') && text.includes('extended'); + return hasPhrase(text, 'pro') && hasExtendedWord(text); } return false; }; @@ -588,10 +601,10 @@ function buildThinkingTimeExpression( } const label = normalize(button?.textContent ?? ''); if (TARGET_LEVEL === 'standard') { - return hasToken(label, 'pro') && !hasToken(label, 'extended'); + return hasToken(label, 'pro') && !hasExtendedWord(label); } if (TARGET_LEVEL === 'extended') { - return hasToken(label, 'pro') && hasToken(label, 'extended'); + return hasToken(label, 'pro') && hasExtendedWord(label); } return false; }; @@ -601,13 +614,9 @@ function buildThinkingTimeExpression( const normalizedLabel = normalize( (button?.textContent ?? '') + ' ' + (button?.getAttribute?.('aria-label') ?? ''), ); - if ( - TARGET_IS_GPT56_MODEL && - TARGET_LEVEL === 'heavy' && - hasToken(normalizedLabel, 'pro') - ) { - return true; - } + // No 5.6-heavy "a Pro pill counts as heavy" shortcut here: that would also + // make post-click verification pass on an unchanged Pro pill. selectAndVerify + // handles the already-on-Pro case explicitly before any click. if ((modelKindOverride || TARGET_MODEL_KIND || modelKindFromNode(button)) === 'pro') { return false; } @@ -620,14 +629,21 @@ function buildThinkingTimeExpression( modelKindFromNode(trigger) || effectiveTargetModelKind(); const option = findOption(); - if ( - !option && - TARGET_IS_GPT56_MODEL && - TARGET_LEVEL === 'heavy' && - currentEffortPillMatchesTarget(trigger, triggerModelKind) - ) { - closeOpenMenus(); - return { status: 'already-selected', label: trigger.textContent?.trim?.() || null }; + if (!option && TARGET_IS_GPT56_MODEL && TARGET_LEVEL === 'heavy') { + // GPT-5.6 has no "heavy" tier: Pro is the closest thing. Accept a pill that + // is already on Pro as satisfying the request, but never click Pro to get + // there, and never let this stand in for post-click verification. + const pill = freshComposerTrigger(trigger) || findModelButton(); + const pillLabel = normalize( + (pill?.textContent ?? '') + ' ' + (pill?.getAttribute?.('aria-label') ?? ''), + ); + if ( + hasToken(pillLabel, 'pro') || + currentEffortPillMatchesTarget(trigger, triggerModelKind) + ) { + closeOpenMenus(); + return { status: 'already-selected', label: trigger.textContent?.trim?.() || null }; + } } if (!option) return failure('option-not-found', { modelKind: triggerModelKind }); const label = option.textContent?.trim?.() || null; @@ -924,7 +940,7 @@ function buildThinkingTimeExpression( const text = normalize( (node?.textContent ?? '') + ' ' + (node?.getAttribute?.('aria-label') ?? ''), ); - return text.includes('pro') && text.includes('extended'); + return hasPhrase(text, 'pro') && hasExtendedWord(text); }; const findProExtendedOption = () => { const menu = document.querySelector(INTELLIGENCE_MENU_SELECTOR); diff --git a/src/browser/chromeLifecycle.ts b/src/browser/chromeLifecycle.ts index cf2c2bd5d..61a959291 100644 --- a/src/browser/chromeLifecycle.ts +++ b/src/browser/chromeLifecycle.ts @@ -742,6 +742,12 @@ function buildChromeFlags( "--disable-features=TranslateUI,AutomationControlled", "--mute-audio", "--window-size=1280,720", + // Chrome that *we* launch is pinned to English, so ChatGPT renders the labels + // our selectors were written against. This does not make English the only case + // to handle: --browser-attach-running and --remote-chrome never build these + // flags (see controlPlan.ts), so those runs inherit the user's own Chrome + // locale, and a ChatGPT account language setting can localize the UI even here. + // That is why the model/effort matchers must stay language-tolerant. "--lang=en-US", "--accept-lang=en-US,en", ]; diff --git a/tests/browser/thinkingTime.test.ts b/tests/browser/thinkingTime.test.ts index ca68346ff..69ed31272 100644 --- a/tests/browser/thinkingTime.test.ts +++ b/tests/browser/thinkingTime.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import type { ThinkingTimeLevel } from "../../src/oracle/types.js"; import { buildThinkingTimeExpressionForTest, ensureThinkingTime, @@ -331,7 +332,7 @@ describe("browser thinking-time selection expression", () => { ), ).resolves.toBeUndefined(); - expect(logs.at(-1)).toContain("continuing with ChatGPT default"); + expect(logs.at(-1)).toContain("keeping the effort already selected in ChatGPT"); }); it("drives ChatGPT's new Intelligence effort picker for Pro Extended", () => { @@ -611,7 +612,8 @@ describe("browser thinking-time selection expression", () => { FakeMouseEvent, FakeElement, ), - ).resolves.toEqual({ status: "switched", label: "Pro" }); + // No heavy label in the menu: keep whatever is selected instead of switching to Pro. + ).resolves.toMatchObject({ status: "option-not-found" }); const competingProAttributes: Record = { role: "menuitemradio", @@ -675,23 +677,18 @@ describe("browser thinking-time selection expression", () => { FakeMouseEvent, FakeElement, ), - ).resolves.toEqual({ status: "switched", label: "Pro" }); + // Extra High is not a heavy label either, so Pro must not be hijacked. + ).resolves.toMatchObject({ status: "option-not-found" }); const extraHighAttributes: Record = { role: "menuitemradio", "aria-checked": "false", "data-state": "unchecked", }; - const selectableExtraHigh = new FakeElement( - "Extra High", - extraHighAttributes, - [], - null, - () => { - extraHighAttributes["aria-checked"] = "true"; - extraHighAttributes["data-state"] = "checked"; - }, - ); + const selectableExtraHigh = new FakeElement("Extra High", extraHighAttributes, [], null, () => { + extraHighAttributes["aria-checked"] = "true"; + extraHighAttributes["data-state"] = "checked"; + }); const extraHighItems = [ selectableExtraHigh, new FakeElement("Pro", { @@ -1028,6 +1025,185 @@ describe("browser thinking-time selection expression", () => { } }); + it("selects German Intelligence tiers and keeps Hoch distinct from Sehr hoch", async () => { + class FakeEventTarget { + dispatchEvent(_event: unknown): boolean { + return true; + } + } + class FakeElement extends FakeEventTarget { + constructor( + public textContent: string, + private readonly attributes: Record = {}, + private readonly children: FakeElement[] = [], + private readonly nestedIntelligence: FakeElement | null = null, + private readonly onDispatch?: () => void, + ) { + super(); + } + getAttribute(name: string): string | null { + return this.attributes[name] ?? null; + } + setAttribute(name: string, value: string): void { + this.attributes[name] = value; + } + querySelector(selector: string): FakeElement | null { + return selector.includes("composer-intelligence-picker-content") + ? this.nestedIntelligence + : null; + } + querySelectorAll(_selector: string): FakeElement[] { + return this.children; + } + closest(_selector: string): FakeElement | null { + return null; + } + matches(selector: string): boolean { + return ( + selector.includes("__composer-pill") && + this.attributes.class?.includes("__composer-pill") === true + ); + } + focus(): void {} + getBoundingClientRect(): { width: number; height: number } { + return { width: 144, height: 36 }; + } + override dispatchEvent(event: unknown): boolean { + this.onDispatch?.(); + return super.dispatchEvent(event); + } + } + class FakeMouseEvent { + constructor( + public readonly type: string, + public readonly init?: unknown, + ) {} + } + + const GERMAN_TIERS = ["Sofort", "Mittel", "Hoch", "Sehr hoch"]; + const cases: Array<{ level: ThinkingTimeLevel; label: string | null; tiers: string[] }> = [ + { level: "light", label: "Sofort", tiers: GERMAN_TIERS }, + { level: "standard", label: "Mittel", tiers: GERMAN_TIERS }, + { level: "extended", label: "Hoch", tiers: GERMAN_TIERS }, + { level: "extra-high", label: "Sehr hoch", tiers: GERMAN_TIERS }, + // Sehr hoch must never satisfy `extended`, even when it is the only high tier. + { level: "extended", label: null, tiers: ["Sofort", "Mittel", "Sehr hoch"] }, + // ...nor may Hoch satisfy `extra-high` when Sehr hoch is absent. + { level: "extra-high", label: null, tiers: ["Sofort", "Mittel", "Hoch"] }, + // Row descriptions must not decide the tier: "sehr" inside Hoch's description + // may not disqualify it, and "Hochladen" may not stand in for Hoch. + { + level: "extended", + label: "Hoch – für sehr komplexe Aufgaben", + tiers: ["Sofort", "Mittel", "Hoch – für sehr komplexe Aufgaben", "Sehr hoch"], + }, + { level: "extended", label: null, tiers: ["Sofort", "Mittel", "Hochladen"] }, + { + level: "standard", + label: "Mittel – ausgewogene Denkdauer", + tiers: ["Sofort", "Mittel – ausgewogene Denkdauer", "Hoch", "Sehr hoch"], + }, + { level: "standard", label: null, tiers: ["Sofort", "Ermitteln", "Hoch"] }, + ]; + + for (const testCase of cases) { + let clickedLabel: string | null = null; + const makeRadio = (label: string) => { + const radio = new FakeElement( + label, + { role: "menuitemradio", "aria-checked": "false", "data-state": "unchecked" }, + [], + null, + () => { + clickedLabel = label; + radio.setAttribute("aria-checked", "true"); + radio.setAttribute("data-state", "checked"); + }, + ); + return radio; + }; + const effortItems = [ + ...testCase.tiers.map(makeRadio), + new FakeElement("Pro", { + role: "menuitemradio", + "aria-checked": "false", + "data-state": "unchecked", + }), + new FakeElement("GPT-5.6", { role: "menuitem", "aria-haspopup": "menu" }), + ]; + const intelligenceGroup = new FakeElement( + `Intelligenz ${effortItems.map((item) => item.textContent).join(" ")}`, + { "data-testid": "composer-intelligence-picker-content", role: "group" }, + effortItems, + ); + const outerMenu = new FakeElement( + intelligenceGroup.textContent, + { role: "menu" }, + effortItems, + intelligenceGroup, + ); + const modelButton = new FakeElement("Hoch", { + class: "__composer-pill", + "aria-expanded": "true", + "aria-haspopup": "menu", + }); + const documentStub = { + body: new FakeElement(""), + querySelector: (selector: string) => { + if (selector.includes("composer-intelligence-pro-thinking-effort-trigger")) return null; + if (selector.includes("composer-intelligence-picker-content")) return intelligenceGroup; + if ( + selector.includes("model-switcher-dropdown-button") || + selector.includes("__composer-pill") + ) { + return modelButton; + } + return null; + }, + querySelectorAll: (selector: string) => { + if (selector.includes("__composer-pill")) return [modelButton]; + if (selector.includes('role="menu"') || selector.includes("data-radix")) { + return [outerMenu]; + } + return []; + }, + dispatchEvent: () => true, + }; + let now = 0; + const performanceStub = { now: () => (now += 100) }; + const evaluate = new Function( + "document", + "performance", + "setTimeout", + "window", + "EventTarget", + "PointerEvent", + "MouseEvent", + "HTMLElement", + `return ${buildThinkingTimeExpressionForTest(testCase.level, "GPT-5.6")};`, + ) as (...args: unknown[]) => Promise; + + const result = (await evaluate( + documentStub, + performanceStub, + (callback: () => void) => callback(), + { PointerEvent: FakeMouseEvent, MouseEvent: FakeMouseEvent, Event: FakeMouseEvent }, + FakeEventTarget, + FakeMouseEvent, + FakeMouseEvent, + FakeElement, + )) as { status: string; label?: string | null }; + if (testCase.label === null) { + expect(result.status).toBe("option-not-found"); + expect(clickedLabel).toBeNull(); + } else { + expect(result.status).toBe("switched"); + expect(result.label).toBe(testCase.label); + expect(clickedLabel).toBe(testCase.label); + } + } + }); + it("selects Extended from the current standalone Pro composer pill", async () => { class FakeEventTarget { dispatchEvent(_event: unknown): boolean {