wtagent 0.2.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Use your web AI account as a local CLI agent.
4
4
 
5
- WTAgent connects ChatGPT, Claude, DeepSeek, Gemini, Kimi, or GLM Web to your local project: the model reasons in the browser while WTAgent reads and writes local files and runs commands on your machine.
5
+ WTAgent connects ChatGPT, Claude, DeepSeek, Gemini, Grok, Kimi, or GLM Web to your local project: the model reasons in the browser while WTAgent reads and writes local files and runs commands on your machine.
6
6
 
7
7
  [中文](#中文)
8
8
 
@@ -57,7 +57,7 @@ Multiline paste and `↑` / `↓` input history are supported. Press `Ctrl+C` or
57
57
 
58
58
  ## 中文
59
59
 
60
- WTAgent 将 ChatGPT、Claude、DeepSeek、Gemini、Kimi 或 GLM 网页聊天连接到本地项目:模型在浏览器中思考,WTAgent 在你的电脑上读写本地文件并运行命令。
60
+ WTAgent 将 ChatGPT、Claude、DeepSeek、Gemini、Grok、Kimi 或 GLM 网页聊天连接到本地项目:模型在浏览器中思考,WTAgent 在你的电脑上读写本地文件并运行命令。
61
61
 
62
62
  ### 快速开始
63
63
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wtagent",
3
- "version": "0.2.2",
3
+ "version": "0.3.0",
4
4
  "description": "Turn your own web AI session into a local tool-using agent.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -30,7 +30,7 @@
30
30
  ],
31
31
  "scripts": {
32
32
  "test": "node --test",
33
- "test:unit": "node --test test/protocol.test.js test/policy.test.js test/tools.test.js test/prompt-builder.test.js test/session-export.test.js test/platform.test.js test/cli.test.js test/at-files.test.js test/self-update.test.js test/startup-notices.test.js test/provider-registry.test.js test/claude-adapter.test.js test/deepseek-adapter.test.js test/gemini-adapter.test.js test/kimi-adapter.test.js test/glm-adapter.test.js",
33
+ "test:unit": "node --test test/protocol.test.js test/policy.test.js test/tools.test.js test/prompt-builder.test.js test/session-export.test.js test/platform.test.js test/cli.test.js test/at-files.test.js test/self-update.test.js test/startup-notices.test.js test/provider-registry.test.js test/claude-adapter.test.js test/deepseek-adapter.test.js test/gemini-adapter.test.js test/grok-adapter.test.js test/kimi-adapter.test.js test/glm-adapter.test.js",
34
34
  "test:integration": "node --test test/runtime.test.js",
35
35
  "test:windows": "node --test test/windows-command-launcher.test.js test/windows-diagnostics.test.js test/windows-search-path.test.js test/atomic-write.test.js test/process-utils.test.js test/cdp-state.test.js test/windows-fixtures.test.js test/windows-package-smoke.test.js",
36
36
  "check": "node scripts/check-syntax.js",
@@ -5,7 +5,6 @@ import { launchAndConnectCdpChrome } from "./cdp-browser.js";
5
5
  import { discoverChromeExecutable } from "../platform/chrome-discovery.js";
6
6
  import { ensureDirectory } from "../platform/paths.js";
7
7
  import { BrowserAdapterError } from "../shared/errors.js";
8
- import { runModeSelection } from "./mode-selection.js";
9
8
 
10
9
  // Playwright error messages for a dead transport. The Chrome process itself is
11
10
  // usually still alive (e.g. the connection died while the Mac slept); these
@@ -76,9 +75,10 @@ function sameConversationUrl(left, right) {
76
75
  // The runtime talks to this class only through its public methods; everything
77
76
  // that varies between providers (ChatGPT, DeepSeek, …) is isolated behind the
78
77
  // overridable "primitive" methods below. A concrete provider subclass supplies
79
- // its base URL, DOM locators, message-identity extraction, and (optionally) a
80
- // model-switcher port; it must NOT re-implement the turn-completion loop, the
81
- // send/auth/reconnect flow, or the WTAgent <agent_response> protocol timing.
78
+ // its base URL, DOM locators, and message-identity extraction; it must NOT
79
+ // re-implement the turn-completion loop, send/auth/reconnect flow, or the
80
+ // WTAgent <agent_response> protocol timing. Model selection deliberately stays
81
+ // outside adapters: users choose their model directly on the provider website.
82
82
  //
83
83
  // IMPORTANT (JavaScript semantics): primitives that the base dispatches to a
84
84
  // provider are declared as ordinary (non-#private) methods. `#private` methods
@@ -113,7 +113,6 @@ export class BaseWebAdapter {
113
113
  this.assistantCountBeforeSend = 0;
114
114
  this.sentUserTurn = null;
115
115
  this.lastAssistantMessageId = null;
116
- this.lastModeSelection = null;
117
116
  }
118
117
 
119
118
  // ---- provider primitives (override in subclasses) ----------------------
@@ -203,23 +202,6 @@ export class BaseWebAdapter {
203
202
  return null;
204
203
  }
205
204
 
206
- // Port consumed by runModeSelection(). Default reports no switcher, so
207
- // selectMode() resolves to "switcher_not_found" and never blocks a provider
208
- // that has no model picker.
209
- modeSelectionPort() {
210
- return {
211
- alreadyOnMode: async () => false,
212
- hasSwitcher: async () => false,
213
- openMenu: async () => {},
214
- readOptions: async () => [],
215
- clickOption: async () => false,
216
- waitClosed: async () => false,
217
- waitSelected: async () => false,
218
- closeMenu: async () => {},
219
- writeDiagnostics: async (label) => this.writeDiagnostics(label),
220
- };
221
- }
222
-
223
205
  // Best-effort composer file upload. Default: nothing attached.
224
206
  async attachFiles(_files) {
225
207
  return { attached: [], failed: [] };
@@ -521,18 +503,6 @@ export class BaseWebAdapter {
521
503
  }
522
504
  }
523
505
 
524
- async selectMode(mode) {
525
- this.requirePage();
526
- if (!mode) {
527
- return { status: "skipped", requested: mode, attempts: 0 };
528
- }
529
-
530
- const port = this.modeSelectionPort();
531
- const result = await runModeSelection(port, mode);
532
- this.lastModeSelection = result;
533
- return result;
534
- }
535
-
536
506
  async getConversationUrl() {
537
507
  this.requirePage();
538
508
  return this.page.url();
@@ -1,12 +1,6 @@
1
1
  import { BaseWebAdapter, firstVisible, hasCompleteAgentEnvelope } from "./base-web-adapter.js";
2
2
  import { BrowserAdapterError } from "../shared/errors.js";
3
3
  import { isUsageLimitNotice } from "../shared/usage-limit.js";
4
- import {
5
- chooseModeOption,
6
- labelMatchesToken,
7
- slugMatchesToken,
8
- normalizeToken,
9
- } from "./mode-selection.js";
10
4
 
11
5
  // Re-exported so existing importers (the runtime and browser-adapter tests)
12
6
  // keep importing it from here even though it now lives on the provider-agnostic
@@ -21,10 +15,10 @@ function parseConversationTurn(value) {
21
15
  }
22
16
 
23
17
  // ChatGPT-specific implementation of the WebModelAdapter contract. Only the
24
- // provider primitives (DOM locators, message identity, mode-switcher port,
25
- // upload, overlay handling, usage-limit card) live here; all orchestration —
26
- // launch/auth/send/reconnect and the turn-completion loop — is inherited from
27
- // BaseWebAdapter.
18
+ // provider primitives (DOM locators, message identity, upload, overlay
19
+ // handling, usage-limit card) live here; all orchestration — launch/auth/send/
20
+ // reconnect and the turn-completion loop — is inherited from BaseWebAdapter.
21
+ // Model choice is intentionally left to the user in the ChatGPT website.
28
22
  export class ChatGPTWebAdapter extends BaseWebAdapter {
29
23
  constructor(options = {}) {
30
24
  super({
@@ -337,211 +331,4 @@ export class ChatGPTWebAdapter extends BaseWebAdapter {
337
331
  }).catch(() => null);
338
332
  }
339
333
 
340
- // DOM port for mode-selection.js. Stable attribute slugs are preferred;
341
- // exact visible labels are a fallback for current menus that omit those
342
- // attributes. Disabled state is still read from DOM state, never from text.
343
- modeSelectionPort() {
344
- const page = this.page;
345
- return {
346
- alreadyOnMode: async (requested) => {
347
- const token = normalizeToken(requested);
348
- const switcher = await this.#findModelSwitcher();
349
- const switcherLabel = switcher
350
- ? await switcher.innerText().catch(() => "")
351
- : "";
352
- if (labelMatchesToken(switcherLabel, token)) {
353
- return true;
354
- }
355
-
356
- const messages = this.assistantMessages();
357
- if (await messages.count() === 0) {
358
- return false;
359
- }
360
- const slug = await messages.last()
361
- .getAttribute("data-message-model-slug")
362
- .catch(() => null);
363
- return Boolean(slug) && slugMatchesToken(slug, token);
364
- },
365
- hasSwitcher: async () => Boolean(await this.#findModelSwitcher()),
366
- openMenu: async (requested) => {
367
- const switcher = await this.#findModelSwitcher();
368
- if (switcher) {
369
- const opened = await switcher.click({ timeout: 5_000 })
370
- .then(() => true)
371
- .catch(() => false);
372
- if (!opened) {
373
- await switcher.click({ force: true, timeout: 5_000 })
374
- .catch(() => null);
375
- }
376
- // Wait for the Radix menu portal to attach before reading options.
377
- await page.locator('[role="menu"]:visible [role="menuitem"]:visible, [role="menu"]:visible [role="menuitemradio"]:visible')
378
- .first().waitFor({ state: "visible", timeout: 5_000 })
379
- .catch(() => null);
380
- await this.#revealNestedModeOption(requested);
381
- }
382
- },
383
- readOptions: async () => this.#readModeOptions(),
384
- clickOption: async (index) => {
385
- const option = this.#modeOptionLocators().nth(index);
386
- try {
387
- await option.click({ timeout: 5_000 });
388
- return true;
389
- } catch {
390
- return false;
391
- }
392
- },
393
- waitClosed: async () => {
394
- const ok = await page.locator('[role="menu"]').first()
395
- .waitFor({ state: "hidden", timeout: 5_000 })
396
- .then(() => true)
397
- .catch(() => false);
398
- return ok;
399
- },
400
- waitSelected: async (requested) => {
401
- const token = normalizeToken(requested);
402
- const deadline = Date.now() + 5_000;
403
- while (Date.now() < deadline) {
404
- const switcher = await this.#findModelSwitcher(500);
405
- const label = switcher
406
- ? await switcher.innerText().catch(() => "")
407
- : "";
408
- if (labelMatchesToken(label, token)) {
409
- return true;
410
- }
411
-
412
- const selectedOption = (await this.#readModeOptions()).find(
413
- (option) => option.selected
414
- && (
415
- slugMatchesToken(option.slug, token)
416
- || labelMatchesToken(option.label, token)
417
- ),
418
- );
419
- if (selectedOption) {
420
- return true;
421
- }
422
- await page.waitForTimeout(100);
423
- }
424
- return false;
425
- },
426
- closeMenu: async () => {
427
- await page.keyboard.press("Escape").catch(() => null);
428
- await page.waitForTimeout(150);
429
- },
430
- writeDiagnostics: async (label) => this.writeDiagnostics(label),
431
- };
432
- }
433
-
434
- // ---- ChatGPT-only mode-picker helpers (private) ------------------------
435
-
436
- #modeOptionLocators() {
437
- return this.page.locator(
438
- '[role="menu"]:visible [role="menuitemradio"]:visible, '
439
- + '[role="menu"]:visible [role="menuitem"]:visible',
440
- );
441
- }
442
-
443
- async #revealNestedModeOption(requested) {
444
- const present = (options) => {
445
- const choice = chooseModeOption(options, requested);
446
- return choice.status !== "unavailable";
447
- };
448
-
449
- let options = await this.#readModeOptions();
450
- if (present(options)) {
451
- return;
452
- }
453
-
454
- const submenuTriggers = this.page.locator(
455
- '[role="menu"]:visible [role="menuitem"][aria-haspopup="menu"]:visible',
456
- );
457
- const count = await submenuTriggers.count().catch(() => 0);
458
- for (let index = 0; index < count; index += 1) {
459
- const trigger = submenuTriggers.nth(index);
460
- await trigger.dispatchEvent("pointermove", { pointerType: "mouse" })
461
- .catch(() => null);
462
- await this.page.waitForTimeout(250);
463
- options = await this.#readModeOptions();
464
- if (present(options)) {
465
- return;
466
- }
467
- }
468
- }
469
-
470
- // Enumerates the open menu's options into { index, slug, label, disabled }.
471
- // `slug` is the first stable attribute found and may be empty in current
472
- // ChatGPT menus; `disabled` is read from ARIA / data-state, never from text.
473
- async #readModeOptions() {
474
- const locator = this.#modeOptionLocators();
475
- const count = await locator.count().catch(() => 0);
476
- const options = [];
477
- for (let index = 0; index < count; index += 1) {
478
- const item = locator.nth(index);
479
- const [testId, dataValue, dataTestValue, id, ariaDisabled, ariaChecked, dataDisabled, dataState, label] =
480
- await Promise.all([
481
- item.getAttribute("data-testid").catch(() => null),
482
- item.getAttribute("data-value").catch(() => null),
483
- item.getAttribute("data-test-value").catch(() => null),
484
- item.getAttribute("id").catch(() => null),
485
- item.getAttribute("aria-disabled").catch(() => null),
486
- item.getAttribute("aria-checked").catch(() => null),
487
- item.getAttribute("data-disabled").catch(() => null),
488
- item.getAttribute("data-state").catch(() => null),
489
- item.innerText().catch(() => ""),
490
- ]);
491
- const slug = [testId, dataValue, dataTestValue, id].find(Boolean) ?? "";
492
- const disabled = ariaDisabled === "true"
493
- || dataDisabled === "true"
494
- || dataDisabled === ""
495
- || dataState === "disabled"
496
- || !await item.isEnabled().catch(() => true);
497
- const selected = ariaChecked === "true" || dataState === "checked";
498
- options.push({
499
- index,
500
- slug,
501
- label: (label ?? "").trim(),
502
- disabled,
503
- selected,
504
- });
505
- }
506
- return options;
507
- }
508
-
509
- async #findModelSwitcher(timeoutMs = 45_000) {
510
- const deadline = Date.now() + timeoutMs;
511
-
512
- while (Date.now() < deadline) {
513
- const direct = await firstVisible([
514
- this.page.locator('[data-testid="model-switcher-dropdown-button"]'),
515
- this.page.locator(
516
- 'main button.__composer-pill[aria-haspopup="menu"]',
517
- ),
518
- this.page.locator('button[aria-label*="model" i]'),
519
- this.page.locator('button[aria-label*="模式"]'),
520
- ]);
521
- if (direct) {
522
- return direct;
523
- }
524
-
525
- const menuButtons = this.page.locator(
526
- 'main button[aria-haspopup="menu"]:not([data-testid^="history-item-"])',
527
- );
528
- const count = await menuButtons.count();
529
- for (let index = 0; index < count; index += 1) {
530
- const button = menuButtons.nth(index);
531
- if (!await button.isVisible().catch(() => false)) continue;
532
- const text = (await button.innerText().catch(() => "")).trim();
533
- const label = await button.getAttribute("aria-label").catch(() => "");
534
- if (
535
- /^(chatgpt|gpt(?:-[\w.]+)?|auto|instant|thinking|pro)$/i.test(text)
536
- || /model|模型|模式/i.test(label ?? "")
537
- ) {
538
- return button;
539
- }
540
- }
541
-
542
- await this.page.waitForTimeout(250);
543
- }
544
-
545
- return null;
546
- }
547
334
  }
@@ -34,112 +34,6 @@ export class DeepSeekWebAdapter extends BaseWebAdapter {
34
34
  return /^\/a\/chat\/s\//;
35
35
  }
36
36
 
37
- // DeepSeek has no ChatGPT-style model dropdown; instead a new conversation
38
- // exposes mode chips (快速/专家/识图) and toggles (深度思考/智能搜索). The
39
- // registry's defaultMode ("expert-thinking") asks for 专家模式 (Expert) +
40
- // 深度思考 (Deep Thinking) — WTAgent's preferred DeepSeek setup, applied
41
- // silently on every fresh conversation (no interactive picker). Any other
42
- // requested value keeps the site's current setting.
43
- //
44
- // Selection is LANGUAGE-INDEPENDENT and idempotent:
45
- // - the chips are role="radio" in a fixed order (fast, expert, image), so
46
- // the expert chip is the second radio; visible labels are only used to
47
- // double-check the position when they match a locale we know
48
- // - after expert is active DeepSeek shows exactly ONE .ds-toggle-button
49
- // (deep thinking); fast mode shows two (deep thinking + web search), so
50
- // the toggle count itself proves the chip switch landed. Clicking the
51
- // single remaining toggle needs no label at all.
52
- // Selection is best-effort — a UI change never aborts the run; it reports
53
- // "unresolved" and keeps the current mode, like runModeSelection.
54
- async selectMode(mode) {
55
- this.requirePage();
56
- if (mode !== "expert-thinking") {
57
- return { status: "skipped", requested: mode, attempts: 0 };
58
- }
59
-
60
- const steps = [];
61
- const chip = await this.#findExpertChip();
62
- if (!chip) {
63
- steps.push({ ok: false, label: "expert-chip" });
64
- } else {
65
- steps.push(await this.#ensureChipChecked(chip, "expert-chip"));
66
- if (steps[0].ok) {
67
- steps.push(await this.#ensureSingleThinkingToggle());
68
- }
69
- }
70
-
71
- const failed = steps.filter((s) => !s.ok);
72
- if (failed.length > 0) {
73
- await this.writeDiagnostics("deepseek-mode-partial");
74
- return {
75
- status: "unresolved",
76
- requested: mode,
77
- selectedLabel: "expert + deep-thinking",
78
- attempts: 1,
79
- reason: `Could not confirm: ${failed.map((s) => s.label).join(", ")}.`,
80
- };
81
- }
82
- return {
83
- status: "select",
84
- requested: mode,
85
- selectedLabel: "expert + deep-thinking",
86
- attempts: 1,
87
- reason: "Selected expert mode with deep thinking.",
88
- };
89
- }
90
-
91
- // Locates the expert chip without relying on its locale: try known labels
92
- // first, then fall back to the fixed chip order (fast, expert, image).
93
- async #findExpertChip() {
94
- const radios = this.page.locator('[role="radio"]');
95
- const count = await radios.count().catch(() => 0);
96
- if (count === 0) {
97
- return null;
98
- }
99
- const labelPattern = /专家|expert/i;
100
- for (let index = 0; index < count; index += 1) {
101
- const text = (await radios.nth(index).innerText().catch(() => "")).trim();
102
- if (labelPattern.test(text)) {
103
- return radios.nth(index);
104
- }
105
- }
106
- // Positional fallback: 快速/专家/识图 — the expert chip is the 2nd radio.
107
- return count >= 2 ? radios.nth(1) : null;
108
- }
109
-
110
- // Clicks a chip unless it is already aria-checked. Returns { ok, label }.
111
- async #ensureChipChecked(chip, label) {
112
- if (await chip.getAttribute("aria-checked").catch(() => null) === "true") {
113
- return { ok: true, label };
114
- }
115
- await chip.click({ timeout: 5_000 }).catch(() => null);
116
- await this.page.waitForTimeout(400);
117
- const checked = await chip.getAttribute("aria-checked").catch(() => null);
118
- return { ok: checked === "true", label };
119
- }
120
-
121
- // In expert mode exactly ONE toggle (deep thinking) exists; in fast mode
122
- // there are two. Clicking the single remaining toggle therefore never needs
123
- // a label. The toggle count also verifies the chip switch actually landed.
124
- async #ensureSingleThinkingToggle() {
125
- const toggles = this.page.locator(".ds-toggle-button");
126
- const count = await toggles.count().catch(() => 0);
127
- if (count !== 1) {
128
- return { ok: false, label: "deep-thinking-toggle" };
129
- }
130
- const toggle = toggles.nth(0);
131
- const isSelected = async () => (
132
- (await toggle.getAttribute("class").catch(() => "") ?? "")
133
- .includes("ds-toggle-button--selected")
134
- );
135
- if (await isSelected()) {
136
- return { ok: true, label: "deep-thinking-toggle" };
137
- }
138
- await toggle.click({ timeout: 5_000 }).catch(() => null);
139
- await this.page.waitForTimeout(400);
140
- return { ok: await isSelected(), label: "deep-thinking-toggle" };
141
- }
142
-
143
37
  composerLocators() {
144
38
  return [
145
39
  this.page.locator('textarea[name="search"]'),
@@ -5,11 +5,6 @@ export { isConnectionLostError } from "./base-web-adapter.js";
5
5
 
6
6
  const GLM_URL = "https://chat.z.ai/";
7
7
 
8
- // Preferred models, newest first: try GLM-5.3 when present, else GLM-5.2. The
9
- // site sometimes exposes 5.3 and sometimes only 5.2, so selection walks this
10
- // list and clicks the first one present in the menu.
11
- const PREFERRED_MODELS = ["GLM-5.3", "GLM-5.2"];
12
-
13
8
  // GLM / Z.ai (chat.z.ai) adapter.
14
9
  //
15
10
  // chat.z.ai is an Open WebUI (Svelte) frontend, verified against the live app:
@@ -21,8 +16,8 @@ const PREFERRED_MODELS = ["GLM-5.3", "GLM-5.2"];
21
16
  // - assistant answer markdown is `.markdown-prose` / `.prose`; a "思考过程"
22
17
  // (deep-thinking) block may precede it and is excluded when reading the reply
23
18
  // - a live conversation URL is /c/<uuid>
24
- // - model switcher: `button.modelSelectorButton`; the registry defaultMode
25
- // "latest" picks the newest available model (GLM-5.3, else GLM-5.2)
19
+ // - model choice is intentionally left to the user on chat.z.ai; WTAgent does
20
+ // not inspect or override the site's current model selection
26
21
  // - Cloudflare guards the site; the base throwIfBlockedPage surfaces the
27
22
  // window so the user can pass the check (wtagent's own CDP launch is not
28
23
  // fingerprinted the way headless automation is)
@@ -161,86 +156,4 @@ export class GLMWebAdapter extends BaseWebAdapter {
161
156
  return 120;
162
157
  }
163
158
 
164
- // Selects the newest available model. The registry's defaultMode "latest" maps
165
- // to PREFERRED_MODELS (GLM-5.3, else GLM-5.2). Best-effort and non-throwing.
166
- //
167
- // The switcher is `button.modelSelectorButton`; opening it lists options whose
168
- // visible text is the exact model name. After clicking, the switcher label
169
- // becomes the selected model name.
170
- async selectMode(mode) {
171
- this.requirePage();
172
- if (mode !== "latest") {
173
- return { status: "skipped", requested: mode, attempts: 0 };
174
- }
175
-
176
- const switcher = this.page.locator("button.modelSelectorButton").first();
177
- await switcher.waitFor({ state: "visible", timeout: 10_000 }).catch(() => null);
178
- if (await switcher.count().catch(() => 0) === 0) {
179
- await this.writeDiagnostics("glm-model-switcher-not-found");
180
- return {
181
- status: "switcher_not_found",
182
- requested: mode,
183
- attempts: 0,
184
- reason: "Model switcher was not found.",
185
- };
186
- }
187
-
188
- const current = (await switcher.innerText().catch(() => "")).trim();
189
- // Already on the most-preferred model that exists? If the current label is
190
- // the first preferred model, nothing to do.
191
- if (current.startsWith(PREFERRED_MODELS[0])) {
192
- return {
193
- status: "already",
194
- requested: mode,
195
- selectedLabel: PREFERRED_MODELS[0],
196
- attempts: 0,
197
- reason: `Already using ${PREFERRED_MODELS[0]}.`,
198
- };
199
- }
200
-
201
- for (const model of PREFERRED_MODELS) {
202
- await switcher.click({ timeout: 5_000 }).catch(() => null);
203
- await this.page.waitForTimeout(600);
204
- const option = this.page.getByText(model, { exact: true }).first();
205
- if (await option.count().catch(() => 0) === 0) {
206
- // Not in the menu; close and try the next preferred model.
207
- await this.page.keyboard.press("Escape").catch(() => null);
208
- continue;
209
- }
210
- await option.click({ timeout: 5_000 }).catch(() => null);
211
- await this.page.waitForTimeout(600);
212
- const after = (await switcher.innerText().catch(() => "")).trim();
213
- if (after.startsWith(model)) {
214
- // The model menu stays open after a selection; a click in the page
215
- // center dismisses it so it does not cover the composer.
216
- await this.#dismissModelMenu();
217
- return {
218
- status: current.startsWith(model) ? "already" : "select",
219
- requested: mode,
220
- selectedLabel: model,
221
- attempts: 1,
222
- reason: `Selected ${model}.`,
223
- };
224
- }
225
- }
226
-
227
- await this.#dismissModelMenu();
228
- await this.writeDiagnostics("glm-mode-latest-unresolved");
229
- return {
230
- status: "unresolved",
231
- requested: mode,
232
- attempts: 1,
233
- reason: `Could not select any of: ${PREFERRED_MODELS.join(", ")}.`,
234
- };
235
- }
236
-
237
- async #dismissModelMenu() {
238
- const viewport = this.page.viewportSize?.() ?? { width: 1280, height: 800 };
239
- await this.page.mouse.click(
240
- Math.floor(viewport.width / 2),
241
- Math.floor(viewport.height / 2),
242
- ).catch(() => null);
243
- await this.page.keyboard.press("Escape").catch(() => null);
244
- await this.page.waitForTimeout(200);
245
- }
246
159
  }