explorbot 0.3.5 → 0.4.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 (155) hide show
  1. package/bin/explorbot-cli.ts +23 -16
  2. package/boat/api-tester/src/ai/chief.ts +7 -1
  3. package/boat/api-tester/src/ai/curler.ts +7 -1
  4. package/boat/api-tester/src/apibot.ts +10 -4
  5. package/boat/api-tester/src/cli.ts +12 -2
  6. package/boat/api-tester/src/config.ts +28 -8
  7. package/boat/doc-collector/bin/doc-collector-cli.ts +3 -2
  8. package/boat/doc-collector/src/cli.ts +6 -1
  9. package/boat/doc-collector/src/docbot.ts +4 -2
  10. package/boat/prima/bin/prima-cli.ts +2 -0
  11. package/boat/prima/src/cli.ts +23 -8
  12. package/boat/prima/src/envelope.ts +35 -9
  13. package/boat/prima/src/prima.ts +23 -10
  14. package/dist/bin/explorbot-cli.js +24 -16
  15. package/dist/boat/api-tester/bin/apibot-cli.js +3 -2
  16. package/dist/boat/api-tester/src/ai/chief.js +6 -1
  17. package/dist/boat/api-tester/src/ai/curler.js +6 -1
  18. package/dist/boat/api-tester/src/apibot.js +7 -3
  19. package/dist/boat/api-tester/src/cli.js +12 -2
  20. package/dist/boat/api-tester/src/config.js +31 -8
  21. package/dist/boat/doc-collector/bin/doc-collector-cli.js +3 -2
  22. package/dist/boat/doc-collector/src/cli.js +6 -1
  23. package/dist/boat/doc-collector/src/docbot.js +4 -2
  24. package/dist/boat/prima/bin/prima-cli.js +2 -0
  25. package/dist/boat/prima/src/cli.js +22 -8
  26. package/dist/boat/prima/src/envelope.js +24 -6
  27. package/dist/boat/prima/src/prima.js +23 -11
  28. package/dist/package.json +2 -2
  29. package/dist/rules/planner/styles/normal.md +1 -1
  30. package/dist/src/action-result.d.ts +9 -1
  31. package/dist/src/action-result.js +57 -18
  32. package/dist/src/action.d.ts +1 -1
  33. package/dist/src/action.js +87 -12
  34. package/dist/src/ai/captain.js +1 -1
  35. package/dist/src/ai/driller.d.ts +0 -1
  36. package/dist/src/ai/driller.js +8 -20
  37. package/dist/src/ai/fisherman-tools.d.ts +9 -0
  38. package/dist/src/ai/fisherman-tools.js +52 -6
  39. package/dist/src/ai/fisherman.d.ts +4 -2
  40. package/dist/src/ai/fisherman.js +48 -27
  41. package/dist/src/ai/historian/codeceptjs.js +1 -1
  42. package/dist/src/ai/historian/playwright.js +1 -1
  43. package/dist/src/ai/navigator.js +1 -1
  44. package/dist/src/ai/pilot.d.ts +1 -0
  45. package/dist/src/ai/pilot.js +13 -1
  46. package/dist/src/ai/planner.js +9 -7
  47. package/dist/src/ai/provider.js +20 -3
  48. package/dist/src/ai/researcher/deep-analysis.js +1 -3
  49. package/dist/src/ai/researcher.js +5 -3
  50. package/dist/src/ai/rules.js +3 -3
  51. package/dist/src/ai/tester.d.ts +3 -0
  52. package/dist/src/ai/tester.js +40 -3
  53. package/dist/src/ai/tools.d.ts +1 -0
  54. package/dist/src/ai/tools.js +13 -6
  55. package/dist/src/api/request-result.d.ts +2 -0
  56. package/dist/src/api/request-result.js +8 -2
  57. package/dist/src/api/request-store.d.ts +3 -2
  58. package/dist/src/api/request-store.js +66 -14
  59. package/dist/src/api/spec-reader.js +1 -1
  60. package/dist/src/commands/config-command.js +1 -1
  61. package/dist/src/commands/drill-command.js +1 -1
  62. package/dist/src/commands/explore-command.d.ts +6 -0
  63. package/dist/src/commands/explore-command.js +39 -3
  64. package/dist/src/commands/freesail-command.js +10 -1
  65. package/dist/src/commands/options/base-option.d.ts +8 -0
  66. package/dist/src/commands/options/base-option.js +12 -0
  67. package/dist/src/commands/options/index.d.ts +5 -0
  68. package/dist/src/commands/options/index.js +5 -0
  69. package/dist/src/commands/options/knowledge-option.d.ts +7 -0
  70. package/dist/src/commands/options/knowledge-option.js +12 -0
  71. package/dist/src/commands/options/ws-option.d.ts +7 -0
  72. package/dist/src/commands/options/ws-option.js +21 -0
  73. package/dist/src/commands/plans-command.js +6 -6
  74. package/dist/src/config.d.ts +1 -0
  75. package/dist/src/config.js +12 -0
  76. package/dist/src/experience-tracker.js +5 -0
  77. package/dist/src/explorbot.d.ts +0 -1
  78. package/dist/src/explorbot.js +24 -37
  79. package/dist/src/knowledge-tracker.d.ts +20 -7
  80. package/dist/src/knowledge-tracker.js +69 -31
  81. package/dist/src/remote.d.ts +0 -3
  82. package/dist/src/remote.js +0 -18
  83. package/dist/src/state-manager.d.ts +5 -1
  84. package/dist/src/state-manager.js +10 -7
  85. package/dist/src/test-plan.d.ts +3 -0
  86. package/dist/src/test-plan.js +27 -0
  87. package/dist/src/utils/aria.d.ts +1 -1
  88. package/dist/src/utils/aria.js +6 -42
  89. package/dist/src/utils/html-diff.d.ts +4 -0
  90. package/dist/src/utils/html-diff.js +62 -7
  91. package/dist/src/utils/html.d.ts +5 -15
  92. package/dist/src/utils/html.js +14 -85
  93. package/dist/src/utils/overlay.d.ts +56 -11
  94. package/dist/src/utils/overlay.js +191 -21
  95. package/dist/src/utils/request-map.d.ts +7 -0
  96. package/dist/src/utils/request-map.js +16 -0
  97. package/dist/src/utils/url-matcher.js +4 -2
  98. package/docs/api-testing/basics.md +15 -0
  99. package/docs/api-testing/planning.md +10 -1
  100. package/docs/reference/commands.md +32 -5
  101. package/docs/reference/websocket.md +1 -0
  102. package/docs/superpowers/plans/2026-08-29-fisherman-reliability.md +953 -0
  103. package/docs/superpowers/plans/2026-08-29-region-states.md +1292 -0
  104. package/docs/superpowers/plans/2026-08-30-fisherman-live-session-auth.md +457 -0
  105. package/docs/superpowers/specs/2026-08-29-fisherman-reliability-design.md +45 -0
  106. package/docs/superpowers/specs/2026-08-29-region-states-design.md +262 -0
  107. package/docs/superpowers/specs/2026-08-29-region-states-fixes-design.md +269 -0
  108. package/docs/superpowers/specs/2026-08-30-fisherman-live-session-auth-design.md +37 -0
  109. package/docs/workflow/agentic-usage.md +12 -2
  110. package/docs/workflow/ci.md +1 -0
  111. package/docs/workflow/knowledge.md +46 -2
  112. package/package.json +2 -2
  113. package/rules/planner/styles/normal.md +1 -1
  114. package/src/action-result.ts +61 -22
  115. package/src/action.ts +87 -14
  116. package/src/ai/captain.ts +1 -1
  117. package/src/ai/driller.ts +7 -39
  118. package/src/ai/fisherman-tools.ts +56 -7
  119. package/src/ai/fisherman.ts +48 -28
  120. package/src/ai/historian/codeceptjs.ts +1 -1
  121. package/src/ai/historian/playwright.ts +1 -1
  122. package/src/ai/navigator.ts +1 -1
  123. package/src/ai/pilot.ts +9 -1
  124. package/src/ai/planner.ts +9 -8
  125. package/src/ai/provider.ts +21 -3
  126. package/src/ai/researcher/deep-analysis.ts +1 -2
  127. package/src/ai/researcher.ts +4 -3
  128. package/src/ai/rules.ts +3 -3
  129. package/src/ai/tester.ts +40 -3
  130. package/src/ai/tools.ts +17 -9
  131. package/src/api/request-result.ts +10 -2
  132. package/src/api/request-store.ts +60 -13
  133. package/src/api/spec-reader.ts +1 -1
  134. package/src/commands/config-command.ts +1 -1
  135. package/src/commands/drill-command.ts +1 -1
  136. package/src/commands/explore-command.ts +37 -3
  137. package/src/commands/freesail-command.ts +7 -1
  138. package/src/commands/options/base-option.ts +18 -0
  139. package/src/commands/options/index.ts +7 -0
  140. package/src/commands/options/knowledge-option.ts +14 -0
  141. package/src/commands/options/ws-option.ts +24 -0
  142. package/src/commands/plans-command.ts +6 -6
  143. package/src/config.ts +12 -0
  144. package/src/experience-tracker.ts +5 -1
  145. package/src/explorbot.ts +21 -37
  146. package/src/knowledge-tracker.ts +94 -36
  147. package/src/remote.ts +0 -20
  148. package/src/state-manager.ts +13 -7
  149. package/src/test-plan.ts +29 -0
  150. package/src/utils/aria.ts +7 -44
  151. package/src/utils/html-diff.ts +62 -7
  152. package/src/utils/html.ts +14 -91
  153. package/src/utils/overlay.ts +226 -23
  154. package/src/utils/request-map.ts +19 -0
  155. package/src/utils/url-matcher.ts +3 -2
@@ -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)}');`);
@@ -98,7 +98,7 @@ export function WithPlaywright<T extends Constructor>(Base: T) {
98
98
  lines.push('');
99
99
  lines.push(`test.describe('${escapeString(plan.title)}', () => {`);
100
100
 
101
- const startUrl = plan.url || plan.tests[0]?.startUrl;
101
+ const startUrl = plan.startUrl;
102
102
  if (startUrl) {
103
103
  lines.push(' test.beforeEach(async ({ page }) => {');
104
104
  lines.push(` await page.goto('${escapeString(startUrl)}');`);
@@ -575,7 +575,7 @@ class Navigator implements Agent {
575
575
  }
576
576
 
577
577
  const currentActionResult = actionResult || ActionResult.fromState(state);
578
- const research = Researcher.getCachedResearch(state) || '';
578
+ const research = Researcher.getCachedResearch(currentActionResult) || '';
579
579
  const combinedHtml = await currentActionResult.combinedHtml();
580
580
 
581
581
  const history = stateManager.getStateHistory();
package/src/ai/pilot.ts CHANGED
@@ -765,6 +765,7 @@ export class Pilot implements Agent {
765
765
  const parts = [c.type];
766
766
  if (c.title) parts.push(`"${c.title}"`);
767
767
  if (c.id) parts.push(`(id: ${c.id})`);
768
+ if (c.request) parts.push(`via ${c.request}`);
768
769
  return parts.join(' ');
769
770
  });
770
771
  const stepText = `Precondition: created ${items.join(', ')}`;
@@ -827,7 +828,13 @@ export class Pilot implements Agent {
827
828
 
828
829
  const focusArea = state.overlay;
829
830
  if (focusArea.detected) {
830
- lines.push(`modal: ${focusArea.name || focusArea.type}`);
831
+ let line = `modal: ${focusArea.name || focusArea.type}`;
832
+ if (focusArea.root) line += ` (root: ${focusArea.root})`;
833
+ lines.push(line);
834
+ } else if (focusArea.present) {
835
+ let line = `region: ${focusArea.name || 'unnamed'} (inline`;
836
+ if (focusArea.root) line += `, root: ${focusArea.root}`;
837
+ lines.push(`${line})`);
831
838
  } else {
832
839
  lines.push('modal: none');
833
840
  }
@@ -1133,6 +1140,7 @@ export class Pilot implements Agent {
1133
1140
  Diagnostic patterns (use <state>, executed/element/skipped fields, ariaDiff):
1134
1141
  - Click failed + button in "disabled buttons" → required field missing. Instruct fill first.
1135
1142
  - "modal: none" but Tester targets a modal → modal closed; re-trigger.
1143
+ - "region:" in <state> → a large area appeared in place without navigation (subview, wizard step, panel). Direct Tester to act inside it; the rest of the page is still usable.
1136
1144
  - Action SUCCESS but ariaDiff empty → may have worked without visible DOM change; check result message.
1137
1145
  - MultipleElementsFound → xpathCheck() to identify the right one, then precise locator or visualClick().
1138
1146
  - Wrong page (settings vs feature) → getVisitedStates() then back() or reset(). Don't try breadcrumbs (SPA back-nav is unreliable).
package/src/ai/planner.ts CHANGED
@@ -114,11 +114,15 @@ export class Planner extends PlannerBase implements Agent {
114
114
  Tests must be relevant to the page
115
115
  Tests must be achievable from UI
116
116
  Tests must be verifiable from UI
117
- NEVER split one workflow into multiple tests. Each test must be a complete end-to-end flow.
117
+ One test verifies ONE business operation: the steps that reach it, the action itself, and its verification.
118
+ Steps that only reach the action — opening a form, expanding a panel, creating or locating the item to act on — belong to that test. A second operation with its own verification does not.
118
119
  Bad: "Open delete dropdown" + "Confirm deletion" — these are ONE test, not two.
119
120
  Bad: "Search for X" + "Verify search results" — searching and verifying is ONE test.
120
121
  Bad: "Leave field empty" + "Click submit" — that's one negative test, not two.
121
- If two scenarios cannot run independently (one requires the other to run first), merge them into one.${featureDirective}${focusExistingDataDirective}
122
+ Bad: "Create a record, rename it, delete it" three verified operations, so THREE tests, not one.
123
+ Good: "Rename existing record and verify the new title" — ONE test; creating is skipped, we assume record already exists, only the rename is verified.
124
+ You may rely on another test having run first in case we deal with empty state and no relevant data was created yet and we expect another our test creates it
125
+ When the page reports a record is missing or unavailable, it is not a testable surface — plan list-level or recovery behavior instead of operations on that record.${featureDirective}${focusExistingDataDirective}
122
126
  </task>
123
127
 
124
128
  ${customPrompt || ''}
@@ -195,10 +199,6 @@ export class Planner extends PlannerBase implements Agent {
195
199
  throw new Error('No tasks were created successfully');
196
200
  }
197
201
 
198
- if (aiResult.object.scenarios.length === 0 && !this.currentPlan) {
199
- throw new Error('No tasks were created successfully');
200
- }
201
-
202
202
  const defaultStartUrl = this.getDefaultStartUrl(state);
203
203
  const fromPlanning = aiResult.object.scenarios.map((s: any) => new Test(s.scenario, s.priority, s.expectedOutcomes, s.startUrl || defaultStartUrl, s.steps || []));
204
204
 
@@ -335,6 +335,7 @@ export class Planner extends PlannerBase implements Agent {
335
335
  <task>
336
336
  Based on the page research, create ${this.MIN_TASKS}-${this.MAX_TASKS} exploratory testing scenarios.
337
337
  For each scenario provide specific steps and expected outcomes.
338
+ Exception: if the page reports the requested resource is missing, shows a failure state, or holds no content and no controls, return an empty scenarios list. Never invent tests for a page with nothing to exercise.
338
339
  </task>
339
340
 
340
341
  <rules>
@@ -348,13 +349,13 @@ export class Planner extends PlannerBase implements Agent {
348
349
  Focus on error or success messages as outcome.
349
350
  Focus on URL page change or data persistency after page reload.
350
351
  If there are subpages (pages with same URL path) plan testing of those subpages as well
351
- If you plan to test CRUD operations, plan them in correct order: create, read, update.
352
+ Plan CRUD operations in order: create, read, update, delete.
352
353
  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.
353
354
  When validation placement or wording was not observed, require feedback associated with the invalid input without inventing a specific location or message.
354
355
  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.
355
356
  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.
356
357
  Only propose scenarios whose prerequisites are evident from page research, visited pages, or API data preparation context.
357
- If a scenario needs existing records, recipients, results, notifications, or other target data, propose it only when that data is visible or API preconditions can create it.
358
+ 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.
358
359
  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.
359
360
  Do not assume hidden data exists just because a control is present.
360
361
  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.
@@ -426,10 +426,20 @@ export class Provider {
426
426
  const config = this.buildGenerateConfig({ tools: toolsWithCommentary, maxOutputTokens: 16384, toolChoice: 'auto', experimental_repairToolCall: repairToolCall }, { stopWhen: stopConditions, model }, options);
427
427
  let attemptMessages = messages;
428
428
  let invalidRequestFeedbackAdded = false;
429
+ const executedStepMessages: ModelMessage[] = [];
429
430
  try {
430
431
  const response = await this.withModelRequestSlot(() =>
431
432
  withRetry(async () => {
432
- const result = (await this.raceWithIdleTimeout((signal) => generateText({ messages: attemptMessages, ...config, abortSignal: signal }), config.timeout || 30000).catch((error) => {
433
+ const stepMessages: ModelMessage[] = [];
434
+ const onStepEnd = (step: any) => {
435
+ stepMessages.push(...(step.response?.messages || []));
436
+ };
437
+ const result = (await this.raceWithIdleTimeout((signal) => generateText({ messages: attemptMessages, ...config, abortSignal: signal, onStepEnd }), config.timeout || 30000).catch((error) => {
438
+ if (stepMessages.length > 0) {
439
+ tag('warning').log(`Keeping ${stepMessages.length} messages from tool steps that already ran before the failure`);
440
+ executedStepMessages.push(...stepMessages);
441
+ attemptMessages = [...attemptMessages, ...stepMessages];
442
+ }
433
443
  if (!invalidRequestFeedbackAdded) {
434
444
  const amended = withInvalidRequestFeedback(attemptMessages, error);
435
445
  invalidRequestFeedbackAdded = amended !== attemptMessages;
@@ -448,6 +458,8 @@ export class Provider {
448
458
 
449
459
  clearActivity();
450
460
 
461
+ withExecutedSteps(response, executedStepMessages);
462
+
451
463
  // Log tool usage summary
452
464
  if (response.toolCalls && response.toolCalls.length > 0) {
453
465
  responseLog(response.toolCalls);
@@ -462,12 +474,13 @@ export class Provider {
462
474
  } catch (error: any) {
463
475
  clearActivity();
464
476
  if (error?.message?.includes('Tool choice is required')) {
465
- return { text: '', toolCalls: [], toolResults: [], response: { messages: [] }, usage: null };
477
+ return { text: '', toolCalls: [], toolResults: [], responseMessages: executedStepMessages, usage: null };
466
478
  }
467
479
  if (error?.name === 'AbortError') throw error;
468
480
  if (error instanceof ContextLengthError) throw error;
469
481
  if (Provider.isContextLengthError(error)) {
470
- return this.recoverFromContextLength(error, messages, options, (m, o) => this.generateWithTools(m, model, tools, o));
482
+ const recovered = await this.recoverFromContextLength(error, attemptMessages, options, (m, o) => this.generateWithTools(m, model, tools, o));
483
+ return withExecutedSteps(recovered, executedStepMessages);
471
484
  }
472
485
  if (error.constructor?.name === 'AI_APICallError') {
473
486
  responseLog(error.message);
@@ -703,6 +716,11 @@ function repairToolCall(options: ToolCallRepairOptions): any | null {
703
716
  return repairHarmonyChannel(options);
704
717
  }
705
718
 
719
+ function withExecutedSteps(result: any, executed: ModelMessage[]): any {
720
+ if (executed.length === 0) return result;
721
+ return Object.defineProperty(result, 'responseMessages', { value: [...executed, ...(result.responseMessages || [])], configurable: true, enumerable: true });
722
+ }
723
+
706
724
  function withInvalidRequestFeedback(messages: ModelMessage[], error: unknown): ModelMessage[] {
707
725
  if (!(error instanceof APICallError) || error.statusCode !== 400) return messages;
708
726
  tag('warning').log('Provider rejected the request as invalid — relaying its reason before the retry');
@@ -89,8 +89,7 @@ export function WithDeepAnalysis<T extends Constructor>(Base: T) {
89
89
 
90
90
  async researchOverlay(current: ActionResult, previous: ActionResult, pageStateHash: string): Promise<string | null> {
91
91
  const focusArea = current.overlay;
92
- if (!focusArea.detected || !focusArea.name) return null;
93
- if (focusArea.type !== 'dialog' && focusArea.type !== 'modal') return null;
92
+ if (!focusArea.present || !focusArea.name) return null;
94
93
 
95
94
  const cached = getCachedResearch(pageStateHash);
96
95
  if (!cached) return null;
@@ -76,7 +76,8 @@ export class Researcher extends ResearcherBase implements Agent {
76
76
  }
77
77
 
78
78
  static getCachedResearch(state: WebPageState): string {
79
- return getCachedResearch(state.hash || '');
79
+ if (state instanceof ActionResult) return getCachedResearch(state.baseHash);
80
+ return getCachedResearch(ActionResult.fromState(state).baseHash);
80
81
  }
81
82
 
82
83
  getSystemMessage(): string {
@@ -96,7 +97,7 @@ export class Researcher extends ResearcherBase implements Agent {
96
97
  const maxRetries = (this.config.ai?.agents?.researcher as any)?.retries ?? 2;
97
98
  let retriesLeft = opts._retriesLeft ?? maxRetries;
98
99
  this.actionResult = ActionResult.fromState(state);
99
- const stateHash = state.hash || this.actionResult.getStateHash();
100
+ const stateHash = this.actionResult.baseHash;
100
101
  const researchState = { ...state, hash: stateHash };
101
102
 
102
103
  if (!force && stateHash) {
@@ -268,7 +269,7 @@ export class Researcher extends ResearcherBase implements Agent {
268
269
 
269
270
  if (!interrupted() && deep) {
270
271
  try {
271
- await this.performDeepAnalysis(state, result);
272
+ await this.performDeepAnalysis(researchState, result);
272
273
  } catch (err) {
273
274
  tag('warning').log(`Deep analysis failed, continuing with best-effort research: ${err instanceof Error ? err.message : err}`);
274
275
  }
package/src/ai/rules.ts CHANGED
@@ -154,11 +154,11 @@ export const protectionRule = dedent`
154
154
 
155
155
  Pre-existing data on the page belongs to the application, not the test.
156
156
  Items that were not created inside the current test scenario must not be deleted, removed, emptied, reset, archived, or otherwise destroyed.
157
- If a scenario needs to verify destructive behaviour, the same scenario must first create a disposable target and then destroy that specific target — never operate on data that was already there when the test started.
157
+ If a scenario needs to verify destructive behaviour, the same scenario must first create its own target and then destroy that specific target — never operate on data that was already there when the test started.
158
158
 
159
159
  The resource that the current page URL represents is "under test".
160
160
  The test must not destroy the resource it is running against — doing so invalidates every subsequent scenario that starts on the same URL.
161
- Do not propose or perform delete/remove/archive actions on the entity that owns the current URL; propose such actions only on disposable children created within the scenario itself.
161
+ Do not propose or perform delete/remove/archive actions on the entity that owns the current URL; propose such actions only on children created within the scenario itself.
162
162
  </important>
163
163
  `;
164
164
 
@@ -174,7 +174,7 @@ export const dataProtectionRules = dedent`
174
174
  filter, tab, or list-inspection constraint. Use visible existing data when it is available.
175
175
  If no suitable data exists, report the missing precondition instead of creating data.
176
176
 
177
- Destructive actions are allowed only against disposable data created by the current scenario
177
+ Destructive actions are allowed only against data created by the current scenario
178
178
  or prepared for that scenario by Fisherman/API preconditions. Existing application data must
179
179
  remain unchanged.
180
180
  </data_protection_rules>
package/src/ai/tester.ts CHANGED
@@ -63,6 +63,8 @@ export class Tester extends TaskAgent implements Agent {
63
63
  private seenUiMapUrls = new Set<string>();
64
64
  private lastAnalyzedStateHash: string | null = null;
65
65
  private stalledIterations = 0;
66
+ private previousRegionPresent: boolean | null = null;
67
+ private regionTransitioned = false;
66
68
  private readonly MAX_STALLED_ITERATIONS = 3;
67
69
 
68
70
  private skipResearch = (err: Error): string => {
@@ -117,6 +119,8 @@ export class Tester extends TaskAgent implements Agent {
117
119
  this.seenUiMapUrls.clear();
118
120
  this.lastAnalyzedStateHash = null;
119
121
  this.stalledIterations = 0;
122
+ this.previousRegionPresent = null;
123
+ this.regionTransitioned = false;
120
124
  this.stateManager.clearHistory();
121
125
  this.resetFailureCount();
122
126
  this.pilot?.reset();
@@ -246,6 +250,7 @@ export class Tester extends TaskAgent implements Agent {
246
250
  const codeceptjsTools = createCodeceptJSTools(this.toolDeps, task);
247
251
  let assertionPerformed = false;
248
252
  let extensions = 0;
253
+ let deadlineReached = false;
249
254
  let shouldContinue = true;
250
255
 
251
256
  while (shouldContinue) {
@@ -253,6 +258,12 @@ export class Tester extends TaskAgent implements Agent {
253
258
 
254
259
  await loop(
255
260
  async ({ stop, pause, iteration, userInput }) => {
261
+ if (opts.deadline != null && Date.now() >= opts.deadline) {
262
+ deadlineReached = true;
263
+ task.addNote('Time budget reached. Stopped');
264
+ stop();
265
+ return;
266
+ }
256
267
  debugLog('iteration', iteration);
257
268
  if (!(await this.explorer.recover()).ok) {
258
269
  task.addNote('Browser page is unavailable');
@@ -420,6 +431,7 @@ export class Tester extends TaskAgent implements Agent {
420
431
  );
421
432
 
422
433
  if (task.hasFinished) break;
434
+ if (deadlineReached) break;
423
435
 
424
436
  if (!(await this.explorer.recover()).ok) break;
425
437
 
@@ -460,6 +472,10 @@ export class Tester extends TaskAgent implements Agent {
460
472
  }
461
473
 
462
474
  private shouldAnalyzeProgress(iteration: number, currentState: ActionResult): boolean {
475
+ if (this.regionTransitioned) {
476
+ this.regionTransitioned = false;
477
+ return true;
478
+ }
463
479
  if (this.consecutiveFailures >= 3) return true;
464
480
  if (this.consecutiveEmptyResults >= 2) return true;
465
481
  if (iteration % this.progressCheckInterval !== 0) return false;
@@ -530,6 +546,12 @@ export class Tester extends TaskAgent implements Agent {
530
546
  const currentStateHash = currentState.hash;
531
547
 
532
548
  const isNewUrl = this.previousUrl !== currentUrl;
549
+ const isNewState = !isNewUrl && this.previousStateHash !== null && this.previousStateHash !== currentStateHash;
550
+
551
+ if (this.previousRegionPresent !== null && this.previousRegionPresent !== currentState.overlay.present) {
552
+ this.regionTransitioned = true;
553
+ }
554
+ this.previousRegionPresent = currentState.overlay.present;
533
555
 
534
556
  this.previousUrl = currentUrl;
535
557
  this.previousStateHash = currentStateHash;
@@ -557,9 +579,11 @@ export class Tester extends TaskAgent implements Agent {
557
579
 
558
580
  if (focusArea.detected) {
559
581
  const areaName = focusArea.name ? ` "${focusArea.name}"` : '';
582
+ let rootHint = '';
583
+ if (focusArea.root) rootHint = `\nIts content lives inside \`${focusArea.root}\` — scope locators to it.`;
560
584
  context += dedent`
561
585
  <focus_scope>
562
- A ${focusArea.type}${areaName} is currently open above the page.
586
+ A ${focusArea.type}${areaName} is currently open above the page.${rootHint}
563
587
  Scope all interactions to elements inside this ${focusArea.type}.
564
588
  Page navigation, filters, and tabs that exist outside it are not actionable while it is open and may share names or roles with elements inside it — prefer the locator inside the ${focusArea.type}.
565
589
  Use <page_aria> to confirm the element you target is actually inside the ${focusArea.type}.
@@ -567,6 +591,18 @@ export class Tester extends TaskAgent implements Agent {
567
591
  `;
568
592
  }
569
593
 
594
+ if (!focusArea.detected && focusArea.present && isNewState) {
595
+ let rootHint = '';
596
+ if (focusArea.root) rootHint = `\nIt lives inside \`${focusArea.root}\`.`;
597
+ context += dedent`
598
+ <area_of_interest>
599
+ A large new area "${focusArea.name || 'unnamed area'}" appeared on this page without navigation.${rootHint}
600
+ The scenario most likely continues inside this area — prefer its elements for your next actions.
601
+ The rest of the page (navigation, menus, filters) is still interactive and remains available.
602
+ </area_of_interest>
603
+ `;
604
+ }
605
+
570
606
  if (currentState.isInsideIframe) {
571
607
  const iframeInfo = currentState.iframeURL || 'iframe context active';
572
608
  context += dedent`
@@ -589,7 +625,7 @@ export class Tester extends TaskAgent implements Agent {
589
625
  if (!alreadySeenUiMap) {
590
626
  research = await this.researcher.research(currentState).catch(this.skipResearch);
591
627
  }
592
- this.pageStateHash = currentStateHash;
628
+ this.pageStateHash = currentState.baseHash;
593
629
  this.pageActionResult = currentState;
594
630
  let uiMapSection = '';
595
631
  if (research) {
@@ -627,7 +663,7 @@ export class Tester extends TaskAgent implements Agent {
627
663
  return context;
628
664
  }
629
665
 
630
- if (focusArea.detected && focusArea.name && this.pageStateHash && this.pageActionResult) {
666
+ if (focusArea.present && focusArea.name && this.pageStateHash && this.pageActionResult) {
631
667
  const overlaySection = await this.researcher.researchOverlay(currentState, this.pageActionResult, this.pageStateHash).catch(this.skipResearch);
632
668
  if (overlaySection) {
633
669
  context += dedent`
@@ -1172,4 +1208,5 @@ interface TestSessionHandlers {
1172
1208
 
1173
1209
  export interface TestOptions {
1174
1210
  startOnCurrentPage?: boolean;
1211
+ deadline?: number;
1175
1212
  }
package/src/ai/tools.ts CHANGED
@@ -8,8 +8,9 @@ import { Stats } from '../stats.ts';
8
8
  import { type Task, TestResult } from '../test-plan.js';
9
9
  import { LARGE_ARIA_CHANGE_THRESHOLD } from '../utils/aria.ts';
10
10
  import { isFatalBrowserError } from '../utils/browser-errors.ts';
11
+ import { cleanHtmlSnippet } from '../utils/html.ts';
11
12
  import { createDebug, tag } from '../utils/logger.js';
12
- import { compactErrorMessage } from '../utils/strings.ts';
13
+ import { compactErrorMessage, normalizeInlineText, truncate } from '../utils/strings.ts';
13
14
  import { pause } from '../utils/loop.js';
14
15
  import { WebElement } from '../utils/web-element.ts';
15
16
  import type { ToolDeps } from './agent.ts';
@@ -1332,20 +1333,23 @@ export function clickFailureSuggestion(attempts: Array<{ error?: string }>): str
1332
1333
  }
1333
1334
 
1334
1335
  const MAX_DISAMBIGUATE_ELEMENTS = 10;
1336
+ const MAX_DISAMBIGUATE_TEXT = 80;
1337
+ const MAX_DISAMBIGUATE_HTML = 300;
1335
1338
  const MULTIPLE_ELEMENTS_PATTERN = 'multiple elements';
1336
1339
 
1337
- async function extractWebElements(error: Error | null | undefined): Promise<Array<{ xpath: string; html: string }> | null> {
1340
+ async function extractWebElements(error: Error | null | undefined): Promise<Array<{ xpath: string; html: string; text: string }> | null> {
1338
1341
  if (!error || error.name !== 'MultipleElementsFound') return null;
1339
1342
 
1340
- const elements = (error as any).webElements as Array<{ toAbsoluteXPath: () => Promise<string>; toSimplifiedHTML: () => Promise<string> }> | undefined;
1343
+ const elements = (error as any).webElements as Array<{ toAbsoluteXPath: () => Promise<string>; toOuterHTML: () => Promise<string>; getText: () => Promise<string | null> }> | undefined;
1341
1344
  if (!elements?.length) return null;
1342
1345
 
1343
- const result: Array<{ xpath: string; html: string }> = [];
1346
+ const result: Array<{ xpath: string; html: string; text: string }> = [];
1344
1347
  for (let i = 0; i < Math.min(elements.length, MAX_DISAMBIGUATE_ELEMENTS); i++) {
1345
1348
  try {
1346
1349
  const xpath = await elements[i].toAbsoluteXPath();
1347
- const html = await elements[i].toSimplifiedHTML();
1348
- result.push({ xpath, html });
1350
+ const html = truncate(cleanHtmlSnippet(await elements[i].toOuterHTML()), MAX_DISAMBIGUATE_HTML);
1351
+ const text = truncate(normalizeInlineText((await elements[i].getText()) || ''), MAX_DISAMBIGUATE_TEXT);
1352
+ result.push({ xpath, html, text });
1349
1353
  } catch (e) {
1350
1354
  debugLog('Failed to get details for element %d: %s', i, e);
1351
1355
  }
@@ -1353,17 +1357,21 @@ async function extractWebElements(error: Error | null | undefined): Promise<Arra
1353
1357
  return result.length > 0 ? result : null;
1354
1358
  }
1355
1359
 
1356
- async function formatMatchedElements(error: Error | null | undefined): Promise<string | null> {
1360
+ function formatElementList(details: Array<{ xpath: string; html: string; text: string }>): string {
1361
+ return details.map((el, i) => `Element ${i + 1}:\nText: "${el.text}"\nXPath: ${el.xpath}\nHTML: ${el.html}`).join('\n\n');
1362
+ }
1363
+
1364
+ export async function formatMatchedElements(error: Error | null | undefined): Promise<string | null> {
1357
1365
  const details = await extractWebElements(error);
1358
1366
  if (!details) return 'Could not fetch element details. Repeat the action to get better info.';
1359
- return details.map((el, i) => `Element ${i + 1}\nXPath: ${el.xpath}\nHTML: ${el.html}`).join('\n\n');
1367
+ return formatElementList(details);
1360
1368
  }
1361
1369
 
1362
1370
  async function disambiguateElements(error: Error | null | undefined, explanation: string, provider: AIProvider): Promise<{ position: number; xpath: string } | null> {
1363
1371
  const elementDetails = await extractWebElements(error);
1364
1372
  if (!elementDetails) return null;
1365
1373
 
1366
- const elementList = elementDetails.map((el, i) => `Element ${i + 1}:\nXPath: ${el.xpath}\nHTML: ${el.html}`).join('\n\n');
1374
+ const elementList = formatElementList(elementDetails);
1367
1375
 
1368
1376
  const schema = z.object({
1369
1377
  position: z.number().nullable().describe('1-based position of the correct element, or null if none match'),
@@ -85,6 +85,10 @@ export class RequestResult {
85
85
  }
86
86
  }
87
87
 
88
+ get isWrite(): boolean {
89
+ return ['POST', 'PUT', 'PATCH', 'DELETE'].includes(this.method);
90
+ }
91
+
88
92
  save(outputDir: string): void {
89
93
  const requestsDir = path.join(outputDir, 'requests');
90
94
  if (!existsSync(requestsDir)) {
@@ -169,7 +173,7 @@ export class RequestResult {
169
173
  statusText: meta.statusText || '',
170
174
  responseHeaders: meta.responseHeaders || {},
171
175
  timing: Number.parseInt(meta.timing) || 0,
172
- timestamp: new Date(meta.timestamp || Date.now()),
176
+ timestamp: new Date(meta.timestamp || 0),
173
177
  });
174
178
 
175
179
  result.requestFile = requestFile;
@@ -178,8 +182,12 @@ export class RequestResult {
178
182
  return result;
179
183
  }
180
184
 
185
+ toEndpoint(): string {
186
+ return `${this.method} ${this.path}`;
187
+ }
188
+
181
189
  toSummary(): string {
182
- return `${this.method} ${this.path} → ${this.status} (${this.timing}ms)`;
190
+ return `${this.toEndpoint()} → ${this.status} (${this.timing}ms)`;
183
191
  }
184
192
 
185
193
  extractIdAndTitle(): { id?: string | number; title?: string } {