explorbot 0.1.21 → 0.1.23

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.
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "explorbot",
3
- "version": "0.1.21",
3
+ "version": "0.1.23",
4
4
  "description": "CLI app built with React Ink, CodeceptJS, and Playwright",
5
5
  "license": "Elastic-2.0",
6
6
  "type": "module",
@@ -82,7 +82,7 @@
82
82
  "@opentelemetry/sdk-trace-base": "^2.2.0",
83
83
  "@opentelemetry/semantic-conventions": "^1.38.0",
84
84
  "@scalar/openapi-parser": "^0.25.6",
85
- "@testomatio/reporter": "^2.7.9-beta.3-markdown",
85
+ "@testomatio/reporter": "^2.8.4",
86
86
  "ai": "^6.0.6",
87
87
  "axe-core": "^4.11.1",
88
88
  "bash-tool": "^1.3.15",
@@ -108,7 +108,7 @@
108
108
  "micromatch": "^4.0.8",
109
109
  "ora-classic": "^5.4.2",
110
110
  "parse5": "^8.0.0",
111
- "playwright": "^1.59.0",
111
+ "playwright": "^1.60",
112
112
  "react": "^19.1.1",
113
113
  "strip-ansi": "^7.1.2",
114
114
  "turndown": "^7.2.1",
@@ -151,6 +151,18 @@ export class ActionResult {
151
151
  this.verifications ??= {};
152
152
  this.verifications[assertion] = passed;
153
153
  }
154
+ getVerification(message) {
155
+ if (!this.verifications)
156
+ return null;
157
+ if (typeof message === 'string') {
158
+ return this.verifications[message] ?? null;
159
+ }
160
+ for (const [assertion, passed] of Object.entries(this.verifications)) {
161
+ if (message.test(assertion))
162
+ return passed;
163
+ }
164
+ return null;
165
+ }
154
166
  isSameUrl(state) {
155
167
  if (!this.url || this.url === '') {
156
168
  return false;
@@ -21,7 +21,7 @@ export function WithWebMode(Base) {
21
21
  return state ? ActionResult.fromState(state) : null;
22
22
  },
23
23
  });
24
- const { see, context, visualClick, learn_experience } = agentTools;
24
+ const { see, context, visualClick, learnExperience } = agentTools;
25
25
  return {
26
26
  navigate: tool({
27
27
  description: 'Navigate to a URL or page description using AI-powered navigation.',
@@ -98,7 +98,7 @@ export function WithWebMode(Base) {
98
98
  see,
99
99
  context,
100
100
  visualClick,
101
- learn_experience,
101
+ learnExperience,
102
102
  };
103
103
  }
104
104
  webModePrompt() {
@@ -70,6 +70,12 @@ class Navigator {
70
70
  this.experienceTracker = experienceTracker || new ExperienceTracker();
71
71
  this.hooksRunner = new HooksRunner(explorer, explorer.getConfig());
72
72
  }
73
+ get verifyAttempts() {
74
+ return this.explorer.getConfig().ai?.agents?.navigator?.verifyAttempts ?? 3;
75
+ }
76
+ get verifyTimeout() {
77
+ return this.explorer.getConfig().ai?.agents?.navigator?.verifyTimeout ?? 1500;
78
+ }
73
79
  getBaseOrigin() {
74
80
  const baseUrl = this.explorer.getConfig().playwright.url;
75
81
  try {
@@ -442,16 +448,16 @@ class Navigator {
442
448
  const s = stateManager.getCurrentState();
443
449
  return s ? ActionResult.fromState(s) : null;
444
450
  };
445
- const { learn_experience } = createAgentTools({
451
+ const { learnExperience } = createAgentTools({
446
452
  explorer: this.explorer,
447
453
  researcher: null,
448
454
  navigator: this,
449
455
  experienceTracker: this.experienceTracker,
450
456
  getState,
451
457
  });
452
- if (!learn_experience)
458
+ if (!learnExperience)
453
459
  return undefined;
454
- return { learn_experience };
460
+ return { learnExperience };
455
461
  }
456
462
  async freeSail(opts, actionResult) {
457
463
  const stateManager = this.explorer.getStateManager();
@@ -565,6 +571,11 @@ class Navigator {
565
571
  async verifyState(message, actionResult) {
566
572
  tag('info').log('AI Navigator verifying state at', actionResult.url);
567
573
  debugLog('Verification message:', message);
574
+ const cachedVerification = actionResult.getVerification(message);
575
+ if (cachedVerification !== null) {
576
+ tag('substep').log(`Reusing cached verification: ${cachedVerification ? 'PASS' : 'FAIL'}`);
577
+ return { verified: cachedVerification, successfulCodes: [], assertionSteps: [], totalAttempted: 0 };
578
+ }
568
579
  let knowledge = '';
569
580
  let experience = '';
570
581
  const relevantKnowledge = this.knowledgeTracker.getRelevantKnowledge(actionResult);
@@ -584,6 +595,20 @@ class Navigator {
584
595
  experience = renderExperienceToc(toc);
585
596
  }
586
597
  }
598
+ const priorVerifications = Object.entries(actionResult.verifications ?? {});
599
+ let verificationContext = '';
600
+ if (priorVerifications.length > 0) {
601
+ const lines = priorVerifications.map(([claim, passed]) => `- "${claim}" → ${passed ? 'passed' : 'failed'}`).join('\n');
602
+ verificationContext = dedent `
603
+ <already_verified>
604
+ These claims were already checked on this page:
605
+ ${lines}
606
+
607
+ If the claim to verify has the same meaning as one above (even if worded differently), do NOT write any assertion code.
608
+ Respond with a single line and nothing else: ALREADY_VERIFIED: <exact text of the matching claim>
609
+ </already_verified>
610
+ `;
611
+ }
587
612
  const prompt = dedent `
588
613
  <message>
589
614
  ${message}
@@ -597,11 +622,13 @@ class Navigator {
597
622
  </page_html>
598
623
  </page>
599
624
 
625
+ ${verificationContext}
626
+
600
627
  <task>
601
628
  Identify what assertion the user wants to verify on the page.
602
- Propose different CodeceptJS assertion code blocks to verify the expected state.
629
+ Propose 2-3 strong, distinct CodeceptJS assertion code blocks that each directly prove the claim.
603
630
  Use only data from the <page> context to plan the verification.
604
- Try various locators and approaches to verify the assertion.
631
+ Prefer the fewest, most specific assertions over many variants of the same locator.
605
632
 
606
633
  IMPORTANT: Each code block must verify the SPECIFIC claim in the message, not just a generic aspect of it.
607
634
  Bad: I.seeElement({"role":"button","aria-pressed":"true"}) — matches ANY button, not the specific one
@@ -620,50 +647,83 @@ class Navigator {
620
647
  tag('debug').log('Prompt:', prompt);
621
648
  const conversation = this.provider.startConversation(this.systemPrompt, 'navigator');
622
649
  conversation.addUserText(prompt);
650
+ let alreadyVerified = false;
623
651
  const tools = this.buildExperienceTools();
624
652
  let codeBlocks = [];
625
653
  const successfulCodes = [];
626
654
  const assertionSteps = [];
627
655
  const action = this.explorer.createAction();
628
- await loop(async ({ stop, iteration }) => {
629
- if (codeBlocks.length === 0) {
630
- const result = await this.provider.invokeConversation(conversation, tools);
631
- if (!result)
656
+ let failures = 0;
657
+ const page = this.explorer.playwrightHelper?.page;
658
+ const originalTimeout = this.explorer.playwrightHelper?.options?.timeout ?? 3000;
659
+ page?.setDefaultTimeout(this.verifyTimeout);
660
+ try {
661
+ await loop(async ({ stop, iteration }) => {
662
+ if (codeBlocks.length === 0) {
663
+ const result = await this.provider.invokeConversation(conversation, tools);
664
+ if (!result)
665
+ return;
666
+ const aiResponse = result?.response?.text ?? '';
667
+ debugLog('Received AI response:', aiResponse.length, 'characters');
668
+ tag('step').log('Verifying assertion...');
669
+ if (this.checkAlreadyVerified(aiResponse, actionResult)) {
670
+ alreadyVerified = true;
671
+ stop();
672
+ return;
673
+ }
674
+ codeBlocks = extractCodeBlocks(aiResponse);
675
+ }
676
+ if (codeBlocks.length === 0) {
632
677
  return;
633
- const aiResponse = result?.response?.text;
634
- debugLog('Received AI response:', aiResponse?.length ?? 0, 'characters');
635
- tag('step').log('Verifying assertion...');
636
- codeBlocks = extractCodeBlocks(aiResponse ?? '');
637
- }
638
- if (codeBlocks.length === 0) {
639
- return;
640
- }
641
- const codeBlock = codeBlocks[iteration - 1];
642
- if (!codeBlock) {
643
- stop();
644
- return;
645
- }
646
- await this.explorer.switchToMainFrame();
647
- const verified = await action.attempt(codeBlock, message, false);
648
- if (verified) {
649
- tag('success').log('Verification passed');
650
- successfulCodes.push(codeBlock);
651
- assertionSteps.push(...action.assertionSteps);
652
- }
653
- }, {
654
- maxAttempts: this.MAX_ATTEMPTS,
655
- observability: {
656
- agent: 'navigator',
657
- },
658
- catch: async (error) => {
659
- debugLog(error);
660
- },
661
- });
662
- const totalAttempted = Math.min(codeBlocks.length, this.MAX_ATTEMPTS);
663
- const verified = totalAttempted <= 1 ? successfulCodes.length > 0 : successfulCodes.length > totalAttempted / 2;
678
+ }
679
+ const codeBlock = codeBlocks[iteration - 1];
680
+ if (!codeBlock) {
681
+ stop();
682
+ return;
683
+ }
684
+ await this.explorer.switchToMainFrame();
685
+ const verified = await action.attempt(codeBlock, message, false);
686
+ if (verified) {
687
+ tag('success').log('Verification passed');
688
+ successfulCodes.push(codeBlock);
689
+ assertionSteps.push(...action.assertionSteps);
690
+ }
691
+ else {
692
+ failures++;
693
+ }
694
+ const target = Math.min(codeBlocks.length, this.verifyAttempts);
695
+ const majorityNeeded = Math.floor(target / 2) + 1;
696
+ if (successfulCodes.length >= majorityNeeded || failures > target - majorityNeeded) {
697
+ stop();
698
+ }
699
+ }, {
700
+ maxAttempts: this.verifyAttempts,
701
+ observability: {
702
+ agent: 'navigator',
703
+ },
704
+ catch: async (error) => {
705
+ debugLog(error);
706
+ },
707
+ });
708
+ }
709
+ finally {
710
+ page?.setDefaultTimeout(originalTimeout);
711
+ }
712
+ const totalAttempted = Math.min(codeBlocks.length, this.verifyAttempts);
713
+ const majorityNeeded = Math.floor(totalAttempted / 2) + 1;
714
+ let verified = successfulCodes.length >= majorityNeeded;
715
+ if (alreadyVerified)
716
+ verified = true;
664
717
  actionResult.addVerification(message, verified);
665
718
  this.explorer.getStateManager().updateState(actionResult);
666
719
  return { verified, successfulCodes, assertionSteps, totalAttempted };
667
720
  }
721
+ checkAlreadyVerified(aiResponse, actionResult) {
722
+ const verifiedMatch = aiResponse.match(/ALREADY_VERIFIED:\s*(.+)/i);
723
+ if (!verifiedMatch)
724
+ return false;
725
+ const claim = verifiedMatch[1].trim().replace(/^["']|["']$/g, '');
726
+ return actionResult.getVerification(claim) === true;
727
+ }
668
728
  }
669
729
  export { Navigator };
@@ -279,6 +279,12 @@ export class Pilot {
279
279
  - "Delete X" → X must be gone. Clicking delete is NOT enough.
280
280
  - "Edit X" → updated value must be persisted (visible in list/detail). Opening edit is NOT enough; redirect after save with the new value visible IS enough.
281
281
  - Negative tests ("without a name", "invalid", "duplicate", "unauthorized") → success means the system PREVENTED the action with validation/error.
282
+ - Navigation-prefixed titles ("Access/Open/Go to X to <do Y>") → the goal is <do Y>; reaching X is
283
+ only a milestone. A satisfied milestone (tab active, panel/prompt visible, list shown) is NEVER a pass
284
+ if <do Y> did not occur this run.
285
+ - If the page reveals the goal cannot be performed here — required control absent, integration not
286
+ connected, or only a setup/connect/empty-state prompt is shown — vote "skipped" (prerequisites unmet),
287
+ never "pass".
282
288
 
283
289
  PROVENANCE: the entity you cite as proof must appear by name in <notes> or
284
290
  <session_log> tool inputs for THIS run. Name absent from tester activity = stale
@@ -383,12 +389,12 @@ export class Pilot {
383
389
  the elements needed for the scenario. The page summary does not list every element.
384
390
  Prefer interacting with the current page over navigating away.
385
391
 
386
- If you load a recipe via learn_experience, do NOT rewrite its code in your plan — the
392
+ If you load a recipe via learnExperience, do NOT rewrite its code in your plan — the
387
393
  raw recipe is forwarded to Tester automatically. Reference it by step ("apply recipe
388
394
  steps 1–3, then…") and call out anywhere your scenario diverges from it.
389
395
 
390
396
  Be concise and specific. Tester will follow your plan.
391
- `, 'pilot.planTest', { tools: true, planningOnly: true, maxToolRoundtrips: 3, task });
397
+ `, 'pilot.planTest', { tools: true, maxToolRoundtrips: 3, task });
392
398
  }
393
399
  async reviewNewPage(task, currentState, testerConversation) {
394
400
  if (!this.conversation)
@@ -478,7 +484,7 @@ export class Pilot {
478
484
  this.conversation.addUserText(finalUserText);
479
485
  let tools;
480
486
  if (opts.tools) {
481
- tools = opts.planningOnly ? this.pickPlanningTools() : this.agentTools;
487
+ tools = this.pickPlanningTools();
482
488
  }
483
489
  if (opts.tools && opts.task) {
484
490
  tools = { ...tools, ...this.buildPreconditionTool(opts.task) };
@@ -490,7 +496,7 @@ export class Pilot {
490
496
  experimental_telemetry: { functionId },
491
497
  });
492
498
  const text = result?.response?.text || '';
493
- const learned = (result?.toolExecutions || []).filter((e) => e.toolName === 'learn_experience' && e.output?.content).map((e) => e.output.content);
499
+ const learned = (result?.toolExecutions || []).filter((e) => e.toolName === 'learnExperience' && e.output?.content).map((e) => e.output.content);
494
500
  if (learned.length === 0)
495
501
  return text;
496
502
  return dedent `
@@ -515,7 +521,7 @@ export class Pilot {
515
521
  return renderExperienceToc(toc);
516
522
  }
517
523
  pickPlanningTools() {
518
- const { see, context, verify, research, getVisitedStates, xpathCheck, learn_experience } = this.agentTools ?? {};
524
+ const { see, context, verify, research, getVisitedStates, xpathCheck, learnExperience, askUser } = this.agentTools ?? {};
519
525
  const planning = {};
520
526
  if (see)
521
527
  planning.see = see;
@@ -529,8 +535,10 @@ export class Pilot {
529
535
  planning.getVisitedStates = getVisitedStates;
530
536
  if (xpathCheck)
531
537
  planning.xpathCheck = xpathCheck;
532
- if (learn_experience)
533
- planning.learn_experience = learn_experience;
538
+ if (learnExperience)
539
+ planning.learnExperience = learnExperience;
540
+ if (askUser)
541
+ planning.askUser = askUser;
534
542
  return planning;
535
543
  }
536
544
  buildPreconditionTool(task) {
@@ -391,7 +391,7 @@ export function createSpecialContextTools(explorer, context) {
391
391
  }),
392
392
  };
393
393
  }
394
- export function createAgentTools({ explorer, researcher, navigator, experienceTracker, getState, }) {
394
+ export function createAgentTools({ explorer, researcher, navigator, experienceTracker, getState, supervisor, }) {
395
395
  let visionDisabled = false;
396
396
  const tools = {
397
397
  see: tool({
@@ -685,41 +685,6 @@ export function createAgentTools({ explorer, researcher, navigator, experienceTr
685
685
  }
686
686
  },
687
687
  }),
688
- askUser: tool({
689
- description: dedent `
690
- Ask the user for help when you're stuck or unsure how to proceed.
691
- Only available in interactive mode (TUI).
692
-
693
- Use when:
694
- - Locator-based clicks keep failing
695
- - You can't find an element that should exist
696
- - Form interaction isn't working as expected
697
- - You need clarification on what action to take
698
- `,
699
- inputSchema: z.object({
700
- question: z.string().describe('What you need help with - be specific about what failed'),
701
- context: z.string().optional().describe('Relevant context like locators tried, errors received'),
702
- }),
703
- execute: async ({ question, context }) => {
704
- if (!isInteractive()) {
705
- return {
706
- success: false,
707
- message: 'User input not available in non-interactive mode',
708
- suggestion: 'Continue with automated recovery',
709
- };
710
- }
711
- const prompt = context ? `${question}\n\nContext: ${context}\n\nYour suggestion ("skip" to continue):` : `${question}\n\nYour suggestion ("skip" to continue):`;
712
- const userInput = await pause(prompt);
713
- if (!userInput || userInput.toLowerCase() === 'skip') {
714
- return { success: false, message: 'User skipped' };
715
- }
716
- return {
717
- success: true,
718
- userSuggestion: userInput,
719
- instruction: 'Follow the user suggestion. Use interact() tool to execute.',
720
- };
721
- },
722
- }),
723
688
  back: tool({
724
689
  description: dedent `
725
690
  Navigate back to the previous page (most recent URL different from current).
@@ -834,7 +799,7 @@ export function createAgentTools({ explorer, researcher, navigator, experienceTr
834
799
  }),
835
800
  };
836
801
  if (experienceTracker && getState) {
837
- tools.learn_experience = tool({
802
+ tools.learnExperience = tool({
838
803
  description: dedent `
839
804
  Read the full body of a specific experience section listed in <experience>.
840
805
  The TOC shows entries like "A.1 ## FLOW: ..." or "A.2 ## ACTION: ...". Pass the fileTag and sectionIndex.
@@ -857,6 +822,43 @@ export function createAgentTools({ explorer, researcher, navigator, experienceTr
857
822
  },
858
823
  });
859
824
  }
825
+ if (supervisor) {
826
+ tools.askUser = tool({
827
+ description: dedent `
828
+ Ask the user for help when automated recovery is stuck or the next step is unclear.
829
+ Only available in interactive mode (TUI).
830
+
831
+ Use when:
832
+ - The Tester keeps failing the same locator/element
833
+ - An element that should exist cannot be found
834
+ - Form interaction isn't working as expected
835
+ - You need a human decision on how to proceed
836
+ `,
837
+ inputSchema: z.object({
838
+ question: z.string().describe('What you need help with - be specific about what failed'),
839
+ context: z.string().optional().describe('Relevant context like locators tried, errors received'),
840
+ }),
841
+ execute: async ({ question, context }) => {
842
+ if (!isInteractive()) {
843
+ return {
844
+ success: false,
845
+ message: 'User input not available in non-interactive mode',
846
+ suggestion: 'Continue with automated recovery',
847
+ };
848
+ }
849
+ const prompt = context ? `${question}\n\nContext: ${context}\n\nYour suggestion ("skip" to continue):` : `${question}\n\nYour suggestion ("skip" to continue):`;
850
+ const userInput = await pause(prompt);
851
+ if (!userInput || userInput.toLowerCase() === 'skip') {
852
+ return { success: false, message: 'User skipped' };
853
+ }
854
+ return {
855
+ success: true,
856
+ userSuggestion: userInput,
857
+ instruction: 'Relay this suggestion to the Tester as the next concrete step.',
858
+ };
859
+ },
860
+ });
861
+ }
860
862
  return tools;
861
863
  }
862
864
  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.';
@@ -29,6 +29,15 @@ const config = {
29
29
  // agentic model for decision making
30
30
  agenticModel: openrouter('minimax/minimax-m2.5:nitro'),
31
31
  },
32
+
33
+ reporter: {
34
+ // Save a local HTML report after each run.
35
+ html: true,
36
+ // Save a local markdown report after each run.
37
+ markdown: true,
38
+ // Group runs by title in Testomat.io / HTML reports. Defaults to today's date — customize or remove.
39
+ runGroup: new Date().toISOString().slice(0, 10),
40
+ },
32
41
  };
33
42
 
34
43
  export default config;
@@ -455,7 +455,7 @@ export function renderExperienceToc(toc) {
455
455
  lines.push('Locators and step ordering worked then; the page may have changed since.');
456
456
  lines.push('Treat as a starting hypothesis, not ground truth. If a step fails, fall back to ARIA/UI-map.');
457
457
  lines.push('FLOW: = multi-step recipe (bullets + code + discovery). ACTION: = single-step snippet (one code block).');
458
- lines.push('Call learn_experience({ fileTag, sectionIndex }) to read a section when it looks relevant to the current step.');
458
+ lines.push('Call learnExperience({ fileTag, sectionIndex }) to read a section when it looks relevant to the current step.');
459
459
  lines.push('');
460
460
  for (const entry of toc) {
461
461
  lines.push(`File ${entry.fileTag} ${entry.url}:`);
@@ -170,7 +170,7 @@ export class ExplorBot {
170
170
  const state = stateManager.getCurrentState();
171
171
  return state ? ActionResult.fromState(state) : null;
172
172
  };
173
- const tools = createAgentTools({ explorer, researcher, navigator, experienceTracker, getState });
173
+ const tools = createAgentTools({ explorer, researcher, navigator, experienceTracker, getState, supervisor: true });
174
174
  return new Pilot(ai, tools, researcher, explorer, experienceTracker);
175
175
  }));
176
176
  }
@@ -11,7 +11,7 @@ export class Reporter {
11
11
  constructor(config, stateManager) {
12
12
  this.reporterEnabled = Reporter.resolveEnabled(config);
13
13
  this.stateManager = stateManager;
14
- if (this.reporterEnabled && (!process.env.TESTOMATIO || config?.html)) {
14
+ if (this.reporterEnabled && config?.html) {
15
15
  this.configureHtmlPipe();
16
16
  }
17
17
  if (this.reporterEnabled && config?.markdown) {
@@ -45,6 +45,8 @@ export class Reporter {
45
45
  return true;
46
46
  if (config?.enabled === false)
47
47
  return false;
48
+ if (config?.html || config?.markdown)
49
+ return true;
48
50
  return Boolean(process.env.TESTOMATIO);
49
51
  }
50
52
  configureHtmlPipe() {
@@ -68,13 +70,9 @@ export class Reporter {
68
70
  configureRunGroup(runGroup) {
69
71
  if (process.env.TESTOMATIO_RUNGROUP_TITLE)
70
72
  return;
71
- if (runGroup === null)
72
- return;
73
- if (runGroup) {
74
- process.env.TESTOMATIO_RUNGROUP_TITLE = runGroup;
73
+ if (!runGroup)
75
74
  return;
76
- }
77
- process.env.TESTOMATIO_RUNGROUP_TITLE = `Explorbot ${new Date().toISOString().slice(0, 10)}`;
75
+ process.env.TESTOMATIO_RUNGROUP_TITLE = runGroup;
78
76
  }
79
77
  async startRun() {
80
78
  if (this.isRunStarted) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "explorbot",
3
- "version": "0.1.21",
3
+ "version": "0.1.23",
4
4
  "description": "CLI app built with React Ink, CodeceptJS, and Playwright",
5
5
  "license": "Elastic-2.0",
6
6
  "type": "module",
@@ -82,7 +82,7 @@
82
82
  "@opentelemetry/sdk-trace-base": "^2.2.0",
83
83
  "@opentelemetry/semantic-conventions": "^1.38.0",
84
84
  "@scalar/openapi-parser": "^0.25.6",
85
- "@testomatio/reporter": "^2.7.9-beta.3-markdown",
85
+ "@testomatio/reporter": "^2.8.4",
86
86
  "ai": "^6.0.6",
87
87
  "axe-core": "^4.11.1",
88
88
  "bash-tool": "^1.3.15",
@@ -108,7 +108,7 @@
108
108
  "micromatch": "^4.0.8",
109
109
  "ora-classic": "^5.4.2",
110
110
  "parse5": "^8.0.0",
111
- "playwright": "^1.59.0",
111
+ "playwright": "^1.60",
112
112
  "react": "^19.1.1",
113
113
  "strip-ansi": "^7.1.2",
114
114
  "turndown": "^7.2.1",
@@ -207,6 +207,17 @@ export class ActionResult implements ActionResultData {
207
207
  this.verifications[assertion] = passed;
208
208
  }
209
209
 
210
+ getVerification(message: string | RegExp): boolean | null {
211
+ if (!this.verifications) return null;
212
+ if (typeof message === 'string') {
213
+ return this.verifications[message] ?? null;
214
+ }
215
+ for (const [assertion, passed] of Object.entries(this.verifications)) {
216
+ if (message.test(assertion)) return passed;
217
+ }
218
+ return null;
219
+ }
220
+
210
221
  isSameUrl(state: WebPageState): boolean {
211
222
  if (!this.url || this.url === '') {
212
223
  return false;
@@ -22,7 +22,7 @@ export function WithWebMode<T extends Constructor>(Base: T) {
22
22
  return state ? ActionResult.fromState(state) : null;
23
23
  },
24
24
  });
25
- const { see, context, visualClick, learn_experience } = agentTools;
25
+ const { see, context, visualClick, learnExperience } = agentTools;
26
26
 
27
27
  return {
28
28
  navigate: tool({
@@ -103,7 +103,7 @@ export function WithWebMode<T extends Constructor>(Base: T) {
103
103
  see,
104
104
  context,
105
105
  visualClick,
106
- learn_experience,
106
+ learnExperience,
107
107
  };
108
108
  }
109
109
 
@@ -82,6 +82,14 @@ class Navigator implements Agent {
82
82
  this.hooksRunner = new HooksRunner(explorer, explorer.getConfig());
83
83
  }
84
84
 
85
+ private get verifyAttempts(): number {
86
+ return this.explorer.getConfig().ai?.agents?.navigator?.verifyAttempts ?? 3;
87
+ }
88
+
89
+ private get verifyTimeout(): number {
90
+ return this.explorer.getConfig().ai?.agents?.navigator?.verifyTimeout ?? 1500;
91
+ }
92
+
85
93
  private getBaseOrigin(): string | null {
86
94
  const baseUrl = this.explorer.getConfig().playwright.url;
87
95
  try {
@@ -473,21 +481,21 @@ class Navigator implements Agent {
473
481
  return resolved;
474
482
  }
475
483
 
476
- private buildExperienceTools(): { learn_experience: unknown } | undefined {
484
+ private buildExperienceTools(): { learnExperience: unknown } | undefined {
477
485
  const stateManager = this.explorer.getStateManager();
478
486
  const getState = () => {
479
487
  const s = stateManager.getCurrentState();
480
488
  return s ? ActionResult.fromState(s) : null;
481
489
  };
482
- const { learn_experience } = createAgentTools({
490
+ const { learnExperience } = createAgentTools({
483
491
  explorer: this.explorer,
484
492
  researcher: null as unknown as Researcher,
485
493
  navigator: this,
486
494
  experienceTracker: this.experienceTracker,
487
495
  getState,
488
496
  });
489
- if (!learn_experience) return undefined;
490
- return { learn_experience };
497
+ if (!learnExperience) return undefined;
498
+ return { learnExperience };
491
499
  }
492
500
 
493
501
  async freeSail(opts?: { strategy?: 'deep' | 'shallow'; scope?: string; visitedUrls?: Set<string> }, actionResult?: ActionResult): Promise<{ target: string; reason: string } | null> {
@@ -623,6 +631,12 @@ class Navigator implements Agent {
623
631
  tag('info').log('AI Navigator verifying state at', actionResult.url);
624
632
  debugLog('Verification message:', message);
625
633
 
634
+ const cachedVerification = actionResult.getVerification(message);
635
+ if (cachedVerification !== null) {
636
+ tag('substep').log(`Reusing cached verification: ${cachedVerification ? 'PASS' : 'FAIL'}`);
637
+ return { verified: cachedVerification, successfulCodes: [], assertionSteps: [], totalAttempted: 0 };
638
+ }
639
+
626
640
  let knowledge = '';
627
641
  let experience = '';
628
642
 
@@ -645,6 +659,21 @@ class Navigator implements Agent {
645
659
  }
646
660
  }
647
661
 
662
+ const priorVerifications = Object.entries(actionResult.verifications ?? {});
663
+ let verificationContext = '';
664
+ if (priorVerifications.length > 0) {
665
+ const lines = priorVerifications.map(([claim, passed]) => `- "${claim}" → ${passed ? 'passed' : 'failed'}`).join('\n');
666
+ verificationContext = dedent`
667
+ <already_verified>
668
+ These claims were already checked on this page:
669
+ ${lines}
670
+
671
+ If the claim to verify has the same meaning as one above (even if worded differently), do NOT write any assertion code.
672
+ Respond with a single line and nothing else: ALREADY_VERIFIED: <exact text of the matching claim>
673
+ </already_verified>
674
+ `;
675
+ }
676
+
648
677
  const prompt = dedent`
649
678
  <message>
650
679
  ${message}
@@ -658,11 +687,13 @@ class Navigator implements Agent {
658
687
  </page_html>
659
688
  </page>
660
689
 
690
+ ${verificationContext}
691
+
661
692
  <task>
662
693
  Identify what assertion the user wants to verify on the page.
663
- Propose different CodeceptJS assertion code blocks to verify the expected state.
694
+ Propose 2-3 strong, distinct CodeceptJS assertion code blocks that each directly prove the claim.
664
695
  Use only data from the <page> context to plan the verification.
665
- Try various locators and approaches to verify the assertion.
696
+ Prefer the fewest, most specific assertions over many variants of the same locator.
666
697
 
667
698
  IMPORTANT: Each code block must verify the SPECIFIC claim in the message, not just a generic aspect of it.
668
699
  Bad: I.seeElement({"role":"button","aria-pressed":"true"}) — matches ANY button, not the specific one
@@ -684,6 +715,7 @@ class Navigator implements Agent {
684
715
  const conversation = this.provider.startConversation(this.systemPrompt, 'navigator');
685
716
  conversation.addUserText(prompt);
686
717
 
718
+ let alreadyVerified = false;
687
719
  const tools = this.buildExperienceTools();
688
720
 
689
721
  let codeBlocks: string[] = [];
@@ -691,57 +723,90 @@ class Navigator implements Agent {
691
723
  const assertionSteps: Array<{ name: string; args: any[] }> = [];
692
724
 
693
725
  const action = this.explorer.createAction();
726
+ let failures = 0;
694
727
 
695
- await loop(
696
- async ({ stop, iteration }) => {
697
- if (codeBlocks.length === 0) {
698
- const result = await this.provider.invokeConversation(conversation, tools);
699
- if (!result) return;
700
- const aiResponse = result?.response?.text;
701
- debugLog('Received AI response:', aiResponse?.length ?? 0, 'characters');
702
- tag('step').log('Verifying assertion...');
703
- codeBlocks = extractCodeBlocks(aiResponse ?? '');
704
- }
728
+ const page = this.explorer.playwrightHelper?.page;
729
+ const originalTimeout = this.explorer.playwrightHelper?.options?.timeout ?? 3000;
730
+ page?.setDefaultTimeout(this.verifyTimeout);
705
731
 
706
- if (codeBlocks.length === 0) {
707
- return;
708
- }
732
+ try {
733
+ await loop(
734
+ async ({ stop, iteration }) => {
735
+ if (codeBlocks.length === 0) {
736
+ const result = await this.provider.invokeConversation(conversation, tools);
737
+ if (!result) return;
738
+ const aiResponse = result?.response?.text ?? '';
739
+ debugLog('Received AI response:', aiResponse.length, 'characters');
740
+ tag('step').log('Verifying assertion...');
741
+
742
+ if (this.checkAlreadyVerified(aiResponse, actionResult)) {
743
+ alreadyVerified = true;
744
+ stop();
745
+ return;
746
+ }
709
747
 
710
- const codeBlock = codeBlocks[iteration - 1];
711
- if (!codeBlock) {
712
- stop();
713
- return;
714
- }
748
+ codeBlocks = extractCodeBlocks(aiResponse);
749
+ }
715
750
 
716
- await this.explorer.switchToMainFrame();
751
+ if (codeBlocks.length === 0) {
752
+ return;
753
+ }
717
754
 
718
- const verified = await action.attempt(codeBlock, message, false);
755
+ const codeBlock = codeBlocks[iteration - 1];
756
+ if (!codeBlock) {
757
+ stop();
758
+ return;
759
+ }
719
760
 
720
- if (verified) {
721
- tag('success').log('Verification passed');
722
- successfulCodes.push(codeBlock);
723
- assertionSteps.push(...action.assertionSteps);
724
- }
725
- },
726
- {
727
- maxAttempts: this.MAX_ATTEMPTS,
728
- observability: {
729
- agent: 'navigator',
730
- },
731
- catch: async (error) => {
732
- debugLog(error);
761
+ await this.explorer.switchToMainFrame();
762
+
763
+ const verified = await action.attempt(codeBlock, message, false);
764
+
765
+ if (verified) {
766
+ tag('success').log('Verification passed');
767
+ successfulCodes.push(codeBlock);
768
+ assertionSteps.push(...action.assertionSteps);
769
+ } else {
770
+ failures++;
771
+ }
772
+
773
+ const target = Math.min(codeBlocks.length, this.verifyAttempts);
774
+ const majorityNeeded = Math.floor(target / 2) + 1;
775
+ if (successfulCodes.length >= majorityNeeded || failures > target - majorityNeeded) {
776
+ stop();
777
+ }
733
778
  },
734
- }
735
- );
779
+ {
780
+ maxAttempts: this.verifyAttempts,
781
+ observability: {
782
+ agent: 'navigator',
783
+ },
784
+ catch: async (error) => {
785
+ debugLog(error);
786
+ },
787
+ }
788
+ );
789
+ } finally {
790
+ page?.setDefaultTimeout(originalTimeout);
791
+ }
736
792
 
737
- const totalAttempted = Math.min(codeBlocks.length, this.MAX_ATTEMPTS);
738
- const verified = totalAttempted <= 1 ? successfulCodes.length > 0 : successfulCodes.length > totalAttempted / 2;
793
+ const totalAttempted = Math.min(codeBlocks.length, this.verifyAttempts);
794
+ const majorityNeeded = Math.floor(totalAttempted / 2) + 1;
795
+ let verified = successfulCodes.length >= majorityNeeded;
796
+ if (alreadyVerified) verified = true;
739
797
 
740
798
  actionResult.addVerification(message, verified);
741
799
  this.explorer.getStateManager().updateState(actionResult);
742
800
 
743
801
  return { verified, successfulCodes, assertionSteps, totalAttempted };
744
802
  }
803
+
804
+ private checkAlreadyVerified(aiResponse: string, actionResult: ActionResult): boolean {
805
+ const verifiedMatch = aiResponse.match(/ALREADY_VERIFIED:\s*(.+)/i);
806
+ if (!verifiedMatch) return false;
807
+ const claim = verifiedMatch[1].trim().replace(/^["']|["']$/g, '');
808
+ return actionResult.getVerification(claim) === true;
809
+ }
745
810
  }
746
811
 
747
812
  export { Navigator };
package/src/ai/pilot.ts CHANGED
@@ -322,6 +322,12 @@ export class Pilot implements Agent {
322
322
  - "Delete X" → X must be gone. Clicking delete is NOT enough.
323
323
  - "Edit X" → updated value must be persisted (visible in list/detail). Opening edit is NOT enough; redirect after save with the new value visible IS enough.
324
324
  - Negative tests ("without a name", "invalid", "duplicate", "unauthorized") → success means the system PREVENTED the action with validation/error.
325
+ - Navigation-prefixed titles ("Access/Open/Go to X to <do Y>") → the goal is <do Y>; reaching X is
326
+ only a milestone. A satisfied milestone (tab active, panel/prompt visible, list shown) is NEVER a pass
327
+ if <do Y> did not occur this run.
328
+ - If the page reveals the goal cannot be performed here — required control absent, integration not
329
+ connected, or only a setup/connect/empty-state prompt is shown — vote "skipped" (prerequisites unmet),
330
+ never "pass".
325
331
 
326
332
  PROVENANCE: the entity you cite as proof must appear by name in <notes> or
327
333
  <session_log> tool inputs for THIS run. Name absent from tester activity = stale
@@ -433,14 +439,14 @@ export class Pilot implements Agent {
433
439
  the elements needed for the scenario. The page summary does not list every element.
434
440
  Prefer interacting with the current page over navigating away.
435
441
 
436
- If you load a recipe via learn_experience, do NOT rewrite its code in your plan — the
442
+ If you load a recipe via learnExperience, do NOT rewrite its code in your plan — the
437
443
  raw recipe is forwarded to Tester automatically. Reference it by step ("apply recipe
438
444
  steps 1–3, then…") and call out anywhere your scenario diverges from it.
439
445
 
440
446
  Be concise and specific. Tester will follow your plan.
441
447
  `,
442
448
  'pilot.planTest',
443
- { tools: true, planningOnly: true, maxToolRoundtrips: 3, task }
449
+ { tools: true, maxToolRoundtrips: 3, task }
444
450
  );
445
451
  }
446
452
 
@@ -541,7 +547,7 @@ export class Pilot implements Agent {
541
547
  return `CHECKED: ${checked.length > 0 ? checked.join(', ') : 'none'}\nREMAINING: ${remaining.length > 0 ? remaining.join(', ') : 'none'}`;
542
548
  }
543
549
 
544
- private async sendToPilot(userText: string, functionId: string, opts: { tools?: boolean; planningOnly?: boolean; maxToolRoundtrips?: number; task?: Test } = {}): Promise<string> {
550
+ private async sendToPilot(userText: string, functionId: string, opts: { tools?: boolean; maxToolRoundtrips?: number; task?: Test } = {}): Promise<string> {
545
551
  debugLog(`sendToPilot: ${functionId}, tools: ${!!opts.tools}, roundtrips: ${opts.maxToolRoundtrips ?? 0}`);
546
552
 
547
553
  let finalUserText = userText;
@@ -554,7 +560,7 @@ export class Pilot implements Agent {
554
560
  this.conversation!.addUserText(finalUserText);
555
561
  let tools: any;
556
562
  if (opts.tools) {
557
- tools = opts.planningOnly ? this.pickPlanningTools() : this.agentTools;
563
+ tools = this.pickPlanningTools();
558
564
  }
559
565
 
560
566
  if (opts.tools && opts.task) {
@@ -568,7 +574,7 @@ export class Pilot implements Agent {
568
574
  experimental_telemetry: { functionId },
569
575
  });
570
576
  const text = result?.response?.text || '';
571
- const learned = (result?.toolExecutions || []).filter((e: any) => e.toolName === 'learn_experience' && e.output?.content).map((e: any) => e.output.content);
577
+ const learned = (result?.toolExecutions || []).filter((e: any) => e.toolName === 'learnExperience' && e.output?.content).map((e: any) => e.output.content);
572
578
  if (learned.length === 0) return text;
573
579
  return dedent`
574
580
  ${text}
@@ -592,7 +598,7 @@ export class Pilot implements Agent {
592
598
  }
593
599
 
594
600
  private pickPlanningTools() {
595
- const { see, context, verify, research, getVisitedStates, xpathCheck, learn_experience } = this.agentTools ?? {};
601
+ const { see, context, verify, research, getVisitedStates, xpathCheck, learnExperience, askUser } = this.agentTools ?? {};
596
602
  const planning: Record<string, unknown> = {};
597
603
  if (see) planning.see = see;
598
604
  if (context) planning.context = context;
@@ -600,7 +606,8 @@ export class Pilot implements Agent {
600
606
  if (research) planning.research = research;
601
607
  if (getVisitedStates) planning.getVisitedStates = getVisitedStates;
602
608
  if (xpathCheck) planning.xpathCheck = xpathCheck;
603
- if (learn_experience) planning.learn_experience = learn_experience;
609
+ if (learnExperience) planning.learnExperience = learnExperience;
610
+ if (askUser) planning.askUser = askUser;
604
611
  return planning;
605
612
  }
606
613
 
package/src/ai/tools.ts CHANGED
@@ -466,12 +466,14 @@ export function createAgentTools({
466
466
  navigator,
467
467
  experienceTracker,
468
468
  getState,
469
+ supervisor,
469
470
  }: {
470
471
  explorer: Explorer;
471
472
  researcher: Researcher;
472
473
  navigator: Navigator;
473
474
  experienceTracker?: ExperienceTracker;
474
475
  getState?: () => ActionResult | null;
476
+ supervisor?: boolean;
475
477
  }): any {
476
478
  let visionDisabled = false;
477
479
 
@@ -803,46 +805,6 @@ export function createAgentTools({
803
805
  },
804
806
  }),
805
807
 
806
- askUser: tool({
807
- description: dedent`
808
- Ask the user for help when you're stuck or unsure how to proceed.
809
- Only available in interactive mode (TUI).
810
-
811
- Use when:
812
- - Locator-based clicks keep failing
813
- - You can't find an element that should exist
814
- - Form interaction isn't working as expected
815
- - You need clarification on what action to take
816
- `,
817
- inputSchema: z.object({
818
- question: z.string().describe('What you need help with - be specific about what failed'),
819
- context: z.string().optional().describe('Relevant context like locators tried, errors received'),
820
- }),
821
- execute: async ({ question, context }) => {
822
- if (!isInteractive()) {
823
- return {
824
- success: false,
825
- message: 'User input not available in non-interactive mode',
826
- suggestion: 'Continue with automated recovery',
827
- };
828
- }
829
-
830
- const prompt = context ? `${question}\n\nContext: ${context}\n\nYour suggestion ("skip" to continue):` : `${question}\n\nYour suggestion ("skip" to continue):`;
831
-
832
- const userInput = await pause(prompt);
833
-
834
- if (!userInput || userInput.toLowerCase() === 'skip') {
835
- return { success: false, message: 'User skipped' };
836
- }
837
-
838
- return {
839
- success: true,
840
- userSuggestion: userInput,
841
- instruction: 'Follow the user suggestion. Use interact() tool to execute.',
842
- };
843
- },
844
- }),
845
-
846
808
  back: tool({
847
809
  description: dedent`
848
810
  Navigate back to the previous page (most recent URL different from current).
@@ -973,7 +935,7 @@ export function createAgentTools({
973
935
  };
974
936
 
975
937
  if (experienceTracker && getState) {
976
- tools.learn_experience = tool({
938
+ tools.learnExperience = tool({
977
939
  description: dedent`
978
940
  Read the full body of a specific experience section listed in <experience>.
979
941
  The TOC shows entries like "A.1 ## FLOW: ..." or "A.2 ## ACTION: ...". Pass the fileTag and sectionIndex.
@@ -997,6 +959,48 @@ export function createAgentTools({
997
959
  });
998
960
  }
999
961
 
962
+ if (supervisor) {
963
+ tools.askUser = tool({
964
+ description: dedent`
965
+ Ask the user for help when automated recovery is stuck or the next step is unclear.
966
+ Only available in interactive mode (TUI).
967
+
968
+ Use when:
969
+ - The Tester keeps failing the same locator/element
970
+ - An element that should exist cannot be found
971
+ - Form interaction isn't working as expected
972
+ - You need a human decision on how to proceed
973
+ `,
974
+ inputSchema: z.object({
975
+ question: z.string().describe('What you need help with - be specific about what failed'),
976
+ context: z.string().optional().describe('Relevant context like locators tried, errors received'),
977
+ }),
978
+ execute: async ({ question, context }) => {
979
+ if (!isInteractive()) {
980
+ return {
981
+ success: false,
982
+ message: 'User input not available in non-interactive mode',
983
+ suggestion: 'Continue with automated recovery',
984
+ };
985
+ }
986
+
987
+ const prompt = context ? `${question}\n\nContext: ${context}\n\nYour suggestion ("skip" to continue):` : `${question}\n\nYour suggestion ("skip" to continue):`;
988
+
989
+ const userInput = await pause(prompt);
990
+
991
+ if (!userInput || userInput.toLowerCase() === 'skip') {
992
+ return { success: false, message: 'User skipped' };
993
+ }
994
+
995
+ return {
996
+ success: true,
997
+ userSuggestion: userInput,
998
+ instruction: 'Relay this suggestion to the Tester as the next concrete step.',
999
+ };
1000
+ },
1001
+ });
1002
+ }
1003
+
1000
1004
  return tools;
1001
1005
  }
1002
1006
 
@@ -30,6 +30,15 @@ const config = {
30
30
  // agentic model for decision making
31
31
  agenticModel: openrouter('minimax/minimax-m2.5:nitro'),
32
32
  },
33
+
34
+ reporter: {
35
+ // Save a local HTML report after each run.
36
+ html: true,
37
+ // Save a local markdown report after each run.
38
+ markdown: true,
39
+ // Group runs by title in Testomat.io / HTML reports. Defaults to today's date — customize or remove.
40
+ runGroup: new Date().toISOString().slice(0, 10),
41
+ },
33
42
  };
34
43
 
35
44
  export default config;
package/src/config.ts CHANGED
@@ -84,6 +84,8 @@ interface PilotAgentConfig extends AgentConfig {
84
84
  interface NavigatorAgentConfig extends AgentConfig {
85
85
  addHtmlOnTry?: number;
86
86
  maxAttempts?: number;
87
+ verifyAttempts?: number;
88
+ verifyTimeout?: number;
87
89
  }
88
90
 
89
91
  type HealFn = (ctx: { I: any }) => Promise<void> | void;
@@ -499,7 +499,7 @@ export function renderExperienceToc(toc: ExperienceTocEntry[]): string {
499
499
  lines.push('Locators and step ordering worked then; the page may have changed since.');
500
500
  lines.push('Treat as a starting hypothesis, not ground truth. If a step fails, fall back to ARIA/UI-map.');
501
501
  lines.push('FLOW: = multi-step recipe (bullets + code + discovery). ACTION: = single-step snippet (one code block).');
502
- lines.push('Call learn_experience({ fileTag, sectionIndex }) to read a section when it looks relevant to the current step.');
502
+ lines.push('Call learnExperience({ fileTag, sectionIndex }) to read a section when it looks relevant to the current step.');
503
503
  lines.push('');
504
504
  for (const entry of toc) {
505
505
  lines.push(`File ${entry.fileTag} ${entry.url}:`);
package/src/explorbot.ts CHANGED
@@ -210,7 +210,7 @@ export class ExplorBot {
210
210
  const state = stateManager.getCurrentState();
211
211
  return state ? ActionResult.fromState(state) : null;
212
212
  };
213
- const tools = createAgentTools({ explorer, researcher, navigator, experienceTracker, getState });
213
+ const tools = createAgentTools({ explorer, researcher, navigator, experienceTracker, getState, supervisor: true });
214
214
  return new Pilot(ai, tools, researcher, explorer, experienceTracker);
215
215
  }));
216
216
  }
package/src/reporter.ts CHANGED
@@ -29,7 +29,7 @@ export class Reporter {
29
29
  this.reporterEnabled = Reporter.resolveEnabled(config);
30
30
  this.stateManager = stateManager;
31
31
 
32
- if (this.reporterEnabled && (!process.env.TESTOMATIO || config?.html)) {
32
+ if (this.reporterEnabled && config?.html) {
33
33
  this.configureHtmlPipe();
34
34
  }
35
35
 
@@ -63,6 +63,7 @@ export class Reporter {
63
63
  static resolveEnabled(config?: ReporterConfig): boolean {
64
64
  if (config?.enabled === true) return true;
65
65
  if (config?.enabled === false) return false;
66
+ if (config?.html || config?.markdown) return true;
66
67
  return Boolean(process.env.TESTOMATIO);
67
68
  }
68
69
 
@@ -88,12 +89,8 @@ export class Reporter {
88
89
 
89
90
  private configureRunGroup(runGroup: string | null | undefined): void {
90
91
  if (process.env.TESTOMATIO_RUNGROUP_TITLE) return;
91
- if (runGroup === null) return;
92
- if (runGroup) {
93
- process.env.TESTOMATIO_RUNGROUP_TITLE = runGroup;
94
- return;
95
- }
96
- process.env.TESTOMATIO_RUNGROUP_TITLE = `Explorbot ${new Date().toISOString().slice(0, 10)}`;
92
+ if (!runGroup) return;
93
+ process.env.TESTOMATIO_RUNGROUP_TITLE = runGroup;
97
94
  }
98
95
 
99
96
  async startRun(): Promise<void> {