Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions bin/explorbot-cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,7 @@ program
.option('-s, --show', 'Show browser window')
.option('--headless', 'Run browser in headless mode')
.option('--data', 'Include data extraction in research')
.option('--deep', 'Enable deep analysis (expand hidden elements)')
.action(async (url, options) => {
try {
const mainOptions: ExplorBotOptions = {
Expand All @@ -390,6 +391,7 @@ program
screenshot: true,
force: true,
data: options.data || false,
deep: options.deep || false,
});

await explorBot.stop();
Expand Down
36 changes: 19 additions & 17 deletions src/ai/researcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ const POSSIBLE_SECTIONS = {

const DEFAULT_STOP_WORDS = ['close', 'cancel', 'dismiss', 'exit', 'back', 'cookie', 'consent', 'gdpr', 'privacy', 'accept all', 'decline all', 'reject all', 'share', 'print', 'download'];

const CLICKABLE_ROLES = new Set(['button', 'link', 'menuitem', 'tab', 'option', 'combobox', 'switch']);
const CLICKABLE_ROLES = new Set(['button', 'link', 'menuitem', 'tab', 'option', 'combobox', 'switch', 'checkbox', 'radio', 'slider', 'textbox', 'treeitem']);

export class Researcher implements Agent {
emoji = '🔍';
Expand Down Expand Up @@ -131,13 +131,16 @@ export class Researcher implements Agent {

this.hasScreenshotToAnalyze = screenshot && this.provider.hasVision() && isOnCurrentState;

const prompt = await this.buildResearchPrompt();

const conversation = this.provider.startConversation(this.getSystemMessage(), 'researcher');
conversation.addUserText(prompt);

if (this.hasScreenshotToAnalyze) {
this.actionResult = await this.explorer.createAction().caputrePageWithScreenshot();
}

const prompt = await this.buildResearchPrompt();
conversation.addUserText(prompt);

if (this.hasScreenshotToAnalyze) {
const screenshotAnalysis = await this.analyzeScreenshotForUIElements();
if (screenshotAnalysis) {
this.addScreenshotPrompt(conversation, screenshotAnalysis);
Expand All @@ -153,6 +156,8 @@ export class Researcher implements Agent {

if (deep) {
researchText += await this.performDeepAnalysis(conversation, state, state.html ?? '');
researchText += '\n\n## Interactive Elements Exploration\n\n';
researchText += await this.performInteractiveExploration(state, { maxElements: 50 });
}

if (data) {
Expand Down Expand Up @@ -950,12 +955,12 @@ export class Researcher implements Agent {
}
}

async performInteractiveExploration(state: WebPageState): Promise<string> {
async performInteractiveExploration(state: WebPageState, opts: { maxElements?: number } = {}): Promise<string> {
const config = this.getResearcherConfig();
const stopWords = config?.stopWords ?? DEFAULT_STOP_WORDS;
const excludeSelectors = config?.excludeSelectors || [];
const includeSelectors = config?.includeSelectors || [];
const maxElements = config?.maxElementsToExplore ?? 10;
const maxElements = opts.maxElements ?? config?.maxElementsToExplore ?? 10;

const interactiveNodes = collectInteractiveNodes(state.ariaSnapshot || '');
const originalUrl = state.url;
Expand All @@ -969,12 +974,7 @@ export class Researcher implements Agent {
return false;
}

if (!name) {
debugLog(`Skipping unnamed ${role} element`);
return false;
}

if (name.length > 50) {
if (name.length > 80) {
debugLog(`Skipping "${name.slice(0, 30)}..." - name too long`);
return false;
}
Expand Down Expand Up @@ -1011,22 +1011,24 @@ export class Researcher implements Agent {
const role = String(node.role || '');
const name = String(node.name || '').trim();

tag('substep').log(`[${i + 1}/${targets.length}] Exploring: "${name}" (${role})`);
const label = name || `unnamed ${role}`;
tag('substep').log(`[${i + 1}/${targets.length}] Exploring: "${label}" (${role})`);

const action = this.explorer.createAction();
const beforeState = await action.capturePageState({});

try {
await action.execute(`I.click({ role: '${role}', text: '${name.replace(/'/g, "\\'")}' })`);
const clickCommand = name ? `I.click({ role: '${role}', text: '${name.replace(/'/g, "\\'")}' })` : `I.click({ role: '${role}' })`;
await action.execute(clickCommand);
const afterState = await action.capturePageState({});

const resultDescription = this.detectChangeResult(beforeState, afterState, originalUrl);
results.push({ element: name, role, result: resultDescription });
results.push({ element: label, role, result: resultDescription });

await this.restoreState(afterState, originalUrl);
} catch (error) {
debugLog(`Failed to explore ${name}:`, error);
results.push({ element: name, role, result: 'click failed' });
debugLog(`Failed to explore ${label}:`, error);
results.push({ element: label, role, result: 'click failed' });
}
}

Expand Down
10 changes: 6 additions & 4 deletions src/commands/research-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,16 @@ import { BaseCommand } from './base-command.js';

export class ResearchCommand extends BaseCommand {
name = 'research';
description = 'Research current page or navigate to URI and research';
suggestions = ['/navigate <page> - to go to another page', '/plan <feature> - to plan testing'];
description = 'Research current page or navigate to URI and research. Use --deep to explore interactive elements by clicking them. Use --data to include page data.';
suggestions = ['/research --deep - explore by clicking buttons', '/navigate <page> - to go to another page', '/plan <feature> - to plan testing'];

async execute(args: string): Promise<void> {
const includeData = args.includes('--data');
const target = args.replace('--data', '').trim();
const enableDeep = args.includes('--deep');
const target = args.replace('--data', '').replace('--deep', '').trim();
Comment on lines 9 to +11
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: The current argument parsing removes the substrings '--data' and '--deep' from the entire args string, so if a URL itself contains these sequences (for example as query parameters), the URL will be mangled before navigation, causing the command to visit an unintended page or an invalid URL; instead, parse args into tokens and strip flags only when they are separate arguments. [logic error]

Severity Level: Major ⚠️
- ❌ /research navigates to wrong page when URL contains flags.
- ⚠️ AI research summary mismatched with user's intended target URL.
Suggested change
const includeData = args.includes('--data');
const target = args.replace('--data', '').trim();
const enableDeep = args.includes('--deep');
const target = args.replace('--data', '').replace('--deep', '').trim();
const tokens = args.split(/\s+/).filter(Boolean);
const includeData = tokens.includes('--data');
const enableDeep = tokens.includes('--deep');
const target = tokens.filter((t) => t !== '--data' && t !== '--deep').join(' ');
Steps of Reproduction ✅
1. In the TUI or CLI, run the `/research` command with a URL that legitimately contains
the substring `--deep` or `--data` as part of the URL, for example:

   `/research https://example.test/search?q=--deep&x=1 --data`.

   This flows into the `ResearchCommand` implementation at
   `src/commands/research-command.ts:8` (`execute(args: string)`).

2. The command framework (via `BaseCommand`) invokes `ResearchCommand.execute()` with
`args` equal to `https://example.test/search?q=--deep&x=1 --data` (verified from the
signature and usage at `src/commands/research-command.ts:8-11`).

3. Inside `execute` at `src/commands/research-command.ts:9-11`, the following happens:

   - `includeData = args.includes('--data')` becomes `true` because the string ends with
   `--data`.

   - `enableDeep = args.includes('--deep')` becomes `true` because the query parameter
   value is `--deep`.

   - `target = args.replace('--data', '').replace('--deep', '').trim()` produces
   `https://example.test/search?q=&x=1`, because both occurrences of the substrings
   `--data` and `--deep` anywhere in the string are stripped, including the query
   parameter value.

4. With this mangled URL, the navigation call
`this.explorBot.agentNavigator().visit(target)` at
`src/commands/research-command.ts:13-15` is executed using
`https://example.test/search?q=&x=1` instead of the user-specified
`https://example.test/search?q=--deep&x=1`, causing the navigator to open the wrong or an
unintended page.

5. After navigation, `const state =
this.explorBot.getExplorer().getStateManager().getCurrentState();` at
`src/commands/research-command.ts:17` retrieves the current page state (the wrong page)
via the underlying `getCurrentState()` implementation (see
`/tmp/pr-review/repo-clones/testomatio/explorbot/7fc8e06e315045d0eda04b721a873f36dd09b4cd/src/action.ts:336-338`),
and `agentResearcher().research(state, ...)` at `src/commands/research-command.ts:22-26`
then performs research on this unintended page, leading to incorrect research results for
the command the user actually entered.
Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** src/commands/research-command.ts
**Line:** 9:11
**Comment:**
	*Logic Error: The current argument parsing removes the substrings '--data' and '--deep' from the entire args string, so if a URL itself contains these sequences (for example as query parameters), the URL will be mangled before navigation, causing the command to visit an unintended page or an invalid URL; instead, parse args into tokens and strip flags only when they are separate arguments.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
👍 | 👎


if (target) {
await this.explorBot.getExplorer().visit(target);
await this.explorBot.agentNavigator().visit(target);
}

const state = this.explorBot.getExplorer().getStateManager().getCurrentState();
Expand All @@ -22,6 +23,7 @@ export class ResearchCommand extends BaseCommand {
screenshot: true,
force: true,
data: includeData,
deep: enableDeep,
});
}
}
6 changes: 2 additions & 4 deletions src/utils/aria.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,10 +79,8 @@ const buildInteractiveEntry = (node: AriaNode): Record<string, unknown> | null =
shouldInclude = true;
}
if (isButtonOrLink && !entryName && !hasValue) {
shouldInclude = false;
}
if (node.role === 'link' && entryName && entryName.length > 30) {
shouldInclude = false;
entry.unnamed = true;
shouldInclude = true;
}
if (!shouldInclude) {
return null;
Expand Down
Loading