Skip to content
Open
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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,19 @@
# Changelog

## 2026-07-22

### Changes
- Fixed a test being reported as started when the browser could not be recovered before it began. Browser recovery now runs before the test starts, so a failed recovery no longer leaves a started-but-never-finished test in the report.

## 2026-07-17

### Changes
- Browser crash recovery now applies to every browser operation. Previously some interactions and page lookups could fail permanently when the page or browser crashed mid-session; now every action, capture, and element query automatically reattaches the page or restarts the browser and retries once before giving up.
- [Captain] The `browser` tool's `restart` action was merged into `recover` — recovering a page now escalates to a full browser restart automatically when needed, so there is no separate action to choose.
- Failed page actions that accidentally entered an iframe now return to the main page automatically. Previously only failed form actions did this; now it applies to every action, so a failed click or keypress can no longer leave the session stuck inside an iframe.
- [Pilot] Pilot's instructions and its list of available tools now stay identical across every call in a session, with the scenario details moved to the end. Providers can reuse the prompt they already processed instead of re-reading it on each call, which lowers the cost of long test runs.
- [Planner] Planning rules and output format instructions now come before the page research, so planning more tests for the same page reuses an already-processed prompt.
- When a click matches several elements, the follow-up question that picks the right one now uses the default model instead of the more expensive agentic model.
- Fixed startup failing with `AI connection failed: Invalid 'max_output_tokens'` on models that reject a one-token response. The check that verifies your AI credentials at startup no longer caps the reply length, so it works with every provider regardless of their minimum.

## 2026-07-10
Expand Down
4 changes: 2 additions & 2 deletions bin/explorbot-cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -720,11 +720,11 @@ addCommonOptions(program.command('shell <url> <command>').description('Execute a
await explorBot.start();
await explorBot.agentNavigator().visit(url);

const action = explorBot.getExplorer().createAction();
const action = explorBot.getExplorer().action();
await action.execute(command);

log('Command executed successfully');
const state = explorBot.getExplorer().getStateManager().getCurrentState();
const state = explorBot.stateManager().getCurrentState();
if (state) log(`URL: ${state.url}`);

await explorBot.stop();
Expand Down
7 changes: 5 additions & 2 deletions boat/doc-collector/src/ai/documentarian.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import dedent from 'dedent';
import { z } from 'zod';
import type { AIProvider } from '../../../../src/ai/provider.ts';
import type Explorer from '../../../../src/explorer.ts';
import type { StateManager } from '../../../../src/state-manager.ts';
import type { WebPageState } from '../../../../src/state-manager.ts';
import { tag } from '../../../../src/utils/logger.ts';
import type { DocbotConfig } from '../config.ts';
Expand All @@ -11,11 +12,13 @@ class Documentarian {
private provider: AIProvider;
private config: DocbotConfig;
private explorer?: Explorer;
private stateManager?: StateManager;

constructor(provider: AIProvider, config: DocbotConfig = {}, explorer?: Explorer) {
constructor(provider: AIProvider, config: DocbotConfig = {}, explorer?: Explorer, stateManager?: StateManager) {
this.provider = provider;
this.config = config;
this.explorer = explorer;
this.stateManager = stateManager;
}

async document(state: WebPageState, research: string, captureState?: CaptureInteractionState): Promise<PageDocumentation> {
Expand Down Expand Up @@ -46,7 +49,7 @@ class Documentarian {
try {
tag('info').log('Starting interactive exploration...');

const deterministicInteractions = await collectDocInteractions(this.explorer!, state, research, this.config, captureState);
const deterministicInteractions = await collectDocInteractions(this.explorer!, this.stateManager!, state, research, this.config, captureState);
meaningfulInteractions = this.getMeaningfulInteractions(deterministicInteractions);
if (meaningfulInteractions.length > 0) {
tag('success').log(`Collected ${meaningfulInteractions.length} deterministic interactions`);
Expand Down
23 changes: 12 additions & 11 deletions boat/doc-collector/src/ai/tools.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { type ResearchElement, parseResearchSections } from '../../../../src/ai/researcher/parser.ts';
import type Explorer from '../../../../src/explorer.ts';
import type { WebPageState } from '../../../../src/state-manager.ts';
import type { StateManager, WebPageState } from '../../../../src/state-manager.ts';
import { detectFocusArea } from '../../../../src/utils/aria.ts';
import type { DocbotConfig } from '../config.ts';

Expand Down Expand Up @@ -58,22 +58,22 @@ const DEFAULT_WAIT_MS = 700;
const TAB_WAIT_MS = 500;
const DEFAULT_DENIED_ACTION_LABELS = ['delete', 'remove', 'destroy', 'archive', 'discard', 'logout', 'sign out', 'signout', 'sign_out', 'erase', 'drop'];

export async function collectDocInteractions(explorer: Explorer, state: WebPageState, research: string, config: DocbotConfig = {}, captureState?: CaptureInteractionState): Promise<DocStateTransition[]> {
export async function collectDocInteractions(explorer: Explorer, stateManager: StateManager, state: WebPageState, research: string, config: DocbotConfig = {}, captureState?: CaptureInteractionState): Promise<DocStateTransition[]> {
const sections = parseResearchSections(research);
const transitions: DocStateTransition[] = [];
const maxInteractions = getPositiveConfigNumber(config.docs?.maxInteractions, DEFAULT_MAX_INTERACTIONS);
const tabGroup = findTabGroup(sections);

if (tabGroup) {
transitions.push(...(await exploreTabGroup(explorer, tabGroup, state.url, maxInteractions, captureState)));
transitions.push(...(await exploreTabGroup(explorer, stateManager, tabGroup, state.url, maxInteractions, captureState)));
}

for (const candidate of findActionCandidates(sections, config)) {
if (transitions.length >= maxInteractions) {
break;
}

const transition = await executeInteraction(explorer, candidate, state.url, DEFAULT_WAIT_MS, captureState);
const transition = await executeInteraction(explorer, stateManager, candidate, state.url, DEFAULT_WAIT_MS, captureState);
if (!transition) {
continue;
}
Expand All @@ -92,7 +92,7 @@ export function pickDocActionCandidates(research: string, config: DocbotConfig =
}));
}

async function exploreTabGroup(explorer: Explorer, tabGroup: { elements: ResearchElement[]; container?: string; sectionName: string }, restoreUrl: string, maxInteractions: number, captureState?: CaptureInteractionState): Promise<DocStateTransition[]> {
async function exploreTabGroup(explorer: Explorer, stateManager: StateManager, tabGroup: { elements: ResearchElement[]; container?: string; sectionName: string }, restoreUrl: string, maxInteractions: number, captureState?: CaptureInteractionState): Promise<DocStateTransition[]> {
const transitions: DocStateTransition[] = [];

for (const element of tabGroup.elements) {
Expand All @@ -102,6 +102,7 @@ async function exploreTabGroup(explorer: Explorer, tabGroup: { elements: Researc

const transition = await executeInteraction(
explorer,
stateManager,
{
element,
container: tabGroup.container,
Expand All @@ -123,8 +124,8 @@ async function exploreTabGroup(explorer: Explorer, tabGroup: { elements: Researc
return transitions;
}

async function executeInteraction(explorer: Explorer, candidate: InteractionCandidate, restoreUrl: string, waitMs: number, captureState?: CaptureInteractionState): Promise<DocStateTransition | null> {
const beforeState = explorer.getStateManager().getCurrentState();
async function executeInteraction(explorer: Explorer, stateManager: StateManager, candidate: InteractionCandidate, restoreUrl: string, waitMs: number, captureState?: CaptureInteractionState): Promise<DocStateTransition | null> {
const beforeState = stateManager.getCurrentState();
if (!beforeState) {
return null;
}
Expand All @@ -136,7 +137,7 @@ async function executeInteraction(explorer: Explorer, candidate: InteractionCand

await wait(waitMs);

const afterState = explorer.getStateManager().getCurrentState();
const afterState = stateManager.getCurrentState();
if (!afterState) {
return null;
}
Expand Down Expand Up @@ -164,7 +165,7 @@ async function executeInteraction(explorer: Explorer, candidate: InteractionCand
}

async function attemptInteraction(explorer: Explorer, candidate: InteractionCandidate): Promise<boolean> {
const action = explorer.createAction();
const action = explorer.action();

for (const command of buildClickCommands(candidate.element, candidate.container)) {
const success = await action.attempt(command, buildPurpose(candidate));
Expand All @@ -178,15 +179,15 @@ async function attemptInteraction(explorer: Explorer, candidate: InteractionCand

async function restoreInteractionState(explorer: Explorer, restoreUrl: string, primaryCommand?: string | null): Promise<void> {
if (primaryCommand) {
const action = explorer.createAction();
const action = explorer.action();
const restored = await action.attempt(primaryCommand, `Restore initial state on ${restoreUrl}`);
if (restored) {
await wait(TAB_WAIT_MS);
return;
}
}

const action = explorer.createAction();
const action = explorer.action();
await action.attempt(`I.amOnPage(${JSON.stringify(restoreUrl)})`, `Restore page ${restoreUrl}`);
}

Expand Down
6 changes: 3 additions & 3 deletions boat/doc-collector/src/docbot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,10 @@ import { sanitizeFilename } from '../../../src/utils/strings.ts';
import { Documentarian, type PageDocumentation } from './ai/documentarian.ts';
import { type DocbotConfig, DocbotConfigParser } from './config.ts';
import { type DocumentedPage, type SkippedPage, renderPageDocumentation, renderSpecIndex } from './docs-renderer.ts';
import { renderMermaidBody } from './state-diagram.ts';
import { getDocPageKey, shouldCrawlDocPath } from './path-filter.ts';
import { extractResearchNavigationTargets } from './research-navigation.ts';
import { type DocumentationScreenshot, captureDocumentationScreenshots, captureInteractionScreenshot } from './screenshots.ts';
import { renderMermaidBody } from './state-diagram.ts';

class DocBot {
private explorBot: ExplorBot;
Expand Down Expand Up @@ -43,7 +43,7 @@ class DocBot {
config: this.options.docsConfig,
path: this.options.path,
});
this.documentarian = new Documentarian(this.explorBot.getProvider(), this.config, this.explorBot.getExplorer());
this.documentarian = new Documentarian(this.explorBot.getProvider(), this.config, this.explorBot.getExplorer(), this.explorBot.stateManager());
this.ensureDirectory(this.configParser.getOutputDir());
this.ensureDirectory(this.getPagesDir());
}
Expand Down Expand Up @@ -76,7 +76,7 @@ class DocBot {
continue;
}

const stateManager = this.explorBot.getExplorer().getStateManager();
const stateManager = this.explorBot.stateManager();
if (stateManager.hasVisitedState(target)) {
continue;
}
Expand Down
4 changes: 2 additions & 2 deletions boat/doc-collector/src/screenshots.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import type { DocbotConfig } from './config.ts';
const DEFAULT_MAX_SECTION_SCREENSHOTS = 8;

export async function captureDocumentationScreenshots(explorer: Explorer, state: WebPageState, research: string, options: DocumentationScreenshotOptions): Promise<DocumentationScreenshot[]> {
const page = explorer.playwrightHelper?.page;
const page = explorer.page;
if (!page) {
return [];
}
Expand Down Expand Up @@ -62,7 +62,7 @@ export function getScreenshotSections(research: string): ScreenshotSection[] {
}

export async function captureInteractionScreenshot(explorer: Explorer, state: WebPageState, transition: DocStateTransition, options: DocumentationScreenshotOptions): Promise<DocumentationScreenshot | null> {
const page = explorer.playwrightHelper?.page;
const page = explorer.page;
if (!page) {
return null;
}
Expand Down
2 changes: 1 addition & 1 deletion docs/contributing/ai-integration-tests.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ Reference implementation: `tests/integration/planner.test.ts`.

- **The AI provider** — via the aimock HTTP server. Point the Vercel AI SDK at `mock.url/v1` with `createOpenAI({ compatibility: 'compatible' })` and `openai.chat('model-name')`. The `compatibility: 'compatible'` option keeps the mock on the Chat Completions API; without it, the SDK defaults to the Responses API, which aimock does not fully implement.
- **Explorer, StateManager, Researcher, ExperienceTracker** — duck-typed mocks with only the methods the agent under test calls. Each agent runs in isolation; downstream agents (such as Researcher when testing Planner) return canned output.
- **`playwrightLocatorCount`** — for agents that validate locators, since no browser runs in these tests.
- **`withPage`** — resolves with a fake page object for agents that validate locators, since no browser runs in these tests.

### What we don't mock

Expand Down
30 changes: 25 additions & 5 deletions src/action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ import { browserErrorMessage, isFatalBrowserError, isNavigationTransitionError }
import { captureHtmlForSnapshot, htmlCombinedSnapshot, minifyHtml } from './utils/html.js';
import { createDebug, setStepSpanParent, tag } from './utils/logger.js';
import { sleep, waitForPageReadiness } from './utils/page-readiness.ts';
import { codeceptJSSandbox, hasPlaywrightCommands, playwrightSandbox, sanitizeCodeBlock } from './utils/web-sandbox.ts';
import { safeFilename } from './utils/strings.ts';
import { codeceptJSSandbox, hasPlaywrightCommands, playwrightSandbox, sanitizeCodeBlock } from './utils/web-sandbox.ts';

const debugLog = createDebug('explorbot:action');
const CAPTURE_NAVIGATION_TRANSITION_ATTEMPTS = 3;
Expand All @@ -33,14 +33,16 @@ class Action {
public playwrightGroupId: string | null = null;
public assertionSteps: Array<{ name: string; args: any[] }> = [];
private recorder?: PlaywrightRecorder;
private recovery: RecoveryRunner;
private mainDocumentStatus: number | undefined = undefined;

constructor(actor: CodeceptJS.I, stateManager: StateManager, recorder?: PlaywrightRecorder) {
constructor(actor: CodeceptJS.I, stateManager: StateManager, recorder?: PlaywrightRecorder, recovery?: RecoveryRunner) {
this.actor = actor;
this.stateManager = stateManager;
this.config = ConfigParser.getInstance().getConfig();
this.playwrightHelper = container.helpers('Playwright');
this.recorder = recorder;
this.recovery = recovery || ((fn) => fn());
}

async saveScreenshot(): Promise<string | undefined> {
Expand All @@ -59,7 +61,15 @@ class Action {
}
}

async capturePageState({ includeScreenshot = false, codeBlock }: { includeScreenshot?: boolean; codeBlock?: string } = {}): Promise<ActionResult> {
async capturePageState(opts: { includeScreenshot?: boolean; codeBlock?: string } = {}): Promise<ActionResult> {
return this.recovery(() => this.captureOnce(opts));
}

async execute(code: string): Promise<Action> {
return this.recovery(() => this.executeOnce(code));
}

private async captureOnce({ includeScreenshot = false, codeBlock }: { includeScreenshot?: boolean; codeBlock?: string } = {}): Promise<ActionResult> {
try {
const currentState = this.stateManager.getCurrentState();
const stateHash = currentState?.hash || 'screenshot';
Expand Down Expand Up @@ -260,7 +270,7 @@ class Action {
}
}

async execute(code: string): Promise<Action> {
private async executeOnce(code: string): Promise<Action> {
let error: Error | null = null;

setActivity('🔎 Browsing...', 'action');
Expand Down Expand Up @@ -301,7 +311,7 @@ class Action {
codeString = executedSteps.join('\n');
}

const pageState = await this.capturePageState({ codeBlock: codeString });
const pageState = await this.captureOnce({ codeBlock: codeString });

this.actionResult = pageState;
this.assertionSteps = assertionSteps;
Expand Down Expand Up @@ -329,6 +339,7 @@ class Action {
}

public async attempt(codeBlock: string, originalMessage?: string): Promise<boolean> {
const wasInFrame = !!this.playwrightHelper.frame;
try {
debugLog('Resolution attempt...');
setActivity('🦾 Acting in browser...', 'action');
Expand All @@ -343,11 +354,18 @@ class Action {
} catch (error) {
this.lastError = error as Error;
if (isFatalBrowserError(error)) throw error;
if (!wasInFrame) await this.exitIframe().catch(() => {});
debugLog(`Attempt failed: ${codeBlock}: ${errorToString(error) || this.lastError?.toString()}`);
return false;
}
}

async exitIframe(): Promise<void> {
if (!this.playwrightHelper.frame) return;
debugLog('Switching to main frame');
await this.playwrightHelper.switchTo();
}

getActor(): CodeceptJS.I {
return this.actor;
}
Expand All @@ -366,6 +384,8 @@ class Action {

export default Action;

export type RecoveryRunner = <T>(fn: () => Promise<T>) => Promise<T>;

function errorToString(error: any): string {
if (error.cliMessage) {
return error.cliMessage();
Expand Down
20 changes: 20 additions & 0 deletions src/ai/agent.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,23 @@
import type { RequestStore } from '../api/request-store.ts';
import type { ExplorbotConfig } from '../config.ts';
import type Explorer from '../explorer.ts';
import type { KnowledgeTracker } from '../knowledge-tracker.ts';
import type { PlaywrightRecorder } from '../playwright-recorder.ts';
import type { StateManager } from '../state-manager.ts';
import type { AIProvider } from './provider.ts';

export interface Agent {
emoji?: string;
}

export interface AgentDeps {
explorer: Explorer;
ai: AIProvider;
config: ExplorbotConfig;
stateManager: StateManager;
knowledgeTracker: KnowledgeTracker;
requestStore: RequestStore;
playwrightRecorder: PlaywrightRecorder;
}

export type ToolDeps = Pick<AgentDeps, 'explorer' | 'stateManager' | 'ai'>;
Loading
Loading