explorbot 0.3.4 → 0.4.0

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 (104) hide show
  1. package/bin/explorbot-cli.ts +18 -13
  2. package/boat/doc-collector/src/cli.ts +3 -0
  3. package/boat/doc-collector/src/docbot.ts +3 -1
  4. package/boat/prima/src/cli.ts +21 -8
  5. package/boat/prima/src/envelope.ts +35 -9
  6. package/boat/prima/src/prima.ts +23 -10
  7. package/dist/bin/explorbot-cli.js +19 -13
  8. package/dist/boat/doc-collector/src/cli.js +3 -0
  9. package/dist/boat/doc-collector/src/docbot.js +3 -1
  10. package/dist/boat/prima/src/cli.js +19 -8
  11. package/dist/boat/prima/src/envelope.js +24 -6
  12. package/dist/boat/prima/src/prima.js +23 -11
  13. package/dist/package.json +2 -2
  14. package/dist/src/action-result.d.ts +9 -1
  15. package/dist/src/action-result.js +57 -18
  16. package/dist/src/action.d.ts +1 -1
  17. package/dist/src/action.js +87 -12
  18. package/dist/src/ai/driller.d.ts +0 -1
  19. package/dist/src/ai/driller.js +8 -20
  20. package/dist/src/ai/fisherman-tools.d.ts +9 -0
  21. package/dist/src/ai/fisherman-tools.js +52 -6
  22. package/dist/src/ai/fisherman.d.ts +4 -2
  23. package/dist/src/ai/fisherman.js +48 -27
  24. package/dist/src/ai/historian/codeceptjs.js +1 -1
  25. package/dist/src/ai/historian/playwright.js +1 -1
  26. package/dist/src/ai/pilot.d.ts +1 -0
  27. package/dist/src/ai/pilot.js +15 -1
  28. package/dist/src/ai/planner.js +1 -1
  29. package/dist/src/ai/provider.js +47 -4
  30. package/dist/src/ai/researcher/deep-analysis.js +1 -3
  31. package/dist/src/ai/researcher.js +3 -3
  32. package/dist/src/ai/tester.d.ts +3 -0
  33. package/dist/src/ai/tester.js +40 -3
  34. package/dist/src/ai/tools.d.ts +1 -0
  35. package/dist/src/ai/tools.js +13 -6
  36. package/dist/src/api/request-result.d.ts +2 -0
  37. package/dist/src/api/request-result.js +8 -2
  38. package/dist/src/api/request-store.d.ts +3 -2
  39. package/dist/src/api/request-store.js +66 -14
  40. package/dist/src/commands/explore-command.d.ts +6 -0
  41. package/dist/src/commands/explore-command.js +27 -2
  42. package/dist/src/commands/freesail-command.js +10 -1
  43. package/dist/src/commands/plans-command.js +6 -6
  44. package/dist/src/config.js +1 -0
  45. package/dist/src/experience-tracker.js +5 -0
  46. package/dist/src/explorbot.d.ts +0 -1
  47. package/dist/src/explorbot.js +23 -36
  48. package/dist/src/state-manager.d.ts +5 -1
  49. package/dist/src/state-manager.js +10 -7
  50. package/dist/src/test-plan.d.ts +3 -0
  51. package/dist/src/test-plan.js +27 -0
  52. package/dist/src/utils/aria.d.ts +1 -1
  53. package/dist/src/utils/aria.js +6 -42
  54. package/dist/src/utils/html-diff.d.ts +4 -0
  55. package/dist/src/utils/html-diff.js +62 -7
  56. package/dist/src/utils/html.d.ts +5 -15
  57. package/dist/src/utils/html.js +14 -85
  58. package/dist/src/utils/overlay.d.ts +56 -11
  59. package/dist/src/utils/overlay.js +191 -21
  60. package/dist/src/utils/request-map.d.ts +7 -0
  61. package/dist/src/utils/request-map.js +16 -0
  62. package/dist/src/utils/url-matcher.js +4 -2
  63. package/docs/reference/commands.md +8 -1
  64. package/docs/reference/websocket.md +1 -0
  65. package/docs/superpowers/plans/2026-08-29-fisherman-reliability.md +953 -0
  66. package/docs/superpowers/plans/2026-08-29-region-states.md +1292 -0
  67. package/docs/superpowers/plans/2026-08-30-fisherman-live-session-auth.md +457 -0
  68. package/docs/superpowers/specs/2026-08-29-fisherman-reliability-design.md +45 -0
  69. package/docs/superpowers/specs/2026-08-29-region-states-design.md +262 -0
  70. package/docs/superpowers/specs/2026-08-29-region-states-fixes-design.md +269 -0
  71. package/docs/superpowers/specs/2026-08-30-fisherman-live-session-auth-design.md +37 -0
  72. package/docs/workflow/agentic-usage.md +1 -0
  73. package/docs/workflow/ci.md +1 -0
  74. package/package.json +2 -2
  75. package/src/action-result.ts +61 -22
  76. package/src/action.ts +87 -14
  77. package/src/ai/driller.ts +7 -39
  78. package/src/ai/fisherman-tools.ts +56 -7
  79. package/src/ai/fisherman.ts +48 -28
  80. package/src/ai/historian/codeceptjs.ts +1 -1
  81. package/src/ai/historian/playwright.ts +1 -1
  82. package/src/ai/pilot.ts +11 -1
  83. package/src/ai/planner.ts +1 -1
  84. package/src/ai/provider.ts +48 -4
  85. package/src/ai/researcher/deep-analysis.ts +1 -2
  86. package/src/ai/researcher.ts +3 -3
  87. package/src/ai/tester.ts +40 -3
  88. package/src/ai/tools.ts +17 -9
  89. package/src/api/request-result.ts +10 -2
  90. package/src/api/request-store.ts +60 -13
  91. package/src/commands/explore-command.ts +25 -2
  92. package/src/commands/freesail-command.ts +7 -1
  93. package/src/commands/plans-command.ts +6 -6
  94. package/src/config.ts +1 -0
  95. package/src/experience-tracker.ts +5 -1
  96. package/src/explorbot.ts +20 -36
  97. package/src/state-manager.ts +13 -7
  98. package/src/test-plan.ts +29 -0
  99. package/src/utils/aria.ts +7 -44
  100. package/src/utils/html-diff.ts +62 -7
  101. package/src/utils/html.ts +14 -91
  102. package/src/utils/overlay.ts +226 -23
  103. package/src/utils/request-map.ts +19 -0
  104. package/src/utils/url-matcher.ts +3 -2
@@ -35,7 +35,6 @@ interface ActionResultData extends WebPageState {
35
35
  focusedElement?: FocusedElement | null;
36
36
  iframeURL?: string;
37
37
  links?: Link[];
38
- overlayHtml?: string;
39
38
  }
40
39
 
41
40
  export interface PageDiff {
@@ -49,6 +48,7 @@ export interface PageDiff {
49
48
  consoleErrors?: string[];
50
49
  htmlParts?: HtmlDiffPart[];
51
50
  iframes?: string;
51
+ areaOfInterest?: string;
52
52
  }
53
53
 
54
54
  export interface ToolResultMetadata {
@@ -89,6 +89,7 @@ export class ActionResult implements ActionResultData {
89
89
  public links: Link[] = [];
90
90
  public verifications?: Record<string, boolean>;
91
91
  public overlay: Overlay = new Overlay();
92
+ private _diffCache: { previousId: number | undefined; diff: Diff } | null = null;
92
93
 
93
94
  constructor(data: ActionResultData) {
94
95
  this.id = data.id;
@@ -168,6 +169,7 @@ export class ActionResult implements ActionResultData {
168
169
  set html(value: string) {
169
170
  this._html = value;
170
171
  this.snapshotCache.clear();
172
+ this._diffCache = null;
171
173
  }
172
174
 
173
175
  get screenshot(): Buffer | undefined {
@@ -260,6 +262,11 @@ export class ActionResult implements ActionResultData {
260
262
 
261
263
  isRelevantExperienceRecord(record: WebPageState, options?: { includeDescendantExperience?: boolean }): boolean {
262
264
  if (!record.url || !this.url) return false;
265
+ if (record.region && this.overlay.name !== record.region) return false;
266
+ if (record.root) {
267
+ if (!this.overlay.present) return false;
268
+ if (this.overlay.root && this.overlay.root !== record.root) return false;
269
+ }
263
270
  if (this.isMatchedBy(record)) return true;
264
271
  if (!options?.includeDescendantExperience) return false;
265
272
  const cur = extractStatePath(this.url);
@@ -476,29 +483,18 @@ export class ActionResult implements ActionResultData {
476
483
  }
477
484
 
478
485
  getStateHash(): string {
479
- const parts: string[] = [];
480
-
481
- parts.push(this.relativeUrl || this.url || '/');
482
-
483
- this.extractHeadings(this.html);
484
-
485
- if (this.h1) parts.push(`h1_${this.h1}`);
486
- if (this.h2) parts.push(`h2_${this.h2}`);
487
-
488
- let stateString = slugify(parts.map((part) => part.substring(0, 100)).join('_'));
489
-
490
- if (stateString.length > 200) {
491
- stateString = stateString.substring(0, 200);
492
- if (stateString.endsWith('_')) {
493
- stateString = stateString.slice(0, -1);
494
- }
495
- }
486
+ return this.computeStateHash(true);
487
+ }
496
488
 
497
- return stateString;
489
+ get baseHash(): string {
490
+ return this.computeStateHash(false);
498
491
  }
499
492
 
500
493
  async diff(previousState: ActionResult | null): Promise<Diff> {
501
- return Diff.create(this, previousState);
494
+ if (this._diffCache && this._diffCache.previousId === previousState?.id) return this._diffCache.diff;
495
+ const diff = await Diff.create(this, previousState);
496
+ this._diffCache = { previousId: previousState?.id, diff };
497
+ return diff;
502
498
  }
503
499
 
504
500
  async toToolResult(previousState: ActionResult | null, locator: string): Promise<ToolResultMetadata> {
@@ -549,7 +545,18 @@ export class ActionResult implements ActionResultData {
549
545
  pageDiff.ariaChangeCount = diff.ariaChangeCount;
550
546
  }
551
547
 
552
- if (diff.htmlParts.length > 0) {
548
+ if (this.overlay.present && (!previousState.overlay.present || previousState.overlay.name !== this.overlay.name)) {
549
+ pageDiff.areaOfInterest = this.overlay.describe();
550
+ }
551
+
552
+ if (pageDiff.areaOfInterest && this.overlay.html && this.overlay.root) {
553
+ const htmlConfig = ConfigParser.getInstance().getConfig().html;
554
+ let subtree = await minifyHtml(htmlCombinedSnapshot(this.overlay.html, htmlConfig?.combined));
555
+ if (subtree.length > HTML_PART_SUBTREE_BUDGET) {
556
+ subtree = `${subtree.slice(0, HTML_PART_SUBTREE_BUDGET)}...<!-- truncated -->`;
557
+ }
558
+ pageDiff.htmlParts = [{ container: this.overlay.root, subtree, rawSize: subtree.length, added: [], removed: [] }];
559
+ } else if (diff.isSameUrl() && diff.htmlParts.length > 0) {
553
560
  const collapsed = collapseHtmlParts(await diff.cleanedHtmlParts());
554
561
  if (collapsed.length > 0) {
555
562
  pageDiff.htmlParts = collapsed;
@@ -565,6 +572,29 @@ export class ActionResult implements ActionResultData {
565
572
  return result;
566
573
  }
567
574
 
575
+ private computeStateHash(includeRegion: boolean): string {
576
+ const parts: string[] = [];
577
+
578
+ parts.push(this.relativeUrl || this.url || '/');
579
+
580
+ this.extractHeadings(this.html);
581
+
582
+ if (this.h1) parts.push(`h1_${this.h1}`);
583
+ if (this.h2) parts.push(`h2_${this.h2}`);
584
+ if (includeRegion && this.overlay.present && this.overlay.name) parts.push(`region_${this.overlay.name}`);
585
+
586
+ let stateString = slugify(parts.map((part) => part.substring(0, 100)).join('_'));
587
+
588
+ if (stateString.length > 200) {
589
+ stateString = stateString.substring(0, 200);
590
+ if (stateString.endsWith('_')) {
591
+ stateString = stateString.slice(0, -1);
592
+ }
593
+ }
594
+
595
+ return stateString;
596
+ }
597
+
568
598
  private consoleErrors(): string[] {
569
599
  const errors: string[] = [];
570
600
 
@@ -681,15 +711,24 @@ export class Diff {
681
711
  return this._messages;
682
712
  }
683
713
 
714
+ get similarity(): number {
715
+ return this._htmlDiffResult?.similarity ?? 0;
716
+ }
717
+
718
+ get pageSize(): number {
719
+ return this._htmlDiffResult?.pageSize ?? 0;
720
+ }
721
+
684
722
  async calculate(): Promise<void> {
685
723
  if (!this.previous) return;
686
724
 
725
+ this._htmlDiffResult = await htmlDiff(this.previous.html, this.current.html, ConfigParser.getInstance().getConfig().html);
726
+
687
727
  if (!this._isSameUrl) {
688
728
  this._messages = liveRegionMessages(this.previous.html, this.current.html);
689
729
  return;
690
730
  }
691
731
 
692
- this._htmlDiffResult = await htmlDiff(this.previous.html, this.current.html, ConfigParser.getInstance().getConfig().html);
693
732
  this._messages = this._htmlDiffResult.messages;
694
733
 
695
734
  const ariaDiff = diffAriaSnapshots(this.previous.ariaSnapshot, this.current.ariaSnapshot);
package/src/action.ts CHANGED
@@ -11,9 +11,9 @@ import { Observability } from './observability.ts';
11
11
  import type { PlaywrightRecorder } from './playwright-recorder.ts';
12
12
  import type { StateManager } from './state-manager.js';
13
13
  import { browserErrorMessage, isFatalBrowserError, isNavigationTransitionError } from './utils/browser-errors.ts';
14
- import { captureHtmlForSnapshot, getVisibleOverlayHtmlExtractorSource, htmlCombinedSnapshot, minifyHtml } from './utils/html.js';
14
+ import { captureHtmlForSnapshot, htmlCombinedSnapshot, minifyHtml } from './utils/html.js';
15
15
  import { createDebug, setStepSpanParent, tag } from './utils/logger.js';
16
- import { Overlay } from './utils/overlay.js';
16
+ import { Overlay, OverlayPage } from './utils/overlay.js';
17
17
  import { sleep, waitForPageReadiness } from './utils/page-readiness.ts';
18
18
  import { safeFilename } from './utils/strings.ts';
19
19
  import { codeceptJSSandbox, hasPlaywrightCommands, playwrightSandbox, sanitizeCodeBlock } from './utils/web-sandbox.ts';
@@ -146,13 +146,11 @@ class Action {
146
146
  let ariaSnapshot: string | null = null;
147
147
  let ariaSnapshotFile: string | undefined = undefined;
148
148
  let focusedElement: FocusedElement | null = null;
149
- let overlayHtml = '';
150
149
 
151
150
  try {
152
151
  const page = this.playwrightHelper.page;
153
152
  ariaSnapshot = await page.locator('body').ariaSnapshot();
154
153
  focusedElement = await page.evaluate(readFocusedElement);
155
- if (!frame) overlayHtml = await this.captureOverlayHtml();
156
154
  } catch (err) {
157
155
  debugLog('ARIA snapshot failed:', err instanceof Error ? `${err.message}\n${err.stack}` : err);
158
156
  }
@@ -181,9 +179,9 @@ class Action {
181
179
  ariaSnapshot,
182
180
  ariaSnapshotFile,
183
181
  focusedElement,
184
- overlayHtml: overlayHtml || undefined,
185
182
  iframeURL: frame ? frame.url?.() || 'iframe' : undefined,
186
183
  });
184
+ if (!frame) await this.detectRegionOfInterest(result).catch((err: Error) => debugLog('Region detection failed:', err.message));
187
185
  this.stateManager.updateState(result, codeBlock);
188
186
  return result;
189
187
  } catch (err) {
@@ -195,14 +193,64 @@ class Action {
195
193
  }
196
194
  }
197
195
 
198
- private async captureOverlayHtml(): Promise<string> {
199
- return this.playwrightHelper.page.evaluate(
200
- ({ extractorSource, config }: { extractorSource: string; config: any }) => {
201
- const extract = new Function(`return ${extractorSource}`)() as (config: any) => string;
202
- return extract(config);
203
- },
204
- { extractorSource: getVisibleOverlayHtmlExtractorSource(), config: Overlay.captureConfig() }
205
- );
196
+ private async detectRegionOfInterest(result: ActionResult): Promise<void> {
197
+ const previousState = this.stateManager.getCurrentState();
198
+ if (!previousState) return;
199
+ const previous = ActionResult.fromState(previousState);
200
+ const previousOverlay = previous.overlay;
201
+ const sameUrl = !!previous.url && result.isSameUrl({ url: previous.url });
202
+ const overlayPage = new OverlayPage(this.playwrightHelper.page);
203
+
204
+ if (result.overlay.detected && previousOverlay.detected && previousOverlay.root && previousOverlay.type === result.overlay.type && previousOverlay.name === result.overlay.name) {
205
+ result.overlay = previousOverlay;
206
+ return;
207
+ }
208
+
209
+ if (!previous.html) return;
210
+
211
+ if (previous.html === result.html) {
212
+ if (sameUrl && previousOverlay.present && previousOverlay.xpath && !result.overlay.detected) result.overlay = previousOverlay;
213
+ return;
214
+ }
215
+
216
+ let carried: Overlay | null = null;
217
+ if (sameUrl && previousOverlay.present && previousOverlay.xpath) {
218
+ if (await overlayPage.isStillOpen(previousOverlay)) {
219
+ carried = previousOverlay;
220
+ } else {
221
+ debugLog(`Region closed: ${previousOverlay.name || previousOverlay.type}`);
222
+ if (!result.overlay.detected) {
223
+ const parent = previousOverlay.parent;
224
+ if (parent?.xpath) {
225
+ const restored = new Overlay(parent);
226
+ if (await overlayPage.isStillOpen(restored)) result.overlay = restored;
227
+ }
228
+ return;
229
+ }
230
+ }
231
+ }
232
+
233
+ const diff = await result.diff(previous);
234
+ const detected = await overlayPage.detectRegion({
235
+ parts: diff.htmlParts,
236
+ pageSize: diff.pageSize,
237
+ similarity: diff.similarity,
238
+ sameUrl,
239
+ previousHtml: previous.html,
240
+ });
241
+
242
+ if (result.overlay.detected) {
243
+ if (detected) result.overlay = result.overlay.withGeometry(detected);
244
+ return;
245
+ }
246
+
247
+ if (detected) {
248
+ result.overlay = detected;
249
+ if (carried) result.overlay = detected.withParent(carried);
250
+ return;
251
+ }
252
+
253
+ if (carried) result.overlay = carried;
206
254
  }
207
255
 
208
256
  private async captureMainDocumentStatus(): Promise<number | undefined> {
@@ -381,6 +429,13 @@ class Action {
381
429
  await recorder.reset();
382
430
  await recorder.start();
383
431
  }
432
+ if (executedSteps.length > 0) {
433
+ codeString = executedSteps.map((step) => step.command).join('\n');
434
+ }
435
+ if (!isFatalBrowserError(err)) {
436
+ const captured = await this.captureOnce({ codeBlock: codeString }).catch(() => null);
437
+ if (captured && !captured.error) this.actionResult = captured;
438
+ }
384
439
  this.assertionSteps = [];
385
440
  throw err;
386
441
  } finally {
@@ -491,11 +546,29 @@ const ASSERTION_STEP_NAMES = new Set(['see', 'dontSee', 'seeElement', 'dontSeeEl
491
546
  type StepListener = (step: any, error?: any) => void;
492
547
 
493
548
  export const attachStepLogger = (target: ExecutedStep[], assertionsTarget?: Array<{ name: string; args: any[] }>): (() => void) => {
549
+ const recorded = new WeakMap<object, ExecutedStep>();
550
+ let batchFailed = false;
494
551
  const listener: StepListener = (step, error) => {
495
552
  if (!step?.toCode) return;
496
553
  if (step.name?.startsWith('grab')) return;
554
+
555
+ const existing = recorded.get(step);
556
+ if (existing) {
557
+ if (!error && !existing.success) {
558
+ existing.success = true;
559
+ existing.error = undefined;
560
+ batchFailed = target.some((entry) => !entry.success);
561
+ }
562
+ return;
563
+ }
564
+ if (batchFailed) return;
565
+
497
566
  const executed: ExecutedStep = { command: step.toCode(), success: !error };
498
- if (error) executed.error = errorToString(error);
567
+ if (error) {
568
+ executed.error = errorToString(error);
569
+ batchFailed = true;
570
+ }
571
+ recorded.set(step, executed);
499
572
  target.push(executed);
500
573
  if (assertionsTarget && ASSERTION_STEP_NAMES.has(step.name)) {
501
574
  assertionsTarget.push({ name: step.name, args: step.args || [] });
package/src/ai/driller.ts CHANGED
@@ -6,23 +6,9 @@ import { setActivity } from '../activity.ts';
6
6
  import { Observability } from '../observability.ts';
7
7
  import { Plan, Test, TestResult } from '../test-plan.ts';
8
8
  import { collectInteractiveNodes } from '../utils/aria.ts';
9
- import {
10
- EXPLORBOT_ATTRS,
11
- HTML_COMPOSITE_AREA_HINTS,
12
- HTML_COMPOSITE_TARGET_ROLES,
13
- HTML_EXTRACTION_LIMITS,
14
- HTML_FORM_CONTROL_ROLES,
15
- HTML_FORM_CONTROL_TAGS,
16
- HTML_INTERACTIVE_ROLES,
17
- HTML_SELECTORS,
18
- HTML_VISIBILITY_LIMITS,
19
- getComponentScopeHtmlExtractorSource,
20
- getVisibleOverlayHtmlExtractorSource,
21
- inferHtmlRole,
22
- } from '../utils/html.ts';
9
+ import { EXPLORBOT_ATTRS, HTML_COMPOSITE_AREA_HINTS, HTML_COMPOSITE_TARGET_ROLES, HTML_EXTRACTION_LIMITS, HTML_FORM_CONTROL_ROLES, HTML_FORM_CONTROL_TAGS, HTML_INTERACTIVE_ROLES, HTML_SELECTORS, getComponentScopeHtmlExtractorSource, inferHtmlRole } from '../utils/html.ts';
23
10
  import { createDebug, tag } from '../utils/logger.ts';
24
11
  import { loop, pause } from '../utils/loop.ts';
25
- import { OVERLAY_SELECTORS } from '../utils/overlay.ts';
26
12
  import { annotatePageElements } from '../utils/web-annotate.ts';
27
13
  import { eidxInContainer } from '../utils/web-eidx.ts';
28
14
  import { WebElement } from '../utils/web-element.ts';
@@ -648,8 +634,11 @@ export class Driller extends TaskAgent implements Agent {
648
634
  private async detectNestedOverlayContext(component: ComponentInfo, result: any): Promise<string | null> {
649
635
  if (!result?.pageDiff?.ariaChanges || result.pageDiff.urlChanged) return null;
650
636
 
651
- const overlayHtml = await this.getVisibleOverlayHtml();
652
- if (!overlayHtml) return null;
637
+ const parts = result.pageDiff.htmlParts ?? [];
638
+ let appeared = parts.filter((part: any) => part.added?.length > 0);
639
+ if (result.pageDiff.areaOfInterest) appeared = parts;
640
+ const appearedHtml = appeared.map((part: any) => part.subtree).join('\n');
641
+ if (!appearedHtml) return null;
653
642
 
654
643
  const state = this.stateManager.getCurrentState();
655
644
  if (!state) return null;
@@ -661,7 +650,7 @@ export class Driller extends TaskAgent implements Agent {
661
650
  Keep the recorded code reusable and include the parent-opening action when the nested element requires the overlay to be open.
662
651
 
663
652
  <overlay_html>
664
- ${overlayHtml}
653
+ ${appearedHtml}
665
654
  </overlay_html>
666
655
 
667
656
  <current_page_aria>
@@ -671,27 +660,6 @@ export class Driller extends TaskAgent implements Agent {
671
660
  `;
672
661
  }
673
662
 
674
- private async getVisibleOverlayHtml(): Promise<string> {
675
- return this.explorer.withPage((page) =>
676
- page.evaluate(
677
- ({ extractorSource, config }) => {
678
- const extract = new Function(`return ${extractorSource}`)() as (config: any) => string;
679
- return extract(config);
680
- },
681
- {
682
- extractorSource: getVisibleOverlayHtmlExtractorSource(),
683
- config: {
684
- interactiveContentSelector: HTML_SELECTORS.interactiveContent,
685
- limits: HTML_EXTRACTION_LIMITS,
686
- overlaySelectors: OVERLAY_SELECTORS.semanticOverlays,
687
- overlaySemanticSelector: OVERLAY_SELECTORS.overlaySemanticSelector,
688
- visibilityLimits: HTML_VISIBILITY_LIMITS,
689
- },
690
- }
691
- )
692
- );
693
- }
694
-
695
663
  private async getComponentScopeHtml(component: ComponentInfo, originalState: ActionResult): Promise<string> {
696
664
  const scopedHtml = await this.explorer.withPage((page) =>
697
665
  page.evaluate(
@@ -2,16 +2,28 @@ import { tool } from 'ai';
2
2
  import dedent from 'dedent';
3
3
  import { z } from 'zod';
4
4
  import type { ApiClient } from '../api/api-client.ts';
5
+ import type { RequestResult } from '../api/request-result.ts';
5
6
  import type { RequestStore } from '../api/request-store.ts';
6
7
  import { extractEndpointDefinition } from '../api/spec-reader.ts';
7
8
  import { tag } from '../utils/logger.ts';
9
+ import { RequestMap } from '../utils/request-map.ts';
10
+ import { isDynamicSegment } from '../utils/url-matcher.ts';
8
11
 
9
12
  export function createFishermanTools(apiClient: ApiClient, requestStore: RequestStore, opts: { spec?: any; baseEndpoint?: string }) {
10
13
  let finished = false;
11
- let result: FishermanResult = { success: false, summary: '', created: [], failed: [] };
14
+ let result: FishermanResult | null = null;
15
+ const ledgerStart = requestStore.getMadeRequests().length;
12
16
 
13
- const getResult = () => result;
17
+ const runRequests = () => requestStore.getMadeRequests().slice(ledgerStart);
18
+ const successfulWrites = () => runRequests().filter((r) => r.isWrite && !r.error && r.status >= 200 && r.status < 400);
19
+ const getResult = () => result ?? synthesizeResult(runRequests(), successfulWrites(), false);
14
20
  const isFinished = () => finished;
21
+ const finishFromText = (text?: string) => {
22
+ finished = true;
23
+ const synthesized = synthesizeResult(runRequests(), successfulWrites(), true);
24
+ if (text && synthesized.success) synthesized.summary = text;
25
+ result = synthesized;
26
+ };
15
27
 
16
28
  const tools = {
17
29
  getEndpointSpec: tool({
@@ -76,7 +88,7 @@ export function createFishermanTools(apiClient: ApiClient, requestStore: Request
76
88
  request: tool({
77
89
  description: dedent`
78
90
  Make an HTTP request to the API.
79
- Returns status, timing, and auto-extracted IDs and names from the response.
91
+ Returns status, plus IDs and names auto-extracted from the response under 'extracted'.
80
92
  `,
81
93
  inputSchema: z.object({
82
94
  method: z.enum(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']).describe('HTTP method'),
@@ -119,7 +131,7 @@ export function createFishermanTools(apiClient: ApiClient, requestStore: Request
119
131
  return {
120
132
  success: true,
121
133
  status: reqResult.status,
122
- ...extracted,
134
+ extracted,
123
135
  };
124
136
  },
125
137
  }),
@@ -148,9 +160,32 @@ export function createFishermanTools(apiClient: ApiClient, requestStore: Request
148
160
  .describe('List of items that could not be created'),
149
161
  }),
150
162
  execute: async ({ summary, created, failed }) => {
163
+ const writes = successfulWrites();
164
+ if (writes.length === 0) {
165
+ tag('warning').log('Fisherman: finish rejected — no successful write request in this run');
166
+ return { finished: false, error: 'No successful write request was made in this run, so nothing was created. Keep working, or call stop if the data cannot be prepared.' };
167
+ }
168
+
169
+ const createdRequests = new RequestMap(writes);
170
+
171
+ const verified: FishermanResult['created'] = [];
172
+ for (const item of created) {
173
+ if (item.id === undefined) {
174
+ verified.push(item);
175
+ continue;
176
+ }
177
+ const request = createdRequests.get(item.id);
178
+ if (!request) {
179
+ tag('warning').log(`Fisherman: dropped unverified created item ${item.type} (id: ${item.id})`);
180
+ continue;
181
+ }
182
+ verified.push({ ...item, request: request.toEndpoint() });
183
+ }
184
+ if (verified.length === 0) verified.push(...writes.map(toCreatedItem));
185
+
151
186
  tag('success').log(`Fisherman done: ${summary}`);
152
187
  finished = true;
153
- result = { success: true, summary, created, failed: failed || [] };
188
+ result = { success: true, summary, created: verified, failed: failed || [] };
154
189
  return { finished: true };
155
190
  },
156
191
  }),
@@ -169,7 +204,21 @@ export function createFishermanTools(apiClient: ApiClient, requestStore: Request
169
204
  }),
170
205
  };
171
206
 
172
- return { tools, getResult, isFinished };
207
+ return { tools, getResult, isFinished, finishFromText };
208
+ }
209
+
210
+ function synthesizeResult(made: RequestResult[], writes: RequestResult[], declaredDone: boolean): FishermanResult {
211
+ const failures = made.filter((r) => r.status >= 400 || r.error);
212
+ let summary = `Stopped before finishing: ${made.length} requests, ${writes.length} successful writes, ${failures.length} failed`;
213
+ const lastFailure = failures[failures.length - 1];
214
+ if (lastFailure) summary += `; last failure: ${lastFailure.toSummary()}`;
215
+ return { success: declaredDone && writes.length > 0, summary, created: writes.map(toCreatedItem), failed: [] };
216
+ }
217
+
218
+ function toCreatedItem(write: RequestResult): FishermanResult['created'][number] {
219
+ const { id, title } = write.extractIdAndTitle();
220
+ const segments = write.path.split('/').filter((s) => s && !isDynamicSegment(s));
221
+ return { type: segments[segments.length - 1] || 'item', id, title, request: write.toEndpoint() };
173
222
  }
174
223
 
175
224
  function responseCategory(status: number): ResponseCategory {
@@ -209,7 +258,7 @@ function extractKeyFields(body: any, result: Record<string, any> = {}, depth = 0
209
258
  export interface FishermanResult {
210
259
  success: boolean;
211
260
  summary: string;
212
- created: Array<{ type: string; id?: string | number; title?: string }>;
261
+ created: Array<{ type: string; id?: string | number; title?: string; request?: string }>;
213
262
  failed: Array<{ type: string; reason: string }>;
214
263
  }
215
264
 
@@ -13,6 +13,7 @@ import { dataProtectionRules } from './rules.ts';
13
13
 
14
14
  const MAX_ITERATIONS = 15;
15
15
  const MAX_TOOL_ROUNDTRIPS = 5;
16
+ const REPEATED_FAILURE_LIMIT = 4;
16
17
 
17
18
  export class Fisherman implements Agent {
18
19
  emoji = '🎣';
@@ -20,21 +21,22 @@ export class Fisherman implements Agent {
20
21
  private apiClient: ApiClient;
21
22
  private requestStore: RequestStore;
22
23
  private specLoader: () => Promise<any | null>;
23
- private cookieProvider: () => Promise<Record<string, string>>;
24
+ private browserHeaderProvider: () => Promise<Record<string, string>>;
24
25
  private configHeaders: Record<string, string>;
25
26
  private sessionName?: string;
26
27
  private baseEndpoint: string;
27
28
  private spec: any | null = null;
28
29
  private mode: 'replicate' | 'achieve' | 'disabled' = 'disabled';
29
30
  private hasApiConfig: boolean;
31
+ private scopeDegraded = false;
30
32
 
31
- constructor(provider: Provider, apiClient: ApiClient, requestStore: RequestStore, specLoader: () => Promise<any | null>, baseEndpoint: string, cookieProvider: () => Promise<Record<string, string>>, configHeaders: Record<string, string> = {}, hasApiConfig = false) {
33
+ constructor(provider: Provider, apiClient: ApiClient, requestStore: RequestStore, specLoader: () => Promise<any | null>, baseEndpoint: string, browserHeaderProvider: () => Promise<Record<string, string>>, configHeaders: Record<string, string> = {}, hasApiConfig = false) {
32
34
  this.provider = provider;
33
35
  this.apiClient = apiClient;
34
36
  this.requestStore = requestStore;
35
37
  this.specLoader = specLoader;
36
38
  this.baseEndpoint = baseEndpoint;
37
- this.cookieProvider = cookieProvider;
39
+ this.browserHeaderProvider = browserHeaderProvider;
38
40
  this.configHeaders = configHeaders;
39
41
  this.hasApiConfig = hasApiConfig;
40
42
  this.mode = hasApiConfig ? 'achieve' : 'replicate';
@@ -77,10 +79,11 @@ export class Fisherman implements Agent {
77
79
  await this.refreshAuth();
78
80
  debugLog(`auth headers: ${Object.keys(this.apiClient.getHeaders()).join(', ')}`);
79
81
 
80
- const { tools, getResult, isFinished } = createFishermanTools(this.apiClient, this.requestStore, {
82
+ const { tools, getResult, isFinished, finishFromText } = createFishermanTools(this.apiClient, this.requestStore, {
81
83
  spec: this.spec,
82
84
  baseEndpoint: this.baseEndpoint,
83
85
  });
86
+ const ledgerStart = this.requestStore.getMadeRequests().length;
84
87
 
85
88
  const conversation = this.provider.startConversation(this.buildSystemPrompt(endpointList, Object.keys(tools), scopeUrl), 'fisherman');
86
89
  conversation.addUserText(this.buildTaskPrompt(instructions));
@@ -90,7 +93,6 @@ export class Fisherman implements Agent {
90
93
  debugLog(`iteration ${iteration}`);
91
94
  const invokeResult = await this.provider.invokeConversation(conversation, tools, {
92
95
  maxToolRoundtrips: MAX_TOOL_ROUNDTRIPS,
93
- toolChoice: 'required',
94
96
  agentName: 'fisherman',
95
97
  });
96
98
  debugLog(`iteration ${iteration} done, text: ${invokeResult?.response?.text?.slice(0, 200) || '(none)'}`);
@@ -100,6 +102,19 @@ export class Fisherman implements Agent {
100
102
  return;
101
103
  }
102
104
 
105
+ if (!invokeResult?.toolExecutions?.length) {
106
+ debugLog('no tool call in this turn — treating as finish');
107
+ finishFromText(invokeResult?.response?.text);
108
+ stop();
109
+ return;
110
+ }
111
+
112
+ if (this.isStuckOnEndpoint(ledgerStart)) {
113
+ tag('warning').log('Fisherman: repeated failures on the same endpoint — stopping');
114
+ stop();
115
+ return;
116
+ }
117
+
103
118
  if (iteration >= MAX_ITERATIONS) {
104
119
  tag('warning').log('Fisherman: max iterations reached');
105
120
  stop();
@@ -145,14 +160,16 @@ export class Fisherman implements Agent {
145
160
  }
146
161
 
147
162
  private async refreshAuth(): Promise<void> {
148
- const cookies = await this.cookieProvider();
149
- if (Object.keys(cookies).length > 0) {
150
- this.apiClient.setHeaders(cookies);
151
- }
163
+ if (this.mode === 'replicate') {
164
+ const xhrHeaders = this.requestStore.extractAuthHeaders();
165
+ if (Object.keys(xhrHeaders).length > 0) {
166
+ this.apiClient.setHeaders(xhrHeaders);
167
+ }
152
168
 
153
- const xhrHeaders = this.requestStore.extractAuthHeaders();
154
- if (Object.keys(xhrHeaders).length > 0) {
155
- this.apiClient.setHeaders(xhrHeaders);
169
+ const browserHeaders = await this.browserHeaderProvider();
170
+ if (Object.keys(browserHeaders).length > 0) {
171
+ this.apiClient.setHeaders(browserHeaders);
172
+ }
156
173
  }
157
174
 
158
175
  if (Object.keys(this.configHeaders).length > 0) {
@@ -161,31 +178,25 @@ export class Fisherman implements Agent {
161
178
  }
162
179
 
163
180
  private buildEndpointList(scopeUrl?: string): string {
181
+ this.scopeDegraded = false;
164
182
  if (this.mode === 'achieve' && this.spec) {
165
183
  const specEndpoints = listAllEndpoints(this.spec, this.baseEndpoint);
166
184
  if (specEndpoints) return specEndpoints;
167
185
  }
168
186
 
169
- let writeRequests = this.requestStore.getWriteRequestsForScope(scopeUrl || '/');
170
- if (writeRequests.length === 0) {
171
- writeRequests = this.requestStore.getWriteRequestsForScope('/');
172
- }
187
+ const scoped = this.requestStore.toEndpointList(scopeUrl || '/');
188
+ if (scoped) return scoped;
173
189
 
174
- const seen = new Set<string>();
175
- const lines: string[] = [];
176
-
177
- for (const req of writeRequests) {
178
- const key = `${req.method} ${req.path}`;
179
- if (seen.has(key)) continue;
180
- seen.add(key);
181
- lines.push(key);
182
- }
183
-
184
- return lines.join('\n');
190
+ this.scopeDegraded = true;
191
+ return this.requestStore.toEndpointList();
185
192
  }
186
193
 
187
194
  private buildSystemPrompt(endpointList: string, toolNames: string[], scopeUrl?: string): string {
188
- const scopeBlock = scopeUrl ? `\n\nSCOPE: You are operating within ${scopeUrl}.\nAll created items must belong to this scope.` : '';
195
+ let scopeBlock = '';
196
+ if (scopeUrl) {
197
+ scopeBlock = `\n\nSCOPE: You are operating within ${scopeUrl}.\nAll created items must belong to this scope.`;
198
+ if (this.scopeDegraded) scopeBlock += '\nThe endpoint list could not be narrowed to this scope and may include endpoints belonging to other scopes. Before writing, confirm the target belongs to this scope.';
199
+ }
189
200
 
190
201
  return dedent`
191
202
  You are Fisherman — a data preparation agent. You create test data by making API requests.
@@ -210,12 +221,21 @@ export class Fisherman implements Agent {
210
221
  - Chain requests logically — create parent resources before children
211
222
  - Use the response category and error text to decide what failed: validation requires corrected data, authorization requires valid access, not_found requires a valid path or parent, and conflict requires resolving the conflicting state
212
223
  - Retry temporary or server failures once. Retry other failures only when the specification or error text gives a concrete correction
224
+ - Create only the resource types that were requested. If no endpoint creates a requested type, call stop — never create a different type as a substitute
213
225
  - Use realistic but unique data for each item (vary names, titles)
214
226
 
215
227
  ${dataProtectionRules}
216
228
  `;
217
229
  }
218
230
 
231
+ private isStuckOnEndpoint(ledgerStart: number): boolean {
232
+ const made = this.requestStore.getMadeRequests().slice(ledgerStart);
233
+ if (made.length < REPEATED_FAILURE_LIMIT) return false;
234
+ const recent = made.slice(-REPEATED_FAILURE_LIMIT);
235
+ const first = recent[0];
236
+ return recent.every((r) => (r.status >= 400 || r.error) && r.method === first.method && r.path === first.path);
237
+ }
238
+
219
239
  private buildTaskPrompt(instructions: string): string {
220
240
  return dedent`
221
241
  Prepare the following test data:
@@ -64,7 +64,7 @@ export function WithCodeceptJS<T extends Constructor>(Base: T) {
64
64
  lines.push(`Feature('${escapeString(plan.title)}')`);
65
65
  lines.push('');
66
66
 
67
- const startUrl = plan.url || plan.tests[0]?.startUrl;
67
+ const startUrl = plan.startUrl;
68
68
  if (startUrl) {
69
69
  lines.push('Before(({ I }) => {');
70
70
  lines.push(` I.amOnPage('${escapeString(startUrl)}');`);