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
7 changes: 2 additions & 5 deletions .github/workflows/regression.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,8 @@ on:
scenario:
description: Scenario to run
type: choice
options: [all, basic, experience, slow, gated, staged-login, variants]
options: [all, basic, experience, slow, gated, gated-no-auth, staged-login, variants]
default: all
retries:
description: Retries per scenario
default: '2'
variant:
description: Widget variant
type: choice
Expand Down Expand Up @@ -69,7 +66,7 @@ jobs:
run: bunx playwright install --with-deps chromium

- name: Run regression scenarios
run: bunx bunosh regression:${{ github.event.inputs.scenario || 'all' }} --retries ${{ github.event.inputs.retries || '2' }} --variant ${{ github.event.inputs.variant || 'native' }} --seed ${{ github.event.inputs.seed || '42' }}
run: bunx bunosh regression:${{ github.event.inputs.scenario || 'all' }} --variant ${{ github.event.inputs.variant || 'native' }} --seed ${{ github.event.inputs.seed || '42' }}
env:
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}

Expand Down
134 changes: 62 additions & 72 deletions Bunoshfile.js

Large diffs are not rendered by default.

12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,18 @@
- [Planner] A Scout failure no longer stops planning. The log shows a warning and the plan is made without documentation.
- Prima: `npx prima-cli` releases are published again. Nothing newer than 0.5.4 reached npm because a release test failed, so fixes such as treating `localhost` and `127.0.0.1` as one site were missing from it.

## 2026-11-01

### Changes
- [Explore] A page that cannot be opened (for example a sign-in form with no credentials) is now skipped as a whole: no more planning styles or tests run on it, and pages that look the same are skipped without trying again, listed as "matches a blocked page". Exploration stops after 3 such pages.
- [Explore] When the start page itself is blocked, the run ends right away and suggests how to unblock it: pass credentials with `--auth`, or save them with `learn`. If knowledge for the page exists but did not help, it suggests updating it.
```bash
explorbot explore /workspace --auth 'login: admin@example.com password: secret123'
explorbot learn /workspace "login: admin@example.com password: secret123"
```
- [Captain] Takes over a blocked page once. It no longer gets the same blocked page again each time the Planner words the problem differently.
- Console output: blocker messages stand out with an empty line before and after, and empty lines no longer stack up.

## 2026-10-31

### New CLI Options
Expand Down
4 changes: 2 additions & 2 deletions boat/doc-collector/src/template-dedup.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { ariaTemplateSignature } from '../../../src/utils/aria.ts';
import { diceSimilarity } from '../../../src/utils/similarity.ts';
import { similarityScore } from '../../../src/utils/similarity.ts';

const TEMPLATE_MIN_SIGNATURE_SIZE = 8;
const TEMPLATE_SIMILARITY_THRESHOLD = 90;
Expand All @@ -16,7 +16,7 @@ export function findTemplateMatch(snapshot: string | null, known: TemplateRecord
let bestUrl: string | null = null;
let bestScore = 0;
for (const record of known) {
const score = diceSimilarity(signature, record.signature);
const score = similarityScore(signature, record.signature);
if (score < bestScore) continue;
bestScore = score;
bestUrl = record.url;
Expand Down
25 changes: 15 additions & 10 deletions docs/contributing/regression-tests.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,13 @@ Runs scenario A's assertions against `explore /workspace`. Signed out, `/workspa

Runs `explorbot navigate /account` with the credentials in `seeds/knowledge/staged-login.md`. Signed out, `/account` redirects to `/signin`, which asks only for the email; `/signin/password` asks only for the password and `/signin/otp` only for the one-time code. The later fields do not exist until the earlier stage is submitted, so the Navigator has to read each page as it arrives. The run must exit 0 with no Captain handover, since a Captain rescue would hide a Navigator failure. It tests navigation only: no research, planning or test execution.

### Scenario F — sign-in form without credentials (`regression:gated-no-auth`)

Runs `explore /workspace` with no knowledge, so nothing can sign in. Explorbot must give up on its own: the run fails if it is still going after 180 seconds, or if Captain takes over the blocked page more than once (`MAX_CAPTAIN_HANDOVERS` in `tests/regression/lib/assertions.ts`).

## The fixture app — "Trackly"

A self-contained issue tracker served in-process by `tests/regression/fixture/server.ts` (`Bun.serve`, ephemeral port, fresh in-memory store per attempt). No external network, no database.
A self-contained issue tracker served in-process by `tests/regression/fixture/server.ts` (`Bun.serve`, ephemeral port, fresh in-memory store per run). No external network, no database.

| Route | Auth | Contents |
|-------|------|----------|
Expand Down Expand Up @@ -81,15 +85,16 @@ bunx bunosh regression:serve

# Real-AI runs (need a provider key)
export OPENROUTER_API_KEY=sk-...
bunx bunosh regression:experience --retries 0 # cheapest AI path
bunx bunosh regression:basic --retries 0
bunx bunosh regression:slow --retries 0
bunx bunosh regression:gated --retries 0
bunx bunosh regression:staged-login --retries 0
bunx bunosh regression:all # all scenarios, default retries
bunx bunosh regression:experience # cheapest AI path
bunx bunosh regression:basic
bunx bunosh regression:slow
bunx bunosh regression:gated
bunx bunosh regression:staged-login
bunx bunosh regression:gated-no-auth
bunx bunosh regression:all # all scenarios
```

Each attempt runs in an isolated throwaway directory under `tests/regression/.runs/` (gitignored). Inspect `tests/regression/.runs/<scenario>-<variant>-a<n>/output/` for the research, plans, and reporter markdown a run produced, and `tests/regression/.runs/report.md` for the summary.
Every scenario runs once; there are no retries, so a flaky pass or fail shows up as it happened. Each run uses an isolated throwaway directory under `tests/regression/.runs/` (gitignored). Inspect `tests/regression/.runs/<scenario>-<variant>/output/` for the research, plans, and reporter markdown a run produced, and `tests/regression/.runs/report.md` for the summary.

### Variant matrix

Expand All @@ -103,11 +108,11 @@ bunx bunosh regression:variants --scenario basic --variants native,aria,plain

`.github/workflows/regression.yml` runs on `pull_request` and `workflow_dispatch`. `regression:all` runs the scenarios in parallel inside one job, so a run lasts as long as its slowest scenario and needs one approval. The job uses the `regression` GitHub environment, which holds `OPENROUTER_API_KEY` behind a **required reviewer**. Every run — including fork PRs — pauses at the environment gate before the secret is exposed. The job runs the PR's own code, so the reviewer must read the PR diff (workflow, Bunoshfile, fixture, lib) before approving; that review is the security boundary. The workflow never uses `pull_request_target`.

Every attempt writes a `record.json` into its run directory. `regression:report` merges those records and posts the report as a sticky PR comment (on `pull_request`) or a `Regression Reports` GitHub Discussion (on `workflow_dispatch`). Fork PRs get a read-only token, so comment posting may fail; the report is also written to the step summary.
Every run writes a `record.json` into its run directory. `regression:report` merges those records and posts the report as a sticky PR comment (on `pull_request`) or a `Regression Reports` GitHub Discussion (on `workflow_dispatch`). Fork PRs get a read-only token, so comment posting may fail; the report is also written to the step summary.

### Speed comparison

The report's `Time` column is the duration of the passing attempt. `Previous median` is the median of that scenario's passing time over the last 5 runs, and `Change` flags a difference of 20% or more. The job uploads the run's records as the `regression-summary` artifact (the run directories go to `regression-runs`) (kept 90 days), and the next run downloads the latest five to compare against. Which tests the planner picks changes a run's time, so read one flagged run as a hint and a trend over several as a regression.
The report's `Time` column is the duration of the run. `Previous median` is the median of that scenario's passing time over the last 5 runs, and `Change` flags a difference of 20% or more. The job uploads the run's records as the `regression-summary` artifact (the run directories go to `regression-runs`) (kept 90 days), and the next run downloads the latest five to compare against. Which tests the planner picks changes a run's time, so read one flagged run as a hint and a trend over several as a regression.

The report also embeds the **Session Analysis** from the fresh-explore run — Explorbot's Analyst agent writes a prose summary of what works, defects, UX issues, and execution issues, which the harness reads from `output/reports/<label>.md` and appends to the comment. The Analyst is enabled in the config template (`ai.agents.analyst.enabled: true`); it adds one AI call per basic run.

Expand Down
8 changes: 4 additions & 4 deletions src/ai/captain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,6 @@ export class Captain extends CaptainBase implements Agent {
private hooksRunner: HooksRunner | null = null;
private commandExecutor: ((cmd: string) => Promise<void>) | null = null;
private commandDescriptions: { name: string; description: string; options: string }[] = [];
private unresolvedBlockers = new Set<string>();
private handling = false;
private blocker: BlockedError | null = null;

Expand Down Expand Up @@ -535,15 +534,16 @@ export class Captain extends CaptainBase implements Agent {

async resolveBlocker(error: unknown, resolved: () => boolean | Promise<boolean>): Promise<boolean> {
if (!(error instanceof BlockedError) || this.handling) return false;
const key = `${error.name} ${error.url} ${error.blocker}`;
if (this.unresolvedBlockers.has(key)) return false;
const stateManager = this.explorBot.stateManager();
const blockedState = stateManager.getCurrentState();
if (stateManager.isPageBlocked(blockedState)) return false;
tag('info').log(`Captain takes over: ${error.message}`);
this.blocker = error;
await this.handle(error.request, { reset: true }).finally(() => {
this.blocker = null;
});
if (await resolved()) return true;
this.unresolvedBlockers.add(key);
if (blockedState) stateManager.markBlocked(blockedState, error.blocker);
return false;
}
}
Expand Down
4 changes: 2 additions & 2 deletions src/ai/researcher/fingerprint-worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { join } from 'node:path';
import { parentPort } from 'node:worker_threads';
import { computeHtmlFingerprint } from '../../utils/html-diff.ts';
import { isSamePageFamily } from '../../utils/url-matcher.ts';
import { diceSimilarity } from '../../utils/similarity.ts';
import { similarityScore } from '../../utils/similarity.ts';

parentPort!.on('message', (data: FingerprintRequest) => {
const { id, html, statesDir, maxAgeMs, threshold, url } = data;
Expand Down Expand Up @@ -34,7 +34,7 @@ parentPort!.on('message', (data: FingerprintRequest) => {
const record = readFingerprint(filePath);
if (url && record.url && !isSamePageFamily(url, record.url)) continue;
const storedFingerprint = new Set(record.entries);
const similarity = diceSimilarity(currentFingerprint, storedFingerprint);
const similarity = similarityScore(currentFingerprint, storedFingerprint);

if (similarity > bestSimilarity) {
bestSimilarity = similarity;
Expand Down
44 changes: 40 additions & 4 deletions src/commands/explore-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { type ArgumentCompletion, BaseCommand, type Suggestion } from './base-co
import { PlanCommand } from './plan-command.js';

const MAX_SUB_PAGE_ATTEMPTS = 30;
const MAX_BLOCKED_PAGES = 3;
const PRIORITY_ORDER: Record<string, number> = { critical: 0, important: 1, high: 2, normal: 3, low: 4 };
export const DEADLINE_RESERVE_MS = 3 * 60_000;
export const DEADLINE_TEST_ALLOWANCE_MS = 5 * 60_000;
Expand Down Expand Up @@ -85,6 +86,7 @@ export class ExploreCommand extends BaseCommand {
Stats.mode ??= 'explore';
Stats.focus ??= feature;
const mainUrl = this.getCurrentPageUrl();
const startState = this.explorBot.stateManager().getCurrentState();
const error = getStateErrorPageError(this.explorBot.stateManager().getCurrentState());
if (error && !this.sitemapUrls) {
tag('warning').log(error.message);
Expand All @@ -100,6 +102,12 @@ export class ExploreCommand extends BaseCommand {
await this.runFreshMode(mainUrl, feature, cfg.styles);
}

if (this.explorBot.stateManager().isPageBlocked(startState)) {
tag('error').log(`Start page ${startState?.url} is blocked, nothing to explore`, { emphasize: true });
this.printNextSteps();
return;
}

const mainPlan = this.completedPlans[0];
if (mainPlan) this.explorBot.setCurrentPlan(mainPlan);
if (this.dryRun) {
Expand Down Expand Up @@ -321,6 +329,7 @@ export class ExploreCommand extends BaseCommand {
tag('warning').log(`Skipping page: ${errorPage.message}`);
return false;
}
if (this.skipBlockedPage()) return false;
await this.runAllStyles(url, feature, parentPlan, styles);
const plan = this.explorBot.getCurrentPlan();
if (plan?.tests.length && !this.completedPlans.includes(plan)) {
Expand All @@ -337,6 +346,7 @@ export class ExploreCommand extends BaseCommand {
const opts: { fresh: boolean; style: string; noSave?: boolean } = { fresh: false, style };
if (this.dryRun) opts.noSave = true;
await this.planWithRetry(feature, opts, pageUrl);
if (this.skipBlockedPage()) return;
await this.runPendingTests();
}
}
Expand All @@ -353,6 +363,7 @@ export class ExploreCommand extends BaseCommand {
if (fresh && parentPlan) opts.extend = parentPlan;
if (this.dryRun) opts.noSave = true;
await this.planWithRetry(feature, opts, pageUrl);
if (this.skipBlockedPage()) return;
const plan = this.explorBot.getCurrentPlan();
if (plan && plan.tests.length === 0) {
tag('warning').log('Nothing to test on this page, moving on');
Expand All @@ -376,10 +387,7 @@ export class ExploreCommand extends BaseCommand {
const before = new Set(this.explorBot.getCurrentPlan()?.tests ?? []);

await this.explorBot.plan(feature, opts);
if (this.explorBot.lastPlanError instanceof BlockedError) {
tag('warning').log(`Skipping page: ${this.explorBot.lastPlanError.message}`);
return;
}
if (this.explorBot.lastPlanError instanceof BlockedError) return;
if (this.explorBot.lastPlanError) {
if (this.explorBot.lastPlanError instanceof ErrorPageError) {
throw this.explorBot.lastPlanError;
Expand Down Expand Up @@ -582,6 +590,22 @@ export class ExploreCommand extends BaseCommand {
});
}

for (const page of this.explorBot.stateManager().getBlockedPages()) {
let commands = [{ label: 'Knowledge for this page did not unblock it, update it', command: `${cli} learn ${page.url} "<facts>"` }];
if (!this.explorBot.knowledgeTracker().getMatchingKnowledge(page.url).length) {
commands = [
{ label: 'Pass credentials for this run', command: `${cli} explore ${page.url} --auth '<credentials>'` },
{ label: 'Or save them as knowledge', command: `${cli} learn ${page.url} "<credentials>"` },
];
}
sections.push({ label: `Blocked ${page.url}: ${page.reason}`, commands });
}

if (!this.completedPlans.length) {
printNextSteps(sections);
return;
}

sections.push({
label: 'Explore more',
commands: [
Expand All @@ -601,8 +625,20 @@ export class ExploreCommand extends BaseCommand {
test.finish(TestResult.SKIPPED);
}

private skipBlockedPage(): boolean {
const stateManager = this.explorBot.stateManager();
const state = stateManager.getCurrentState();
if (!state || !stateManager.isPageBlocked(state)) return false;
const reason = 'matches a blocked page';
stateManager.markBlocked(state, reason);
tag('warning').log(`Skipping page ${state.url}: ${reason}`, { emphasize: true });
if (stateManager.getBlockedPages().length >= MAX_BLOCKED_PAGES) tag('warning').log(`${MAX_BLOCKED_PAGES} blocked pages met, stopping exploration`);
return true;
}

private isLimitReached(): boolean {
if (this.maxTests != null && this.testsRun >= this.maxTests) return true;
if (this.explorBot.stateManager().getBlockedPages().length >= MAX_BLOCKED_PAGES) return true;
return this.isDeadlineReached();
}

Expand Down
7 changes: 6 additions & 1 deletion src/components/LogPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -211,11 +211,16 @@ const LogPane: React.FC<LogPaneProps> = React.memo(({ verboseMode }) => {
}

let marginTop = 0;
let marginBottom = 0;
if (log.type === 'info') marginTop = 1;
if (log.emphasize) {
marginTop = 1;
marginBottom = 1;
}
const icon = log.type === 'info' ? '◉' : log.type === 'success' ? '✓' : log.type === 'error' ? '✗' : log.type === 'warning' ? '!' : log.type === 'debug' ? '*' : '';

return (
<Box key={index} columnGap={1} marginTop={marginTop} flexDirection="row">
<Box key={index} columnGap={1} marginTop={marginTop} marginBottom={marginBottom} flexDirection="row">
{icon && <Text {...styles}>{icon}</Text>}
<Box flexDirection="column">
{lines.map((line, lineIndex) => (
Expand Down
30 changes: 30 additions & 0 deletions src/state-manager.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
import { ActionResult, type FocusedElement } from './action-result.js';
import type { ExperienceTracker } from './experience-tracker.js';
import type { Knowledge, KnowledgeTracker } from './knowledge-tracker.js';
import { ariaTemplateSignature } from './utils/aria.ts';
import { createDebug, tag } from './utils/logger.js';
import { Overlay } from './utils/overlay.js';
import type { Region } from './utils/region.js';
import { similarityScore } from './utils/similarity.ts';
import { slugify } from './utils/strings.js';
import { extractStatePath } from './utils/url-matcher.js';

const debugLog = createDebug('explorbot:state');
const BLOCKED_PAGE_SIMILARITY = 90;

export interface Link {
title: string;
Expand Down Expand Up @@ -86,6 +89,7 @@ export class StateManager {
private currentState: WebPageState | null = null;
private stateHistory: StateTransition[] = [];
private allVisitedUrls: Set<string> = new Set();
private blockedPages: BlockedPage[] = [];
private experienceUrls?: string[];
private stateChangeListeners: StateChangeListener[] = [];
private experienceTracker!: ExperienceTracker;
Expand Down Expand Up @@ -352,6 +356,25 @@ export class StateManager {
/**
* Check if we've been in this state before
*/
markBlocked(state: WebPageState, reason: string): void {
if (this.blockedPages.some((page) => page.url === state.url)) return;
this.blockedPages.push({ url: state.url, reason, signature: ariaTemplateSignature(ActionResult.fromState(state).ariaSnapshot) });
}

isPageBlocked(state: WebPageState | null): boolean {
if (!state) return false;
const signature = ariaTemplateSignature(ActionResult.fromState(state).ariaSnapshot);
return this.blockedPages.some((page) => {
if (page.url === state.url) return true;
if (!signature.size || !page.signature.size) return false;
return similarityScore(signature, page.signature) >= BLOCKED_PAGE_SIMILARITY;
});
}

getBlockedPages(): BlockedPage[] {
return this.blockedPages;
}

hasVisitedState(path: string): boolean {
return this.allVisitedUrls.has(normalizeUrl(path));
}
Expand Down Expand Up @@ -431,6 +454,7 @@ export class StateManager {
this.currentState = null;
this.stateHistory = [];
this.allVisitedUrls.clear();
this.blockedPages = [];
this.stateChangeListeners = [];
this.nextStateId = 1;

Expand All @@ -451,3 +475,9 @@ export function normalizeUrl(url: string): string {
return url.replace(/^\/+|\/+$/g, '');
}
}

export interface BlockedPage {
url: string;
reason: string;
signature: Set<string>;
}
Loading
Loading