copilot-operator 0.1.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.
Files changed (128) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +556 -0
  3. package/dist/src/api/app.module.js +18 -0
  4. package/dist/src/api/dataAcl.js +109 -0
  5. package/dist/src/api/folderPicker.js +110 -0
  6. package/dist/src/api/main.js +132 -0
  7. package/dist/src/api/operator.controller.js +975 -0
  8. package/dist/src/api/operator.service.js +1971 -0
  9. package/dist/src/api/saveToDesktop.js +157 -0
  10. package/dist/src/api/security.js +225 -0
  11. package/dist/src/api/settings.js +57 -0
  12. package/dist/src/cli.js +531 -0
  13. package/dist/src/config/layout.js +66 -0
  14. package/dist/src/config/lockedPolicy.js +176 -0
  15. package/dist/src/config/schema.js +487 -0
  16. package/dist/src/context/contextFiles.js +295 -0
  17. package/dist/src/context/desktopMirror.js +113 -0
  18. package/dist/src/context/projectMirror.js +364 -0
  19. package/dist/src/exec/authorizer.js +61 -0
  20. package/dist/src/exec/checks.js +285 -0
  21. package/dist/src/exec/confinement.js +224 -0
  22. package/dist/src/exec/dangerous.js +161 -0
  23. package/dist/src/exec/environment.js +80 -0
  24. package/dist/src/exec/isolation.js +166 -0
  25. package/dist/src/exec/network.js +242 -0
  26. package/dist/src/exec/policy.js +201 -0
  27. package/dist/src/exec/policyManifest.js +84 -0
  28. package/dist/src/exec/processes.js +329 -0
  29. package/dist/src/exec/programs.js +290 -0
  30. package/dist/src/exec/redaction.js +93 -0
  31. package/dist/src/exec/reportFile.js +154 -0
  32. package/dist/src/exec/runner.js +245 -0
  33. package/dist/src/exec/scriptFiles.js +81 -0
  34. package/dist/src/exec/shellExecuteTrap.js +221 -0
  35. package/dist/src/exec/shells.js +298 -0
  36. package/dist/src/exec/stepEnv.js +72 -0
  37. package/dist/src/exec/winps.js +17 -0
  38. package/dist/src/exec/workDir.js +94 -0
  39. package/dist/src/log/runLog.js +43 -0
  40. package/dist/src/orchestrator/derivedChecks.js +95 -0
  41. package/dist/src/orchestrator/review.js +590 -0
  42. package/dist/src/orchestrator/taskRunner.js +1582 -0
  43. package/dist/src/plan/brief.js +1785 -0
  44. package/dist/src/plan/importPlan.js +202 -0
  45. package/dist/src/plan/schema.js +483 -0
  46. package/dist/src/plan/script.js +1012 -0
  47. package/dist/src/plan/systemGuide.js +1851 -0
  48. package/dist/src/protocol/parser.js +224 -0
  49. package/dist/src/protocol/replySchema.js +207 -0
  50. package/dist/src/protocol/reporter.js +75 -0
  51. package/dist/src/protocol/reviewSchema.js +205 -0
  52. package/dist/src/session/compose.js +67 -0
  53. package/dist/src/session/debugExport.js +165 -0
  54. package/dist/src/session/events.js +34 -0
  55. package/dist/src/session/exportRecord.js +261 -0
  56. package/dist/src/session/exports.js +484 -0
  57. package/dist/src/session/model.js +22 -0
  58. package/dist/src/session/retention.js +34 -0
  59. package/dist/src/session/runName.js +36 -0
  60. package/dist/src/session/store.js +440 -0
  61. package/dist/src/session/story.js +198 -0
  62. package/dist/src/transport/acceptance.js +34 -0
  63. package/dist/src/transport/chatSession.js +73 -0
  64. package/dist/src/transport/copilotTransport.js +1208 -0
  65. package/dist/src/transport/edgeCrash.js +59 -0
  66. package/dist/src/transport/locators.js +261 -0
  67. package/dist/src/transport/profileLock.js +102 -0
  68. package/dist/src/util/pacing.js +89 -0
  69. package/dist/src/vcs/commitHygiene.js +102 -0
  70. package/dist/src/vcs/git.js +286 -0
  71. package/dist/src/vcs/taskVcs.js +343 -0
  72. package/dist/web/404.html +15 -0
  73. package/dist/web/_next/static/chunks/12.f1f4d35b52633e8d.js +1 -0
  74. package/dist/web/_next/static/chunks/131-bae98f790a541679.js +1 -0
  75. package/dist/web/_next/static/chunks/197-f7c33a0114fc3db3.js +1 -0
  76. package/dist/web/_next/static/chunks/489.158bbe303a595fba.js +1 -0
  77. package/dist/web/_next/static/chunks/643-34b9f66a6c317c4a.js +1 -0
  78. package/dist/web/_next/static/chunks/867-03b7cac8f79896c3.js +1 -0
  79. package/dist/web/_next/static/chunks/955-63de06e182d98949.js +1 -0
  80. package/dist/web/_next/static/chunks/app/_not-found/page-0d0d8e7e62efca12.js +1 -0
  81. package/dist/web/_next/static/chunks/app/appearance/page-501842e7aeac7f6b.js +1 -0
  82. package/dist/web/_next/static/chunks/app/defaults/page-ed858127cbdfb4ab.js +1 -0
  83. package/dist/web/_next/static/chunks/app/history/page-bad4f3e89b485719.js +1 -0
  84. package/dist/web/_next/static/chunks/app/import/page-db1f5f1ab2f5cad1.js +1 -0
  85. package/dist/web/_next/static/chunks/app/layout-72251c52cc54f95e.js +1 -0
  86. package/dist/web/_next/static/chunks/app/level1/page-cf3e50cf64fecf4f.js +1 -0
  87. package/dist/web/_next/static/chunks/app/page-6fd16e5c8cf3b20f.js +1 -0
  88. package/dist/web/_next/static/chunks/app/presets/page-951e78b80e0e22e6.js +1 -0
  89. package/dist/web/_next/static/chunks/app/sessions/view/page-f2e63618097d7221.js +1 -0
  90. package/dist/web/_next/static/chunks/app/system/page-44e7954c0e48f4ba.js +1 -0
  91. package/dist/web/_next/static/chunks/c7879cf7-b5ab1053c1d9a2e7.js +1 -0
  92. package/dist/web/_next/static/chunks/framework-9ac6a346bd42cedd.js +1 -0
  93. package/dist/web/_next/static/chunks/main-6be4444e2d667e41.js +1 -0
  94. package/dist/web/_next/static/chunks/main-app-e3a5f713ba31557e.js +1 -0
  95. package/dist/web/_next/static/chunks/pages/_app-32d20d5a04949d29.js +1 -0
  96. package/dist/web/_next/static/chunks/pages/_error-abbab53f25ea29b0.js +1 -0
  97. package/dist/web/_next/static/chunks/polyfills-42372ed130431b0a.js +1 -0
  98. package/dist/web/_next/static/chunks/webpack-0f31b926d9a9c5a4.js +1 -0
  99. package/dist/web/_next/static/css/d77735cdc9020a86.css +1 -0
  100. package/dist/web/_next/static/u8rZNwh4d3n-HGJnfZ72Y/_buildManifest.js +1 -0
  101. package/dist/web/_next/static/u8rZNwh4d3n-HGJnfZ72Y/_ssgManifest.js +1 -0
  102. package/dist/web/appearance.html +15 -0
  103. package/dist/web/appearance.txt +26 -0
  104. package/dist/web/defaults.html +15 -0
  105. package/dist/web/defaults.txt +26 -0
  106. package/dist/web/history.html +15 -0
  107. package/dist/web/history.txt +26 -0
  108. package/dist/web/import.html +15 -0
  109. package/dist/web/import.txt +26 -0
  110. package/dist/web/index.html +15 -0
  111. package/dist/web/index.txt +26 -0
  112. package/dist/web/level1.html +15 -0
  113. package/dist/web/level1.txt +26 -0
  114. package/dist/web/presets.html +18 -0
  115. package/dist/web/presets.txt +26 -0
  116. package/dist/web/sessions/view.html +15 -0
  117. package/dist/web/sessions/view.txt +26 -0
  118. package/dist/web/system.html +15 -0
  119. package/dist/web/system.txt +26 -0
  120. package/package.json +111 -0
  121. package/prompts/level1.md +535 -0
  122. package/prompts/organisation.bg.md +44 -0
  123. package/prompts/organisation.en.md +44 -0
  124. package/prompts/persona.bg.md +17 -0
  125. package/prompts/persona.en.md +17 -0
  126. package/prompts/review1.md +277 -0
  127. package/prompts/work.bg.md +19 -0
  128. package/prompts/work.en.md +19 -0
@@ -0,0 +1,1208 @@
1
+ /**
2
+ * Drives the Microsoft 365 Copilot web app through Playwright.
3
+ *
4
+ * Every DOM fact this file relies on was captured from the live, signed-in app and is
5
+ * recorded in `docs/locators-findings.md`. The three that shape the code:
6
+ *
7
+ * 1. Code blocks are virtualized and interleave line numbers with the code, and have been
8
+ * observed dropping a chunk of a valid JSON reply. Replies are therefore read by
9
+ * clicking "Copy Response" and reading the clipboard, never by scraping the DOM.
10
+ * 2. Enter does not submit the composer. The Send button has to be clicked.
11
+ * 3. A message cannot consist of an attachment alone; Send stays disabled without text.
12
+ */
13
+ import { chromium } from 'playwright';
14
+ import { mkdir } from 'node:fs/promises';
15
+ import { join } from 'node:path';
16
+ import { findRecentCrash, describeCrash } from './edgeCrash.js';
17
+ import { landed } from './acceptance.js';
18
+ import { Blocker, Css, Label, Model, Rename, Sidebar, Signal, Surface, TestId, Upload, Url } from './locators.js';
19
+ import { acquireProfileLock } from './profileLock.js';
20
+ import { parseChatId } from './chatSession.js';
21
+ const ORIGIN = 'https://m365.cloud.microsoft';
22
+ /** Escapes a model name so it can be matched literally inside a regular expression. */
23
+ function escapeForRegExp(text) {
24
+ return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
25
+ }
26
+ /**
27
+ * The chat refused the message: it never became a turn, so no reply is coming.
28
+ *
29
+ * Distinct from a timeout on purpose. A timeout means "it is taking too long"; this means
30
+ * "it will never arrive", and the caller can retry the send instead of waiting.
31
+ */
32
+ export class SendRejectedError extends Error {
33
+ constructor(message) {
34
+ super(message);
35
+ this.name = 'SendRejectedError';
36
+ }
37
+ }
38
+ /**
39
+ * Takes the copy button off the operator's clipboard.
40
+ *
41
+ * Reading a reply means clicking Copilot's "Copy Response" and taking the raw markdown, which
42
+ * is the only lossless way to get it: the rendered DOM mangles code fences. The obvious
43
+ * implementation — click, then read the clipboard — quietly makes the machine unusable while a
44
+ * run is going. Every reply overwrites whatever the person had copied, so their next paste is a
45
+ * page of somebody else's markdown; and in the gap between the click and the read, anything
46
+ * *they* copy is what the runner picks up and parses as Copilot's answer. Both have happened.
47
+ *
48
+ * So the clipboard is removed from the path entirely. This script replaces the page's own way
49
+ * of copying with one that hands the text to a variable on `window` and never calls through, so
50
+ * pressing copy in this browser writes nothing anywhere on the machine. The runner then reads
51
+ * the variable. Nothing is written to the system clipboard and nothing is read from it, which
52
+ * means the person can copy and paste all day while a run is in progress and neither can reach
53
+ * the other.
54
+ *
55
+ * Both routes a web page has are covered: the modern `navigator.clipboard` calls and the older
56
+ * hidden-textarea-plus-`execCommand('copy')` trick. The counter is what makes a stale read
57
+ * impossible — the runner notes it before the click and waits for it to move, so a copy that
58
+ * did not happen reads as a failure rather than as the previous answer.
59
+ */
60
+ export const CLIPBOARD_GUARD = `(() => {
61
+ const state = { text: '', seq: 0 };
62
+ try {
63
+ Object.defineProperty(window, '__copClipboard', { value: state, enumerable: false });
64
+ } catch (e) {
65
+ window.__copClipboard = state;
66
+ }
67
+
68
+ const remember = (text) => {
69
+ if (typeof text !== 'string' || text.length === 0) return;
70
+ state.text = text;
71
+ state.seq += 1;
72
+ };
73
+
74
+ const clip = navigator.clipboard;
75
+ if (clip) {
76
+ try {
77
+ Object.defineProperty(clip, 'writeText', {
78
+ configurable: true,
79
+ writable: true,
80
+ value: (text) => {
81
+ remember(String(text));
82
+ return Promise.resolve();
83
+ },
84
+ });
85
+ } catch (e) { /* a frame that will not let its clipboard be replaced reads as a failure later */ }
86
+
87
+ try {
88
+ Object.defineProperty(clip, 'write', {
89
+ configurable: true,
90
+ writable: true,
91
+ value: async (items) => {
92
+ for (const item of items || []) {
93
+ try {
94
+ if (item && item.types && item.types.indexOf('text/plain') >= 0) {
95
+ remember(await (await item.getType('text/plain')).text());
96
+ }
97
+ } catch (e) { /* one unreadable item is not a reason to drop the rest */ }
98
+ }
99
+ },
100
+ });
101
+ } catch (e) { /* as above */ }
102
+ }
103
+
104
+ // The old way: select text in a hidden field, then execCommand('copy'). The selection is
105
+ // exactly the text being copied, so it is taken and the command is never run.
106
+ try {
107
+ const realExec = document.execCommand.bind(document);
108
+ document.execCommand = (command, ...rest) => {
109
+ if (String(command).toLowerCase() === 'copy') {
110
+ const selected = String(window.getSelection() || '');
111
+ if (selected.length > 0) {
112
+ remember(selected);
113
+ return true;
114
+ }
115
+ }
116
+ return realExec(command, ...rest);
117
+ };
118
+ } catch (e) { /* as above */ }
119
+ })();`;
120
+ export class CopilotTransport {
121
+ opts;
122
+ context = null;
123
+ page = null;
124
+ lock = null;
125
+ constructor(opts) {
126
+ this.opts = opts;
127
+ }
128
+ emit(event, detail) {
129
+ this.opts.onEvent?.(event, detail);
130
+ }
131
+ get p() {
132
+ if (!this.page)
133
+ throw new Error('Transport is not open. Call open() first.');
134
+ return this.page;
135
+ }
136
+ async open() {
137
+ // Fails loudly when another Edge holds the profile, instead of letting Playwright
138
+ // report a closed browser with no explanation.
139
+ this.lock = acquireProfileLock(this.opts.profileDir);
140
+ await mkdir(this.opts.profileDir, { recursive: true });
141
+ await mkdir(this.opts.transportDir, { recursive: true });
142
+ this.context = await chromium.launchPersistentContext(this.opts.profileDir, {
143
+ channel: this.opts.channel,
144
+ headless: this.opts.headless,
145
+ /*
146
+ * No downloads, at the level of the browser itself. The runner takes a reply as text only —
147
+ * the JSON of command steps, copied out of the answer — and has taken nothing else since file
148
+ * steps were removed. Nothing in this code clicks a download link, but the window was still
149
+ * opened willing to accept one, so a click from anywhere would have saved a file the bot had no
150
+ * business having. Refused here, a download is cancelled by the browser before it is written.
151
+ */
152
+ acceptDownloads: false,
153
+ /*
154
+ * Edge keeps its own sandbox. Playwright starts Chromium with `--no-sandbox` unless told
155
+ * otherwise, and a browser without its sandbox, started by node.exe with a debugging channel
156
+ * open, is the shape endpoint tooling looks for when a program is after the cookies of a
157
+ * signed-in session. The automation does not need the sandbox off.
158
+ */
159
+ chromiumSandbox: true,
160
+ viewport: null,
161
+ args: ['--start-maximized'],
162
+ });
163
+ // Replies are captured without the machine's clipboard ever being touched. See CLIPBOARD_GUARD.
164
+ await this.context.addInitScript(CLIPBOARD_GUARD).catch(() => this.emit('clipboard-guard-failed'));
165
+ this.page = this.context.pages()[0] ?? (await this.context.newPage());
166
+ this.page.setDefaultTimeout(60_000);
167
+ this.emit('browser-opened', { profileDir: this.opts.profileDir });
168
+ }
169
+ async close() {
170
+ await this.context?.close().catch(() => undefined);
171
+ this.context = null;
172
+ this.page = null;
173
+ this.lock?.release();
174
+ this.lock = null;
175
+ }
176
+ /** True when the composer is visible, i.e. we are signed in and the chat is usable. */
177
+ async isChatReady(timeoutMs = 5_000) {
178
+ try {
179
+ await this.composer().waitFor({ state: 'visible', timeout: timeoutMs });
180
+ return true;
181
+ }
182
+ catch {
183
+ return false;
184
+ }
185
+ }
186
+ composer() {
187
+ return this.p.locator(Css.composer).first();
188
+ }
189
+ /**
190
+ * Which Copilot we actually landed on.
191
+ *
192
+ * `m365.cloud.microsoft` silently redirects to the consumer Copilot when the profile is
193
+ * not signed in with a work account. Both have a message box, so "the composer is visible"
194
+ * is not proof. It matters because the consumer surface runs human verification and the
195
+ * work surface does not, so ending up there looks exactly like being detected as a bot
196
+ * when it is really a sign-in problem.
197
+ */
198
+ async surface() {
199
+ const url = this.p.url();
200
+ if (url.includes(Signal.loginHost))
201
+ return 'sign-in';
202
+ if (Surface.consumerHosts.some((h) => url.includes(h)))
203
+ return 'consumer';
204
+ if (url.includes(Surface.workHost)) {
205
+ const hasWorkComposer = (await this.p.locator(Surface.workMarkers[0]).count()) > 0;
206
+ return hasWorkComposer ? 'work' : 'unknown';
207
+ }
208
+ return 'unknown';
209
+ }
210
+ /**
211
+ * Email addresses visible in the page, as a way to tell which account is signed in.
212
+ *
213
+ * There is no supported API for "who is signed in to this web app", and the account
214
+ * flyout's markup is not something to depend on, so this scrapes the rendered HTML for
215
+ * address-shaped strings. It is a diagnostic, not a security check: it exists so that
216
+ * signing in as the wrong user is caught immediately instead of three failures later.
217
+ */
218
+ async findAccountsInPage() {
219
+ return await this.p
220
+ .evaluate(() => {
221
+ const html = document.documentElement.innerHTML;
222
+ const found = html.match(/[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g) ?? [];
223
+ const junk = /(\.png|\.jpg|\.svg|\.gif|@2x|@3x|sentry|example\.com|microsoft\.com$)/i;
224
+ return [...new Set(found.filter((e) => !junk.test(e)))].slice(0, 10);
225
+ })
226
+ .catch(() => []);
227
+ }
228
+ /**
229
+ * Signs the browser out of Microsoft, so the next visit cannot silently reuse a session.
230
+ *
231
+ * Needed because Edge on a domain-joined machine will happily sign the profile in with
232
+ * whatever account Windows knows about, which is how the wrong user ends up in the chat
233
+ * without anyone choosing it.
234
+ */
235
+ async signOut() {
236
+ this.emit('signing-out');
237
+ await this.p
238
+ .goto('https://login.microsoftonline.com/common/oauth2/v2.0/logout', {
239
+ waitUntil: 'domcontentloaded',
240
+ timeout: 60_000,
241
+ })
242
+ .catch(() => undefined);
243
+ await this.p.waitForTimeout(3_000);
244
+ await this.context?.clearCookies().catch(() => undefined);
245
+ this.emit('signed-out');
246
+ }
247
+ /**
248
+ * Opens the chat asking for a specific account.
249
+ *
250
+ * `login_hint` tells Microsoft which account to use and `prompt=select_account` stops it
251
+ * picking one for you. Neither is a guarantee, so the caller still verifies afterwards.
252
+ */
253
+ async gotoChatAs(upn, url = this.opts.chatUrl) {
254
+ const target = new URL(url);
255
+ if (upn) {
256
+ target.searchParams.set('login_hint', upn);
257
+ target.searchParams.set('prompt', 'select_account');
258
+ }
259
+ await this.p.goto(target.toString(), { waitUntil: 'domcontentloaded' });
260
+ }
261
+ /** Throws with an explanation when the profile is not on the work Copilot. */
262
+ async assertWorkSurface() {
263
+ const where = await this.surface();
264
+ if (where === 'work')
265
+ return;
266
+ const url = this.p.url();
267
+ const explain = {
268
+ consumer: 'This is the consumer Copilot, not Microsoft 365 Copilot. The profile is signed in ' +
269
+ 'with a personal account, or not signed in with a work account at all. Run ' +
270
+ '"cop login" and sign in with the work or school account.',
271
+ 'sign-in': 'The profile is signed out. Run "cop login".',
272
+ unknown: 'The page is not the Microsoft 365 Copilot chat.',
273
+ };
274
+ throw new Error(`${explain[where] ?? explain.unknown}
275
+ Current URL: ${url}`);
276
+ }
277
+ /**
278
+ * Something in the page that only a person can clear.
279
+ *
280
+ * The bot never clicks a verification checkbox and never attempts a challenge. It reports
281
+ * what it sees and waits for the human, which is the difference between a run that pauses
282
+ * with a clear instruction and one that times out with a confusing error.
283
+ */
284
+ async detectBlocker() {
285
+ try {
286
+ const text = (await this.p.locator('body').innerText({ timeout: 5_000 }).catch(() => '')) ?? '';
287
+ const phrase = Blocker.verificationText.find((t) => text.includes(t));
288
+ if (phrase)
289
+ return { kind: 'verification', reason: `page text contains "${phrase}"` };
290
+ const frames = this.p.frames().map((f) => f.url());
291
+ const frame = frames.find((u) => Blocker.challengeFrameHosts.some((h) => u.includes(h)));
292
+ if (frame)
293
+ return { kind: 'verification', reason: `challenge iframe: ${frame.slice(0, 120)}` };
294
+ const banner = Blocker.errorBannerText.find((t) => text.includes(t));
295
+ if (banner)
296
+ return { kind: 'error-banner', reason: `page text contains "${banner}"` };
297
+ }
298
+ catch {
299
+ /* a closed or navigating page is handled by the caller */
300
+ }
301
+ return { kind: 'none', reason: '' };
302
+ }
303
+ /**
304
+ * Waits for a human to clear a challenge, or clears a transient error banner itself.
305
+ *
306
+ * Returns true when the page became usable again. The checkbox is deliberately left
307
+ * untouched: completing a human-verification check is the human's part of this.
308
+ */
309
+ async handleBlocker(found) {
310
+ const { kind, reason } = found;
311
+ // Evidence first: whatever happens next, there is a picture of what the page looked like.
312
+ await this.dumpFailure(join(this.opts.transportDir, '..', 'failures'), `blocker-${kind}-${Date.now()}`)
313
+ .catch(() => undefined);
314
+ if (kind === 'error-banner') {
315
+ this.emit('error-banner', { action: 'reloading', reason });
316
+ const refresh = this.p.getByRole('button', { name: Blocker.refreshLabel, exact: true });
317
+ if ((await refresh.count()) > 0)
318
+ await refresh.first().click().catch(() => undefined);
319
+ else
320
+ await this.p.reload({ waitUntil: 'domcontentloaded' }).catch(() => undefined);
321
+ await this.p.waitForTimeout(3_000);
322
+ return (await this.detectBlocker()).kind === 'none';
323
+ }
324
+ const waitMs = this.opts.humanWaitMs ?? 15 * 60_000;
325
+ this.emit('verification-required', { waitMs, reason });
326
+ const deadline = Date.now() + waitMs;
327
+ while (Date.now() < deadline) {
328
+ await this.p.waitForTimeout(3_000);
329
+ if ((await this.detectBlocker()).kind === 'none') {
330
+ this.emit('verification-cleared');
331
+ return true;
332
+ }
333
+ }
334
+ return false;
335
+ }
336
+ /** Detects a blocker and waits it out. Throws when it is still there after the wait. */
337
+ async clearBlockers() {
338
+ /*
339
+ * Both of these run before every message, not only when a conversation is opened.
340
+ *
341
+ * Edge's first-run dialogs and the consent banner do not wait for a convenient moment; they
342
+ * appear when they appear, sit over the chat, and block the composer. A run that met one
343
+ * mid-task did not fail at the dialog — it failed several steps later, looking like
344
+ * something else. Declining them before each send costs two locator lookups.
345
+ */
346
+ await this.dismissPopups().catch(() => undefined);
347
+ await this.dismissFeedbackPanel().catch(() => undefined);
348
+ for (let i = 0; i < 3; i += 1) {
349
+ const found = await this.detectBlocker();
350
+ if (found.kind === 'none')
351
+ return;
352
+ const cleared = await this.handleBlocker(found);
353
+ if (cleared)
354
+ return;
355
+ if (found.kind === 'verification') {
356
+ throw new Error('The chat is showing a human-verification challenge and it was not cleared in time. ' +
357
+ 'Complete it in the open Edge window, then start the run again. ' +
358
+ 'The bot does not attempt verification challenges by design.');
359
+ }
360
+ }
361
+ throw new Error('The chat kept reporting an error after several reloads.');
362
+ }
363
+ /**
364
+ * Opens the chat and, if the tenant has signed us out, waits for the human. The bot never
365
+ * types credentials; it only waits for the composer to appear.
366
+ */
367
+ async ensureSignedIn(url = this.opts.chatUrl) {
368
+ await this.p.goto(url, { waitUntil: 'domcontentloaded' });
369
+ if (!(await this.isChatReady(15_000))) {
370
+ this.emit('sign-in-required', { url: this.p.url() });
371
+ await this.composer().waitFor({ state: 'visible', timeout: this.opts.signInTimeoutMs });
372
+ this.emit('signed-in');
373
+ }
374
+ await this.clearBlockers();
375
+ await this.assertWorkSurface();
376
+ }
377
+ async newChat() {
378
+ await this.p.goto(Url.newChat, { waitUntil: 'domcontentloaded' });
379
+ await this.composer().waitFor({ state: 'visible' });
380
+ await this.dismissPopups();
381
+ }
382
+ /** Reopens a conversation by its id. Returns false when it no longer opens. */
383
+ async openConversation(chatId) {
384
+ await this.p.goto(Url.conversation(chatId), { waitUntil: 'domcontentloaded' });
385
+ if (!(await this.isChatReady(20_000)))
386
+ return false;
387
+ return parseChatId(this.p.url()) === chatId;
388
+ }
389
+ /** Finds a conversation by its exact name in the sidebar. */
390
+ async openConversationByName(name) {
391
+ const row = this.p.locator(`${Sidebar.conversationLink}[aria-label="${name.replace(/"/g, '\\"')}"]`);
392
+ if ((await row.count()) === 0)
393
+ return false;
394
+ await row.first().click();
395
+ return await this.isChatReady(20_000);
396
+ }
397
+ async currentChatId() {
398
+ return parseChatId(this.p.url());
399
+ }
400
+ /**
401
+ * Best-effort dismissal of first-run dialogs. Unknown ones are left alone and reported.
402
+ *
403
+ * Every label here **declines**, and that is the whole rule. Edge's own first-run dialog
404
+ * offers "Confirm" next to "Set later", and confirming makes Edge the machine's default
405
+ * browser — a system setting, changed by a bot, because a task happened to start while the
406
+ * dialog was open. The consent banner underneath it offers "I Accept" next to "Reject All",
407
+ * and accepting agrees to tracking on the operator's behalf. So: "Set later", never
408
+ * "Confirm"; "Reject All", never "I Accept". A dialog whose only option is to agree to
409
+ * something is not dismissed at all — it is reported and left for a person.
410
+ *
411
+ * This matters more than it looks. The dialogs sit over the chat and block the composer, so a
412
+ * run that meets one does not fail cleanly; it fails somewhere further on, looking like
413
+ * something else entirely.
414
+ */
415
+ async dismissPopups() {
416
+ for (const name of ['Got it', 'Close', 'Dismiss', 'No thanks', 'Skip', 'Set later', 'Not now', 'Maybe later', 'Reject All', 'Reject all']) {
417
+ const b = this.p.getByRole('button', { name, exact: true });
418
+ if ((await b.count()) > 0 && (await b.first().isVisible().catch(() => false))) {
419
+ await b.first().click().catch(() => undefined);
420
+ this.emit('popup-dismissed', { name });
421
+ }
422
+ }
423
+ }
424
+ /**
425
+ * Renames the current conversation. The sidebar row's overflow menu holds "Rename", the
426
+ * dialog holds an input and a Save button, and Enter does not submit it.
427
+ */
428
+ async nameChat(chatId, name) {
429
+ const row = this.p.locator(`${Sidebar.conversationLink}[href*="${chatId}"]`).first();
430
+ if ((await row.count()) === 0)
431
+ return false;
432
+ /*
433
+ * Every step here waits for what it is about to click.
434
+ *
435
+ * `count()` does not wait. It was used for both the overflow button and the menu item, so
436
+ * a menu that had not finished rendering read as "not there", the method returned false —
437
+ * and left the menu standing open over the sidebar, on whichever chat it had been opened
438
+ * for. The rename then silently never happened, which is why conversations kept Copilot's
439
+ * own auto-generated titles instead of the name this runner gave them.
440
+ */
441
+ let opened = false;
442
+ try {
443
+ await row.hover();
444
+ const more = row
445
+ .locator('xpath=ancestor-or-self::*[self::li or self::div][1]')
446
+ .getByRole('button', { name: Rename.moreButtonLabel, exact: true })
447
+ .first();
448
+ await more.waitFor({ state: 'visible', timeout: 5_000 });
449
+ await more.click();
450
+ opened = true;
451
+ const item = this.p.getByRole('menuitem', { name: Rename.menuItem, exact: true }).first();
452
+ await item.waitFor({ state: 'visible', timeout: 5_000 });
453
+ await item.click();
454
+ opened = false;
455
+ const input = this.p.locator(Rename.input);
456
+ await input.waitFor({ state: 'visible', timeout: 10_000 });
457
+ await input.fill(name.slice(0, Rename.maxLength));
458
+ await this.p.getByRole('button', { name: Rename.saveText, exact: true }).first().click();
459
+ await input.waitFor({ state: 'detached', timeout: 10_000 }).catch(() => undefined);
460
+ this.emit('chat-named', { chatId, name });
461
+ return true;
462
+ }
463
+ catch (e) {
464
+ this.emit('chat-name-failed', { chatId, name, error: e.message });
465
+ return false;
466
+ }
467
+ finally {
468
+ // Whatever happened, the sidebar is left as it was found. A menu left hanging over the
469
+ // chat list is not only untidy: it covers the rows and swallows the next click.
470
+ if (opened)
471
+ await this.dismissOpenMenu();
472
+ }
473
+ }
474
+ /**
475
+ * Closes whatever menu or dialog is open, without caring which one it is.
476
+ *
477
+ * Escape first, because it is what the UI itself listens for; a click on a dead area of the
478
+ * page as the fallback for a menu that ignores it. Neither is allowed to throw: this runs in
479
+ * a `finally`, and a cleanup that can fail the operation it is cleaning up after is worse
480
+ * than the mess it was trying to tidy.
481
+ */
482
+ async dismissOpenMenu() {
483
+ try {
484
+ await this.p.keyboard.press('Escape');
485
+ await this.p.waitForTimeout(150);
486
+ const open = this.p.locator('[role="menu"]');
487
+ if ((await open.count()) > 0) {
488
+ await this.p.mouse.click(4, 4);
489
+ await this.p.waitForTimeout(150);
490
+ }
491
+ }
492
+ catch {
493
+ /* the page may be navigating; a leftover menu is not worth an exception */
494
+ }
495
+ }
496
+ // --- the model picker ---------------------------------------------------------------
497
+ /**
498
+ * Which model the chat is set to, as the picker button reports it.
499
+ *
500
+ * Returns null when the picker is not in the page at all, which is a real state: some
501
+ * tenants do not expose a choice, and that is worth showing to the user as "this tenant
502
+ * offers no choice" rather than as an error.
503
+ */
504
+ async currentModel() {
505
+ const button = await this.resolveModelButton(5_000);
506
+ if (!button)
507
+ return null;
508
+ const text = ((await button.innerText().catch(() => '')) ?? '').trim();
509
+ const label = (await button.getAttribute('aria-label').catch(() => null)) ?? '';
510
+ // The aria-label is the control's name ("Model Selector"); the text is the value ("Auto").
511
+ return text.length > 0 ? text.split('\n')[0].trim() : label.replace(Model.buttonLabel, '').trim() || null;
512
+ }
513
+ modelButton() {
514
+ const byId = this.p.locator(Model.button);
515
+ return byId;
516
+ }
517
+ /**
518
+ * The picker button, by id if it is there, otherwise by its accessible name.
519
+ *
520
+ * It waits rather than looking once. The composer is ready well before the toolbar around
521
+ * it finishes hydrating, so an immediate check after `ensureSignedIn` finds nothing and
522
+ * reports "this tenant has no model picker" about a tenant that plainly does. That is
523
+ * exactly what the first live read did, and a four-second pause in a probe is what showed
524
+ * it. Waiting is also the right answer for a slow morning on a cold profile.
525
+ */
526
+ async resolveModelButton(timeoutMs = 15_000) {
527
+ const deadline = Date.now() + timeoutMs;
528
+ do {
529
+ const byId = this.modelButton();
530
+ if ((await byId.count().catch(() => 0)) > 0)
531
+ return byId.first();
532
+ const byLabel = this.p.getByRole('button', { name: Model.buttonLabel, exact: false });
533
+ if ((await byLabel.count().catch(() => 0)) > 0)
534
+ return byLabel.first();
535
+ await this.p.waitForTimeout(500);
536
+ } while (Date.now() < deadline);
537
+ return null;
538
+ }
539
+ /**
540
+ * Opens the picker and reads what it offers, then closes it again without choosing.
541
+ *
542
+ * The list is whatever this tenant shows today. Nothing is filtered and nothing is
543
+ * translated: a name shown here is the name the chat uses, so clicking it later is an exact
544
+ * match rather than a guess. `raw` keeps the option's whole text, which is where Microsoft
545
+ * puts the one-line description and any "limit reached" notice.
546
+ */
547
+ async listModels() {
548
+ const button = await this.resolveModelButton();
549
+ if (!button) {
550
+ return { options: [], current: null, note: 'This chat does not show a model picker, so there is nothing to choose.' };
551
+ }
552
+ const current = await this.currentModel();
553
+ await button.click();
554
+ if (!(await this.waitForPopup())) {
555
+ await this.closeMenu();
556
+ return { options: [], current, note: 'The model picker opened nothing that could be read.' };
557
+ }
558
+ const top = await this.readMenuRows();
559
+ await this.closeMenu();
560
+ const options = top.filter((r) => !r.opensSubmenu).map(({ opensSubmenu, ...o }) => o);
561
+ const groups = top.filter((r) => r.opensSubmenu);
562
+ const topNames = new Set(top.map((r) => r.name.toLowerCase()));
563
+ // Each group is opened in its own pass, from a freshly opened menu. Walking several
564
+ // submenus in one pass works until one of them closes the one above it, and then the
565
+ // reader silently returns half a list; re-opening costs a second and cannot go wrong.
566
+ for (const group of groups) {
567
+ const children = await this.readSubmenu(button, group.name, topNames);
568
+ for (const child of children) {
569
+ options.push({ ...child, group: group.name, groupRaw: group.raw });
570
+ }
571
+ }
572
+ this.emit('models-read', { count: options.length, groups: groups.length, current });
573
+ return { options, current };
574
+ }
575
+ /** Every visible menu row right now, across the menu and whatever submenu is open. */
576
+ async readMenuRows() {
577
+ return await this.p.evaluate(({ roles, selectedAttrs, submenuAttrs }) => {
578
+ const seen = new Set();
579
+ const out = [];
580
+ for (const role of roles) {
581
+ for (const el of Array.from(document.querySelectorAll(`[role="${role}"]`))) {
582
+ // A menu that is closed is still in the DOM, so visibility is what separates
583
+ // what is on screen from what was on screen a moment ago.
584
+ if (el.getClientRects().length === 0)
585
+ continue;
586
+ const raw = (el.innerText || '').trim();
587
+ const label = el.getAttribute('aria-label')?.trim() ?? '';
588
+ // The first line is the name; the description sits underneath it.
589
+ const name = (raw.split('\n')[0] || label || '').trim();
590
+ if (!name || seen.has(name.toLowerCase()))
591
+ continue;
592
+ seen.add(name.toLowerCase());
593
+ out.push({
594
+ name,
595
+ raw,
596
+ selected: selectedAttrs.some((a) => el.getAttribute(a) === 'true'),
597
+ disabled: el.getAttribute('aria-disabled') === 'true' || el.hasAttribute('disabled'),
598
+ role,
599
+ opensSubmenu: submenuAttrs.some((a) => {
600
+ const v = el.getAttribute(a);
601
+ return a === 'aria-haspopup' ? v === 'menu' || v === 'true' : v !== null;
602
+ }),
603
+ });
604
+ }
605
+ }
606
+ return out;
607
+ }, {
608
+ roles: [...Model.optionRoles],
609
+ selectedAttrs: [...Model.selectedAttributes],
610
+ submenuAttrs: [...Model.submenuAttributes],
611
+ });
612
+ }
613
+ /**
614
+ * Opens one group and reads what is inside it.
615
+ *
616
+ * The children are found by difference: whatever is on screen that was not on the top
617
+ * level belongs to the submenu that was just opened. That avoids having to identify which
618
+ * popup container is which, which is Fluent's business and changes with its internals.
619
+ */
620
+ async readSubmenu(button, groupName, topNames) {
621
+ await button.click();
622
+ if (!(await this.waitForPopup()))
623
+ return [];
624
+ const trigger = this.menuRow(groupName);
625
+ if ((await trigger.count()) === 0) {
626
+ await this.closeMenu();
627
+ return [];
628
+ }
629
+ // Hover is how these open; a click is the fallback for a build that wants one.
630
+ await trigger.hover().catch(() => undefined);
631
+ let children = await this.waitForNewRows(topNames);
632
+ if (children.length === 0) {
633
+ await trigger.click().catch(() => undefined);
634
+ children = await this.waitForNewRows(topNames);
635
+ }
636
+ await this.closeMenu();
637
+ return children;
638
+ }
639
+ /** Waits for rows to appear that were not on the top level, and returns them. */
640
+ async waitForNewRows(known, timeoutMs = 4_000) {
641
+ const deadline = Date.now() + timeoutMs;
642
+ do {
643
+ const rows = (await this.readMenuRows()).filter((r) => !known.has(r.name.toLowerCase()));
644
+ if (rows.length > 0)
645
+ return rows;
646
+ await this.p.waitForTimeout(250);
647
+ } while (Date.now() < deadline);
648
+ return [];
649
+ }
650
+ /** One visible menu row, matched on the first line of its text. */
651
+ menuRow(name) {
652
+ const selector = Model.optionRoles.map((r) => `[role="${r}"]:visible`).join(', ');
653
+ return this.p.locator(selector).filter({ hasText: new RegExp(`^${escapeForRegExp(name)}`, 'i') }).first();
654
+ }
655
+ /**
656
+ * Chooses one row of the open picker.
657
+ *
658
+ * A row inside a submenu is clicked on the element itself, in the page, and not with the mouse.
659
+ * A mouse click first travels to the row, and on the way it crosses the other rows of the menu
660
+ * above; Fluent opens or re-renders their submenus as the pointer passes, the row being aimed at
661
+ * is replaced, and Playwright starts again — "element is not stable", "element was detached from
662
+ * the DOM, retrying" — for as long as the page's default timeout allows. Live, that was 38
663
+ * seconds of a window stuck on the model menu with "GPT 5.6 Think deeper" in plain sight, until
664
+ * the window was closed and the task failed with "Target page, context or browser has been
665
+ * closed". A click dispatched on the element does not move the pointer, so nothing closes under
666
+ * it.
667
+ *
668
+ * Either way the attempt is short: the caller reads the model button back to decide whether the
669
+ * choice took, so a click that did not land costs seconds and says so, rather than a minute.
670
+ */
671
+ async clickMenuRow(name, inSubmenu) {
672
+ const row = this.menuRow(name);
673
+ if (inSubmenu) {
674
+ const dispatched = await row
675
+ .evaluate((el) => el.click(), undefined, { timeout: 5_000 })
676
+ .then(() => true)
677
+ .catch(() => false);
678
+ if (dispatched)
679
+ return;
680
+ }
681
+ await row.click({ timeout: 8_000 }).catch(() => undefined);
682
+ }
683
+ /** Closes the picker, submenu included. */
684
+ async closeMenu() {
685
+ for (let i = 0; i < 3; i += 1) {
686
+ await this.p.keyboard.press('Escape').catch(() => undefined);
687
+ await this.p.waitForTimeout(200);
688
+ if ((await this.readMenuRows()).length === 0)
689
+ return;
690
+ }
691
+ }
692
+ /** The first popup container that becomes visible after the picker is clicked. */
693
+ async waitForPopup(timeoutMs = 8_000) {
694
+ const deadline = Date.now() + timeoutMs;
695
+ while (Date.now() < deadline) {
696
+ for (const selector of Model.popupSelectors) {
697
+ const candidate = this.p.locator(selector);
698
+ const n = await candidate.count().catch(() => 0);
699
+ for (let i = 0; i < n; i += 1) {
700
+ const one = candidate.nth(i);
701
+ if (await one.isVisible().catch(() => false))
702
+ return one;
703
+ }
704
+ }
705
+ await this.p.waitForTimeout(250);
706
+ }
707
+ return null;
708
+ }
709
+ /**
710
+ * Sets the chat to a model by the exact name the picker showed.
711
+ *
712
+ * Verified by reading the button back rather than by trusting the click, because a menu
713
+ * item that is out of quota can look clicked and change nothing. When it did not take, the
714
+ * caller is told what the chat is actually on, which is the honest answer.
715
+ */
716
+ async selectModel(name) {
717
+ const button = await this.resolveModelButton();
718
+ if (!button)
719
+ return { ok: false, current: null, reason: 'This chat does not show a model picker.' };
720
+ const before = await this.currentModel();
721
+ if (before && before.toLowerCase() === name.toLowerCase())
722
+ return { ok: true, current: before };
723
+ await button.click();
724
+ if (!(await this.waitForPopup()))
725
+ return { ok: false, current: before, reason: 'The model picker did not open.' };
726
+ // The menu is walked rather than replayed from a saved path. A cached path goes stale the
727
+ // moment Microsoft moves a model between groups, and a name is what the user chose.
728
+ const top = await this.readMenuRows();
729
+ const wanted = (r) => r.name.toLowerCase() === name.toLowerCase();
730
+ let target = top.find((r) => wanted(r) && !r.opensSubmenu);
731
+ const inSubmenu = !target;
732
+ if (!target) {
733
+ const topNames = new Set(top.map((r) => r.name.toLowerCase()));
734
+ for (const group of top.filter((r) => r.opensSubmenu)) {
735
+ const trigger = this.menuRow(group.name);
736
+ await trigger.hover().catch(() => undefined);
737
+ let children = await this.waitForNewRows(topNames);
738
+ if (children.length === 0) {
739
+ await trigger.click().catch(() => undefined);
740
+ children = await this.waitForNewRows(topNames);
741
+ }
742
+ const found = children.find(wanted);
743
+ if (found) {
744
+ target = found;
745
+ break;
746
+ }
747
+ }
748
+ }
749
+ if (!target) {
750
+ await this.closeMenu();
751
+ return { ok: false, current: before, reason: `The picker does not offer "${name}" any more.` };
752
+ }
753
+ if (target.disabled) {
754
+ await this.closeMenu();
755
+ return { ok: false, current: before, reason: `"${name}" is shown but not available right now.` };
756
+ }
757
+ await this.clickMenuRow(target.name, inSubmenu);
758
+ await this.p.waitForTimeout(1_000);
759
+ const after = await this.currentModel();
760
+ // The button shows the choice, so it is the check. It also **shortens** it: picking
761
+ // "GPT 5.6 Quick response" leaves the button reading "GPT 5.6 Quick". So a shown value
762
+ // that is a piece of the asked one counts as agreement, and the full name is what gets
763
+ // reported back, because that is what was actually chosen and what will be asked for
764
+ // again next time.
765
+ const asked = name.toLowerCase();
766
+ const shown = (after ?? '').toLowerCase();
767
+ if (shown && (shown === asked || shown.includes(asked) || asked.includes(shown))) {
768
+ const settled = asked.includes(shown) ? name : after;
769
+ this.emit('model-selected', { model: settled, buttonShows: after });
770
+ return { ok: true, current: settled };
771
+ }
772
+ return {
773
+ ok: false,
774
+ current: after,
775
+ reason: `The chat reports "${after ?? 'unknown'}" after choosing "${name}".`,
776
+ };
777
+ }
778
+ /**
779
+ * How many messages the conversation holds — not how many the page has rendered.
780
+ *
781
+ * Copilot virtualises a long conversation: a saved page of a 48-message chat held four turn
782
+ * elements, each carrying `aria-setsize="48"`. Acceptance and reply detection used to be
783
+ * "the number of turn elements went up", which stops being true the moment the window is
784
+ * full. After re-entering a long chat for the third task of a session, a findings message
785
+ * landed — twice, and drew a reply — and was declared "not accepted", and the task failed.
786
+ * The app says the real size itself, in the accessibility attribute; that is what is
787
+ * counted, with the rendered count as the fallback for a page that does not carry it.
788
+ */
789
+ async turnCount() {
790
+ return await this.p.evaluate((turnSel) => {
791
+ const nodes = Array.from(document.querySelectorAll(turnSel));
792
+ let size = 0;
793
+ for (const n of nodes) {
794
+ const carrier = n.matches('[aria-setsize]') ? n : (n.querySelector('[aria-setsize]') ?? n.closest('[aria-setsize]'));
795
+ const v = carrier ? Number(carrier.getAttribute('aria-setsize')) : NaN;
796
+ if (!Number.isNaN(v) && v > size)
797
+ size = v;
798
+ }
799
+ return size > 0 ? size : nodes.length;
800
+ }, `[data-testid="${TestId.turn}"]`);
801
+ }
802
+ /** The newest user message as shown, for telling "landed but not counted" from "not sent". */
803
+ async lastUserTurnText() {
804
+ return await this.p
805
+ .evaluate(() => {
806
+ const nodes = document.querySelectorAll('[id^="user-message-"]');
807
+ const last = nodes[nodes.length - 1];
808
+ return last ? last.innerText : '';
809
+ })
810
+ .catch(() => '');
811
+ }
812
+ /** Puts text in the composer and clicks Send. Enter is not used: it does not submit. */
813
+ async send(text, attachments = []) {
814
+ if (text.trim().length === 0) {
815
+ throw new Error('Refusing to send an empty message: the composer requires text.');
816
+ }
817
+ await this.clearBlockers();
818
+ if (attachments.length > 0)
819
+ await this.attach(attachments);
820
+ const composer = this.composer();
821
+ await composer.click();
822
+ await composer.fill(text);
823
+ const send = await this.sendButton();
824
+ await send.waitFor({ state: 'visible', timeout: 20_000 });
825
+ await send.click();
826
+ this.emit('message-sent', { chars: text.length, attachments: attachments.length });
827
+ }
828
+ /**
829
+ * The composer's own Send button, and nothing else called Send.
830
+ *
831
+ * Asking the page for "the button named Send" used to be enough. It is not: the Office
832
+ * feedback panel brings its own, and the moment it opens the lookup matches two elements and
833
+ * the run dies on a strict mode violation. So the search is scoped to the composer, with two
834
+ * fallbacks that narrow by a different signal each time, because the thing most likely to
835
+ * change here is the attribute name rather than the button.
836
+ */
837
+ async sendButton() {
838
+ const wrapper = this.p.locator(Css.composerWrapper);
839
+ if ((await wrapper.count()) > 0) {
840
+ return wrapper.first().getByRole('button', { name: Label.send, exact: true }).first();
841
+ }
842
+ const submit = this.p.locator(Css.composerSendSubmit);
843
+ if ((await submit.count()) > 0) {
844
+ this.emit('send-button-wrapper-missing', { using: Css.composerSendSubmit });
845
+ return submit.first();
846
+ }
847
+ // Last resort, and `.first()` rather than a bare locator: an unscoped match that finds two
848
+ // buttons should pick one and carry on, not end the run.
849
+ this.emit('send-button-unscoped');
850
+ return this.p.getByRole('button', { name: Label.send, exact: true }).first();
851
+ }
852
+ /**
853
+ * Closes the Office feedback panel if it has appeared over the chat.
854
+ *
855
+ * Escape and nothing else. The panel's own controls send an opinion from the operator's
856
+ * account, and a program that has no opinion must not be the thing that submits one. If
857
+ * Escape does not clear it, that is said out loud and the run carries on: the Send button is
858
+ * scoped now, so the panel being open is untidy rather than fatal.
859
+ */
860
+ async dismissFeedbackPanel() {
861
+ const panel = this.p.locator(Css.feedbackPanel);
862
+ if ((await panel.count()) === 0)
863
+ return;
864
+ this.emit('feedback-panel-open');
865
+ await this.p.keyboard.press('Escape').catch(() => undefined);
866
+ await this.p.waitForTimeout(500);
867
+ if ((await panel.count()) > 0)
868
+ this.emit('feedback-panel-stuck');
869
+ else
870
+ this.emit('feedback-panel-dismissed');
871
+ }
872
+ /**
873
+ * Sends and confirms the message was actually accepted.
874
+ *
875
+ * Clicking Send is not the same as the message arriving. A rejected request leaves the
876
+ * text sitting in the composer and shows an error banner, and the old code then waited
877
+ * fifteen minutes for a reply that could never come. Acceptance is defined as the turn
878
+ * count going up, which is the only thing that really means "the chat took it".
879
+ *
880
+ * On a verification challenge the human clears it and the message is sent again, because
881
+ * the rejected one was never delivered. The bot does not attempt the challenge itself.
882
+ */
883
+ async sendAndConfirm(text, attachments = [], maxAttempts = 3) {
884
+ let lastProblem = '';
885
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
886
+ const before = await this.turnCount();
887
+ const shownBefore = await this.lastUserTurnText();
888
+ // A retry after a message that did land would put it in the chat twice — which is
889
+ // exactly what happened before the size was read from the app. Look first.
890
+ if (attempt > 1 && landed(text, shownBefore, this.retryBaseline)) {
891
+ this.emit('send-landed-after-all', { attempt });
892
+ return this.retryBaselineTurns;
893
+ }
894
+ if (attempt === 1) {
895
+ this.retryBaseline = shownBefore;
896
+ this.retryBaselineTurns = before;
897
+ }
898
+ await this.send(text, attachments);
899
+ const accepted = await this.waitForAccepted(before, 30_000, text, shownBefore);
900
+ if (accepted)
901
+ return before;
902
+ const found = await this.detectBlocker();
903
+ lastProblem = found.kind === 'none' ? 'the message did not appear in the chat' : `${found.kind}: ${found.reason}`;
904
+ this.emit('send-not-accepted', { attempt, problem: lastProblem });
905
+ if (found.kind !== 'none') {
906
+ const cleared = await this.handleBlocker(found);
907
+ if (!cleared) {
908
+ throw new SendRejectedError(`The chat is blocking messages (${lastProblem}) and it was not cleared in time. ` +
909
+ 'If this is a human-verification challenge, complete it in the Edge window and run again.');
910
+ }
911
+ }
912
+ // Clear whatever is left in the composer before trying again.
913
+ await this.composer().fill('').catch(() => undefined);
914
+ await this.p.waitForTimeout(2_000);
915
+ }
916
+ throw new SendRejectedError(`The chat did not accept the message after ${maxAttempts} attempts (${lastProblem}).`);
917
+ }
918
+ /** What the newest user message said before the first attempt, for the retry check. */
919
+ retryBaseline = '';
920
+ retryBaselineTurns = 0;
921
+ /**
922
+ * True once the chat really took the message: the conversation grew, or the newest user
923
+ * message is now the one that was sent. Two signals, because the first is what the app
924
+ * reports and the second is what a person would look at.
925
+ */
926
+ async waitForAccepted(previousTurns, timeoutMs, text, shownBefore) {
927
+ const deadline = Date.now() + timeoutMs;
928
+ while (Date.now() < deadline) {
929
+ if (this.p.isClosed())
930
+ return false;
931
+ const now = await this.turnCount().catch(() => previousTurns);
932
+ if (now > previousTurns)
933
+ return true;
934
+ if (landed(text, await this.lastUserTurnText(), shownBefore))
935
+ return true;
936
+ const found = await this.detectBlocker();
937
+ if (found.kind !== 'none')
938
+ return false;
939
+ await this.p.waitForTimeout(1_000);
940
+ }
941
+ return false;
942
+ }
943
+ /**
944
+ * Uploads files through the hidden input the composer keeps in the DOM, so no native
945
+ * Windows file dialog is ever involved. Waits until each chip's id carries the `SPO_`
946
+ * prefix, which is the signal that the upload actually finished.
947
+ */
948
+ async attach(paths) {
949
+ await this.p.setInputFiles(Upload.input, paths);
950
+ for (const path of paths) {
951
+ const name = path.split(/[\\/]/).pop();
952
+ const chip = this.p.locator(`${Upload.chip}[aria-label="${name.replace(/"/g, '\\"')}"]`);
953
+ await chip.first().waitFor({ state: 'visible', timeout: 120_000 });
954
+ await this.p
955
+ .waitForFunction(({ sel, prefix }) => {
956
+ const el = document.querySelector(sel);
957
+ return !!el && (el.id || '').startsWith(prefix);
958
+ }, { sel: `${Upload.chip}[aria-label="${name.replace(/"/g, '\\"')}"]`, prefix: Upload.uploadedIdPrefix }, { timeout: 120_000 })
959
+ .catch(() => this.emit('attachment-upload-unconfirmed', { name }));
960
+ this.emit('attached', { name });
961
+ }
962
+ }
963
+ /**
964
+ * Waits for the answer to finish streaming, then returns its raw markdown.
965
+ *
966
+ * This polls rather than using one long `waitForFunction`, for three reasons that all
967
+ * showed up in practice:
968
+ *
969
+ * - a human-verification challenge can appear mid-wait, and the run should pause with an
970
+ * instruction rather than time out fifteen minutes later,
971
+ * - if the browser window is closed, Playwright's message is
972
+ * "Target page, context or browser has been closed", which explains nothing,
973
+ * - a stalled reply is worth reporting with how long it waited and what it saw.
974
+ */
975
+ async waitForReply(previousTurns) {
976
+ const startedAt = Date.now();
977
+ const deadline = startedAt + this.opts.replyTimeoutMs;
978
+ const state = async () => await this.p.evaluate(({ turnSel, stopLabel, wrapperSel, copySel }) => {
979
+ // The conversation's size as the app reports it, not the rendered count: see turnCount.
980
+ const nodes = Array.from(document.querySelectorAll(turnSel));
981
+ let size = 0;
982
+ for (const n of nodes) {
983
+ const carrier = n.matches('[aria-setsize]') ? n : (n.querySelector('[aria-setsize]') ?? n.closest('[aria-setsize]'));
984
+ const v = carrier ? Number(carrier.getAttribute('aria-setsize')) : NaN;
985
+ if (!Number.isNaN(v) && v > size)
986
+ size = v;
987
+ }
988
+ const turns = size > 0 ? size : nodes.length;
989
+ const streaming = Array.from(document.querySelectorAll('button')).some((b) => b.getAttribute('aria-label') === stopLabel);
990
+ const wrappers = document.querySelectorAll(wrapperSel);
991
+ const last = wrappers[wrappers.length - 1];
992
+ return { turns, streaming, finished: !streaming && !!last && !!last.querySelector(copySel) };
993
+ }, {
994
+ turnSel: `[data-testid="${TestId.turn}"]`,
995
+ stopLabel: Label.stopGenerating,
996
+ wrapperSel: Signal.answerWrapper,
997
+ copySel: Signal.copyButtonInAnswer,
998
+ });
999
+ let sawNewTurn = false;
1000
+ let last = { turns: previousTurns, streaming: false, finished: false };
1001
+ while (Date.now() < deadline) {
1002
+ if (this.p.isClosed()) {
1003
+ throw new Error('The browser window was closed while waiting for a reply. If you did not close it, ' +
1004
+ 'another Edge process was probably already using the bot profile; ' +
1005
+ 'close every Edge window on that profile and run again.');
1006
+ }
1007
+ try {
1008
+ last = await state();
1009
+ }
1010
+ catch (e) {
1011
+ const msg = e.message;
1012
+ if (msg.includes('closed')) {
1013
+ throw new Error('The browser closed while waiting for a reply. Close any other Edge window using ' +
1014
+ 'the bot profile, then run again.');
1015
+ }
1016
+ // A navigation can make one poll fail; try again.
1017
+ await this.p.waitForTimeout(1_000);
1018
+ continue;
1019
+ }
1020
+ if (last.turns > previousTurns)
1021
+ sawNewTurn = true;
1022
+ if (sawNewTurn && last.finished)
1023
+ break;
1024
+ // Only look for blockers while nothing is streaming, so the check stays cheap.
1025
+ if (!last.streaming) {
1026
+ const blocker = await this.detectBlocker();
1027
+ if (blocker.kind !== 'none') {
1028
+ this.emit('blocker-during-reply', { blocker: blocker.kind, reason: blocker.reason });
1029
+ await this.clearBlockers();
1030
+ // The request that triggered the blocker was rejected, so no reply is coming.
1031
+ // Say so instead of waiting out the whole timeout.
1032
+ if (!sawNewTurn) {
1033
+ throw new SendRejectedError(`The chat blocked the request (${blocker.kind}: ${blocker.reason}). ` +
1034
+ 'The message was not accepted, so no reply will arrive.');
1035
+ }
1036
+ }
1037
+ }
1038
+ await this.p.waitForTimeout(1_000);
1039
+ }
1040
+ if (!sawNewTurn || !last.finished) {
1041
+ await this.dumpFailure(join(this.opts.transportDir, '..', 'failures'), 'reply-timeout').catch(() => undefined);
1042
+ throw new Error(`Copilot did not finish a reply within ${Math.round(this.opts.replyTimeoutMs / 1000)}s ` +
1043
+ `(new turn seen: ${sawNewTurn}, still streaming: ${last.streaming}). ` +
1044
+ 'A screenshot and an HTML dump are in the run folder.');
1045
+ }
1046
+ // Let the text settle: the widget re-renders briefly after the stream ends.
1047
+ let previousText = '';
1048
+ for (let i = 0; i < 20; i += 1) {
1049
+ const now = await this.lastMessageText();
1050
+ if (now === previousText && now.length > 0)
1051
+ break;
1052
+ previousText = now;
1053
+ await this.p.waitForTimeout(400);
1054
+ }
1055
+ const attachments = await this.lastMessageAttachmentNames();
1056
+ // A reply that carries a file is told nothing by being ignored, and neither is the operator; the
1057
+ // file is not fetched, and the log says so by name. The contract already tells the chat why.
1058
+ if (attachments.length)
1059
+ this.emit('reply-files-ignored', { files: attachments });
1060
+ const codeBlocksDom = await this.lastAnswerCodeBlocks();
1061
+ const { markdown, degraded } = await this.copyLastReply();
1062
+ this.emit('reply-received', {
1063
+ chars: markdown.length,
1064
+ degraded,
1065
+ attachments: attachments.length,
1066
+ codeBlocks: codeBlocksDom.length,
1067
+ waitedMs: Date.now() - startedAt,
1068
+ });
1069
+ return { markdown, degraded, attachments, codeBlocksDom };
1070
+ }
1071
+ /**
1072
+ * The newest answer, as a whole.
1073
+ *
1074
+ * `copilot-message-div` rather than `lastChatMessage`, because the latter is only the
1075
+ * answer body: the toolbar with the copy button is its sibling, not its child. Anchoring
1076
+ * on the wrapper is what makes the copy button, the code blocks and the download anchors
1077
+ * all reachable from one locator.
1078
+ */
1079
+ lastAnswer() {
1080
+ return this.p.getByTestId(TestId.assistantMessage).last();
1081
+ }
1082
+ async lastMessageText() {
1083
+ return (await this.lastAnswer().innerText().catch(() => '')) ?? '';
1084
+ }
1085
+ /** The on-screen text of each code block in the newest answer, for comparison only. */
1086
+ async lastAnswerCodeBlocks() {
1087
+ const blocks = this.lastAnswer().locator(Css.codeBlock);
1088
+ const n = await blocks.count().catch(() => 0);
1089
+ const out = [];
1090
+ for (let i = 0; i < n; i += 1) {
1091
+ out.push((await blocks.nth(i).innerText().catch(() => '')) ?? '');
1092
+ }
1093
+ return out;
1094
+ }
1095
+ async lastMessageAttachmentNames() {
1096
+ const links = this.lastAnswer().locator(Css.downloadLink);
1097
+ const n = await links.count();
1098
+ const names = [];
1099
+ for (let i = 0; i < n; i += 1) {
1100
+ const name = await links.nth(i).getAttribute('download');
1101
+ if (name)
1102
+ names.push(name);
1103
+ }
1104
+ return names;
1105
+ }
1106
+ /**
1107
+ * Clicks "Copy Response" and takes what the page tried to copy, which is the whole answer as
1108
+ * raw markdown with the fences intact.
1109
+ *
1110
+ * What it does not do is touch the machine's clipboard: `CLIPBOARD_GUARD` has already put
1111
+ * the page's copy call somewhere only this process reads. Falls back to the DOM text, flagged
1112
+ * as degraded, because that text is known to be lossy.
1113
+ */
1114
+ async copyLastReply() {
1115
+ const copy = this.lastAnswer().getByTestId(TestId.copyResponse).first();
1116
+ try {
1117
+ /*
1118
+ * Two guards around one click, both about the neighbours.
1119
+ *
1120
+ * The answer toolbar is a row of small buttons — copy, like, dislike, retry — and the
1121
+ * copy button is the first of them. So before clicking, the button's own label is read
1122
+ * and it has to look like a copy control: if the test id ever moves to a different
1123
+ * button, the click is skipped rather than landing on "like", which would be a rating
1124
+ * left on somebody's account by a program that has no opinion.
1125
+ */
1126
+ const label = ((await copy.getAttribute('aria-label')) ?? (await copy.getAttribute('title')) ?? '').toLowerCase();
1127
+ if (label && !label.includes('copy')) {
1128
+ this.emit('copy-button-moved', { label });
1129
+ throw new Error(`the copy test id now points at a button labelled "${label}"`);
1130
+ }
1131
+ /*
1132
+ * The counter is read before the click and waited on afterwards, so what comes back is
1133
+ * necessarily what this click produced. A copy that silently did nothing times out and
1134
+ * degrades; it can never be served the answer from the iteration before.
1135
+ */
1136
+ const before = await this.p.evaluate(() => window.__copClipboard?.seq ?? -1);
1137
+ if (before < 0)
1138
+ throw new Error('the clipboard guard is not installed on this page');
1139
+ await copy.click({ timeout: 15_000 });
1140
+ // And afterwards the pointer is parked away from the toolbar. Left where it was, it
1141
+ // hovers whichever button the re-render slides under it, which shows a tooltip over the
1142
+ // answer and puts the mouse one stray event away from a button nobody meant to press.
1143
+ await this.p.mouse.move(2, 2).catch(() => undefined);
1144
+ const captured = await this.p.waitForFunction((seq) => {
1145
+ const state = window.__copClipboard;
1146
+ return state && state.seq > seq ? state.text : null;
1147
+ }, before, { timeout: 15_000 });
1148
+ const text = await captured.jsonValue();
1149
+ if (text && text.trim().length > 0)
1150
+ return { markdown: text, degraded: false };
1151
+ }
1152
+ catch {
1153
+ /* fall through */
1154
+ }
1155
+ this.emit('clipboard-unavailable');
1156
+ return { markdown: await this.lastMessageText(), degraded: true };
1157
+ }
1158
+ /**
1159
+ * Downloads one file Copilot attached to the last answer.
1160
+ *
1161
+ * The attachment is an ordinary anchor with a `blob:` href, a `download` attribute and
1162
+ * `target="_blank"`, which is exactly why hovering shows nothing useful. The download event
1163
+ * has to be registered before the click, and the popup case is covered too.
1164
+ */
1165
+ /** An Edge crash written to the profile in the last few minutes, if there is one. */
1166
+ async recentCrash() {
1167
+ return await findRecentCrash(this.opts.profileDir).catch(() => null);
1168
+ }
1169
+ async turnCountNow() {
1170
+ return await this.turnCount();
1171
+ }
1172
+ /**
1173
+ * Screenshot plus HTML dump, for when a locator stops matching.
1174
+ *
1175
+ * When the page is already gone the dump would be an empty file, which is worse than
1176
+ * nothing because it looks like evidence. In that case a note is written instead.
1177
+ */
1178
+ async dumpFailure(dir, tag) {
1179
+ await mkdir(dir, { recursive: true });
1180
+ const { writeFile } = await import('node:fs/promises');
1181
+ if (!this.page || this.page.isClosed()) {
1182
+ // A closed page reads the same whether the window was closed, another Edge took the
1183
+ // profile, or Edge crashed. Only the last leaves a minidump, and it says which process.
1184
+ const crash = await this.recentCrash();
1185
+ await writeFile(join(dir, `${tag}.txt`), crash
1186
+ ? `${describeCrash(crash)}\n\nThe page was already closed when the failure was recorded, so there is nothing else to capture.`
1187
+ : 'The page was already closed when the failure was recorded, so there is nothing to ' +
1188
+ 'capture. No Edge crash report was written in the last minutes, so this usually means ' +
1189
+ 'the browser window was closed, or another Edge process was using the same profile.', 'utf8').catch(() => undefined);
1190
+ if (crash)
1191
+ this.emit('browser-crashed', { dir, tag, ...crash });
1192
+ else
1193
+ this.emit('failure-dump-empty', { dir, tag });
1194
+ return;
1195
+ }
1196
+ await this.p.screenshot({ path: join(dir, `${tag}.png`), fullPage: true }).catch(() => undefined);
1197
+ // The page's HTML carries the account's other conversation titles and its sign-in state, so it
1198
+ // is written only when the operator has asked for it; the screenshot and the URL always are.
1199
+ if (this.opts.keepFailurePage) {
1200
+ const html = await this.p.content().catch(() => '');
1201
+ if (html.length > 0) {
1202
+ await writeFile(join(dir, `${tag}.html`), html, 'utf8').catch(() => undefined);
1203
+ }
1204
+ }
1205
+ await writeFile(join(dir, `${tag}.url.txt`), this.p.url(), 'utf8').catch(() => undefined);
1206
+ this.emit('failure-dumped', { dir, tag, url: this.p.url() });
1207
+ }
1208
+ }