explorbot 0.2.4 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/bin/explorbot-cli.ts +19 -7
  2. package/boat/api-tester/src/cli.ts +17 -0
  3. package/boat/doc-collector/src/cli.ts +14 -1
  4. package/boat/prima/README.md +96 -0
  5. package/boat/prima/package.json +14 -10
  6. package/boat/prima/src/cli.ts +29 -12
  7. package/boat/prima/src/envelope.ts +35 -13
  8. package/boat/prima/src/prima.ts +78 -45
  9. package/dist/bin/explorbot-cli.js +19 -7
  10. package/dist/boat/api-tester/src/cli.js +17 -0
  11. package/dist/boat/doc-collector/src/cli.js +14 -1
  12. package/dist/boat/prima/src/cli.js +26 -7
  13. package/dist/boat/prima/src/envelope.js +32 -8
  14. package/dist/boat/prima/src/prima.js +75 -43
  15. package/dist/models.json +4 -4
  16. package/dist/package.json +6 -2
  17. package/dist/src/action-result.d.ts +13 -0
  18. package/dist/src/action-result.js +46 -15
  19. package/dist/src/action.d.ts +5 -2
  20. package/dist/src/action.js +53 -18
  21. package/dist/src/ai/captain/web-mode.js +1 -2
  22. package/dist/src/ai/captain.d.ts +20 -0
  23. package/dist/src/ai/captain.js +10 -1
  24. package/dist/src/ai/driller.js +6 -2
  25. package/dist/src/ai/fisherman-tools.d.ts +40 -1
  26. package/dist/src/ai/fisherman-tools.js +39 -0
  27. package/dist/src/ai/fisherman.js +2 -1
  28. package/dist/src/ai/navigator.d.ts +28 -0
  29. package/dist/src/ai/navigator.js +223 -175
  30. package/dist/src/ai/pilot.d.ts +7 -4
  31. package/dist/src/ai/pilot.js +89 -30
  32. package/dist/src/ai/planner/subpages.js +2 -16
  33. package/dist/src/ai/planner.js +1 -1
  34. package/dist/src/ai/provider.d.ts +2 -2
  35. package/dist/src/ai/provider.js +28 -22
  36. package/dist/src/ai/researcher/cache.d.ts +10 -3
  37. package/dist/src/ai/researcher/cache.js +23 -10
  38. package/dist/src/ai/researcher/deep-analysis.js +1 -1
  39. package/dist/src/ai/researcher/fingerprint-worker.js +21 -4
  40. package/dist/src/ai/researcher.js +6 -4
  41. package/dist/src/ai/rules.js +1 -5
  42. package/dist/src/ai/session-analyst.js +2 -0
  43. package/dist/src/ai/tester.d.ts +6 -3
  44. package/dist/src/ai/tester.js +30 -35
  45. package/dist/src/ai/tools.d.ts +8 -5
  46. package/dist/src/ai/tools.js +83 -57
  47. package/dist/src/commands/config-command.d.ts +51 -0
  48. package/dist/src/commands/config-command.js +117 -0
  49. package/dist/src/commands/index.js +2 -0
  50. package/dist/src/commands/init-command.js +13 -20
  51. package/dist/src/config.d.ts +8 -1
  52. package/dist/src/config.js +43 -1
  53. package/dist/src/experience-tracker.d.ts +2 -0
  54. package/dist/src/experience-tracker.js +12 -0
  55. package/dist/src/explorbot.js +5 -2
  56. package/dist/src/playwright-recorder.js +6 -12
  57. package/dist/src/remote.d.ts +3 -2
  58. package/dist/src/remote.js +8 -2
  59. package/dist/src/state-manager.d.ts +1 -1
  60. package/dist/src/state-manager.js +3 -1
  61. package/dist/src/test-plan.d.ts +9 -0
  62. package/dist/src/test-plan.js +30 -0
  63. package/dist/src/utils/html-diff.d.ts +5 -0
  64. package/dist/src/utils/html-diff.js +65 -6
  65. package/dist/src/utils/logger.d.ts +1 -1
  66. package/dist/src/utils/logger.js +8 -0
  67. package/dist/src/utils/strings.d.ts +2 -0
  68. package/dist/src/utils/strings.js +32 -0
  69. package/dist/src/utils/url-matcher.d.ts +1 -0
  70. package/dist/src/utils/url-matcher.js +31 -2
  71. package/docs/basics/getting-started.md +33 -10
  72. package/docs/basics/providers.md +6 -4
  73. package/docs/contributing/npm-package.md +73 -4
  74. package/docs/index.json +2 -1
  75. package/docs/reference/commands.md +3 -0
  76. package/docs/reference/websocket.md +50 -0
  77. package/docs/superpowers/specs/2026-08-18-prima-false-verdicts.md +159 -0
  78. package/models.json +4 -4
  79. package/package.json +6 -2
  80. package/src/action-result.ts +61 -16
  81. package/src/action.ts +56 -18
  82. package/src/ai/captain/web-mode.ts +1 -2
  83. package/src/ai/captain.ts +9 -1
  84. package/src/ai/driller.ts +6 -2
  85. package/src/ai/fisherman-tools.ts +35 -0
  86. package/src/ai/fisherman.ts +2 -1
  87. package/src/ai/navigator.ts +238 -179
  88. package/src/ai/pilot.ts +104 -36
  89. package/src/ai/planner/subpages.ts +2 -13
  90. package/src/ai/planner.ts +1 -1
  91. package/src/ai/provider.ts +29 -21
  92. package/src/ai/researcher/cache.ts +29 -11
  93. package/src/ai/researcher/deep-analysis.ts +1 -1
  94. package/src/ai/researcher/fingerprint-worker.ts +23 -5
  95. package/src/ai/researcher.ts +6 -4
  96. package/src/ai/rules.ts +1 -5
  97. package/src/ai/session-analyst.ts +2 -0
  98. package/src/ai/tester.ts +33 -34
  99. package/src/ai/tools.ts +88 -61
  100. package/src/commands/config-command.ts +146 -0
  101. package/src/commands/index.ts +2 -0
  102. package/src/commands/init-command.ts +14 -20
  103. package/src/config.ts +47 -2
  104. package/src/experience-tracker.ts +13 -0
  105. package/src/explorbot.ts +4 -2
  106. package/src/playwright-recorder.ts +6 -11
  107. package/src/remote.ts +8 -2
  108. package/src/state-manager.ts +5 -2
  109. package/src/test-plan.ts +38 -0
  110. package/src/utils/html-diff.ts +72 -7
  111. package/src/utils/logger.ts +9 -1
  112. package/src/utils/strings.ts +36 -0
  113. package/src/utils/url-matcher.ts +27 -2
@@ -9,11 +9,14 @@ import Explorer from '../explorer.ts';
9
9
  import type { KnowledgeTracker } from '../knowledge-tracker.js';
10
10
  import { type StateManager, normalizeUrl } from '../state-manager.js';
11
11
  import { renderAssertion } from '../playwright-recorder.ts';
12
+ import { isFatalBrowserError } from '../utils/browser-errors.ts';
13
+ import { getCliName } from '../utils/cli-name.ts';
12
14
  import { extractCodeBlocks } from '../utils/code-extractor.js';
13
15
  import { HooksRunner } from '../utils/hooks-runner.ts';
14
16
  import { createDebug, pluralize, tag } from '../utils/logger.js';
15
17
  import { loop, pause } from '../utils/loop.js';
16
18
  import { RulesLoader } from '../utils/rules-loader.ts';
19
+ import { normalizeInlineText } from '../utils/strings.ts';
17
20
  import { extractStatePath, matchesNavigationUrl } from '../utils/url-matcher.js';
18
21
  import type { Agent, AgentDeps } from './agent.js';
19
22
  import type { Conversation } from './conversation.js';
@@ -33,6 +36,7 @@ class Navigator implements Agent {
33
36
  private hooksRunner: HooksRunner;
34
37
 
35
38
  private MAX_ATTEMPTS = Number.parseInt(process.env.MAX_ATTEMPTS || '5');
39
+ lastFailureReason: string | null = null;
36
40
 
37
41
  private systemPrompt = dedent`
38
42
  <role>
@@ -105,8 +109,12 @@ class Navigator implements Agent {
105
109
  private getComparableCurrentUrl(stateManager: any, expectedUrl: string): string {
106
110
  const currentState = stateManager.getCurrentState();
107
111
  if (!currentState) return '';
108
- const current = /^https?:\/\//i.test(expectedUrl) ? currentState.fullUrl || currentState.url || '' : currentState.url || '';
109
- return current;
112
+ return this.comparableUrl(currentState, expectedUrl);
113
+ }
114
+
115
+ private comparableUrl(state: { url?: string; fullUrl?: string }, expectedUrl: string): string {
116
+ if (/^https?:\/\//i.test(expectedUrl)) return state.fullUrl || state.url || '';
117
+ return state.url || '';
110
118
  }
111
119
 
112
120
  private isSameExpectedOrigin(expectedUrl: string, stateManager: any): boolean {
@@ -162,7 +170,7 @@ class Navigator implements Agent {
162
170
 
163
171
  const resolved = await this.resolveState(originalMessage, actionResult, { action, expectedUrl: url });
164
172
  if (!resolved) {
165
- throw new Error(`Navigation to ${url} failed: redirected to ${actualPath} and could not resolve`);
173
+ throw this.navigationError(url, `redirected to ${actualPath} and could not resolve`);
166
174
  }
167
175
  } else if (action.lastError) {
168
176
  const actionResult = action.actionResult || ActionResult.fromState(action.stateManager.getCurrentState()!);
@@ -174,7 +182,7 @@ class Navigator implements Agent {
174
182
 
175
183
  const resolved = await this.resolveState(originalMessage, actionResult, { action, expectedUrl: url });
176
184
  if (!resolved) {
177
- throw new Error(`Navigation to ${url} failed: ${action.lastError?.message}`);
185
+ throw this.navigationError(url, action.lastError?.message || 'Navigation failed');
178
186
  }
179
187
  }
180
188
  await this.explorer.capture({ screenshot: true });
@@ -190,9 +198,25 @@ class Navigator implements Agent {
190
198
  }
191
199
  }
192
200
 
193
- async resolveState(message: string, actionResult: ActionResult, opts?: { action?: Action; expectedUrl?: string; onAttempt?: (attempt: { code: string; error?: string }) => void }): Promise<boolean> {
201
+ private navigationError(url: string, fallback: string): Error {
202
+ if (this.lastFailureReason) return new Error(`Navigation to ${url} failed: ${this.lastFailureReason}`);
203
+ return new Error(`Navigation to ${url} failed: ${fallback}`);
204
+ }
205
+
206
+ private failureReason(stopReason: string | null, knowledge: string, url: string): string | null {
207
+ const reasons: string[] = [];
208
+ if (stopReason) reasons.push(stopReason);
209
+ if (!knowledge) {
210
+ const path = extractStatePath(url).split('?')[0].split('#')[0];
211
+ reasons.push(`no knowledge is set for ${path} — teach it what this page needs (credentials, hints) with: ${getCliName()} learn "${path}" "<facts>"`);
212
+ }
213
+ return reasons.join('; ') || null;
214
+ }
215
+
216
+ async resolveState(message: string, actionResult: ActionResult, opts?: { action?: Action; expectedUrl?: string; experience?: string; onAttempt?: (attempt: { code: string; error?: string }) => void }): Promise<boolean> {
194
217
  if (!this.provider) throw new Error('AI-assisted recovery is unavailable: no AI model is configured.');
195
218
 
219
+ this.lastFailureReason = null;
196
220
  tag('info').log('AI Navigator resolving state at', actionResult.url);
197
221
  debugLog('Resolution message:', message);
198
222
 
@@ -200,52 +224,9 @@ class Navigator implements Agent {
200
224
  const expectedUrl = opts?.expectedUrl;
201
225
 
202
226
  const knowledge = this.knowledgeTracker.renderRelevantContext(actionResult);
203
- let experience = '';
204
-
205
- if (!actionResult.isInsideIframe) {
206
- const successful = this.experienceTracker.getSuccessfulExperience(actionResult);
207
- if (successful.length > 0) {
208
- tag('operation').log(`Found ${successful.length} experience ${pluralize(successful.length, 'file')} for: ${actionResult.url}`);
209
- experience = `<experience>\nPast successful recipes recorded from prior runs for this page. Prefer these solutions first if they match the goal.\n\n${successful.join('\n\n')}\n</experience>`;
210
- }
211
- }
212
-
213
- const prompt = dedent`
214
- <message>
215
- ${message}
216
- </message>
217
-
218
- <page>
219
- ${actionResult.toAiContext()}
220
-
221
- <page_html>
222
- ${await actionResult.combinedHtml()}
223
- </page_html>
224
- </page>
225
-
226
- <task>
227
- Identify the actual request of the user.
228
- Identify what is expected by user.
229
- Identify what might have caused the error.
230
- Propose different solutions to achieve the result.
231
- Solution should be valid CodeceptJS code.
232
- Use only data from the <page> context to plan the solution.
233
- Try various ways to achieve the result
234
- </task>
235
-
236
- ${actionRule}
237
-
238
- ${unexpectedPopupRule}
239
-
240
- ${RulesLoader.loadRules('navigator', ['multiple-locator', 'output'], actionResult.url || '').replace('{{maxAttempts}}', String(this.MAX_ATTEMPTS))}
241
-
242
- ${experience}
243
-
244
- ${knowledge}
245
- `;
246
227
 
247
228
  const conversation = this.provider.startConversation(this.systemPrompt, 'navigator');
248
- conversation.addUserText(prompt);
229
+ conversation.addUserText(await this.buildResolutionPrompt(message, actionResult, opts?.experience));
249
230
 
250
231
  let stopReason: string | null = null;
251
232
  const tools = {
@@ -273,8 +254,9 @@ class Navigator implements Agent {
273
254
  let htmlContextAdded = false;
274
255
  let codeBlockIndex = 0;
275
256
  let totalAttempts = 0;
257
+ let lastFailure: string | null = null;
276
258
  const progressBlocks: string[] = [];
277
- const batchFailures: Array<{ code: string; error: string; ariaChanges?: string | null; urlAfter?: string }> = [];
259
+ const batchFailures: BatchFailure[] = [];
278
260
 
279
261
  let resolved = false;
280
262
  await loop(
@@ -283,7 +265,6 @@ class Navigator implements Agent {
283
265
  const result = await this.provider.invokeConversation(conversation, tools);
284
266
  if (!result) return;
285
267
  if (stopReason) {
286
- tag('error').log(`Navigator stopped: ${stopReason}`);
287
268
  resolved = false;
288
269
  stop();
289
270
  return;
@@ -307,41 +288,8 @@ class Navigator implements Agent {
307
288
  return;
308
289
  }
309
290
  tag('operation').log('Feeding failures back to AI for a new batch...');
310
- let contextMsg = 'Previous solutions did not work. Analyze the failures and try DIFFERENT strategies (not syntactic variants of the same locator).\n\n';
311
- if (batchFailures.length > 0) {
312
- const lines = batchFailures
313
- .map((f) => {
314
- const head = `- \`${f.code.split('\n')[0]}\` → ${f.error}`;
315
- if (!f.ariaChanges) return head;
316
- const trimmed = f.ariaChanges.split('\n').slice(0, 12).join('\n ');
317
- return `${head}\n • ARIA changes after the action:\n ${trimmed}`;
318
- })
319
- .join('\n');
320
- contextMsg += `<previous_failures>\n${lines}\n</previous_failures>\n\n`;
321
- }
322
- if (!htmlContextAdded) {
323
- htmlContextAdded = true;
324
- contextMsg += `Full HTML context:\n\n<page_html>\n${await actionResult.combinedHtml()}\n</page_html>\n\n`;
325
- }
326
- const pageReacted = batchFailures.some((f) => f.ariaChanges);
327
- if (pageReacted) {
328
- contextMsg += dedent`
329
- Some steps in the previous batch did not throw, but the URL did not change to the expected target and the page changed in other ways — the ARIA diff for each such step is listed in <previous_failures> above.
330
-
331
- Read those diffs and judge what each step actually triggered. Different action types produce different reactions; the diff is your only evidence of what happened. A diff might show, for example: a new alert / alertdialog / status / validation message appearing near a field or at page level; a modal, dialog, or wizard step opening; a banner, toast, or notification region appearing; a section expanding or collapsing; a tab or accordion switching content. A diff might also be empty or unrelated to the step — that is also a signal.
332
-
333
- Choose exactly ONE path based on what the diffs actually show — do not assume the previous step submitted any particular kind of data:
334
-
335
- A. The diff indicates the application requires something only the user can supply — for example: an authentication failure you cannot guess past, a captcha, a permission the test cannot satisfy, or knowledge that is not present in the provided context. Call the stop() tool and quote what you saw in the diff and what is needed.
336
-
337
- B. The diff indicates the next step is something you can perform from the existing knowledge / hint context — for example: re-emit a step with a value that exists in the knowledge but was used incorrectly; dismiss an unexpected modal; accept a confirmation; take a follow-up step the page now requires. Emit code blocks for that next step. Do NOT change the locator of a step that already produced a reaction.
338
-
339
- C. The diff is empty or unrelated to your step — the action likely missed its target. Propose a different locator strategy.
340
- `;
341
- } else {
342
- contextMsg += 'Propose new solutions. If errors mention "intercepts pointer events" or timeouts on visible elements, an overlay is blocking — dismiss it first (Escape, click outside, Close button) before retrying the original action.';
343
- }
344
- conversation.addUserText(contextMsg);
291
+ conversation.addUserText(await this.buildRetryFeedback(batchFailures, !htmlContextAdded, actionResult));
292
+ htmlContextAdded = true;
345
293
  codeBlocks = [];
346
294
  batchFailures.length = 0;
347
295
  return;
@@ -349,114 +297,59 @@ class Navigator implements Agent {
349
297
  codeBlockIndex++;
350
298
  totalAttempts++;
351
299
 
352
- await action.exitIframe();
353
-
354
300
  const prevActionResult = action.actionResult ?? actionResult;
355
301
  const prevHash = prevActionResult.getStateHash();
356
302
 
357
- debugLog(`Attempting resolution: ${codeBlock}`);
358
- const attemptOk = await action.attempt(codeBlock, message);
359
-
360
- const page = action.playwrightHelper?.page;
361
- if (page) {
362
- try {
363
- await page.waitForLoadState('load', { timeout: 5000 });
364
- } catch {
365
- // Navigation did not reach 'load' state within timeout; continue and verify URL
366
- }
367
- }
368
-
369
- if (attemptOk) opts?.onAttempt?.({ code: codeBlock });
370
-
371
- if (!attemptOk) {
372
- const raw = action.lastError?.message || 'attempt failed';
373
- const firstMeaningful = raw.split('\n').find((l) => l.trim() && !l.trim().startsWith('at ')) || raw;
374
- const shortErr = firstMeaningful.replace(/\s+/g, ' ').trim().slice(0, 220);
375
- batchFailures.push({ code: codeBlock, error: shortErr });
376
- opts?.onAttempt?.({ code: codeBlock, error: shortErr });
303
+ const attempt = await this.executeAttempt(action, codeBlock, message);
304
+ opts?.onAttempt?.({ code: codeBlock, error: attempt.error });
305
+ if (attempt.error) {
306
+ batchFailures.push({ code: codeBlock, error: attempt.error });
307
+ lastFailure = attempt.error;
377
308
  }
378
309
 
379
310
  if (expectedUrl) {
380
- if (page) {
381
- try {
382
- await page.waitForURL((url: URL) => matchesNavigationUrl(expectedUrl, `${url.pathname}${url.search}${url.hash}`), { timeout: 5000 });
383
- } catch {
384
- // URL did not transition to expectedUrl within timeout
385
- }
386
- }
387
- const freshState = await this.explorer.capture();
388
- const currentUrl = /^https?:\/\//i.test(expectedUrl) ? freshState.fullUrl || freshState.url || '' : freshState.url || '';
389
- const urlMatches = this.isSameExpectedOrigin(expectedUrl, action.stateManager) && matchesNavigationUrl(expectedUrl, currentUrl);
390
- const stateChanged = freshState.getStateHash() !== actionResult.getStateHash();
391
- resolved = urlMatches && stateChanged;
392
-
393
- if (!resolved && attemptOk) {
394
- let ariaChanges: string | null = null;
395
- if (freshState.getStateHash() !== prevHash) {
396
- try {
397
- const diff = await freshState.diff(prevActionResult);
398
- ariaChanges = diff.ariaChanged;
399
- } catch (err) {
400
- debugLog('Failed to compute pageDiff for failed URL verification:', err);
401
- }
402
- }
311
+ const check = await this.verifyNavigation(action, expectedUrl);
312
+ const freshHash = check.freshState.getStateHash();
313
+ resolved = check.urlMatches && freshHash !== actionResult.getStateHash();
314
+
315
+ if (!resolved && attempt.ok) {
316
+ lastFailure = `URL did not change (still ${check.freshState.url})`;
403
317
  batchFailures.push({
404
318
  code: codeBlock,
405
- error: `URL did not change (still ${freshState.url})`,
406
- ariaChanges,
407
- urlAfter: freshState.url,
319
+ error: lastFailure,
320
+ ariaChanges: await this.ariaDiff(check.freshState, prevActionResult),
321
+ urlAfter: check.freshState.url,
408
322
  });
409
- tag('warning').log(`URL verification failed: expected ${expectedUrl}, got ${freshState.url}`);
323
+ tag('warning').log(`URL verification failed: expected ${expectedUrl}, got ${check.freshState.url}`);
410
324
  }
411
- if (freshState.getStateHash() !== prevHash && (attemptOk || urlMatches)) {
325
+ if (freshHash !== prevHash && (attempt.ok || check.urlMatches)) {
412
326
  progressBlocks.push(codeBlock);
413
327
  }
414
328
  } else {
415
- resolved = attemptOk;
416
- if (attemptOk) progressBlocks.push(codeBlock);
329
+ resolved = attempt.ok;
330
+ if (attempt.ok) progressBlocks.push(codeBlock);
417
331
  }
418
332
 
419
- if (resolved) {
420
- tag('success').log('Navigation resolved successfully');
421
- let scenario = message.split('\n')[0];
422
- if (expectedUrl) {
423
- const fromPath = extractStatePath(actionResult.url || '');
424
- const toPath = extractStatePath(expectedUrl);
425
- scenario = `reach ${toPath} from ${fromPath}`;
426
- }
427
- const recipe = progressBlocks
428
- .join('\n')
429
- .split('\n')
430
- .filter((line) => !/^\s*I\.amOnPage\s*\(/.test(line))
431
- .join('\n')
432
- .trim();
433
- if (recipe) {
434
- const body = `## FLOW: ${scenario}\n\n* ${scenario}\n\n\`\`\`js\n${recipe}\n\`\`\`\n\n---\n`;
435
- this.experienceTracker.writeFlow(actionResult, body);
436
- }
437
- stop();
438
- return;
439
- }
333
+ if (!resolved) return;
334
+
335
+ tag('success').log('Navigation resolved successfully');
336
+ this.saveFlow(message, expectedUrl, actionResult, progressBlocks);
337
+ stop();
440
338
  },
441
339
  {
442
340
  maxAttempts: this.MAX_ATTEMPTS * 2,
443
341
  observability: {
444
342
  agent: 'navigator',
445
343
  },
446
- catch: async (error) => {
344
+ catch: async ({ error }) => {
345
+ if (isFatalBrowserError(error)) throw error;
447
346
  debugLog(error);
448
347
  resolved = false;
449
348
  },
450
349
  }
451
350
  );
452
351
 
453
- if (!resolved && expectedUrl) {
454
- await (action.getActor() as any).wait(1);
455
- if (this.isOnExpectedPage(expectedUrl, action.stateManager)) {
456
- resolved = true;
457
- tag('success').log('Navigation resolved after delayed redirect');
458
- }
459
- }
352
+ if (!resolved && expectedUrl) resolved = await this.rescueDelayedRedirect(action, expectedUrl);
460
353
 
461
354
  if (!resolved && stopReason) {
462
355
  tag('error').log(`Navigator stopped: ${stopReason}`);
@@ -464,24 +357,188 @@ class Navigator implements Agent {
464
357
  tag('error').log(`Navigation failed after ${totalAttempts} attempts`);
465
358
  }
466
359
 
467
- if (!resolved && isInteractive()) {
468
- const stopLine = stopReason ? `Navigator stopped: ${stopReason}\n` : '';
469
- const userInput = await pause(`${stopLine}Navigator failed to resolve. Current: ${action.stateManager.getCurrentState()?.url}\n` + `Target: ${expectedUrl ?? '(none)'}\nEnter CodeceptJS commands (or press Enter to skip):`);
360
+ if (!resolved && isInteractive()) resolved = await this.askUserToResolve(action, message, expectedUrl, stopReason);
470
361
 
471
- if (userInput?.trim()) {
472
- resolved = await action.attempt(userInput, message);
473
- if (resolved && expectedUrl) {
474
- await (action.getActor() as any).wait(1);
475
- if (!this.isOnExpectedPage(expectedUrl, action.stateManager)) {
476
- resolved = false;
477
- }
478
- }
479
- }
362
+ if (!resolved) {
363
+ let cause = 'the AI proposed no working solution for this page';
364
+ if (lastFailure) cause = `${totalAttempts} ${pluralize(totalAttempts, 'attempt')} failed, last: ${lastFailure}`;
365
+ if (stopReason) cause = stopReason;
366
+ this.lastFailureReason = this.failureReason(cause, knowledge, actionResult.url || '');
480
367
  }
481
368
 
482
369
  return resolved;
483
370
  }
484
371
 
372
+ private async buildResolutionPrompt(message: string, actionResult: ActionResult, injectedExperience?: string): Promise<string> {
373
+ let experience = injectedExperience || '';
374
+ if (!experience && !actionResult.isInsideIframe) {
375
+ experience = this.experienceTracker.renderExperienceFor(actionResult);
376
+ }
377
+
378
+ return dedent`
379
+ <message>
380
+ ${message}
381
+ </message>
382
+
383
+ <page>
384
+ ${actionResult.toAiContext()}
385
+
386
+ <page_html>
387
+ ${await actionResult.combinedHtml()}
388
+ </page_html>
389
+ </page>
390
+
391
+ <task>
392
+ Identify the actual request of the user.
393
+ Identify what is expected by user.
394
+ Identify what might have caused the error.
395
+ Propose different solutions to achieve the result.
396
+ Solution should be valid CodeceptJS code.
397
+ Use only data from the <page> context to plan the solution.
398
+ Try various ways to achieve the result
399
+ </task>
400
+
401
+ ${actionRule}
402
+
403
+ ${unexpectedPopupRule}
404
+
405
+ ${RulesLoader.loadRules('navigator', ['multiple-locator', 'output'], actionResult.url || '').replace('{{maxAttempts}}', String(this.MAX_ATTEMPTS))}
406
+
407
+ ${experience}
408
+
409
+ ${this.knowledgeTracker.renderRelevantContext(actionResult)}
410
+ `;
411
+ }
412
+
413
+ private async buildRetryFeedback(failures: BatchFailure[], includeHtml: boolean, actionResult: ActionResult): Promise<string> {
414
+ let contextMsg = 'Previous solutions did not work. Analyze the failures and try DIFFERENT strategies (not syntactic variants of the same locator).\n\n';
415
+
416
+ if (failures.length > 0) {
417
+ const lines = failures
418
+ .map((f) => {
419
+ const head = `- \`${f.code.split('\n')[0]}\` → ${f.error}`;
420
+ if (!f.ariaChanges) return head;
421
+ const trimmed = f.ariaChanges.split('\n').slice(0, 12).join('\n ');
422
+ return `${head}\n • ARIA changes after the action:\n ${trimmed}`;
423
+ })
424
+ .join('\n');
425
+ contextMsg += `<previous_failures>\n${lines}\n</previous_failures>\n\n`;
426
+ }
427
+
428
+ if (includeHtml) {
429
+ contextMsg += `Full HTML context:\n\n<page_html>\n${await actionResult.combinedHtml()}\n</page_html>\n\n`;
430
+ }
431
+
432
+ if (!failures.some((f) => f.ariaChanges)) {
433
+ return `${contextMsg}Propose new solutions. If errors mention "intercepts pointer events" or timeouts on visible elements, an overlay is blocking — dismiss it first (Escape, click outside, Close button) before retrying the original action.`;
434
+ }
435
+
436
+ return (
437
+ contextMsg +
438
+ dedent`
439
+ Some steps in the previous batch did not throw, but the URL did not change to the expected target and the page changed in other ways — the ARIA diff for each such step is listed in <previous_failures> above.
440
+
441
+ Read those diffs and judge what each step actually triggered. Different action types produce different reactions; the diff is your only evidence of what happened. A diff might show, for example: a new alert / alertdialog / status / validation message appearing near a field or at page level; a modal, dialog, or wizard step opening; a banner, toast, or notification region appearing; a section expanding or collapsing; a tab or accordion switching content. A diff might also be empty or unrelated to the step — that is also a signal.
442
+
443
+ Choose exactly ONE path based on what the diffs actually show — do not assume the previous step submitted any particular kind of data:
444
+
445
+ A. The diff indicates the application requires something only the user can supply — for example: an authentication failure you cannot guess past, a captcha, a permission the test cannot satisfy, or knowledge that is not present in the provided context. Call the stop() tool and quote what you saw in the diff and what is needed.
446
+
447
+ B. The diff indicates the next step is something you can perform from the existing knowledge / hint context — for example: re-emit a step with a value that exists in the knowledge but was used incorrectly; dismiss an unexpected modal; accept a confirmation; take a follow-up step the page now requires. Emit code blocks for that next step. Do NOT change the locator of a step that already produced a reaction.
448
+
449
+ C. The diff is empty or unrelated to your step — the action likely missed its target. Propose a different locator strategy.
450
+ `
451
+ );
452
+ }
453
+
454
+ private async executeAttempt(action: Action, codeBlock: string, message: string): Promise<{ ok: boolean; error?: string }> {
455
+ await action.exitIframe();
456
+
457
+ debugLog(`Attempting resolution: ${codeBlock}`);
458
+ const ok = await action.attempt(codeBlock, message);
459
+
460
+ const page = action.playwrightHelper?.page;
461
+ if (page) {
462
+ try {
463
+ await page.waitForLoadState('load', { timeout: 5000 });
464
+ } catch {
465
+ // Navigation did not reach 'load' state within timeout; continue and verify URL
466
+ }
467
+ }
468
+
469
+ if (ok) return { ok };
470
+
471
+ const raw = action.lastError?.message || 'attempt failed';
472
+ const firstMeaningful = raw.split('\n').find((l) => l.trim() && !l.trim().startsWith('at ')) || raw;
473
+ return { ok, error: normalizeInlineText(firstMeaningful).slice(0, 220) || 'attempt failed' };
474
+ }
475
+
476
+ private async verifyNavigation(action: Action, expectedUrl: string): Promise<{ freshState: ActionResult; urlMatches: boolean }> {
477
+ const page = action.playwrightHelper?.page;
478
+ if (page) {
479
+ try {
480
+ await page.waitForURL((url: URL) => matchesNavigationUrl(expectedUrl, `${url.pathname}${url.search}${url.hash}`), { timeout: 5000 });
481
+ } catch {
482
+ // URL did not transition to expectedUrl within timeout
483
+ }
484
+ }
485
+
486
+ const freshState = await this.explorer.capture();
487
+ const urlMatches = this.isSameExpectedOrigin(expectedUrl, action.stateManager) && matchesNavigationUrl(expectedUrl, this.comparableUrl(freshState, expectedUrl));
488
+
489
+ return { freshState, urlMatches };
490
+ }
491
+
492
+ private async ariaDiff(freshState: ActionResult, previous: ActionResult): Promise<string | null> {
493
+ if (freshState.getStateHash() === previous.getStateHash()) return null;
494
+ try {
495
+ const diff = await freshState.diff(previous);
496
+ return diff.ariaChanged;
497
+ } catch (err) {
498
+ debugLog('Failed to compute pageDiff for failed URL verification:', err);
499
+ return null;
500
+ }
501
+ }
502
+
503
+ private saveFlow(message: string, expectedUrl: string | undefined, actionResult: ActionResult, progressBlocks: string[]): void {
504
+ let scenario = message.split('\n')[0];
505
+ if (expectedUrl) {
506
+ scenario = `reach ${extractStatePath(expectedUrl)} from ${extractStatePath(actionResult.url || '')}`;
507
+ }
508
+
509
+ const recipe = progressBlocks
510
+ .join('\n')
511
+ .split('\n')
512
+ .filter((line) => !/^\s*I\.amOnPage\s*\(/.test(line))
513
+ .join('\n')
514
+ .trim();
515
+ if (!recipe) return;
516
+
517
+ this.experienceTracker.writeFlow(actionResult, `## FLOW: ${scenario}\n\n* ${scenario}\n\n\`\`\`js\n${recipe}\n\`\`\`\n\n---\n`);
518
+ }
519
+
520
+ private async rescueDelayedRedirect(action: Action, expectedUrl: string): Promise<boolean> {
521
+ await (action.getActor() as any).wait(1);
522
+ if (!this.isOnExpectedPage(expectedUrl, action.stateManager)) return false;
523
+ tag('success').log('Navigation resolved after delayed redirect');
524
+ return true;
525
+ }
526
+
527
+ private async askUserToResolve(action: Action, message: string, expectedUrl: string | undefined, stopReason: string | null): Promise<boolean> {
528
+ let stopLine = '';
529
+ if (stopReason) stopLine = `Navigator stopped: ${stopReason}\n`;
530
+
531
+ const userInput = await pause(`${stopLine}Navigator failed to resolve. Current: ${action.stateManager.getCurrentState()?.url}\nTarget: ${expectedUrl ?? '(none)'}\nEnter CodeceptJS commands (or press Enter to skip):`);
532
+ if (!userInput?.trim()) return false;
533
+
534
+ const resolved = await action.attempt(userInput, message);
535
+ if (!resolved) return false;
536
+ if (!expectedUrl) return true;
537
+
538
+ await (action.getActor() as any).wait(1);
539
+ return this.isOnExpectedPage(expectedUrl, action.stateManager);
540
+ }
541
+
485
542
  private buildExperienceTools(): { learnExperience: unknown } | undefined {
486
543
  if (!this.experienceTracker) return undefined;
487
544
  const stateManager = this.stateManager;
@@ -791,6 +848,8 @@ class Navigator implements Agent {
791
848
  }
792
849
  }
793
850
 
851
+ type BatchFailure = { code: string; error: string; ariaChanges?: string | null; urlAfter?: string };
852
+
794
853
  export type AssertionResult = { code: string; passed: boolean; proof: string[] };
795
854
 
796
855
  export { Navigator };