explorbot 0.1.32-beta.1 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (222) hide show
  1. package/README.md +35 -19
  2. package/bin/explorbot-cli.ts +33 -10
  3. package/boat/api-tester/src/config.ts +45 -3
  4. package/boat/doc-collector/src/ai/documentarian.ts +26 -28
  5. package/boat/doc-collector/src/ai/tools.ts +74 -16
  6. package/boat/doc-collector/src/cli.ts +1 -6
  7. package/boat/doc-collector/src/docbot.ts +30 -9
  8. package/boat/doc-collector/src/docs-renderer.ts +19 -24
  9. package/boat/doc-collector/src/screenshots.ts +34 -2
  10. package/boat/doc-collector/src/state-diagram.ts +281 -0
  11. package/dist/bin/explorbot-cli.js +29 -9
  12. package/dist/boat/api-tester/src/config.js +43 -4
  13. package/dist/boat/doc-collector/src/ai/documentarian.js +19 -7
  14. package/dist/boat/doc-collector/src/ai/tools.js +53 -16
  15. package/dist/boat/doc-collector/src/cli.js +1 -5
  16. package/dist/boat/doc-collector/src/docbot.js +27 -8
  17. package/dist/boat/doc-collector/src/docs-renderer.js +18 -3
  18. package/dist/boat/doc-collector/src/screenshots.js +30 -1
  19. package/dist/boat/doc-collector/src/state-diagram.js +200 -0
  20. package/dist/models.json +30 -0
  21. package/dist/package.json +23 -3
  22. package/dist/src/action-result.d.ts +8 -10
  23. package/dist/src/action-result.js +72 -164
  24. package/dist/src/action.d.ts +13 -12
  25. package/dist/src/action.js +37 -113
  26. package/dist/src/ai/agent.d.ts +17 -0
  27. package/dist/src/ai/captain/idle-mode.js +1 -1
  28. package/dist/src/ai/captain/test-mode.js +1 -1
  29. package/dist/src/ai/captain/web-mode.js +18 -29
  30. package/dist/src/ai/captain.d.ts +1 -3
  31. package/dist/src/ai/captain.js +17 -45
  32. package/dist/src/ai/conversation.d.ts +2 -5
  33. package/dist/src/ai/conversation.js +9 -28
  34. package/dist/src/ai/driller.d.ts +2 -17
  35. package/dist/src/ai/driller.js +26 -50
  36. package/dist/src/ai/experience-compactor.d.ts +2 -2
  37. package/dist/src/ai/experience-compactor.js +16 -33
  38. package/dist/src/ai/fisherman-tools.js +1 -1
  39. package/dist/src/ai/fisherman.d.ts +0 -1
  40. package/dist/src/ai/fisherman.js +0 -3
  41. package/dist/src/ai/historian/codeceptjs.d.ts +2 -0
  42. package/dist/src/ai/historian/codeceptjs.js +4 -3
  43. package/dist/src/ai/historian/playwright.d.ts +4 -1
  44. package/dist/src/ai/historian/playwright.js +4 -3
  45. package/dist/src/ai/historian/screencast.d.ts +2 -1
  46. package/dist/src/ai/historian/screencast.js +2 -2
  47. package/dist/src/ai/historian/utils.d.ts +0 -1
  48. package/dist/src/ai/historian/utils.js +0 -1
  49. package/dist/src/ai/historian.d.ts +10 -8
  50. package/dist/src/ai/historian.js +1 -5
  51. package/dist/src/ai/navigator.d.ts +8 -6
  52. package/dist/src/ai/navigator.js +34 -65
  53. package/dist/src/ai/pilot.d.ts +13 -8
  54. package/dist/src/ai/pilot.js +53 -59
  55. package/dist/src/ai/planner.d.ts +5 -4
  56. package/dist/src/ai/planner.js +66 -52
  57. package/dist/src/ai/provider.d.ts +4 -1
  58. package/dist/src/ai/provider.js +77 -137
  59. package/dist/src/ai/quartermaster.d.ts +22 -22
  60. package/dist/src/ai/quartermaster.js +8 -14
  61. package/dist/src/ai/rerunner.d.ts +3 -13
  62. package/dist/src/ai/rerunner.js +23 -44
  63. package/dist/src/ai/researcher/cache.js +11 -18
  64. package/dist/src/ai/researcher/coordinates.js +4 -3
  65. package/dist/src/ai/researcher/deep-analysis.d.ts +2 -0
  66. package/dist/src/ai/researcher/deep-analysis.js +8 -11
  67. package/dist/src/ai/researcher/locators.d.ts +9 -2
  68. package/dist/src/ai/researcher/locators.js +65 -8
  69. package/dist/src/ai/researcher/parser.js +1 -1
  70. package/dist/src/ai/researcher/research-result.d.ts +0 -1
  71. package/dist/src/ai/researcher/research-result.js +0 -19
  72. package/dist/src/ai/researcher/sections.d.ts +2 -0
  73. package/dist/src/ai/researcher/sections.js +7 -3
  74. package/dist/src/ai/researcher.js +40 -106
  75. package/dist/src/ai/rules.d.ts +1 -0
  76. package/dist/src/ai/rules.js +22 -15
  77. package/dist/src/ai/session-analyst.js +8 -5
  78. package/dist/src/ai/task-agent.d.ts +19 -6
  79. package/dist/src/ai/task-agent.js +37 -33
  80. package/dist/src/ai/tester.d.ts +6 -14
  81. package/dist/src/ai/tester.js +46 -111
  82. package/dist/src/ai/tools.d.ts +26 -14
  83. package/dist/src/ai/tools.js +138 -132
  84. package/dist/src/command-handler.js +1 -1
  85. package/dist/src/commands/compact-command.d.ts +1 -1
  86. package/dist/src/commands/compact-command.js +6 -6
  87. package/dist/src/commands/context-aria-command.js +1 -1
  88. package/dist/src/commands/context-command.js +8 -6
  89. package/dist/src/commands/context-data-command.js +2 -2
  90. package/dist/src/commands/context-experience-command.js +2 -2
  91. package/dist/src/commands/context-html-command.js +2 -2
  92. package/dist/src/commands/context-knowledge-command.js +2 -2
  93. package/dist/src/commands/drill-command.js +1 -1
  94. package/dist/src/commands/experience-command.js +1 -1
  95. package/dist/src/commands/explore-command.js +4 -3
  96. package/dist/src/commands/freesail-command.js +2 -2
  97. package/dist/src/commands/knows-command.js +1 -1
  98. package/dist/src/commands/learn-command.js +5 -3
  99. package/dist/src/commands/path-command.js +1 -1
  100. package/dist/src/commands/research-command.js +1 -1
  101. package/dist/src/commands/test-command.js +1 -1
  102. package/dist/src/components/AddKnowledge.d.ts +2 -0
  103. package/dist/src/components/AddKnowledge.js +3 -7
  104. package/dist/src/components/App.js +3 -3
  105. package/dist/src/config.d.ts +19 -0
  106. package/dist/src/config.js +156 -10
  107. package/dist/src/experience-tracker.d.ts +11 -6
  108. package/dist/src/experience-tracker.js +82 -155
  109. package/dist/src/explorbot.d.ts +21 -13
  110. package/dist/src/explorbot.js +84 -80
  111. package/dist/src/explorer.d.ts +66 -102
  112. package/dist/src/explorer.js +332 -623
  113. package/dist/src/knowledge-tracker.d.ts +2 -2
  114. package/dist/src/knowledge-tracker.js +38 -45
  115. package/dist/src/state-manager.d.ts +10 -30
  116. package/dist/src/state-manager.js +11 -129
  117. package/dist/src/utils/aria.d.ts +6 -1
  118. package/dist/src/utils/aria.js +4 -1
  119. package/dist/src/utils/cache.d.ts +15 -0
  120. package/dist/src/utils/cache.js +34 -0
  121. package/dist/src/utils/context-formatter.d.ts +6 -0
  122. package/dist/src/utils/context-formatter.js +15 -27
  123. package/dist/src/utils/hooks-runner.js +2 -4
  124. package/dist/src/utils/html.d.ts +5 -0
  125. package/dist/src/utils/html.js +71 -0
  126. package/dist/src/utils/markdown-files.d.ts +10 -0
  127. package/dist/src/utils/markdown-files.js +21 -0
  128. package/dist/src/utils/markdown-query.d.ts +6 -0
  129. package/dist/src/utils/markdown-query.js +14 -1
  130. package/dist/src/utils/page-readiness.d.ts +1 -0
  131. package/dist/src/utils/page-readiness.js +1 -1
  132. package/dist/src/utils/secrets.js +2 -2
  133. package/dist/src/utils/strings.d.ts +2 -0
  134. package/dist/src/utils/strings.js +10 -0
  135. package/dist/src/utils/test-files.js +1 -1
  136. package/dist/src/utils/web-annotate.d.ts +5 -0
  137. package/dist/src/utils/web-annotate.js +58 -0
  138. package/dist/src/utils/web-eidx.d.ts +2 -0
  139. package/dist/src/utils/web-eidx.js +20 -0
  140. package/dist/src/utils/web-element.d.ts +3 -1
  141. package/dist/src/utils/web-element.js +32 -0
  142. package/dist/src/utils/web-sandbox.d.ts +4 -0
  143. package/dist/src/utils/web-sandbox.js +37 -0
  144. package/models.json +30 -0
  145. package/package.json +23 -3
  146. package/src/action-result.ts +70 -173
  147. package/src/action.ts +38 -132
  148. package/src/ai/agent.ts +20 -0
  149. package/src/ai/captain/idle-mode.ts +1 -1
  150. package/src/ai/captain/test-mode.ts +1 -1
  151. package/src/ai/captain/web-mode.ts +18 -30
  152. package/src/ai/captain.ts +17 -42
  153. package/src/ai/conversation.ts +9 -32
  154. package/src/ai/driller.ts +53 -83
  155. package/src/ai/experience-compactor.ts +16 -35
  156. package/src/ai/fisherman-tools.ts +1 -1
  157. package/src/ai/fisherman.ts +0 -4
  158. package/src/ai/historian/codeceptjs.ts +5 -3
  159. package/src/ai/historian/playwright.ts +7 -4
  160. package/src/ai/historian/screencast.ts +4 -3
  161. package/src/ai/historian/utils.ts +0 -1
  162. package/src/ai/historian.ts +6 -8
  163. package/src/ai/navigator.ts +39 -70
  164. package/src/ai/pilot.ts +59 -61
  165. package/src/ai/planner.ts +76 -62
  166. package/src/ai/provider.ts +85 -146
  167. package/src/ai/quartermaster.ts +39 -45
  168. package/src/ai/rerunner.ts +27 -50
  169. package/src/ai/researcher/cache.ts +10 -16
  170. package/src/ai/researcher/coordinates.ts +4 -3
  171. package/src/ai/researcher/deep-analysis.ts +10 -11
  172. package/src/ai/researcher/locators.ts +66 -9
  173. package/src/ai/researcher/parser.ts +1 -1
  174. package/src/ai/researcher/research-result.ts +0 -18
  175. package/src/ai/researcher/sections.ts +9 -3
  176. package/src/ai/researcher.ts +42 -119
  177. package/src/ai/rules.ts +24 -13
  178. package/src/ai/session-analyst.ts +8 -5
  179. package/src/ai/task-agent.ts +45 -38
  180. package/src/ai/tester.ts +51 -129
  181. package/src/ai/tools.ts +145 -153
  182. package/src/command-handler.ts +1 -1
  183. package/src/commands/compact-command.ts +7 -8
  184. package/src/commands/context-aria-command.ts +1 -1
  185. package/src/commands/context-command.ts +8 -6
  186. package/src/commands/context-data-command.ts +2 -2
  187. package/src/commands/context-experience-command.ts +2 -2
  188. package/src/commands/context-html-command.ts +2 -2
  189. package/src/commands/context-knowledge-command.ts +2 -2
  190. package/src/commands/drill-command.ts +1 -1
  191. package/src/commands/experience-command.ts +1 -1
  192. package/src/commands/explore-command.ts +4 -3
  193. package/src/commands/freesail-command.ts +2 -2
  194. package/src/commands/knows-command.ts +1 -1
  195. package/src/commands/learn-command.ts +5 -3
  196. package/src/commands/path-command.ts +1 -1
  197. package/src/commands/research-command.ts +1 -1
  198. package/src/commands/test-command.ts +1 -1
  199. package/src/components/AddKnowledge.tsx +5 -7
  200. package/src/components/App.tsx +3 -3
  201. package/src/config.ts +185 -11
  202. package/src/experience-tracker.ts +89 -152
  203. package/src/explorbot.ts +88 -83
  204. package/src/explorer.ts +402 -693
  205. package/src/knowledge-tracker.ts +42 -49
  206. package/src/state-manager.ts +20 -155
  207. package/src/utils/aria.ts +11 -2
  208. package/src/utils/cache.ts +40 -0
  209. package/src/utils/context-formatter.ts +12 -15
  210. package/src/utils/hooks-runner.ts +2 -4
  211. package/src/utils/html.ts +79 -0
  212. package/src/utils/markdown-files.ts +30 -0
  213. package/src/utils/markdown-query.ts +15 -1
  214. package/src/utils/page-readiness.ts +1 -1
  215. package/src/utils/secrets.ts +2 -3
  216. package/src/utils/strings.ts +12 -0
  217. package/src/utils/test-files.ts +1 -1
  218. package/src/utils/web-annotate.ts +64 -0
  219. package/src/utils/web-eidx.ts +21 -0
  220. package/src/utils/web-element.ts +34 -0
  221. package/src/utils/web-sandbox.ts +43 -0
  222. package/dist/boat/api-tester/example/apibot.config.js +0 -30
package/src/ai/planner.ts CHANGED
@@ -3,7 +3,7 @@ import { z } from 'zod';
3
3
  import { ActionResult } from '../action-result.ts';
4
4
  import { setActivity } from '../activity.ts';
5
5
  import { ConfigParser } from '../config.ts';
6
- import { ExperienceTracker } from '../experience-tracker.ts';
6
+ import type { ExperienceTracker } from '../experience-tracker.ts';
7
7
  import type Explorer from '../explorer.ts';
8
8
  import { Observability } from '../observability.ts';
9
9
  import type { StateManager } from '../state-manager.js';
@@ -14,14 +14,14 @@ import { createDebug, tag } from '../utils/logger.js';
14
14
  import { jsonToTable } from '../utils/markdown-parser.ts';
15
15
  import { mdq } from '../utils/markdown-query.js';
16
16
  import { planToCompactAiContext } from '../utils/test-plan-markdown.ts';
17
- import type { Agent } from './agent.js';
17
+ import type { Agent, AgentDeps } from './agent.js';
18
18
  import { Conversation } from './conversation.ts';
19
19
  import type { Fisherman } from './fisherman.ts';
20
20
  import { WithSessionDedup } from './planner/session-dedup.ts';
21
- import { getActiveStyle, getStyles } from './planner/styles.ts';
21
+ import { getActiveStyle } from './planner/styles.ts';
22
22
  import { WithSubPages, getPlannedByStateHash, getRegisteredPlan, registerPlan } from './planner/subpages.ts';
23
23
  import type { Provider } from './provider.js';
24
- import { POSSIBLE_SECTIONS, Researcher } from './researcher.ts';
24
+ import { POSSIBLE_SECTIONS, type Researcher } from './researcher.ts';
25
25
  import { findSimilarStateHash } from './researcher/cache.ts';
26
26
  import { hasFocusedSection } from './researcher/focus.ts';
27
27
  import { capabilityGroundingRule, dataProtectionRules, fileUploadRule } from './rules.ts';
@@ -63,13 +63,13 @@ export class Planner extends PlannerBase implements Agent {
63
63
  researcher: Researcher;
64
64
  private fisherman: Fisherman | null = null;
65
65
 
66
- constructor(explorer: Explorer, provider: Provider) {
66
+ constructor(deps: AgentDeps, researcher: Researcher) {
67
67
  super();
68
- this.explorer = explorer;
69
- this.provider = provider;
70
- this.researcher = new Researcher(explorer, provider);
71
- this.stateManager = explorer.getStateManager();
72
- this.experienceTracker = new ExperienceTracker();
68
+ this.explorer = deps.explorer;
69
+ this.provider = deps.ai;
70
+ this.researcher = researcher;
71
+ this.stateManager = deps.stateManager;
72
+ this.experienceTracker = deps.stateManager.getExperienceTracker();
73
73
  }
74
74
 
75
75
  setFisherman(fisherman: Fisherman): void {
@@ -98,7 +98,7 @@ export class Planner extends PlannerBase implements Agent {
98
98
  You are ISTQB certified senior manual QA planning exploratory testing session of a web application.
99
99
  Each test scenario must complete a meaningful user workflow — not just open a UI element and verify it exists.
100
100
  Bad: "Open the Help panel" — just opens something.
101
- Good: "Create a new suite and verify it appears in the list" — completes a business action.
101
+ Good: "Create a new post and verify it appears in the list" — completes a business action.
102
102
  </role>
103
103
  <task>
104
104
  List possible testing scenarios for the web page.
@@ -171,6 +171,9 @@ export class Planner extends PlannerBase implements Agent {
171
171
 
172
172
  const tags = ['planner'];
173
173
  if (style) tags.push(style);
174
+
175
+ const existingTestScenarios = this.getExistingTestFileScenarios(state.url);
176
+
174
177
  const result = await Observability.run(`planner: ${state.url}`, { tags, sessionId: state.url }, async () => {
175
178
  const actionResult = ActionResult.fromState(state);
176
179
  const conversation = await this.buildConversation(actionResult, style, parentPlan, feature);
@@ -211,7 +214,6 @@ export class Planner extends PlannerBase implements Agent {
211
214
  const defaultStartUrl = this.getDefaultStartUrl(state);
212
215
  if (parentPlan) this.currentPlan.parentPlan = parentPlan;
213
216
  const allPreviousScenarios = this.getPreviousSessionScenarios();
214
- const existingTestScenarios = this.getExistingTestFileScenarios(state.url);
215
217
  for (const s of existingTestScenarios) allPreviousScenarios.add(s);
216
218
  for (const t of tests) {
217
219
  if (allPreviousScenarios.has(t.scenario.toLowerCase())) continue;
@@ -266,6 +268,7 @@ export class Planner extends PlannerBase implements Agent {
266
268
  }
267
269
 
268
270
  private getExistingTestFileScenarios(currentUrl?: string): Set<string> {
271
+ this.lastSuite = null;
269
272
  if (!currentUrl) return new Set<string>();
270
273
  try {
271
274
  this.lastSuite = new Suite(currentUrl);
@@ -277,29 +280,36 @@ export class Planner extends PlannerBase implements Agent {
277
280
  }
278
281
 
279
282
  private cleanExperienceFlows(text: string): string | null {
280
- const seenTitles = new Set<string>();
281
283
  let result = text;
284
+ const seenTitles = new Set<string>();
285
+ for (const selector of ['section2', 'section3']) {
286
+ result = mdq(result)
287
+ .query(selector)
288
+ .replaceEach((section) => {
289
+ const heading = section.query('heading').text().trim();
290
+ const withoutHeadings = mdq(section.text()).query('heading').replace('');
291
+ const body = mdq(withoutHeadings).query('hr').replace('').trim();
292
+ if (body && !seenTitles.has(heading)) {
293
+ seenTitles.add(heading);
294
+ return section.text();
295
+ }
296
+ return '';
297
+ });
298
+ }
282
299
 
283
- for (const section of [...mdq(text).query('section2').each(), ...mdq(text).query('section3').each()]) {
284
- const heading = section.query('heading').text().trim();
285
- const body = mdq(section.text())
286
- .query('heading')
287
- .replace('')
288
- .replace(/^---\s*$/gm, '')
289
- .trim();
290
-
291
- if (!body || seenTitles.has(heading)) {
292
- result = result.replace(section.text(), '');
293
- continue;
294
- }
295
- seenTitles.add(heading);
296
-
297
- const blockquotes = section.query('blockquote').each();
298
- if (blockquotes.length <= 10) continue;
299
- for (const bq of blockquotes.slice(10)) {
300
- result = result.replace(bq.text(), '');
301
- }
302
- result = result.replace(section.text().trim(), `${section.text().trim()}\n> ... and ${blockquotes.length - 10} more discoveries`);
300
+ const trimmedTitles = new Set<string>();
301
+ for (const selector of ['section2', 'section3']) {
302
+ result = mdq(result)
303
+ .query(selector)
304
+ .replaceEach((section) => {
305
+ const heading = section.query('heading').text().trim();
306
+ if (trimmedTitles.has(heading)) return section.text();
307
+ const count = section.query('blockquote').count();
308
+ if (count <= 10) return section.text();
309
+ const kept = mdq(section.text()).query('blockquote[10:]').replace('');
310
+ trimmedTitles.add(heading);
311
+ return `${kept.trimEnd()}\n> ... and ${count - 10} more discoveries\n`;
312
+ });
303
313
  }
304
314
 
305
315
  return result.trim() || null;
@@ -338,6 +348,7 @@ export class Planner extends PlannerBase implements Agent {
338
348
  If there are subpages (pages with same URL path) plan testing of those subpages as well
339
349
  If you plan to test CRUD operations, plan them in correct order: create, read, update.
340
350
  Do not invent specific route names, success messages, validation texts, badge counts, or welcome messages unless they are visible in research, visited pages, or prior observed flows.
351
+ When validation placement or wording was not observed, require feedback associated with the invalid input without inventing a specific location or message.
341
352
  If exact wording is unknown, describe the expected result generically, for example "an authentication error is shown" or "the user stays on the login page" instead of guessing the literal text.
342
353
  If exact redirect destination is unknown, describe the destination by visible page identity, for example "the dashboard page opens" or "the current workspace home page opens" instead of inventing a URL slug.
343
354
  Only propose scenarios whose prerequisites are evident from page research, visited pages, or API data preparation context.
@@ -363,31 +374,26 @@ export class Planner extends PlannerBase implements Agent {
363
374
  ${dataProtectionRules}
364
375
  ${fileUploadRule}
365
376
  </rules>
366
-
367
- ${this.buildApproach(style)}
368
-
369
- <context>
370
- URL: ${state.url || 'Unknown'}
371
- Title: ${state.title || 'Unknown'}
372
- </context>
373
377
  `;
374
378
 
375
379
  conversation.addUserText(planningPrompt);
380
+ conversation.addUserText(this.getTasksMessage());
376
381
  const currentState = this.stateManager.getCurrentState();
377
382
  const research = await this.researcher.research(currentState || state, {
378
383
  deep: true,
379
384
  });
380
385
  let plannerResearch = mdq(research).query('code').replace('');
381
- for (const table of mdq(plannerResearch).query('table').each()) {
382
- const rawTable = table.text();
383
- const rows = table.toJson();
384
- if (rows.length === 0 || !rows[0].Element) continue;
385
- const elementWithType = rows.map((r) => ({
386
- Element: r.Element,
387
- Type: r.Type || '',
388
- }));
389
- plannerResearch = plannerResearch.replace(rawTable, jsonToTable(elementWithType, ['Element', 'Type']));
390
- }
386
+ plannerResearch = mdq(plannerResearch)
387
+ .query('table')
388
+ .replaceEach((table) => {
389
+ const rows = table.toJson();
390
+ if (rows.length === 0 || !rows[0].Element) return table.text();
391
+ const elementWithType = rows.map((r) => ({
392
+ Element: r.Element,
393
+ Type: r.Type || '',
394
+ }));
395
+ return jsonToTable(elementWithType, ['Element', 'Type']);
396
+ });
391
397
 
392
398
  const hasFocusedOverlay = hasFocusedSection(plannerResearch);
393
399
  const focusNote = hasFocusedOverlay ? "IMPORTANT: One section is marked as **Focused** — this is the user's current focus area. Concentrate testing on the Focused section FIRST — test all interactions inside it before planning tests for the rest of the page." : '';
@@ -405,6 +411,15 @@ export class Planner extends PlannerBase implements Agent {
405
411
  </page_research>
406
412
  `);
407
413
 
414
+ conversation.addUserText(dedent`
415
+ ${this.buildApproach(style)}
416
+
417
+ <context>
418
+ URL: ${state.url || 'Unknown'}
419
+ Title: ${state.title || 'Unknown'}
420
+ </context>
421
+ `);
422
+
408
423
  if (this.fisherman) {
409
424
  await this.fisherman.ensureReady(state.url);
410
425
  if (this.fisherman.isAvailable()) {
@@ -556,8 +571,11 @@ export class Planner extends PlannerBase implements Agent {
556
571
  `);
557
572
  }
558
573
 
559
- const hasCurrentPlan = !!this.currentPlan;
560
- const tasksMessage = dedent`
574
+ return conversation;
575
+ }
576
+
577
+ private getTasksMessage(): string {
578
+ return dedent`
561
579
  <task>
562
580
  Provide testing scenarios as structured data with the following requirements:
563
581
  1. Create a short, descriptive plan name that summarizes what will be tested (e.g., "User Authentication Testing", "Product Catalog Navigation", "Form Validation Tests")
@@ -576,17 +594,17 @@ export class Planner extends PlannerBase implements Agent {
576
594
 
577
595
  STEPS - actionable commands:
578
596
  - Each step should be a specific action to perform
579
- - Good: "Click Create Suite button", "Enter 'My New Suite' as suite name", "Click Save button"
580
- - Good: "Click Star icon on 'Template API Testing' suite"
597
+ - Good: "Click Create Post button", "Enter 'My New Post' as post title", "Click Save button"
598
+ - Good: "Click Star icon on 'Quarterly Report' item"
581
599
  - Bad: "Open the dropdown menu" (opening is not a goal, it's a means)
582
600
  - Bad: "Verify modal appears" (verification belongs in expected outcomes, not steps)
583
601
  - Steps should form a complete workflow, not stop at opening a UI element
584
602
  - NEVER split a workflow across two tests. One test = one complete action + its verification
585
603
 
586
604
  EXPECTED OUTCOMES - verifiable results:
587
- - Good: "New suite 'My New Suite' appears in the suite list"
588
- - Good: "Suite appears under Starred filter tab"
589
- - Good: "Success message 'Suite created' is displayed"
605
+ - Good: "New post 'My New Post' appears in the posts list"
606
+ - Good: "Item appears under Starred filter tab"
607
+ - Good: "Success message 'Post created' is displayed"
590
608
  - Good when wording is unknown: "An authentication error is displayed"
591
609
  - Good when route is unknown: "The workspace home page is displayed"
592
610
  - Bad: "Modal is displayed" (just verifying existence, no business value)
@@ -603,12 +621,8 @@ export class Planner extends PlannerBase implements Agent {
603
621
  - Do not wrap text in ** or * quotes, ( or ) brackets.
604
622
  - Avoid using emojis or special characters.
605
623
  7. Only tests that can be tested from web UI should be proposed.
606
- ${hasCurrentPlan ? '8. CRITICAL: Return ONLY NEW scenarios not in the existing tests list. Return empty array if no new tests needed.' : `8. At least ${this.MIN_TASKS} tests should be proposed.`}
624
+ 8. For a fresh plan propose at least ${this.MIN_TASKS} tests; when expanding an existing plan return ONLY new scenarios not in the tested list, and an empty array if none remain.
607
625
  </task>
608
626
  `;
609
-
610
- conversation.addUserText(tasksMessage);
611
-
612
- return conversation;
613
627
  }
614
628
  }
@@ -11,7 +11,7 @@ import { Stats } from '../stats.ts';
11
11
  import { createDebug, tag } from '../utils/logger.js';
12
12
  import { type RetryOptions, withRetry } from '../utils/retry.js';
13
13
  import { RulesLoader } from '../utils/rules-loader.ts';
14
- import { Conversation } from './conversation.js';
14
+ import { Conversation, toToolExecution } from './conversation.js';
15
15
 
16
16
  const debugLog = createDebug('explorbot:provider');
17
17
  const promptLog = createDebug('explorbot:provider:out');
@@ -22,6 +22,8 @@ export class ContextLengthError extends Error {}
22
22
 
23
23
  let telemetryRegistered = false;
24
24
 
25
+ const CONTEXT_LENGTH_PATTERNS = ['reduce the length', 'context length', 'maximum context', 'token limit', 'too many tokens', 'max_tokens', 'context_length_exceeded', 'output truncated at maxtokens'];
26
+
25
27
  function extractCachedTokens(usage: any): number {
26
28
  if (!usage) return 0;
27
29
  const direct = usage.inputTokenDetails?.cacheReadTokens ?? usage.cachedInputTokens;
@@ -31,10 +33,10 @@ function extractCachedTokens(usage: any): number {
31
33
  return typeof fromRaw === 'number' ? fromRaw : 0;
32
34
  }
33
35
 
34
- function rejectAfterIdle(ms: number, signal: { cancelled: boolean }, controller: AbortController): Promise<never> {
36
+ function abortAfterIdle(ms: number, cancel: { cancelled: boolean }, controller: AbortController): Promise<never> {
35
37
  return new Promise((_, reject) => {
36
38
  const tick = () => {
37
- if (signal.cancelled) return;
39
+ if (cancel.cancelled) return;
38
40
  if (executionController.isAwaitingInput()) {
39
41
  setTimeout(tick, ms);
40
42
  return;
@@ -46,6 +48,12 @@ function rejectAfterIdle(ms: number, signal: { cancelled: boolean }, controller:
46
48
  });
47
49
  }
48
50
 
51
+ function combinedAbortSignal(controller: AbortController): AbortSignal {
52
+ const executionSignal = executionController.getAbortSignal();
53
+ if (!executionSignal) return controller.signal;
54
+ return AbortSignal.any([controller.signal, executionSignal]);
55
+ }
56
+
49
57
  export class Provider {
50
58
  private config: AIConfig;
51
59
  private telemetryEnabled = false;
@@ -70,7 +78,6 @@ export class Provider {
70
78
  },
71
79
  };
72
80
 
73
- static readonly CONTEXT_LENGTH_PATTERNS = ['reduce the length', 'context length', 'maximum context', 'token limit', 'too many tokens', 'max_tokens', 'context_length_exceeded', 'output truncated at maxtokens'];
74
81
  lastConversation: Conversation | null = null;
75
82
 
76
83
  constructor(config: AIConfig) {
@@ -90,7 +97,6 @@ export class Provider {
90
97
  await generateText({
91
98
  model: this.config.model,
92
99
  prompt: 'hi',
93
- maxOutputTokens: 1,
94
100
  });
95
101
  } catch (error: any) {
96
102
  throw new AiError(`AI connection failed: ${error.message}`);
@@ -138,12 +144,12 @@ export class Provider {
138
144
  return parts.length > 0 ? parts.join('\n\n') : undefined;
139
145
  }
140
146
 
141
- getProviderOptionsForAgent(agentName: string): Record<string, any> | undefined {
147
+ private getProviderOptionsForAgent(agentName: string): Record<string, any> | undefined {
142
148
  const agentConfig = this.config.agents?.[agentName as keyof typeof this.config.agents];
143
149
  return agentConfig?.providerOptions;
144
150
  }
145
151
 
146
- getReasoningForAgent(agentName?: string): string | undefined {
152
+ private getReasoningForAgent(agentName?: string): string | undefined {
147
153
  if (!agentName) return undefined;
148
154
  const agentConfig = this.config.agents?.[agentName as keyof typeof this.config.agents];
149
155
  return agentConfig?.reasoning;
@@ -172,6 +178,50 @@ export class Provider {
172
178
  if (reasoning) config.reasoning ??= reasoning;
173
179
  }
174
180
 
181
+ private buildGenerateConfig(defaults: Record<string, any>, overrides: Record<string, any>, options: any): Record<string, any> {
182
+ const telemetry = this.getTelemetry(options);
183
+ const config = this.mergeProviderOptions(
184
+ {
185
+ ...defaults,
186
+ allowSystemInMessages: true,
187
+ ...(this.config.config || {}),
188
+ ...options,
189
+ ...overrides,
190
+ },
191
+ options.agentName
192
+ );
193
+ this.finalizeConfig(config, options, telemetry);
194
+ return config;
195
+ }
196
+
197
+ private recordUsage(agentName: string, modelName: string, usage: any): void {
198
+ if (!usage) return;
199
+ Stats.recordTokens(agentName, modelName, {
200
+ input: usage.inputTokens ?? usage.promptTokens ?? 0,
201
+ output: usage.outputTokens ?? usage.completionTokens ?? 0,
202
+ total: usage.totalTokens ?? 0,
203
+ cached: extractCachedTokens(usage),
204
+ });
205
+ }
206
+
207
+ private async raceWithIdleTimeout<T>(fn: (signal: AbortSignal) => Promise<T>, timeoutMs: number): Promise<T> {
208
+ const cancel = { cancelled: false };
209
+ const controller = new AbortController();
210
+ const combinedSignal = combinedAbortSignal(controller);
211
+ try {
212
+ return await Promise.race([fn(combinedSignal), abortAfterIdle(timeoutMs, cancel, controller)]);
213
+ } finally {
214
+ cancel.cancelled = true;
215
+ }
216
+ }
217
+
218
+ private async recoverFromContextLength(error: any, messages: ModelMessage[], options: any, retry: (messages: ModelMessage[], options: any) => Promise<any>): Promise<any> {
219
+ const reduced = this.tryReduceMessages(messages, options._contextRetryLevel || 0);
220
+ if (!reduced) throw new ContextLengthError(error.message || error.toString());
221
+ tag('warning').log('Context length exceeded, retrying with reduced messages...');
222
+ return retry(reduced.messages, { ...options, _contextRetryLevel: reduced.nextLevel });
223
+ }
224
+
175
225
  private initLangfuse() {
176
226
  const langfuseConfig = this.config.langfuse;
177
227
  const publicKey = langfuseConfig?.publicKey || process.env.LANGFUSE_PUBLIC_KEY;
@@ -207,20 +257,25 @@ export class Provider {
207
257
 
208
258
  const runTelemetry = Observability.getTelemetry();
209
259
 
210
- if (!options.telemetry) {
260
+ let optionTelemetry = options.telemetry;
261
+ if (options.telemetryFunctionId && !optionTelemetry?.functionId) {
262
+ optionTelemetry = { ...optionTelemetry, functionId: options.telemetryFunctionId };
263
+ }
264
+
265
+ if (!optionTelemetry) {
211
266
  return runTelemetry;
212
267
  }
213
268
 
214
269
  if (!runTelemetry) {
215
- return options.telemetry;
270
+ return optionTelemetry;
216
271
  }
217
272
 
218
273
  return {
219
274
  ...runTelemetry,
220
- ...options.telemetry,
275
+ ...optionTelemetry,
221
276
  metadata: {
222
277
  ...runTelemetry.metadata,
223
- ...options.telemetry.metadata,
278
+ ...optionTelemetry.metadata,
224
279
  },
225
280
  };
226
281
  }
@@ -254,12 +309,8 @@ export class Provider {
254
309
  const toolCalls = response.toolCalls || [];
255
310
  const toolResults = response.toolResults || [];
256
311
 
257
- const toolExecutions = toolCalls.map((call: any, index: number) => ({
258
- toolName: call.toolName || '',
259
- input: call.input,
260
- output: toolResults[index]?.output,
261
- wasSuccessful: toolResults[index]?.output?.success || false,
262
- }));
312
+ const resultsById = new Map(toolResults.map((r: any) => [r.toolCallId, r]));
313
+ const toolExecutions = toolCalls.map((call: any) => toToolExecution(call.toolName || '', call.input, resultsById.get(call.toolCallId)?.output));
263
314
 
264
315
  return { conversation, response, toolExecutions };
265
316
  }
@@ -269,19 +320,7 @@ export class Provider {
269
320
  setActivity(`🤖 Asking ${modelName}`, 'ai');
270
321
  promptLog(`Using model: ${modelName}`);
271
322
 
272
- const telemetry = this.getTelemetry(options);
273
- const config = this.mergeProviderOptions(
274
- {
275
- maxOutputTokens: 16384,
276
- allowSystemInMessages: true,
277
- ...(this.config.config || {}),
278
- ...options,
279
- model,
280
- abortSignal: executionController.getAbortSignal(),
281
- },
282
- options.agentName
283
- );
284
- this.finalizeConfig(config, options, telemetry);
323
+ const config = this.buildGenerateConfig({ maxOutputTokens: 16384 }, { model, abortSignal: executionController.getAbortSignal() }, options);
285
324
 
286
325
  promptLog(messages[messages.length - 1].content);
287
326
  try {
@@ -303,14 +342,7 @@ export class Provider {
303
342
  clearActivity();
304
343
  responseLog(response.text);
305
344
 
306
- if (response.usage) {
307
- Stats.recordTokens(options.agentName || 'unknown', modelName, {
308
- input: response.usage.inputTokens ?? response.usage.promptTokens ?? 0,
309
- output: response.usage.outputTokens ?? response.usage.completionTokens ?? 0,
310
- total: response.usage.totalTokens ?? 0,
311
- cached: extractCachedTokens(response.usage),
312
- });
313
- }
345
+ this.recordUsage(options.agentName || 'unknown', modelName, response.usage);
314
346
 
315
347
  return response;
316
348
  } catch (error: any) {
@@ -318,12 +350,7 @@ export class Provider {
318
350
  if (error?.name === 'AbortError') throw error;
319
351
  if (error instanceof ContextLengthError) throw error;
320
352
  if (Provider.isContextLengthError(error)) {
321
- const reduced = this.tryReduceMessages(messages, options._contextRetryLevel || 0);
322
- if (reduced) {
323
- tag('warning').log('Context length exceeded, retrying with reduced messages...');
324
- return this.chat(reduced.messages, model, { ...options, _contextRetryLevel: reduced.nextLevel });
325
- }
326
- throw new ContextLengthError(error.message || error.toString());
353
+ return this.recoverFromContextLength(error, messages, options, (m, o) => this.chat(m, model, o));
327
354
  }
328
355
  const message = error.message || error.toString();
329
356
  if (message !== 'No response text from AI') {
@@ -343,50 +370,19 @@ export class Provider {
343
370
  promptLog('Available tools:', toolNames);
344
371
  promptLog(messages[messages.length - 1].content);
345
372
 
346
- const telemetry = this.getTelemetry(options);
347
373
  const maxRoundtrips = options.maxToolRoundtrips ?? 5;
348
374
  const extraStop = options.stopWhen;
349
375
  const stopConditions: any[] = [isStepCount(maxRoundtrips)];
350
376
  if (extraStop) stopConditions.push(extraStop);
351
- const { stopWhen: _ignoredStopWhen, ...optionsWithoutStop } = options;
352
- const config = this.mergeProviderOptions(
353
- {
354
- tools,
355
- maxOutputTokens: 16384,
356
- toolChoice: 'auto',
357
- allowSystemInMessages: true,
358
- ...(this.config.config || {}),
359
- ...optionsWithoutStop,
360
- stopWhen: stopConditions,
361
- model,
362
- abortSignal: executionController.getAbortSignal(),
363
- },
364
- options.agentName
365
- );
366
- this.finalizeConfig(config, options, telemetry);
377
+ const config = this.buildGenerateConfig({ tools, maxOutputTokens: 16384, toolChoice: 'auto' }, { stopWhen: stopConditions, model }, options);
367
378
  try {
368
379
  const response = await withRetry(async () => {
369
- const timeout = config.timeout || 30000;
370
- const cancel = { cancelled: false };
371
- const controller = new AbortController();
372
- const combinedSignal = AbortSignal.any([controller.signal, executionController.getAbortSignal()].filter(Boolean) as AbortSignal[]);
373
- try {
374
- const result = (await Promise.race([
375
- generateText({
376
- messages,
377
- ...config,
378
- abortSignal: combinedSignal,
379
- }),
380
- rejectAfterIdle(timeout, cancel, controller),
381
- ])) as any;
382
- const hasToolCall = (result.toolCalls?.length || 0) > 0;
383
- if (!result.text && !hasToolCall && result.finishReason === 'length') {
384
- throw new ContextLengthError('AI response empty: output truncated at maxTokens. Increase maxOutputTokens in config or use a model with higher output capacity.');
385
- }
386
- return result;
387
- } finally {
388
- cancel.cancelled = true;
380
+ const result = (await this.raceWithIdleTimeout((signal) => generateText({ messages, ...config, abortSignal: signal }), config.timeout || 30000)) as any;
381
+ const hasToolCall = (result.toolCalls?.length || 0) > 0;
382
+ if (!result.text && !hasToolCall && result.finishReason === 'length') {
383
+ throw new ContextLengthError('AI response empty: output truncated at maxTokens. Increase maxOutputTokens in config or use a model with higher output capacity.');
389
384
  }
385
+ return result;
390
386
  }, this.getRetryOptions(options));
391
387
 
392
388
  clearActivity();
@@ -401,14 +397,7 @@ export class Provider {
401
397
 
402
398
  responseLog(response.text);
403
399
 
404
- if (response.usage) {
405
- Stats.recordTokens(options.agentName || 'unknown', modelName, {
406
- input: response.usage.inputTokens ?? response.usage.promptTokens ?? 0,
407
- output: response.usage.outputTokens ?? response.usage.completionTokens ?? 0,
408
- total: response.usage.totalTokens ?? 0,
409
- cached: extractCachedTokens(response.usage),
410
- });
411
- }
400
+ this.recordUsage(options.agentName || 'unknown', modelName, response.usage);
412
401
 
413
402
  return response;
414
403
  } catch (error: any) {
@@ -419,12 +408,7 @@ export class Provider {
419
408
  if (error?.name === 'AbortError') throw error;
420
409
  if (error instanceof ContextLengthError) throw error;
421
410
  if (Provider.isContextLengthError(error)) {
422
- const reduced = this.tryReduceMessages(messages, options._contextRetryLevel || 0);
423
- if (reduced) {
424
- tag('warning').log('Context length exceeded, retrying with reduced messages...');
425
- return this.generateWithTools(reduced.messages, model, tools, { ...options, _contextRetryLevel: reduced.nextLevel });
426
- }
427
- throw new ContextLengthError(error.message || error.toString());
411
+ return this.recoverFromContextLength(error, messages, options, (m, o) => this.generateWithTools(m, model, tools, o));
428
412
  }
429
413
  if (error.constructor?.name === 'AI_APICallError') {
430
414
  responseLog(error.message);
@@ -440,52 +424,18 @@ export class Provider {
440
424
  setActivity(`🤖 Asking ${modelName} for structured output`, 'ai');
441
425
  promptLog(`Using model: ${modelName}`);
442
426
 
443
- const telemetry = this.getTelemetry(options);
444
- const config = this.mergeProviderOptions(
445
- {
446
- schema,
447
- allowSystemInMessages: true,
448
- ...(this.config.config || {}),
449
- ...options,
450
- model: modelToUse,
451
- abortSignal: executionController.getAbortSignal(),
452
- },
453
- options.agentName
454
- );
455
- this.finalizeConfig(config, options, telemetry);
427
+ const config = this.buildGenerateConfig({ schema }, { model: modelToUse }, options);
456
428
 
457
429
  try {
458
430
  promptLog(messages[messages.length - 1].content);
459
431
  const response = await withRetry(async () => {
460
- const timeout = config.timeout || 30000;
461
- const cancel = { cancelled: false };
462
- const controller = new AbortController();
463
- const combinedSignal = AbortSignal.any([controller.signal, executionController.getAbortSignal()].filter(Boolean) as AbortSignal[]);
464
- try {
465
- return (await Promise.race([
466
- generateObject({
467
- messages,
468
- ...config,
469
- abortSignal: combinedSignal,
470
- }),
471
- rejectAfterIdle(timeout, cancel, controller),
472
- ])) as any;
473
- } finally {
474
- cancel.cancelled = true;
475
- }
432
+ return (await this.raceWithIdleTimeout((signal) => generateObject({ messages, ...config, abortSignal: signal }), config.timeout || 30000)) as any;
476
433
  }, this.getRetryOptions(options));
477
434
 
478
435
  clearActivity();
479
436
  responseLog(response.object);
480
437
 
481
- if (response.usage) {
482
- Stats.recordTokens(options.agentName || 'unknown', modelName, {
483
- input: response.usage.inputTokens ?? response.usage.promptTokens ?? 0,
484
- output: response.usage.outputTokens ?? response.usage.completionTokens ?? 0,
485
- total: response.usage.totalTokens ?? 0,
486
- cached: extractCachedTokens(response.usage),
487
- });
488
- }
438
+ this.recordUsage(options.agentName || 'unknown', modelName, response.usage);
489
439
 
490
440
  return response;
491
441
  } catch (error: any) {
@@ -493,12 +443,7 @@ export class Provider {
493
443
  if (error?.name === 'AbortError') throw error;
494
444
  if (error instanceof ContextLengthError) throw error;
495
445
  if (Provider.isContextLengthError(error)) {
496
- const reduced = this.tryReduceMessages(messages, options._contextRetryLevel || 0);
497
- if (reduced) {
498
- tag('warning').log('Context length exceeded, retrying with reduced messages...');
499
- return this.generateObject(reduced.messages, schema, model, { ...options, _contextRetryLevel: reduced.nextLevel });
500
- }
501
- throw new ContextLengthError(error.message || error.toString());
446
+ return this.recoverFromContextLength(error, messages, options, (m, o) => this.generateObject(m, schema, model, o));
502
447
  }
503
448
  throw new AiError(error.message || error.toString());
504
449
  }
@@ -506,7 +451,7 @@ export class Provider {
506
451
 
507
452
  static isContextLengthError(error: any): boolean {
508
453
  const msg = (error?.message || error?.toString() || '').toLowerCase();
509
- return Provider.CONTEXT_LENGTH_PATTERNS.some((p) => msg.includes(p));
454
+ return CONTEXT_LENGTH_PATTERNS.some((p) => msg.includes(p));
510
455
  }
511
456
 
512
457
  static trimMessagesForRetry(messages: ModelMessage[]): ModelMessage[] | null {
@@ -677,13 +622,7 @@ export class Provider {
677
622
  clearActivity();
678
623
  responseLog(response.text);
679
624
 
680
- if (response.usage) {
681
- Stats.recordTokens('vision', this.getModelName(this.config.visionModel), {
682
- input: response.usage.inputTokens ?? response.usage.promptTokens ?? 0,
683
- output: response.usage.outputTokens ?? response.usage.completionTokens ?? 0,
684
- total: response.usage.totalTokens ?? 0,
685
- });
686
- }
625
+ this.recordUsage('vision', this.getModelName(this.config.visionModel), response.usage);
687
626
 
688
627
  return response;
689
628
  } catch (error: any) {