explorbot 0.4.6 → 0.4.8

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 (74) hide show
  1. package/boat/api-tester/src/ai/chief.ts +3 -1
  2. package/boat/api-tester/src/ai/curler.ts +74 -66
  3. package/boat/api-tester/src/apibot.ts +1 -0
  4. package/boat/api-tester/src/cli.ts +2 -0
  5. package/boat/api-tester/src/config.ts +18 -1
  6. package/dist/boat/api-tester/src/ai/chief.js +3 -1
  7. package/dist/boat/api-tester/src/ai/curler.js +59 -56
  8. package/dist/boat/api-tester/src/apibot.js +1 -0
  9. package/dist/boat/api-tester/src/cli.js +2 -0
  10. package/dist/boat/api-tester/src/config.js +3 -1
  11. package/dist/package.json +2 -2
  12. package/dist/rules/chief/general.md +2 -0
  13. package/dist/rules/researcher/pagination.md +7 -0
  14. package/dist/src/action-result.d.ts +6 -0
  15. package/dist/src/action-result.js +12 -0
  16. package/dist/src/action.js +3 -2
  17. package/dist/src/ai/navigator.js +1 -4
  18. package/dist/src/ai/pilot.js +8 -12
  19. package/dist/src/ai/planner/session-dedup.d.ts +2 -1
  20. package/dist/src/ai/planner/session-dedup.js +18 -1
  21. package/dist/src/ai/planner.js +12 -4
  22. package/dist/src/ai/provider.js +18 -4
  23. package/dist/src/ai/researcher/locators.js +1 -1
  24. package/dist/src/ai/researcher/pagination.d.ts +16 -0
  25. package/dist/src/ai/researcher/pagination.js +62 -0
  26. package/dist/src/ai/researcher/parser.d.ts +3 -0
  27. package/dist/src/ai/researcher/parser.js +22 -6
  28. package/dist/src/ai/researcher/sections.js +1 -1
  29. package/dist/src/ai/researcher.js +7 -2
  30. package/dist/src/ai/rules.js +17 -0
  31. package/dist/src/ai/scout.js +8 -2
  32. package/dist/src/ai/tester.js +1 -1
  33. package/dist/src/ai/tools.js +12 -4
  34. package/dist/src/commands/options/ws-option.d.ts +7 -0
  35. package/dist/src/commands/options/ws-option.js +14 -0
  36. package/dist/src/config.d.ts +1 -0
  37. package/dist/src/config.js +14 -11
  38. package/dist/src/remote.d.ts +2 -0
  39. package/dist/src/remote.js +23 -16
  40. package/dist/src/utils/aria.d.ts +2 -0
  41. package/dist/src/utils/aria.js +6 -1
  42. package/dist/src/utils/code-extractor.js +6 -2
  43. package/dist/src/utils/markdown-query.d.ts +2 -0
  44. package/dist/src/utils/markdown-query.js +39 -0
  45. package/dist/src/utils/pagination.d.ts +16 -0
  46. package/dist/src/utils/pagination.js +20 -0
  47. package/docs/superpowers/plans/2026-09-10-pagination.md +1420 -0
  48. package/docs/superpowers/specs/2026-09-09-pagination-rule-design.md +125 -97
  49. package/package.json +2 -2
  50. package/rules/chief/general.md +2 -0
  51. package/rules/researcher/pagination.md +7 -0
  52. package/src/action-result.ts +16 -0
  53. package/src/action.ts +3 -2
  54. package/src/ai/navigator.ts +1 -4
  55. package/src/ai/pilot.ts +8 -12
  56. package/src/ai/planner/session-dedup.ts +16 -2
  57. package/src/ai/planner.ts +12 -4
  58. package/src/ai/provider.ts +18 -3
  59. package/src/ai/researcher/locators.ts +1 -1
  60. package/src/ai/researcher/pagination.ts +68 -0
  61. package/src/ai/researcher/parser.ts +23 -5
  62. package/src/ai/researcher/sections.ts +1 -1
  63. package/src/ai/researcher.ts +9 -3
  64. package/src/ai/rules.ts +17 -0
  65. package/src/ai/scout.ts +9 -2
  66. package/src/ai/tester.ts +1 -1
  67. package/src/ai/tools.ts +9 -4
  68. package/src/commands/options/ws-option.ts +14 -0
  69. package/src/config.ts +15 -11
  70. package/src/remote.ts +22 -15
  71. package/src/utils/aria.ts +8 -1
  72. package/src/utils/code-extractor.ts +6 -2
  73. package/src/utils/markdown-query.ts +39 -0
  74. package/src/utils/pagination.ts +36 -0
@@ -1,4 +1,4 @@
1
- import type { Plan } from '../../test-plan.js';
1
+ import { type Plan, type Test } from '../../test-plan.js';
2
2
  import type { Constructor } from '../researcher/mixin.js';
3
3
  export declare function WithSessionDedup<T extends Constructor>(Base: T): {
4
4
  new (...args: any[]): {
@@ -9,4 +9,5 @@ export declare function WithSessionDedup<T extends Constructor>(Base: T): {
9
9
  getPreviousSessionScenariosExcluding(plan: Plan): Set<string>;
10
10
  };
11
11
  } & T;
12
+ export declare function formatSessionTest(plan: Plan, test: Test): string;
12
13
  export declare function clearSessionDedup(): void;
@@ -1,3 +1,4 @@
1
+ import { TestResult } from "../../test-plan.js";
1
2
  const previousPlans = [];
2
3
  export function WithSessionDedup(Base) {
3
4
  return class extends Base {
@@ -12,7 +13,7 @@ export function WithSessionDedup(Base) {
12
13
  if (plan === this.currentPlan)
13
14
  continue;
14
15
  for (const test of plan.tests) {
15
- lines.push(`${plan.url || '/'} | ${test.style || 'default'} | ${test.scenario}`);
16
+ lines.push(formatSessionTest(plan, test));
16
17
  }
17
18
  }
18
19
  return lines.join('\n');
@@ -25,6 +26,22 @@ export function WithSessionDedup(Base) {
25
26
  }
26
27
  };
27
28
  }
29
+ export function formatSessionTest(plan, test) {
30
+ const lastNote = Object.values(test.notes)
31
+ .filter((note) => note.message)
32
+ .pop();
33
+ let outcome = test.result;
34
+ if (!outcome)
35
+ outcome = 'pending';
36
+ if (!test.result && lastNote)
37
+ outcome = 'unfinished';
38
+ const line = `${plan.url || '/'} | ${test.style || 'default'} | ${outcome} | ${test.scenario}`;
39
+ if (!lastNote)
40
+ return line;
41
+ if (outcome !== TestResult.FAILED && outcome !== 'unfinished')
42
+ return line;
43
+ return `${line} — ${lastNote.message.slice(0, 140)}`;
44
+ }
28
45
  export function clearSessionDedup() {
29
46
  previousPlans.length = 0;
30
47
  }
@@ -316,8 +316,10 @@ export class Planner extends PlannerBase {
316
316
  You can suggest scenarios that can be tested only through web interface.
317
317
  You can't test emails, database, SMS, or any external services.
318
318
  Suggest scenarios that can be potentially verified by UI.
319
- Focus on error or success messages as outcome.
320
- Focus on URL page change or data persistency after page reload.
319
+ Prefer outcomes grounded in observed interface behavior.
320
+ Every expected outcome must be verifiable through the web interface.
321
+ If a page or subpage has not been observed, describe the expected visible result generically instead of inventing interface details.
322
+ Persistency after a reload counts only when the persisted state can be confirmed through the interface.
321
323
  If there are subpages (pages with same URL path) plan testing of those subpages as well
322
324
  Plan CRUD operations in order: create, read, update, delete.
323
325
  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.
@@ -328,7 +330,7 @@ export class Planner extends PlannerBase {
328
330
  If a scenario needs existing records, recipients, results, notifications, or other target data, propose it only when that data is visible, API preconditions can create it, or the scenario itself creates the record as its setup.
329
331
  If the page appears read-only, degraded, demo-limited, maintenance-like, or lacks write controls, prefer read-only scenarios such as opening panels, inspecting visible lists, filtering, searching, or verifying current state.
330
332
  Do not assume hidden data exists just because a control is present.
331
- For scenarios that act on existing items or search/filter by existing values, use only item names or values visible in research, visited pages, or prior observed flows.
333
+ Do not put record IDs or unique record names in test plans. Describe which record is needed and let Pilot choose it during execution; name a specific record only when research shows a small, complete list of available records.
332
334
  If the list is empty or no concrete item names are visible, do not invent "known" or "existing" items. Prefer empty-state, no-match search, clear-search, or read-only list behavior scenarios.
333
335
  Search, filter, sorting, tab, and list scenarios must start from a stable page where those controls are visible; avoid transient create/edit/new URLs unless the scenario tests that form.
334
336
  For option values and list items, use only visible or previously observed data; do not add create/update/delete setup unless the user explicitly requests that workflow.
@@ -404,12 +406,16 @@ export class Planner extends PlannerBase {
404
406
  `);
405
407
  }
406
408
  }
409
+ let activeRegion = '';
410
+ if (state.overlay.isOpen)
411
+ activeRegion = `Active region: ${state.overlay.describe()} — the user's current focus area. Plan tests for the controls inside it first.`;
407
412
  conversation.addUserText(dedent `
408
413
  ${this.buildApproach(style)}
409
414
 
410
415
  <context>
411
416
  URL: ${state.url || 'Unknown'}
412
417
  Title: ${state.title || 'Unknown'}
418
+ ${activeRegion}
413
419
  </context>
414
420
  `);
415
421
  if (this.fisherman) {
@@ -536,7 +542,9 @@ export class Planner extends PlannerBase {
536
542
  const sessionTests = this.getSessionTestsSummary();
537
543
  if (sessionTests) {
538
544
  conversation.addUserText(dedent `
539
- Tests already planned in this session across all pages. DO NOT duplicate any of these:
545
+ Tests already planned in this session across all pages, with how each one ended. DO NOT duplicate any of these.
546
+ A failed test means the app or the harness could not do what it tried: do not re-propose the same behavior on another page unless you can name what makes it work this time.
547
+ A failed or unfinished test carries the last thing it observed after the dash — read it before deciding that the behavior is worth trying again.
540
548
 
541
549
  <session_tests>
542
550
  ${sessionTests}
@@ -32,6 +32,11 @@ function createHarmonyChannelFallbackTool() {
32
32
  execute: async () => ({ message: 'Noted. Continue with your next action.' }),
33
33
  });
34
34
  }
35
+ function withHarmonyChannelFallback(tools) {
36
+ if (tools?.commentary)
37
+ return tools;
38
+ return { ...tools, commentary: createHarmonyChannelFallbackTool() };
39
+ }
35
40
  let telemetryRegistered = false;
36
41
  let beforeExitFlushHooked = false;
37
42
  let activeOtelSdk = null;
@@ -367,8 +372,8 @@ export class Provider {
367
372
  setActivity(`🤖 Asking ${modelName} with dynamic tools`, 'ai');
368
373
  promptLog(`Using model: ${modelName}`);
369
374
  let toolsWithCommentary = tools;
370
- if (!tools?.commentary && options.toolChoice !== 'required')
371
- toolsWithCommentary = { ...tools, commentary: createHarmonyChannelFallbackTool() };
375
+ if (options.toolChoice !== 'required')
376
+ toolsWithCommentary = withHarmonyChannelFallback(tools);
372
377
  const toolNames = Object.keys(toolsWithCommentary || {});
373
378
  tag('debug').log(`Tools enabled: [${toolNames.join(', ')}]`);
374
379
  promptLog('Available tools:', toolNames);
@@ -378,9 +383,10 @@ export class Provider {
378
383
  const stopConditions = [isStepCount(maxRoundtrips)];
379
384
  if (extraStop)
380
385
  stopConditions.push(extraStop);
381
- const config = this.buildGenerateConfig({ tools: toolsWithCommentary, maxOutputTokens: 16384, toolChoice: 'auto', experimental_repairToolCall: repairToolCall }, { stopWhen: stopConditions, model }, options);
386
+ let config = this.buildGenerateConfig({ tools: toolsWithCommentary, maxOutputTokens: 16384, toolChoice: 'auto', experimental_repairToolCall: repairToolCall }, { stopWhen: stopConditions, model }, options);
382
387
  let attemptMessages = messages;
383
388
  let invalidRequestFeedbackAdded = false;
389
+ let requiredToolChoiceRelaxed = false;
384
390
  const executedStepMessages = [];
385
391
  try {
386
392
  let response = await this.withModelRequestSlot(() => withRetry(async () => {
@@ -399,6 +405,11 @@ export class Provider {
399
405
  invalidRequestFeedbackAdded = amended !== attemptMessages;
400
406
  attemptMessages = amended;
401
407
  }
408
+ if (!requiredToolChoiceRelaxed && isRequiredToolChoiceError(error)) {
409
+ requiredToolChoiceRelaxed = true;
410
+ config = { ...config, tools: withHarmonyChannelFallback(tools), toolChoice: 'auto' };
411
+ tag('warning').log('Provider rejected required tool choice — retrying with automatic tool choice and channel fallback');
412
+ }
402
413
  throw error;
403
414
  }));
404
415
  this.recordUsage(options.agentName || 'unknown', modelName, result.usage);
@@ -422,7 +433,7 @@ export class Provider {
422
433
  }
423
434
  catch (error) {
424
435
  clearActivity();
425
- if (error?.message?.includes('Tool choice is required')) {
436
+ if (isRequiredToolChoiceError(error)) {
426
437
  return { text: '', toolCalls: [], toolResults: [], responseMessages: executedStepMessages, usage: null };
427
438
  }
428
439
  if (error?.name === 'AbortError')
@@ -659,6 +670,9 @@ function withInvalidRequestFeedback(messages, error) {
659
670
  },
660
671
  ];
661
672
  }
673
+ function isRequiredToolChoiceError(error) {
674
+ return error instanceof Error && error.message.includes('Tool choice is required');
675
+ }
662
676
  function repairChannelMarker({ toolCall, tools }) {
663
677
  const markerIndex = toolCall.toolName.indexOf('<|channel|>');
664
678
  if (markerIndex <= 0)
@@ -281,7 +281,7 @@ export function WithLocators(Base) {
281
281
  if (sectionQuery.count() === 0)
282
282
  sectionQuery = mdq(result.text).query(`section3(~"${escaped}")`);
283
283
  if (newCss) {
284
- result.text = sectionQuery.query('blockquote[0]').replace(`Container: '${newCss}'`);
284
+ result.text = sectionQuery.query('blockquote[0]').setKeyValue('Container', `'${newCss}'`);
285
285
  }
286
286
  else {
287
287
  result.text = sectionQuery.query('blockquote[0]').replace('');
@@ -0,0 +1,16 @@
1
+ import type Explorer from '../../explorer.js';
2
+ import { type ListMeasure, type PaginationStrategy } from '../../utils/pagination.js';
3
+ import { type Constructor } from './mixin.js';
4
+ import type { ResearchResult } from './research-result.js';
5
+ export declare function WithPagination<T extends Constructor>(Base: T): {
6
+ new (...args: any[]): {
7
+ explorer: Explorer;
8
+ detectPagination(result: ResearchResult): Promise<void>;
9
+ probeSection(css: string): Promise<PaginationStrategy | null>;
10
+ measure(css: string): Promise<ListMeasure | null>;
11
+ recordPagination(result: ResearchResult, name: string, strategy: PaginationStrategy): void;
12
+ };
13
+ } & T;
14
+ export interface PaginationMethods {
15
+ detectPagination(result: ResearchResult): Promise<void>;
16
+ }
@@ -0,0 +1,62 @@
1
+ import { mdq } from "../../utils/markdown-query.js";
2
+ import { inspectList, restoreScroll } from "../../utils/pagination.js";
3
+ import { debugLog } from "./mixin.js";
4
+ import { extractPaginationFromBlockquote, parseDataSections, parseResearchSections } from "./parser.js";
5
+ export function WithPagination(Base) {
6
+ return class extends Base {
7
+ async detectPagination(result) {
8
+ const sections = [...parseResearchSections(result.text), ...parseDataSections(result.text)];
9
+ for (const section of sections) {
10
+ const css = section.containerCss;
11
+ if (!css)
12
+ continue;
13
+ if (extractPaginationFromBlockquote(section.rawMarkdown))
14
+ continue;
15
+ const strategy = await this.probeSection(css);
16
+ if (!strategy)
17
+ continue;
18
+ this.recordPagination(result, section.name, strategy);
19
+ debugLog(`Pagination in "${section.name}": ${strategy}`);
20
+ }
21
+ }
22
+ async probeSection(css) {
23
+ const before = await this.measure(css);
24
+ if (!before)
25
+ return null;
26
+ if (before.hasPagingControls)
27
+ return 'controls';
28
+ if (before.isFeed)
29
+ return 'infinite';
30
+ if (!before.scrolls)
31
+ return null;
32
+ const action = this.explorer.action();
33
+ const scrolled = await action.attempt(`I.scrollTo('${css} > *:last-child')`).catch(() => false);
34
+ if (!scrolled)
35
+ return null;
36
+ const after = await this.measure(css);
37
+ await this.explorer.withPage((page) => page.evaluate(restoreScroll, { css, scrollTop: before.scrollTop, pageScrollY: before.pageScrollY })).catch(() => { });
38
+ if (!after)
39
+ return null;
40
+ if (after.items > before.items)
41
+ return 'infinite';
42
+ return null;
43
+ }
44
+ measure(css) {
45
+ return this.explorer
46
+ .withPage((page) => page.evaluate(inspectList, css))
47
+ .catch((err) => {
48
+ debugLog(`List measurement failed for '${css}': ${err.message}`);
49
+ return null;
50
+ });
51
+ }
52
+ recordPagination(result, name, strategy) {
53
+ const escaped = name.replace(/"/g, '\\"');
54
+ let sectionQuery = mdq(result.text).query(`section2(~"${escaped}")`);
55
+ if (sectionQuery.count() === 0)
56
+ sectionQuery = mdq(result.text).query(`section3(~"${escaped}")`);
57
+ if (sectionQuery.count() === 0)
58
+ return;
59
+ result.text = sectionQuery.query('blockquote[0]').setKeyValue('Pagination', strategy);
60
+ }
61
+ };
62
+ }
@@ -1,3 +1,4 @@
1
+ import type { PaginationStrategy } from '../../utils/pagination.js';
1
2
  export interface ResearchElement {
2
3
  name: string;
3
4
  type: string | null;
@@ -23,6 +24,8 @@ export declare const RESEARCH_COLUMN_ORDER: string[];
23
24
  export declare function mapRowToElement(row: Record<string, string>): ResearchElement | null;
24
25
  export declare function extractContainerFromBlockquote(sectionMarkdown: string): string | null;
25
26
  export declare function parseResearchSections(markdown: string): ResearchSection[];
27
+ export declare function parseDataSections(markdown: string): ResearchSection[];
28
+ export declare function extractPaginationFromBlockquote(sectionMarkdown: string): PaginationStrategy | null;
26
29
  export declare function extractValidContainers(researchText: string, opts?: {
27
30
  exclude?: string[];
28
31
  }): Array<{
@@ -57,13 +57,10 @@ export function mapRowToElement(row) {
57
57
  };
58
58
  }
59
59
  export function extractContainerFromBlockquote(sectionMarkdown) {
60
- const bq = mdq(sectionMarkdown).query('blockquote[0]').text().trim();
61
- if (!bq)
60
+ const entry = mdq(sectionMarkdown).query('blockquote[0]').keyValue().container;
61
+ if (!entry)
62
62
  return null;
63
- const match = bq.match(/Container:\s*(.+)/i);
64
- if (!match)
65
- return null;
66
- const css = normalizeLocatorValue(match[1]);
63
+ const css = normalizeLocatorValue(entry);
67
64
  if (!css || !/^[.#\[\w]/.test(css))
68
65
  return null;
69
66
  return css;
@@ -80,6 +77,25 @@ export function parseResearchSections(markdown) {
80
77
  return { name: section.name, containerCss, elements, rawMarkdown: section.rawMarkdown, isExtended };
81
78
  });
82
79
  }
80
+ export function parseDataSections(markdown) {
81
+ return parseSections(markdown)
82
+ .filter((s) => s.name.toLowerCase().startsWith('data:'))
83
+ .map((section) => ({
84
+ name: section.name,
85
+ containerCss: extractContainerFromBlockquote(section.rawMarkdown),
86
+ elements: [],
87
+ rawMarkdown: section.rawMarkdown,
88
+ isExtended: false,
89
+ }));
90
+ }
91
+ export function extractPaginationFromBlockquote(sectionMarkdown) {
92
+ const value = mdq(sectionMarkdown).query('blockquote[0]').keyValue().pagination?.toLowerCase();
93
+ if (value === 'controls')
94
+ return 'controls';
95
+ if (value === 'infinite')
96
+ return 'infinite';
97
+ return null;
98
+ }
83
99
  export function extractValidContainers(researchText, opts) {
84
100
  const exclude = opts?.exclude || [];
85
101
  return parseResearchSections(researchText)
@@ -63,7 +63,7 @@ export function WithSections(Base) {
63
63
  }
64
64
  async _researchSingleSection(name, description, ariaSnapshot, focusCss) {
65
65
  const currentUrl = this.stateManager.getCurrentState()?.url || '';
66
- const rules = RulesLoader.loadRules('researcher', ['ui-map-table', 'list-element', 'container-rules'], currentUrl);
66
+ const rules = RulesLoader.loadRules('researcher', ['ui-map-table', 'list-element', 'container-rules', 'pagination'], currentUrl);
67
67
  const url = this.actionResult?.url || 'Unknown';
68
68
  const title = this.actionResult?.title || 'Unknown';
69
69
  let focusHint = '';
@@ -18,6 +18,7 @@ import { WithCoordinates } from "./researcher/coordinates.js";
18
18
  import { WithDeepAnalysis } from "./researcher/deep-analysis.js";
19
19
  import { detectFocusedSection, hasFocusedSection, markSectionAsFocused, pickDefaultFocusedSection } from "./researcher/focus.js";
20
20
  import { WithLocators } from "./researcher/locators.js";
21
+ import { WithPagination } from "./researcher/pagination.js";
21
22
  import { extractValidContainers, formatResearchSummary, parseResearchSections } from "./researcher/parser.js";
22
23
  import { ResearchResult } from "./researcher/research-result.js";
23
24
  import { WithSections } from "./researcher/sections.js";
@@ -33,7 +34,7 @@ export const POSSIBLE_SECTIONS = {
33
34
  menu: 'page menu (toolbar, context actions, filters, dropdowns)',
34
35
  navigation: 'main navigation (top bar, sidebar, breadcrumbs)',
35
36
  };
36
- const ResearcherBase = WithSections(WithDeepAnalysis(WithCoordinates(WithLocators(TaskAgent))));
37
+ const ResearcherBase = WithSections(WithPagination(WithDeepAnalysis(WithCoordinates(WithLocators(TaskAgent)))));
37
38
  export class Researcher extends ResearcherBase {
38
39
  ACTION_TOOLS = ['click'];
39
40
  emoji = '🔍';
@@ -230,6 +231,9 @@ export class Researcher extends ResearcherBase {
230
231
  await this.backfillCoordinates(result);
231
232
  await this.backfillBrokenLocators(result);
232
233
  }
234
+ if (!interrupted()) {
235
+ await this.detectPagination(result);
236
+ }
233
237
  // Focused section: final fallback (vision-only — without a screenshot we don't infer focus)
234
238
  if (this.hasScreenshotToAnalyze && !hasFocusedSection(result.text)) {
235
239
  const sections = parseResearchSections(result.text);
@@ -366,7 +370,7 @@ export class Researcher extends ResearcherBase {
366
370
 
367
371
  ${generalLocatorRuleText}
368
372
 
369
- ${RulesLoader.loadRules('researcher', ['ui-map-table', 'list-element', 'container-rules'], currentUrl)}
373
+ ${RulesLoader.loadRules('researcher', ['ui-map-table', 'list-element', 'container-rules', 'pagination'], currentUrl)}
370
374
 
371
375
  <section_identification>
372
376
  Identify page sections in this priority order:
@@ -439,6 +443,7 @@ export class Researcher extends ResearcherBase {
439
443
  - When a section contains a list of similar data items (records, entities, rows — content that varies by data, not by app UI), output it as a Data section with NO table.
440
444
  - Data section heading MUST be a level-2 heading (##) that starts exactly with "Data:" — for example: "## Data: Suites List". Do NOT use ### or add section numbers.
441
445
  - Data sections must NOT include a UI map table. Only include the container and a brief summary line.
446
+ - When the data list has controls that move between pages of the collection, add "> Pagination: controls" under its container.
442
447
  - Example data section:
443
448
 
444
449
  ## Data: Suites List
@@ -177,6 +177,7 @@ export const capabilityGroundingRule = dedent `
177
177
  When a scenario depends on a named action, menu item, status, option, workflow, or feature,
178
178
  that capability must be visible or explicitly confirmed in the current research/page context
179
179
  for the same target entity type.
180
+ Ground on the scenario's outcome, not a planned step's control label — a missing label is not a missing capability.
180
181
 
181
182
  Do not transfer capabilities between similar entities, rows, lists, detail pages, or menus.
182
183
  Do not replace a requested action with a synonym or related action unless the UI explicitly
@@ -332,6 +333,22 @@ export const actionRule = dedent `
332
333
  For checkboxes, prefer I.checkOption/I.uncheckOption over I.click.
333
334
 
334
335
 
336
+ ### I.scrollTo
337
+
338
+ scrolls until the element is in view
339
+
340
+ I.scrollTo(<locator>)
341
+
342
+ Scrolls every scrollable ancestor of the target, so it reaches an element inside a container
343
+ that has its own scrollbar. I.scrollPageToBottom() moves only the page itself.
344
+
345
+ <example>
346
+ I.scrollTo('.rows > *:last-child');
347
+ I.scrollTo({ role: 'listitem', text: 'Last entry' });
348
+ I.scrollPageToBottom();
349
+ </example>
350
+
351
+
335
352
  ### I.fillField
336
353
 
337
354
  fills the field with the given value
@@ -47,12 +47,18 @@ export class Scout {
47
47
  maxToolRoundtrips: MAX_TOOL_ROUNDTRIPS,
48
48
  agentName: 'scout',
49
49
  });
50
+ const responseText = invokeResult?.response?.text;
51
+ if (responseText?.trim()) {
52
+ finishFromText(responseText);
53
+ stop();
54
+ return;
55
+ }
50
56
  if (!invokeResult?.toolExecutions?.length) {
51
- finishFromText(invokeResult?.response?.text);
52
57
  stop();
53
58
  return;
54
59
  }
55
- if (iteration >= MAX_ITERATIONS) {
60
+ if (iteration >= MAX_ITERATIONS - 1) {
61
+ conversation.addUserText('Exploration time is over. Report your findings now as your final message.');
56
62
  const final = await this.provider.invokeConversation(conversation, undefined, { agentName: 'scout' });
57
63
  finishFromText(final?.response?.text);
58
64
  stop();
@@ -29,7 +29,7 @@ const SAMPLE_FILES = {
29
29
  };
30
30
  export class Tester extends TaskAgent {
31
31
  ACTION_TOOLS = ['click', 'hover', 'pressKey', 'form'];
32
- DELEGATED_ACTION_TOOLS = ['interact'];
32
+ DELEGATED_ACTION_TOOLS = ['interact', 'visualClick'];
33
33
  SPECIAL_CONTEXT_ACTION_TOOLS = ['exitIframe'];
34
34
  emoji = '🧪';
35
35
  requestStore;
@@ -311,6 +311,8 @@ export function createCodeceptJSTools({ explorer, stateManager }, task) {
311
311
  - Working with iframes (switch context with I.switchTo)
312
312
  - Performing multiple form actions in a single batch
313
313
  - Complex interactions requiring sequential commands
314
+ - Reaching items further down a list (I.scrollTo)
315
+ - Reloading the page to prove a change outlived it (I.reloadPage)
314
316
 
315
317
  Example - filling a form with context (PREFERRED):
316
318
  I.fillField('Username', 'John', '.login-form')
@@ -323,7 +325,7 @@ export function createCodeceptJSTools({ explorer, stateManager }, task) {
323
325
  I.selectOption({"role":"combobox","text":"Category"}, 'Technology')
324
326
 
325
327
  Do not submit form - use verify() first to check fields were filled correctly, then click() to submit.
326
- Do not use: wait functions, amOnPage, reloadPage, saveScreenshot
328
+ Do not use: wait functions, amOnPage, saveScreenshot
327
329
  `,
328
330
  inputSchema: z.object({
329
331
  codeBlock: z.string().describe('Valid CodeceptJS code starting with I. Can contain multiple commands separated by newlines.'),
@@ -365,10 +367,10 @@ export function createCodeceptJSTools({ explorer, stateManager }, task) {
365
367
  }
366
368
  if (!hasObservablePageChange(toolResult)) {
367
369
  activeNote.commit(TestResult.FAILED);
368
- return failedToolResult('form', 'Form command executed, but no observable page or form-state change was captured.', {
370
+ return failedToolResult('form', 'Command executed, but nothing on the page changed: no navigation, no ARIA change, no HTML change and no request.', {
369
371
  ...toolResult,
370
372
  code: codeBlock,
371
- suggestion: 'Treat the field/form action as not completed. Re-locate the editable control, check whether another UI layer is active, then retry and verify the field value before submitting.',
373
+ suggestion: 'The command ran without reaching anything. Re-locate the target, check whether another UI layer is active, then retry. If the goal was to load more of a list, no change means the collection has ended.',
372
374
  });
373
375
  }
374
376
  await commitNote(activeNote, TestResult.PASSED, toolResult, action);
@@ -1080,7 +1082,13 @@ export function successToolResult(action, data, source) {
1080
1082
  return result;
1081
1083
  }
1082
1084
  export function isMajorPageChange(pageDiff) {
1083
- return pageDiff.urlChanged !== true && (pageDiff.ariaChangeCount ?? 0) >= LARGE_ARIA_CHANGE_THRESHOLD;
1085
+ if (pageDiff.urlChanged === true)
1086
+ return false;
1087
+ if ((pageDiff.ariaChangeCount ?? 0) < LARGE_ARIA_CHANGE_THRESHOLD)
1088
+ return false;
1089
+ if (pageDiff.ariaRemoved === 0 && (pageDiff.ariaAdded ?? 0) > 0)
1090
+ return false;
1091
+ return true;
1084
1092
  }
1085
1093
  export function hasFailedRequest(pageDiff) {
1086
1094
  return (pageDiff.requests ?? []).some((request) => request.status >= 400);
@@ -3,5 +3,12 @@ import { BaseOption } from './base-option.js';
3
3
  export declare class WsOption extends BaseOption {
4
4
  flags: string;
5
5
  description: string;
6
+ /**
7
+ * An open socket holds the event loop, and `WebSocket` has no `unref` on
8
+ * either runtime — so a command that ends by returning rather than through
9
+ * `showStatsAndExit` would never exit once it is attached. The option that
10
+ * opened the connection is what closes it.
11
+ */
12
+ register(command: Command): void;
6
13
  apply(options: Record<string, any>, command: Command): void;
7
14
  }
@@ -3,6 +3,20 @@ import { BaseOption } from './base-option.js';
3
3
  export class WsOption extends BaseOption {
4
4
  flags = '--ws <url>';
5
5
  description = 'Stream this run to a remote UI over WebSocket';
6
+ /**
7
+ * An open socket holds the event loop, and `WebSocket` has no `unref` on
8
+ * either runtime — so a command that ends by returning rather than through
9
+ * `showStatsAndExit` would never exit once it is attached. The option that
10
+ * opened the connection is what closes it.
11
+ */
12
+ register(command) {
13
+ super.register(command);
14
+ command.hook('postAction', async () => {
15
+ if (!remote.isAttached())
16
+ return;
17
+ await remote.close(0);
18
+ });
19
+ }
6
20
  apply(options, command) {
7
21
  const url = options.ws || process.env.EXPLORBOT_WS_URL;
8
22
  if (!url)
@@ -287,6 +287,7 @@ export declare function modelName(model: unknown): string;
287
287
  export declare function modelProvider(model: unknown): string;
288
288
  export declare function configuredModels(ai?: AIConfig): Record<string, ConfiguredModel>;
289
289
  export declare function resolveConfigModels(ai?: AIConfig): Promise<void>;
290
+ export declare function resolveLangfuse(ai?: AIConfig): void;
290
291
  export declare function resolveOutputRoot(baseUrl?: string): string;
291
292
  export declare function resolveStateRoot(baseUrl: string, ephemeral?: boolean): string;
292
293
  export declare function materializeKnowledge(outputRoot: string): void;
@@ -377,17 +377,7 @@ export class ConfigParser {
377
377
  config.playwright = config.playwright || { browser: 'chromium', url: '' };
378
378
  config.playwright.url = options.baseUrl;
379
379
  }
380
- if (config.ai) {
381
- const langfuse = config.ai.langfuse;
382
- const publicKey = langfuse?.publicKey || process.env.LANGFUSE_PUBLIC_KEY;
383
- const secretKey = langfuse?.secretKey || process.env.LANGFUSE_SECRET_KEY;
384
- config.ai.langfuse = {
385
- enabled: langfuse?.enabled ?? Boolean(publicKey && secretKey),
386
- publicKey,
387
- secretKey,
388
- baseUrl: langfuse?.baseUrl || process.env.LANGFUSE_BASE_URL || process.env.LANGFUSE_HOST,
389
- };
390
- }
380
+ resolveLangfuse(config.ai);
391
381
  return config;
392
382
  }
393
383
  validateConfig(config) {
@@ -540,6 +530,19 @@ export async function resolveConfigModels(ai) {
540
530
  agent.model = await resolveModel(agent.model);
541
531
  }
542
532
  }
533
+ export function resolveLangfuse(ai) {
534
+ if (!ai)
535
+ return;
536
+ const langfuse = ai.langfuse;
537
+ const publicKey = langfuse?.publicKey || process.env.LANGFUSE_PUBLIC_KEY;
538
+ const secretKey = langfuse?.secretKey || process.env.LANGFUSE_SECRET_KEY;
539
+ ai.langfuse = {
540
+ enabled: langfuse?.enabled ?? Boolean(publicKey && secretKey),
541
+ publicKey,
542
+ secretKey,
543
+ baseUrl: langfuse?.baseUrl || process.env.LANGFUSE_BASE_URL || process.env.LANGFUSE_HOST,
544
+ };
545
+ }
543
546
  export function resolveOutputRoot(baseUrl) {
544
547
  if (cachedOutputRoot)
545
548
  return cachedOutputRoot;
@@ -18,6 +18,7 @@ export declare class Remote implements LogDestination {
18
18
  asks: Map<string, (value: string | null) => void>;
19
19
  askCounter: number;
20
20
  lastActivity: string | null;
21
+ closing: Promise<void> | null;
21
22
  attach(url: string, command: string): void;
22
23
  isAttached(): boolean;
23
24
  send(type: string, data?: Record<string, unknown>): void;
@@ -30,6 +31,7 @@ export declare class Remote implements LogDestination {
30
31
  * back out of flattened text.
31
32
  */
32
33
  write(entry: TaggedLogEntry): void;
34
+ shutdown(exitCode: number): Promise<void>;
33
35
  connect(): void;
34
36
  reconnect(): void;
35
37
  flush(): Promise<void>;
@@ -26,6 +26,7 @@ export class Remote {
26
26
  asks = new Map();
27
27
  askCounter = 0;
28
28
  lastActivity = null;
29
+ closing = null;
29
30
  attach(url, command) {
30
31
  if (this.url)
31
32
  return;
@@ -68,23 +69,12 @@ export class Remote {
68
69
  });
69
70
  });
70
71
  }
71
- async close(exitCode) {
72
+ close(exitCode) {
72
73
  if (!this.url)
73
- return;
74
- this.send('result', { ok: exitCode === 0, exitCode });
75
- await this.flush();
76
- this.url = null;
77
- if (this.reconnectTimer)
78
- clearTimeout(this.reconnectTimer);
79
- // Whoever asks next has nobody to ask — leaving the callback installed would
80
- // route them into a closed socket and park them until the ask times out.
81
- executionController.clearInputCallback();
82
- for (const resolve of this.asks.values())
83
- resolve(null);
84
- this.asks.clear();
85
- this.queue = [];
86
- this.socket?.close();
87
- this.socket = null;
74
+ return Promise.resolve();
75
+ if (!this.closing)
76
+ this.closing = this.shutdown(exitCode);
77
+ return this.closing;
88
78
  }
89
79
  isEnabled() {
90
80
  return this.isAttached();
@@ -112,6 +102,23 @@ export class Remote {
112
102
  error: this.errorOf(entry.originalArgs),
113
103
  });
114
104
  }
105
+ async shutdown(exitCode) {
106
+ this.send('result', { ok: exitCode === 0, exitCode });
107
+ await this.flush();
108
+ this.url = null;
109
+ if (this.reconnectTimer)
110
+ clearTimeout(this.reconnectTimer);
111
+ // Whoever asks next has nobody to ask — leaving the callback installed would
112
+ // route them into a closed socket and park them until the ask times out.
113
+ executionController.clearInputCallback();
114
+ for (const resolve of this.asks.values())
115
+ resolve(null);
116
+ this.asks.clear();
117
+ this.queue = [];
118
+ this.socket?.close();
119
+ this.socket = null;
120
+ this.closing = null;
121
+ }
115
122
  connect() {
116
123
  if (!this.url)
117
124
  return;
@@ -16,4 +16,6 @@ export declare const LARGE_ARIA_CHANGE_THRESHOLD = 50;
16
16
  export interface AriaDiff {
17
17
  text: string | null;
18
18
  count: number;
19
+ added: number;
20
+ removed: number;
19
21
  }