explorbot 0.4.3 → 0.4.5

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 (87) hide show
  1. package/boat/api-tester/src/apibot.ts +8 -13
  2. package/boat/api-tester/src/cli.ts +7 -3
  3. package/boat/api-tester/src/config.ts +45 -9
  4. package/boat/prima/src/cli.ts +33 -99
  5. package/boat/prima/src/envelope.ts +3 -1
  6. package/boat/prima/src/help.ts +72 -0
  7. package/boat/prima/src/prima.ts +41 -46
  8. package/dist/boat/api-tester/src/apibot.js +7 -6
  9. package/dist/boat/api-tester/src/cli.js +9 -3
  10. package/dist/boat/api-tester/src/config.js +32 -6
  11. package/dist/boat/prima/src/cli.js +30 -86
  12. package/dist/boat/prima/src/envelope.js +2 -1
  13. package/dist/boat/prima/src/help.js +63 -0
  14. package/dist/boat/prima/src/prima.js +39 -44
  15. package/dist/package.json +1 -1
  16. package/dist/src/action-result.d.ts +3 -0
  17. package/dist/src/action-result.js +5 -0
  18. package/dist/src/action.js +12 -1
  19. package/dist/src/ai/fisherman/tools.js +7 -1
  20. package/dist/src/ai/fisherman.js +2 -1
  21. package/dist/src/ai/pilot.d.ts +0 -1
  22. package/dist/src/ai/pilot.js +8 -24
  23. package/dist/src/ai/planner.d.ts +4 -0
  24. package/dist/src/ai/planner.js +28 -0
  25. package/dist/src/ai/provider.js +3 -1
  26. package/dist/src/ai/researcher/deep-analysis.d.ts +1 -1
  27. package/dist/src/ai/researcher/deep-analysis.js +4 -1
  28. package/dist/src/ai/researcher/sections.d.ts +1 -1
  29. package/dist/src/ai/researcher/sections.js +2 -1
  30. package/dist/src/ai/researcher.js +25 -11
  31. package/dist/src/ai/rules.js +8 -7
  32. package/dist/src/ai/scout/tools.d.ts +17 -0
  33. package/dist/src/ai/scout/tools.js +130 -0
  34. package/dist/src/ai/scout.d.ts +21 -0
  35. package/dist/src/ai/scout.js +150 -0
  36. package/dist/src/ai/tester.d.ts +1 -0
  37. package/dist/src/ai/tester.js +27 -33
  38. package/dist/src/ai/tools.js +61 -30
  39. package/dist/src/application-spec.d.ts +3 -0
  40. package/dist/src/application-spec.js +21 -5
  41. package/dist/src/commands/config-command.js +6 -2
  42. package/dist/src/config.d.ts +9 -1
  43. package/dist/src/config.js +14 -0
  44. package/dist/src/explorbot.d.ts +3 -0
  45. package/dist/src/explorbot.js +33 -0
  46. package/dist/src/knowledge-tracker.d.ts +1 -0
  47. package/dist/src/knowledge-tracker.js +3 -0
  48. package/dist/src/state-manager.js +5 -1
  49. package/dist/src/utils/aria-ref.d.ts +16 -0
  50. package/dist/src/utils/aria-ref.js +47 -0
  51. package/dist/src/utils/aria.js +3 -3
  52. package/dist/src/utils/web-annotate.js +3 -15
  53. package/dist/src/utils/web-element.d.ts +0 -2
  54. package/dist/src/utils/web-element.js +0 -8
  55. package/docs/api-testing/basics.md +12 -4
  56. package/docs/reference/commands.md +1 -0
  57. package/docs/reference/configuration.md +28 -1
  58. package/docs/web-testing/agents.md +9 -1
  59. package/docs/web-testing/planner.md +5 -0
  60. package/docs/workflow/agentic-usage.md +3 -1
  61. package/docs/workflow/application-spec.md +4 -0
  62. package/package.json +1 -1
  63. package/src/action-result.ts +7 -0
  64. package/src/action.ts +14 -2
  65. package/src/ai/fisherman/tools.ts +8 -1
  66. package/src/ai/fisherman.ts +2 -1
  67. package/src/ai/pilot.ts +8 -25
  68. package/src/ai/planner.ts +33 -0
  69. package/src/ai/provider.ts +2 -1
  70. package/src/ai/researcher/deep-analysis.ts +4 -2
  71. package/src/ai/researcher/sections.ts +2 -2
  72. package/src/ai/researcher.ts +28 -11
  73. package/src/ai/rules.ts +8 -7
  74. package/src/ai/scout/tools.ts +150 -0
  75. package/src/ai/scout.ts +173 -0
  76. package/src/ai/tester.ts +25 -30
  77. package/src/ai/tools.ts +75 -36
  78. package/src/application-spec.ts +22 -4
  79. package/src/commands/config-command.ts +4 -1
  80. package/src/config.ts +23 -0
  81. package/src/explorbot.ts +36 -0
  82. package/src/knowledge-tracker.ts +4 -0
  83. package/src/state-manager.ts +6 -1
  84. package/src/utils/aria-ref.ts +61 -0
  85. package/src/utils/aria.ts +3 -3
  86. package/src/utils/web-annotate.ts +3 -15
  87. package/src/utils/web-element.ts +0 -9
@@ -0,0 +1,150 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { resolve } from 'node:path';
3
+ import { tool } from 'ai';
4
+ import { createBashTool } from 'bash-tool';
5
+ import dedent from 'dedent';
6
+ import { z } from 'zod';
7
+ import { ConfigParser } from '../../config.ts';
8
+ import { tag } from '../../utils/logger.ts';
9
+ import { loadMarkdownFiles } from '../../utils/markdown-files.ts';
10
+ import { readCaptainFile } from '../captain/file-tools.ts';
11
+
12
+ const MAX_FILES = 500;
13
+ const MAX_FINDINGS = 6000;
14
+
15
+ let cachedScanner: 'rg' | 'grep' | null = null;
16
+
17
+ export function loadScoutCorpus(dirs: string[]): ScoutCorpus {
18
+ const files: ScoutCorpusFile[] = [];
19
+ for (const dir of dirs) {
20
+ if (files.length >= MAX_FILES) {
21
+ tag('warning').log(`Scout corpus capped at ${MAX_FILES} files — remaining directories skipped`);
22
+ break;
23
+ }
24
+ for (const file of loadMarkdownFiles(dir, { recursive: true })) {
25
+ if (files.length >= MAX_FILES) break;
26
+ const entry: ScoutCorpusFile = { path: file.filePath };
27
+ if (typeof file.data.url === 'string') entry.url = file.data.url;
28
+ files.push(entry);
29
+ }
30
+ }
31
+ return { dirs, files, excludedPaths: [] };
32
+ }
33
+
34
+ export function excludeCorpusUrls(corpus: ScoutCorpus, urls: string[]): ScoutCorpus {
35
+ if (urls.length === 0) return corpus;
36
+
37
+ const excludedUrls = new Set(urls);
38
+ const files: ScoutCorpusFile[] = [];
39
+ const excludedPaths = [...corpus.excludedPaths];
40
+ for (const file of corpus.files) {
41
+ if (file.url && excludedUrls.has(file.url)) {
42
+ excludedPaths.push(file.path);
43
+ continue;
44
+ }
45
+ files.push(file);
46
+ }
47
+ return { dirs: corpus.dirs, files, excludedPaths };
48
+ }
49
+
50
+ export async function createScoutTools(corpus: ScoutCorpus) {
51
+ const scanner = await detectScanner();
52
+ const projectRoot = ConfigParser.getInstance().getProjectRoot();
53
+
54
+ let result = '';
55
+ let searchedOrRead = false;
56
+
57
+ const getResult = () => result;
58
+ const finishFromText = (text?: string) => {
59
+ if (text && searchedOrRead) result = text.slice(0, MAX_FINDINGS);
60
+ };
61
+
62
+ const files: Record<string, string> = {};
63
+ const readableFiles = new Set<string>();
64
+ for (const file of corpus.files) {
65
+ files[toPosix(file.path)] = readFileSync(file.path, 'utf8');
66
+ readableFiles.add(resolve(file.path));
67
+ }
68
+
69
+ const toolkit = await createBashTool({
70
+ destination: '/',
71
+ files,
72
+ maxOutputLength: 20000,
73
+ });
74
+
75
+ const bashExecute = toolkit.bash.execute;
76
+ const bash = {
77
+ ...toolkit.bash,
78
+ execute: async (input: { command: string }) => {
79
+ tag('step').log(`Scout: bash ${input.command}`);
80
+ searchedOrRead = true;
81
+ return bashExecute?.(input);
82
+ },
83
+ };
84
+
85
+ const tools: Record<string, any> = {
86
+ bash,
87
+ readFile: tool({
88
+ description: dedent`
89
+ Read one documentation file from the corpus.
90
+ Pass the exact path returned by a search result.
91
+ `,
92
+ inputSchema: z.object({
93
+ path: z.string().describe('File path from a search result'),
94
+ startLine: z.number().optional().describe('First line to read, 1-based. Negative values count from the end of the file'),
95
+ endLine: z.number().optional().describe('Last line to read, 1-based and inclusive. Negative values count from the end of the file'),
96
+ maxChars: z.number().optional().describe('Maximum characters to return, default 12000'),
97
+ }),
98
+ execute: async (input) => {
99
+ tag('step').log(`Scout: read ${input.path}`);
100
+ const output = readCaptainFile(projectRoot, input, corpus.dirs);
101
+ if (!output.success) return output;
102
+ const resolvedPath = resolve(projectRoot || process.cwd(), output.path);
103
+ if (!readableFiles.has(resolvedPath)) {
104
+ return { success: false, message: 'File is outside the Scout corpus' };
105
+ }
106
+ searchedOrRead = true;
107
+ return output;
108
+ },
109
+ }),
110
+ };
111
+
112
+ return { tools, scanner, getResult, finishFromText };
113
+ }
114
+
115
+ async function detectScanner(): Promise<'rg' | 'grep'> {
116
+ if (cachedScanner) return cachedScanner;
117
+ if (await binaryRuns('rg')) {
118
+ cachedScanner = 'rg';
119
+ return cachedScanner;
120
+ }
121
+ if (await binaryRuns('grep')) {
122
+ cachedScanner = 'grep';
123
+ return cachedScanner;
124
+ }
125
+ throw new Error('Scout requires ripgrep or grep on PATH — neither was found');
126
+ }
127
+
128
+ async function binaryRuns(binary: 'rg' | 'grep'): Promise<boolean> {
129
+ try {
130
+ const proc = Bun.spawn([binary, '--version'], { stdout: 'ignore', stderr: 'ignore', stdin: 'ignore' });
131
+ return (await proc.exited) === 0;
132
+ } catch {
133
+ return false;
134
+ }
135
+ }
136
+
137
+ function toPosix(path: string): string {
138
+ return path.split('\\').join('/');
139
+ }
140
+
141
+ export interface ScoutCorpus {
142
+ dirs: string[];
143
+ files: ScoutCorpusFile[];
144
+ excludedPaths: string[];
145
+ }
146
+
147
+ export interface ScoutCorpusFile {
148
+ path: string;
149
+ url?: string;
150
+ }
@@ -0,0 +1,173 @@
1
+ import dedent from 'dedent';
2
+ import { tag } from '../utils/logger.ts';
3
+ import { loop } from '../utils/loop.ts';
4
+ import type { Agent } from './agent.ts';
5
+ import type { Provider } from './provider.ts';
6
+ import { type ScoutCorpus, createScoutTools, excludeCorpusUrls } from './scout/tools.ts';
7
+
8
+ const MAX_ITERATIONS = 3;
9
+ const MAX_TOOL_ROUNDTRIPS = 5;
10
+ const CACHE_LIMIT = 40;
11
+ const URL_LISTING_LIMIT = 40;
12
+
13
+ export class Scout implements Agent {
14
+ emoji = '🔎';
15
+ private cache = new Map<string, string>();
16
+
17
+ constructor(
18
+ private provider: Provider,
19
+ private corpus: ScoutCorpus
20
+ ) {}
21
+
22
+ isAvailable(): boolean {
23
+ return this.corpus.files.length > 0;
24
+ }
25
+
26
+ async collectDocs(query: ScoutQuery): Promise<string> {
27
+ if (!this.isAvailable()) return '';
28
+
29
+ const cacheKey = `${query.url || ''}|${query.feature || ''}|${query.excludeUrls.join(',')}`;
30
+ const cached = this.cache.get(cacheKey);
31
+ if (cached !== undefined) return cached;
32
+
33
+ const corpus = excludeCorpusUrls(this.corpus, query.excludeUrls);
34
+ if (corpus.files.length === 0) return '';
35
+
36
+ const result = await this.runSession(corpus, query);
37
+ if (result === null) return '';
38
+
39
+ if (this.cache.size > CACHE_LIMIT) this.cache.clear();
40
+ this.cache.set(cacheKey, result);
41
+ return result;
42
+ }
43
+
44
+ private async runSession(corpus: ScoutCorpus, query: ScoutQuery): Promise<string | null> {
45
+ const { tools, scanner, getResult, finishFromText } = await createScoutTools(corpus);
46
+ const conversation = this.provider.startConversation(this.buildSystemPrompt(Object.keys(tools), corpus, query, scanner), 'scout', this.provider.getAgenticModel('scout'));
47
+ conversation.addUserText(this.buildTaskPrompt(query));
48
+
49
+ tag('info').log(`Scout: collecting documentation for ${query.feature || query.url || 'the current page'}`);
50
+
51
+ let failed = false;
52
+ await loop(
53
+ async ({ stop, iteration }) => {
54
+ const invokeResult = await this.provider.invokeConversation(conversation, tools, {
55
+ maxToolRoundtrips: MAX_TOOL_ROUNDTRIPS,
56
+ agentName: 'scout',
57
+ });
58
+
59
+ if (!invokeResult?.toolExecutions?.length) {
60
+ finishFromText(invokeResult?.response?.text);
61
+ stop();
62
+ return;
63
+ }
64
+
65
+ if (iteration >= MAX_ITERATIONS) {
66
+ const final = await this.provider.invokeConversation(conversation, undefined, { agentName: 'scout' });
67
+ finishFromText(final?.response?.text);
68
+ stop();
69
+ }
70
+ },
71
+ {
72
+ maxAttempts: MAX_ITERATIONS,
73
+ observability: { name: `scout: ${query.feature || query.url || 'docs'}`, agent: 'scout' },
74
+ catch: async ({ error, stop }) => {
75
+ failed = true;
76
+ tag('warning').log(`Scout error: ${(error as Error).message}`);
77
+ stop();
78
+ },
79
+ }
80
+ );
81
+
82
+ if (failed) return null;
83
+
84
+ const digest = getResult();
85
+ if (digest) {
86
+ const preview = digest.slice(0, 600);
87
+ const ellipsis = digest.length > 600 ? '…' : '';
88
+ tag('info').log(`Scout digest:\n${preview}${ellipsis}`);
89
+ }
90
+ return digest;
91
+ }
92
+
93
+ private buildSystemPrompt(toolNames: string[], corpus: ScoutCorpus, query: ScoutQuery, scanner: 'rg' | 'grep'): string {
94
+ const urls = corpus.files.map((file) => file.url).filter(Boolean) as string[];
95
+ const urlless = corpus.files.filter((file) => !file.url);
96
+ let pagesListing = '';
97
+ if (urls.length > 0) {
98
+ const listing = urls
99
+ .slice(0, URL_LISTING_LIMIT)
100
+ .map((url) => `- ${url}`)
101
+ .join('\n');
102
+ pagesListing = `Documented pages:\n${listing}`;
103
+ const remaining = urls.length - URL_LISTING_LIMIT;
104
+ if (remaining > 0) pagesListing += `\n…and ${remaining} more — find them with ${scanner}`;
105
+ }
106
+ if (urlless.length > 0) {
107
+ const listing = urlless
108
+ .slice(0, URL_LISTING_LIMIT)
109
+ .map((file) => `- ${toPosix(file.path)}`)
110
+ .join('\n');
111
+ pagesListing += `\nFiles with no page URL (hand-written docs):\n${listing}`;
112
+ }
113
+
114
+ const prompt = dedent`
115
+ You are Scout — a documentation retrieval agent. You find collected documentation relevant to a testing focus and report it for test planning.
116
+
117
+ You never see the application itself. The documentation corpus is your only source of truth.
118
+
119
+ CORPUS:
120
+ ${corpus.files.length} markdown files under:
121
+ - ${corpus.dirs.map(toPosix).join('\n- ')}
122
+ ${pagesListing}
123
+
124
+ These pages are already provided to the planner in full — do not re-report them:
125
+ ${query.excludeUrls.map((url) => `- ${url}`).join('\n') || '- none'}
126
+
127
+ AVAILABLE TOOLS:
128
+ ${toolNames.join(', ')}.
129
+ Use tool names exactly as listed. Do not invent aliases or combined names.
130
+ Match each tool input schema exactly. Do not invent parameter names or pass extra fields.
131
+
132
+ SCANNER:
133
+ ${scanner} is the search command. Scan the working directory through bash() — explore freely, pipelines, globs and repeated searches are fine. Read files with readFile().
134
+
135
+ WORKFLOW:
136
+ 1. Scan with ${scanner} using plain prose words from the focus — feature names, page purposes, capabilities
137
+ 2. Read the files whose hits look most relevant
138
+ 3. Report the digest as your final message — no tool call is needed to finish
139
+
140
+ RULES:
141
+ - Report only what the documentation states. Never fill gaps with assumptions about the application
142
+ - Keep verified capabilities and unverified possibilities distinguishable, the way the documentation marks them
143
+ - Name the page URL each item belongs to, so scenarios anchor to real routes
144
+ - Explore briefly: a few scans and reads are enough, then report
145
+ - A short accurate digest beats a long loose one; reporting that nothing relevant exists is a valid answer
146
+ `;
147
+
148
+ const customPrompt = this.provider.getSystemPromptForAgent('scout');
149
+ if (customPrompt) return `${prompt}\n\n${customPrompt}`;
150
+ return prompt;
151
+ }
152
+
153
+ private buildTaskPrompt(query: ScoutQuery): string {
154
+ return dedent`
155
+ Page URL: ${query.url || 'Unknown'}
156
+ Page title: ${query.title || 'Unknown'}
157
+ Focus: ${query.feature || 'the page as a whole'}
158
+
159
+ Report the documented capabilities, states and transitions a test planner could turn into scenarios.
160
+ `;
161
+ }
162
+ }
163
+
164
+ function toPosix(path: string): string {
165
+ return path.split('\\').join('/');
166
+ }
167
+
168
+ export interface ScoutQuery {
169
+ url?: string;
170
+ title?: string;
171
+ feature?: string;
172
+ excludeUrls: string[];
173
+ }
package/src/ai/tester.ts CHANGED
@@ -53,6 +53,7 @@ export class Tester extends TaskAgent implements Agent {
53
53
  MAX_ITERATIONS = 30;
54
54
  MAX_EXTENSIONS = 2;
55
55
  ASSERTION_TOOLS = ['verify'];
56
+ private pendingReview = '';
56
57
  researcher: Researcher;
57
58
  navigator: Navigator;
58
59
  agentTools: any;
@@ -119,6 +120,7 @@ export class Tester extends TaskAgent implements Agent {
119
120
  this.seenUiMapUrls.clear();
120
121
  this.lastAnalyzedStateHash = null;
121
122
  this.stalledIterations = 0;
123
+ this.pendingReview = '';
122
124
  this.previousRegionPresent = null;
123
125
  this.regionTransitioned = false;
124
126
  this.stateManager.clearHistory();
@@ -333,7 +335,7 @@ export class Tester extends TaskAgent implements Agent {
333
335
  const result = await this.provider.invokeConversation(conversation, tools, {
334
336
  maxToolRoundtrips: 3,
335
337
  toolChoice: 'required',
336
- stopWhen: () => task.hasFinished,
338
+ stopWhen: () => task.hasFinished || !!this.pendingReview,
337
339
  });
338
340
 
339
341
  if (!result) throw new Error('Failed to get response from provider');
@@ -388,6 +390,14 @@ export class Tester extends TaskAgent implements Agent {
388
390
  }
389
391
  }
390
392
 
393
+ if (this.pendingReview && this.pilot) {
394
+ const reviewed = this.pendingReview;
395
+ this.pendingReview = '';
396
+ const reviewState = this.getCurrentState();
397
+ if (reviewed === 'finish') await this.pilot.reviewFinish(task, reviewState, conversation, this.navigator);
398
+ if (reviewed === 'stop') await this.pilot.reviewStop(task, reviewState, conversation);
399
+ }
400
+
391
401
  if (task.hasFinished) {
392
402
  stop();
393
403
  return;
@@ -579,16 +589,19 @@ export class Tester extends TaskAgent implements Agent {
579
589
 
580
590
  if (region.isModal) {
581
591
  const areaName = region.name ? ` "${region.name}"` : '';
582
- let rootHint = '';
583
- if (region.root) rootHint = `\nIts content lives inside \`${region.root}\` — scope locators to it.`;
592
+ let scoping = 'Use <page_aria> to confirm the element you target is actually inside the overlay.';
593
+ if (region.root) {
594
+ scoping = `Its root is \`${region.root}\` — build every locator as ARIA scoped to that root, e.g. I.click({ role: 'button', text: 'Continue' }, '${region.root}')`;
595
+ }
584
596
  context += dedent`
585
597
  <overlay>
586
- An overlay${areaName} is currently open above the page.${rootHint}
587
- Scope all interactions to elements inside this overlay.
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 overlay.
589
- Use <page_aria> to confirm the element you target is actually inside the overlay.
590
- </overlay>
598
+ You are inside an overlay${areaName} opened above the page.
599
+ ${scoping}
600
+ Elements outside the overlay are behind it and not actionable while it is open they may share names or roles with the ones inside, so never target them by bare text.
591
601
  `;
602
+ const regionAria = currentState.getRegionARIA();
603
+ if (regionAria) context += `\nIt holds exactly these elements:\n<overlay_aria>\n${regionAria}\n</overlay_aria>`;
604
+ context += '\n</overlay>\n';
592
605
  }
593
606
 
594
607
  if (!region.isModal && region.isOpen && isNewState) {
@@ -1006,18 +1019,9 @@ export class Tester extends TaskAgent implements Agent {
1006
1019
  }),
1007
1020
  execute: async ({ reason }) => {
1008
1021
  task.addNote(`Stop requested: ${reason}`);
1022
+ this.pendingReview = 'stop';
1009
1023
 
1010
- if (this.pilot) {
1011
- const currentState = this.getCurrentState();
1012
- await this.pilot.reviewStop(task, currentState, conversation);
1013
- if (!task.hasFinished) {
1014
- return {
1015
- success: false,
1016
- action: 'stop',
1017
- message: 'Stop rejected; Continue execution',
1018
- };
1019
- }
1020
- } else {
1024
+ if (!this.pilot) {
1021
1025
  task.addNote(reason, TestResult.FAILED);
1022
1026
  task.finish(TestResult.FAILED);
1023
1027
  }
@@ -1053,18 +1057,9 @@ export class Tester extends TaskAgent implements Agent {
1053
1057
  return { success: true, action: 'finish', message: 'already finished' };
1054
1058
  }
1055
1059
  task.addNote(`Finish requested: ${verify}`);
1060
+ this.pendingReview = 'finish';
1056
1061
 
1057
- if (this.pilot) {
1058
- const currentState = this.getCurrentState();
1059
- await this.pilot.reviewFinish(task, currentState, conversation, this.navigator);
1060
- if (!task.hasFinished) {
1061
- return {
1062
- success: false,
1063
- action: 'finish',
1064
- message: 'Finishing rejected; Continue execution',
1065
- };
1066
- }
1067
- } else {
1062
+ if (!this.pilot) {
1068
1063
  task.addNote('Test finished successfully', TestResult.PASSED);
1069
1064
  task.finish(TestResult.PASSED);
1070
1065
  }
package/src/ai/tools.ts CHANGED
@@ -12,6 +12,7 @@ import { cleanHtmlSnippet } from '../utils/html.ts';
12
12
  import { createDebug, tag } from '../utils/logger.js';
13
13
  import { compactErrorMessage, normalizeInlineText, truncate } from '../utils/strings.ts';
14
14
  import { pause } from '../utils/loop.js';
15
+ import { ariaRefSelector, describeRef, refIsGone } from '../utils/aria-ref.ts';
15
16
  import { WebElement } from '../utils/web-element.ts';
16
17
  import type { ToolDeps } from './agent.ts';
17
18
  import { Navigator } from './navigator.ts';
@@ -66,7 +67,6 @@ export function createCodeceptJSTools({ explorer, stateManager }: ToolDeps, task
66
67
  2. I.click(ARIA, container) - e.g. I.click({"role":"button","text":"Save"}, ".modal")
67
68
  3. I.click(CSS, container) - e.g. I.click("#btn", ".modal")
68
69
  4. I.click(CSS) or I.click(XPath) - when locator already includes context (ID, XPath)
69
- 5. I.clickXY(x, y) - coordinates fallback
70
70
  After a result reporting multiple matches, reuse that locator with step.opts({ elementIndex: N }) as the last argument.
71
71
  `),
72
72
  explanation: z.string().describe('Why you are clicking this element'),
@@ -79,11 +79,21 @@ export function createCodeceptJSTools({ explorer, stateManager }: ToolDeps, task
79
79
  return failedToolResult('click', 'No commands provided');
80
80
  }
81
81
 
82
- const invalidCommands = rawCommands.map((cmd) => cmd.trim()).filter((cmd) => cmd.startsWith('I.') && !cmd.startsWith('I.click'));
82
+ const trimmedCommands = rawCommands.map((cmd) => cmd.trim());
83
+ const coordinateCommands = trimmedCommands.filter((cmd) => cmd.startsWith('I.clickXY'));
84
+
85
+ if (coordinateCommands.length > 0) {
86
+ activeNote.commit(TestResult.FAILED);
87
+ return failedToolResult('click', `Coordinate commands are not locators: ${coordinateCommands.join(', ')}. A coordinate click always runs, so it cannot tell you whether the element was there.`, {
88
+ suggestion: 'Name the element instead. Use visualClick() when you can see the target but cannot address it, or form() for a deliberate coordinate click such as dismissing a layer.',
89
+ });
90
+ }
91
+
92
+ const invalidCommands = trimmedCommands.filter((cmd) => cmd.startsWith('I.') && !cmd.startsWith('I.click'));
83
93
 
84
94
  if (invalidCommands.length > 0) {
85
95
  activeNote.commit(TestResult.FAILED);
86
- return failedToolResult('click', `Invalid commands: ${invalidCommands.join(', ')}. Click tool only accepts I.click() or I.clickXY() commands.`, {
96
+ return failedToolResult('click', `Invalid commands: ${invalidCommands.join(', ')}. Click tool only accepts I.click() commands.`, {
87
97
  suggestion: 'Use form() tool for typing text or multiple actions, or exitIframe() to leave iframe context.',
88
98
  });
89
99
  }
@@ -111,6 +121,17 @@ export function createCodeceptJSTools({ explorer, stateManager }: ToolDeps, task
111
121
 
112
122
  if (success) {
113
123
  const toolResult = await ActionResult.fromState(stateManager.getCurrentState()!).toToolResult(previousState, command);
124
+
125
+ if (!hasObservablePageChange(toolResult)) {
126
+ activeNote.commit(TestResult.FAILED);
127
+ return failedToolResult('click', 'Click executed, but no observable page change was captured.', {
128
+ ...toolResult,
129
+ attempts,
130
+ code: command,
131
+ suggestion: 'Treat the element as not clicked. It may be covered by another layer, disabled, or the locator may have matched a non-interactive ancestor. Re-locate via xpathCheck(), which reports whether the element is covered or offscreen, before retrying.',
132
+ });
133
+ }
134
+
114
135
  await commitNote(activeNote, TestResult.PASSED, toolResult, action);
115
136
  return successToolResult('click', { ...toolResult, attempts, code: command }, action);
116
137
  }
@@ -461,10 +482,18 @@ export function createRefTools({ explorer, stateManager }: ToolDeps, task: Task)
461
482
  }),
462
483
  execute: async ({ ref, element }) => {
463
484
  const activeNote = task.startNote(`Click ${element}`);
485
+
486
+ if (await refIsGone(explorer, ref)) {
487
+ activeNote.commit(TestResult.FAILED);
488
+ return failedToolResult('clickRef', `Ref ${ref} names no element on the page any more.`, {
489
+ suggestion: 'The page has been rebuilt since you were given that ref. Call context() and use the ref it gives, or fall back to click() with a locator.',
490
+ });
491
+ }
492
+
464
493
  const previousState = ActionResult.fromState(stateManager.getCurrentState()!);
465
494
  const action = explorer.action();
466
495
  const named = await describeRef(explorer, ref);
467
- const run = `I.usePlaywrightTo(${JSON.stringify(`click ${element}`)}, async ({ page }) => page.locator(${JSON.stringify(`aria-ref=${ref}`)}).click())`;
496
+ const run = `I.usePlaywrightTo(${JSON.stringify(`click ${element}`)}, async ({ page }) => page.locator(${JSON.stringify(ariaRefSelector(ref))}).click())`;
468
497
 
469
498
  if (!(await action.attempt(run, `Click ${element}`))) {
470
499
  activeNote.commit(TestResult.FAILED);
@@ -737,6 +766,12 @@ export function createAgentTools({ explorer, stateManager, ai, researcher, navig
737
766
 
738
767
  const researchResult = await researcher.research(currentState, { screenshot: true, data: true });
739
768
 
769
+ if (!researchResult) {
770
+ return failedToolResult('research', 'No UI map is available for this page.', {
771
+ suggestion: 'Use context() to read the page structure and act on the elements it lists.',
772
+ });
773
+ }
774
+
740
775
  return successToolResult('research', {
741
776
  analysis: researchResult,
742
777
  aria: cap(ActionResult.fromState(currentState).getInteractiveARIA(), ARIA_OUTPUT_CAP),
@@ -1163,21 +1198,6 @@ export async function commitNote(activeNote: any, result: TestResult, toolResult
1163
1198
  activeNote.commit(result);
1164
1199
  }
1165
1200
 
1166
- async function describeRef(explorer: any, ref: string): Promise<{ role: string; text: string } | null> {
1167
- return Promise.resolve(
1168
- explorer?.withPage?.((page: any) =>
1169
- page.locator(`aria-ref=${ref}`).evaluate((el: any) => {
1170
- const tag = el.tagName.toLowerCase();
1171
- const roles: Record<string, string> = { a: 'link', button: 'button', select: 'combobox', textarea: 'textbox' };
1172
- const role = el.getAttribute('role') || roles[tag] || tag;
1173
- const text = (el.getAttribute('aria-label') || el.innerText || el.value || '').trim().split('\n')[0];
1174
- if (!text) return null;
1175
- return { role, text };
1176
- })
1177
- )
1178
- ).catch(() => null);
1179
- }
1180
-
1181
1201
  async function hasFocusedElement(explorer: any): Promise<boolean> {
1182
1202
  return explorer.withPage((page: any) => page.evaluate(() => !!document.activeElement && document.activeElement !== document.body)).catch(() => true);
1183
1203
  }
@@ -1232,6 +1252,7 @@ function hasObservablePageChange(data?: Record<string, any>): boolean {
1232
1252
  if (data.pageDiff.urlChanged === true) return true;
1233
1253
  if (data.pageDiff.ariaChanges) return true;
1234
1254
  if (data.pageDiff.messages?.length) return true;
1255
+ if (data.pageDiff.requests?.length) return true;
1235
1256
  return Array.isArray(data.pageDiff.htmlParts) && data.pageDiff.htmlParts.length > 0;
1236
1257
  }
1237
1258
 
@@ -1242,12 +1263,11 @@ export async function failedToolResult(action: string, message: string, data?: R
1242
1263
  }
1243
1264
 
1244
1265
  const errorTexts = [message, ...(data?.attempts?.map((a: any) => a.error || '') || [])];
1245
- const hasMultipleElements = errorTexts.some((t: string) => t.toLowerCase().includes(MULTIPLE_ELEMENTS_PATTERN));
1246
- const multipleElementsSuggestion = hasMultipleElements ? getMultipleElementsSuggestion() : null;
1247
- if (multipleElementsSuggestion) {
1248
- result.suggestion = multipleElementsSuggestion;
1266
+ if (errorTexts.some((t: string) => t.toLowerCase().includes(MULTIPLE_ELEMENTS_PATTERN))) {
1267
+ const matched = await extractWebElements(error);
1268
+ result.suggestion = getMultipleElementsSuggestion(matched);
1249
1269
  result.multipleElementsDetected = true;
1250
- result.elements = await formatMatchedElements(error);
1270
+ result.elements = formatElementList(matched);
1251
1271
  return result;
1252
1272
  }
1253
1273
 
@@ -1260,11 +1280,16 @@ export async function failedToolResult(action: string, message: string, data?: R
1260
1280
  return result;
1261
1281
  }
1262
1282
 
1263
- function getMultipleElementsSuggestion(): string {
1283
+ function getMultipleElementsSuggestion(matched: MatchedElement[] | null): string {
1284
+ const visible = (matched || []).filter((element) => element.visible !== false);
1285
+ let onlyVisible = '';
1286
+ if (matched && visible.length === 1) onlyVisible = `\nOnly element ${matched.indexOf(visible[0]) + 1} is on screen, so that is the one to act on.`;
1287
+
1264
1288
  return dedent`
1265
1289
  Multiple elements matched your locator, so that command did nothing — it selected no element and acted on none.
1266
- Read the numbered elements list and click the one you meant by its number:
1290
+ Read the numbered elements list and act on the one you meant by its number:
1267
1291
  reuse the same locator with step.opts({ elementIndex: N }) as the last argument.
1292
+ A match reported as not visible can never be acted on — pick one that is.${onlyVisible}
1268
1293
  If none of them is the element you want, narrow the locator with a container or its full unique text.
1269
1294
  If the list is missing, call xpathCheck() to see what the locator matches.
1270
1295
  `;
@@ -1288,7 +1313,7 @@ export function clickFailureSuggestion(attempts: Array<{ error?: string }>): str
1288
1313
  }
1289
1314
 
1290
1315
  if (errors.some((e) => e.includes('is not visible'))) {
1291
- return 'Element is in the DOM but not visible. Reveal it first — scroll to it, expand its section, or open the panel holding it.';
1316
+ return 'Element is in the DOM but not visible. Reveal it — scroll to it, expand its section, open the panel holding it — or, when the page carries several copies of the same control, target the one that is on screen.';
1292
1317
  }
1293
1318
 
1294
1319
  if (errors.some((e) => e.includes('SyntaxError'))) {
@@ -1313,19 +1338,20 @@ const MAX_DISAMBIGUATE_TEXT = 80;
1313
1338
  const MAX_DISAMBIGUATE_HTML = 300;
1314
1339
  const MULTIPLE_ELEMENTS_PATTERN = 'multiple elements';
1315
1340
 
1316
- async function extractWebElements(error: Error | null | undefined): Promise<Array<{ xpath: string; html: string; text: string }> | null> {
1341
+ async function extractWebElements(error: Error | null | undefined): Promise<MatchedElement[] | null> {
1317
1342
  if (!error || error.name !== 'MultipleElementsFound') return null;
1318
1343
 
1319
- const elements = (error as any).webElements as Array<{ toAbsoluteXPath: () => Promise<string>; toOuterHTML: () => Promise<string>; getText: () => Promise<string | null> }> | undefined;
1344
+ const elements = (error as any).webElements as Array<{ toAbsoluteXPath: () => Promise<string>; toOuterHTML: () => Promise<string>; getText: () => Promise<string | null>; isVisible?: () => Promise<boolean> }> | undefined;
1320
1345
  if (!elements?.length) return null;
1321
1346
 
1322
- const result: Array<{ xpath: string; html: string; text: string }> = [];
1347
+ const result: MatchedElement[] = [];
1323
1348
  for (let i = 0; i < Math.min(elements.length, MAX_DISAMBIGUATE_ELEMENTS); i++) {
1324
1349
  try {
1325
1350
  const xpath = await elements[i].toAbsoluteXPath();
1326
1351
  const html = truncate(cleanHtmlSnippet(await elements[i].toOuterHTML()), MAX_DISAMBIGUATE_HTML);
1327
1352
  const text = truncate(normalizeInlineText((await elements[i].getText()) || ''), MAX_DISAMBIGUATE_TEXT);
1328
- result.push({ xpath, html, text });
1353
+ const visible = await Promise.resolve(elements[i].isVisible?.()).catch(() => undefined);
1354
+ result.push({ xpath, html, text, visible });
1329
1355
  } catch (e) {
1330
1356
  debugLog('Failed to get details for element %d: %s', i, e);
1331
1357
  }
@@ -1333,14 +1359,20 @@ async function extractWebElements(error: Error | null | undefined): Promise<Arra
1333
1359
  return result.length > 0 ? result : null;
1334
1360
  }
1335
1361
 
1336
- function formatElementList(details: Array<{ xpath: string; html: string; text: string }>): string {
1337
- return details.map((el, i) => `Element ${i + 1}:\nText: "${el.text}"\nXPath: ${el.xpath}\nHTML: ${el.html}`).join('\n\n');
1362
+ function formatElementList(matched: MatchedElement[] | null): string {
1363
+ if (!matched) return 'Could not fetch element details. Repeat the action to get better info.';
1364
+ return matched
1365
+ .map((el, i) => {
1366
+ const lines = [`Element ${i + 1}:`, `Text: "${el.text}"`];
1367
+ if (el.visible !== undefined) lines.push(`Visible: ${el.visible}`);
1368
+ lines.push(`XPath: ${el.xpath}`, `HTML: ${el.html}`);
1369
+ return lines.join('\n');
1370
+ })
1371
+ .join('\n\n');
1338
1372
  }
1339
1373
 
1340
1374
  export async function formatMatchedElements(error: Error | null | undefined): Promise<string | null> {
1341
- const details = await extractWebElements(error);
1342
- if (!details) return 'Could not fetch element details. Repeat the action to get better info.';
1343
- return formatElementList(details);
1375
+ return formatElementList(await extractWebElements(error));
1344
1376
  }
1345
1377
 
1346
1378
  function getNotFoundSuggestion(errorMessage: string): string | null {
@@ -1356,3 +1388,10 @@ function getNotFoundSuggestion(errorMessage: string): string | null {
1356
1388
  4. Prefer ARIA locators: { "role": "button", "text": "visible text" }
1357
1389
  `;
1358
1390
  }
1391
+
1392
+ interface MatchedElement {
1393
+ xpath: string;
1394
+ html: string;
1395
+ text: string;
1396
+ visible?: boolean;
1397
+ }