explorbot 0.2.5 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/boat/prima/README.md +96 -0
  2. package/boat/prima/package.json +14 -10
  3. package/boat/prima/src/cli.ts +5 -0
  4. package/boat/prima/src/prima.ts +17 -4
  5. package/dist/boat/prima/src/cli.js +7 -0
  6. package/dist/boat/prima/src/prima.js +18 -4
  7. package/dist/models.json +4 -4
  8. package/dist/package.json +6 -2
  9. package/dist/src/action-result.d.ts +13 -0
  10. package/dist/src/action-result.js +46 -15
  11. package/dist/src/action.d.ts +5 -2
  12. package/dist/src/action.js +48 -17
  13. package/dist/src/ai/captain/web-mode.js +1 -2
  14. package/dist/src/ai/captain.d.ts +20 -0
  15. package/dist/src/ai/captain.js +10 -1
  16. package/dist/src/ai/conversation.d.ts +1 -0
  17. package/dist/src/ai/conversation.js +3 -0
  18. package/dist/src/ai/driller.js +6 -2
  19. package/dist/src/ai/fisherman-tools.d.ts +40 -1
  20. package/dist/src/ai/fisherman-tools.js +39 -0
  21. package/dist/src/ai/fisherman.js +3 -2
  22. package/dist/src/ai/navigator.d.ts +2 -1
  23. package/dist/src/ai/navigator.js +5 -9
  24. package/dist/src/ai/pilot.js +51 -29
  25. package/dist/src/ai/planner/subpages.js +2 -16
  26. package/dist/src/ai/planner.js +1 -1
  27. package/dist/src/ai/provider.d.ts +3 -0
  28. package/dist/src/ai/provider.js +80 -17
  29. package/dist/src/ai/researcher/cache.d.ts +8 -3
  30. package/dist/src/ai/researcher/cache.js +13 -8
  31. package/dist/src/ai/researcher/deep-analysis.js +1 -1
  32. package/dist/src/ai/researcher/fingerprint-worker.js +21 -4
  33. package/dist/src/ai/researcher.js +4 -3
  34. package/dist/src/ai/rules.js +1 -5
  35. package/dist/src/ai/tester.d.ts +1 -1
  36. package/dist/src/ai/tester.js +34 -26
  37. package/dist/src/ai/tools.d.ts +8 -5
  38. package/dist/src/ai/tools.js +79 -56
  39. package/dist/src/commands/explore-command.js +22 -17
  40. package/dist/src/commands/init-command.js +13 -20
  41. package/dist/src/config.d.ts +2 -1
  42. package/dist/src/config.js +3 -1
  43. package/dist/src/experience-tracker.d.ts +2 -0
  44. package/dist/src/experience-tracker.js +12 -0
  45. package/dist/src/explorbot.js +1 -1
  46. package/dist/src/playwright-recorder.js +6 -12
  47. package/dist/src/test-plan.d.ts +8 -0
  48. package/dist/src/test-plan.js +11 -0
  49. package/dist/src/utils/html-diff.d.ts +5 -0
  50. package/dist/src/utils/html-diff.js +65 -6
  51. package/dist/src/utils/strings.d.ts +2 -0
  52. package/dist/src/utils/strings.js +32 -0
  53. package/dist/src/utils/url-matcher.d.ts +1 -0
  54. package/dist/src/utils/url-matcher.js +31 -2
  55. package/docs/basics/getting-started.md +33 -10
  56. package/docs/basics/providers.md +6 -4
  57. package/docs/contributing/npm-package.md +73 -4
  58. package/models.json +4 -4
  59. package/package.json +6 -2
  60. package/src/action-result.ts +61 -16
  61. package/src/action.ts +51 -17
  62. package/src/ai/captain/web-mode.ts +1 -2
  63. package/src/ai/captain.ts +9 -1
  64. package/src/ai/conversation.ts +3 -0
  65. package/src/ai/driller.ts +6 -2
  66. package/src/ai/fisherman-tools.ts +35 -0
  67. package/src/ai/fisherman.ts +3 -2
  68. package/src/ai/navigator.ts +6 -10
  69. package/src/ai/pilot.ts +54 -32
  70. package/src/ai/planner/subpages.ts +2 -13
  71. package/src/ai/planner.ts +1 -1
  72. package/src/ai/provider.ts +111 -41
  73. package/src/ai/researcher/cache.ts +17 -9
  74. package/src/ai/researcher/deep-analysis.ts +1 -1
  75. package/src/ai/researcher/fingerprint-worker.ts +23 -5
  76. package/src/ai/researcher.ts +4 -3
  77. package/src/ai/rules.ts +1 -5
  78. package/src/ai/tester.ts +32 -27
  79. package/src/ai/tools.ts +84 -60
  80. package/src/commands/explore-command.ts +17 -14
  81. package/src/commands/init-command.ts +14 -20
  82. package/src/config.ts +4 -2
  83. package/src/experience-tracker.ts +13 -0
  84. package/src/explorbot.ts +1 -1
  85. package/src/playwright-recorder.ts +6 -11
  86. package/src/test-plan.ts +18 -0
  87. package/src/utils/html-diff.ts +72 -7
  88. package/src/utils/strings.ts +36 -0
  89. package/src/utils/url-matcher.ts +27 -2
@@ -2,6 +2,7 @@ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { parentPort } from 'node:worker_threads';
4
4
  import { computeHtmlFingerprint } from '../../utils/html-diff.ts';
5
+ import { isSamePageFamily } from '../../utils/url-matcher.ts';
5
6
 
6
7
  function diceSimilarity(a: Set<string>, b: Set<string>): number {
7
8
  let intersection = 0;
@@ -13,8 +14,8 @@ function diceSimilarity(a: Set<string>, b: Set<string>): number {
13
14
  return Math.round(((2 * intersection) / total) * 100);
14
15
  }
15
16
 
16
- parentPort!.on('message', (data: { html: string; statesDir: string; maxAgeMs: number; threshold: number }) => {
17
- const { html, statesDir, maxAgeMs, threshold } = data;
17
+ parentPort!.on('message', (data: FingerprintRequest) => {
18
+ const { html, statesDir, maxAgeMs, threshold, url } = data;
18
19
 
19
20
  if (!existsSync(statesDir)) {
20
21
  parentPort!.postMessage({ matchHash: null, similarity: 0 });
@@ -32,22 +33,39 @@ parentPort!.on('message', (data: { html: string; statesDir: string; maxAgeMs: nu
32
33
 
33
34
  let bestHash: string | null = null;
34
35
  let bestSimilarity = 0;
36
+ let bestUrl: string | undefined;
35
37
 
36
38
  for (const file of files) {
37
39
  const filePath = join(statesDir, file);
38
40
  const mtime = statSync(filePath).mtimeMs;
39
41
  if (now - mtime > maxAgeMs) continue;
40
42
 
41
- const lines = readFileSync(filePath, 'utf8').split('\n').filter(Boolean);
42
- const storedFingerprint = new Set(lines);
43
+ const record = readFingerprint(filePath);
44
+ if (url && record.url && !isSamePageFamily(url, record.url)) continue;
45
+ const storedFingerprint = new Set(record.entries);
43
46
  const similarity = diceSimilarity(currentFingerprint, storedFingerprint);
44
47
 
45
48
  if (similarity > bestSimilarity) {
46
49
  bestSimilarity = similarity;
47
50
  bestHash = file.replace('.fingerprint', '');
51
+ bestUrl = record.url;
48
52
  }
49
53
  }
50
54
 
51
55
  const matched = bestSimilarity >= threshold;
52
- parentPort!.postMessage({ matchHash: matched ? bestHash : null, similarity: bestSimilarity });
56
+ parentPort!.postMessage({ matchHash: matched ? bestHash : null, similarity: bestSimilarity, url: matched ? bestUrl : undefined });
53
57
  });
58
+
59
+ function readFingerprint(filePath: string): FingerprintRecord {
60
+ const content = readFileSync(filePath, 'utf8');
61
+ try {
62
+ const record = JSON.parse(content);
63
+ if (Array.isArray(record.entries)) return record;
64
+ } catch {
65
+ return { entries: content.split('\n').filter(Boolean) };
66
+ }
67
+ return { entries: content.split('\n').filter(Boolean) };
68
+ }
69
+
70
+ type FingerprintRecord = { entries: string[]; url?: string };
71
+ type FingerprintRequest = { html: string; statesDir: string; maxAgeMs: number; threshold: number; url?: string };
@@ -97,6 +97,7 @@ export class Researcher extends ResearcherBase implements Agent {
97
97
  let retriesLeft = opts._retriesLeft ?? maxRetries;
98
98
  this.actionResult = ActionResult.fromState(state);
99
99
  const stateHash = state.hash || this.actionResult.getStateHash();
100
+ const researchState = { ...state, hash: stateHash };
100
101
 
101
102
  if (!force && stateHash) {
102
103
  const cached = getCachedResearch(stateHash);
@@ -139,10 +140,10 @@ export class Researcher extends ResearcherBase implements Agent {
139
140
  const combinedHtml = await this.actionResult!.combinedHtml();
140
141
 
141
142
  if (!deep && !force) {
142
- const similar = await findSimilarResearch(combinedHtml);
143
+ const similar = await findSimilarResearch(combinedHtml, state.url);
143
144
  if (similar) {
144
145
  tag('operation').log('Similar research found, reusing cached result');
145
- if (stateHash) saveResearch(stateHash, similar, combinedHtml);
146
+ if (stateHash) saveResearch(researchState, similar, combinedHtml);
146
147
  tag('multiline').log(formatResearchSummary(similar));
147
148
  tag('success').log('Research complete (reused)');
148
149
  await this.hooksRunner.runAfterHook('researcher', state.url);
@@ -286,7 +287,7 @@ export class Researcher extends ResearcherBase implements Agent {
286
287
 
287
288
  let researchFile: string | null = null;
288
289
  if (stateHash) {
289
- researchFile = saveResearch(stateHash, result.text, combinedHtml);
290
+ researchFile = saveResearch(researchState, result.text, combinedHtml);
290
291
  }
291
292
 
292
293
  const summaryText = mdq(result.text).query('section2(/^summary/)').query('paragraph[0]').text().trim();
package/src/ai/rules.ts CHANGED
@@ -4,10 +4,6 @@ export const recommendedCodeceptCommands = ['I.click', 'I.type', 'I.fillField',
4
4
 
5
5
  const locatorPriorityRule = dedent`
6
6
  <locator_priority>
7
- When the page context shows the element a ref, such as [ref=e14], there is no locator to select: click it with clickRef
8
- and that ref. A ref names one exact element, so it never matches the wrong one and never has to be narrowed. Everything
9
- below is for elements the context gives no ref for.
10
-
11
7
  Use the following priority when selecting locators:
12
8
 
13
9
  1. ARIA locators (first choice) - target browser's accessibility tree, most reliable
@@ -80,7 +76,7 @@ const locatorStrategyRule = dedent`
80
76
 
81
77
  NEVER include \`eidx\` attribute in any locator (ARIA, CSS, XPath). It is an internal annotation.
82
78
 
83
- If <aria> section is not present or element is not found there, fall back to CSS/XPath locators from <html> section.
79
+ If the element is not found in the ARIA snapshot, fall back to CSS/XPath locators from page HTML.
84
80
 
85
81
  Stick to semantic attributes like role, aria-*, id, class, name, data-id, etc.
86
82
  Avoid IDs that follow framework auto-generation patterns (these change on every page load):
package/src/ai/tester.ts CHANGED
@@ -8,13 +8,14 @@ import { clearActivity, setActivity } from '../activity.ts';
8
8
  import type { RequestStore } from '../api/request-store.ts';
9
9
  import type { TestRun } from '../explorer.ts';
10
10
  import { Observability } from '../observability.ts';
11
- import type { StateTransition } from '../state-manager.ts';
11
+ import { type StateTransition, normalizeUrl } from '../state-manager.ts';
12
12
  import { Stats } from '../stats.ts';
13
13
  import { type Test, TestResult, type TestResultType } from '../test-plan.ts';
14
- import { compactAriaSnapshot, detectFocusArea } from '../utils/aria.ts';
14
+ import { detectFocusArea } from '../utils/aria.ts';
15
15
  import { ErrorPageError, isErrorPage } from '../utils/error-page.ts';
16
16
  import { createDebug, tag } from '../utils/logger.ts';
17
17
  import { loop } from '../utils/loop.ts';
18
+ import { compactErrorMessage } from '../utils/strings.ts';
18
19
  import type { Agent, AgentDeps } from './agent.ts';
19
20
  import type { Captain } from './captain.ts';
20
21
  import type { Conversation } from './conversation.ts';
@@ -41,6 +42,7 @@ const SAMPLE_FILES: Record<string, string> = {
41
42
 
42
43
  export class Tester extends TaskAgent implements Agent {
43
44
  protected readonly ACTION_TOOLS = ['click', 'hover', 'pressKey', 'form'];
45
+ protected readonly DELEGATED_ACTION_TOOLS = ['interact'];
44
46
  protected readonly SPECIAL_CONTEXT_ACTION_TOOLS = ['exitIframe'];
45
47
  emoji = '🧪';
46
48
  private requestStore: RequestStore;
@@ -98,7 +100,7 @@ export class Tester extends TaskAgent implements Agent {
98
100
 
99
101
  async test(task: Test, opts: TestOptions = {}): Promise<{ success: boolean }> {
100
102
  Stats.tests++;
101
- const state = this.stateManager.getCurrentState();
103
+ let state = this.stateManager.getCurrentState();
102
104
  if (!state) throw new Error('No state found');
103
105
 
104
106
  setActivity(`🧪 Testing: ${task.scenario}`, 'action');
@@ -120,7 +122,21 @@ export class Tester extends TaskAgent implements Agent {
120
122
  task.addObservation(`Network error: ${r.method} ${r.path} → ${r.status}`);
121
123
  });
122
124
 
123
- const initialState = ActionResult.fromState(state);
125
+ let initialState = ActionResult.fromState(state);
126
+ const currentUrl = state.fullUrl || state.url;
127
+ let startOnCurrentPage = opts.startOnCurrentPage;
128
+ if (isErrorPage(initialState) && !startOnCurrentPage && task.startUrl && normalizeUrl(currentUrl) !== normalizeUrl(task.startUrl)) {
129
+ debugLog(`Recovering from error page at ${currentUrl} by navigating to ${task.startUrl}`);
130
+ try {
131
+ await this.explorer.visit(task.startUrl);
132
+ state = this.stateManager.getCurrentState();
133
+ if (!state) throw new Error('No state found after navigating to test start URL');
134
+ initialState = ActionResult.fromState(state);
135
+ startOnCurrentPage = true;
136
+ } catch (error) {
137
+ debugLog(`Could not recover from error page: ${compactErrorMessage(error)}`);
138
+ }
139
+ }
124
140
  if (isErrorPage(initialState)) {
125
141
  task.start();
126
142
  this.testRun = await this.explorer.beginTest(task);
@@ -151,7 +167,7 @@ export class Tester extends TaskAgent implements Agent {
151
167
  expected: task.expected,
152
168
  },
153
169
  },
154
- async () => this.runTestSession(task, initialState, conversation, { offFailedRequest }, opts)
170
+ async () => this.runTestSession(task, initialState, conversation, { offFailedRequest }, { ...opts, startOnCurrentPage })
155
171
  );
156
172
  }
157
173
 
@@ -275,8 +291,6 @@ export class Tester extends TaskAgent implements Agent {
275
291
  }
276
292
 
277
293
  conversation.cleanupTag('page_aria', '...cleaned aria snapshot...', 1);
278
- conversation.cleanupTag('page_html', '...cleaned HTML snapshot...', 1);
279
- conversation.cleanupTag('experience', '...cleaned experience...', 1);
280
294
  conversation.cleanupTag('applied_experience', '...cleaned past experience...', 1);
281
295
  conversation.cleanupTag('page_ui_map', '...cleaned UI map...', 1);
282
296
  conversation.cleanupTag('page_ui_map_overlay', '...cleaned UI overlay...', 1);
@@ -414,8 +428,6 @@ export class Tester extends TaskAgent implements Agent {
414
428
  this.stalledIterations = 0;
415
429
  tag('info').log(`Pilot extending test (${extensions}/${this.MAX_EXTENSIONS})`);
416
430
  conversation.cleanupTag('page_aria', '...trimmed...', 1);
417
- conversation.cleanupTag('page_html', '...trimmed...', 0);
418
- conversation.cleanupTag('experience', '...trimmed...', 0);
419
431
  conversation.cleanupTag('page_ui_map', '...trimmed...', 0);
420
432
  conversation.cleanupTag('page_ui_map_overlay', '...trimmed...', 0);
421
433
  conversation.compactToolResults(1);
@@ -455,7 +467,7 @@ export class Tester extends TaskAgent implements Agent {
455
467
 
456
468
  const currentState = this.getCurrentState();
457
469
  const stateChanged = previousState.url !== currentState.url || previousState.hash !== currentState.hash;
458
- const actionTools = [...this.ACTION_TOOLS, ...this.SPECIAL_CONTEXT_ACTION_TOOLS];
470
+ const actionTools = [...this.ACTION_TOOLS, ...this.DELEGATED_ACTION_TOOLS, ...this.SPECIAL_CONTEXT_ACTION_TOOLS];
459
471
  const hasSuccessfulAction = toolExecutions.some((execution) => execution.wasSuccessful && actionTools.includes(execution.toolName));
460
472
  const hasSuccessfulAssertion = toolExecutions.some((execution) => execution.wasSuccessful && this.ASSERTION_TOOLS.includes(execution.toolName));
461
473
 
@@ -482,7 +494,8 @@ export class Tester extends TaskAgent implements Agent {
482
494
 
483
495
  <rules>
484
496
  Use tools ${this.ACTION_TOOLS.join(', ')} to interact with the page.
485
- Use tool names exactly as listed in this prompt. Do not invent combined tool names, aliases, or names with channel markers such as "commentary".
497
+ Fall back to interact() when those fail, when the step needs a sequence of actions, or when your context is not enough to locate the element.
498
+ Use tool names exactly as listed in this prompt. Do not invent combined tool names or aliases.
486
499
  Match each tool input schema exactly. Do not invent parameter names or pass extra fields.
487
500
  Do not do unsuccesful clicks again.
488
501
  Do not run same tool calls with same parameters again.
@@ -602,13 +615,13 @@ export class Tester extends TaskAgent implements Agent {
602
615
  </page>
603
616
 
604
617
  <page_aria>
605
- ${await this.interactiveAriaWithRefs(currentState)}
618
+ ${currentState.getInteractiveARIA()}
606
619
  </page_aria>
607
620
  ${uiMapSection}
608
621
 
609
622
  Use <page_ui_map> to understand the page structure and its main elements.
610
- However, <page_ui_map> is not always up to date, use <page_aria> and <page_html> to understand the ACTUAL state of the page
611
- Do not interact with elements that are not listed in <page_aria> and <page_html>
623
+ However, <page_ui_map> is not always up to date, use <page_aria> to understand the ACTUAL state of the page
624
+ Do not interact with elements that are not listed in <page_aria> or in HTML returned by tools
612
625
  Refer to information on page sections in <page_ui_map> and use container CSS locators to interact with elements inside sections
613
626
  `;
614
627
  return context;
@@ -639,17 +652,11 @@ export class Tester extends TaskAgent implements Agent {
639
652
  </page>
640
653
 
641
654
  <page_aria>
642
- ${await this.interactiveAriaWithRefs(currentState)}
655
+ ${currentState.getInteractiveARIA()}
643
656
  </page_aria>
644
657
  `;
645
658
  }
646
659
 
647
- private async interactiveAriaWithRefs(state: ActionResult): Promise<string> {
648
- const withRefs = await Promise.resolve(this.explorer?.withPage?.((page: any) => page.locator('body').ariaSnapshot({ mode: 'ai' }))).catch(() => null);
649
- if (!withRefs) return state.getInteractiveARIA();
650
- return compactAriaSnapshot(withRefs, false);
651
- }
652
-
653
660
  private finishTest(task: Test): void {
654
661
  if (!task.result) {
655
662
  if (task.hasAchievedAll()) task.finish(TestResult.PASSED);
@@ -719,7 +726,7 @@ export class Tester extends TaskAgent implements Agent {
719
726
 
720
727
  <rules>
721
728
  - Refer to UI Map from <page_ui_map> to understand the page structure and its main elements
722
- - Use only elements that exist in the provided ARIA tree or HTML, <page_aria> and <page_html>
729
+ - Use only elements that exist in <page_aria> or in HTML returned by tools
723
730
  - Use tool input schemas exactly as documented. Do not invent parameter names or add fields not listed by the tool schema.
724
731
  - Use click() for buttons, links, and clickable elements ONLY - do NOT include I.fillField() or I.type() commands in click() tool
725
732
  - click() commands array is for FALLBACK LOCATORS of the SAME element, NOT for clicking different elements in sequence. If you need to click two different elements, make two separate click() calls.
@@ -742,7 +749,8 @@ export class Tester extends TaskAgent implements Agent {
742
749
  - Check for error messages to understand if there are issues
743
750
  - Verify if data was correctly saved and changes are reflected on the page
744
751
  - By default, you receive accessibility tree data which shows interactive elements and page structure
745
- - Understand current context by following <page_html>, <page_aria>, and <page_ui_map>
752
+ - Full page HTML is never injected automatically. When ARIA is not enough, delegate to the tools that read it: verify() to assert, interact() to act
753
+ - Understand current context by following <page_aria> and <page_ui_map>
746
754
  - Before submitting form, check all inputs were filled in correctly using see() tool
747
755
  - When you interact with form with inputs, ensure that you click corresponding button to save its data
748
756
  - Follow <locator_priority> rules when selecting locators for all tools
@@ -794,7 +802,6 @@ export class Tester extends TaskAgent implements Agent {
794
802
 
795
803
  private buildScenarioBlock(task: Test, actionResult: ActionResult): string {
796
804
  const knowledge = this.getKnowledge(actionResult);
797
- const experience = this.getExperience(actionResult);
798
805
 
799
806
  return dedent`
800
807
  <task>
@@ -824,8 +831,6 @@ export class Tester extends TaskAgent implements Agent {
824
831
  ${this.buildAvailableFiles()}
825
832
 
826
833
  ${knowledge}
827
-
828
- ${experience}
829
834
  `;
830
835
  }
831
836
 
@@ -947,7 +952,7 @@ export class Tester extends TaskAgent implements Agent {
947
952
  };
948
953
 
949
954
  if (resetAction.lastError) {
950
- result.error = resetAction.lastError.toString();
955
+ result.error = compactErrorMessage(resetAction.lastError);
951
956
  }
952
957
 
953
958
  return result;
package/src/ai/tools.ts CHANGED
@@ -2,12 +2,13 @@ import { tool } from 'ai';
2
2
  import dedent from 'dedent';
3
3
  import { z } from 'zod';
4
4
  import { ActionResult, type PageDiff, type ToolResultMetadata } from '../action-result.ts';
5
- import type { ExperienceTracker } from '../experience-tracker.ts';
5
+ import { type ExperienceTracker, renderExperienceRecipes } from '../experience-tracker.ts';
6
6
  import { Stats } from '../stats.ts';
7
7
  import { type Task, TestResult } from '../test-plan.js';
8
8
  import { LARGE_ARIA_CHANGE_THRESHOLD } from '../utils/aria.ts';
9
9
  import { isFatalBrowserError } from '../utils/browser-errors.ts';
10
10
  import { createDebug, tag } from '../utils/logger.js';
11
+ import { compactErrorMessage } from '../utils/strings.ts';
11
12
  import { pause } from '../utils/loop.js';
12
13
  import { WebElement } from '../utils/web-element.ts';
13
14
  import type { ToolDeps } from './agent.ts';
@@ -34,10 +35,6 @@ export function createCodeceptJSTools({ explorer, stateManager, ai }: ToolDeps,
34
35
  description: dedent`
35
36
  Click an element by trying multiple CodeceptJS commands in order until one succeeds.
36
37
 
37
- Use this only for elements the page context gives you no ref for. When the element shows a ref such as [ref=e14],
38
- call clickRef with that ref instead — composing a locator for an element that already has a ref is wasted work,
39
- and a locator can match several elements where a ref cannot.
40
-
41
38
  Follow <locator_priority> from system prompt for locator selection.
42
39
 
43
40
  I.click(locator) - click element matching locator
@@ -98,7 +95,7 @@ export function createCodeceptJSTools({ explorer, stateManager, ai }: ToolDeps,
98
95
  const success = await action.attempt(command, explanation);
99
96
 
100
97
  const attempt: { command: string; success: boolean; error?: string } = { command, success };
101
- if (action.lastError) attempt.error = action.lastError.toString();
98
+ if (action.lastError) attempt.error = errorText(action.lastError);
102
99
  attempts.push(attempt);
103
100
 
104
101
  if (success) {
@@ -124,7 +121,7 @@ export function createCodeceptJSTools({ explorer, stateManager, ai }: ToolDeps,
124
121
 
125
122
  for (const retryCmd of retryCommands) {
126
123
  if (!(await action.attempt(retryCmd, explanation))) {
127
- attempts.push({ command: retryCmd, success: false, error: action.lastError?.toString() });
124
+ attempts.push({ command: retryCmd, success: false, error: errorText(action.lastError) });
128
125
  continue;
129
126
  }
130
127
  const toolResult = await ActionResult.fromState(stateManager.getCurrentState()!).toToolResult(previousState, retryCmd);
@@ -151,41 +148,6 @@ export function createCodeceptJSTools({ explorer, stateManager, ai }: ToolDeps,
151
148
  },
152
149
  }),
153
150
 
154
- clickRef: tool({
155
- description: dedent`
156
- Click an element by the ref the page context gave it, e.g. [ref=e14].
157
-
158
- Prefer this over click() whenever the element you want carries a ref. A ref names one exact element, so it
159
- cannot match several by mistake and never needs disambiguating — it is the fastest way to click.
160
- Only pass a ref that appears in the page context you were given. Never invent or guess one.
161
- If it reports the ref is gone, the page has been rebuilt: get fresh context and use the new ref.
162
- `,
163
- inputSchema: z.object({
164
- ref: z.string().describe('The ref exactly as it appears in the page context, e.g. "e14"'),
165
- element: z.string().describe('Role and name of the element you are clicking, for the record'),
166
- }),
167
- execute: async ({ ref, element }) => {
168
- const activeNote = task.startNote(`Click ${element}`);
169
- const previousState = ActionResult.fromState(stateManager.getCurrentState()!);
170
- const action = explorer.action();
171
- const named = await describeRef(explorer, ref);
172
- const run = `I.usePlaywrightTo(${JSON.stringify(`click ${element}`)}, async ({ page }) => page.locator(${JSON.stringify(`aria-ref=${ref}`)}).click())`;
173
-
174
- if (!(await action.attempt(run, `Click ${element}`))) {
175
- activeNote.commit(TestResult.FAILED);
176
- return failedToolResult('clickRef', `Ref ${ref} could not be clicked: ${errorText(action.lastError)}`, {
177
- suggestion: 'The ref may belong to an older version of the page. Get fresh context and use the ref it gives, or fall back to click() with a locator.',
178
- });
179
- }
180
-
181
- // a ref belongs to this session only, so the run is reported as the locator a later test can replay
182
- const code = named ? `I.click(${JSON.stringify(named)})` : run;
183
- const toolResult = await ActionResult.fromState(stateManager.getCurrentState()!).toToolResult(previousState, code);
184
- await commitNote(activeNote, TestResult.PASSED, toolResult, action);
185
- return successToolResult('clickRef', { ...toolResult, code }, action);
186
- },
187
- }),
188
-
189
151
  hover: tool({
190
152
  description: dedent`
191
153
  Move the mouse cursor to an element to reveal hover-only controls.
@@ -237,7 +199,7 @@ export function createCodeceptJSTools({ explorer, stateManager, ai }: ToolDeps,
237
199
  for (const command of commands) {
238
200
  const success = await action.attempt(command, explanation);
239
201
  const attempt: { command: string; success: boolean; error?: string } = { command, success };
240
- if (action.lastError) attempt.error = action.lastError.toString();
202
+ if (action.lastError) attempt.error = errorText(action.lastError);
241
203
  attempts.push(attempt);
242
204
 
243
205
  if (!success) continue;
@@ -320,7 +282,7 @@ export function createCodeceptJSTools({ explorer, stateManager, ai }: ToolDeps,
320
282
  );
321
283
  }
322
284
 
323
- const errorMsg = `pressKey fallback to type() failed: ${action.lastError?.toString()}`;
285
+ const errorMsg = `pressKey fallback to type() failed: ${errorText(action.lastError)}`;
324
286
  await commitNote(activeNote, TestResult.FAILED, toolResult, action);
325
287
  return failedToolResult('pressKey', errorMsg, {
326
288
  ...toolResult,
@@ -366,7 +328,7 @@ export function createCodeceptJSTools({ explorer, stateManager, ai }: ToolDeps,
366
328
  );
367
329
  }
368
330
 
369
- const errorMsg = `pressKey() failed: ${action.lastError?.toString()}`;
331
+ const errorMsg = `pressKey() failed: ${errorText(action.lastError)}`;
370
332
  await commitNote(activeNote, TestResult.FAILED, toolResult, action);
371
333
  return failedToolResult('pressKey', errorMsg, {
372
334
  ...toolResult,
@@ -387,8 +349,6 @@ export function createCodeceptJSTools({ explorer, stateManager, ai }: ToolDeps,
387
349
  Execute raw CodeceptJS code block with multiple commands.
388
350
  USE THIS TOOL for typing text into fields: I.fillField, I.type
389
351
 
390
- Do not put a click on a ref-bearing element in here — clickRef with its ref is cheaper and cannot mis-target.
391
-
392
352
  Follow <actions> from system prompt for available commands.
393
353
  Follow <locator_priority> from system prompt for locator selection.
394
354
 
@@ -448,7 +408,7 @@ export function createCodeceptJSTools({ explorer, stateManager, ai }: ToolDeps,
448
408
  const toolResult = await ActionResult.fromState(stateManager.getCurrentState()!).toToolResult(previousState, formLocator);
449
409
 
450
410
  if (action.lastError) {
451
- const message = action.lastError ? String(action.lastError) : 'Unknown error';
411
+ const message = errorText(action.lastError);
452
412
  await commitNote(activeNote, TestResult.FAILED, toolResult, action);
453
413
 
454
414
  let formSuggestion = 'Look into error message and identify which commands passed and which failed. Continue execution using step-by-step approach using click() and form() tools.';
@@ -502,6 +462,45 @@ export function createCodeceptJSTools({ explorer, stateManager, ai }: ToolDeps,
502
462
  };
503
463
  }
504
464
 
465
+ export function createRefTools({ explorer, stateManager }: ToolDeps, task: Task) {
466
+ return {
467
+ clickRef: tool({
468
+ description: dedent`
469
+ Click an element by the ref the page context gave it, e.g. [ref=e14].
470
+
471
+ Prefer this over click() whenever the element you want carries a ref. A ref names one exact element, so it
472
+ cannot match several by mistake and never needs disambiguating — it is the fastest way to click.
473
+ Only pass a ref that appears in the page context you were given. Never invent or guess one.
474
+ If it reports the ref is gone, the page has been rebuilt: get fresh context and use the new ref.
475
+ `,
476
+ inputSchema: z.object({
477
+ ref: z.string().describe('The ref exactly as it appears in the page context, e.g. "e14"'),
478
+ element: z.string().describe('Role and name of the element you are clicking, for the record'),
479
+ }),
480
+ execute: async ({ ref, element }) => {
481
+ const activeNote = task.startNote(`Click ${element}`);
482
+ const previousState = ActionResult.fromState(stateManager.getCurrentState()!);
483
+ const action = explorer.action();
484
+ const named = await describeRef(explorer, ref);
485
+ const run = `I.usePlaywrightTo(${JSON.stringify(`click ${element}`)}, async ({ page }) => page.locator(${JSON.stringify(`aria-ref=${ref}`)}).click())`;
486
+
487
+ if (!(await action.attempt(run, `Click ${element}`))) {
488
+ activeNote.commit(TestResult.FAILED);
489
+ return failedToolResult('clickRef', `Ref ${ref} could not be clicked: ${errorText(action.lastError)}`, {
490
+ suggestion: 'The ref may belong to an older version of the page. Get fresh context and use the ref it gives, or fall back to click() with a locator.',
491
+ });
492
+ }
493
+
494
+ // a ref belongs to this session only, so the run is reported as the locator a later test can replay
495
+ const code = named ? `I.click(${JSON.stringify(named)})` : run;
496
+ const toolResult = await ActionResult.fromState(stateManager.getCurrentState()!).toToolResult(previousState, code);
497
+ await commitNote(activeNote, TestResult.PASSED, toolResult, action);
498
+ return successToolResult('clickRef', { ...toolResult, code }, action);
499
+ },
500
+ }),
501
+ };
502
+ }
503
+
505
504
  export function createIframeTools({ explorer, stateManager }: ToolDeps) {
506
505
  return {
507
506
  exitIframe: tool({
@@ -627,7 +626,7 @@ export function createAgentTools({ explorer, stateManager, ai, researcher, navig
627
626
 
628
627
  DO NOT call this if:
629
628
  - You just performed an action (pageDiff already provided in response)
630
- - You already have recent <page_html>/<page_aria> in context
629
+ - You already have a recent <page_aria> snapshot in context
631
630
  - You're about to perform an action (you'll get pageDiff after)
632
631
 
633
632
  Call ONLY when:
@@ -782,12 +781,18 @@ export function createAgentTools({ explorer, stateManager, ai, researcher, navig
782
781
 
783
782
  interact: tool({
784
783
  description: dedent`
785
- Execute an action on the current page using AI-powered interaction.
786
- Use this to perform actions like clicking buttons, selecting options, filling forms, etc.
787
- The AI will generate and try multiple CodeceptJS code strategies to accomplish the instruction.
784
+ Delegate one step to the Navigator, which reads the full page HTML and tries multiple CodeceptJS strategies.
785
+ Slower than the direct action tools use it as a fallback, not as the default.
786
+
787
+ Use when:
788
+ - direct action tools failed and you have no better locator to try
789
+ - the step needs a sequence of actions to complete
790
+ - the element is not in the context you have
791
+
792
+ Describe the outcome to reach, not the locator to use.
788
793
  `,
789
794
  inputSchema: z.object({
790
- instruction: z.string().describe('What action to perform on the page, e.g. "select new suite option", "click the Submit button"'),
795
+ instruction: z.string().describe('The step to perform on the page, described by its intent'),
791
796
  }),
792
797
  execute: async ({ instruction }) => {
793
798
  try {
@@ -799,7 +804,8 @@ export function createAgentTools({ explorer, stateManager, ai, researcher, navig
799
804
 
800
805
  const previousState = ActionResult.fromState(currentState);
801
806
  const actionResult = ActionResult.fromState(currentState);
802
- const success = await navigator.resolveState(instruction, actionResult);
807
+ const experience = renderExperienceRecipes(explorer.activeTest?.getAppliedExperience(actionResult) ?? []);
808
+ const success = await navigator.resolveState(instruction, actionResult, { experience });
803
809
 
804
810
  const toolResult = await ActionResult.fromState(stateManager.getCurrentState()!).toToolResult(previousState, instruction);
805
811
 
@@ -952,7 +958,7 @@ export function createAgentTools({ explorer, stateManager, ai, researcher, navig
952
958
  }
953
959
 
954
960
  const failData: Record<string, any> = { suggestion: 'Try reset() to return to the starting page.' };
955
- if (action.lastError) failData.error = action.lastError.toString();
961
+ if (action.lastError) failData.error = errorText(action.lastError);
956
962
  return failedToolResult('back', `Failed to navigate back to ${targetUrl}`, failData);
957
963
  },
958
964
  }),
@@ -1015,7 +1021,7 @@ export function createAgentTools({ explorer, stateManager, ai, researcher, navig
1015
1021
 
1016
1022
  if (result.totalFound === 0) {
1017
1023
  return failedToolResult('xpathCheck', `No elements matched XPath: ${xpath}`, {
1018
- suggestion: 'Try a broader expression. Examples: //*[contains(@class, "btn")], //button, //*[contains(text(), "keyword")]',
1024
+ suggestion: 'Do not guess another expression. Narrow down from what you know about the target: its role, its visible text, its nearest labelled ancestor. Add one constraint at a time.',
1019
1025
  });
1020
1026
  }
1021
1027
 
@@ -1112,7 +1118,12 @@ export function createAgentTools({ explorer, stateManager, ai, researcher, navig
1112
1118
  return tools;
1113
1119
  }
1114
1120
 
1115
- const PAGE_DIFF_SUGGESTION = 'Analyze page diff. htmlParts shows what changed and WHERE — each part has a container selector. Use the container as context when clicking elements from the diff.';
1121
+ const PAGE_DIFF_SUGGESTION =
1122
+ 'Analyze page diff. htmlParts shows what changed and WHERE — each part has a container selector. Use the container as context when clicking elements from the diff. messages holds text the app showed in response, requests the calls it made and consoleErrors what it logged.';
1123
+
1124
+ const FAILED_REQUEST_SUGGESTION = 'The server rejected a request made by this action (see requests). The UI accepted the interaction but the operation did not complete — read messages and consoleErrors for the reason and report it instead of repeating the action.';
1125
+
1126
+ const NAVIGATED_SUGGESTION = 'The action left the page. Elements are never compared across pages, so this diff carries the move itself and what the app announced in transit — an empty element diff does not mean nothing happened.';
1116
1127
 
1117
1128
  const ARIA_OUTPUT_CAP = 4000;
1118
1129
  const HTML_OUTPUT_CAP = 6000;
@@ -1154,7 +1165,7 @@ function transformContainsCommand(command: string): string {
1154
1165
  }
1155
1166
 
1156
1167
  function errorText(error: unknown): string {
1157
- if (error instanceof Error) return error.toString();
1168
+ if (error instanceof Error) return compactErrorMessage(error);
1158
1169
  return 'Unknown error occurred';
1159
1170
  }
1160
1171
 
@@ -1197,7 +1208,11 @@ export function successToolResult(action: string, data?: Record<string, any>, so
1197
1208
  const ariaChanges = data.pageDiff.ariaChanges || '';
1198
1209
  const urlChanged = data.pageDiff.urlChanged === true;
1199
1210
  const hasHtmlParts = Array.isArray(data.pageDiff.htmlParts) && data.pageDiff.htmlParts.length > 0;
1200
- if (isMajorPageChange(data.pageDiff)) {
1211
+ if (hasFailedRequest(data.pageDiff)) {
1212
+ suggestion = `${FAILED_REQUEST_SUGGESTION} ${suggestion}`;
1213
+ } else if (urlChanged) {
1214
+ suggestion = `${NAVIGATED_SUGGESTION} ${suggestion}`;
1215
+ } else if (isMajorPageChange(data.pageDiff)) {
1201
1216
  suggestion = `MAJOR PAGE CHANGE. Page entered a different mode. Check htmlParts and iframes in pageDiff before next action. ${suggestion}`;
1202
1217
  } else if (!urlChanged && !ariaChanges && !hasHtmlParts) {
1203
1218
  suggestion = 'Action ran without error but produced no observable change (URL, ARIA and HTML all unchanged). The locator likely matched a non-interactive ancestor or an element outside the intended control. Re-locate via xpathCheck() or verify with see() before treating this as success.';
@@ -1213,10 +1228,15 @@ export function isMajorPageChange(pageDiff: PageDiff): boolean {
1213
1228
  return pageDiff.urlChanged !== true && (pageDiff.ariaChangeCount ?? 0) >= LARGE_ARIA_CHANGE_THRESHOLD;
1214
1229
  }
1215
1230
 
1231
+ export function hasFailedRequest(pageDiff: PageDiff): boolean {
1232
+ return (pageDiff.requests ?? []).some((request) => request.status >= 400);
1233
+ }
1234
+
1216
1235
  function hasObservablePageChange(data?: Record<string, any>): boolean {
1217
1236
  if (!data?.pageDiff) return false;
1218
1237
  if (data.pageDiff.urlChanged === true) return true;
1219
1238
  if (data.pageDiff.ariaChanges) return true;
1239
+ if (data.pageDiff.messages?.length) return true;
1220
1240
  return Array.isArray(data.pageDiff.htmlParts) && data.pageDiff.htmlParts.length > 0;
1221
1241
  }
1222
1242
 
@@ -1278,6 +1298,10 @@ export function clickFailureSuggestion(attempts: Array<{ error?: string }>): str
1278
1298
  return 'Element is in the DOM but not visible. Reveal it first — scroll to it, expand its section, or open the panel holding it.';
1279
1299
  }
1280
1300
 
1301
+ if (errors.some((e) => e.includes('SyntaxError'))) {
1302
+ return 'The command string never parsed as JavaScript — quotes or brackets do not match. No element was looked up, so this tells you nothing about the page. Re-emit the same intent as valid CodeceptJS.';
1303
+ }
1304
+
1281
1305
  const notFound = errors.filter((e) => e.includes('was not found'));
1282
1306
 
1283
1307
  if (notFound.length && notFound.every((e) => e.includes('was not found inside element'))) {
@@ -1372,7 +1396,7 @@ function getNotFoundSuggestion(errorMessage: string): string | null {
1372
1396
  Element was not found. The locator does not exist on this page.
1373
1397
  1. Use see() to visually analyze what elements are actually on the page
1374
1398
  2. Use context() to get fresh HTML and ARIA snapshot
1375
- 3. Use ONLY locators from <page_aria> or <page_html>
1399
+ 3. Use ONLY locators from <page_aria> or from HTML returned by context()
1376
1400
  4. Prefer ARIA locators: { "role": "button", "text": "visible text" }
1377
1401
  `;
1378
1402
  }
@@ -63,23 +63,26 @@ export class ExploreCommand extends BaseCommand {
63
63
  return;
64
64
  }
65
65
 
66
- if (cfg.enabled) {
67
- await this.runReuseMode(mainUrl, feature, cfg);
68
- } else {
69
- await this.runFreshMode(mainUrl, feature, cfg.styles);
70
- }
66
+ try {
67
+ if (cfg.enabled) {
68
+ await this.runReuseMode(mainUrl, feature, cfg);
69
+ } else {
70
+ await this.runFreshMode(mainUrl, feature, cfg.styles);
71
+ }
71
72
 
72
- const mainPlan = this.completedPlans[0];
73
- if (mainPlan) this.explorBot.setCurrentPlan(mainPlan);
74
- if (this.dryRun) {
73
+ const mainPlan = this.completedPlans[0];
74
+ if (mainPlan) this.explorBot.setCurrentPlan(mainPlan);
75
+ if (this.dryRun) {
76
+ this.printResults();
77
+ return;
78
+ }
79
+ if (mainUrl) await this.explorBot.visit(mainUrl).catch((err) => tag('warning').log(`Could not return to ${mainUrl}: ${browserErrorMessage(err)}`));
80
+ const savedPath = this.explorBot.savePlans(this.completedPlans);
75
81
  this.printResults();
76
- return;
82
+ this.printNextSteps(savedPath);
83
+ } finally {
84
+ if (!this.dryRun) await this.explorBot.printSessionAnalysis();
77
85
  }
78
- if (mainUrl) await this.explorBot.visit(mainUrl).catch((err) => tag('warning').log(`Could not return to ${mainUrl}: ${browserErrorMessage(err)}`));
79
- const savedPath = this.explorBot.savePlans(this.completedPlans);
80
- this.printResults();
81
- await this.explorBot.printSessionAnalysis();
82
- this.printNextSteps(savedPath);
83
86
  }
84
87
 
85
88
  private originLabel(test: Test): string {