diff --git a/CLAUDE.md b/CLAUDE.md index 45ab531b..2d69a0e8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -428,6 +428,21 @@ There are also CodeceptJS commands available: - I.see - ... ... etc (all codeceptjs commands) +## Remote log destination (`src/remote.ts`) + +`--ws ` (or `EXPLORBOT_WS_URL`) streams a run to a host UI over one WebSocket. Explorbot dials **out**, so the same code covers a child process the host spawned and a CI bot connecting from elsewhere. + +One class in one file, and nothing in explorbot knows about it. `remote` (the singleton in `src/remote.ts`) plugs into the two extension points that already exist: + +- `addDestination()` on the logger — `Remote` **implements `LogDestination`**, so it registers *itself* alongside the console and the file. It gets the entry the logger already built, so the `LogType` reaches the UI as the frame's `level` and each kind is styled there instead of scraped out of flattened text. `html` is dropped, ANSI stripped, content capped. +- `executionController.setInputCallback()` — every ask in the codebase already goes through the controller, so the host answers the Pilot's `askUser`, the Navigator's login prompt and `drill_ask` without any of them knowing where the answer came from. Installing it is also what stops a TTY-less child hanging on the readline fallback. + +Consequently `isInteractive()` (`src/ai/task-agent.ts`) is **`INK_RUNNING || executionController.hasInputCallback()`** — "somebody can answer", asked of the controller rather than of any particular front end. + +**There are no frame types.** A frame is `{type, ts, ...whatever}`; `send(type, data)` puts data on the wire and the UI renders what it recognises. Neither side validates the other's shape, so either can start sending more at any time. It queues while disconnected, reconnects with backoff, and `remote.close(exitCode)` flushes before exit (called from `showStatsAndExit`). + +`remote.registerOption(program)` adds the flag through a Commander `preAction` hook, so it covers every command including the mounted `api`/`docs` subcommands and the standalone `boat/*` bins. + ## Command Line Usage Explorbot uses the `explorbot` CLI command (defined in `bin/explorbot-cli.ts`): diff --git a/bin/explorbot-cli.ts b/bin/explorbot-cli.ts index 02bb38e4..dea833a8 100755 --- a/bin/explorbot-cli.ts +++ b/bin/explorbot-cli.ts @@ -11,6 +11,7 @@ import { App } from '../src/components/App.js'; import { StatusPane } from '../src/components/StatusPane.js'; import { ConfigParser, EXPLORBOT_ENV_VARS, PROVIDERS } from '../src/config.js'; import { ExplorBot, type ExplorBotOptions } from '../src/explorbot.js'; +import { remote } from '../src/remote.js'; import { Stats } from '../src/stats.js'; import { Plan } from '../src/test-plan.js'; import { getCliName } from '../src/utils/cli-name.ts'; @@ -26,6 +27,7 @@ const pkgPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../p const pkgVersion = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')).version as string; program.name(cli).description('AI-powered web exploration tool').version(pkgVersion, '-V, --version'); +remote.registerOption(program); if (!process.env.EXPLORBOT_NO_BANNER) { console.log(`⛵ ${chalk.yellow.bold(`Explorbot v${pkgVersion}`)} ${chalk.dim('Autonomous Testing Agent')}`); @@ -99,6 +101,10 @@ async function startTUI(explorBot: ExplorBot): Promise { } async function showStatsAndExit(code: number): Promise { + if (remote.isAttached()) { + await remote.close(code); + process.exit(code); + } if (Stats.hasActivity()) { await new Promise((resolve) => { const { unmount } = render( diff --git a/boat/api-tester/bin/apibot-cli.ts b/boat/api-tester/bin/apibot-cli.ts index e883788f..28e91987 100755 --- a/boat/api-tester/bin/apibot-cli.ts +++ b/boat/api-tester/bin/apibot-cli.ts @@ -1,5 +1,7 @@ #!/usr/bin/env bun +import { remote } from '../../../src/remote.ts'; import { createApiCommands } from '../src/cli.ts'; const program = createApiCommands('apibot'); +remote.registerOption(program); program.parse(); diff --git a/boat/doc-collector/bin/doc-collector-cli.ts b/boat/doc-collector/bin/doc-collector-cli.ts index 1502211e..5d2b6bf0 100644 --- a/boat/doc-collector/bin/doc-collector-cli.ts +++ b/boat/doc-collector/bin/doc-collector-cli.ts @@ -1,5 +1,7 @@ #!/usr/bin/env bun +import { remote } from '../../../src/remote.ts'; import { createDocsCommands } from '../src/cli.ts'; const program = createDocsCommands('doc-collector'); +remote.registerOption(program); program.parse(); diff --git a/docs/superpowers/plans/2026-08-01-actor-boat.md b/docs/superpowers/plans/2026-08-01-actor-boat.md new file mode 100644 index 00000000..ed4cd806 --- /dev/null +++ b/docs/superpowers/plans/2026-08-01-actor-boat.md @@ -0,0 +1,925 @@ +# Actor Boat Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship `boat/actor` — an intent-level browser driver CLI (`explorbot act ...`) that lets an orchestrating agent act on pages via Playwright calls or natural language while explorbot's cheap models handle perception, healing, and evidence. + +**Architecture:** A boat following the `boat/doc-collector` pattern: `Actor` class wraps `ExplorBot`, reuses Navigator/Researcher/Explorer/StateManager, adds a pure envelope renderer and a pw function wrapper. Core changes are minimal: named browser-server instances, a Navigator heal-attempt hook, and a global-config/per-host-state ladder in config loading. + +**Tech Stack:** Bun (never Node), TypeScript, commander, CodeceptJS/Playwright via Explorer, `bun:test`, Biome. + +**Spec:** `docs/superpowers/specs/2026-08-01-actor-boat-design.md` — read it first. + +**Execution model:** Run implementation subagents on Opus (user directive). + +## Global Constraints + +- Bun only; never Node.js APIs where Bun equivalents exist; tests via `bun:test`. +- No code comments unless explicitly specified; premature exit over if/else; no ternary operators; no `...(cond ? {} : {})` spreads; `?.` over `&&` chains; private methods after public; types at end of file; `dedent` for prompts. +- Prompts and tool descriptions must be GENERAL — never encode a specific failing example. +- Business logic lives in the `Actor` class / agents; CLI handlers only parse options and delegate (repo rule). +- Run `bun run format` after each code change; `bun run lint:fix` after big ones. +- Clean stdout: respect `EXPLORBOT_NO_BANNER`; envelope output goes to `console.log`, logs go through `tag()` logger. +- DO NOT duplicate existing code — reuse `src/utils/aria.ts`, `src/action-result.ts`, `src/browser-server.ts`, Historian converters. + +## File Structure + +``` +boat/actor/ +├── package.json # name "actbot", bin actbot +├── bin/actbot-cli.ts # standalone CLI entry +├── src/ +│ ├── cli.ts # createActCommands(name = 'act') +│ ├── actor.ts # Actor class (all business logic) +│ ├── envelope.ts # EnvelopeData type, renderEnvelope(), writeArtifacts() +│ └── pw-parser.ts # isFunctionExpression(), toCodeceptWrapper() +└── tests/ + ├── envelope.test.ts + ├── pw-parser.test.ts + └── actor.test.ts # duck-typed ExplorBot mocks +Core modifications: +├── src/browser-server.ts # named instances (endpoint file per instance) +├── src/ai/navigator.ts # resolveState onAttempt hook (small) +├── src/config.ts # global config + global .env + per-host state dir +└── bin/explorbot-cli.ts # program.addCommand(createActCommands('act')) +``` + +--- + +### Task 1: Envelope module + +**Files:** +- Create: `boat/actor/package.json`, `boat/actor/src/envelope.ts` +- Test: `boat/actor/tests/envelope.test.ts` + +**Interfaces:** +- Produces (used by Tasks 3–7 and 9): + +```typescript +export interface InstanceInfo { + name: string; + tabs: number; + startedAgo?: string; + others: Array<{ name: string; tabs: number }>; +} +export interface HealAttempt { + code: string; + outcome: string; +} +export interface EnvelopeData { + ok: boolean; + command: string; + healed?: boolean; + healNote?: string; + used?: string[]; + page: { url: string; previousUrl?: string; title: string; state: string; visits: number }; + changes?: string | null; + answer?: string; + research?: string; + verdict?: { passed: boolean; evidence: string; code: string }; + failure?: { error: string; attempts: HealAttempt[]; reasoning?: string; compactAria?: string }; + instance: InstanceInfo; + artifacts?: { aria: string; html: string; network: string }; +} +export function renderEnvelope(data: EnvelopeData): string; +export function writeArtifacts(dir: string, snapshot: { aria: string | null; html: string | null; requests: unknown[] }): { aria: string; html: string; network: string }; +``` + +- [ ] **Step 1: Scaffold the boat package** + +`boat/actor/package.json` (mirror `boat/api-tester/package.json` shape): + +```json +{ + "name": "actbot", + "version": "1.0.0", + "description": "High-level browser driver CLI for orchestrating agents", + "type": "module", + "bin": { "actbot": "./bin/actbot-cli.ts" }, + "scripts": { + "format": "biome format --write .", + "lint:fix": "biome lint --write .", + "check:fix": "biome check --write ." + }, + "dependencies": { + "commander": "^14.0.1", + "dedent": "^1.6.0" + } +} +``` + +- [ ] **Step 2: Write failing envelope tests** + +`boat/actor/tests/envelope.test.ts`: + +```typescript +import { describe, expect, test } from 'bun:test'; +import { mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { readFileSync } from 'node:fs'; +import { type EnvelopeData, renderEnvelope, writeArtifacts } from '../src/envelope.ts'; + +const base: EnvelopeData = { + ok: true, + command: "pw ({ page }) => page.click('text=Login')", + used: ["I.click('Login')"], + page: { url: 'https://app.example.com/dashboard', previousUrl: 'https://app.example.com/login', title: 'Dashboard', state: 'dashboard_h1_dashboard', visits: 1 }, + changes: 'ariaDiff:\n added:\n - heading "Dashboard"', + instance: { name: 'default', tabs: 3, startedAgo: '12m', others: [{ name: 'auth-test', tabs: 1 }] }, + artifacts: { aria: '/tmp/x/aria.yml', html: '/tmp/x/page.html', network: '/tmp/x/network.jsonl' }, +}; + +describe('renderEnvelope', () => { + test('success envelope contains all sections in order', () => { + const out = renderEnvelope(base); + const sections = ['### Result', '### Page', '### Changes', '### Instance', '### Artifacts']; + const positions = sections.map((s) => out.indexOf(s)); + expect(positions.every((p) => p >= 0)).toBe(true); + expect([...positions].sort((a, b) => a - b)).toEqual(positions); + expect(out).toContain('ok: true'); + expect(out).toContain("used: I.click('Login')"); + expect(out).toContain('(changed: https://app.example.com/login → https://app.example.com/dashboard)'); + expect(out).toContain('instance: default (3 tabs) | other instances: auth-test (1 tab)'); + }); + + test('unchanged url renders without changed marker', () => { + const out = renderEnvelope({ ...base, page: { ...base.page, previousUrl: base.page.url } }); + expect(out).not.toContain('(changed:'); + }); + + test('failure envelope renders attempts, reasoning and compact aria', () => { + const out = renderEnvelope({ + ...base, + ok: false, + failure: { + error: "locator 'text=Login' not found", + attempts: [{ code: "I.click('Login')", outcome: 'not visible' }, { code: 'scroll + retry', outcome: 'covered by cookie banner' }], + reasoning: 'element hidden behind consent overlay', + compactAria: '- button "Accept all"', + }, + }); + expect(out).toContain('### Failure'); + expect(out).toContain('### Healing attempts (2)'); + expect(out).toContain("1. I.click('Login')"); + expect(out).toContain('→ not visible'); + expect(out).toContain('### Current page (compact ARIA)'); + expect(out).toContain('- button "Accept all"'); + }); + + test('answer replaces changes for ask', () => { + const out = renderEnvelope({ ...base, changes: undefined, answer: 'A login form with email and password fields' }); + expect(out).toContain('### Answer'); + expect(out).not.toContain('### Changes'); + }); + + test('verdict replaces changes for verify', () => { + const out = renderEnvelope({ ...base, changes: undefined, verdict: { passed: true, evidence: 'heading "Dashboard" present', code: "I.see('Dashboard')" } }); + expect(out).toContain('### Verdict'); + expect(out).toContain('passed: true'); + expect(out).toContain("I.see('Dashboard')"); + }); + + test('healed success carries note', () => { + const out = renderEnvelope({ ...base, healed: true, healNote: 'dismissed overlay first' }); + expect(out).toContain('healed: true (dismissed overlay first)'); + }); +}); + +describe('writeArtifacts', () => { + test('writes aria, html and network files and returns absolute paths', () => { + const dir = mkdtempSync(path.join(tmpdir(), 'act-')); + const result = writeArtifacts(dir, { aria: '- button "Login"', html: '', requests: [{ url: '/api/user', status: 200 }] }); + expect(readFileSync(result.aria, 'utf-8')).toContain('button "Login"'); + expect(readFileSync(result.html, 'utf-8')).toContain(''); + expect(readFileSync(result.network, 'utf-8')).toContain('/api/user'); + expect(path.isAbsolute(result.aria)).toBe(true); + }); +}); +``` + +- [ ] **Step 3: Run tests, verify they fail** + +Run: `bun test boat/actor/tests/envelope.test.ts` +Expected: FAIL — cannot resolve `../src/envelope.ts`. + +- [ ] **Step 4: Implement `boat/actor/src/envelope.ts`** + +Pure string building. Rules: +- Sections always in order: Result, Page, then exactly one of Changes/Answer/Verdict (Changes only when `changes` is a non-empty string), Failure + Healing attempts + Current page (only when `failure` set), Instance, Artifacts (only when set). +- `healed: true (note)` on one line when healNote present, plain `healed: false` otherwise; omit line when `healed` is undefined. +- `used:` joins multiple codes with `; `. +- Page line: `url: (changed: )` only when previousUrl differs. +- State line: `state: (visit #)`. +- Instance line exactly as tested; `others` empty → `| other instances: none`. +- Network artifact written as JSONL (one `JSON.stringify` per request). +- Every attempt line: `. ` padded, then `→ `. + +Keep it one exported function plus small private helpers below it; no classes. + +- [ ] **Step 5: Run tests, verify pass** + +Run: `bun test boat/actor/tests/envelope.test.ts` +Expected: PASS (7 tests). + +- [ ] **Step 6: Format and commit** + +```bash +bun run format +git add boat/actor +git commit -m "feat(actor): boat scaffold and result envelope" +``` + +--- + +### Task 2: pw function wrapper + +The `pw` argument is a function expression in the exact shape `I.usePlaywrightTo` accepts — `({ page, browserContext, browser }) => ...` — so callers destructure whichever Playwright objects they need. The parser only answers "is this a parseable function expression" (for clean `tool:` errors) and interpolates it verbatim into the CodeceptJS call that `Action.execute` expects. + +**Files:** +- Create: `boat/actor/src/pw-parser.ts` +- Test: `boat/actor/tests/pw-parser.test.ts` + +**Interfaces:** +- Produces (used by Task 4): + +```typescript +export function isFunctionExpression(expr: string): { valid: boolean; error?: string }; +export function toCodeceptWrapper(expr: string): string; +``` + +- [ ] **Step 1: Write failing tests** + +`boat/actor/tests/pw-parser.test.ts`: + +```typescript +import { describe, expect, test } from 'bun:test'; +import { isFunctionExpression, toCodeceptWrapper } from '../src/pw-parser.ts'; + +describe('isFunctionExpression', () => { + test.each([ + "({ page }) => page.click('text=Login')", + "async ({ page }) => { await page.fill('#email', 'user@example.com'); await page.keyboard.press('Enter'); }", + "({ browserContext }) => browserContext.clearCookies()", + "({ page, browser }) => browser.version()", + "function ({ page }) { return page.title() }", + ])('accepts %s', (expr) => { + expect(isFunctionExpression(expr).valid).toBe(true); + }); + + test.each([ + "page.click('text=Login')", + "({ page }) => page.click('a'", + "just some text", + "", + ])('rejects %s', (expr) => { + const result = isFunctionExpression(expr); + expect(result.valid).toBe(false); + expect(result.error).toBeTruthy(); + }); +}); + +describe('toCodeceptWrapper', () => { + test('interpolates the function verbatim into usePlaywrightTo', () => { + const code = toCodeceptWrapper("({ page }) => page.click('text=Login')"); + expect(code).toBe("I.usePlaywrightTo('pw', ({ page }) => page.click('text=Login'))"); + }); +}); +``` + +- [ ] **Step 2: Run tests, verify fail** + +Run: `bun test boat/actor/tests/pw-parser.test.ts` +Expected: FAIL — module not found. + +- [ ] **Step 3: Implement `boat/actor/src/pw-parser.ts`** + +```typescript +const FUNCTION_SHAPE = /^(async\s+)?(function\b|\()/; + +export function isFunctionExpression(expr: string): { valid: boolean; error?: string } { + const trimmed = expr.trim(); + if (!trimmed) return { valid: false, error: 'empty expression; pass a function like ({ page }) => ...' }; + if (!FUNCTION_SHAPE.test(trimmed)) return { valid: false, error: 'expression must be a function like ({ page }) => ... destructuring the playwright objects it needs' }; + try { + new Function(`return (${trimmed})`); + } catch (e) { + return { valid: false, error: `not a valid function expression: ${(e as Error).message}` }; + } + return { valid: true }; +} + +export function toCodeceptWrapper(expr: string): string { + return `I.usePlaywrightTo('pw', ${expr.trim()})`; +} +``` + +`new Function` is construction-only — the user's code is never invoked here (invoking would execute non-function inputs). The shape regex rejects bare call chains like `page.click(...)` with an error pointing at the expected form; the construction catch turns syntax errors (unbalanced brackets, garbage text) into the `tool:` error in the envelope. Note the shape regex also requires arrow parameters to be parenthesized — acceptable since the destructured `({ page })` form is the documented contract. + +- [ ] **Step 4: Run tests, verify pass** + +Run: `bun test boat/actor/tests/pw-parser.test.ts` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +bun run format +git add boat/actor/src/pw-parser.ts boat/actor/tests/pw-parser.test.ts +git commit -m "feat(actor): pw function wrapper" +``` + +--- + +### Task 3: Named browser instances in browser-server + +**Files:** +- Modify: `src/browser-server.ts` (exports at line 88: `readEndpoint, removeEndpointFile, isServerRunning, launchServer, getEndpointFilePath, getAliveEndpoint`) +- Modify: `bin/explorbot-cli.ts` browser start/stop/status handlers (lines ~736-821) to pass instance through +- Test: `tests/unit/browser-server-instances.test.ts` + +**Interfaces:** +- Produces (used by Tasks 4 and 7): every exported function gains an optional trailing `instance = 'default'` parameter; endpoint file becomes `.browser-endpoint` for `default` and `.browser-endpoint-` otherwise. New export: + +```typescript +export function listInstances(): Array<{ name: string; endpoint: string }>; +``` + +- [ ] **Step 1: Read `src/browser-server.ts` fully** — understand `getEndpointFilePath`, `writeEndpoint`, `getAliveEndpoint` before touching anything. + +- [ ] **Step 2: Write failing test** + +`tests/unit/browser-server-instances.test.ts`: + +```typescript +import { describe, expect, test } from 'bun:test'; +import path from 'node:path'; +import { getEndpointFilePath } from '../../src/browser-server.ts'; + +describe('named instances', () => { + test('default instance keeps legacy filename', () => { + expect(path.basename(getEndpointFilePath())).toBe('.browser-endpoint'); + expect(path.basename(getEndpointFilePath('default'))).toBe('.browser-endpoint'); + }); + + test('named instance gets suffixed filename', () => { + expect(path.basename(getEndpointFilePath('staging'))).toBe('.browser-endpoint-staging'); + }); +}); +``` + +Add a test for `listInstances()` writing two endpoint files into a temp output dir if `getEndpointFilePath` resolves from a configurable root; if the output root comes from config at import time, keep `listInstances` scanning `path.dirname(getEndpointFilePath())` for files matching `.browser-endpoint*` and test via that dir. + +- [ ] **Step 3: Run test, verify fail** + +Run: `bun test tests/unit/browser-server-instances.test.ts` +Expected: FAIL — `getEndpointFilePath` does not accept an argument (or wrong filename). + +- [ ] **Step 4: Implement** + +Thread `instance = 'default'` through `getEndpointFilePath`, `readEndpoint`, `writeEndpoint`, `removeEndpointFile`, `isServerRunning`, `getAliveEndpoint`, `launchServer`. Filename: `default` → `.browser-endpoint` (backward compatible), else `.browser-endpoint-${instance}`. Sanitize instance to `[a-z0-9-]` and reject others with a thrown Error. `listInstances()` scans the endpoint dir with `readdirSync`, maps filenames back to names. + +- [ ] **Step 5: Run full unit suite** + +Run: `bun test tests/unit/` +Expected: PASS, no regressions. + +- [ ] **Step 6: Commit** + +```bash +bun run format +git add src/browser-server.ts tests/unit/browser-server-instances.test.ts bin/explorbot-cli.ts +git commit -m "feat: named browser server instances" +``` + +--- + +### Task 4: Actor class core — lifecycle and pw command + +**Files:** +- Create: `boat/actor/src/actor.ts` +- Test: `boat/actor/tests/actor.test.ts` + +**Interfaces:** +- Consumes: `renderEnvelope`/`writeArtifacts`/`EnvelopeData`/`InstanceInfo` (Task 1), `isFunctionExpression`/`toCodeceptWrapper` (Task 2), `getAliveEndpoint(instance)`/`launchServer` (Task 3), `ExplorBot` API (`src/explorbot.ts`: `start()`, `stop()`, `visit(url)`, `getExplorer()`, `stateManager()`, `getCurrentState()`, `agentNavigator()`, `agentResearcher()`, `agentHistorian()`, `requestStore()`), `Explorer.action(): Action` (`src/explorer.ts:151`), `Action.execute(code)` / `Action.capturePageState()` (`src/action.ts:64-68`), `ActionResult` (`getStateHash()`, `ariaSnapshot`, `combinedHtml()`, `url`, `title`), `compactAriaSnapshot` (`src/utils/aria.ts`), `Diff`/`PageDiff` via `ActionResult` (`src/action-result.ts`). +- Produces (used by Tasks 5–7, 9): + +```typescript +export interface ActorOptions { + verbose?: boolean; + config?: string; + path?: string; + instance?: string; + session?: string; + heal?: boolean; + ephemeral?: boolean; + framework?: 'codeceptjs' | 'playwright'; + vision?: boolean; + url?: string; +} +export class Actor { + constructor(options?: ActorOptions); + async start(): Promise; + async stop(): Promise; + async pw(expression: string): Promise; + async instanceInfo(): Promise; +} +``` + +- [ ] **Step 1: Study `boat/doc-collector/src/docbot.ts`** — the Actor mirrors how DocBot wraps ExplorBot (constructor builds `new ExplorBot({...})`, `start()` boots it, agents accessed lazily). + +- [ ] **Step 2: Write failing tests with duck-typed mocks** + +`boat/actor/tests/actor.test.ts` — follow the duck-type-mock style of `tests/integration/` (mock Explorer/StateManager, no real browser): + +```typescript +import { describe, expect, test } from 'bun:test'; +import { Actor } from '../src/actor.ts'; + +function fakeState(over: Record = {}) { + return { + url: 'https://app.example.com/login', + title: 'Login', + getStateHash: () => 'login_h1_login', + ariaSnapshot: '- textbox "Email"\n- button "Sign in"', + combinedHtml: () => '
', + ...over, + }; +} + +function fakeActor() { + const actor = new Actor({ instance: 'default' }); + const executed: string[] = []; + const after = fakeState({ url: 'https://app.example.com/dashboard', title: 'Dashboard', getStateHash: () => 'dashboard_h1_dashboard' }); + (actor as any).bot = { + getExplorer: () => ({ + action: () => ({ + execute: async (code: string) => { + executed.push(code); + return { actionResult: after, lastError: null }; + }, + }), + capture: async () => after, + }), + stateManager: () => ({ + getCurrentState: () => fakeState(), + getVisitCount: () => 1, + }), + requestStore: () => ({ getRequests: () => [] }), + }; + (actor as any).artifactsDir = '/tmp/act-test'; + return { actor, executed }; +} + +describe('Actor.pw', () => { + test('rejects non-function argument as tool error without executing', async () => { + const { actor, executed } = fakeActor(); + const envelope = await actor.pw("page.click('text=Login')"); + expect(envelope.ok).toBe(false); + expect(envelope.failure?.error).toContain('function'); + expect(executed.length).toBe(0); + }); + + test('executes wrapped function and returns success envelope data', async () => { + const { actor, executed } = fakeActor(); + const envelope = await actor.pw("({ page }) => page.click('text=Login')"); + expect(executed[0]).toContain("I.usePlaywrightTo"); + expect(envelope.ok).toBe(true); + expect(envelope.used).toEqual(["({ page }) => page.click('text=Login')"]); + expect(envelope.page.url).toBe('https://app.example.com/dashboard'); + expect(envelope.page.previousUrl).toBe('https://app.example.com/login'); + }); +}); +``` + +- [ ] **Step 3: Run tests, verify fail** + +Run: `bun test boat/actor/tests/actor.test.ts` +Expected: FAIL — module not found. + +- [ ] **Step 4: Implement Actor core** + +`boat/actor/src/actor.ts` responsibilities in this task: +- Constructor stores options, builds `ExplorBot` options (`config`, `path`, `verbose`, `session`, `headless: true`) — but do NOT boot in constructor (DocBot pattern). +- `start()`: resolve instance endpoint via `getAliveEndpoint(this.options.instance ?? 'default')`; when absent, launch via `launchServer` equivalent used by `explorbot browser start` (reuse, do not reimplement); then `await this.bot.start()`. When `options.url` is set and no current state exists, `await this.bot.visit(options.url)`. +- `pw(expression)`: + 1. `isFunctionExpression` — invalid → return tool-error envelope (`ok: false`, `failure.error` prefixed `tool:`), never execute. + 2. Capture `before = stateManager.getCurrentState()`. + 3. `const action = explorer.action(); await action.execute(toCodeceptWrapper(expression))`. + 4. On success build `EnvelopeData` with `used: [expression]`, page block from resulting `ActionResult` (`previousUrl` from `before`), `changes` from the pageDiff ariaChanges the Action pipeline computed (see `ActionResult.toToolResult` usage in `src/ai/tools.ts:1122` for how diffs are obtained — reuse the same path, do not recompute). + 5. Write artifacts via `writeArtifacts(this.nextArtifactDir(), { aria: result.ariaSnapshot, html: result.combinedHtml(), requests: this.bot.requestStore().getRequests() })`. + 6. On execution error: this task returns a plain failure envelope (heal comes in Task 5). +- `instanceInfo()`: name from options; tabs from `explorer` playwright context pages count (add a small public accessor if none exists — check `src/explorer.ts:79` `playwrightHelper?.page`); others from `listInstances()` (Task 3) excluding self; tabs for others may be reported as 0 when unreachable — do not connect to other instances. +- `nextArtifactDir()`: `/act//`, one per command invocation. + +Mockability rule: everything the tests stub lives behind `this.bot` — keep all ExplorBot access via that single field. + +- [ ] **Step 5: Run tests, verify pass** + +Run: `bun test boat/actor/tests/` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +bun run format +git add boat/actor +git commit -m "feat(actor): Actor core with pw execution and instance info" +``` + +--- + +### Task 5: Heal loop + +**Files:** +- Modify: `src/ai/navigator.ts:192` (`resolveState` signature), `boat/actor/src/actor.ts` +- Test: `boat/actor/tests/actor.test.ts` (extend), `tests/integration/` untouched + +**Interfaces:** +- Consumes: `navigator.resolveState(message, actionResult, opts)` (`src/ai/navigator.ts:192`). +- Produces: `resolveState` opts gains `onAttempt?: (attempt: { code: string; error?: string }) => void`, invoked once per executed recovery attempt with the exact code string and the error message when it failed. Actor gains private `heal(...)` used by pw (and Task 6 commands). + +- [ ] **Step 1: Extend `resolveState` with the attempt hook** + +Read `src/ai/navigator.ts` `resolveState` implementation; find where recovery code executes (each `action.execute`/attempt site). Add `opts.onAttempt` invocation at each attempt completion with `{ code, error: lastError?.message }`. Smallest change possible; no behavior change when the callback is absent. + +- [ ] **Step 2: Write failing Actor heal test** + +Extend `boat/actor/tests/actor.test.ts`; stub `agentNavigator` on the fake bot: + +```typescript +test('failed pw heals via navigator and reports healed envelope', async () => { + const { actor } = fakeActor(); + (actor as any).bot.getExplorer = () => ({ + action: () => ({ + execute: async () => { + throw new Error("locator 'text=Login' not found"); + }, + }), + capture: async () => fakeState(), + }); + (actor as any).bot.agentNavigator = () => ({ + resolveState: async (_msg: string, _result: unknown, opts: any) => { + opts?.onAttempt?.({ code: "I.click('Login')", error: 'not visible' }); + opts?.onAttempt?.({ code: "I.click('#login-btn')" }); + return true; + }, + }); + const envelope = await actor.pw("({ page }) => page.click('text=Login')"); + expect(envelope.ok).toBe(true); + expect(envelope.healed).toBe(true); + expect(envelope.used).toEqual(["I.click('#login-btn')"]); +}); + +test('exhausted heal returns failure envelope with attempts and compact aria', async () => { + const { actor } = fakeActor(); + (actor as any).bot.getExplorer = () => ({ + action: () => ({ + execute: async () => { + throw new Error("locator 'text=Login' not found"); + }, + }), + capture: async () => fakeState(), + }); + (actor as any).bot.agentNavigator = () => ({ + resolveState: async (_msg: string, _result: unknown, opts: any) => { + opts?.onAttempt?.({ code: "I.click('Login')", error: 'not visible' }); + return false; + }, + }); + const envelope = await actor.pw("({ page }) => page.click('text=Login')"); + expect(envelope.ok).toBe(false); + expect(envelope.failure?.attempts.length).toBe(1); + expect(envelope.failure?.compactAria).toContain('button'); +}); +``` + +- [ ] **Step 3: Run tests, verify fail** + +Run: `bun test boat/actor/tests/actor.test.ts` +Expected: new tests FAIL (heal not implemented). + +- [ ] **Step 4: Implement heal in Actor** + +Private `heal(errorMessage, actionResult, originalCode)`: +- Skip entirely when `options.heal === false` — go straight to failure envelope. +- Collect attempts array via `onAttempt`; call `navigator.resolveState(errorMessage, actionResult, { onAttempt })`. +- `true` → success envelope: `healed: true`, `healNote` = last attempt outcome summary, `used` = codes of successful attempts (last attempt without error), current state re-read from `stateManager.getCurrentState()`. +- `false` → failure envelope: `error`, `attempts` (map error→outcome, success→'ok'), `compactAria` from `compactAriaSnapshot(state.ariaSnapshot, true)` (`src/utils/aria.ts`), reasoning left to Task 8's compaction if trivial — set `reasoning` to a one-line join of distinct outcomes for now (general, not model-generated). +- Failure envelopes still include artifacts and instance blocks. + +- [ ] **Step 5: Run boat tests and repo unit tests** + +Run: `bun test boat/actor/tests/ tests/unit/` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +bun run format +git add src/ai/navigator.ts boat/actor +git commit -m "feat(actor): heal loop over navigator recovery with attempt trace" +``` + +--- + +### Task 6: do, click, fill, ask, verify, research commands + +**Files:** +- Modify: `boat/actor/src/actor.ts` +- Test: `boat/actor/tests/actor.test.ts` (extend) + +**Interfaces:** +- Consumes: `collectInteractiveNodes(snapshot)` (`src/utils/aria.ts`), `createCodeceptJSTools` (`src/ai/tools.ts:30`) — tool objects expose `.execute(input)`; `provider.invokeConversation` with `maxToolRoundtrips` (Driller pattern, `src/ai/driller.ts:325-329`); `researcher.answerQuestionAboutScreenshot(state, question)` and `researcher.summary(state)` (`src/ai/researcher.ts:544, 659`); `navigator.verifyState(message, actionResult)` (`src/ai/navigator.ts:618`) returning `{ verified, successfulCodes, assertionSteps, totalAttempted }`. +- Produces: + +```typescript +async do(instruction: string): Promise; +async click(target: string): Promise; +async fill(field: string, value: string): Promise; +async ask(question: string): Promise; +async verify(assertion: string): Promise; +async research(opts?: { data?: boolean; deep?: boolean; fresh?: boolean }): Promise; +``` + +`EnvelopeData` gains an optional `research?: string` field (Task 1's renderer: `### Research` replaces `### Changes` when set, mutually exclusive with `answer`/`verdict` — add a render test alongside the answer/verdict ones). + +- [ ] **Step 1: Write failing tests for the deterministic fast path** + +```typescript +test('do with unambiguous role+name executes without AI', async () => { + const { actor, executed } = fakeActor(); + let aiCalled = false; + (actor as any).bot.getProvider = () => ({ invokeConversation: async () => { aiCalled = true; } }); + const envelope = await actor.do('click the "Sign in" button'); + expect(aiCalled).toBe(false); + expect(executed.some((code) => code.includes("I.click"))).toBe(true); + expect(envelope.ok).toBe(true); +}); + +test('verify returns verdict with assertion code', async () => { + const { actor } = fakeActor(); + (actor as any).bot.agentNavigator = () => ({ + verifyState: async () => ({ verified: true, successfulCodes: ["I.see('Dashboard')"], assertionSteps: [], totalAttempted: 1 }), + }); + const envelope = await actor.verify('user sees the dashboard'); + expect(envelope.verdict?.passed).toBe(true); + expect(envelope.verdict?.code).toBe("I.see('Dashboard')"); +}); + +test('research returns UI map in envelope', async () => { + const { actor } = fakeActor(); + (actor as any).bot.agentResearcher = () => ({ research: async () => '## Section: Login Form\n| Element | ARIA | CSS |' }); + const envelope = await actor.research({ data: true }); + expect(envelope.research).toContain('Login Form'); + expect(envelope.ok).toBe(true); +}); + +test('ask without vision answers from researcher summary', async () => { + const { actor } = fakeActor(); + (actor as any).bot.agentResearcher = () => ({ summary: async () => 'Login form with email and password' }); + (actor as any).bot.getProvider = () => ({ chat: async () => ({ text: 'A login page with an email form' }) }); + const envelope = await actor.ask('what do I see?'); + expect(envelope.answer).toContain('login'); +}); +``` + +- [ ] **Step 2: Run tests, verify fail** — `bun test boat/actor/tests/actor.test.ts`. + +- [ ] **Step 3: Implement** + +- `do(instruction)`: + 1. Fast path: quote-extract or word-match the instruction against `collectInteractiveNodes(state.ariaSnapshot)`; when exactly one node matches by name (case-insensitive) and the instruction's verb maps to that node's default interaction, execute `I.click('')` / `I.fillField(...)` via `explorer.action().execute(...)` with zero AI. The matching must be generic (role vocabulary from `INTERACTIVE_ROLES`), never keyed to specific words from any one app. + 2. Otherwise: bounded agentic call — `provider.invokeConversation(conversation, createCodeceptJSTools(explorer, ...), { maxToolRoundtrips: 3, toolChoice: 'required' })` with a dedent system prompt: current compact ARIA + the instruction + rule to perform exactly the instructed interaction and stop. Collect executed codes from the tool results (same shape the Driller reads). + 3. Failures feed `heal(...)` from Task 5. +- `click(target)` / `fill(field, value)`: call the corresponding tool object from `createCodeceptJSTools` directly (`tools.click.execute({ locator: target })` — read the tool's exact input schema in `src/ai/tools.ts` first and match it); ladder failures feed `heal`. +- `ask(question)`: `options.vision` → `researcher.answerQuestionAboutScreenshot(state, question)`; otherwise `provider.chat` over dedent prompt containing `researcher.summary(state)` + compact ARIA + the question. Non-mutating: envelope has `answer`, no `changes`, artifacts still written. +- `verify(assertion)`: `explorer.capture()` then `navigator.verifyState(assertion, actionResult)`; verdict `{ passed: verified, evidence: , code: successfulCodes.join('\n') }`. +- `research(opts)`: `researcher.research(state, { screenshot: true, data: opts.data, deep: opts.deep, force: opts.fresh })` (`src/ai/researcher.ts:94`); envelope `research` = returned UI map verbatim (staleness banner included when cached), no `changes`. Non-mutating; artifacts still written. +- All command methods end by building `used` from actually executed code (never the requested input when they differ). + +- [ ] **Step 4: Run tests, verify pass** — `bun test boat/actor/tests/`. + +- [ ] **Step 5: Commit** + +```bash +bun run format +git add boat/actor +git commit -m "feat(actor): do, click, fill, ask, verify commands" +``` + +--- + +### Task 7: go command and browser instance management + +**Files:** +- Modify: `boat/actor/src/actor.ts` +- Test: `boat/actor/tests/actor.test.ts` (extend) + +**Interfaces:** +- Consumes: `navigator.visit(destination)` (`src/ai/navigator.ts:140` — handles both URLs and NL/state destinations, same call the TUI `/navigate` uses per `src/commands/navigate-command.ts`), Task 3 instance functions. +- Produces: + +```typescript +async go(target: string): Promise; +async browserStart(): Promise; +async browserStop(all?: boolean): Promise; +async browserStatus(): Promise; +``` + +- [ ] **Step 1: Write failing tests** + +```typescript +test('go delegates to navigator.visit and returns envelope', async () => { + const { actor } = fakeActor(); + const visited: string[] = []; + (actor as any).bot.agentNavigator = () => ({ visit: async (dest: string) => { visited.push(dest); } }); + const envelope = await actor.go('billing settings'); + expect(visited).toEqual(['billing settings']); + expect(envelope.ok).toBe(true); + expect(envelope.page.url).toBeTruthy(); +}); +``` + +- [ ] **Step 2: Run, verify fail.** + +- [ ] **Step 3: Implement** + +- `go(target)`: `navigator.visit(target)` (it resolves URL vs intent internally); envelope from resulting state; navigation errors feed `heal`. +- `browserStart/Stop/Status`: thin delegation to Task 3's browser-server functions with `options.instance`; `browserStop(true)` iterates `listInstances()`. Status string includes what `instanceInfo()` knows. +- Autostart: already in `start()` (Task 4) — verify `go` path hits it when no daemon runs; with `options.session` set, the launched context loads storage state (Explorer already honors `session` — `src/explorer.ts:106,337`). + +- [ ] **Step 4: Run tests, verify pass; commit** + +```bash +bun run format +git add boat/actor +git commit -m "feat(actor): go command and instance management" +``` + +--- + +### Task 8: Config ladder — global config, global .env, per-host state dir + +**Files:** +- Modify: `src/config.ts` (`ConfigParser.loadConfig` at line 324, `buildEnvConfig` at line 489, `resolveOutputRoot` at line 667) +- Test: `tests/unit/config-ladder.test.ts` + +**Interfaces:** +- Produces: `loadConfig` resolution order becomes: explicit `--config` path → project `explorbot.config.js|ts` in cwd → `~/.config/explorbot/config.js|ts` → env-var config (`buildEnvConfig`). `.env` loading order: cwd `.env` (existing behavior) then `~/.config/explorbot/.env` (only for keys not already set). New exported helper: + +```typescript +export function resolveStateRoot(baseUrl: string, ephemeral?: boolean): string; +``` + +returning `~/.local/state/explorbot//` (created), or a `mkdtempSync` temp dir when `ephemeral`. + +- [ ] **Step 1: Read `src/config.ts` load path fully** (lines 297-560) before changing anything. + +- [ ] **Step 2: Write failing tests** + +`tests/unit/config-ladder.test.ts`: + +```typescript +import { afterEach, describe, expect, test } from 'bun:test'; +import { existsSync } from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { resolveStateRoot } from '../../src/config.ts'; + +describe('resolveStateRoot', () => { + test('derives persistent per-host dir', () => { + const dir = resolveStateRoot('https://app.example.com/login'); + expect(dir).toBe(path.join(os.homedir(), '.local', 'state', 'explorbot', 'app.example.com')); + expect(existsSync(dir)).toBe(true); + }); + + test('ephemeral returns fresh temp dir', () => { + const a = resolveStateRoot('https://app.example.com', true); + const b = resolveStateRoot('https://app.example.com', true); + expect(a).not.toBe(b); + expect(a).toContain('explorbot'); + }); +}); +``` + +Global-config precedence test: point `ConfigParser.loadConfig` at a temp `HOME` (set `process.env.HOME` in the test, restore in `afterEach`), write `~/.config/explorbot/config.js` exporting a marker value, assert it loads when cwd has no project config and that a project config wins when both exist. Follow existing config tests in `tests/unit/` for how ConfigParser is instantiated. + +- [ ] **Step 3: Run, verify fail.** + +- [ ] **Step 4: Implement** + +- `resolveStateRoot`: host from `new URL(baseUrl).host`; `mkdirSync(..., { recursive: true })`; ephemeral via `mkdtempSync(path.join(os.tmpdir(), 'explorbot-'))`. +- In `loadConfig`: after project-config lookup misses, try `path.join(os.homedir(), '.config', 'explorbot', 'config.js')` then `.ts` through the existing `loadConfigModule`. +- `.env`: where the existing cwd `.env` loads, additionally load `~/.config/explorbot/.env` without overwriting already-set keys. +- In `buildEnvConfig` (config-free mode): when no project config, set `dirs` (knowledge/experience/output) under `resolveStateRoot(baseUrl, ephemeralFlag)`; ephemeral flag arrives via new `EXPLORBOT_EPHEMERAL` env var so the boat can pass it without new plumbing. + +- [ ] **Step 5: Run full unit suite** — `bun test tests/unit/` — PASS, no regressions. + +- [ ] **Step 6: Commit** + +```bash +bun run format +git add src/config.ts tests/unit/config-ladder.test.ts +git commit -m "feat: global config ladder and per-host state dirs" +``` + +--- + +### Task 9: CLI wiring and --help contract + +**Files:** +- Create: `boat/actor/src/cli.ts`, `boat/actor/bin/actbot-cli.ts` +- Modify: `bin/explorbot-cli.ts` (add near line 874: `program.addCommand(createActCommands('act'))`) +- Test: manual smoke via `--help` (Step 4) + +**Interfaces:** +- Consumes: `Actor`/`ActorOptions` (Tasks 4-7), `renderEnvelope` (Task 1). +- Produces: `export function createActCommands(name = 'act'): Command`. + +- [ ] **Step 1: Implement `boat/actor/src/cli.ts`** + +Mirror `boat/doc-collector/src/cli.ts` structure (`addCommonOptions`, `buildOptions`, subcommands). Subcommands: `pw `, `do `, `click `, `fill `, `ask `, `verify ` (alias `assert`), `research` (flags `--data`, `--deep`, `--fresh`), `go `, `browser `. Common options: + +``` +-v, --verbose --debug +-c, --config -p, --path +-i, --instance --session [file] +--no-heal --ephemeral +--framework --vision (ask only) +--url (start page for config-free mode) +``` + +Every action handler: `setPreserveConsoleLogs(true)`, build `Actor`, `await actor.start()`, run the method, `console.log(renderEnvelope(result))`, `await actor.stop()`, `process.exit(result.ok ? 0 : 1)`. `--ephemeral` sets `process.env.EXPLORBOT_EPHEMERAL = '1'` before Actor construction. Zero business logic in handlers. + +- [ ] **Step 2: Write the --help contract text** + +The command description plus `addHelpText('after', ...)` on the `act` group is the sole teaching surface for orchestrating agents. It must compactly cover (dedent block, ~40 lines): the tiering (pw = precise, click/fill = ladder, do = intent), the recommended loop (research once for verified locators → drive with pw → verify), the envelope sections and their meaning, `used:` as reusable verified code, heal semantics and `--no-heal`, failure = inline compact ARIA + artifact file paths for deep dives, `--instance` vs `--session`, autostart behavior, the close-when-finished convention driven by `### Instance`, and one usage example per tier. General shapes only — no app-specific examples. + +- [ ] **Step 3: Wire into main CLI and standalone bin** + +- `bin/explorbot-cli.ts`: `import { createActCommands } from '../boat/actor/src/cli.ts';` + `program.addCommand(createActCommands('act'));` next to the existing api/docs registrations (line ~874). +- `boat/actor/bin/actbot-cli.ts`: mirror `boat/api-tester/bin` entry — a commander program that mounts the same subcommands at top level. + +- [ ] **Step 4: Smoke the help output** + +Run: `bun bin/explorbot-cli.ts act --help` and `bun boat/actor/bin/actbot-cli.ts --help` +Expected: full contract text, all 8 subcommands listed, no banner noise with `EXPLORBOT_NO_BANNER=1`. + +- [ ] **Step 5: Commit** + +```bash +bun run format && bun run lint:fix +git add boat/actor bin/explorbot-cli.ts +git commit -m "feat(actor): act CLI namespace and standalone actbot bin" +``` + +--- + +### Task 10: End-to-end smoke, changelog, docs + +**Files:** +- Create: `tests/node/actor-smoke.test.ts` (or `tests/regression/` — match where existing browser-driving tests live; inspect both dirs first) +- Modify: `docs/reference/commands.md`, `CHANGELOG.md` (via `/changelog` skill at commit time) + +- [ ] **Step 1: Inspect existing e2e/browser test setup** — `tests/node/` and `tests/regression/` — reuse their fixture-server pattern for a local page (a form with a button and an input; fictional content only). + +- [ ] **Step 2: Write the smoke test** + +Scenarios, driven through the `Actor` class directly against the local fixture (real browser, no AI provider needed for these paths): +1. `pw "({ page }) => page.click('text=Submit')"` on the fixture → `ok: true`, envelope contains `### Changes` and artifact files exist on disk. +2. `pw` with a non-function argument → `ok: false`, `tool:`-prefixed error, exit path returns without browser action. +3. `do 'click the "Submit" button'` → fast path, zero AI (assert no provider configured and it still works). +4. Instance autostart honored: run without a pre-started daemon; assert endpoint file appears. + +Heal-path e2e requires an AI provider — cover it with an aimock integration test in `tests/integration/actor-heal.test.ts` following `tests/integration/planner.test.ts` (mock provider returns a recovery instruction; assert `healed: true` envelope and `onAttempt` trace). + +- [ ] **Step 3: Run everything** + +Run: `bun test boat/actor/tests/ tests/unit/ tests/integration/` plus the smoke file. +Expected: all PASS. + +- [ ] **Step 4: Document** + +Add an "Actor boat" section to `docs/reference/commands.md`: command table, envelope sample, tiering guidance, instance/session flags, config-free example (`EXPLORBOT_AI_PROVIDER=groq explorbot act go https://app.example.com`). + +- [ ] **Step 5: Final checks and commit** + +```bash +bun run format && bun run check:fix +``` + +Invoke the `/changelog` skill, then: + +```bash +git add -A +git commit -m "feat(actor): e2e smoke, docs and changelog" +``` + +--- + +## Self-Review Notes + +- Spec coverage: pw/do/click/fill/ask/verify+assert/research/go (Tasks 4-7, 9), envelope + used code (1, 4), heal + attempt trace + `--no-heal` (5), instances + autostart + `--session` reuse (3, 4, 7), config-free ladder + per-host state + `--ephemeral` (8), `--help`-only discovery (9), testing incl. aimock heal test (10). Framework flag (`--framework`) is parsed (9) and stored (4); Historian-based conversion of `used:` into Playwright dialect is deliberately deferred until `used` collection stabilizes — v1 emits the executed CodeceptJS (pw commands echo the Playwright expression itself), which satisfies "actual used locator" for both dialect inputs. If reviewers want full conversion in v1, extend Task 6 with `historian.toPlaywrightCode` per `src/ai/historian/playwright.ts:21`. +- Vision `ask` degrades to text path when no `visionModel` configured (`provider.hasVision()`, `src/ai/provider.ts:635`) — implementer: guard in `ask`. +- Type consistency: `EnvelopeData`/`InstanceInfo`/`HealAttempt` defined once in Task 1 and only consumed elsewhere; `ActorOptions` defined in Task 4 and consumed by 9. diff --git a/docs/superpowers/specs/2026-08-01-actor-boat-design.md b/docs/superpowers/specs/2026-08-01-actor-boat-design.md new file mode 100644 index 00000000..3375537e --- /dev/null +++ b/docs/superpowers/specs/2026-08-01-actor-boat-design.md @@ -0,0 +1,204 @@ +# Actor Boat — High-Level Browser Driver for Orchestrating Agents + +**Date:** 2026-08-01 +**Status:** Draft for review +**Implementation model:** Opus + +## Problem + +Coding agents (Claude Code on Opus/Fable) drive browsers through playwright-cli or Playwright MCP. Every action forces page state (aria snapshots) into the orchestrator's context: the expensive model pays to read the tree, pick a ref, and re-read after every step, and each snapshot stays in conversation history compounding cost for the rest of the session. A 20-step flow means 20+ expensive perception roundtrips. + +Explorbot already owns the layers that fix this: cheap-model perception (Navigator, Researcher), semantic diffs (ariaDiff, pageDiff), state tracking, and experience replay. The Actor boat exposes those layers as an intent-level CLI so the orchestrator sends instructions and receives compact evidence — page data never enters the expensive context on the happy path. + +## Goals + +- Orchestrator issues precise Playwright calls or natural-language instructions; explorbot's cheap models run the perception–action micro-loop. +- Every response is a uniform envelope: evidence of what happened, never a raw page dump. +- Failures exhaust cheap healing first, then return a compacted investigation so the orchestrator can drop down a level and drive directly. +- Zero project setup: works from any directory via env vars and a global user config, with per-host persistent state. +- Every successful action reports the exact code that worked, so sessions double as verified-locator material for test generation. + +## Non-Goals + +- Not a replacement for playwright-cli as a general browser tool. +- No MCP server, no SKILL.md package in v1 — the contract is taught entirely by `--help` text. +- No new core agents unless heal orchestration proves to need one (then it lives inside the boat). + +## Architecture + +A boat, following the existing `boat/api-tester` / `boat/doc-collector` pattern: + +``` +boat/actor/ +├── package.json # name: "actbot", own bin +├── bin/actbot-cli.ts # standalone CLI +└── src/ + ├── cli.ts # createActCommands('act') → composed into main CLI + ├── actor.ts # Actor class wrapping ExplorBot (like DocBot) + ├── envelope.ts # result envelope: inline render + artifact files + └── config.ts +``` + +Registered in `bin/explorbot-cli.ts` via `program.addCommand(createActCommands('act'))`. The `Actor` class wraps `ExplorBot`, reusing `agentNavigator()`, `agentResearcher()`, `stateManager()`, Historian, and the Explorer/Action capture pipeline. Business logic lives in the Actor class and agents; CLI handlers stay thin, per repo convention. + +## Command Surface + +``` +explorbot act pw "({ page }) => page.click('text=Login')" # raw Playwright fn, healed on failure +explorbot act do "click the login link" # NL action via Navigator +explorbot act click "Login" # targeted click via existing fallback ladder +explorbot act fill "Search" "wireless mouse" # targeted fill via existing ladder +explorbot act ask "what do I see here?" # cheap-model page Q&A via Researcher +explorbot act research [--data] [--deep] [--fresh] # verified UI map for precise pw driving +explorbot act verify "user is logged in" # AI assertion (alias: assert) +explorbot act go "billing settings" # URL or NL navigation +explorbot act browser start|stop|status|list # instance management (stop --all) +``` + +Tiering: `pw` = precise (no AI on happy path), `click`/`fill` = targeted with deterministic fallback ladders (AI only on ambiguity), `do` = intent (cheap-model planning). `select`, `pressKey`, `hover`, `drag` intentionally stay behind `pw` — a subcommand without a ladder is surface without value. + +### Common flags + +- `--instance ` / `-i` — which named browser daemon to talk to. Default instance otherwise. +- `--session [file]` — existing auth-session semantics; an autostarted instance launches with the saved cookies/storage state. +- `--no-heal` — fail fast without cheap-model recovery. +- `--ephemeral` — throwaway temp state dir instead of the per-host persistent one. +- `--framework playwright|codeceptjs` — output dialect for `used:` code (default from `ai.agents.historian.framework`). +- `--vision` — force screenshot pass for `ask`. + +### Execution paths + +- **pw**: the argument is a function expression in the exact shape `I.usePlaywrightTo` accepts — `({ page, browserContext, browser }) => ...` — checked only for being a parseable function, then interpolated directly into `I.usePlaywrightTo('pw', )` and executed through the Explorer/Action pipeline so state capture, ariaDiff, and experience recording come for free. Destructure whichever Playwright objects the call needs. Deterministic and near-instant on the happy path. +- **do**: one bounded Navigator invocation (max ~3 tool roundtrips) reusing the existing click/type/form tool ladders. Deterministic fast path first: if the instruction resolves to exactly one interactive ARIA node by role+name, execute with zero AI calls. +- **click / fill**: the existing multi-fallback ladders directly (text → ARIA → experience candidates); cheap-model disambiguation only when multiple candidates match. +- **ask**: Researcher answers from current compact ARIA / cached UI map; `--vision` routes through the vision model. Non-mutating. +- **research**: `researcher.research(state, { screenshot: true, data, deep, force })`. The envelope's `### Research` section carries the UI map inline — it is the deliverable: verified, live-tested locators that let the orchestrator drive `pw` precisely without reading raw ARIA. `--data` adds extracted data sections, `--deep` deep analysis, `--fresh` bypasses the cache; cached results keep the existing staleness banner. Non-mutating. +- **verify / assert**: `navigator.verifyState()` on the cheap model; verdict plus the assertion code that proved it. +- **go**: URL-shaped input navigates directly; intent-shaped input uses Navigator's stateful navigation with visited-state history and knowledge. Autostarts the instance like every other command. + +### Examples of `do` + +- `act do "click the login link"` → ARIA has one `link "Login"` → `I.click('Login')`, zero AI. +- `act do "search for 'wireless mouse'"` → cheap model plans fill + Enter → both lines reported in `used:`. +- `act do "dismiss the cookie banner"` → ambiguous → cheap model picks `button "Accept all"` from compact ARIA. +- `act do "open the newest invoice"` → list interpretation → cheap model over the UI map picks the first row link. + +## The Envelope + +The uniform response contract: every command prints the same block structure in the same order to stdout, success or failure. Two jobs: evidence (prove what happened without showing the page) and housekeeping (what is open, what to clean up, where to dig deeper). + +### Success (~200–400 tokens inline) + +``` +### Result +ok: true +command: pw ({ page }) => page.click('text=Login') +healed: false +used: I.click('Login') + +### Page +url: https://app.example.com/dashboard (changed: /login → /dashboard) +title: Dashboard +state: dashboard_h1_dashboard (new state, visit #1) + +### Changes +ariaDiff: + added: + - heading "Dashboard" + - button "New Project" + removed: + - textbox "Email" + +### Instance +instance: default (3 tabs) | other instances: auth-test (1 tab) +browser: running (started 12m ago) + +### Artifacts +aria: /output/act//aria.yml +html: /output/act//page.html +network: /output/act//network.jsonl +``` + +- `used:` — the exact code that worked (healed form when healed), rendered in the configured framework via Historian's converters. Sessions thereby double as a verified-locator oracle for test generation. +- `### Changes` becomes `### Answer` for `ask`, `### Verdict` (pass/fail + evidence line + assertion code) for `verify`, and `### Research` (the UI map, inline) for `research`. +- `### Instance` appears on every response — tab counts and other live instances — so the orchestrator sees leftover state and knows to close it when finished. +- Artifact paths are always absolute; full HTML, full ARIA, and the network log are always written, never inlined. + +### Failure (after healing exhausted, ~1–2k tokens inline) + +Same sections, plus: + +``` +### Failure +error: locator 'text=Login' not found (timeout 5s) + +### Healing attempts (3) +1. I.click('Login') → not visible +2. scroll + retry → covered by cookie banner +3. click 'Accept cookies', retry → navigation timeout +reasoning (compacted): ~5-line cheap-model summary of what it observed and why each attempt was chosen + +### Current page (compact ARIA, inline) +- button "Accept cookies" +- link "Login" +... +``` + +Inline: ariaDiff, error, compact ARIA. On disk: full HTML, full ARIA, network log. The orchestrator investigates from the inline compact ARIA and reads artifact files only when it needs depth. + +## Heal Loop + +On `pw`, `do`, `click`, or `fill` failure, the failing intent goes to Navigator's existing recovery ladder on the cheap model: alternative locators from ARIA/experience, scroll-into-view, overlay dismissal, retry. Capped at 3 attempts by default; `--no-heal` disables. Every attempt logs `{action, code, result, ariaDiff}`; reasoning is compacted to ~5 lines at the end. Heal success returns a success envelope with `healed: true` and writes the fix to experience so the next run replays it without AI. Heal exhaustion returns the failure envelope. + +Tool errors (daemon unreachable, AI provider missing, invalid `pw` expression) are reported as `ok: false` with a `### Failure` naming the failed layer — never disguised as page failures. + +## Instances & Sessions + +- Named browser daemons via `--instance`; state persists across CLI invocations through the existing `explorbot browser start` server and `.browser-endpoint` discovery. +- Any `act` command with no running daemon autostarts one for its instance; combined with `--session [file]`, the autostarted browser launches already authenticated. +- `act browser start|stop|status|list` manages instances explicitly; `stop --all` kills everything. + +## Config-Free Operation + +Resolution ladder, first hit wins per setting: + +1. Project config — `explorbot.config.js|ts` in cwd. +2. Global user config — `~/.config/explorbot/config.js|ts` (models, providers, keys). +3. Env vars — existing `EXPLORBOT_*` set, from process env, cwd `.env`, or `~/.config/explorbot/.env`. + +The ladder lives in core config loading (`buildEnvConfig` grows global-config and global-`.env` sources); the boat inherits it. + +With no project config, working dirs move to a persistent per-host state dir: + +``` +~/.local/state/explorbot// +├── experience/ +├── knowledge/ +└── output/act/... +``` + +Experience accumulates per target host across runs from any directory — zero-setup feel with memory. `--ephemeral` opts into a throwaway temp dir (CI, demos). + +## Discovery + +`explorbot act --help` (and `actbot --help`) is the sole teaching surface: it must compactly document the envelope shape, heal semantics, tiering (`pw` vs `click`/`fill` vs `do`), instance/session flags, and the artifact-file pattern. Clean stdout throughout (no banner). + +## Testing + +- Unit: envelope rendering (success, failure, ask/verify variants), `pw` expression validation, config ladder resolution. +- Integration: heal-loop prompts via the existing `@copilotkit/aimock` harness per `docs/contributing/ai-integration-tests.md`; fictional fixture data only. +- End-to-end smoke against a local fixture page: `pw` success, healed failure, exhausted failure, `do` fast path (zero AI), instance autostart with `--session`. + +## Decisions Log + +- Heal-first on all action failures (not fail-fast, not opt-in). `--no-heal` escape hatch. +- Failure delivery: ariaDiff + error + compact ARIA inline; full HTML/ARIA/network as files. +- Full command surface in v1 (pw, do, click, fill, ask, verify/assert, go, browser mgmt). +- Discovery via `--help` only; no SKILL.md or MCP in v1. +- Boat architecture (`boat/actor`, namespace `act`), not core commands. +- `--instance` for daemon switching; `--session` keeps existing auth-state meaning. +- Persistent per-host state dir by default in config-free mode; `--ephemeral` for temp. +- `used:` code in envelope via Historian converters; `verify` exposes assertion code. +- click/fill exposed as ladder-backed sugar; select/pressKey/hover/drag stay behind `pw`. +- `research` exposed with `--data`/`--deep`/`--fresh`; UI map inline as the deliverable (verified locators enable precise `pw` driving). +- Implementation runs on Opus. diff --git a/src/ai/task-agent.ts b/src/ai/task-agent.ts index 3cc91e1a..75eda15d 100644 --- a/src/ai/task-agent.ts +++ b/src/ai/task-agent.ts @@ -1,5 +1,6 @@ import type { ActionResult } from '../action-result.js'; import type { ExplorbotConfig } from '../config.ts'; +import { executionController } from '../execution-controller.ts'; import type { ExperienceTracker } from '../experience-tracker.js'; import type Explorer from '../explorer.ts'; import type { KnowledgeTracker } from '../knowledge-tracker.js'; @@ -12,7 +13,8 @@ import type { Provider } from './provider.js'; import { Quartermaster } from './quartermaster.js'; export function isInteractive(): boolean { - return process.env.INK_RUNNING === 'true'; + if (process.env.INK_RUNNING === 'true') return true; + return executionController.hasInputCallback(); } function createNullProxy(): T { diff --git a/src/execution-controller.ts b/src/execution-controller.ts index 682e3aad..5be8d224 100644 --- a/src/execution-controller.ts +++ b/src/execution-controller.ts @@ -27,6 +27,14 @@ export class ExecutionController extends EventEmitter { this.inputCallback = callback; } + hasInputCallback(): boolean { + return !!this.inputCallback; + } + + clearInputCallback(): void { + this.inputCallback = null; + } + startExecution(): void { this.interrupted = false; this.abortController = new AbortController(); diff --git a/src/remote.ts b/src/remote.ts new file mode 100644 index 00000000..a03cb6ff --- /dev/null +++ b/src/remote.ts @@ -0,0 +1,238 @@ +import type { Command } from 'commander'; +import stripAnsi from 'strip-ansi'; +import { type ActivityEntry, addActivityListener } from './activity.ts'; +import { executionController } from './execution-controller.ts'; +import { type LogDestination, type TaggedLogEntry, addDestination } from './utils/logger.ts'; + +const QUEUE_CAP = 1000; +const CONTENT_CAP = 8000; +const RECONNECT_BASE_MS = 500; +const RECONNECT_MAX_MS = 10_000; +const ASK_TIMEOUT_MS = 15 * 60_000; +const FLUSH_TIMEOUT_MS = 3000; + +/** + * Streams a run to a remote UI over one WebSocket, dialling out so a child + * process and a CI bot are the same case. + * + * It **is** a LogDestination — that is the whole integration on the logger's + * side — and it answers asks by installing itself as the execution + * controller's input callback. Nothing else in explorbot knows it exists. + */ +export class Remote implements LogDestination { + private url: string | null = null; + private socket: WebSocket | null = null; + private queue: Frame[] = []; + private reconnectDelay = RECONNECT_BASE_MS; + private reconnectTimer: ReturnType | null = null; + private asks = new Map void>(); + private askCounter = 0; + private lastActivity: string | null = null; + + registerOption(program: Command): void { + program.option('--ws ', 'Stream this run to a remote UI over WebSocket'); + program.hook('preAction', (_thisCommand, actionCommand) => { + const url = actionCommand.optsWithGlobals().ws || process.env.EXPLORBOT_WS_URL; + if (!url) return; + this.attach(String(url), this.commandPath(actionCommand)); + }); + } + + attach(url: string, command: string): void { + if (this.url) return; + this.url = url; + this.connect(); + + this.send('hello', { command, cwd: process.cwd(), pid: process.pid }); + addDestination(this); + executionController.setInputCallback((prompt) => this.ask(prompt)); + addActivityListener((activity) => this.reportActivity(activity)); + } + + isAttached(): boolean { + return !!this.url; + } + + send(type: string, data: Record = {}): void { + if (!this.url) return; + const frame: Frame = { type, ts: Date.now(), ...data }; + if (this.socket?.readyState === WebSocket.OPEN) { + this.push(frame); + return; + } + this.queue.push(frame); + if (this.queue.length > QUEUE_CAP) this.queue.splice(0, this.queue.length - QUEUE_CAP); + } + + ask(prompt: string): Promise { + if (!this.url) return Promise.resolve(null); + this.askCounter++; + const askId = `ask-${this.askCounter}`; + this.send('ask', { askId, prompt }); + + return new Promise((resolve) => { + const timer = setTimeout(() => { + this.asks.delete(askId); + resolve(null); + }, ASK_TIMEOUT_MS); + this.asks.set(askId, (value) => { + clearTimeout(timer); + resolve(value); + }); + }); + } + + async close(exitCode: number): Promise { + if (!this.url) return; + this.send('result', { ok: exitCode === 0, exitCode }); + await this.flush(); + + this.url = null; + if (this.reconnectTimer) clearTimeout(this.reconnectTimer); + // Whoever asks next has nobody to ask — leaving the callback installed would + // route them into a closed socket and park them until the ask times out. + executionController.clearInputCallback(); + for (const resolve of this.asks.values()) resolve(null); + this.asks.clear(); + this.queue = []; + this.socket?.close(); + this.socket = null; + } + + isEnabled(): boolean { + return this.isAttached(); + } + + /** + * The entry as the logger already built it, so the `LogType` reaches the UI + * as the frame's level and each kind is styled there instead of being scraped + * back out of flattened text. + */ + write(entry: TaggedLogEntry): void { + if (entry.type === 'html') return; + let content = stripAnsi(entry.content ?? ''); + if (content.length > CONTENT_CAP) content = `${content.slice(0, CONTENT_CAP)}… (${content.length} chars)`; + this.send('log', { + level: entry.type, + content, + namespace: entry.namespace, + error: this.errorOf(entry.originalArgs), + }); + } + + private connect(): void { + if (!this.url) return; + + let opening: WebSocket; + try { + opening = new WebSocket(this.url); + } catch { + this.reconnect(); + return; + } + this.socket = opening; + + opening.addEventListener('open', () => { + this.reconnectDelay = RECONNECT_BASE_MS; + this.drain(); + }); + + opening.addEventListener('message', (event: MessageEvent) => this.receive(String(event.data))); + + opening.addEventListener('close', () => { + if (this.socket !== opening) return; + this.socket = null; + this.reconnect(); + }); + + // A failed connection always closes too, which is where the retry is scheduled. + opening.addEventListener('error', () => {}); + } + + private reconnect(): void { + if (!this.url || this.reconnectTimer) return; + const delay = this.reconnectDelay; + this.reconnectDelay = Math.min(this.reconnectDelay * 2, RECONNECT_MAX_MS); + this.reconnectTimer = setTimeout(() => { + this.reconnectTimer = null; + this.connect(); + }, delay); + this.reconnectTimer.unref?.(); + } + + private async flush(): Promise { + const deadline = Date.now() + FLUSH_TIMEOUT_MS; + while (Date.now() < deadline) { + if (this.socket?.readyState === WebSocket.OPEN) { + this.drain(); + if (!this.queue.length && !this.socket.bufferedAmount) return; + } + await new Promise((resolve) => setTimeout(resolve, 25)); + } + } + + private drain(): void { + if (this.socket?.readyState !== WebSocket.OPEN) return; + const pending = this.queue; + this.queue = []; + for (const frame of pending) this.push(frame); + } + + private push(frame: Frame): void { + try { + this.socket?.send(JSON.stringify(frame)); + } catch { + this.queue.push(frame); + } + } + + private receive(raw: string): void { + let frame: Frame; + try { + frame = JSON.parse(raw); + } catch { + return; + } + if (frame.type === 'interrupt') { + executionController.interrupt(); + return; + } + const resolve = this.asks.get(String(frame.askId)); + if (!resolve) return; + this.asks.delete(String(frame.askId)); + resolve((frame.value as string | null) ?? null); + } + + /** A new listener is primed with the current activity, which at attach time is + * nothing — so skip repeats, the priming null included. */ + private reportActivity(activity: ActivityEntry | null): void { + const message = activity?.message ?? null; + if (message === this.lastActivity) return; + this.lastActivity = message; + this.send('activity', { message, kind: activity?.type }); + } + + private errorOf(args: any[] | undefined): string | undefined { + const failure = args?.[1] ?? args?.[0]?.error; + if (!failure) return undefined; + if (typeof failure === 'string') return failure; + if (typeof failure.message === 'string') return failure.message; + return undefined; + } + + private commandPath(command: Command): string { + const parts: string[] = []; + let node: Command | null = command; + while (node) { + parts.unshift(node.name()); + node = node.parent; + } + return parts.slice(1).join(' ') || parts.join(' '); + } +} + +export const remote = new Remote(); + +/** Whatever the run wants to say. Not a schema — the UI renders what it knows + * and ignores the rest, so either side can start sending more at any time. */ +type Frame = { type: string; ts: number; [key: string]: unknown }; diff --git a/src/utils/logger.ts b/src/utils/logger.ts index e7e4173e..4019e2b3 100644 --- a/src/utils/logger.ts +++ b/src/utils/logger.ts @@ -24,7 +24,7 @@ export interface TaggedLogEntry { type LogEntry = TaggedLogEntry; -interface LogDestination { +export interface LogDestination { isEnabled(): boolean; write(entry: TaggedLogEntry): void; } @@ -332,6 +332,7 @@ class Logger { private span = new SpanDestination(); public react = new ReactDestination(); public captain = new CaptainDestination(); + private extra: LogDestination[] = []; private truncateTags: string[] = ['page_html']; private constructor() {} @@ -364,6 +365,11 @@ class Logger { return this.debugDestination.isEnabled(); } + addDestination(destination: LogDestination): void { + if (this.extra.includes(destination)) return; + this.extra.push(destination); + } + registerLogPane(addLog: (entry: LogEntry) => void): void { this.react.registerLogPane(addLog); } @@ -456,6 +462,9 @@ class Logger { if (this.file.isEnabled()) this.file.write(entry); if (this.span.isEnabled()) this.span.write(entry); if (this.captain.isEnabled()) this.captain.write(entry); + for (const destination of this.extra) { + if (destination.isEnabled()) destination.write(entry); + } if (process.env.INK_RUNNING) { this.react.write(entry); } else if (this.console.isEnabled()) { @@ -541,6 +550,7 @@ export const isVerboseMode = () => logger.isVerboseMode(); export const setDebugMode = (enabled: boolean) => logger.setDebugMode(enabled); export const isDebugMode = () => logger.isDebugMode(); +export const addDestination = (destination: LogDestination) => logger.addDestination(destination); export const registerLogPane = (addLog: (entry: LogEntry) => void) => logger.registerLogPane(addLog); export const unregisterLogPane = (addLog: (entry: LogEntry) => void) => logger.unregisterLogPane(addLog); export const addTruncateTag = (tagName: string) => logger.addTruncateTag(tagName); diff --git a/tests/unit/remote.test.ts b/tests/unit/remote.test.ts new file mode 100644 index 00000000..f27673a9 --- /dev/null +++ b/tests/unit/remote.test.ts @@ -0,0 +1,133 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import type { ServerWebSocket } from 'bun'; +import { isInteractive } from '../../src/ai/task-agent.ts'; +import { executionController } from '../../src/execution-controller.ts'; +import { remote } from '../../src/remote.ts'; +import { tag } from '../../src/utils/logger.ts'; + +let server: ReturnType | null = null; +let received: any[] = []; +let clients: ServerWebSocket[] = []; + +function url(): string { + return `ws://127.0.0.1:${server!.port}`; +} + +async function waitFor(read: () => T | undefined, timeout = 3000): Promise { + const deadline = Date.now() + timeout; + while (Date.now() < deadline) { + const value = read(); + if (value !== undefined) return value; + await new Promise((resolve) => setTimeout(resolve, 10)); + } + throw new Error('timed out waiting for a frame'); +} + +const frameOf = (type: string) => () => received.find((f) => f.type === type); + +beforeEach(() => { + received = []; + clients = []; + server = Bun.serve({ + port: 0, + hostname: '127.0.0.1', + fetch(req, srv) { + if (srv.upgrade(req)) return undefined as unknown as Response; + return new Response('expected a websocket', { status: 400 }); + }, + websocket: { + open(ws) { + clients.push(ws); + }, + message(_ws, message) { + received.push(JSON.parse(String(message))); + }, + }, + }); +}); + +afterEach(async () => { + await remote.close(0); + executionController.reset(); + server?.stop(true); + server = null; +}); + +describe('remote', () => { + test('announces the run, and frames sent before the socket opens are flushed in order', async () => { + remote.attach(url(), 'explore'); + remote.send('activity', { message: 'first' }); + remote.send('activity', { message: 'second' }); + + await waitFor(() => (received.filter((f) => f.type === 'activity').length >= 2 ? true : undefined)); + + expect(received[0]).toMatchObject({ type: 'hello', command: 'explore', pid: process.pid }); + expect(received[0].ts).toBeNumber(); + expect(received.filter((f) => f.type === 'activity').map((f) => f.message)).toEqual(['first', 'second']); + }); + + test('every log type reaches the UI as its own level, with the step error alongside', async () => { + remote.attach(url(), 'test'); + await waitFor(frameOf('hello')); + + tag('info').log('exploring /login'); + tag('multiline').log('# Heading\n\nbody'); + tag('step').log({ toCode: () => 'I.click("Sign in")' }, new Error('element not found')); + tag('html').log('dropped'); + + await waitFor(() => (received.filter((f) => f.type === 'log').length >= 3 ? true : undefined)); + const logs = received.filter((f) => f.type === 'log'); + + expect(logs.map((f) => f.level)).toEqual(['info', 'multiline', 'step']); + expect(logs[0].content).toBe('exploring /login'); + expect(logs[2]).toMatchObject({ content: 'I.click("Sign in")', error: 'element not found' }); + }); + + test('an ask goes through the execution controller and its answer comes back', async () => { + remote.attach(url(), 'explore'); + const answer = executionController.requestInput('Which credentials should I use?'); + + const ask = await waitFor(frameOf('ask')); + expect(ask.prompt).toBe('Which credentials should I use?'); + clients[0].send(JSON.stringify({ type: 'answer', askId: ask.askId, value: 'admin/admin' })); + + expect(await answer).toBe('admin/admin'); + }); + + test('a skipped ask resolves to null', async () => { + remote.attach(url(), 'explore'); + const answer = executionController.requestInput('Anything to add?'); + const ask = await waitFor(frameOf('ask')); + clients[0].send(JSON.stringify({ type: 'answer', askId: ask.askId, value: null })); + expect(await answer).toBeNull(); + }); + + test('an interrupt frame interrupts the run, and an unknown frame is ignored', async () => { + remote.attach(url(), 'explore'); + await waitFor(frameOf('hello')); + + clients[0].send(JSON.stringify({ type: 'from-the-future', payload: 1 })); + clients[0].send(JSON.stringify({ type: 'interrupt' })); + + await waitFor(() => (executionController.isInterrupted() ? true : undefined)); + expect(executionController.isInterrupted()).toBe(true); + }); + + test('closing reports the exit code and resolves outstanding asks as skipped', async () => { + remote.attach(url(), 'explore'); + const answer = executionController.requestInput('never answered'); + await waitFor(frameOf('ask')); + + await remote.close(1); + + expect(await answer).toBeNull(); + expect(remote.isAttached()).toBe(false); + expect(await waitFor(frameOf('result'))).toMatchObject({ ok: false, exitCode: 1 }); + }); + + test('a run that can be asked is interactive, whoever installed the callback', () => { + expect(isInteractive()).toBe(false); + remote.attach(url(), 'explore'); + expect(isInteractive()).toBe(true); + }); +});