codex-chatgpt-control 0.3.0-alpha.1 → 0.5.1-alpha.1

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.
Files changed (224) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +42 -2
  3. package/contracts/v1/fixtures/backend-capabilities.json +10 -0
  4. package/contracts/v1/fixtures/command-descriptors.json +265 -8
  5. package/contracts/v1/fixtures/describe-runner-run.json +5 -2
  6. package/contracts/v1/fixtures/doctor-scenario-preflight.json +34 -2
  7. package/contracts/v1/fixtures/help-root.json +1 -1
  8. package/contracts/v1/fixtures/output-json-parse-success.json +9 -0
  9. package/contracts/v1/fixtures/run-timeout-partial.json +15 -4
  10. package/contracts/v1/fixtures/stream-in-progress.ndjson +3 -0
  11. package/contracts/v1/fixtures/stream-submitted-completed.ndjson +1 -1
  12. package/contracts/v1/fixtures/surface-chat-legacy.json +35 -0
  13. package/contracts/v1/fixtures/surface-chat-simplified.json +37 -0
  14. package/contracts/v1/fixtures/surface-sidebar-false-positive.json +29 -0
  15. package/contracts/v1/fixtures/surface-work-advanced.json +45 -0
  16. package/contracts/v1/fixtures/surface-work-basic.json +38 -0
  17. package/contracts/v1/fixtures/workflow-ask-success.json +8 -0
  18. package/contracts/v1/manifest.json +32 -1
  19. package/contracts/v1/parity-suite.json +124 -0
  20. package/contracts/v1/schemas/backend-request.schema.json +15 -0
  21. package/contracts/v1/schemas/manifest.schema.json +6 -3
  22. package/contracts/v1/schemas/surface-profile.schema.json +166 -0
  23. package/dist/codex-chatgpt-control-backend.mjs +3475 -367
  24. package/dist/codex-chatgpt-control.bundle.mjs +5249 -2102
  25. package/dist/src/backend/client.d.ts +26 -1
  26. package/dist/src/backend/client.js +23 -1
  27. package/dist/src/backend/protocol.d.ts +1 -1
  28. package/dist/src/backend/protocol.js +10 -0
  29. package/dist/src/backend/session.js +20 -0
  30. package/dist/src/browser/attach.d.ts +1 -0
  31. package/dist/src/browser/attach.js +3 -3
  32. package/dist/src/browser/downloads.d.ts +1 -1
  33. package/dist/src/browser/downloads.js +13 -4
  34. package/dist/src/browser/page-state.js +5 -2
  35. package/dist/src/client.d.ts +31 -1
  36. package/dist/src/client.js +69 -14
  37. package/dist/src/commands/artifacts.js +1 -7
  38. package/dist/src/commands/configuration.d.ts +15 -0
  39. package/dist/src/commands/configuration.js +897 -0
  40. package/dist/src/commands/doctor.js +38 -1
  41. package/dist/src/commands/experience.d.ts +13 -0
  42. package/dist/src/commands/experience.js +421 -0
  43. package/dist/src/commands/files.js +160 -10
  44. package/dist/src/commands/messages.d.ts +2 -1
  45. package/dist/src/commands/messages.js +138 -16
  46. package/dist/src/commands/modes.js +28 -14
  47. package/dist/src/commands/registry.js +74 -7
  48. package/dist/src/commands/response-actions.js +1 -7
  49. package/dist/src/commands/sequence.js +24 -1
  50. package/dist/src/commands/session.d.ts +2 -0
  51. package/dist/src/commands/session.js +50 -1
  52. package/dist/src/commands/threads.js +1 -7
  53. package/dist/src/commands/work.d.ts +6 -0
  54. package/dist/src/commands/work.js +338 -0
  55. package/dist/src/dom/locale/am.d.ts +15 -1
  56. package/dist/src/dom/locale/am.js +15 -1
  57. package/dist/src/dom/locale/ar.d.ts +15 -1
  58. package/dist/src/dom/locale/ar.js +18 -4
  59. package/dist/src/dom/locale/bg.d.ts +15 -1
  60. package/dist/src/dom/locale/bg.js +18 -4
  61. package/dist/src/dom/locale/bn.d.ts +15 -1
  62. package/dist/src/dom/locale/bn.js +15 -1
  63. package/dist/src/dom/locale/bs.d.ts +14 -1
  64. package/dist/src/dom/locale/bs.js +18 -5
  65. package/dist/src/dom/locale/ca.d.ts +14 -1
  66. package/dist/src/dom/locale/ca.js +19 -6
  67. package/dist/src/dom/locale/cs.d.ts +14 -1
  68. package/dist/src/dom/locale/cs.js +14 -1
  69. package/dist/src/dom/locale/da.d.ts +14 -1
  70. package/dist/src/dom/locale/da.js +15 -2
  71. package/dist/src/dom/locale/de.d.ts +16 -3
  72. package/dist/src/dom/locale/de.js +18 -5
  73. package/dist/src/dom/locale/el.d.ts +15 -1
  74. package/dist/src/dom/locale/el.js +18 -4
  75. package/dist/src/dom/locale/en.d.ts +27 -2
  76. package/dist/src/dom/locale/en.js +28 -2
  77. package/dist/src/dom/locale/es-419.d.ts +15 -1
  78. package/dist/src/dom/locale/es-419.js +15 -1
  79. package/dist/src/dom/locale/es-ES.d.ts +15 -1
  80. package/dist/src/dom/locale/es-ES.js +15 -1
  81. package/dist/src/dom/locale/et.d.ts +14 -1
  82. package/dist/src/dom/locale/et.js +14 -1
  83. package/dist/src/dom/locale/fa.d.ts +14 -1
  84. package/dist/src/dom/locale/fa.js +16 -3
  85. package/dist/src/dom/locale/fi.d.ts +15 -1
  86. package/dist/src/dom/locale/fi.js +15 -1
  87. package/dist/src/dom/locale/fr-CA.d.ts +13 -1
  88. package/dist/src/dom/locale/fr-CA.js +13 -1
  89. package/dist/src/dom/locale/fr-FR.d.ts +16 -3
  90. package/dist/src/dom/locale/fr-FR.js +20 -7
  91. package/dist/src/dom/locale/gu.d.ts +15 -1
  92. package/dist/src/dom/locale/gu.js +19 -5
  93. package/dist/src/dom/locale/hi.d.ts +15 -1
  94. package/dist/src/dom/locale/hi.js +17 -3
  95. package/dist/src/dom/locale/hr.d.ts +14 -1
  96. package/dist/src/dom/locale/hr.js +17 -4
  97. package/dist/src/dom/locale/hu.d.ts +15 -1
  98. package/dist/src/dom/locale/hu.js +18 -4
  99. package/dist/src/dom/locale/hy.d.ts +15 -1
  100. package/dist/src/dom/locale/hy.js +18 -4
  101. package/dist/src/dom/locale/id.d.ts +14 -1
  102. package/dist/src/dom/locale/id.js +16 -3
  103. package/dist/src/dom/locale/index.d.ts +17 -2
  104. package/dist/src/dom/locale/index.js +65 -0
  105. package/dist/src/dom/locale/is.d.ts +14 -1
  106. package/dist/src/dom/locale/is.js +14 -1
  107. package/dist/src/dom/locale/it.d.ts +13 -1
  108. package/dist/src/dom/locale/it.js +17 -5
  109. package/dist/src/dom/locale/ja.d.ts +15 -1
  110. package/dist/src/dom/locale/ja.js +19 -5
  111. package/dist/src/dom/locale/ka.d.ts +15 -1
  112. package/dist/src/dom/locale/ka.js +15 -1
  113. package/dist/src/dom/locale/kk.d.ts +15 -1
  114. package/dist/src/dom/locale/kk.js +17 -3
  115. package/dist/src/dom/locale/kn.d.ts +15 -1
  116. package/dist/src/dom/locale/kn.js +19 -5
  117. package/dist/src/dom/locale/ko.d.ts +13 -1
  118. package/dist/src/dom/locale/ko.js +13 -1
  119. package/dist/src/dom/locale/lt.d.ts +15 -1
  120. package/dist/src/dom/locale/lt.js +17 -3
  121. package/dist/src/dom/locale/lv.d.ts +15 -1
  122. package/dist/src/dom/locale/lv.js +15 -1
  123. package/dist/src/dom/locale/mk.d.ts +15 -1
  124. package/dist/src/dom/locale/mk.js +19 -5
  125. package/dist/src/dom/locale/ml.d.ts +15 -1
  126. package/dist/src/dom/locale/ml.js +17 -3
  127. package/dist/src/dom/locale/mn.d.ts +15 -1
  128. package/dist/src/dom/locale/mn.js +15 -1
  129. package/dist/src/dom/locale/mr.d.ts +15 -1
  130. package/dist/src/dom/locale/mr.js +17 -3
  131. package/dist/src/dom/locale/ms.d.ts +13 -1
  132. package/dist/src/dom/locale/ms.js +13 -1
  133. package/dist/src/dom/locale/my.d.ts +15 -1
  134. package/dist/src/dom/locale/my.js +17 -3
  135. package/dist/src/dom/locale/nb.d.ts +14 -1
  136. package/dist/src/dom/locale/nb.js +14 -1
  137. package/dist/src/dom/locale/nl.d.ts +14 -1
  138. package/dist/src/dom/locale/nl.js +16 -3
  139. package/dist/src/dom/locale/pa.d.ts +15 -1
  140. package/dist/src/dom/locale/pa.js +19 -5
  141. package/dist/src/dom/locale/pl.d.ts +14 -1
  142. package/dist/src/dom/locale/pl.js +16 -3
  143. package/dist/src/dom/locale/pt-BR.d.ts +15 -1
  144. package/dist/src/dom/locale/pt-BR.js +17 -3
  145. package/dist/src/dom/locale/pt-PT.d.ts +15 -1
  146. package/dist/src/dom/locale/pt-PT.js +19 -5
  147. package/dist/src/dom/locale/ro.d.ts +13 -1
  148. package/dist/src/dom/locale/ro.js +16 -4
  149. package/dist/src/dom/locale/ru.d.ts +17 -1
  150. package/dist/src/dom/locale/ru.js +18 -2
  151. package/dist/src/dom/locale/sk.d.ts +14 -1
  152. package/dist/src/dom/locale/sk.js +16 -3
  153. package/dist/src/dom/locale/sl.d.ts +14 -1
  154. package/dist/src/dom/locale/sl.js +17 -4
  155. package/dist/src/dom/locale/so.d.ts +15 -1
  156. package/dist/src/dom/locale/so.js +17 -3
  157. package/dist/src/dom/locale/sq.d.ts +13 -1
  158. package/dist/src/dom/locale/sq.js +17 -5
  159. package/dist/src/dom/locale/sr.d.ts +17 -1
  160. package/dist/src/dom/locale/sr.js +18 -2
  161. package/dist/src/dom/locale/sv.d.ts +14 -1
  162. package/dist/src/dom/locale/sv.js +14 -1
  163. package/dist/src/dom/locale/sw.d.ts +15 -1
  164. package/dist/src/dom/locale/sw.js +15 -1
  165. package/dist/src/dom/locale/ta.d.ts +15 -1
  166. package/dist/src/dom/locale/ta.js +19 -5
  167. package/dist/src/dom/locale/te.d.ts +15 -1
  168. package/dist/src/dom/locale/te.js +19 -5
  169. package/dist/src/dom/locale/th.d.ts +7 -1
  170. package/dist/src/dom/locale/th.js +7 -1
  171. package/dist/src/dom/locale/tl.d.ts +11 -5
  172. package/dist/src/dom/locale/tl.js +11 -5
  173. package/dist/src/dom/locale/tr.d.ts +14 -1
  174. package/dist/src/dom/locale/tr.js +16 -3
  175. package/dist/src/dom/locale/types.d.ts +18 -1
  176. package/dist/src/dom/locale/uk.d.ts +15 -1
  177. package/dist/src/dom/locale/uk.js +17 -3
  178. package/dist/src/dom/locale/ur.d.ts +15 -1
  179. package/dist/src/dom/locale/ur.js +18 -4
  180. package/dist/src/dom/locale/vi.d.ts +15 -1
  181. package/dist/src/dom/locale/vi.js +18 -4
  182. package/dist/src/dom/locale/zh-HK.d.ts +15 -1
  183. package/dist/src/dom/locale/zh-HK.js +17 -3
  184. package/dist/src/dom/locale/zh-Hans.d.ts +15 -1
  185. package/dist/src/dom/locale/zh-Hans.js +19 -5
  186. package/dist/src/dom/locale/zh-TW.d.ts +15 -1
  187. package/dist/src/dom/locale/zh-TW.js +17 -3
  188. package/dist/src/dom/menus.js +26 -2
  189. package/dist/src/dom/selectors.js +6 -1
  190. package/dist/src/index.d.ts +3 -0
  191. package/dist/src/index.js +3 -0
  192. package/dist/src/platform/local-paths.d.ts +1 -0
  193. package/dist/src/platform/local-paths.js +7 -3
  194. package/dist/src/runner/responses.d.ts +3 -1
  195. package/dist/src/runner/responses.js +16 -1
  196. package/dist/src/runner/result.js +129 -4
  197. package/dist/src/runner/stream.d.ts +1 -1
  198. package/dist/src/runner/stream.js +6 -0
  199. package/dist/src/runner/types.d.ts +35 -1
  200. package/dist/src/safety/risk.d.ts +10 -0
  201. package/dist/src/safety/risk.js +10 -0
  202. package/dist/src/scripts/apply-intelligence-locale-captures.d.ts +15 -0
  203. package/dist/src/scripts/apply-intelligence-locale-captures.js +225 -7
  204. package/dist/src/scripts/capture-intelligence-locales.d.ts +8 -0
  205. package/dist/src/scripts/capture-intelligence-locales.js +663 -6
  206. package/dist/src/scripts/capture-surface-profile.d.ts +25 -0
  207. package/dist/src/scripts/capture-surface-profile.js +354 -0
  208. package/dist/src/scripts/live-smoke/scenarios.d.ts +2 -0
  209. package/dist/src/scripts/live-smoke/scenarios.js +347 -5
  210. package/dist/src/scripts/live-smoke.js +2 -0
  211. package/dist/src/scripts/locale-capture/surface-graph.d.ts +26 -0
  212. package/dist/src/scripts/locale-capture/surface-graph.js +43 -0
  213. package/dist/src/scripts/release-canary-module.d.ts +20 -0
  214. package/dist/src/scripts/release-canary-module.js +86 -0
  215. package/dist/src/types.d.ts +248 -2
  216. package/package.json +9 -3
  217. package/references/2026-07-16-chat-work-surfaces.md +100 -0
  218. package/references/agents-runner.md +13 -2
  219. package/references/backend-protocol.md +26 -9
  220. package/references/language-coverage.md +51 -3
  221. package/references/localization.md +52 -6
  222. package/references/python-parity.md +12 -2
  223. package/references/responses-adapter.md +12 -1
  224. package/references/troubleshooting.md +38 -4
@@ -7,10 +7,10 @@ import { waitForDownloadFromClick } from "../browser/downloads.js";
7
7
  import { resultError, resultOk } from "../errors.js";
8
8
  import { addFilesButton, cssSelectors, requiredLocator } from "../dom/selectors.js";
9
9
  import { localeLabels } from "../dom/locale-labels.js";
10
- import { basenameForHostPath, isHostAbsolutePath, resolveForHostPath } from "../platform/local-paths.js";
10
+ import { basenameForHostPath, currentHostPathPlatform, isHostAbsolutePath, resolveForHostPath } from "../platform/local-paths.js";
11
11
  import { contextFromPage } from "./context.js";
12
- import { bootstrap } from "./session.js";
13
- import { localGuardTimeout } from "./timeouts.js";
12
+ import { ensurePage } from "./session.js";
13
+ import { localGuardTimeout, withTimeout } from "./timeouts.js";
14
14
  const CODEX_UPLOAD_PERMISSION_FIX = "Codex Settings > Computer Use > Chrome > Permissions > Uploads: set to Always allow, or add chatgpt.com to the allowed upload domains.";
15
15
  const CHROME_FILE_URL_PERMISSION_FIX = "Chrome chrome://extensions > Codex extension > Details: enable Allow access to file URLs.";
16
16
  const DEFAULT_MAX_BYTES_PER_FILE = 512 * 1024 * 1024;
@@ -251,7 +251,7 @@ async function fileMetadata(absolute, bytes, includeHash = false) {
251
251
  return metadata;
252
252
  }
253
253
  function extensionForHostPath(value) {
254
- return process.platform === "win32"
254
+ return currentHostPathPlatform() === "win32"
255
255
  ? path.win32.extname(value).toLowerCase()
256
256
  : path.posix.extname(value).toLowerCase();
257
257
  }
@@ -526,6 +526,24 @@ export async function downloadLatestFile(env, args) {
526
526
  }
527
527
  const page = env.page;
528
528
  try {
529
+ const generatedFileDownload = await tryGeneratedFilePreviewDownload(page, args);
530
+ if (generatedFileDownload !== undefined) {
531
+ return generatedFileDownload;
532
+ }
533
+ if (args.filenamePattern !== undefined) {
534
+ return {
535
+ ok: false,
536
+ status: "unsupported",
537
+ warnings: [],
538
+ blocker: {
539
+ kind: "download_unavailable",
540
+ code: "download_filename_not_found",
541
+ message: `No visible ChatGPT file affordance matched filenamePattern ${JSON.stringify(args.filenamePattern)}.`,
542
+ resumable: true
543
+ },
544
+ context: await contextFromPage(page)
545
+ };
546
+ }
529
547
  const controls = requiredLocator(page, cssSelectors.downloadControls);
530
548
  let count;
531
549
  try {
@@ -571,6 +589,144 @@ export async function downloadLatestFile(env, args) {
571
589
  return resultError(error instanceof Error ? error : new Error(String(error)), await contextFromPage(page));
572
590
  }
573
591
  }
592
+ async function tryGeneratedFilePreviewDownload(page, args) {
593
+ const timeoutMs = args.timeoutMs ?? 120000;
594
+ const candidates = await inspectGeneratedFileAffordances(page, localGuardTimeout(timeoutMs, 5000));
595
+ const selected = selectGeneratedFileAffordance(candidates, args);
596
+ if (selected === undefined)
597
+ return undefined;
598
+ try {
599
+ const assistantMessages = requiredLocator(page, cssSelectors.assistantMessages);
600
+ const assistantCount = await locatorCountWithTimeout(assistantMessages, localGuardTimeout(timeoutMs, 5000), "generated_file_assistant_count_timeout");
601
+ if (selected.assistantIndex < 0 || selected.assistantIndex >= assistantCount) {
602
+ throw new Error("The selected generated-file assistant turn is no longer present.");
603
+ }
604
+ const assistant = assistantMessages.nth?.(selected.assistantIndex) ?? assistantMessages;
605
+ const role = selected.tag === "button" ? "button" : "link";
606
+ const affordance = assistant.getByRole?.(role, { name: selected.filename, exact: true })
607
+ ?? assistant.locator?.(`${selected.tag}[aria-label="${escapeCssAttribute(selected.filename)}"]`);
608
+ const affordanceCount = await locatorCountWithTimeout(affordance, localGuardTimeout(timeoutMs, 5000), "generated_file_affordance_count_timeout");
609
+ if (affordance === undefined || affordanceCount !== 1 || typeof affordance.click !== "function") {
610
+ throw new Error(`Expected one clickable generated-file affordance for ${selected.filename}, found ${affordanceCount}.`);
611
+ }
612
+ if (selected.tag === "a") {
613
+ const downloaded = await waitForDownloadFromClick(page, () => affordance.click({ timeoutMs: localGuardTimeout(timeoutMs, 10000) }), args.destDir, timeoutMs, selected.filename);
614
+ return resultOk(downloaded, await contextFromPage(page));
615
+ }
616
+ await affordance.click({ timeoutMs: localGuardTimeout(timeoutMs, 10000) });
617
+ const preview = requiredLocator(page, `section[aria-label="${escapeCssAttribute(selected.filename)}"]`);
618
+ const download = await waitForPreviewDownloadControl(page, preview, timeoutMs);
619
+ if (download === undefined) {
620
+ throw new Error(`The artifact preview for ${selected.filename} did not expose a visible Download control.`);
621
+ }
622
+ const downloaded = await waitForDownloadFromClick(page, async () => download.click?.({ timeoutMs: localGuardTimeout(timeoutMs, 10000) }), args.destDir, timeoutMs, selected.filename);
623
+ return resultOk(downloaded, await contextFromPage(page));
624
+ }
625
+ catch (error) {
626
+ return resultError(error instanceof Error ? error : new Error(String(error)), await contextFromPage(page));
627
+ }
628
+ }
629
+ async function inspectGeneratedFileAffordances(page, timeoutMs) {
630
+ if (typeof page.evaluate === "function") {
631
+ const fromDom = await withTimeout(page.evaluate(() => {
632
+ const visible = (element) => {
633
+ let current = element;
634
+ while (current !== null) {
635
+ const html = current;
636
+ const style = window.getComputedStyle(html);
637
+ const rect = html.getBoundingClientRect();
638
+ if (style.display === "none" || style.visibility === "hidden" || Number(style.opacity || "1") <= 0)
639
+ return false;
640
+ if (current === element && (rect.width <= 0 || rect.height <= 0))
641
+ return false;
642
+ current = current.parentElement;
643
+ }
644
+ return true;
645
+ };
646
+ const fileLike = (value) => /^[^\\/\r\n]{1,255}\.[a-z0-9][a-z0-9._-]{0,15}$/i.test(value);
647
+ const assistants = Array.from(document.querySelectorAll("[data-message-author-role='assistant']"));
648
+ return assistants.flatMap((assistant, assistantIndex) => Array.from(assistant.querySelectorAll("button[aria-label], a[download], a[href*='/backend-api/files/']"))
649
+ .filter(visible)
650
+ .map(element => ({
651
+ assistantIndex,
652
+ filename: (element.getAttribute("aria-label") ?? element.textContent ?? "").trim(),
653
+ tag: element.tagName.toLocaleLowerCase(),
654
+ text: (element.textContent ?? "").trim()
655
+ }))
656
+ .filter(item => (item.tag === "button" || item.tag === "a") && fileLike(item.filename) && item.filename === item.text)
657
+ .map(({ assistantIndex, filename, tag }) => ({ assistantIndex, filename, tag })));
658
+ }), timeoutMs, "Timed out while inspecting generated-file buttons.").catch(() => undefined);
659
+ if (Array.isArray(fromDom))
660
+ return fromDom;
661
+ }
662
+ if (typeof page.content !== "function")
663
+ return [];
664
+ const html = await withTimeout(page.content(), timeoutMs, "Timed out while reading generated-file button markup.").catch(() => "");
665
+ const candidates = [];
666
+ const buttonPattern = /<(button|a)\b[^>]*\baria-label=(['"])(.*?)\2[^>]*>([\s\S]*?)<\/\1>/gi;
667
+ let match;
668
+ while ((match = buttonPattern.exec(html)) !== null) {
669
+ const filename = decodeBasicHtml(match[3] ?? "").trim();
670
+ const text = decodeBasicHtml((match[4] ?? "").replace(/<[^>]+>/g, " ")).replace(/\s+/g, " ").trim();
671
+ if (/^[^\\/\r\n]{1,255}\.[a-z0-9][a-z0-9._-]{0,15}$/i.test(filename) && filename === text) {
672
+ candidates.push({ assistantIndex: 0, filename, tag: (match[1] ?? "button").toLocaleLowerCase() });
673
+ }
674
+ }
675
+ return candidates;
676
+ }
677
+ function selectGeneratedFileAffordance(candidates, args) {
678
+ let scoped = candidates;
679
+ const from = args.from;
680
+ if (typeof from === "object" && from !== null) {
681
+ scoped = scoped.filter(candidate => candidate.assistantIndex === from.assistantIndex);
682
+ }
683
+ else if (from !== "visible_conversation") {
684
+ const latestAssistant = Math.max(-1, ...scoped.map(candidate => candidate.assistantIndex));
685
+ scoped = scoped.filter(candidate => candidate.assistantIndex === latestAssistant);
686
+ }
687
+ if (args.filenamePattern !== undefined) {
688
+ scoped = scoped.filter(candidate => filenameMatches(candidate.filename, args.filenamePattern));
689
+ }
690
+ return scoped.at(-1);
691
+ }
692
+ function filenameMatches(filename, pattern) {
693
+ try {
694
+ return new RegExp(pattern, "i").test(filename);
695
+ }
696
+ catch {
697
+ return filename.toLocaleLowerCase().includes(pattern.toLocaleLowerCase());
698
+ }
699
+ }
700
+ async function waitForPreviewDownloadControl(page, preview, timeoutMs) {
701
+ const deadline = Date.now() + Math.min(timeoutMs, 15000);
702
+ while (Date.now() < deadline) {
703
+ for (const label of localeLabels.download) {
704
+ const control = preview.getByRole?.("button", { name: label, exact: true })
705
+ ?? preview.locator?.(`button[aria-label="${escapeCssAttribute(label)}"]`);
706
+ if (await locatorCountWithTimeout(control, localGuardTimeout(timeoutMs, 2000), "artifact_preview_download_count_timeout") === 1) {
707
+ return control;
708
+ }
709
+ }
710
+ if (typeof page.waitForTimeout === "function") {
711
+ await page.waitForTimeout(100);
712
+ }
713
+ else {
714
+ await new Promise(resolve => setTimeout(resolve, 100));
715
+ }
716
+ }
717
+ return undefined;
718
+ }
719
+ function escapeCssAttribute(value) {
720
+ return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/[\r\n]/g, " ");
721
+ }
722
+ function decodeBasicHtml(value) {
723
+ return value
724
+ .replace(/&quot;/gi, '"')
725
+ .replace(/&#39;|&apos;/gi, "'")
726
+ .replace(/&lt;/gi, "<")
727
+ .replace(/&gt;/gi, ">")
728
+ .replace(/&amp;/gi, "&");
729
+ }
574
730
  async function setHiddenFileInput(page, files) {
575
731
  if (page === undefined) {
576
732
  throw new Error("No active page is available for file upload.");
@@ -610,12 +766,6 @@ async function readBrowserInputDiagnostic(page) {
610
766
  };
611
767
  });
612
768
  }
613
- async function ensurePage(env) {
614
- if (env.page !== undefined) {
615
- return resultOk({}, await contextFromPage(env.page));
616
- }
617
- return bootstrap(env, { preferExistingTab: true });
618
- }
619
769
  async function setFilesViaDomDataTransfer(page, files) {
620
770
  const totalBytes = files.reduce((sum, file) => sum + file.bytes, 0);
621
771
  const maxInlineBytes = 25 * 1024 * 1024;
@@ -1,5 +1,5 @@
1
1
  import { type AssistantGenerationState } from "../dom/generation-state.js";
2
- import type { AskArgs, AskReadData, CommandResult, ComposeArgs, ComposeData, ReadLatestArgs, ReadLatestData, RuntimeEnv, SubmitArgs, SubmitData, WaitAndReadArgs, WaitArgs, WaitData } from "../types.js";
2
+ import type { AskArgs, AskReadData, CommandResult, ComposeArgs, ComposeData, MessageStatusArgs, MessageStatusData, ReadLatestArgs, ReadLatestData, RuntimeEnv, SubmitArgs, SubmitData, WaitAndReadArgs, WaitArgs, WaitData } from "../types.js";
3
3
  export type CompletionSnapshot = {
4
4
  textStableForMs: number;
5
5
  stableMs: number;
@@ -12,6 +12,7 @@ export declare function composeMessage(env: RuntimeEnv, args: ComposeArgs): Prom
12
12
  export declare function submitMessage(env: RuntimeEnv, args?: SubmitArgs): Promise<CommandResult<SubmitData>>;
13
13
  export declare function waitForMessage(env: RuntimeEnv, args?: WaitArgs): Promise<CommandResult<WaitData>>;
14
14
  export declare function readLatest(env: RuntimeEnv, args?: ReadLatestArgs): Promise<CommandResult<ReadLatestData>>;
15
+ export declare function messageStatus(env: RuntimeEnv, args?: MessageStatusArgs): Promise<CommandResult<MessageStatusData>>;
15
16
  export declare function askMessage(env: RuntimeEnv, args: AskArgs): Promise<CommandResult<AskReadData>>;
16
17
  export declare function waitAndRead(env: RuntimeEnv, args?: WaitAndReadArgs): Promise<CommandResult<AskReadData>>;
17
18
  export declare function submittedUserTurnMatches(actual: string | undefined, wanted: string | undefined): boolean;
@@ -10,7 +10,7 @@ import { contextFromPage } from "./context.js";
10
10
  import { createDeadline } from "./deadline.js";
11
11
  import { withCommandOutputText } from "./output.js";
12
12
  import { createSingleFlightProbe } from "./probes.js";
13
- import { bootstrap } from "./session.js";
13
+ import { ensurePage } from "./session.js";
14
14
  export function isResponseComplete(snapshot) {
15
15
  return snapshot.latestText.trim().length > 0
16
16
  && !isTransientAssistantText(snapshot.latestText)
@@ -98,8 +98,14 @@ export async function submitMessage(env, args = {}) {
98
98
  }
99
99
  if (userTurn === undefined) {
100
100
  const latestUser = await readLatestMessage(page, "user", "normalized_text");
101
+ const generation = await readAssistantGenerationState(page).catch(() => EMPTY_GENERATION_STATE);
102
+ const turnCount = await countPageMessages(page).catch(() => undefined);
101
103
  if (submittedUserTurnMatches(latestUser?.text, args.text)) {
102
- return resultOk(submitData(latestUser?.text, await countPageMessages(page).catch(() => undefined)), await contextFromPage(page));
104
+ return resultOk(submitData(latestUser?.text, turnCount, generation.active ? "submitted_generating" : "submitted", generation), await contextFromPage(page));
105
+ }
106
+ const turnAdvanced = previousTurnCount !== undefined && turnCount !== undefined && turnCount > previousTurnCount;
107
+ if (turnAdvanced || generation.active) {
108
+ return resultOk(submitData(undefined, turnCount, generation.active ? "submitted_generating" : "submitted_unconfirmed", generation), await contextFromPage(page), ["Submitted prompt could not be matched to a rendered user turn yet, but ChatGPT page state indicates progress."]);
103
109
  }
104
110
  return {
105
111
  ok: false,
@@ -113,7 +119,7 @@ export async function submitMessage(env, args = {}) {
113
119
  context: await contextFromPage(page)
114
120
  };
115
121
  }
116
- return resultOk(submitData(userTurn, await countPageMessages(page).catch(() => undefined)), await contextFromPage(page));
122
+ return resultOk(submitData(userTurn, await countPageMessages(page).catch(() => undefined), "submitted", await readAssistantGenerationState(page).catch(() => EMPTY_GENERATION_STATE)), await contextFromPage(page));
117
123
  }
118
124
  catch (error) {
119
125
  return resultError(error instanceof Error ? error : new Error(String(error)), await contextFromPage(page));
@@ -287,6 +293,7 @@ export async function waitForMessage(env, args = {}) {
287
293
  let lastChangedAt = Date.now();
288
294
  let lastObservedTextLength = 0;
289
295
  let latestAssistantCount = await countPageMessages(page, "assistant").catch(() => 0);
296
+ let lastGeneration = EMPTY_GENERATION_STATE;
290
297
  while (Date.now() - started < timeoutMs) {
291
298
  const probeResult = await waitSnapshotProbe(page, deadline, { timeoutMs: probeTimeoutMs });
292
299
  addWarnings(waitWarnings, probeResult.warnings);
@@ -317,6 +324,7 @@ export async function waitForMessage(env, args = {}) {
317
324
  }
318
325
  pollIndex += 1;
319
326
  const hasResponseActions = await resolveResponseActions(page, snapshot);
327
+ lastGeneration = snapshot.generation;
320
328
  if (targetReached && snapshot.generation.stopped && snapshot.text.length > 0) {
321
329
  const stoppedText = normalizeWhitespace(await fetchLatestAssistantText(page) ?? "");
322
330
  // A failed re-read must not fabricate an empty capture: real text was observed in
@@ -324,6 +332,9 @@ export async function waitForMessage(env, args = {}) {
324
332
  const data = stoppedText.length > 0
325
333
  ? waitDataFromText(args, false, stoppedText, latestAssistantCount, Date.now() - started)
326
334
  : waitDataWithoutText(latestAssistantCount, Date.now() - started);
335
+ data.completionState = "stopped";
336
+ data.generationActive = snapshot.generation.active;
337
+ data.generationSignals = snapshot.generation.signals;
327
338
  if (stoppedText.length === 0) {
328
339
  waitWarnings.add(`The interrupted assistant text (~${snapshot.text.length} chars observed) could not be re-read at wait exit; call messages.readLatest on the same thread to capture it.`);
329
340
  }
@@ -361,6 +372,9 @@ export async function waitForMessage(env, args = {}) {
361
372
  };
362
373
  if (fetchedMetadata.hash === snapshot.text.hash && isResponseComplete(completionSnapshot)) {
363
374
  const data = waitDataFromText(args, true, latestText, latestAssistantCount, Date.now() - started);
375
+ data.completionState = "complete";
376
+ data.generationActive = false;
377
+ data.generationSignals = snapshot.generation.signals;
364
378
  return withCommandOutputText(resultOk(data, await contextFromPage(page), [...waitWarnings, ...responseContentWarnings(args, true)]));
365
379
  }
366
380
  if (latestText.length > 0) {
@@ -374,7 +388,11 @@ export async function waitForMessage(env, args = {}) {
374
388
  if (lastObservedTextLength > 0) {
375
389
  const partialText = await fetchLatestAssistantText(page);
376
390
  if (partialText !== undefined && normalizeWhitespace(partialText).length > 0) {
377
- const data = waitDataFromText(args, false, normalizeWhitespace(partialText), latestAssistantCount, Date.now() - started);
391
+ const normalizedPartialText = normalizeWhitespace(partialText);
392
+ const data = waitDataFromText(args, false, normalizedPartialText, latestAssistantCount, Date.now() - started);
393
+ data.completionState = completionStateFromGeneration(lastGeneration, false, normalizedPartialText.length > 0);
394
+ data.generationActive = lastGeneration.active;
395
+ data.generationSignals = lastGeneration.signals;
378
396
  return withCommandOutputText({
379
397
  ok: false,
380
398
  status: "partial",
@@ -552,9 +570,51 @@ export async function readLatest(env, args = {}) {
552
570
  data.thoughtDurationText = latest.thoughtDurationText;
553
571
  if (latest.sourcesAvailable !== undefined)
554
572
  data.sourcesAvailable = latest.sourcesAvailable;
573
+ if (role === "assistant") {
574
+ const generation = await readAssistantGenerationState(page).catch(() => EMPTY_GENERATION_STATE);
575
+ data.completionState = completionStateFromGeneration(generation, undefined, latest.text.trim().length > 0);
576
+ data.generationActive = generation.active;
577
+ data.generationSignals = generation.signals;
578
+ }
555
579
  return withCommandOutputText(resultOk(data, await contextFromPage(page), data.warnings ?? []));
556
580
  }
581
+ export async function messageStatus(env, args = {}) {
582
+ const boot = await ensurePage(env);
583
+ if (!boot.ok) {
584
+ return boot;
585
+ }
586
+ const page = env.page;
587
+ // Reuse the combined wait-snapshot evaluate for counts and generation state in a single
588
+ // round trip; it never carries full assistant text, so a bounded preview is fetched
589
+ // separately below only when the snapshot proves an assistant turn has text.
590
+ const snapshot = await readWaitDomSnapshot(page).catch(() => undefined) ?? await fallbackWaitSnapshot(page, 0);
591
+ const maxPreviewChars = Math.max(0, args.maxPreviewChars ?? 240);
592
+ const latestText = snapshot.text.length > 0
593
+ ? normalizeWhitespace(await fetchLatestAssistantText(page) ?? "")
594
+ : "";
595
+ const data = {
596
+ turnCount: snapshot.turnCount,
597
+ assistantTurnCount: snapshot.assistantTurnCount,
598
+ completionState: completionStateFromGeneration(snapshot.generation, undefined, latestText.length > 0),
599
+ generationActive: snapshot.generation.active,
600
+ generationSignals: snapshot.generation.signals
601
+ };
602
+ if (snapshot.latestAssistantTurnIndex !== undefined)
603
+ data.latestAssistantTurnIndex = snapshot.latestAssistantTurnIndex;
604
+ if (latestText.length > 0) {
605
+ data.latestAssistantTextLength = latestText.length;
606
+ data.latestAssistantPreview = latestText.length > maxPreviewChars
607
+ ? `${latestText.slice(0, Math.max(0, maxPreviewChars - 1))}...`
608
+ : latestText;
609
+ }
610
+ return resultOk(data, await contextFromPage(page));
611
+ }
557
612
  export async function askMessage(env, args) {
613
+ const normalizedPrompt = normalizeAskPrompt(args);
614
+ if (!normalizedPrompt.ok) {
615
+ return normalizedPrompt.result;
616
+ }
617
+ const prompt = normalizedPrompt.text;
558
618
  const boot = await ensurePage(env);
559
619
  if (!boot.ok) {
560
620
  return boot;
@@ -562,7 +622,7 @@ export async function askMessage(env, args) {
562
622
  const page = env.page;
563
623
  const beforeTurnCount = await countPageMessages(page).catch(() => undefined);
564
624
  const beforeAssistantTurnCount = await countPageMessages(page, "assistant").catch(() => undefined);
565
- const composeArgs = { text: args.text, mode: "replace" };
625
+ const composeArgs = { text: prompt, mode: "replace" };
566
626
  if (args.timeoutMs !== undefined) {
567
627
  composeArgs.timeoutMs = args.timeoutMs;
568
628
  }
@@ -570,7 +630,7 @@ export async function askMessage(env, args) {
570
630
  if (!compose.ok) {
571
631
  return forwardFailure(compose);
572
632
  }
573
- const submitArgs = { text: args.text };
633
+ const submitArgs = { text: prompt };
574
634
  if (beforeTurnCount !== undefined) {
575
635
  submitArgs.previousTurnCount = beforeTurnCount;
576
636
  }
@@ -627,7 +687,7 @@ export async function askMessage(env, args) {
627
687
  return forwardFailure(waitFailure);
628
688
  }
629
689
  const state = await readPageState(page).catch(() => undefined);
630
- const data = { prompt: args.text };
690
+ const data = { prompt };
631
691
  const complete = waitResult?.data?.complete ?? (waitResult === undefined ? undefined : false);
632
692
  if (complete !== undefined) {
633
693
  data.complete = complete;
@@ -641,6 +701,7 @@ export async function askMessage(env, args) {
641
701
  if (state?.title !== undefined) {
642
702
  data.title = state.title;
643
703
  }
704
+ applyMessageState(data, submit.data, waitResult?.data);
644
705
  if (waitFailure !== undefined) {
645
706
  data.complete = false;
646
707
  return withCommandOutputText({
@@ -678,7 +739,7 @@ export async function waitAndRead(env, args = {}) {
678
739
  }
679
740
  return forwardFailure(read);
680
741
  }
681
- const data = askReadData("", read.data?.text, wait.data?.complete);
742
+ const data = askReadData("", read.data?.text, wait.data?.complete, wait.data, read.data);
682
743
  const warnings = [...read.warnings, ...wait.warnings];
683
744
  if (!wait.ok && wait.status === "partial") {
684
745
  data.complete = false;
@@ -695,12 +756,6 @@ export async function waitAndRead(env, args = {}) {
695
756
  }
696
757
  return withCommandOutputText(resultOk(data, read.context, warnings));
697
758
  }
698
- async function ensurePage(env) {
699
- if (env.page !== undefined) {
700
- return resultOk({}, await contextFromPage(env.page));
701
- }
702
- return bootstrap(env, { preferExistingTab: true });
703
- }
704
759
  async function waitForSubmittedUserTurn(page, text, previousTurnCount, timeoutMs) {
705
760
  const started = Date.now();
706
761
  const wanted = text === undefined ? undefined : normalizeWhitespace(text);
@@ -820,7 +875,7 @@ async function sleep(page, ms) {
820
875
  }
821
876
  await new Promise(resolve => setTimeout(resolve, ms));
822
877
  }
823
- function submitData(userTurnText, turnCount) {
878
+ function submitData(userTurnText, turnCount, submissionState, generation) {
824
879
  const data = { submitted: true };
825
880
  if (userTurnText !== undefined) {
826
881
  data.userTurnText = userTurnText;
@@ -828,9 +883,13 @@ function submitData(userTurnText, turnCount) {
828
883
  if (turnCount !== undefined) {
829
884
  data.turnCount = turnCount;
830
885
  }
886
+ data.submissionState = submissionState;
887
+ data.completionState = completionStateFromGeneration(generation);
888
+ data.generationActive = generation.active;
889
+ data.generationSignals = generation.signals;
831
890
  return data;
832
891
  }
833
- function askReadData(prompt, responseText, complete) {
892
+ function askReadData(prompt, responseText, complete, wait, read) {
834
893
  const data = { prompt };
835
894
  if (responseText !== undefined) {
836
895
  data.responseText = responseText;
@@ -838,8 +897,71 @@ function askReadData(prompt, responseText, complete) {
838
897
  if (complete !== undefined) {
839
898
  data.complete = complete;
840
899
  }
900
+ applyMessageState(data, undefined, wait, read);
841
901
  return data;
842
902
  }
903
+ function applyMessageState(data, submit, wait, read) {
904
+ if (submit?.submissionState !== undefined)
905
+ data.submissionState = submit.submissionState;
906
+ const completionState = wait?.completionState ?? read?.completionState ?? submit?.completionState;
907
+ if (completionState !== undefined)
908
+ data.completionState = completionState;
909
+ const generationActive = wait?.generationActive ?? read?.generationActive ?? submit?.generationActive;
910
+ if (generationActive !== undefined)
911
+ data.generationActive = generationActive;
912
+ const generationSignals = wait?.generationSignals ?? read?.generationSignals ?? submit?.generationSignals;
913
+ if (generationSignals !== undefined)
914
+ data.generationSignals = generationSignals;
915
+ }
916
+ function completionStateFromGeneration(generation, complete, hasPartialText = false) {
917
+ if (complete === true)
918
+ return "complete";
919
+ if (generation.active)
920
+ return "generating";
921
+ if (generation.stopped)
922
+ return "stopped";
923
+ if (complete === false || hasPartialText)
924
+ return "partial";
925
+ return "unknown";
926
+ }
927
+ function normalizeAskPrompt(args) {
928
+ const text = args.text;
929
+ const prompt = args.prompt;
930
+ if (text !== undefined && prompt !== undefined && text !== prompt) {
931
+ return {
932
+ ok: false,
933
+ result: {
934
+ ok: false,
935
+ status: "error",
936
+ warnings: [],
937
+ error: {
938
+ name: "InvalidAskArgs",
939
+ message: "messages.ask received both text and prompt with different values.",
940
+ recoverable: false
941
+ },
942
+ context: { timestamp: new Date().toISOString() }
943
+ }
944
+ };
945
+ }
946
+ const normalized = text ?? prompt;
947
+ if (normalized === undefined || normalized.trim().length === 0) {
948
+ return {
949
+ ok: false,
950
+ result: {
951
+ ok: false,
952
+ status: "error",
953
+ warnings: [],
954
+ error: {
955
+ name: "InvalidAskArgs",
956
+ message: "messages.ask requires a non-empty text or prompt argument.",
957
+ recoverable: false
958
+ },
959
+ context: { timestamp: new Date().toISOString() }
960
+ }
961
+ };
962
+ }
963
+ return { ok: true, text: normalized };
964
+ }
843
965
  function readRole(read) {
844
966
  return typeof read === "object" ? read.role : undefined;
845
967
  }
@@ -4,11 +4,12 @@ import { localeLabels } from "../dom/locale-labels.js";
4
4
  import { isShortLatinToken, normalizeForLabelMatch, visibleLabelMatches } from "../dom/label-match.js";
5
5
  import { normalizeLabel, normalizeWhitespace } from "../dom/visible-text.js";
6
6
  import { contextFromPage } from "./context.js";
7
- import { bootstrap } from "./session.js";
7
+ import { ensurePage } from "./session.js";
8
8
  const DEFAULT_MODE_EFFORT = "Thinking";
9
9
  const CURRENT_MODE_LABELS = dedupeLabels([
10
10
  ...localeLabels.modeLabels,
11
11
  ...Object.values(localeLabels.modeOptions).flat(),
12
+ ...Object.values(localeLabels.configurationOptions).flat(),
12
13
  ]);
13
14
  const MODE_OPENER_LABELS = [...CURRENT_MODE_LABELS.filter(label => label !== "Pro"), ...localeLabels.modeOpenerExtra];
14
15
  const MODEL_VERSION_FAMILY_PATTERN = /^gpt[\s-]/i;
@@ -224,12 +225,6 @@ export async function selectTool(env, args) {
224
225
  return resultError(error instanceof Error ? error : new Error(String(error)), await contextFromPage(page));
225
226
  }
226
227
  }
227
- async function ensurePage(env) {
228
- if (env.page !== undefined) {
229
- return resultOk({}, await contextFromPage(env.page));
230
- }
231
- return bootstrap(env, { preferExistingTab: true });
232
- }
233
228
  async function clickFirstUniqueButton(page, labels) {
234
229
  for (const label of labels) {
235
230
  const roleLocator = page.getByRole?.("button", { name: label, exact: true });
@@ -508,22 +503,35 @@ async function selectModelVersion(page, requestedVersion, currentCandidates, tim
508
503
  candidates = await enumerateVisibleMenuItems(page);
509
504
  }
510
505
  }
511
- let exact = findExactMenuItem(candidates, requestedVersion);
506
+ let exact = findExactSelectableMenuItem(candidates, requestedVersion);
512
507
  if (exact !== undefined) {
513
- return await clickMenuItem(page, exact.label)
508
+ return await clickResolvedMenuItem(page, exact)
514
509
  ? { selected: exact.label, candidates: candidates.map(candidate => candidate.label) }
515
510
  : { candidates: candidates.map(candidate => candidate.label) };
516
511
  }
517
512
  const opened = await openModelVersionSubmenu(page, currentCandidates);
518
513
  candidates = await enumerateVisibleMenuItems(page);
519
- exact = findExactMenuItem(candidates, requestedVersion);
514
+ exact = findExactSelectableMenuItem(candidates, requestedVersion);
520
515
  if (!opened || exact === undefined) {
521
516
  return { candidates: candidates.map(candidate => candidate.label) };
522
517
  }
523
- return await clickMenuItem(page, exact.label)
518
+ return await clickResolvedMenuItem(page, exact)
524
519
  ? { selected: exact.label, candidates: candidates.map(candidate => candidate.label) }
525
520
  : { candidates: candidates.map(candidate => candidate.label) };
526
521
  }
522
+ function isModelVersionSubmenuOpener(item) {
523
+ return item.hasPopup === true
524
+ || (item.role !== "menuitemradio" && MODEL_VERSION_FAMILY_PATTERN.test(item.label));
525
+ }
526
+ async function clickResolvedMenuItem(page, item) {
527
+ if (item.testId !== undefined && await clickIfUnique(page.locator?.(`[data-testid="${escapeAttributeValue(item.testId)}"]`))) {
528
+ return true;
529
+ }
530
+ if (item.role !== undefined && await clickIfUnique(page.getByRole?.(item.role, { name: item.label, exact: true }))) {
531
+ return true;
532
+ }
533
+ return clickMenuItem(page, item.label);
534
+ }
527
535
  async function openModelVersionSubmenu(page, candidates) {
528
536
  const submenuOpeners = candidates.filter(item => item.hasPopup === true || MODEL_VERSION_FAMILY_PATTERN.test(item.label));
529
537
  if (submenuOpeners.length === 0) {
@@ -604,9 +612,10 @@ async function modelVersionMenuItemsAreVisible(page) {
604
612
  return (await enumerateVisibleMenuItems(page))
605
613
  .some(candidate => candidate.role === "menuitemradio" && MODEL_VERSION_LABEL_PATTERN.test(candidate.label));
606
614
  }
607
- function findExactMenuItem(items, wanted) {
615
+ function findExactSelectableMenuItem(items, wanted) {
608
616
  const normalized = normalizeLabel(wanted);
609
- const matches = items.filter(item => item.normalized === normalized);
617
+ const matches = items.filter(item => item.normalized === normalized
618
+ && !isModelVersionSubmenuOpener(item));
610
619
  return matches.length === 1 ? matches[0] : undefined;
611
620
  }
612
621
  function dedupeLabels(labels) {
@@ -666,9 +675,12 @@ async function visibleModeButtonLabelList(page) {
666
675
  }
667
676
  return text.includes(token);
668
677
  };
678
+ const scopedRoots = Array.from(document.querySelectorAll("main form, main [data-testid*='composer' i], main [class*='composer' i]"));
669
679
  return Array.from(document.querySelectorAll("button, [role='button']"))
670
680
  .map(node => {
671
681
  const element = node;
682
+ if (scopedRoots.length > 0 && !scopedRoots.some(root => root.contains(node)))
683
+ return "";
672
684
  const visibleText = (element.innerText ?? element.textContent ?? "").replace(/\s+/g, " ").trim();
673
685
  const ariaLabel = (element.getAttribute("aria-label") ?? "").replace(/\s+/g, " ").trim();
674
686
  const label = visibleText.length > 0 ? visibleText : ariaLabel;
@@ -680,7 +692,9 @@ async function visibleModeButtonLabelList(page) {
680
692
  if (visibleText.length === 0 && /feedback|conversation options|dismiss/i.test(ariaLabel))
681
693
  return "";
682
694
  const normalized = label.toLowerCase();
683
- if (!normalizedModeLabels.some(modeLabel => tokenMatches(normalized, modeLabel)))
695
+ const structuralModelControl = /model-switcher|model-selector|mode-selector/i.test(testId)
696
+ || /\b(?:gpt|sol|luna|terra)\b/i.test(label);
697
+ if (!structuralModelControl && !normalizedModeLabels.some(modeLabel => tokenMatches(normalized, modeLabel)))
684
698
  return "";
685
699
  return label;
686
700
  })