diff --git a/docs/architecture.md b/docs/architecture.md index 2fd5a3012..8bfc87b64 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1253,7 +1253,7 @@ word list: Discord's channel composer is found by its channelTextArea container and a channel or contact is named by the longest text of its selected rail link that the composer's label also contains, LinkedIn's conversation list by its class, a formatting toolbar by its role, WhatsApp's -contact drawer closes through its header's one named button, and a phone +contact drawer closes through the named button that leads its header (the one before its title when the header also holds Edit), and a phone number in a composer's label is taken by its digits. Chrome's one trusted retry click on a generic element whose synthetic click diff --git a/docs/lean-mode.md b/docs/lean-mode.md index 110947135..a9a9e01c7 100644 --- a/docs/lean-mode.md +++ b/docs/lean-mode.md @@ -10,7 +10,7 @@ For verification, lean mode follows the Claude in Chrome model ([claude-chrome-c Settings → Advanced → **Lean mode** (storage key `leanMode`): -- **Auto** (default): on for strong cloud models, off for local and small models (rules below). +- **Auto** (default): on for strong cloud models, including those WebBrain Companion or a local CLIProxyAPI relays; off for local and small models (rules below). - **On**: always, for every provider and tier. - **Off**: never. @@ -19,7 +19,7 @@ It applies to Act mode only. Ask, Dev and the standalone chat keep their behavio The resolved mode is recorded: - in the trace's runtime config, as `lean_mode_setting` (`auto`, `on` or `off`) and `lean_mode` (`true` or `false`); -- in a `lean_mode` trace note at the start of the run, with the reason (for example `strong_cloud_model`, `local_provider`, `prompt_tier`, `model_not_listed`) and the model family; +- in a `lean_mode` trace note at the start of the run, with the reason (for example `strong_cloud_model`, `strong_relay_model`, `local_provider`, `prompt_tier`, `model_not_listed`) and the model family; - in the run's end details (`onRunFinished`'s `leanMode`), which the scheduler also keeps for scheduled runs. ## Auto rules @@ -27,8 +27,8 @@ The resolved mode is recorded: Auto turns lean mode on only when all of these hold (`src/*/src/agent/lean-mode.js`): 1. **The run is in Act mode.** -2. **The provider is a cloud or router provider.** The category WebBrain gives the provider decides, so Ollama Cloud is a cloud provider. A local provider never gets lean mode under Auto, whatever model it serves, and a provider of unknown category does not either. A provider config that names no category is local when it is llama.cpp, Ollama, LM Studio or WebGPU. -3. **The provider's prompt tier is Full.** Auto keeps WebBrain's checks for a provider the user set to Compact or Mid. +2. **The provider is a cloud or router provider, or a local relay to the cloud.** The category WebBrain gives the provider decides, so Ollama Cloud is a cloud provider. A local provider never gets lean mode under Auto, whatever model it serves (a local model can carry a strong model's name), and a provider of unknown category does not either. A provider config that names no category is local when it is llama.cpp, Ollama, LM Studio or WebGPU. The exception is the two local relays, WebBrain Companion (`webbrain_companion`) and the Local OpenAI-compatible Proxy (`local_openai_proxy`): their address is on this computer, but they forward to the cloud subscriptions the user signed in with (both are CLIProxyAPI), so Auto judges them by the model they serve, as it does a router, and the trace note says `strong_relay_model`. The one model the Companion runs itself, Bonsai, is not on the strong list. +3. **The provider's prompt tier is Full.** Auto keeps WebBrain's checks for a provider the user set to Compact or Mid. A relay's tier is not read: like every local provider it defaults to Mid, and the settings page saves that default with the provider, so it says nothing about the model. 4. **The model id is on the strong-model list:** - Claude Opus and Sonnet, generation 4 and later, and Claude Fable and Mythos (`claude-sonnet-4-5`, `claude-opus-4-1-…`, `claude-fable-5-1`, `anthropic/claude-sonnet-4.5`, Bedrock ids); - GPT-5 and later, including gpt-6 variants, except nano models; diff --git a/docs/native-chat-observation.md b/docs/native-chat-observation.md index 8b4ead0bb..94d9f09a6 100644 --- a/docs/native-chat-observation.md +++ b/docs/native-chat-observation.md @@ -234,9 +234,12 @@ the right chat. The probe reports that kind of request already pinned to a verified chat identity, never moves its anchor. Once a check is armed, a later selection change spends it. -The drawer's close control is its header's one named plain button, whatever -language names it ("Close", "Kapat"); a header with two controls offers none, -and the control must keep the name it had when the guarded click armed it. +The drawer's close control is the named plain button that leads its header, +whatever language names it ("Close", "Kapat"): the header's one button, or, +where the header also holds actions after its title (a saved contact's drawer +reads Close, "Contact info", Edit), the one button before that title. A header +without one title, or with more than one button before it, offers none, and the +control must keep the name it had when the guarded click armed it. Contact-drawer media loaders and the Close icon can update unrelated visual content without changing identity. The original subtitle and phone nodes, diff --git a/src/chrome/src/agent/adapters.js b/src/chrome/src/agent/adapters.js index 5f9128b0d..6ac6e2e26 100644 --- a/src/chrome/src/agent/adapters.js +++ b/src/chrome/src/agent/adapters.js @@ -16397,10 +16397,13 @@ const ADAPTERS = [ fullPageCapture: { infiniteScroll: isRedditInfiniteScrollUrl }, notes: ` - Prefer old.reddit.com — its DOM is simpler and easier to automate. If you land on www.reddit.com or new.reddit.com, navigate to the old.reddit.com equivalent first (swap the subdomain, keep the path). -- old.reddit.com and new.reddit.com have completely different DOMs. Check the URL before assuming selectors. -- Comments are nested deeply; "load more comments" links require clicking to expand. -- Voting requires being logged in; the up/down arrows are siblings of each post/comment. -- Post composer: title, body (markdown editor), flair selector. "Submit" button is at the bottom.`, +- On old.reddit.com /r//submit, choose the "text" tab for a text/self post before filling the separate title and Markdown body textareas. Modern Reddit has a different composer; check the URL first. +- Before opening old Reddit's flair picker, scroll the "choose a flair" row into the upper half of the viewport so the popup and its bottom button fit. After scrolling, observe the new positions before clicking. +- Old Reddit post flair is a two-step popup, not a native select: click "select", choose the appropriate flair, then explicitly click the popup's "apply" button. Clicking an option only previews it; clicking outside dismisses the popup without applying it. +- Choosing a flair inserts a preview and "(remove flair)" row ABOVE "apply", moving that button DOWN. Re-read the picker for a fresh AX reference in element mode, or call take_screenshot in vision mode, then single-click the actual current "apply" button. Do not reuse its pre-selection coordinates, press Enter on the option, or double-click the option to apply it. +- A flair chip can appear on the main form even before it is applied. After explicitly clicking "apply", verify that the popup closes and the intended flair remains. If submit reports "Your post must contain post flair.", preserve the title/body, reopen the picker and explicitly apply the intended flair before retrying submit. +- Submit only after applying any required flair. Verify the resulting /r//comments// permalink and posted title/body in the intended community before marking that item published or moving to the next subreddit; observe the result before retrying a submit. +- Comments are nested deeply; "load more comments" links require clicking to expand.`, }, { name: 'youtube', diff --git a/src/chrome/src/agent/lean-mode.js b/src/chrome/src/agent/lean-mode.js index 23982bba1..dd8861749 100644 --- a/src/chrome/src/agent/lean-mode.js +++ b/src/chrome/src/agent/lean-mode.js @@ -55,11 +55,21 @@ export function strongModelFamily(modelId) { } // Providers that run on the user's machine or in the browser. Auto never -// turns lean mode on for them, whatever model they serve. The type and id -// lists only classify a provider whose config names no category. +// turns lean mode on for them, whatever model they serve: a local model may +// carry a strong model's name ("...-claude-sonnet-4-distill"). The type and +// id lists only classify a provider whose config names no category. const LOCAL_PROVIDER_TYPES = Object.freeze(['llamacpp', 'webgpu']); const LOCAL_PROVIDER_ID = /^(?:llamacpp|ollama|lmstudio|webgpu)(?:$|[_-])/; +// Local relays: apps on this computer that forward to the cloud subscriptions +// the user signed in with. WebBrain Companion embeds CLIProxyAPI, and the +// Local OpenAI-compatible Proxy is CLIProxyAPI on its own. Their category is +// local (their address is), but the model that answers is the cloud model +// they serve, so Auto judges them by that model, as it does a router. The one +// model the Companion runs itself (Bonsai) is not on the strong list. +const LOCAL_RELAY_PROVIDER_NAMES = Object.freeze(['webbrain-companion', 'local-openai-proxy']); +const LOCAL_RELAY_PROVIDER_ID = /^(?:webbrain_companion|local_openai_proxy)(?:$|[_-])/; + // The first value that is a non-blank string, read lazily: a provider's // getters can throw, and a blank configured value (model: '') means "not // set", so the provider's own default applies. @@ -84,27 +94,32 @@ export function leanProviderInfo(provider) { const category = firstText(() => config.category, () => provider?.category).trim().toLowerCase(); const type = firstText(() => config.type, () => provider?.type).trim().toLowerCase(); const providerId = firstText(() => config._providerId, () => provider?.providerId).trim().toLowerCase(); + const providerName = firstText(() => config.providerName, () => provider?.providerName).trim().toLowerCase(); // A blank configured model is unset: the provider's model getter returns // the model it actually calls (AnthropicProvider defaults to its own). const model = firstText(() => config.model, () => provider?.model).trim(); const promptTier = firstText(() => provider?.promptTier, () => config.promptTier).trim().toLowerCase(); + const relay = LOCAL_RELAY_PROVIDER_NAMES.includes(providerName) || LOCAL_RELAY_PROVIDER_ID.test(providerId); // An explicit category decides, as ProviderManager.categoryFor does: the // built-in Ollama Cloud is 'cloud' although its id starts with "ollama-". // The type and id lists only classify a config written without one. const remote = category === 'cloud' || category === 'router'; - const local = category === 'local' - || (!remote && (LOCAL_PROVIDER_TYPES.includes(type) || LOCAL_PROVIDER_ID.test(providerId))); - const cloud = !local && remote; - return { category, type, providerId, model, promptTier, local, cloud }; + const local = !relay && (category === 'local' + || (!remote && (LOCAL_PROVIDER_TYPES.includes(type) || LOCAL_PROVIDER_ID.test(providerId)))); + const cloud = !local && (remote || relay); + return { category, type, providerId, providerName, model, promptTier, relay, local, cloud }; } /** * Whether lean mode applies to a run. Lean mode is an Act-mode setting; Ask * and Dev keep their own behavior. 'on' and 'off' are the user's choice. - * 'auto' needs a cloud provider (not llama.cpp, Ollama, LM Studio, WebGPU or - * any other local provider) serving a listed strong model, and the - * provider's prompt tier vetoes it: a provider the user set to the Compact or - * Mid tier is never lean under Auto. + * 'auto' needs a cloud provider, or a local relay to the cloud (WebBrain + * Companion, the Local OpenAI-compatible Proxy), serving a listed strong + * model; never llama.cpp, Ollama, LM Studio, WebGPU or any other local + * provider. The provider's prompt tier vetoes it: a provider the user set to + * the Compact or Mid tier is never lean under Auto. A relay's tier is not + * read: like every local provider it defaults to Mid, and the settings page + * saves that default with the provider, so it says nothing about the model. * Returns { active, setting, reason, family? }. */ export function resolveLeanMode(setting, { mode = 'act', provider = null } = {}) { @@ -115,12 +130,12 @@ export function resolveLeanMode(setting, { mode = 'act', provider = null } = {}) const info = leanProviderInfo(provider); if (info.local) return { active: false, setting: value, reason: 'local_provider' }; if (!info.cloud) return { active: false, setting: value, reason: 'unknown_provider' }; - if (info.promptTier === 'compact' || info.promptTier === 'mid') { + if (!info.relay && (info.promptTier === 'compact' || info.promptTier === 'mid')) { return { active: false, setting: value, reason: 'prompt_tier' }; } const family = strongModelFamily(info.model); if (!family) return { active: false, setting: value, reason: 'model_not_listed' }; - return { active: true, setting: value, reason: 'strong_cloud_model', family }; + return { active: true, setting: value, reason: info.relay ? 'strong_relay_model' : 'strong_cloud_model', family }; } // Quality guards that keep running in lean mode, with the reason. Each one diff --git a/src/chrome/src/content/chat-observation.js b/src/chrome/src/content/chat-observation.js index e08e92e7e..762591e1a 100644 --- a/src/chrome/src/content/chat-observation.js +++ b/src/chrome/src/content/chat-observation.js @@ -627,16 +627,29 @@ || /^\+[\d ()-]{7,40}$/.test(compact(node.innerText || node.textContent, 80))); }); }; - // The contact drawer's close control, in any language: the one named plain - // button in the drawer's own header (WhatsApp names it in the page's - // language). A drawer that was removed is judged as it was when it closed. + // The contact drawer's close control, in any language (WhatsApp names it in + // the page's language): the named plain button that leads the drawer's own + // header. That is its one button, or, where the header also holds actions + // after its title, the one button before that title. A saved contact's + // drawer reads Close, "Contact info", Edit: taking only a header with one + // button left that Close blocked, the very step the guard names, so a + // saved contact could never be confirmed. A header without one title, or + // with more than one button before it, still offers none. A drawer that + // was removed is judged as it was when it closed. const whatsappDrawerClose = drawer => { if (!drawer) return null; const shown = node => !drawer.isConnected || whatsappRendered(node); const header = query(drawer, 'header').find(shown); - const buttons = header ? query(header, 'button').filter(button => shown(button) - && button.closest('[data-testid="chat-info-drawer"]') === drawer && button.closest('header') === header) : []; - const button = buttons.length === 1 ? buttons[0] : null; + const own = node => shown(node) && node.closest('[data-testid="chat-info-drawer"]') === drawer + && node.closest('header') === header; + const buttons = header ? query(header, 'button').filter(own) : []; + let button = buttons.length === 1 ? buttons[0] : null; + if (buttons.length > 1) { + const titles = query(header, 'h1,h2,h3,h4,h5,h6,[role="heading"]').filter(own); + const leading = titles.length === 1 ? buttons.filter(candidate => !candidate.contains(titles[0]) + && (candidate.compareDocumentPosition(titles[0]) & Node.DOCUMENT_POSITION_FOLLOWING)) : []; + button = leading.length === 1 ? leading[0] : null; + } return button && attribute(button, 'aria-label') && button.type === 'button' && !button.disabled && !button.form && !button.hasAttribute('form') ? button : null; }; diff --git a/src/firefox/src/agent/adapters.js b/src/firefox/src/agent/adapters.js index e10fe432f..14d920ea4 100644 --- a/src/firefox/src/agent/adapters.js +++ b/src/firefox/src/agent/adapters.js @@ -16396,10 +16396,13 @@ const ADAPTERS = [ fullPageCapture: { infiniteScroll: isRedditInfiniteScrollUrl }, notes: ` - Prefer old.reddit.com — its DOM is simpler and easier to automate. If you land on www.reddit.com or new.reddit.com, navigate to the old.reddit.com equivalent first (swap the subdomain, keep the path). -- old.reddit.com and new.reddit.com have completely different DOMs. Check the URL before assuming selectors. -- Comments are nested deeply; "load more comments" links require clicking to expand. -- Voting requires being logged in; the up/down arrows are siblings of each post/comment. -- Post composer: title, body (markdown editor), flair selector. "Submit" button is at the bottom.`, +- On old.reddit.com /r//submit, choose the "text" tab for a text/self post before filling the separate title and Markdown body textareas. Modern Reddit has a different composer; check the URL first. +- Before opening old Reddit's flair picker, scroll the "choose a flair" row into the upper half of the viewport so the popup and its bottom button fit. After scrolling, observe the new positions before clicking. +- Old Reddit post flair is a two-step popup, not a native select: click "select", choose the appropriate flair, then explicitly click the popup's "apply" button. Clicking an option only previews it; clicking outside dismisses the popup without applying it. +- Choosing a flair inserts a preview and "(remove flair)" row ABOVE "apply", moving that button DOWN. Re-read the picker for a fresh AX reference in element mode, or call take_screenshot in vision mode, then single-click the actual current "apply" button. Do not reuse its pre-selection coordinates, press Enter on the option, or double-click the option to apply it. +- A flair chip can appear on the main form even before it is applied. After explicitly clicking "apply", verify that the popup closes and the intended flair remains. If submit reports "Your post must contain post flair.", preserve the title/body, reopen the picker and explicitly apply the intended flair before retrying submit. +- Submit only after applying any required flair. Verify the resulting /r//comments// permalink and posted title/body in the intended community before marking that item published or moving to the next subreddit; observe the result before retrying a submit. +- Comments are nested deeply; "load more comments" links require clicking to expand.`, }, { name: 'youtube', diff --git a/src/firefox/src/agent/lean-mode.js b/src/firefox/src/agent/lean-mode.js index 23982bba1..dd8861749 100644 --- a/src/firefox/src/agent/lean-mode.js +++ b/src/firefox/src/agent/lean-mode.js @@ -55,11 +55,21 @@ export function strongModelFamily(modelId) { } // Providers that run on the user's machine or in the browser. Auto never -// turns lean mode on for them, whatever model they serve. The type and id -// lists only classify a provider whose config names no category. +// turns lean mode on for them, whatever model they serve: a local model may +// carry a strong model's name ("...-claude-sonnet-4-distill"). The type and +// id lists only classify a provider whose config names no category. const LOCAL_PROVIDER_TYPES = Object.freeze(['llamacpp', 'webgpu']); const LOCAL_PROVIDER_ID = /^(?:llamacpp|ollama|lmstudio|webgpu)(?:$|[_-])/; +// Local relays: apps on this computer that forward to the cloud subscriptions +// the user signed in with. WebBrain Companion embeds CLIProxyAPI, and the +// Local OpenAI-compatible Proxy is CLIProxyAPI on its own. Their category is +// local (their address is), but the model that answers is the cloud model +// they serve, so Auto judges them by that model, as it does a router. The one +// model the Companion runs itself (Bonsai) is not on the strong list. +const LOCAL_RELAY_PROVIDER_NAMES = Object.freeze(['webbrain-companion', 'local-openai-proxy']); +const LOCAL_RELAY_PROVIDER_ID = /^(?:webbrain_companion|local_openai_proxy)(?:$|[_-])/; + // The first value that is a non-blank string, read lazily: a provider's // getters can throw, and a blank configured value (model: '') means "not // set", so the provider's own default applies. @@ -84,27 +94,32 @@ export function leanProviderInfo(provider) { const category = firstText(() => config.category, () => provider?.category).trim().toLowerCase(); const type = firstText(() => config.type, () => provider?.type).trim().toLowerCase(); const providerId = firstText(() => config._providerId, () => provider?.providerId).trim().toLowerCase(); + const providerName = firstText(() => config.providerName, () => provider?.providerName).trim().toLowerCase(); // A blank configured model is unset: the provider's model getter returns // the model it actually calls (AnthropicProvider defaults to its own). const model = firstText(() => config.model, () => provider?.model).trim(); const promptTier = firstText(() => provider?.promptTier, () => config.promptTier).trim().toLowerCase(); + const relay = LOCAL_RELAY_PROVIDER_NAMES.includes(providerName) || LOCAL_RELAY_PROVIDER_ID.test(providerId); // An explicit category decides, as ProviderManager.categoryFor does: the // built-in Ollama Cloud is 'cloud' although its id starts with "ollama-". // The type and id lists only classify a config written without one. const remote = category === 'cloud' || category === 'router'; - const local = category === 'local' - || (!remote && (LOCAL_PROVIDER_TYPES.includes(type) || LOCAL_PROVIDER_ID.test(providerId))); - const cloud = !local && remote; - return { category, type, providerId, model, promptTier, local, cloud }; + const local = !relay && (category === 'local' + || (!remote && (LOCAL_PROVIDER_TYPES.includes(type) || LOCAL_PROVIDER_ID.test(providerId)))); + const cloud = !local && (remote || relay); + return { category, type, providerId, providerName, model, promptTier, relay, local, cloud }; } /** * Whether lean mode applies to a run. Lean mode is an Act-mode setting; Ask * and Dev keep their own behavior. 'on' and 'off' are the user's choice. - * 'auto' needs a cloud provider (not llama.cpp, Ollama, LM Studio, WebGPU or - * any other local provider) serving a listed strong model, and the - * provider's prompt tier vetoes it: a provider the user set to the Compact or - * Mid tier is never lean under Auto. + * 'auto' needs a cloud provider, or a local relay to the cloud (WebBrain + * Companion, the Local OpenAI-compatible Proxy), serving a listed strong + * model; never llama.cpp, Ollama, LM Studio, WebGPU or any other local + * provider. The provider's prompt tier vetoes it: a provider the user set to + * the Compact or Mid tier is never lean under Auto. A relay's tier is not + * read: like every local provider it defaults to Mid, and the settings page + * saves that default with the provider, so it says nothing about the model. * Returns { active, setting, reason, family? }. */ export function resolveLeanMode(setting, { mode = 'act', provider = null } = {}) { @@ -115,12 +130,12 @@ export function resolveLeanMode(setting, { mode = 'act', provider = null } = {}) const info = leanProviderInfo(provider); if (info.local) return { active: false, setting: value, reason: 'local_provider' }; if (!info.cloud) return { active: false, setting: value, reason: 'unknown_provider' }; - if (info.promptTier === 'compact' || info.promptTier === 'mid') { + if (!info.relay && (info.promptTier === 'compact' || info.promptTier === 'mid')) { return { active: false, setting: value, reason: 'prompt_tier' }; } const family = strongModelFamily(info.model); if (!family) return { active: false, setting: value, reason: 'model_not_listed' }; - return { active: true, setting: value, reason: 'strong_cloud_model', family }; + return { active: true, setting: value, reason: info.relay ? 'strong_relay_model' : 'strong_cloud_model', family }; } // Quality guards that keep running in lean mode, with the reason. Each one diff --git a/src/firefox/src/content/chat-observation.js b/src/firefox/src/content/chat-observation.js index e08e92e7e..762591e1a 100644 --- a/src/firefox/src/content/chat-observation.js +++ b/src/firefox/src/content/chat-observation.js @@ -627,16 +627,29 @@ || /^\+[\d ()-]{7,40}$/.test(compact(node.innerText || node.textContent, 80))); }); }; - // The contact drawer's close control, in any language: the one named plain - // button in the drawer's own header (WhatsApp names it in the page's - // language). A drawer that was removed is judged as it was when it closed. + // The contact drawer's close control, in any language (WhatsApp names it in + // the page's language): the named plain button that leads the drawer's own + // header. That is its one button, or, where the header also holds actions + // after its title, the one button before that title. A saved contact's + // drawer reads Close, "Contact info", Edit: taking only a header with one + // button left that Close blocked, the very step the guard names, so a + // saved contact could never be confirmed. A header without one title, or + // with more than one button before it, still offers none. A drawer that + // was removed is judged as it was when it closed. const whatsappDrawerClose = drawer => { if (!drawer) return null; const shown = node => !drawer.isConnected || whatsappRendered(node); const header = query(drawer, 'header').find(shown); - const buttons = header ? query(header, 'button').filter(button => shown(button) - && button.closest('[data-testid="chat-info-drawer"]') === drawer && button.closest('header') === header) : []; - const button = buttons.length === 1 ? buttons[0] : null; + const own = node => shown(node) && node.closest('[data-testid="chat-info-drawer"]') === drawer + && node.closest('header') === header; + const buttons = header ? query(header, 'button').filter(own) : []; + let button = buttons.length === 1 ? buttons[0] : null; + if (buttons.length > 1) { + const titles = query(header, 'h1,h2,h3,h4,h5,h6,[role="heading"]').filter(own); + const leading = titles.length === 1 ? buttons.filter(candidate => !candidate.contains(titles[0]) + && (candidate.compareDocumentPosition(titles[0]) & Node.DOCUMENT_POSITION_FOLLOWING)) : []; + button = leading.length === 1 ? leading[0] : null; + } return button && attribute(button, 'aria-label') && button.type === 'button' && !button.disabled && !button.form && !button.hasAttribute('form') ? button : null; }; diff --git a/test/lean-mode.mjs b/test/lean-mode.mjs index cce030831..b8d35eb5a 100644 --- a/test/lean-mode.mjs +++ b/test/lean-mode.mjs @@ -3,7 +3,8 @@ // class `quality` is skipped: no refusal, note, caveat, judge or forced turn. // The hard classes, permission gates, host checks, budgets and user control // keep their behavior. Auto turns lean mode on only for a strong model on a -// cloud provider at the Full tier. Real Agent run loops with a scripted +// cloud provider at the Full tier, or relayed by a local relay to the cloud +// (WebBrain Companion, the Local OpenAI-compatible Proxy) at any tier. Real Agent run loops with a scripted // provider, both builds; the recipient case runs the real content script in // Chromium. import assert from 'node:assert/strict'; @@ -163,6 +164,17 @@ for (const build of ['chrome', 'firefox']) { // Cloud is a cloud provider although its id starts with "ollama-". [{ category: 'cloud', type: 'openai', providerId: 'ollama-cloud', model: 'gemini-3-pro-preview', promptTier: 'full' }, true, 'strong_cloud_model'], [{ category: 'local', providerId: 'custom-cloud', model: 'gpt-5', promptTier: 'full' }, false, 'local_provider'], + // A local relay to the cloud is judged by the model it relays, at any + // tier: its Mid is the local default the settings page saves with it. + [{ category: 'local', providerName: 'webbrain-companion', model: 'claude-sonnet-4-5', promptTier: 'mid' }, true, 'strong_relay_model'], + [{ category: 'local', providerId: 'webbrain_companion', model: 'gpt-5.4', promptTier: 'mid' }, true, 'strong_relay_model'], + [{ category: 'local', providerId: 'webbrain_companion-2', model: 'claude-opus-5-5' }, true, 'strong_relay_model'], + [{ category: 'local', providerName: 'local-openai-proxy', model: 'gemini-3-pro-preview', promptTier: 'compact' }, true, 'strong_relay_model'], + [{ category: 'local', providerName: 'webbrain-companion', model: 'bonsai-2-27b', promptTier: 'mid' }, false, 'model_not_listed'], + [{ category: 'local', providerName: 'webbrain-companion', model: 'gemini-3.8-flash-high', promptTier: 'mid' }, false, 'model_not_listed'], + [{ category: 'local', providerName: 'webbrain-companion', model: 'claude-haiku-5-5', promptTier: 'mid' }, false, 'model_not_listed'], + // Only those two are relays; another local server stays local. + [{ category: 'local', providerName: 'vllm', providerId: 'vllm', model: 'claude-sonnet-4-5', promptTier: 'full' }, false, 'local_provider'], // A blank configured model is unset: the provider's own model decides. [{ config: { category: 'cloud', model: '' }, model: 'claude-sonnet-5' }, true, 'strong_cloud_model'], [{ config: { category: 'cloud', model: ' ' }, model: 'gpt-5' }, true, 'strong_cloud_model'], @@ -223,20 +235,36 @@ for (const build of ['chrome', 'firefox']) { assert.deepEqual(lean.resolveLeanMode('auto', { provider: built('ollama-cloud', { model: 'gemini-3-pro-preview', apiKey: 'test' }) }), { active: true, setting: 'auto', reason: 'strong_cloud_model', family: 'gemini' }); assert.equal(lean.resolveLeanMode('auto', { provider: built('ollama', { model: 'gpt-5' }) }).reason, 'local_provider'); + // From a trace: WebBrain Companion relays the user's cloud subscriptions + // but was taken for a local provider whatever it served, and its Mid tier, + // the local default saved with it, vetoed Auto again. + const companion = built('webbrain_companion', { model: 'claude-sonnet-4-5', promptTier: 'mid' }); + assert.equal(companion.promptTier, 'mid'); + assert.deepEqual(lean.resolveLeanMode('auto', { provider: companion }), + { active: true, setting: 'auto', reason: 'strong_relay_model', family: 'claude' }); + assert.deepEqual(lean.resolveLeanMode('auto', { provider: built('local_openai_proxy', { model: 'gpt-5', apiKey: 'test' }) }), + { active: true, setting: 'auto', reason: 'strong_relay_model', family: 'gpt' }); + // The model the Companion runs itself is not a strong one. + assert.equal(lean.resolveLeanMode('auto', { provider: built('webbrain_companion', { model: 'bonsai-2-27b' }) }).reason, 'model_not_listed'); // A cleared model setting means the provider's default, which for // Anthropic is claude-sonnet-5. const anthropic = built('anthropic', { model: '', apiKey: 'test' }); assert.equal(anthropic.model, 'claude-sonnet-5'); assert.deepEqual(lean.resolveLeanMode('auto', { provider: anthropic }), { active: true, setting: 'auto', reason: 'strong_cloud_model', family: 'claude' }); - // Auto agrees with ProviderManager.categoryFor on every built-in, and a - // built-in whose default URL is on this machine is local. + // Auto agrees with ProviderManager.categoryFor on every built-in except + // the two local relays to the cloud, and every other built-in whose + // default URL is on this machine is local. + const relays = ['webbrain_companion', 'local_openai_proxy']; for (const [id, config] of Object.entries(defaults)) { const category = ProviderManager.categoryFor(id, config); const info = lean.leanProviderInfo({ config: { ...config, category, _providerId: id } }); - assert.equal(info.local, category === 'local', `${id} is ${category}`); - assert.equal(info.cloud, category !== 'local', `${id} is ${category}`); - if (/^https?:\/\/(?:localhost|127\.0\.0\.1|\[::1\])[:/]/.test(String(config.baseUrl || ''))) assert.ok(info.local, `${id} runs locally`); + const relay = relays.includes(id); + assert.equal(info.relay, relay, `${id} relay`); + if (relay) assert.equal(category, 'local', `${id} is a local address`); + assert.equal(info.local, category === 'local' && !relay, `${id} is ${category}`); + assert.equal(info.cloud, category !== 'local' || relay, `${id} is ${category}`); + if (!relay && /^https?:\/\/(?:localhost|127\.0\.0\.1|\[::1\])[:/]/.test(String(config.baseUrl || ''))) assert.ok(info.local, `${id} runs locally`); } }); diff --git a/test/whatsapp-drawer-identity.mjs b/test/whatsapp-drawer-identity.mjs index ed4f55dec..7c9187458 100644 --- a/test/whatsapp-drawer-identity.mjs +++ b/test/whatsapp-drawer-identity.mjs @@ -24,19 +24,31 @@ for (const [build, engine] of [['chrome', chromium], ['firefox', firefox]]) test }), { target: 'content', action, params }); const observe = currentOwner => call('observe_chat', { _nativeChatOwner: currentOwner || owner }); // `closeLabel` is the drawer's close control's name in the page's language; - // `extraHeaderButton` puts a second control in the drawer's header. - async function open({ closeLabel = 'Close', extraHeaderButton = false } = {}) { + // `headerLayout` lays out the drawer's header: its close control alone, or + // with a title and an Edit control (`close-title-edit` is WhatsApp's own + // drawer for a saved contact). + async function open({ closeLabel = 'Close', headerLayout = 'close' } = {}) { await page.goto('https://web.whatsapp.com/'); - await page.evaluate(({ closeLabel, extraHeaderButton }) => { - window.qaHandlers = []; window.qaOpened = 0; window.qaClosed = 0; window.qaSent = 0; window.qaCloseMode = ''; + await page.evaluate(({ closeLabel, headerLayout }) => { + window.qaHandlers = []; window.qaOpened = 0; window.qaClosed = 0; window.qaEdited = 0; window.qaSent = 0; window.qaCloseMode = ''; const runtime = { getURL: x => x, onMessage: { addListener: fn => qaHandlers.push(fn) } }; window.chrome = window.browser = { runtime }; carrier.onclick = () => { qaOpened++; const drawer = document.createElement('div'); drawer.dataset.testid = 'chat-info-drawer'; - drawer.innerHTML = '
' - + (extraHeaderButton ? '' : '') + '
Contact info
Owned Contact
+90 555 000 00 01
Loading
'; - drawer.querySelector('button').onclick = event => { + const close = ''; + const edit = ''; + const header = { + 'close': close, + 'close-title-edit': close + '

Kişi bilgisi

' + edit, + 'close-roleheading-edit': close + '
Kişi bilgisi
' + edit, + 'two-buttons': close + edit, + 'edit-close-title': edit + close + '

Kişi bilgisi

', + }[headerLayout]; + drawer.innerHTML = '
' + header + '
Contact info
Owned Contact
+90 555 000 00 01
Loading
'; + const editButton = drawer.querySelector('#drawer-edit'); + if (editButton) editButton.onclick = () => { qaEdited++; }; + drawer.querySelector('#drawer-close').onclick = event => { qaClosed++; const icon = drawer.querySelector('#close-icon'); if (qaCloseMode === 'visual-close' || qaCloseMode === 'delayed-visual') { @@ -54,7 +66,7 @@ for (const [build, engine] of [['chrome', chromium], ['firefox', firefox]]) test window.qaDrawer = drawer; }; send.onclick = () => qaSent++; - }, { closeLabel, extraHeaderButton }); + }, { closeLabel, headerLayout }); for (const source of sources) await page.addScriptTag({ content: source }); const first = await observe(); assert.equal(first.success, false); assert.equal(first.nativeWhatsappRepair?.phase, 'open_contact', JSON.stringify(first)); @@ -95,8 +107,9 @@ for (const [build, engine] of [['chrome', chromium], ['firefox', firefox]]) test subtitleSame: qaIdentityNodes.subtitle === qaDrawer.querySelector('#subtitle') })), { opened: 1, closed: 1, sent: 0, nativeSame: true, phoneSame: true, subtitleSame: true }); }); - // The drawer's close control is its header's one named button, whatever - // language names it; a header with two controls offers none. + // The drawer's close control is the named button that leads its header, + // whatever language names it: the header's one button, or the one button + // before the header's title. Any other header offers none. await t.test('accept a close control named in another language', async () => { await open({ closeLabel: 'Kapat' }); const closed = await guardedClose(); @@ -106,12 +119,25 @@ for (const [build, engine] of [['chrome', chromium], ['firefox', firefox]]) test assert.match(observation.conversationId, /^whatsapp:phone:[a-f0-9]{32}$/); assert.equal(await page.evaluate(() => qaSent), 0); }); - await t.test('reject a drawer header with two controls', async () => { - await open({ extraHeaderButton: true }); + // From a trace: WhatsApp's drawer for a saved contact has Close, its title + // and Edit in the header. With only a one-button header accepted, its Close + // was blocked twelve times and the contact could never be confirmed. + for (const headerLayout of ['close-title-edit', 'close-roleheading-edit']) await t.test(`accept ${headerLayout}: the close control before the title`, async () => { + await open({ closeLabel: 'Kapat', headerLayout }); const closed = await guardedClose(); - assert.equal(closed.available, false, 'Neither of two header controls is taken for the close control.'); + assert.equal(closed.available, true, 'The button before the title is the close control.'); + assert.equal(closed.result.success, true, JSON.stringify(closed.result)); + assert.equal(closed.result._nativeWhatsappReadRepair, true); + const observation = await observe(); assert.equal(observation.success, true, JSON.stringify(observation)); + assert.match(observation.conversationId, /^whatsapp:phone:[a-f0-9]{32}$/); + assert.deepEqual(await page.evaluate(() => ({ closed: qaClosed, edited: qaEdited, sent: qaSent })), { closed: 1, edited: 0, sent: 0 }); + }); + for (const headerLayout of ['two-buttons', 'edit-close-title']) await t.test(`reject ${headerLayout}: no single control leads the header`, async () => { + await open({ headerLayout }); + const closed = await guardedClose(); + assert.equal(closed.available, false, 'Neither header control is taken for the close control.'); assert.equal((await observe()).success, false); - assert.equal(await page.evaluate(() => qaSent), 0); + assert.deepEqual(await page.evaluate(() => ({ edited: qaEdited, sent: qaSent })), { edited: 0, sent: 0 }); }); const invalid = ['phone-text','phone-return','phone-clone','subtitle-text','subtitle-return','subtitle-clone','different-phone','transient-phone','transient-subtitle','identity-clear-return','selected-return','header-clone','other-drawer-return']; for (const mutation of invalid) await t.test(`reject ${mutation}`, async () => {