explorbot 0.3.2 → 0.3.5

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 (51) hide show
  1. package/bin/explorbot-cli.ts +14 -3
  2. package/dist/bin/explorbot-cli.js +12 -3
  3. package/dist/models.json +2 -0
  4. package/dist/package.json +1 -1
  5. package/dist/src/action-result.d.ts +1 -0
  6. package/dist/src/action-result.js +17 -12
  7. package/dist/src/action.d.ts +10 -0
  8. package/dist/src/action.js +16 -10
  9. package/dist/src/ai/conversation.d.ts +2 -1
  10. package/dist/src/ai/conversation.js +9 -4
  11. package/dist/src/ai/navigator.d.ts +3 -0
  12. package/dist/src/ai/navigator.js +20 -4
  13. package/dist/src/ai/pilot.js +9 -0
  14. package/dist/src/ai/planner.js +1 -1
  15. package/dist/src/ai/provider.js +28 -2
  16. package/dist/src/ai/researcher/deep-analysis.js +5 -1
  17. package/dist/src/ai/researcher/sections.js +0 -1
  18. package/dist/src/ai/researcher.js +0 -1
  19. package/dist/src/ai/rules.js +0 -1
  20. package/dist/src/ai/tester.d.ts +1 -0
  21. package/dist/src/ai/tester.js +8 -9
  22. package/dist/src/ai/tools.d.ts +2 -0
  23. package/dist/src/ai/tools.js +21 -4
  24. package/dist/src/commands/init-command.js +74 -24
  25. package/dist/src/components/InitWizard.d.ts +2 -1
  26. package/dist/src/components/InitWizard.js +8 -4
  27. package/dist/src/explorer.js +1 -0
  28. package/dist/src/knowledge-tracker.d.ts +3 -1
  29. package/dist/src/knowledge-tracker.js +4 -4
  30. package/dist/src/utils/aria.js +1 -1
  31. package/docs/basics/providers.md +2 -4
  32. package/models.json +2 -0
  33. package/package.json +1 -1
  34. package/src/action-result.ts +20 -15
  35. package/src/action.ts +21 -12
  36. package/src/ai/conversation.ts +11 -5
  37. package/src/ai/navigator.ts +22 -4
  38. package/src/ai/pilot.ts +9 -0
  39. package/src/ai/planner.ts +1 -1
  40. package/src/ai/provider.ts +28 -2
  41. package/src/ai/researcher/deep-analysis.ts +6 -1
  42. package/src/ai/researcher/sections.ts +0 -1
  43. package/src/ai/researcher.ts +0 -1
  44. package/src/ai/rules.ts +0 -1
  45. package/src/ai/tester.ts +8 -7
  46. package/src/ai/tools.ts +20 -4
  47. package/src/commands/init-command.ts +81 -22
  48. package/src/components/InitWizard.tsx +8 -4
  49. package/src/explorer.ts +1 -0
  50. package/src/knowledge-tracker.ts +4 -4
  51. package/src/utils/aria.ts +1 -1
@@ -7,16 +7,16 @@ import { Command } from 'commander';
7
7
  import figureSet from 'figures';
8
8
  import { render } from 'ink';
9
9
  import React from 'react';
10
+ import { flushTelemetry } from '../src/ai/provider.js';
10
11
  import { App } from '../src/components/App.js';
11
12
  import { StatusPane } from '../src/components/StatusPane.js';
12
- import { flushTelemetry } from '../src/ai/provider.js';
13
13
  import { ConfigParser, EXPLORBOT_ENV_VARS, PROVIDERS } from '../src/config.js';
14
14
  import { ExplorBot, type ExplorBotOptions } from '../src/explorbot.js';
15
15
  import { remote } from '../src/remote.js';
16
16
  import { Stats } from '../src/stats.js';
17
17
  import { Plan } from '../src/test-plan.js';
18
18
  import { getCliName } from '../src/utils/cli-name.ts';
19
- import { isVerboseMode, log, setPreserveConsoleLogs, setQuietMode } from '../src/utils/logger.js';
19
+ import { isVerboseMode, log, setPreserveConsoleLogs, setQuietMode, tag } from '../src/utils/logger.js';
20
20
  import { jsonToTable } from '../src/utils/markdown-parser.js';
21
21
  import { parseMarkdownToTerminal } from '../src/utils/markdown-terminal.js';
22
22
  import { type NextStepSection, printNextSteps, relativeToCwd } from '../src/utils/next-steps.ts';
@@ -30,6 +30,16 @@ const pkgVersion = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')).version as stri
30
30
  program.name(cli).description('AI-powered web exploration tool').version(pkgVersion, '-V, --version');
31
31
  remote.registerOption(program);
32
32
 
33
+ process.on('uncaughtException', async (error) => {
34
+ tag('error').log(`Uncaught exception: ${error instanceof Error ? `${error.message}\n${error.stack}` : String(error)}`);
35
+ await flushTelemetry();
36
+ process.exit(1);
37
+ });
38
+
39
+ process.on('unhandledRejection', (reason) => {
40
+ tag('error').log(`Unhandled rejection: ${reason instanceof Error ? `${reason.message}\n${reason.stack}` : String(reason)}`);
41
+ });
42
+
33
43
  if (!process.env.EXPLORBOT_NO_BANNER && !process.argv.includes('prima')) {
34
44
  console.log(`⛵ ${chalk.yellow.bold(`Explorbot v${pkgVersion}`)} ${chalk.dim('Autonomous Testing Agent')}`);
35
45
  }
@@ -546,6 +556,7 @@ program
546
556
  .command('learn [url] [description]')
547
557
  .description('Add knowledge for URLs')
548
558
  .option('-p, --path <path>', 'Working directory path')
559
+ .option('--replace', 'Replace existing knowledge for this URL instead of appending')
549
560
  .action(async (url, description, options) => {
550
561
  try {
551
562
  await ConfigParser.getInstance().loadConfig({
@@ -556,7 +567,7 @@ program
556
567
  const tracker = new KnowledgeTracker();
557
568
 
558
569
  if (url && description) {
559
- const result = tracker.addKnowledge(url, description);
570
+ const result = tracker.addKnowledge(url, description, { replace: options.replace });
560
571
  const action = result.isNewFile ? 'Created' : 'Updated';
561
572
  console.log(`Knowledge ${action} in: ${result.filename}`);
562
573
  return;
@@ -7,16 +7,16 @@ import { Command } from 'commander';
7
7
  import figureSet from 'figures';
8
8
  import { render } from 'ink';
9
9
  import React from 'react';
10
+ import { flushTelemetry } from '../src/ai/provider.js';
10
11
  import { App } from '../src/components/App.js';
11
12
  import { StatusPane } from '../src/components/StatusPane.js';
12
- import { flushTelemetry } from '../src/ai/provider.js';
13
13
  import { ConfigParser, EXPLORBOT_ENV_VARS, PROVIDERS } from '../src/config.js';
14
14
  import { ExplorBot } from '../src/explorbot.js';
15
15
  import { remote } from '../src/remote.js';
16
16
  import { Stats } from '../src/stats.js';
17
17
  import { Plan } from '../src/test-plan.js';
18
18
  import { getCliName } from "../src/utils/cli-name.js";
19
- import { isVerboseMode, log, setPreserveConsoleLogs, setQuietMode } from '../src/utils/logger.js';
19
+ import { isVerboseMode, log, setPreserveConsoleLogs, setQuietMode, tag } from '../src/utils/logger.js';
20
20
  import { jsonToTable } from '../src/utils/markdown-parser.js';
21
21
  import { parseMarkdownToTerminal } from '../src/utils/markdown-terminal.js';
22
22
  import { printNextSteps, relativeToCwd } from "../src/utils/next-steps.js";
@@ -26,6 +26,14 @@ const pkgPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../p
26
26
  const pkgVersion = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')).version;
27
27
  program.name(cli).description('AI-powered web exploration tool').version(pkgVersion, '-V, --version');
28
28
  remote.registerOption(program);
29
+ process.on('uncaughtException', async (error) => {
30
+ tag('error').log(`Uncaught exception: ${error instanceof Error ? `${error.message}\n${error.stack}` : String(error)}`);
31
+ await flushTelemetry();
32
+ process.exit(1);
33
+ });
34
+ process.on('unhandledRejection', (reason) => {
35
+ tag('error').log(`Unhandled rejection: ${reason instanceof Error ? `${reason.message}\n${reason.stack}` : String(reason)}`);
36
+ });
29
37
  if (!process.env.EXPLORBOT_NO_BANNER && !process.argv.includes('prima')) {
30
38
  console.log(`⛵ ${chalk.yellow.bold(`Explorbot v${pkgVersion}`)} ${chalk.dim('Autonomous Testing Agent')}`);
31
39
  }
@@ -493,6 +501,7 @@ program
493
501
  .command('learn [url] [description]')
494
502
  .description('Add knowledge for URLs')
495
503
  .option('-p, --path <path>', 'Working directory path')
504
+ .option('--replace', 'Replace existing knowledge for this URL instead of appending')
496
505
  .action(async (url, description, options) => {
497
506
  try {
498
507
  await ConfigParser.getInstance().loadConfig({
@@ -501,7 +510,7 @@ program
501
510
  const { KnowledgeTracker } = await import('../src/knowledge-tracker.js');
502
511
  const tracker = new KnowledgeTracker();
503
512
  if (url && description) {
504
- const result = tracker.addKnowledge(url, description);
513
+ const result = tracker.addKnowledge(url, description, { replace: options.replace });
505
514
  const action = result.isNewFile ? 'Created' : 'Updated';
506
515
  console.log(`Knowledge ${action} in: ${result.filename}`);
507
516
  return;
package/dist/models.json CHANGED
@@ -18,6 +18,8 @@
18
18
  "agenticModel": "gpt-5.6-luna"
19
19
  },
20
20
  "anthropic": {
21
+ "model": "claude-haiku-4-5-20251001",
22
+ "visionModel": "claude-haiku-4-5-20251001",
21
23
  "agenticModel": "claude-haiku-4-5-20251001"
22
24
  },
23
25
  "mistral": {
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "explorbot",
3
- "version": "0.3.2",
3
+ "version": "0.3.5",
4
4
  "description": "CLI app built with React Ink, CodeceptJS, and Playwright",
5
5
  "license": "Elastic-2.0",
6
6
  "type": "module",
@@ -135,6 +135,7 @@ export declare class Diff {
135
135
  isSameUrl(): boolean;
136
136
  urlHasChanged(): boolean;
137
137
  get htmlParts(): HtmlDiffPart[];
138
+ cleanedHtmlParts(): Promise<HtmlDiffPart[]>;
138
139
  get ariaChanged(): string | null;
139
140
  get ariaChangeCount(): number;
140
141
  get htmlDiff(): HtmlDiffResult | null;
@@ -444,17 +444,9 @@ export class ActionResult {
444
444
  pageDiff.ariaChangeCount = diff.ariaChangeCount;
445
445
  }
446
446
  if (diff.htmlParts.length > 0) {
447
- const htmlConfig = this.normalizeHtmlConfig();
448
- const processedParts = [];
449
- for (const part of diff.htmlParts) {
450
- const filteredHtml = htmlCombinedSnapshot(part.subtree, htmlConfig?.combined);
451
- const minified = await minifyHtml(filteredHtml);
452
- if (minified) {
453
- processedParts.push({ ...part, subtree: minified });
454
- }
455
- }
456
- if (processedParts.length > 0) {
457
- pageDiff.htmlParts = collapseHtmlParts(processedParts);
447
+ const collapsed = collapseHtmlParts(await diff.cleanedHtmlParts());
448
+ if (collapsed.length > 0) {
449
+ pageDiff.htmlParts = collapsed;
458
450
  }
459
451
  }
460
452
  if (pageDiff.ariaChanges && this.iframeSnapshots.length > 0) {
@@ -490,7 +482,9 @@ function collapseHtmlParts(parts) {
490
482
  const total = parts.reduce((sum, p) => sum + p.subtree.length, 0);
491
483
  const fullPageReRender = total > HTML_PARTS_TOTAL_BUDGET || parts.length > HTML_PARTS_COUNT_LIMIT;
492
484
  if (fullPageReRender) {
493
- return parts.map((part) => ({
485
+ return parts
486
+ .filter((part) => part.added.length > 0 || part.removed.length > 0)
487
+ .map((part) => ({
494
488
  ...part,
495
489
  subtree: `<html><head></head><body>...collapsed (${part.subtree.length} chars, ${part.added.length} added, ${part.removed.length} removed)...</body></html>`,
496
490
  }));
@@ -543,6 +537,17 @@ export class Diff {
543
537
  return [];
544
538
  return this._htmlDiffResult.parts;
545
539
  }
540
+ async cleanedHtmlParts() {
541
+ const htmlConfig = ConfigParser.getInstance().getConfig().html;
542
+ const cleaned = [];
543
+ for (const part of this.htmlParts) {
544
+ const minified = await minifyHtml(htmlCombinedSnapshot(part.subtree, htmlConfig?.combined));
545
+ if (!minified)
546
+ continue;
547
+ cleaned.push({ ...part, subtree: minified });
548
+ }
549
+ return cleaned;
550
+ }
546
551
  get ariaChanged() {
547
552
  return this._ariaDiffResult;
548
553
  }
@@ -15,6 +15,7 @@ declare class Action {
15
15
  name: string;
16
16
  args: any[];
17
17
  }>;
18
+ executedSteps: ExecutedStep[];
18
19
  lastValue: unknown;
19
20
  recorder?: PlaywrightRecorder;
20
21
  recovery: RecoveryRunner;
@@ -58,3 +59,12 @@ export type RecoveryRunner = <T>(fn: () => Promise<T>) => Promise<T>;
58
59
  export interface ExecuteOptions {
59
60
  verbatim?: boolean;
60
61
  }
62
+ export declare const attachStepLogger: (target: ExecutedStep[], assertionsTarget?: Array<{
63
+ name: string;
64
+ args: any[];
65
+ }>) => (() => void);
66
+ export interface ExecutedStep {
67
+ command: string;
68
+ success: boolean;
69
+ error?: string;
70
+ }
@@ -31,6 +31,7 @@ class Action {
31
31
  playwrightHelper;
32
32
  playwrightGroupId = null;
33
33
  assertionSteps = [];
34
+ executedSteps = [];
34
35
  lastValue;
35
36
  recorder;
36
37
  recovery;
@@ -304,7 +305,7 @@ class Action {
304
305
  let codeString = code.replace(/^\(I\) => /, '').trim();
305
306
  const executedSteps = [];
306
307
  const assertionSteps = [];
307
- const stepListener = attachStepLogger(executedSteps, assertionSteps);
308
+ const detachSteps = attachStepLogger(executedSteps, assertionSteps);
308
309
  const groupId = this.recorder ? await this.recorder.beginAction(codeString) : null;
309
310
  this.playwrightGroupId = groupId;
310
311
  const detachResponses = this.captureResponses();
@@ -330,10 +331,12 @@ class Action {
330
331
  await recorder.add(() => sleep(this.config.action?.delay || 500));
331
332
  await recorder.promise();
332
333
  this.lastValue = await returned;
334
+ if (!recorder.isRunning())
335
+ throw new Error('CodeceptJS recorder is stopped, commands were skipped and never reached the browser');
333
336
  }
334
337
  this.restorePageTimeout();
335
338
  if (executedSteps.length > 0) {
336
- codeString = executedSteps.join('\n');
339
+ codeString = executedSteps.map((step) => step.command).join('\n');
337
340
  }
338
341
  const pageState = await this.captureOnce({ codeBlock: codeString });
339
342
  this.actionResult = pageState;
@@ -350,11 +353,12 @@ class Action {
350
353
  throw err;
351
354
  }
352
355
  finally {
356
+ this.executedSteps = executedSteps;
353
357
  this.restorePageTimeout();
354
358
  detachResponses();
355
359
  if (groupId)
356
360
  await this.recorder.endAction();
357
- detachStepLogger(stepListener);
361
+ detachSteps();
358
362
  if (stepSpan) {
359
363
  stepSpan.end();
360
364
  }
@@ -443,13 +447,16 @@ async function captureTitle(page, actor) {
443
447
  return '';
444
448
  }
445
449
  const ASSERTION_STEP_NAMES = new Set(['see', 'dontSee', 'seeElement', 'dontSeeElement', 'seeInField', 'dontSeeInField', 'seeInCurrentUrl', 'dontSeeInCurrentUrl']);
446
- const attachStepLogger = (target, assertionsTarget) => {
450
+ export const attachStepLogger = (target, assertionsTarget) => {
447
451
  const listener = (step, error) => {
448
452
  if (!step?.toCode)
449
453
  return;
450
454
  if (step.name?.startsWith('grab'))
451
455
  return;
452
- target.push(step.toCode());
456
+ const executed = { command: step.toCode(), success: !error };
457
+ if (error)
458
+ executed.error = errorToString(error);
459
+ target.push(executed);
453
460
  if (assertionsTarget && ASSERTION_STEP_NAMES.has(step.name)) {
454
461
  assertionsTarget.push({ name: step.name, args: step.args || [] });
455
462
  }
@@ -461,11 +468,10 @@ const attachStepLogger = (target, assertionsTarget) => {
461
468
  };
462
469
  codeceptjs.event.dispatcher.on(codeceptjs.event.step.passed, listener);
463
470
  codeceptjs.event.dispatcher.on(codeceptjs.event.step.failed, listener);
464
- return listener;
465
- };
466
- const detachStepLogger = (listener) => {
467
- codeceptjs.event.dispatcher.off(codeceptjs.event.step.passed, listener);
468
- codeceptjs.event.dispatcher.off(codeceptjs.event.step.failed, listener);
471
+ return () => {
472
+ codeceptjs.event.dispatcher.off(codeceptjs.event.step.passed, listener);
473
+ codeceptjs.event.dispatcher.off(codeceptjs.event.step.failed, listener);
474
+ };
469
475
  };
470
476
  const readFocusedElement = () => {
471
477
  const el = document.activeElement;
@@ -4,8 +4,9 @@ export interface ToolExecution {
4
4
  input: any;
5
5
  output: any;
6
6
  wasSuccessful: boolean;
7
+ reasoning?: string;
7
8
  }
8
- export declare function toToolExecution(toolName: string, input: any, rawOutput: any): ToolExecution;
9
+ export declare function toToolExecution(toolName: string, input: any, rawOutput: any, reasoning?: string): ToolExecution;
9
10
  export declare function toolExecutionLabel(input: Record<string, any> | undefined): string;
10
11
  export declare const NARRATION_TOOL = "commentary";
11
12
  export declare class Conversation {
@@ -1,8 +1,8 @@
1
- export function toToolExecution(toolName, input, rawOutput) {
1
+ export function toToolExecution(toolName, input, rawOutput, reasoning) {
2
2
  let output = rawOutput;
3
3
  if (rawOutput?.type === 'json' && rawOutput?.value)
4
4
  output = rawOutput.value;
5
- return { toolName, input, output, wasSuccessful: output?.success !== false };
5
+ return { toolName, input, output, wasSuccessful: output?.success !== false, reasoning };
6
6
  }
7
7
  export function toolExecutionLabel(input) {
8
8
  return input?.explanation || input?.assertion || input?.reason || input?.request || '';
@@ -189,10 +189,14 @@ export class Conversation {
189
189
  continue;
190
190
  if (!Array.isArray(message.content))
191
191
  continue;
192
+ const reasoning = message.content
193
+ .filter((part) => part.type === 'reasoning' && part.text?.trim())
194
+ .map((part) => part.text.trim())
195
+ .join('\n');
192
196
  for (const part of message.content) {
193
197
  if (part.type !== 'tool-call')
194
198
  continue;
195
- toolCalls.set(part.toolCallId, part.input);
199
+ toolCalls.set(part.toolCallId, { input: part.input, reasoning });
196
200
  }
197
201
  }
198
202
  const executions = [];
@@ -206,7 +210,8 @@ export class Conversation {
206
210
  continue;
207
211
  if (part.toolName === NARRATION_TOOL)
208
212
  continue;
209
- executions.push(toToolExecution(part.toolName, toolCalls.get(part.toolCallId) || {}, part.output));
213
+ const call = toolCalls.get(part.toolCallId);
214
+ executions.push(toToolExecution(part.toolName, call?.input || {}, part.output, call?.reasoning));
210
215
  }
211
216
  }
212
217
  return executions;
@@ -1,5 +1,6 @@
1
1
  import { ActionResult } from '../action-result.js';
2
2
  import type Action from '../action.js';
3
+ import type { ExecutedStep } from '../action.js';
3
4
  import type { ExplorbotConfig } from '../config.js';
4
5
  import type { ExperienceTracker } from '../experience-tracker.js';
5
6
  import Explorer from '../explorer.js';
@@ -16,6 +17,7 @@ declare class Navigator implements Agent {
16
17
  hooksRunner: HooksRunner;
17
18
  MAX_ATTEMPTS: number;
18
19
  lastFailureReason: string | null;
20
+ executedSteps: ExecutedStep[];
19
21
  systemPrompt: string;
20
22
  freeSailSystemPrompt: string;
21
23
  explorer: Explorer;
@@ -55,6 +57,7 @@ declare class Navigator implements Agent {
55
57
  freshState: ActionResult;
56
58
  urlMatches: boolean;
57
59
  }>;
60
+ targetUrlReached(action: Action, expectedUrl: string, state: ActionResult): boolean;
58
61
  ariaDiff(freshState: ActionResult, previous: ActionResult): Promise<string | null>;
59
62
  saveFlow(message: string, expectedUrl: string | undefined, actionResult: ActionResult, progressBlocks: string[]): void;
60
63
  rescueDelayedRedirect(action: Action, expectedUrl: string): Promise<boolean>;
@@ -2,8 +2,8 @@ import { tool } from 'ai';
2
2
  import dedent from 'dedent';
3
3
  import { z } from 'zod';
4
4
  import { ActionResult } from '../action-result.js';
5
- import { normalizeUrl } from '../state-manager.js';
6
5
  import { renderAssertion } from "../playwright-recorder.js";
6
+ import { normalizeUrl } from '../state-manager.js';
7
7
  import { isFatalBrowserError } from "../utils/browser-errors.js";
8
8
  import { getCliName } from "../utils/cli-name.js";
9
9
  import { extractCodeBlocks } from '../utils/code-extractor.js';
@@ -26,6 +26,7 @@ class Navigator {
26
26
  hooksRunner;
27
27
  MAX_ATTEMPTS = Number.parseInt(process.env.MAX_ATTEMPTS || '5');
28
28
  lastFailureReason = null;
29
+ executedSteps = [];
29
30
  systemPrompt = dedent `
30
31
  <role>
31
32
  You are senior test automation engineer with master QA skills.
@@ -194,10 +195,15 @@ class Navigator {
194
195
  if (!this.provider)
195
196
  throw new Error('AI-assisted recovery is unavailable: no AI model is configured.');
196
197
  this.lastFailureReason = null;
198
+ this.executedSteps = [];
197
199
  tag('info').log('AI Navigator resolving state at', actionResult.url);
198
200
  debugLog('Resolution message:', message);
199
201
  const action = opts?.action ?? this.explorer.action();
200
202
  const expectedUrl = opts?.expectedUrl;
203
+ if (expectedUrl && this.targetUrlReached(action, expectedUrl, actionResult)) {
204
+ tag('success').log(`Already at ${expectedUrl} — navigation resolved`);
205
+ return true;
206
+ }
201
207
  const knowledge = this.knowledgeTracker.renderRelevantContext(actionResult);
202
208
  const conversation = this.provider.startConversation(this.systemPrompt, 'navigator');
203
209
  conversation.addUserText(await this.buildResolutionPrompt(message, actionResult, opts?.experience));
@@ -278,14 +284,20 @@ class Navigator {
278
284
  const freshHash = check.freshState.getStateHash();
279
285
  resolved = check.urlMatches && freshHash !== actionResult.getStateHash();
280
286
  if (!resolved && attempt.ok) {
281
- lastFailure = `URL did not change (still ${check.freshState.url})`;
287
+ if (check.urlMatches) {
288
+ lastFailure = `Reached ${check.freshState.url} but the page state did not change`;
289
+ tag('warning').log(`Page state did not change at ${check.freshState.url}`);
290
+ }
291
+ else {
292
+ lastFailure = `Reached ${check.freshState.url}, expected ${expectedUrl}`;
293
+ tag('warning').log(`URL verification failed: expected ${expectedUrl}, got ${check.freshState.url}`);
294
+ }
282
295
  batchFailures.push({
283
296
  code: codeBlock,
284
297
  error: lastFailure,
285
298
  ariaChanges: await this.ariaDiff(check.freshState, prevActionResult),
286
299
  urlAfter: check.freshState.url,
287
300
  });
288
- tag('warning').log(`URL verification failed: expected ${expectedUrl}, got ${check.freshState.url}`);
289
301
  }
290
302
  if (freshHash !== prevHash && (attempt.ok || check.urlMatches)) {
291
303
  progressBlocks.push(codeBlock);
@@ -411,6 +423,7 @@ class Navigator {
411
423
  await action.exitIframe();
412
424
  debugLog(`Attempting resolution: ${codeBlock}`);
413
425
  const ok = await action.attempt(codeBlock, message);
426
+ this.executedSteps.push(...action.executedSteps);
414
427
  const page = action.playwrightHelper?.page;
415
428
  if (page) {
416
429
  try {
@@ -437,9 +450,12 @@ class Navigator {
437
450
  }
438
451
  }
439
452
  const freshState = await this.explorer.capture();
440
- const urlMatches = this.isSameExpectedOrigin(expectedUrl, action.stateManager) && matchesNavigationUrl(expectedUrl, this.comparableUrl(freshState, expectedUrl));
453
+ const urlMatches = this.targetUrlReached(action, expectedUrl, freshState);
441
454
  return { freshState, urlMatches };
442
455
  }
456
+ targetUrlReached(action, expectedUrl, state) {
457
+ return this.isSameExpectedOrigin(expectedUrl, action.stateManager) && matchesNavigationUrl(expectedUrl, this.comparableUrl(state, expectedUrl));
458
+ }
443
459
  async ariaDiff(freshState, previous) {
444
460
  if (freshState.getStateHash() === previous.getStateHash())
445
461
  return null;
@@ -16,6 +16,7 @@ import { withdrawVisionTools } from "./tools.js";
16
16
  const CHECK_TOOLS = ['verify', 'see', 'research'];
17
17
  const EVIDENCE_TOOLS = ['verify', 'see'];
18
18
  const META_TOOLS = ['record', 'reset', 'stop', 'finish'];
19
+ const PILOT_REASONING_LIMIT = 500;
19
20
  const PILOT_MESSAGE_LIMIT = 2;
20
21
  const PILOT_MESSAGE_MAX_LENGTH = 160;
21
22
  export class Pilot {
@@ -941,6 +942,12 @@ export class Pilot {
941
942
  line += `\n result: ${resultMessage}`;
942
943
  if (errorDetail && errorDetail !== resultMessage)
943
944
  line += `\n error: ${errorDetail}`;
945
+ if (!t.wasSuccessful && t.reasoning) {
946
+ let rationale = t.reasoning;
947
+ if (rationale.length > PILOT_REASONING_LIMIT)
948
+ rationale = `...${rationale.slice(-PILOT_REASONING_LIMIT)}`;
949
+ line += `\n tester reasoned: ${rationale.replace(/\n+/g, ' ')}`;
950
+ }
944
951
  const attempts = t.output?.attempts;
945
952
  if (attempts && attempts.length > 1 && t.wasSuccessful) {
946
953
  const failedBefore = attempts.filter((a) => !a.success);
@@ -998,6 +1005,8 @@ export class Pilot {
998
1005
  state), instruct Tester to verify() and finish(). If goal was already true at the start, propose
999
1006
  different input data so the test is meaningful. If Tester repeats the same successful action, STOP.
1000
1007
 
1008
+ If needed you should pick the exact item the scenario should act on (from the page, or precondition() one) and pass it to tester
1009
+
1001
1010
  Action classification: GOAL-ADVANCING actions mutate the scenario's subject data (create/edit/delete/submit/verify).
1002
1011
  VIEW-ONLY actions toggle filters/tabs/sort/collapse without changing data. One VIEW-ONLY to reveal a
1003
1012
  target is fine; ≥2 consecutive VIEW-ONLY actions with no GOAL-ADVANCING action in between is thrashing
@@ -24,7 +24,7 @@ const TasksSchema = z.object({
24
24
  planName: z.string().describe('Short descriptive name for the test plan (e.g., "User Authentication Testing", "Product Catalog Navigation", "Form Validation Tests")'),
25
25
  scenarios: z
26
26
  .array(z.object({
27
- scenario: z.string().describe('A single sentence describing what to test'),
27
+ scenario: z.string().describe('A single sentence describing the behavior to test.'),
28
28
  priority: z.enum(['critical', 'important', 'high', 'normal', 'low']).describe('Priority of the task based on business importance'),
29
29
  startUrl: z.string().nullable().describe('Start URL for the test if different from plan URL. Use only stable feature/list/detail pages, not transient create/edit/modal URLs unless the scenario specifically starts inside that form.'),
30
30
  steps: z.array(z.string()).describe('List of steps to perform for this scenario. Each step should be a specific action (e.g., "Open the form", "Enter required data", "Submit the form"). Keep steps atomic and actionable.'),
@@ -2,7 +2,8 @@ import { OpenTelemetry } from '@ai-sdk/otel';
2
2
  import { LangfuseSpanProcessor } from '@langfuse/otel';
3
3
  import { NodeSDK } from '@opentelemetry/sdk-node';
4
4
  import { AsyncLocalStorage } from 'node:async_hooks';
5
- import { generateObject, generateText, isStepCount, registerTelemetry, tool } from 'ai';
5
+ import dedent from 'dedent';
6
+ import { APICallError, generateObject, generateText, isStepCount, registerTelemetry, tool } from 'ai';
6
7
  import { z } from 'zod';
7
8
  import { clearActivity, setActivity } from "../activity.js";
8
9
  import { configuredModels, modelName as getModelName } from '../config.js';
@@ -382,9 +383,18 @@ export class Provider {
382
383
  if (extraStop)
383
384
  stopConditions.push(extraStop);
384
385
  const config = this.buildGenerateConfig({ tools: toolsWithCommentary, maxOutputTokens: 16384, toolChoice: 'auto', experimental_repairToolCall: repairToolCall }, { stopWhen: stopConditions, model }, options);
386
+ let attemptMessages = messages;
387
+ let invalidRequestFeedbackAdded = false;
385
388
  try {
386
389
  const response = await this.withModelRequestSlot(() => withRetry(async () => {
387
- const result = (await this.raceWithIdleTimeout((signal) => generateText({ messages, ...config, abortSignal: signal }), config.timeout || 30000));
390
+ const result = (await this.raceWithIdleTimeout((signal) => generateText({ messages: attemptMessages, ...config, abortSignal: signal }), config.timeout || 30000).catch((error) => {
391
+ if (!invalidRequestFeedbackAdded) {
392
+ const amended = withInvalidRequestFeedback(attemptMessages, error);
393
+ invalidRequestFeedbackAdded = amended !== attemptMessages;
394
+ attemptMessages = amended;
395
+ }
396
+ throw error;
397
+ }));
388
398
  this.recordUsage(options.agentName || 'unknown', modelName, result.usage);
389
399
  const hasToolCall = (result.toolCalls?.length || 0) > 0;
390
400
  if (!result.text && !hasToolCall && result.finishReason === 'length') {
@@ -620,6 +630,22 @@ function repairToolCall(options) {
620
630
  return repairChannelMarker(options);
621
631
  return repairHarmonyChannel(options);
622
632
  }
633
+ function withInvalidRequestFeedback(messages, error) {
634
+ if (!(error instanceof APICallError) || error.statusCode !== 400)
635
+ return messages;
636
+ tag('warning').log('Provider rejected the request as invalid — relaying its reason before the retry');
637
+ return [
638
+ ...messages,
639
+ {
640
+ role: 'user',
641
+ content: dedent `
642
+ The previous request was rejected by the provider as invalid:
643
+ "${error.message}"
644
+ Fix what it describes and re-issue the request.
645
+ `,
646
+ },
647
+ ];
648
+ }
623
649
  function repairChannelMarker({ toolCall, tools }) {
624
650
  const markerIndex = toolCall.toolName.indexOf('<|channel|>');
625
651
  if (markerIndex <= 0)
@@ -5,10 +5,12 @@ import { diffAriaSnapshots } from "../../utils/aria.js";
5
5
  import { extractCodeBlocks } from "../../utils/code-extractor.js";
6
6
  import { tag } from '../../utils/logger.js';
7
7
  import { mdq } from "../../utils/markdown-query.js";
8
+ import { truncate } from "../../utils/strings.js";
8
9
  import { getCachedResearch, getPreviousResearch, saveResearch } from "./cache.js";
9
10
  import { debugLog } from "./mixin.js";
10
11
  import { parseResearchSections } from "./parser.js";
11
12
  const DEFAULT_MAX_EXPANDABLE_CLICKS = 10;
13
+ const MAX_HTML_DIFF_CHARS = 20_000;
12
14
  export function WithDeepAnalysis(Base) {
13
15
  return class extends Base {
14
16
  async performDeepAnalysis(state, result) {
@@ -430,6 +432,8 @@ export function WithDeepAnalysis(Base) {
430
432
 
431
433
  `;
432
434
  }
435
+ const cleanedParts = await diff.cleanedHtmlParts();
436
+ const htmlChanges = truncate(cleanedParts.map((p) => `[Container: ${p.container}]\n${p.subtree}`).join('\n\n'), MAX_HTML_DIFF_CHARS);
433
437
  const prompt = dedent `
434
438
  ${intro}
435
439
  Analyze the changes and produce a UI map section.
@@ -438,7 +442,7 @@ export function WithDeepAnalysis(Base) {
438
442
  ${diff.ariaChanged || 'none'}
439
443
 
440
444
  HTML changes:
441
- ${diff.htmlParts.map((p) => `[Container: ${p.container}]\n${p.subtree}`).join('\n\n') || 'none'}
445
+ ${htmlChanges || 'none'}
442
446
  ${alreadyHint}
443
447
 
444
448
  Respond with a SINGLE section in this format:
@@ -93,7 +93,6 @@ export function WithSections(Base) {
93
93
  - Do not copy global toolbar, navigation, list, or detail elements into this section unless they are descendants of this section container.
94
94
  - Every element with eidx inside this section's container MUST appear in the table.
95
95
  - Every row needs CSS; ARIA may be "-" for icon-only buttons.
96
- - ARIA locator JSON uses keys "role" and "text" (NOT "name").
97
96
  - Elements marked data-explorbot-hit="covered" or "offscreen" are not directly actionable; describe the covering or focused UI first.
98
97
  - In split-pane pages, entity detail panels are active detail context; include close/back/pin controls in the detail panel section when present.
99
98
  </rules>
@@ -345,7 +345,6 @@ export class Researcher extends ResearcherBase {
345
345
  - If an element has data-explorbot-hit="covered" or "offscreen", do not present it as directly actionable. Prefer the overlay, drawer, dialog, or focused section covering it, and mention what must be dismissed or revealed first.
346
346
  - Every element with an eidx attribute MUST appear in exactly one matching UI map section — describe icon-only buttons by their visual role.
347
347
  - Every UI map row needs a CSS selector; ARIA may be "-" for icon-only buttons, CSS must never be "-".
348
- - ARIA locator JSON uses keys "role" and "text" (NOT "name").
349
348
  - Mark elements with likely hover interactions (title, aria-describedby, menu items with submenus) as "(hover)".
350
349
  </rules>
351
350
 
@@ -67,7 +67,6 @@ const locatorStrategyRule = dedent `
67
67
 
68
68
  <bad_aria_locator_example>
69
69
  { "role": "button", "text": "" } // INVALID - empty text is useless, use "-" instead
70
- { "role": "button", "name": "Save" } // WRONG key - use "text", not "name"
71
70
  </bad_aria_locator_example>
72
71
 
73
72
  NEVER include \`eidx\` attribute in any locator (ARIA, CSS, XPath). It is an internal annotation.
@@ -33,6 +33,7 @@ export declare class Tester extends TaskAgent implements Agent {
33
33
  lastAnalyzedStateHash: string | null;
34
34
  stalledIterations: number;
35
35
  readonly MAX_STALLED_ITERATIONS = 3;
36
+ skipResearch: (err: Error) => string;
36
37
  constructor(deps: AgentDeps, researcher: Researcher, navigator: Navigator, agentTools?: any);
37
38
  getNavigator(): Navigator;
38
39
  setPilot(pilot: Pilot): void;
@@ -51,6 +51,12 @@ export class Tester extends TaskAgent {
51
51
  lastAnalyzedStateHash = null;
52
52
  stalledIterations = 0;
53
53
  MAX_STALLED_ITERATIONS = 3;
54
+ skipResearch = (err) => {
55
+ if (err.name === 'AbortError')
56
+ throw err;
57
+ tag('warning').log(`Research skipped: ${err.message}`);
58
+ return '';
59
+ };
54
60
  constructor(deps, researcher, navigator, agentTools) {
55
61
  super(deps);
56
62
  this.requestStore = deps.requestStore;
@@ -512,14 +518,7 @@ export class Tester extends TaskAgent {
512
518
  const alreadySeenUiMap = this.seenUiMapUrls.has(currentUrl);
513
519
  let research = '';
514
520
  if (!alreadySeenUiMap) {
515
- try {
516
- research = await this.researcher.research(currentState);
517
- }
518
- catch (err) {
519
- if (!(err instanceof ErrorPageError))
520
- throw err;
521
- tag('warning').log(`Research skipped: ${err.message}`);
522
- }
521
+ research = await this.researcher.research(currentState).catch(this.skipResearch);
523
522
  }
524
523
  this.pageStateHash = currentStateHash;
525
524
  this.pageActionResult = currentState;
@@ -559,7 +558,7 @@ export class Tester extends TaskAgent {
559
558
  return context;
560
559
  }
561
560
  if (focusArea.detected && focusArea.name && this.pageStateHash && this.pageActionResult) {
562
- const overlaySection = await this.researcher.researchOverlay(currentState, this.pageActionResult, this.pageStateHash);
561
+ const overlaySection = await this.researcher.researchOverlay(currentState, this.pageActionResult, this.pageStateHash).catch(this.skipResearch);
563
562
  if (overlaySection) {
564
563
  context += dedent `
565
564
 
@@ -1,3 +1,4 @@
1
+ import type { ExecutedStep } from '../action.js';
1
2
  import { ActionResult, type PageDiff } from '../action-result.js';
2
3
  import { type ExperienceTracker } from '../experience-tracker.js';
3
4
  import { type Task } from '../test-plan.js';
@@ -62,6 +63,7 @@ export declare function successToolResult(action: string, data?: Record<string,
62
63
  }): Record<string, any>;
63
64
  export declare function isMajorPageChange(pageDiff: PageDiff): boolean;
64
65
  export declare function hasFailedRequest(pageDiff: PageDiff): boolean;
66
+ export declare function formatExecutedSteps(steps: ExecutedStep[], requestedCount?: number): string;
65
67
  export declare function failedToolResult(action: string, message: string, data?: Record<string, any>, error?: Error | null): Promise<Record<string, any>>;
66
68
  export declare function withdrawVisionTools(tools: Record<string, any>): void;
67
69
  export declare function clickFailureSuggestion(attempts: Array<{