copilot-operator 0.1.26 → 0.1.27

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.
@@ -10,7 +10,7 @@
10
10
  * 2. Enter does not submit the composer. The Send button has to be clicked.
11
11
  * 3. A message cannot consist of an attachment alone; Send stays disabled without text.
12
12
  */
13
- import { buttonShows, pageModelFor, sameModel } from './modelMatch.js';
13
+ import { pageModelFor, sameModel } from './modelMatch.js';
14
14
  import { chromium } from 'playwright';
15
15
  import { mkdir, writeFile } from 'node:fs/promises';
16
16
  import { join } from 'node:path';
@@ -39,9 +39,15 @@ export function isReplyTimeout(e) {
39
39
  return e instanceof ReplyTimeoutError || (e instanceof Error && e.name === 'ReplyTimeoutError' && typeof e.seconds === 'number');
40
40
  }
41
41
  const ORIGIN = 'https://m365.cloud.microsoft';
42
- /** Escapes a model name so it can be matched literally inside a regular expression. */
43
- function escapeForRegExp(text) {
44
- return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
42
+ /**
43
+ * A group's name for the list. Its row reads the group ("GPT") until a model in it is chosen, and then
44
+ * that model's name; then the name comes from its test id (`gptSubMenuModelTrigger-OpenAI` → "GPT").
45
+ */
46
+ function groupLabel(group, children) {
47
+ if (!children.some((c) => sameModel(c.name, group.name)))
48
+ return group.name;
49
+ const fromId = /^([a-z0-9]+?)SubMenu/i.exec(group.testId)?.[1];
50
+ return fromId ? fromId.toUpperCase() : group.raw.split('\n')[1]?.trim() || 'Models';
45
51
  }
46
52
  /**
47
53
  * The chat refused the message: it never became a turn, so no reply is coming.
@@ -445,15 +451,17 @@ Current URL: ${url}`);
445
451
  * Renames the current conversation. The sidebar row's overflow menu holds "Rename", the
446
452
  * dialog holds an input and a Save button, and Enter does not submit it.
447
453
  */
448
- async nameChat(chatId, name) {
454
+ async nameChat(chatId, name, opts = {}) {
449
455
  const row = this.p.locator(`${Sidebar.conversationLink}[href*="${chatId}"]`).first();
450
- // Waited for, like everything below: a new chat's row appears in the sidebar a moment after the
451
- // reply, and `count()` read it as missing and returned without a word (live run 2026-10-03).
456
+ // Waited for, like everything below: a new chat's row appears in the sidebar some seconds after the
457
+ // reply, and `count()` read it as missing and returned without a word (live run 2026-10-03). The run
458
+ // tries again later when it is still not there (see `nameIfStillUnnamed` in taskRunner.ts).
459
+ const waitMs = opts.waitMs ?? 15_000;
452
460
  try {
453
- await row.waitFor({ state: 'attached', timeout: 15_000 });
461
+ await row.waitFor({ state: 'attached', timeout: waitMs });
454
462
  }
455
463
  catch {
456
- this.emit('chat-name-failed', { chatId, name, error: 'the conversation did not appear in the sidebar within 15 s' });
464
+ this.emit('chat-name-failed', { chatId, name, error: `the conversation did not appear in the sidebar within ${Math.round(waitMs / 1000)} s` });
457
465
  return false;
458
466
  }
459
467
  /*
@@ -467,23 +475,41 @@ Current URL: ${url}`);
467
475
  */
468
476
  let opened = false;
469
477
  try {
470
- await row.hover();
471
- const more = row
472
- .locator('xpath=ancestor-or-self::*[self::li or self::div][1]')
473
- .getByRole('button', { name: Rename.moreButtonLabel, exact: true })
474
- .first();
475
- await more.waitFor({ state: 'visible', timeout: 5_000 });
476
- await more.click();
478
+ await row.scrollIntoViewIfNeeded().catch(() => undefined);
479
+ // Briefly: a hover the sidebar takes waits out the page's whole timeout otherwise.
480
+ await row.hover({ timeout: 2_000 }).catch(() => undefined);
481
+ /*
482
+ * The chat's own overflow button, by the chat id it carries; through the row only in a build without
483
+ * that attribute. A mouse click on it was taken by the sidebar over it — the chat's link, the sticky
484
+ * section header — and Playwright retried for the whole minute (live 2026-10-05), so `press` clicks
485
+ * the element itself when the mouse cannot reach it.
486
+ */
487
+ const byChat = this.p.locator(Rename.moreButtonForChat(chatId)).first();
488
+ const more = (await byChat.count()) > 0
489
+ ? byChat
490
+ : row.locator('xpath=ancestor-or-self::*[self::li or self::div][1]').getByRole('button', { name: Rename.moreButtonLabel, exact: true }).first();
491
+ await more.waitFor({ state: 'attached', timeout: 5_000 });
492
+ await this.press(more);
477
493
  opened = true;
478
494
  const item = this.p.getByRole('menuitem', { name: Rename.menuItem, exact: true }).first();
479
495
  await item.waitFor({ state: 'visible', timeout: 5_000 });
480
- await item.click();
496
+ await this.press(item);
481
497
  opened = false;
482
498
  const input = this.p.locator(Rename.input);
483
499
  await input.waitFor({ state: 'visible', timeout: 10_000 });
484
- await input.fill(name.slice(0, Rename.maxLength));
485
- await this.p.getByRole('button', { name: Rename.saveText, exact: true }).first().click();
500
+ const wanted = name.slice(0, Rename.maxLength);
501
+ await input.fill(wanted);
502
+ await this.press(this.p.getByRole('button', { name: Rename.saveText, exact: true }).first());
486
503
  await input.waitFor({ state: 'detached', timeout: 10_000 }).catch(() => undefined);
504
+ // Named only when the sidebar says so: its row's label is the chat's name.
505
+ const shows = async () => ((await row.getAttribute('aria-label').catch(() => null)) ?? '').trim();
506
+ const deadline = Date.now() + 8_000;
507
+ while ((await shows()) !== wanted && Date.now() < deadline)
508
+ await this.p.waitForTimeout(250);
509
+ if ((await shows()) !== wanted) {
510
+ this.emit('chat-name-failed', { chatId, name, error: `the sidebar still shows "${await shows()}"` });
511
+ return false;
512
+ }
487
513
  this.emit('chat-named', { chatId, name });
488
514
  return true;
489
515
  }
@@ -498,6 +524,19 @@ Current URL: ${url}`);
498
524
  await this.dismissOpenMenu();
499
525
  }
500
526
  }
527
+ /**
528
+ * Clicks an element: with the mouse when it can reach it, else on the element itself. A short wait
529
+ * for the mouse, not the page's minute: in the sidebar another element lies over the target and
530
+ * takes the click, which no amount of retrying changes.
531
+ */
532
+ async press(target) {
533
+ try {
534
+ await target.click({ timeout: 3_000 });
535
+ }
536
+ catch {
537
+ await target.evaluate((el) => el.click(), undefined, { timeout: 5_000 });
538
+ }
539
+ }
501
540
  /**
502
541
  * Closes whatever menu or dialog is open, without caring which one it is.
503
542
  *
@@ -567,9 +606,14 @@ Current URL: ${url}`);
567
606
  * Opens the picker and reads what it offers, then closes it again without choosing.
568
607
  *
569
608
  * The list is whatever this tenant shows today. Nothing is filtered and nothing is
570
- * translated: a name shown here is the name the chat uses, so clicking it later is an exact
571
- * match rather than a guess. `raw` keeps the option's whole text, which is where Microsoft
572
- * puts the one-line description and any "limit reached" notice.
609
+ * translated: a name shown here is the name the chat uses. `raw` keeps the option's whole text,
610
+ * which is where Microsoft puts the one-line description and any "limit reached" notice.
611
+ *
612
+ * Each option comes with its `locator`: where it sits in the picker, read here and used to choose
613
+ * it again at the start of a run (see `selectModel`). A group is known by its test id and its place,
614
+ * not by its text: once a model of a group is chosen, Copilot writes that model's name on the
615
+ * group's row ("GPT" reads "GPT-5.6 Sol Think deeper"), and read by text the group lost that very
616
+ * model, so the run could not choose it and went on Auto (2026-10-05).
573
617
  */
574
618
  async listModels() {
575
619
  const button = await this.resolveModelButton();
@@ -585,16 +629,18 @@ Current URL: ${url}`);
585
629
  const top = await this.readMenuRows();
586
630
  await this.captureMenu('top', top);
587
631
  await this.closeMenu();
588
- const options = top.filter((r) => !r.opensSubmenu).map(({ opensSubmenu, ...o }) => o);
632
+ const strip = (r) => ({ name: r.name, raw: r.raw, selected: r.selected, disabled: r.disabled, role: r.role });
633
+ const options = top.filter((r) => !r.opensSubmenu).map((r) => ({ ...strip(r), locator: { role: r.role, index: r.index } }));
589
634
  const groups = top.filter((r) => r.opensSubmenu);
590
- const topNames = new Set(top.map((r) => r.name.toLowerCase()));
591
635
  // Each group is opened in its own pass, from a freshly opened menu. Walking several
592
636
  // submenus in one pass works until one of them closes the one above it, and then the
593
637
  // reader silently returns half a list; re-opening costs a second and cannot go wrong.
594
638
  for (const group of groups) {
595
- const children = await this.readSubmenu(button, group.name, topNames);
639
+ const where = { testId: group.testId || undefined, index: group.index, name: group.name };
640
+ const children = await this.readSubmenu(button, where);
641
+ const name = groupLabel(group, children);
596
642
  for (const child of children) {
597
- options.push({ ...child, group: group.name, groupRaw: group.raw });
643
+ options.push({ ...strip(child), group: name, groupRaw: group.raw, locator: { role: child.role, index: child.index, group: { ...where, name } } });
598
644
  }
599
645
  }
600
646
  this.emit('models-read', { count: options.length, groups: groups.length, current });
@@ -636,36 +682,59 @@ Current URL: ${url}`);
636
682
  /* a diagnostic, never the reason a reading fails */
637
683
  }
638
684
  }
639
- /** Every visible menu row right now, across the menu and whatever submenu is open. */
685
+ /**
686
+ * Every visible menu row right now, across the menu and whatever submenu is open, in page order.
687
+ *
688
+ * Each row says which menu it is in (`menuOf`, the menu's `aria-labelledby`: a submenu is labelled by
689
+ * the row that opens it) and its place there (`index`), so the same row can be found again without
690
+ * going by its text. Rows are not merged by name: the group row and the model chosen in it carry the
691
+ * same name once that model is in force.
692
+ */
640
693
  async readMenuRows() {
641
694
  return await this.p.evaluate(({ roles, selectedAttrs, submenuAttrs }) => {
642
- const seen = new Set();
643
695
  const out = [];
644
- for (const role of roles) {
645
- for (const el of Array.from(document.querySelectorAll(`[role="${role}"]`))) {
646
- // A menu that is closed is still in the DOM, so visibility is what separates
647
- // what is on screen from what was on screen a moment ago.
648
- if (el.getClientRects().length === 0)
649
- continue;
650
- const raw = (el.innerText || '').trim();
651
- const label = el.getAttribute('aria-label')?.trim() ?? '';
652
- // The first line is the name; the description sits underneath it.
653
- const name = (raw.split('\n')[0] || label || '').trim();
654
- if (!name || seen.has(name.toLowerCase()))
655
- continue;
656
- seen.add(name.toLowerCase());
657
- out.push({
658
- name,
659
- raw,
660
- selected: selectedAttrs.some((a) => el.getAttribute(a) === 'true'),
661
- disabled: el.getAttribute('aria-disabled') === 'true' || el.hasAttribute('disabled'),
662
- role,
663
- opensSubmenu: submenuAttrs.some((a) => {
664
- const v = el.getAttribute(a);
665
- return a === 'aria-haspopup' ? v === 'menu' || v === 'true' : v !== null;
666
- }),
667
- });
696
+ const selector = roles.map((r) => `[role="${r}"]`).join(',');
697
+ const counted = new Map();
698
+ // A menu's key: what labels it (a submenu is labelled by the row that opens it), else a mark put
699
+ // on it here, so a menu with no label is told apart from the others all the same.
700
+ // (Written inline: a named function in here is wrapped by the bundler in a helper the page does not have.)
701
+ const w = window;
702
+ for (const el of Array.from(document.querySelectorAll(selector))) {
703
+ // A menu that is closed is still in the DOM, so visibility is what separates
704
+ // what is on screen from what was on screen a moment ago.
705
+ if (el.getClientRects().length === 0)
706
+ continue;
707
+ const menu = el.closest('[role="menu"],[role="listbox"]');
708
+ const index = counted.get(menu) ?? 0;
709
+ counted.set(menu, index + 1);
710
+ if (menu && !menu.getAttribute('aria-labelledby') && !menu.getAttribute('data-cop-menu')) {
711
+ w.__copMenus = (w.__copMenus ?? 0) + 1;
712
+ menu.setAttribute('data-cop-menu', `cop-menu-${w.__copMenus}`);
668
713
  }
714
+ const menuOf = menu ? (menu.getAttribute('aria-labelledby') || menu.getAttribute('data-cop-menu') || '') : '';
715
+ // Marked, so the row can be pointed at in this page without its text.
716
+ el.setAttribute('data-cop-row', `${menuOf}#${index}`);
717
+ const raw = (el.innerText || '').trim();
718
+ const label = el.getAttribute('aria-label')?.trim() ?? '';
719
+ // The first line is the name; the description sits underneath it.
720
+ const name = (raw.split('\n')[0] || label || '').trim();
721
+ if (!name)
722
+ continue;
723
+ out.push({
724
+ name,
725
+ raw,
726
+ selected: selectedAttrs.some((a) => el.getAttribute(a) === 'true'),
727
+ disabled: el.getAttribute('aria-disabled') === 'true' || el.hasAttribute('disabled'),
728
+ role: el.getAttribute('role') ?? '',
729
+ opensSubmenu: submenuAttrs.some((a) => {
730
+ const v = el.getAttribute(a);
731
+ return a === 'aria-haspopup' ? v === 'menu' || v === 'true' : v !== null;
732
+ }),
733
+ index,
734
+ menuOf,
735
+ testId: el.getAttribute('data-test-id') ?? el.getAttribute('data-testid') ?? '',
736
+ elId: el.id ?? '',
737
+ });
669
738
  }
670
739
  return out;
671
740
  }, {
@@ -674,76 +743,130 @@ Current URL: ${url}`);
674
743
  submenuAttrs: [...Model.submenuAttributes],
675
744
  });
676
745
  }
746
+ /** The rows of the top menu, read while only it is open: the menu its first row is in. */
747
+ topRows(rows) {
748
+ const key = rows[0]?.menuOf;
749
+ return key === undefined ? [] : rows.filter((r) => r.menuOf === key);
750
+ }
677
751
  /**
678
- * Opens one group and reads what is inside it.
679
- *
680
- * The children are found by difference: whatever is on screen that was not on the top
681
- * level belongs to the submenu that was just opened. That avoids having to identify which
682
- * popup container is which, which is Fluent's business and changes with its internals.
752
+ * Opens one group of the open picker and returns the key of its submenu (see `readMenuRows`), or null.
753
+ * The group's row is found by its test id, else by its place among the top rows, else by its text. Its
754
+ * submenu is the menu labelled by that row, or, in a build that does not label it, the menu that
755
+ * appeared when the row was hovered.
683
756
  */
684
- async readSubmenu(button, groupName, topNames) {
757
+ async openGroup(group) {
758
+ const top = this.topRows(await this.readMenuRows());
759
+ const row = (group.testId ? top.find((r) => r.opensSubmenu && r.testId === group.testId) : undefined) ??
760
+ top.find((r) => r.opensSubmenu && r.index === group.index) ??
761
+ top.find((r) => r.opensSubmenu && sameModel(r.name, group.name));
762
+ if (!row)
763
+ return null;
764
+ const trigger = this.p.locator(`[data-cop-row="${row.menuOf}#${row.index}"]`).first();
765
+ const opened = async (ms) => {
766
+ const deadline = Date.now() + ms;
767
+ do {
768
+ const rows = (await this.readMenuRows()).filter((r) => r.menuOf !== row.menuOf);
769
+ const own = rows.find((r) => row.elId && r.menuOf === row.elId) ?? rows[0];
770
+ if (own)
771
+ return own.menuOf;
772
+ await this.p.waitForTimeout(200);
773
+ } while (Date.now() < deadline);
774
+ return null;
775
+ };
776
+ // Hover is how these open; a click is the fallback for a build that wants one.
777
+ await trigger.hover().catch(() => undefined);
778
+ const hovered = await opened(4_000);
779
+ if (hovered)
780
+ return hovered;
781
+ await trigger.click().catch(() => undefined);
782
+ return await opened(4_000);
783
+ }
784
+ /** Opens a fresh menu, then the group, and reads what is in it. */
785
+ async readSubmenu(button, group) {
685
786
  await button.click();
686
787
  if (!(await this.waitForPopup()))
687
788
  return [];
688
- const trigger = this.menuRow(groupName);
689
- if ((await trigger.count()) === 0) {
690
- await this.closeMenu();
691
- return [];
692
- }
693
- // Hover is how these open; a click is the fallback for a build that wants one.
694
- await trigger.hover().catch(() => undefined);
695
- let children = await this.waitForNewRows(topNames);
696
- if (children.length === 0) {
697
- await trigger.click().catch(() => undefined);
698
- children = await this.waitForNewRows(topNames);
699
- }
700
- await this.captureMenu(`group-${groupName.replace(/[^A-Za-z0-9._-]+/g, '-').slice(0, 40)}`, children);
789
+ const key = await this.openGroup(group);
790
+ const children = key ? (await this.readMenuRows()).filter((r) => r.menuOf === key) : [];
791
+ await this.captureMenu(`group-${group.name.replace(/[^A-Za-z0-9._-]+/g, '-').slice(0, 40)}`, children);
701
792
  await this.closeMenu();
702
793
  return children;
703
794
  }
704
- /** Waits for rows to appear that were not on the top level, and returns them. */
705
- async waitForNewRows(known, timeoutMs = 4_000) {
706
- const deadline = Date.now() + timeoutMs;
707
- do {
708
- const rows = (await this.readMenuRows()).filter((r) => !known.has(r.name.toLowerCase()));
709
- if (rows.length > 0)
710
- return rows;
711
- await this.p.waitForTimeout(250);
712
- } while (Date.now() < deadline);
713
- return [];
795
+ /**
796
+ * The row a locator points at, in a picker this opens (the group included); the menu is left open.
797
+ * The row must still carry the name: a place that now holds another model is not taken, and a model
798
+ * that moved within its menu is found there by name.
799
+ */
800
+ async openAtLocator(name, locator) {
801
+ const button = await this.resolveModelButton();
802
+ if (!button)
803
+ return { problem: 'This chat does not show a model picker.' };
804
+ await button.click();
805
+ if (!(await this.waitForPopup()))
806
+ return { problem: 'The model picker did not open.' };
807
+ let rows;
808
+ if (locator.group) {
809
+ const key = await this.openGroup(locator.group);
810
+ if (!key)
811
+ return { problem: `the group "${locator.group.name}" of the picker did not open.` };
812
+ rows = (await this.readMenuRows()).filter((r) => r.menuOf === key);
813
+ }
814
+ else {
815
+ rows = this.topRows(await this.readMenuRows());
816
+ }
817
+ const choices = rows.filter((r) => !r.opensSubmenu);
818
+ const row = choices.find((r) => r.index === locator.index && sameModel(r.name, name)) ?? choices.find((r) => sameModel(r.name, name));
819
+ return row ? { row } : { problem: `"${name}" is not where the list was read${locator.group ? ` (in "${locator.group.name}")` : ''}.` };
714
820
  }
715
- /** One visible menu row, matched on the first line of its text. */
716
- menuRow(name) {
717
- const selector = Model.optionRoles.map((r) => `[role="${r}"]:visible`).join(', ');
718
- return this.p.locator(selector).filter({ hasText: new RegExp(`^${escapeForRegExp(name)}`, 'i') }).first();
821
+ /** Clicks one row of the open picker, on the element itself: a mouse moving across the menu re-renders the submenus under it. */
822
+ async clickRow(row) {
823
+ return await this.p
824
+ .evaluate((mark) => {
825
+ const el = document.querySelector(`[data-cop-row="${mark}"]`);
826
+ if (!el || el.getClientRects().length === 0)
827
+ return false;
828
+ el.click();
829
+ return true;
830
+ }, `${row.menuOf}#${row.index}`)
831
+ .catch(() => false);
719
832
  }
720
833
  /**
721
- * Chooses one row of the open picker.
722
- *
723
- * A row inside a submenu is clicked on the element itself, in the page, and not with the mouse.
724
- * A mouse click first travels to the row, and on the way it crosses the other rows of the menu
725
- * above; Fluent opens or re-renders their submenus as the pointer passes, the row being aimed at
726
- * is replaced, and Playwright starts again — "element is not stable", "element was detached from
727
- * the DOM, retrying" — for as long as the page's default timeout allows. Live, that was 38
728
- * seconds of a window stuck on the model menu with "GPT 5.6 Think deeper" in plain sight, until
729
- * the window was closed and the task failed with "Target page, context or browser has been
730
- * closed". A click dispatched on the element does not move the pointer, so nothing closes under
731
- * it.
732
- *
733
- * Either way the attempt is short: the caller reads the model button back to decide whether the
734
- * choice took, so a click that did not land costs seconds and says so, rather than a minute.
834
+ * Chooses a model at its locator and reads its row again from a fresh menu: chosen only when the
835
+ * row itself is marked (`aria-checked`). The button's text is no proof — it is shortened, and it is
836
+ * the same for a model and its group.
735
837
  */
736
- async clickMenuRow(name, inSubmenu) {
737
- const row = this.menuRow(name);
738
- if (inSubmenu) {
739
- const dispatched = await row
740
- .evaluate((el) => el.click(), undefined, { timeout: 5_000 })
741
- .then(() => true)
742
- .catch(() => false);
743
- if (dispatched)
744
- return;
838
+ async chooseAt(name, locator) {
839
+ const before = await this.currentModel();
840
+ const found = await this.openAtLocator(name, locator);
841
+ if ('problem' in found) {
842
+ await this.closeMenu();
843
+ return { ok: false, current: before, reason: found.problem };
844
+ }
845
+ if (found.row.disabled) {
846
+ await this.closeMenu();
847
+ return { ok: false, current: before, reason: `"${found.row.name}" is shown but not available right now.` };
848
+ }
849
+ if (found.row.selected) {
850
+ await this.closeMenu();
851
+ this.emit('model-already-selected', { model: found.row.name, buttonShows: before, by: 'locator' });
852
+ return { ok: true, current: found.row.name, by: 'locator' };
853
+ }
854
+ const clicked = await this.clickRow(found.row);
855
+ await this.p.waitForTimeout(800);
856
+ // Whatever the click did, the menu is not left open over the chat.
857
+ await this.closeMenu();
858
+ const again = clicked ? await this.openAtLocator(found.row.name, locator) : { problem: 'the row could not be clicked.' };
859
+ await this.closeMenu();
860
+ const after = await this.currentModel();
861
+ if ('row' in again && again.row.selected) {
862
+ this.emit('model-selected', { model: again.row.name, buttonShows: after, by: 'locator' });
863
+ return { ok: true, current: again.row.name, by: 'locator' };
745
864
  }
746
- await row.click({ timeout: 8_000 }).catch(() => undefined);
865
+ return {
866
+ ok: false,
867
+ current: after,
868
+ reason: 'problem' in again ? `after choosing "${name}", ${again.problem}` : `"${name}" was clicked, but the picker does not mark it as chosen (the button shows "${after ?? 'nothing'}").`,
869
+ };
747
870
  }
748
871
  /**
749
872
  * Whether the model picker is open: its button says so (`aria-expanded`), or its rows are on screen.
@@ -835,107 +958,45 @@ Current URL: ${url}`);
835
958
  return null;
836
959
  }
837
960
  /**
838
- * Sets the chat to a model by the exact name the picker showed.
961
+ * Sets the chat to a model.
839
962
  *
840
- * Verified by reading the button back rather than by trusting the click, because a menu
841
- * item that is out of quota can look clicked and change nothing. When it did not take, the
842
- * caller is told what the chat is actually on, which is the honest answer.
963
+ * With a `locator` from the list the operator read (see `listModels`), the row is gone to directly.
964
+ * Without one, or when the page moved it, the line-up is read again with fresh locators and the model
965
+ * is found there by name — or under its new name when the page renamed it (see `modelMatch.ts`). The
966
+ * choice counts only when the picker marks the row as chosen, read again from a fresh menu, because a
967
+ * menu item that is out of quota can look clicked and change nothing; when it did not take, the
968
+ * caller is told what the chat is actually on.
843
969
  */
844
- async selectModel(name) {
970
+ async selectModel(name, opts = {}) {
845
971
  const button = await this.resolveModelButton();
846
972
  if (!button)
847
973
  return { ok: false, current: null, reason: 'This chat does not show a model picker.' };
848
- const before = await this.currentModel();
849
- if (sameModel(before, name))
850
- return { ok: true, current: before };
851
- await button.click();
852
- if (!(await this.waitForPopup()))
853
- return { ok: false, current: before, reason: 'The model picker did not open.' };
854
- // The menu is walked rather than replayed from a saved path. A cached path goes stale the
855
- // moment Microsoft moves a model between groups, and a name is what the user chose.
856
- const top = await this.readMenuRows();
857
- // As the page writes it today: "GPT-5.6" and "GPT 5.6" are one model (see `modelMatch.ts`).
858
- const wanted = (r) => sameModel(r.name, name);
859
- let target = top.find((r) => wanted(r) && !r.opensSubmenu);
860
- const inSubmenu = !target;
861
- if (!target) {
862
- const topNames = new Set(top.map((r) => r.name.toLowerCase()));
863
- for (const group of top.filter((r) => r.opensSubmenu)) {
864
- const trigger = this.menuRow(group.name);
865
- await trigger.hover().catch(() => undefined);
866
- let children = await this.waitForNewRows(topNames);
867
- if (children.length === 0) {
868
- await trigger.click().catch(() => undefined);
869
- children = await this.waitForNewRows(topNames);
870
- }
871
- const found = children.find(wanted);
872
- if (found) {
873
- target = found;
874
- break;
875
- }
876
- }
974
+ let first = null;
975
+ if (opts.locator) {
976
+ first = await this.chooseAt(name, opts.locator);
977
+ if (first.ok)
978
+ return first;
877
979
  }
878
- if (!target) {
879
- await this.closeMenu();
880
- /*
881
- * Not under that name: the line-up is read from the page, and the same model under its new name is
882
- * chosen when exactly one fits (see `modelMatch.ts`). The list read goes back to the caller as well,
883
- * so the saved catalogue follows the page (live run 2026-10-04: "GPT 5.6 Think deeper" had become
884
- * "GPT 5.6 Sol Think deeper", and every run went on the default).
885
- */
886
- const read = await this.listModels().catch(() => null);
887
- const options = read?.options ?? [];
888
- const match = pageModelFor(name, options);
889
- if (match && !sameModel(match.name, name)) {
890
- const again = await this.selectModel(match.name);
891
- return { ...again, matched: match.name, options };
892
- }
980
+ // The line-up as the page shows it now, each model with its place in it.
981
+ const read = await this.listModels().catch(() => null);
982
+ const options = read?.options ?? [];
983
+ const match = options.find((o) => sameModel(o.name, name)) ?? pageModelFor(name, options);
984
+ if (!match?.locator) {
893
985
  const offered = options.filter((o) => !o.disabled).map((o) => o.name);
894
986
  return {
895
987
  ok: false,
896
- current: before,
897
- reason: `The picker does not offer "${name}" any more${offered.length > 0 ? `; it offers ${offered.map((o) => `"${o}"`).join(', ')}` : ''}.`,
988
+ current: await this.currentModel(),
989
+ reason: `The picker does not offer "${name}" any more${offered.length > 0 ? `; it offers ${offered.map((o) => `"${o}"`).join(', ')}` : ''}.` +
990
+ (first?.reason ? ` (At the place the list was read: ${first.reason})` : ''),
898
991
  ...(options.length > 0 ? { options } : {}),
899
992
  };
900
993
  }
901
- /*
902
- * Already the one in force: close the menu and say so, without clicking.
903
- *
904
- * The button cannot answer this on its own, because it shortens names — "GPT 5.6 Think deeper"
905
- * reads "GPT 5.6 Think" — so the exact comparison above lets a chat that is already on the
906
- * model through to here. The row's own mark (`aria-checked` and its kin) is the answer. The
907
- * click it used to get did nothing in Copilot: the row already in force ignores it, the menu
908
- * stayed open over the composer, and the run went on with the model list hanging on screen.
909
- */
910
- if (target.selected) {
911
- await this.closeMenu();
912
- this.emit('model-already-selected', { model: target.name, buttonShows: before });
913
- return { ok: true, current: target.name };
914
- }
915
- if (target.disabled) {
916
- await this.closeMenu();
917
- return { ok: false, current: before, reason: `"${name}" is shown but not available right now.` };
918
- }
919
- await this.clickMenuRow(target.name, inSubmenu);
920
- await this.p.waitForTimeout(1_000);
921
- // Whatever the click did, the menu is not left open over the chat: a build that keeps it open
922
- // after a choice, or a click that did not land, would otherwise leave it for the next message.
923
- await this.closeMenu();
924
- const after = await this.currentModel();
925
- // The button shows the choice, so it is the check. It also **shortens** it: picking
926
- // "GPT 5.6 Quick response" leaves the button reading "GPT 5.6 Quick". So the shown value
927
- // agrees when it is the row's name with words dropped from its end, and the row's full name
928
- // is what gets reported back, because that is what was chosen and what will be asked for
929
- // next time. A piece from anywhere used to count: "Think deeper" on the button passed for
930
- // "GPT-5.6 Sol Think deeper" (2026-10-05), and the run said it was on a model it was not.
931
- if (buttonShows(after, target.name)) {
932
- this.emit('model-selected', { model: target.name, buttonShows: after });
933
- return { ok: true, current: target.name };
934
- }
994
+ const chosen = await this.chooseAt(match.name, match.locator);
935
995
  return {
936
- ok: false,
937
- current: after,
938
- reason: `The chat reports "${after ?? 'unknown'}" after choosing "${name}".`,
996
+ ...chosen,
997
+ ...(chosen.ok && !sameModel(match.name, name) ? { matched: match.name } : {}),
998
+ ...(chosen.ok ? { by: opts.locator ? 'reread' : 'name' } : {}),
999
+ options,
939
1000
  };
940
1001
  }
941
1002
  /**
@@ -241,6 +241,8 @@ export const Url = {
241
241
  export const Rename = {
242
242
  /** Per-chat overflow button in the sidebar. There is one per chat, so scope it to the row. */
243
243
  moreButtonLabel: 'More',
244
+ /** The same button by the chat it belongs to (seen live 2026-10-05): found without going through the row. */
245
+ moreButtonForChat: (chatId) => `button[data-chat-history-more-button-conversation-id="${chatId}"]`,
244
246
  menuItem: 'Rename',
245
247
  /** The dialog's text input. */
246
248
  input: '#new-session-name',