explorbot 0.3.2 → 0.3.4

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 (47) 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 +7 -0
  14. package/dist/src/ai/researcher/deep-analysis.js +5 -1
  15. package/dist/src/ai/researcher/sections.js +0 -1
  16. package/dist/src/ai/researcher.js +0 -1
  17. package/dist/src/ai/rules.js +0 -1
  18. package/dist/src/ai/tester.d.ts +1 -0
  19. package/dist/src/ai/tester.js +8 -9
  20. package/dist/src/ai/tools.d.ts +2 -0
  21. package/dist/src/ai/tools.js +21 -4
  22. package/dist/src/commands/init-command.js +74 -24
  23. package/dist/src/components/InitWizard.d.ts +2 -1
  24. package/dist/src/components/InitWizard.js +8 -4
  25. package/dist/src/explorer.js +1 -0
  26. package/dist/src/knowledge-tracker.d.ts +3 -1
  27. package/dist/src/knowledge-tracker.js +4 -4
  28. package/dist/src/utils/aria.js +1 -1
  29. package/docs/basics/providers.md +2 -4
  30. package/models.json +2 -0
  31. package/package.json +1 -1
  32. package/src/action-result.ts +20 -15
  33. package/src/action.ts +21 -12
  34. package/src/ai/conversation.ts +11 -5
  35. package/src/ai/navigator.ts +22 -4
  36. package/src/ai/pilot.ts +7 -0
  37. package/src/ai/researcher/deep-analysis.ts +6 -1
  38. package/src/ai/researcher/sections.ts +0 -1
  39. package/src/ai/researcher.ts +0 -1
  40. package/src/ai/rules.ts +0 -1
  41. package/src/ai/tester.ts +8 -7
  42. package/src/ai/tools.ts +20 -4
  43. package/src/commands/init-command.ts +81 -22
  44. package/src/components/InitWizard.tsx +8 -4
  45. package/src/explorer.ts +1 -0
  46. package/src/knowledge-tracker.ts +4 -4
  47. package/src/utils/aria.ts +1 -1
@@ -1,15 +1,17 @@
1
1
  import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
2
2
  import { dirname, extname, join, resolve } from 'node:path';
3
3
  import chalk from 'chalk';
4
- import dedent from 'dedent';
5
4
  import { ConfigParser, PROVIDERS } from "../config.js";
6
5
  import { findGlobalConfig, globalConfigPath, globalDir, globalEnvPath } from "../global-config.js";
7
6
  import { getCliName } from "../utils/cli-name.js";
8
7
  import { log, tag } from '../utils/logger.js';
9
8
  import { relativeToCwd } from "../utils/next-steps.js";
10
- function defaultConfigTemplate() {
9
+ function defaultConfigTemplate(provider, esm) {
10
+ let moduleExport = 'module.exports = config;';
11
+ if (esm)
12
+ moduleExport = 'export default config;';
11
13
  return `// 'provider/model-id' uses a bundled provider.
12
- // It is also possible to import provider as a module from Vercel AI SDK.
14
+ // It is also possible to import provider as a module from Vercel AI SDK.
13
15
  // https://github.com/testomatio/explorbot/blob/main/docs/basics/providers.md
14
16
 
15
17
  const config = {
@@ -19,7 +21,7 @@ const config = {
19
21
  },
20
22
 
21
23
  ai: {
22
- ${modelLines('openrouter')}
24
+ ${modelLines(provider)}
23
25
  },
24
26
 
25
27
  reporter: {
@@ -32,16 +34,17 @@ ${modelLines('openrouter')}
32
34
  },
33
35
  };
34
36
 
35
- export default config;
37
+ ${moduleExport}
36
38
  `;
37
39
  }
38
- const DEFAULT_ENV_TEMPLATE = dedent `
39
- # AI provider API keys
40
- OPENROUTER_API_KEY=
41
-
42
- # OPENAI_API_KEY=
43
- # ANTHROPIC_API_KEY=
44
- # GROQ_API_KEY=
40
+ function envTemplate(provider) {
41
+ const keyLines = Object.entries(PROVIDERS).map(([name, { envKey }]) => {
42
+ if (name === provider)
43
+ return `${envKey}=`;
44
+ return `# ${envKey}=`;
45
+ });
46
+ return `# AI provider API keys
47
+ ${keyLines.join('\n')}
45
48
 
46
49
  # Langfuse Tracing
47
50
  LANGFUSE_SECRET_KEY=
@@ -49,10 +52,11 @@ LANGFUSE_PUBLIC_KEY=
49
52
  LANGFUSE_BASE_URL=
50
53
 
51
54
  # Testomat.io API key to publish run results
52
- TESTOMATIO=
53
- `;
55
+ TESTOMATIO=`;
56
+ }
54
57
  export async function runInit(options) {
55
- if (options.global || options.provider) {
58
+ const localRequested = !!(options.configPath || options.path);
59
+ if (options.global || (options.provider && !localRequested)) {
56
60
  await runGlobalInit(options);
57
61
  return;
58
62
  }
@@ -61,8 +65,12 @@ export async function runInit(options) {
61
65
  return;
62
66
  }
63
67
  const choice = await renderInitWizard('choose');
64
- if (choice === 'local')
65
- runInitCommand(options);
68
+ if (choice !== 'local')
69
+ return;
70
+ const provider = await renderLocalProviderWizard();
71
+ if (!provider)
72
+ return;
73
+ runInitCommand({ ...options, provider });
66
74
  }
67
75
  export function writeGlobalConfig(provider, apiKey) {
68
76
  if (!PROVIDERS[provider]) {
@@ -87,7 +95,7 @@ export function writeGlobalConfig(provider, apiKey) {
87
95
  tag('substep').log(chalk.yellow(`${getCliName()} sites`));
88
96
  }
89
97
  export function runInitCommand(options) {
90
- const configPath = options.configPath ?? './explorbot.config.js';
98
+ const provider = options.provider || 'openrouter';
91
99
  const force = options.force ?? false;
92
100
  const customPath = options.path;
93
101
  const originalCwd = process.cwd();
@@ -100,13 +108,15 @@ export function runInitCommand(options) {
100
108
  process.chdir(dir);
101
109
  log(`Working in directory: ${relativeToCwd(dir)}`);
102
110
  }
111
+ const configName = 'explorbot.config.js';
112
+ const configPath = options.configPath ?? `./${configName}`;
103
113
  try {
104
114
  let outPath = resolve(configPath);
105
115
  if (existsSync(outPath) && statSync(outPath).isDirectory()) {
106
- outPath = join(outPath, 'explorbot.config.js');
116
+ outPath = join(outPath, configName);
107
117
  }
108
118
  else if (!extname(outPath)) {
109
- outPath = join(outPath, 'explorbot.config.js');
119
+ outPath = join(outPath, configName);
110
120
  }
111
121
  const dir = dirname(outPath);
112
122
  if (!existsSync(dir)) {
@@ -118,23 +128,28 @@ export function runInitCommand(options) {
118
128
  log('Use --force to overwrite existing file');
119
129
  process.exit(1);
120
130
  }
121
- writeFileSync(outPath, defaultConfigTemplate(), 'utf8');
131
+ const esm = extname(outPath) !== '.js' || isModuleProject(dirname(outPath));
132
+ writeFileSync(outPath, defaultConfigTemplate(provider, esm), 'utf8');
122
133
  log(`Created config file: ${relativeToCwd(outPath)}`);
123
134
  const envPath = resolve(process.cwd(), '.env');
124
135
  if (!existsSync(envPath)) {
125
- writeFileSync(envPath, `${DEFAULT_ENV_TEMPLATE}\n`, 'utf8');
136
+ writeFileSync(envPath, `${envTemplate(provider)}\n`, 'utf8');
126
137
  log(`Created env file: ${relativeToCwd(envPath)}`);
127
138
  }
128
139
  else {
129
140
  log(`Env file already exists: ${relativeToCwd(envPath)}`);
130
141
  }
142
+ const missing = missingRoles(provider);
143
+ if (missing.length) {
144
+ tag('warning').log(`No recommended ${missing.join(' and ')} for ${provider} — set the model ids in ${relativeToCwd(outPath)}`);
145
+ }
131
146
  log('');
132
147
  log('Next steps:');
133
148
  log('1. Configure AI provider in .env');
134
149
  log('2. Set AI models config file');
135
150
  log('3. Set web application URL in the config file');
136
151
  log('4. Add initial knowledge (how to authorize to the application, etc.)');
137
- tag('substep').log(chalk.yellow(`${getCliName()} learn * 'to aurhorize use these credentials: admin@example.com / secret123'`));
152
+ tag('substep').log(chalk.yellow(`${getCliName()} learn * 'to authorize use these credentials: admin@example.com / secret123'`));
138
153
  tag('substep').log('You can use ${env.LOGIN} and ${env.PASSWORD} to reference environment variables.');
139
154
  log('5. Launch application on a relative URL');
140
155
  tag('substep').log(chalk.yellow(`${getCliName()} start /dashboard`));
@@ -187,6 +202,23 @@ async function renderInitWizard(mode) {
187
202
  }), { exitOnCtrlC: false, patchConsole: false });
188
203
  });
189
204
  }
205
+ async function renderLocalProviderWizard() {
206
+ const [{ render }, React, InitWizard] = await Promise.all([import('ink'), import('react'), import('../components/InitWizard.js').then((m) => m.default)]);
207
+ return new Promise((resolve) => {
208
+ const finish = (provider) => {
209
+ unmount();
210
+ resolve(provider);
211
+ };
212
+ const { unmount } = render(React.createElement(InitWizard, {
213
+ mode: 'local',
214
+ globalConfigExists: !!findGlobalConfig(),
215
+ onLocal: () => finish(null),
216
+ onComplete: () => finish(null),
217
+ onCancel: () => finish(null),
218
+ onLocalProvider: (provider) => finish(provider),
219
+ }), { exitOnCtrlC: false, patchConsole: false });
220
+ });
221
+ }
190
222
  function modelLines(provider) {
191
223
  const recommended = ConfigParser.recommendedModels()[provider] || {};
192
224
  const roles = [
@@ -216,9 +248,27 @@ ${modelLines(provider)}
216
248
  },
217
249
  };
218
250
 
219
- export default config;
251
+ module.exports = config;
220
252
  `;
221
253
  }
254
+ function isModuleProject(configDir) {
255
+ let currentDir = resolve(configDir);
256
+ while (true) {
257
+ const packagePath = join(currentDir, 'package.json');
258
+ if (existsSync(packagePath)) {
259
+ try {
260
+ return JSON.parse(readFileSync(packagePath, 'utf8')).type === 'module';
261
+ }
262
+ catch {
263
+ return false;
264
+ }
265
+ }
266
+ const parentDir = dirname(currentDir);
267
+ if (parentDir === currentDir)
268
+ return false;
269
+ currentDir = parentDir;
270
+ }
271
+ }
222
272
  function missingRoles(provider) {
223
273
  const recommended = ConfigParser.recommendedModels()[provider] || {};
224
274
  return ['model', 'visionModel', 'agenticModel'].filter((role) => !recommended[role]);
@@ -1,10 +1,11 @@
1
1
  import React from 'react';
2
2
  interface InitWizardProps {
3
- mode: 'choose' | 'global';
3
+ mode: 'choose' | 'global' | 'local';
4
4
  globalConfigExists: boolean;
5
5
  onLocal: () => void;
6
6
  onComplete: () => void;
7
7
  onCancel: () => void;
8
+ onLocalProvider?: (provider: string) => void;
8
9
  }
9
10
  declare const InitWizard: React.FC<InitWizardProps>;
10
11
  export default InitWizard;
@@ -7,7 +7,7 @@ import { ConfigParser, PROVIDERS, createModel } from '../config.js';
7
7
  import { globalDir } from '../global-config.js';
8
8
  import InputReadline from './InputReadline.js';
9
9
  const PROVIDER_NAMES = Object.keys(PROVIDERS);
10
- const InitWizard = ({ mode, globalConfigExists, onLocal, onComplete, onCancel }) => {
10
+ const InitWizard = ({ mode, globalConfigExists, onLocal, onComplete, onCancel, onLocalProvider }) => {
11
11
  const [step, setStep] = useState(mode === 'choose' ? 'target' : 'provider');
12
12
  const [targetIndex, setTargetIndex] = useState(0);
13
13
  const [providerIndex, setProviderIndex] = useState(0);
@@ -67,8 +67,12 @@ const InitWizard = ({ mode, globalConfigExists, onLocal, onComplete, onCancel })
67
67
  setProviderIndex((index) => Math.max(0, index - 1));
68
68
  if (key.downArrow)
69
69
  setProviderIndex((index) => Math.min(PROVIDER_NAMES.length - 1, index + 1));
70
- if (key.return)
71
- setStep('key');
70
+ if (key.return) {
71
+ if (mode === 'local')
72
+ onLocalProvider?.(provider);
73
+ else
74
+ setStep('key');
75
+ }
72
76
  return;
73
77
  }
74
78
  if (status)
@@ -121,7 +125,7 @@ const InitWizard = ({ mode, globalConfigExists, onLocal, onComplete, onCancel })
121
125
  React.createElement(Box, { marginTop: 1 },
122
126
  React.createElement(Text, { dimColor: true },
123
127
  "Config goes to ",
124
- globalDir(),
128
+ mode === 'local' ? 'the current directory' : globalDir(),
125
129
  " | ",
126
130
  step === 'key' ? 'Enter: continue' : '↑↓: select | Enter: confirm',
127
131
  " | Ctrl+C: exit"))));
@@ -203,6 +203,7 @@ class Explorer {
203
203
  const projectRoot = configParser.getProjectRoot();
204
204
  global.output_dir = configParser.getStatesDir();
205
205
  global.codecept_dir = projectRoot;
206
+ global.codeceptjs = codeceptjs;
206
207
  configParser.validateConfig(this.config);
207
208
  codeceptjs.container.create(this.convertToCodeceptConfig(this.config), {});
208
209
  }
@@ -17,7 +17,9 @@ export declare class KnowledgeTracker {
17
17
  renderRelevantKnowledge(state: ActionResult): string;
18
18
  renderRelevantContext(state: ActionResult): string;
19
19
  renderApplicationSpec(state: ActionResult): string;
20
- addKnowledge(urlPattern: string, description: string): {
20
+ addKnowledge(urlPattern: string, description: string, opts?: {
21
+ replace?: boolean;
22
+ }): {
21
23
  filename: string;
22
24
  filePath: string;
23
25
  isNewFile: boolean;
@@ -73,7 +73,7 @@ export class KnowledgeTracker {
73
73
  renderApplicationSpec(state) {
74
74
  return this.applicationSpec?.renderFor(state) || '';
75
75
  }
76
- addKnowledge(urlPattern, description) {
76
+ addKnowledge(urlPattern, description, opts) {
77
77
  const configParser = ConfigParser.getInstance();
78
78
  const configPath = configParser.getConfigPath();
79
79
  if (!configPath) {
@@ -102,11 +102,11 @@ export class KnowledgeTracker {
102
102
  const existingDescription = parsed.content.trim();
103
103
  // Append new knowledge with separator
104
104
  let newContent;
105
- if (existingDescription) {
106
- newContent = `${existingDescription}\n\n---\n\n${description}`;
105
+ if (opts?.replace || !existingDescription) {
106
+ newContent = description;
107
107
  }
108
108
  else {
109
- newContent = description;
109
+ newContent = `${existingDescription}\n\n---\n\n${description}`;
110
110
  }
111
111
  const fileContent = matter.stringify(newContent, frontmatter);
112
112
  writeFileSync(filePath, fileContent, 'utf8');
@@ -569,7 +569,7 @@ export function parseAriaLocator(ariaStr) {
569
569
  const trimmed = ariaStr.trim();
570
570
  if (trimmed === '-' || trimmed === '' || trimmed === '"-"')
571
571
  return null;
572
- const match = trimmed.match(/\{\s*["']?role["']?\s*:\s*['"]([^'"]+)['"]\s*,\s*["']?text["']?\s*:\s*['"]([^'"]*)['"]\s*\}/);
572
+ const match = trimmed.match(/\{\s*["']?role["']?\s*:\s*['"]([^'"]+)['"]\s*,\s*["']?(?:text|name)["']?\s*:\s*['"]([^'"]*)['"]\s*\}/);
573
573
  if (!match)
574
574
  return null;
575
575
  return { role: match[1], text: match[2] };
@@ -154,14 +154,12 @@ Set the recommended model in the exported config:
154
154
  ```javascript
155
155
  export default {
156
156
  ai: {
157
+ model: anthropic('claude-haiku-4-5-20251001'),
158
+ visionModel: anthropic('claude-haiku-4-5-20251001'),
157
159
  agenticModel: anthropic('claude-haiku-4-5-20251001'),
158
160
  },
159
161
  };
160
162
  ```
161
-
162
- > [!NOTE]
163
- > This provider currently doesn't serve `model` and `visionModel`, which is required for Explorbot to run at optimal cost and speed.
164
- > It is recommended to pair it with another AI provider.
165
163
  <!-- END provider:anthropic -->
166
164
 
167
165
  ### Azure OpenAI
package/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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "explorbot",
3
- "version": "0.3.2",
3
+ "version": "0.3.4",
4
4
  "description": "CLI app built with React Ink, CodeceptJS, and Playwright",
5
5
  "license": "Elastic-2.0",
6
6
  "type": "module",
@@ -550,17 +550,9 @@ export class ActionResult implements ActionResultData {
550
550
  }
551
551
 
552
552
  if (diff.htmlParts.length > 0) {
553
- const htmlConfig = this.normalizeHtmlConfig();
554
- const processedParts: HtmlDiffPart[] = [];
555
- for (const part of diff.htmlParts) {
556
- const filteredHtml = htmlCombinedSnapshot(part.subtree, htmlConfig?.combined);
557
- const minified = await minifyHtml(filteredHtml);
558
- if (minified) {
559
- processedParts.push({ ...part, subtree: minified });
560
- }
561
- }
562
- if (processedParts.length > 0) {
563
- pageDiff.htmlParts = collapseHtmlParts(processedParts);
553
+ const collapsed = collapseHtmlParts(await diff.cleanedHtmlParts());
554
+ if (collapsed.length > 0) {
555
+ pageDiff.htmlParts = collapsed;
564
556
  }
565
557
  }
566
558
 
@@ -601,10 +593,12 @@ function collapseHtmlParts(parts: HtmlDiffPart[]): HtmlDiffPart[] {
601
593
  const fullPageReRender = total > HTML_PARTS_TOTAL_BUDGET || parts.length > HTML_PARTS_COUNT_LIMIT;
602
594
 
603
595
  if (fullPageReRender) {
604
- return parts.map((part) => ({
605
- ...part,
606
- subtree: `<html><head></head><body>...collapsed (${part.subtree.length} chars, ${part.added.length} added, ${part.removed.length} removed)...</body></html>`,
607
- }));
596
+ return parts
597
+ .filter((part) => part.added.length > 0 || part.removed.length > 0)
598
+ .map((part) => ({
599
+ ...part,
600
+ subtree: `<html><head></head><body>...collapsed (${part.subtree.length} chars, ${part.added.length} added, ${part.removed.length} removed)...</body></html>`,
601
+ }));
608
602
  }
609
603
 
610
604
  return parts.map((part) => {
@@ -660,6 +654,17 @@ export class Diff {
660
654
  return this._htmlDiffResult.parts;
661
655
  }
662
656
 
657
+ async cleanedHtmlParts(): Promise<HtmlDiffPart[]> {
658
+ const htmlConfig = ConfigParser.getInstance().getConfig().html;
659
+ const cleaned: HtmlDiffPart[] = [];
660
+ for (const part of this.htmlParts) {
661
+ const minified = await minifyHtml(htmlCombinedSnapshot(part.subtree, htmlConfig?.combined));
662
+ if (!minified) continue;
663
+ cleaned.push({ ...part, subtree: minified });
664
+ }
665
+ return cleaned;
666
+ }
667
+
663
668
  get ariaChanged(): string | null {
664
669
  return this._ariaDiffResult;
665
670
  }
package/src/action.ts CHANGED
@@ -37,6 +37,7 @@ class Action {
37
37
  public playwrightHelper: any;
38
38
  public playwrightGroupId: string | null = null;
39
39
  public assertionSteps: Array<{ name: string; args: any[] }> = [];
40
+ public executedSteps: ExecutedStep[] = [];
40
41
  public lastValue: unknown;
41
42
  private recorder?: PlaywrightRecorder;
42
43
  private recovery: RecoveryRunner;
@@ -329,9 +330,9 @@ class Action {
329
330
 
330
331
  let codeString = code.replace(/^\(I\) => /, '').trim();
331
332
 
332
- const executedSteps: string[] = [];
333
+ const executedSteps: ExecutedStep[] = [];
333
334
  const assertionSteps: Array<{ name: string; args: any[] }> = [];
334
- const stepListener = attachStepLogger(executedSteps, assertionSteps);
335
+ const detachSteps = attachStepLogger(executedSteps, assertionSteps);
335
336
  const groupId = this.recorder ? await this.recorder.beginAction(codeString) : null;
336
337
  this.playwrightGroupId = groupId;
337
338
  const detachResponses = this.captureResponses();
@@ -360,12 +361,13 @@ class Action {
360
361
  await recorder.add(() => sleep(this.config.action?.delay || 500));
361
362
  await recorder.promise();
362
363
  this.lastValue = await returned;
364
+ if (!recorder.isRunning()) throw new Error('CodeceptJS recorder is stopped, commands were skipped and never reached the browser');
363
365
  }
364
366
 
365
367
  this.restorePageTimeout();
366
368
 
367
369
  if (executedSteps.length > 0) {
368
- codeString = executedSteps.join('\n');
370
+ codeString = executedSteps.map((step) => step.command).join('\n');
369
371
  }
370
372
 
371
373
  const pageState = await this.captureOnce({ codeBlock: codeString });
@@ -382,10 +384,11 @@ class Action {
382
384
  this.assertionSteps = [];
383
385
  throw err;
384
386
  } finally {
387
+ this.executedSteps = executedSteps;
385
388
  this.restorePageTimeout();
386
389
  detachResponses();
387
390
  if (groupId) await this.recorder!.endAction();
388
- detachStepLogger(stepListener);
391
+ detachSteps();
389
392
  if (stepSpan) {
390
393
  stepSpan.end();
391
394
  }
@@ -487,11 +490,13 @@ const ASSERTION_STEP_NAMES = new Set(['see', 'dontSee', 'seeElement', 'dontSeeEl
487
490
 
488
491
  type StepListener = (step: any, error?: any) => void;
489
492
 
490
- const attachStepLogger = (target: string[], assertionsTarget?: Array<{ name: string; args: any[] }>): StepListener => {
493
+ export const attachStepLogger = (target: ExecutedStep[], assertionsTarget?: Array<{ name: string; args: any[] }>): (() => void) => {
491
494
  const listener: StepListener = (step, error) => {
492
495
  if (!step?.toCode) return;
493
496
  if (step.name?.startsWith('grab')) return;
494
- target.push(step.toCode());
497
+ const executed: ExecutedStep = { command: step.toCode(), success: !error };
498
+ if (error) executed.error = errorToString(error);
499
+ target.push(executed);
495
500
  if (assertionsTarget && ASSERTION_STEP_NAMES.has(step.name)) {
496
501
  assertionsTarget.push({ name: step.name, args: step.args || [] });
497
502
  }
@@ -503,12 +508,10 @@ const attachStepLogger = (target: string[], assertionsTarget?: Array<{ name: str
503
508
  };
504
509
  codeceptjs.event.dispatcher.on(codeceptjs.event.step.passed, listener);
505
510
  codeceptjs.event.dispatcher.on(codeceptjs.event.step.failed, listener);
506
- return listener;
507
- };
508
-
509
- const detachStepLogger = (listener: StepListener) => {
510
- codeceptjs.event.dispatcher.off(codeceptjs.event.step.passed, listener);
511
- codeceptjs.event.dispatcher.off(codeceptjs.event.step.failed, listener);
511
+ return () => {
512
+ codeceptjs.event.dispatcher.off(codeceptjs.event.step.passed, listener);
513
+ codeceptjs.event.dispatcher.off(codeceptjs.event.step.failed, listener);
514
+ };
512
515
  };
513
516
 
514
517
  const readFocusedElement = () => {
@@ -530,3 +533,9 @@ const readFocusedElement = () => {
530
533
  if (typeof value === 'string' && value) focused.value = value.slice(0, 200);
531
534
  return focused;
532
535
  };
536
+
537
+ export interface ExecutedStep {
538
+ command: string;
539
+ success: boolean;
540
+ error?: string;
541
+ }
@@ -5,12 +5,13 @@ export interface ToolExecution {
5
5
  input: any;
6
6
  output: any;
7
7
  wasSuccessful: boolean;
8
+ reasoning?: string;
8
9
  }
9
10
 
10
- export function toToolExecution(toolName: string, input: any, rawOutput: any): ToolExecution {
11
+ export function toToolExecution(toolName: string, input: any, rawOutput: any, reasoning?: string): ToolExecution {
11
12
  let output = rawOutput;
12
13
  if (rawOutput?.type === 'json' && rawOutput?.value) output = rawOutput.value;
13
- return { toolName, input, output, wasSuccessful: output?.success !== false };
14
+ return { toolName, input, output, wasSuccessful: output?.success !== false, reasoning };
14
15
  }
15
16
 
16
17
  export function toolExecutionLabel(input: Record<string, any> | undefined): string {
@@ -213,13 +214,17 @@ export class Conversation {
213
214
  }
214
215
 
215
216
  getToolExecutions(): ToolExecution[] {
216
- const toolCalls = new Map<string, any>();
217
+ const toolCalls = new Map<string, { input: any; reasoning?: string }>();
217
218
  for (const message of this.messages) {
218
219
  if (message.role !== 'assistant') continue;
219
220
  if (!Array.isArray(message.content)) continue;
221
+ const reasoning = message.content
222
+ .filter((part: any) => part.type === 'reasoning' && part.text?.trim())
223
+ .map((part: any) => part.text.trim())
224
+ .join('\n');
220
225
  for (const part of message.content) {
221
226
  if (part.type !== 'tool-call') continue;
222
- toolCalls.set(part.toolCallId, part.input);
227
+ toolCalls.set(part.toolCallId, { input: part.input, reasoning });
223
228
  }
224
229
  }
225
230
 
@@ -230,7 +235,8 @@ export class Conversation {
230
235
  for (const part of message.content) {
231
236
  if (part.type !== 'tool-result') continue;
232
237
  if (part.toolName === NARRATION_TOOL) continue;
233
- executions.push(toToolExecution(part.toolName, toolCalls.get(part.toolCallId) || {}, part.output));
238
+ const call = toolCalls.get(part.toolCallId);
239
+ executions.push(toToolExecution(part.toolName, call?.input || {}, part.output, call?.reasoning));
234
240
  }
235
241
  }
236
242
 
@@ -3,12 +3,13 @@ import dedent from 'dedent';
3
3
  import { z } from 'zod';
4
4
  import { ActionResult } from '../action-result.js';
5
5
  import type Action from '../action.ts';
6
+ import type { ExecutedStep } from '../action.ts';
6
7
  import type { ExplorbotConfig } from '../config.ts';
7
8
  import type { ExperienceTracker } from '../experience-tracker.js';
8
9
  import Explorer from '../explorer.ts';
9
10
  import type { KnowledgeTracker } from '../knowledge-tracker.js';
10
- import { type StateManager, normalizeUrl } from '../state-manager.js';
11
11
  import { renderAssertion } from '../playwright-recorder.ts';
12
+ import { type StateManager, normalizeUrl } from '../state-manager.js';
12
13
  import { isFatalBrowserError } from '../utils/browser-errors.ts';
13
14
  import { getCliName } from '../utils/cli-name.ts';
14
15
  import { extractCodeBlocks } from '../utils/code-extractor.js';
@@ -37,6 +38,7 @@ class Navigator implements Agent {
37
38
 
38
39
  private MAX_ATTEMPTS = Number.parseInt(process.env.MAX_ATTEMPTS || '5');
39
40
  lastFailureReason: string | null = null;
41
+ executedSteps: ExecutedStep[] = [];
40
42
 
41
43
  private systemPrompt = dedent`
42
44
  <role>
@@ -217,12 +219,18 @@ class Navigator implements Agent {
217
219
  if (!this.provider) throw new Error('AI-assisted recovery is unavailable: no AI model is configured.');
218
220
 
219
221
  this.lastFailureReason = null;
222
+ this.executedSteps = [];
220
223
  tag('info').log('AI Navigator resolving state at', actionResult.url);
221
224
  debugLog('Resolution message:', message);
222
225
 
223
226
  const action = opts?.action ?? this.explorer.action();
224
227
  const expectedUrl = opts?.expectedUrl;
225
228
 
229
+ if (expectedUrl && this.targetUrlReached(action, expectedUrl, actionResult)) {
230
+ tag('success').log(`Already at ${expectedUrl} — navigation resolved`);
231
+ return true;
232
+ }
233
+
226
234
  const knowledge = this.knowledgeTracker.renderRelevantContext(actionResult);
227
235
 
228
236
  const conversation = this.provider.startConversation(this.systemPrompt, 'navigator');
@@ -313,14 +321,19 @@ class Navigator implements Agent {
313
321
  resolved = check.urlMatches && freshHash !== actionResult.getStateHash();
314
322
 
315
323
  if (!resolved && attempt.ok) {
316
- lastFailure = `URL did not change (still ${check.freshState.url})`;
324
+ if (check.urlMatches) {
325
+ lastFailure = `Reached ${check.freshState.url} but the page state did not change`;
326
+ tag('warning').log(`Page state did not change at ${check.freshState.url}`);
327
+ } else {
328
+ lastFailure = `Reached ${check.freshState.url}, expected ${expectedUrl}`;
329
+ tag('warning').log(`URL verification failed: expected ${expectedUrl}, got ${check.freshState.url}`);
330
+ }
317
331
  batchFailures.push({
318
332
  code: codeBlock,
319
333
  error: lastFailure,
320
334
  ariaChanges: await this.ariaDiff(check.freshState, prevActionResult),
321
335
  urlAfter: check.freshState.url,
322
336
  });
323
- tag('warning').log(`URL verification failed: expected ${expectedUrl}, got ${check.freshState.url}`);
324
337
  }
325
338
  if (freshHash !== prevHash && (attempt.ok || check.urlMatches)) {
326
339
  progressBlocks.push(codeBlock);
@@ -456,6 +469,7 @@ class Navigator implements Agent {
456
469
 
457
470
  debugLog(`Attempting resolution: ${codeBlock}`);
458
471
  const ok = await action.attempt(codeBlock, message);
472
+ this.executedSteps.push(...action.executedSteps);
459
473
 
460
474
  const page = action.playwrightHelper?.page;
461
475
  if (page) {
@@ -484,11 +498,15 @@ class Navigator implements Agent {
484
498
  }
485
499
 
486
500
  const freshState = await this.explorer.capture();
487
- const urlMatches = this.isSameExpectedOrigin(expectedUrl, action.stateManager) && matchesNavigationUrl(expectedUrl, this.comparableUrl(freshState, expectedUrl));
501
+ const urlMatches = this.targetUrlReached(action, expectedUrl, freshState);
488
502
 
489
503
  return { freshState, urlMatches };
490
504
  }
491
505
 
506
+ private targetUrlReached(action: Action, expectedUrl: string, state: ActionResult): boolean {
507
+ return this.isSameExpectedOrigin(expectedUrl, action.stateManager) && matchesNavigationUrl(expectedUrl, this.comparableUrl(state, expectedUrl));
508
+ }
509
+
492
510
  private async ariaDiff(freshState: ActionResult, previous: ActionResult): Promise<string | null> {
493
511
  if (freshState.getStateHash() === previous.getStateHash()) return null;
494
512
  try {
package/src/ai/pilot.ts CHANGED
@@ -28,6 +28,7 @@ import { withdrawVisionTools } from './tools.ts';
28
28
  const CHECK_TOOLS = ['verify', 'see', 'research'];
29
29
  const EVIDENCE_TOOLS = ['verify', 'see'];
30
30
  const META_TOOLS = ['record', 'reset', 'stop', 'finish'];
31
+ const PILOT_REASONING_LIMIT = 500;
31
32
  const PILOT_MESSAGE_LIMIT = 2;
32
33
  const PILOT_MESSAGE_MAX_LENGTH = 160;
33
34
 
@@ -1044,6 +1045,12 @@ export class Pilot implements Agent {
1044
1045
  if (resultMessage) line += `\n result: ${resultMessage}`;
1045
1046
  if (errorDetail && errorDetail !== resultMessage) line += `\n error: ${errorDetail}`;
1046
1047
 
1048
+ if (!t.wasSuccessful && t.reasoning) {
1049
+ let rationale = t.reasoning;
1050
+ if (rationale.length > PILOT_REASONING_LIMIT) rationale = `...${rationale.slice(-PILOT_REASONING_LIMIT)}`;
1051
+ line += `\n tester reasoned: ${rationale.replace(/\n+/g, ' ')}`;
1052
+ }
1053
+
1047
1054
  const attempts = t.output?.attempts;
1048
1055
  if (attempts && attempts.length > 1 && t.wasSuccessful) {
1049
1056
  const failedBefore = attempts.filter((a: any) => !a.success);