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.
- package/boat/api-tester/src/apibot.ts +8 -13
- package/boat/api-tester/src/cli.ts +7 -3
- package/boat/api-tester/src/config.ts +45 -9
- package/boat/prima/src/cli.ts +33 -99
- package/boat/prima/src/envelope.ts +3 -1
- package/boat/prima/src/help.ts +72 -0
- package/boat/prima/src/prima.ts +41 -46
- package/dist/boat/api-tester/src/apibot.js +7 -6
- package/dist/boat/api-tester/src/cli.js +9 -3
- package/dist/boat/api-tester/src/config.js +32 -6
- package/dist/boat/prima/src/cli.js +30 -86
- package/dist/boat/prima/src/envelope.js +2 -1
- package/dist/boat/prima/src/help.js +63 -0
- package/dist/boat/prima/src/prima.js +39 -44
- package/dist/package.json +1 -1
- package/dist/src/action-result.d.ts +3 -0
- package/dist/src/action-result.js +5 -0
- package/dist/src/action.js +12 -1
- package/dist/src/ai/fisherman/tools.js +7 -1
- package/dist/src/ai/fisherman.js +2 -1
- package/dist/src/ai/pilot.d.ts +0 -1
- package/dist/src/ai/pilot.js +8 -24
- package/dist/src/ai/planner.d.ts +4 -0
- package/dist/src/ai/planner.js +28 -0
- package/dist/src/ai/provider.js +3 -1
- package/dist/src/ai/researcher/deep-analysis.d.ts +1 -1
- package/dist/src/ai/researcher/deep-analysis.js +4 -1
- package/dist/src/ai/researcher/sections.d.ts +1 -1
- package/dist/src/ai/researcher/sections.js +2 -1
- package/dist/src/ai/researcher.js +25 -11
- package/dist/src/ai/rules.js +8 -7
- package/dist/src/ai/scout/tools.d.ts +17 -0
- package/dist/src/ai/scout/tools.js +130 -0
- package/dist/src/ai/scout.d.ts +21 -0
- package/dist/src/ai/scout.js +150 -0
- package/dist/src/ai/tester.d.ts +1 -0
- package/dist/src/ai/tester.js +27 -33
- package/dist/src/ai/tools.js +61 -30
- package/dist/src/application-spec.d.ts +3 -0
- package/dist/src/application-spec.js +21 -5
- package/dist/src/commands/config-command.js +6 -2
- package/dist/src/config.d.ts +9 -1
- package/dist/src/config.js +14 -0
- package/dist/src/explorbot.d.ts +3 -0
- package/dist/src/explorbot.js +33 -0
- package/dist/src/knowledge-tracker.d.ts +1 -0
- package/dist/src/knowledge-tracker.js +3 -0
- package/dist/src/state-manager.js +5 -1
- package/dist/src/utils/aria-ref.d.ts +16 -0
- package/dist/src/utils/aria-ref.js +47 -0
- package/dist/src/utils/aria.js +3 -3
- package/dist/src/utils/web-annotate.js +3 -15
- package/dist/src/utils/web-element.d.ts +0 -2
- package/dist/src/utils/web-element.js +0 -8
- package/docs/api-testing/basics.md +12 -4
- package/docs/reference/commands.md +1 -0
- package/docs/reference/configuration.md +28 -1
- package/docs/web-testing/agents.md +9 -1
- package/docs/web-testing/planner.md +5 -0
- package/docs/workflow/agentic-usage.md +3 -1
- package/docs/workflow/application-spec.md +4 -0
- package/package.json +1 -1
- package/src/action-result.ts +7 -0
- package/src/action.ts +14 -2
- package/src/ai/fisherman/tools.ts +8 -1
- package/src/ai/fisherman.ts +2 -1
- package/src/ai/pilot.ts +8 -25
- package/src/ai/planner.ts +33 -0
- package/src/ai/provider.ts +2 -1
- package/src/ai/researcher/deep-analysis.ts +4 -2
- package/src/ai/researcher/sections.ts +2 -2
- package/src/ai/researcher.ts +28 -11
- package/src/ai/rules.ts +8 -7
- package/src/ai/scout/tools.ts +150 -0
- package/src/ai/scout.ts +173 -0
- package/src/ai/tester.ts +25 -30
- package/src/ai/tools.ts +75 -36
- package/src/application-spec.ts +22 -4
- package/src/commands/config-command.ts +4 -1
- package/src/config.ts +23 -0
- package/src/explorbot.ts +36 -0
- package/src/knowledge-tracker.ts +4 -0
- package/src/state-manager.ts +6 -1
- package/src/utils/aria-ref.ts +61 -0
- package/src/utils/aria.ts +3 -3
- package/src/utils/web-annotate.ts +3 -15
- 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
|
+
}
|
package/src/ai/scout.ts
ADDED
|
@@ -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
|
|
583
|
-
if (region.root)
|
|
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
|
-
|
|
587
|
-
|
|
588
|
-
|
|
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
|
|
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()
|
|
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(
|
|
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
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
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 =
|
|
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
|
|
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
|
|
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<
|
|
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:
|
|
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
|
-
|
|
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(
|
|
1337
|
-
|
|
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
|
-
|
|
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
|
+
}
|