explorbot 0.3.5 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/explorbot-cli.ts +23 -16
- package/boat/api-tester/src/ai/chief.ts +7 -1
- package/boat/api-tester/src/ai/curler.ts +7 -1
- package/boat/api-tester/src/apibot.ts +10 -4
- package/boat/api-tester/src/cli.ts +12 -2
- package/boat/api-tester/src/config.ts +28 -8
- package/boat/doc-collector/bin/doc-collector-cli.ts +3 -2
- package/boat/doc-collector/src/cli.ts +6 -1
- package/boat/doc-collector/src/docbot.ts +4 -2
- package/boat/prima/bin/prima-cli.ts +2 -0
- package/boat/prima/src/cli.ts +23 -8
- package/boat/prima/src/envelope.ts +35 -9
- package/boat/prima/src/prima.ts +23 -10
- package/dist/bin/explorbot-cli.js +24 -16
- package/dist/boat/api-tester/bin/apibot-cli.js +3 -2
- package/dist/boat/api-tester/src/ai/chief.js +6 -1
- package/dist/boat/api-tester/src/ai/curler.js +6 -1
- package/dist/boat/api-tester/src/apibot.js +7 -3
- package/dist/boat/api-tester/src/cli.js +12 -2
- package/dist/boat/api-tester/src/config.js +31 -8
- package/dist/boat/doc-collector/bin/doc-collector-cli.js +3 -2
- package/dist/boat/doc-collector/src/cli.js +6 -1
- package/dist/boat/doc-collector/src/docbot.js +4 -2
- package/dist/boat/prima/bin/prima-cli.js +2 -0
- package/dist/boat/prima/src/cli.js +22 -8
- package/dist/boat/prima/src/envelope.js +24 -6
- package/dist/boat/prima/src/prima.js +23 -11
- package/dist/package.json +2 -2
- package/dist/rules/planner/styles/normal.md +1 -1
- package/dist/src/action-result.d.ts +9 -1
- package/dist/src/action-result.js +57 -18
- package/dist/src/action.d.ts +1 -1
- package/dist/src/action.js +87 -12
- package/dist/src/ai/captain.js +1 -1
- package/dist/src/ai/driller.d.ts +0 -1
- package/dist/src/ai/driller.js +8 -20
- package/dist/src/ai/fisherman-tools.d.ts +9 -0
- package/dist/src/ai/fisherman-tools.js +52 -6
- package/dist/src/ai/fisherman.d.ts +4 -2
- package/dist/src/ai/fisherman.js +48 -27
- package/dist/src/ai/historian/codeceptjs.js +1 -1
- package/dist/src/ai/historian/playwright.js +1 -1
- package/dist/src/ai/navigator.js +1 -1
- package/dist/src/ai/pilot.d.ts +1 -0
- package/dist/src/ai/pilot.js +13 -1
- package/dist/src/ai/planner.js +9 -7
- package/dist/src/ai/provider.js +20 -3
- package/dist/src/ai/researcher/deep-analysis.js +1 -3
- package/dist/src/ai/researcher.js +5 -3
- package/dist/src/ai/rules.js +3 -3
- package/dist/src/ai/tester.d.ts +3 -0
- package/dist/src/ai/tester.js +40 -3
- package/dist/src/ai/tools.d.ts +1 -0
- package/dist/src/ai/tools.js +13 -6
- package/dist/src/api/request-result.d.ts +2 -0
- package/dist/src/api/request-result.js +8 -2
- package/dist/src/api/request-store.d.ts +3 -2
- package/dist/src/api/request-store.js +66 -14
- package/dist/src/api/spec-reader.js +1 -1
- package/dist/src/commands/config-command.js +1 -1
- package/dist/src/commands/drill-command.js +1 -1
- package/dist/src/commands/explore-command.d.ts +6 -0
- package/dist/src/commands/explore-command.js +39 -3
- package/dist/src/commands/freesail-command.js +10 -1
- package/dist/src/commands/options/base-option.d.ts +8 -0
- package/dist/src/commands/options/base-option.js +12 -0
- package/dist/src/commands/options/index.d.ts +5 -0
- package/dist/src/commands/options/index.js +5 -0
- package/dist/src/commands/options/knowledge-option.d.ts +7 -0
- package/dist/src/commands/options/knowledge-option.js +12 -0
- package/dist/src/commands/options/ws-option.d.ts +7 -0
- package/dist/src/commands/options/ws-option.js +21 -0
- package/dist/src/commands/plans-command.js +6 -6
- package/dist/src/config.d.ts +1 -0
- package/dist/src/config.js +12 -0
- package/dist/src/experience-tracker.js +5 -0
- package/dist/src/explorbot.d.ts +0 -1
- package/dist/src/explorbot.js +24 -37
- package/dist/src/knowledge-tracker.d.ts +20 -7
- package/dist/src/knowledge-tracker.js +69 -31
- package/dist/src/remote.d.ts +0 -3
- package/dist/src/remote.js +0 -18
- package/dist/src/state-manager.d.ts +5 -1
- package/dist/src/state-manager.js +10 -7
- package/dist/src/test-plan.d.ts +3 -0
- package/dist/src/test-plan.js +27 -0
- package/dist/src/utils/aria.d.ts +1 -1
- package/dist/src/utils/aria.js +6 -42
- package/dist/src/utils/html-diff.d.ts +4 -0
- package/dist/src/utils/html-diff.js +62 -7
- package/dist/src/utils/html.d.ts +5 -15
- package/dist/src/utils/html.js +14 -85
- package/dist/src/utils/overlay.d.ts +56 -11
- package/dist/src/utils/overlay.js +191 -21
- package/dist/src/utils/request-map.d.ts +7 -0
- package/dist/src/utils/request-map.js +16 -0
- package/dist/src/utils/url-matcher.js +4 -2
- package/docs/api-testing/basics.md +15 -0
- package/docs/api-testing/planning.md +10 -1
- package/docs/reference/commands.md +32 -5
- package/docs/reference/websocket.md +1 -0
- package/docs/superpowers/plans/2026-08-29-fisherman-reliability.md +953 -0
- package/docs/superpowers/plans/2026-08-29-region-states.md +1292 -0
- package/docs/superpowers/plans/2026-08-30-fisherman-live-session-auth.md +457 -0
- package/docs/superpowers/specs/2026-08-29-fisherman-reliability-design.md +45 -0
- package/docs/superpowers/specs/2026-08-29-region-states-design.md +262 -0
- package/docs/superpowers/specs/2026-08-29-region-states-fixes-design.md +269 -0
- package/docs/superpowers/specs/2026-08-30-fisherman-live-session-auth-design.md +37 -0
- package/docs/workflow/agentic-usage.md +12 -2
- package/docs/workflow/ci.md +1 -0
- package/docs/workflow/knowledge.md +46 -2
- package/package.json +2 -2
- package/rules/planner/styles/normal.md +1 -1
- package/src/action-result.ts +61 -22
- package/src/action.ts +87 -14
- package/src/ai/captain.ts +1 -1
- package/src/ai/driller.ts +7 -39
- package/src/ai/fisherman-tools.ts +56 -7
- package/src/ai/fisherman.ts +48 -28
- package/src/ai/historian/codeceptjs.ts +1 -1
- package/src/ai/historian/playwright.ts +1 -1
- package/src/ai/navigator.ts +1 -1
- package/src/ai/pilot.ts +9 -1
- package/src/ai/planner.ts +9 -8
- package/src/ai/provider.ts +21 -3
- package/src/ai/researcher/deep-analysis.ts +1 -2
- package/src/ai/researcher.ts +4 -3
- package/src/ai/rules.ts +3 -3
- package/src/ai/tester.ts +40 -3
- package/src/ai/tools.ts +17 -9
- package/src/api/request-result.ts +10 -2
- package/src/api/request-store.ts +60 -13
- package/src/api/spec-reader.ts +1 -1
- package/src/commands/config-command.ts +1 -1
- package/src/commands/drill-command.ts +1 -1
- package/src/commands/explore-command.ts +37 -3
- package/src/commands/freesail-command.ts +7 -1
- package/src/commands/options/base-option.ts +18 -0
- package/src/commands/options/index.ts +7 -0
- package/src/commands/options/knowledge-option.ts +14 -0
- package/src/commands/options/ws-option.ts +24 -0
- package/src/commands/plans-command.ts +6 -6
- package/src/config.ts +12 -0
- package/src/experience-tracker.ts +5 -1
- package/src/explorbot.ts +21 -37
- package/src/knowledge-tracker.ts +94 -36
- package/src/remote.ts +0 -20
- package/src/state-manager.ts +13 -7
- package/src/test-plan.ts +29 -0
- package/src/utils/aria.ts +7 -44
- package/src/utils/html-diff.ts +62 -7
- package/src/utils/html.ts +14 -91
- package/src/utils/overlay.ts +226 -23
- package/src/utils/request-map.ts +19 -0
- package/src/utils/url-matcher.ts +3 -2
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
# Region-of-Interest States — Diff-Detected Modals, Drawers and Soft Navigation
|
|
2
|
+
|
|
3
|
+
**Date:** 2026-08-29
|
|
4
|
+
**Status:** Planned
|
|
5
|
+
**Plan:** `docs/superpowers/plans/2026-08-29-region-states.md`
|
|
6
|
+
|
|
7
|
+
## Problem
|
|
8
|
+
|
|
9
|
+
A state is `url + h1 + h2` (`ActionResult.getStateHash`, `src/action-result.ts:478`). The only
|
|
10
|
+
other state signal is `StateManager.hasDialogAppeared` (`src/state-manager.ts:209`), which fires
|
|
11
|
+
when the ARIA snapshot suddenly contains a dialog/modal node (`Overlay.fromAria` →
|
|
12
|
+
`detectFocusArea`). Everything that opens without an ARIA dialog role and without a URL change is
|
|
13
|
+
invisible as a state:
|
|
14
|
+
|
|
15
|
+
- drawers and side panels rendered as plain positioned `<div>`s;
|
|
16
|
+
- soft navigation — a SPA swapping a large content region in place (wizard step, inline editor,
|
|
17
|
+
detail subview) with no full re-render and no URL change.
|
|
18
|
+
|
|
19
|
+
The diff pipeline actually *sees* these. `htmlDiff` (`src/utils/html-diff.ts`) returns
|
|
20
|
+
`HtmlDiffPart[]` — each with a stable `container` selector and the appeared `subtree` — but
|
|
21
|
+
`collapseHtmlParts` (`src/action-result.ts:591`) treats any diff over 8K chars as a full page
|
|
22
|
+
re-render and collapses it to `...collapsed (12000 chars)...`. The one signal that says "a modal
|
|
23
|
+
just opened" is thrown away as noise.
|
|
24
|
+
|
|
25
|
+
On top of that, overlay detection today is **scattered across three approaches in four files**:
|
|
26
|
+
|
|
27
|
+
1. ARIA role detection — `detectFocusArea` in `aria.ts`, via `Overlay.fromAria`;
|
|
28
|
+
2. a selector-heuristic browser extractor — `extractVisibleOverlayHtml` in `html.ts`, driven by
|
|
29
|
+
`OVERLAY_SELECTORS` class-name patterns (`[class*="modal"]`, `[class*="drawer"]`…) and a
|
|
30
|
+
z-index geometry fallback, invoked from `Action.captureOverlayHtml` and independently from
|
|
31
|
+
`Driller.getVisibleOverlayHtml`;
|
|
32
|
+
3. the `overlayHtml` → `Overlay.resolve` priority chain in `ActionResult`.
|
|
33
|
+
|
|
34
|
+
The class-name selector heuristic is exactly the kind of memorized surface form the project's
|
|
35
|
+
Regex-vs-AI doctrine rejects: it works only on sites that happen to name their CSS that way.
|
|
36
|
+
|
|
37
|
+
Consequences:
|
|
38
|
+
|
|
39
|
+
- Tester gets either the strict `<focus_scope>` block (ARIA dialogs only) or nothing. For a
|
|
40
|
+
drawer without a role it keeps targeting elements behind the drawer.
|
|
41
|
+
- Pilot's `<state>` says `modal: none` while half the screen is a drawer.
|
|
42
|
+
- Modal open/close cycling is invisible to `isInDeadLoop` — every hash in the window is the base
|
|
43
|
+
page.
|
|
44
|
+
- Experience recorded while a modal is open lands in the base page's experience file, with no
|
|
45
|
+
record that it only applies inside that modal.
|
|
46
|
+
|
|
47
|
+
## Design
|
|
48
|
+
|
|
49
|
+
### Unification: `overlay.ts` is the single detection module
|
|
50
|
+
|
|
51
|
+
All area-of-interest semantics live in `src/utils/overlay.ts`. After this change there are
|
|
52
|
+
exactly **two** detection signals, both general:
|
|
53
|
+
|
|
54
|
+
1. **ARIA** — `Overlay.fromAria` (role-based dialogs, free at capture time, needs no previous
|
|
55
|
+
state). `aria.ts` keeps only the ARIA-tree *primitives* (`detectFocusArea`,
|
|
56
|
+
`focusAreaControls`); their sole overlay-semantics consumer is `overlay.ts`.
|
|
57
|
+
2. **Diff + geometry** — the new pipeline below, for everything the ARIA tree does not label.
|
|
58
|
+
|
|
59
|
+
The selector-heuristic path is **deleted entirely** (see "Removed code"). No third approach, no
|
|
60
|
+
class-name patterns, no priority chain.
|
|
61
|
+
|
|
62
|
+
`overlay.ts` exposes exactly two classes: `Overlay`, the immutable value describing what is open,
|
|
63
|
+
and `OverlayPage`, which wraps the live page and owns detection. The single public entry point is
|
|
64
|
+
`new OverlayPage(page).detectRegion(diffParts)`; every lower-level step (subroot picking, the
|
|
65
|
+
browser probe, coverage classification) is a private member. The pipeline runs after every action
|
|
66
|
+
inside `Action.capturePageState`, before the (sync) `stateManager.updateState` call — `Action`
|
|
67
|
+
only hands the page over (it is the only browser mover):
|
|
68
|
+
|
|
69
|
+
```
|
|
70
|
+
capture html/aria
|
|
71
|
+
└─ same URL, not iframe, html changed, no ARIA overlay already detected
|
|
72
|
+
└─ diff vs previous state (parse5, memoized — shared with toToolResult)
|
|
73
|
+
└─ OverlayPage.detectRegion(parts)
|
|
74
|
+
├─ appeared subtree ≥ 5K chars (private)
|
|
75
|
+
├─ coverage probe via page.evaluate (private)
|
|
76
|
+
└─ coverage classification (private)
|
|
77
|
+
├─ overlays the page → Overlay 'modal' | 'drawer'
|
|
78
|
+
└─ inline → Overlay 'region'
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Detection is 100% structural — size threshold, diff paths, geometry. No AI in the path. AI enters
|
|
82
|
+
only downstream: `researchOverlay` describes the region, Tester/Pilot decide what to do in it.
|
|
83
|
+
|
|
84
|
+
### 1. Appeared-subroot detection (`OverlayPage`, over `html-diff.ts` parts)
|
|
85
|
+
|
|
86
|
+
The first private step of `detectRegion` picks the largest part that contains an appeared
|
|
87
|
+
element (`ELEMENT:` line in `part.added`) and whose minified `subtree` is ≥ `SUBROOT_MIN_HTML`
|
|
88
|
+
(10 000 chars, unexported const — no config knob). `html-diff.ts` stays a generic diff engine; it
|
|
89
|
+
newly exports `pathToXPath` so overlay.ts can convert appeared-element paths.
|
|
90
|
+
|
|
91
|
+
The part's `container` is by design an ancestor that exists in **both** snapshots
|
|
92
|
+
(`findStableContainer`) — it is never the appeared element, and for portal roots
|
|
93
|
+
(`#modal-root`-style, zero-height with fixed children) its geometry lies. So the result carries
|
|
94
|
+
both:
|
|
95
|
+
|
|
96
|
+
- `container` — the stable scoping selector, handed to Tester and stored as the experience `root`;
|
|
97
|
+
- `elementXPath` — the appeared element itself (from the `ELEMENT:html[1]/body[1]/div[3]` path
|
|
98
|
+
via `pathToXPath`) — this is what the coverage probe measures.
|
|
99
|
+
|
|
100
|
+
When `container` degrades to `body` (top-level appended node — the common portal case), the
|
|
101
|
+
`elementXPath` doubles as the root selector.
|
|
102
|
+
|
|
103
|
+
### 2. Openness verification (`OverlayPage`)
|
|
104
|
+
|
|
105
|
+
One hit-test decides whether the appeared region is **actually open**: take the center of the
|
|
106
|
+
region's visible (viewport-intersected) rect, ask `document.elementFromPoint` what lives there,
|
|
107
|
+
and check the hit belongs to the region (`.modal` is the region; the input at its center belongs
|
|
108
|
+
to it → it is on top). A region whose own center resolves to a foreign element is hidden or
|
|
109
|
+
covered — verified not open, and **discarded** rather than classified.
|
|
110
|
+
|
|
111
|
+
For an open region, two values collected in the same probe decide the kind:
|
|
112
|
+
|
|
113
|
+
- computed position — floating (`fixed`/`absolute`/positive z-index) → overlaying; in-flow →
|
|
114
|
+
inline `region` (so soft navigation never triggers the strict focus scope);
|
|
115
|
+
- visible-rect coverage of the viewport — overlaying with coverage ≥ 0.8 → `modal`, else
|
|
116
|
+
`drawer`.
|
|
117
|
+
|
|
118
|
+
The probe is a module-private plain function shipped as a source string into `page.evaluate`
|
|
119
|
+
(it must stay self-contained so `toString()` reconstruction works in the browser); tests drive
|
|
120
|
+
`detectRegion` with fake pages returning canned probe results.
|
|
121
|
+
|
|
122
|
+
When the probe cannot run at all (no page, evaluate throws, element already gone) the region
|
|
123
|
+
degrades to inline `region` with a debug log — never to an overlay: a false "overlaying" verdict
|
|
124
|
+
would make Tester refuse legitimate navigation. An off-screen in-flow region (below the fold)
|
|
125
|
+
also stays inline instead of being discarded.
|
|
126
|
+
|
|
127
|
+
### 3. Overlay carries the region (`overlay.ts`)
|
|
128
|
+
|
|
129
|
+
`Overlay` is extended rather than a parallel concept added:
|
|
130
|
+
|
|
131
|
+
- `type`: `'dialog' | 'modal' | 'drawer' | 'region' | null` — `region` means inline subview;
|
|
132
|
+
- `name`: heading-derived (h1–h4 join over the region HTML, private `nameFromHtml`);
|
|
133
|
+
- `root`: the scoping selector (container CSS, or element XPath when the container degraded to
|
|
134
|
+
`body`);
|
|
135
|
+
- `get detected()` — **keeps meaning "verified overlaying"** (`type` is dialog/modal/drawer).
|
|
136
|
+
Every existing consumer of `detected` (Tester `<focus_scope>`, Pilot `modal:` line,
|
|
137
|
+
`hasDialogAppeared`) keeps its semantics.
|
|
138
|
+
- `get present()` — any region, inline included. New consumers that want "an area of interest
|
|
139
|
+
exists" use this.
|
|
140
|
+
- `html`: the region's minified subtree, carried on the overlay itself — `toToolResult` renders
|
|
141
|
+
it as the single diff part instead of a collapsed dump.
|
|
142
|
+
- `describe()` — the one-line human/model-facing summary
|
|
143
|
+
(`drawer "Edit User" opened, scope: aside.panel`), used for `pageDiff.areaOfInterest`.
|
|
144
|
+
- `OverlayPage.detectRegion` builds the Overlay: verdict `overlays: true` with coverage ≥ 0.8 →
|
|
145
|
+
`modal`; overlaying with partial coverage → `drawer`; otherwise `region`.
|
|
146
|
+
- `Overlay.resolve` simplifies to two sources: stored `overlay` data, else `fromAria`.
|
|
147
|
+
|
|
148
|
+
### 4. State identity (`src/action-result.ts`)
|
|
149
|
+
|
|
150
|
+
- `getStateHash()` gains a `region_<name>` part when `overlay.present && overlay.name`.
|
|
151
|
+
**Named regions only**: names come from headings (stable across runs); selectors with dynamic
|
|
152
|
+
classes never enter a hash. An unnamed region does not fork the state — which is why
|
|
153
|
+
`hasDialogAppeared` survives (generalized to `hasRegionAppeared` over `present`) as the
|
|
154
|
+
transition trigger for unnamed overlays.
|
|
155
|
+
- `baseHash` getter — the hash without the region part. The research cache and Tester's
|
|
156
|
+
`pageStateHash` key off `baseHash`, otherwise a modal open at capture time forks
|
|
157
|
+
`getCachedResearch` and poisons `researchOverlay`'s append-to-page-research flow.
|
|
158
|
+
- `diff(previous)` is memoized on `previous.id` so capture-time detection and `toToolResult`
|
|
159
|
+
share one parse5 pass.
|
|
160
|
+
- Side effect, intended: with the region in the hash, modal **close** also changes the hash — a
|
|
161
|
+
test cycling open/close now produces alternating hashes that `isInDeadLoop` can see.
|
|
162
|
+
|
|
163
|
+
### 5. StateManager records region states (`src/state-manager.ts`)
|
|
164
|
+
|
|
165
|
+
Named regions change the hash, so `updateState` records the transition through the existing
|
|
166
|
+
hash-changed path — region states land in `stateHistory`, `getRecentTransitions`, visit counts,
|
|
167
|
+
and the `tag('data').log('state', …)` remote frame (which gains a `region` field). Unnamed
|
|
168
|
+
regions go through `hasRegionAppeared` (the renamed, `present`-based `hasDialogAppeared`).
|
|
169
|
+
|
|
170
|
+
### 6. Experience envelope: `root:` (`src/experience-tracker.ts`)
|
|
171
|
+
|
|
172
|
+
Experience files for region states get a new frontmatter key:
|
|
173
|
+
|
|
174
|
+
```markdown
|
|
175
|
+
---
|
|
176
|
+
url: /users
|
|
177
|
+
title: Users — Admin
|
|
178
|
+
root: 'aside.detail-panel'
|
|
179
|
+
---
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
Envelope checklist (per CLAUDE.md "Data Envelope Formats"):
|
|
183
|
+
|
|
184
|
+
1. **Read deterministically by code** — retrieval gating below; never interpreted by the model.
|
|
185
|
+
2. **Scoped to URL/state** — per `<stateHash>.md` file; region states have their own hash, so
|
|
186
|
+
their file is created while the region is open and `root` comes from `state.overlay.root`.
|
|
187
|
+
3. **Optional with a default** — absent means "whole page"; every existing file on disk behaves
|
|
188
|
+
exactly as today.
|
|
189
|
+
4. **Single writer** — `ExperienceTracker.ensureExperienceFile` only.
|
|
190
|
+
|
|
191
|
+
**Retrieval rule** (in `ActionResult.isRelevantExperienceRecord`, where matching already lives):
|
|
192
|
+
a record carrying `root` is loaded only when the current state has a region open —
|
|
193
|
+
`overlay.present` — and, when the current region's own `root` is known, the selectors match
|
|
194
|
+
exactly. Found by this state + root selector exists → the experience file is loaded; no region
|
|
195
|
+
open → the file is skipped, so drawer recipes stop polluting base-page context. Matching stays
|
|
196
|
+
structural (string equality), never semantic.
|
|
197
|
+
|
|
198
|
+
### 7. Surfacing to the agents
|
|
199
|
+
|
|
200
|
+
- **Tool results** (`toToolResult`): when the region appeared in this transition, `pageDiff`
|
|
201
|
+
gains `areaOfInterest` — e.g. `drawer "Edit User" opened, scope: aside.detail-panel` — and
|
|
202
|
+
`htmlParts` is replaced by a single part containing the region's cleaned snapshot within the
|
|
203
|
+
existing per-part budget, instead of the `...collapsed (12000 chars)...` marker. This is the
|
|
204
|
+
payoff: the diff signal that was discarded becomes the headline of the acting tool's result.
|
|
205
|
+
- **Tester** (`reinjectContextIfNeeded`): verified overlays keep the strict `<focus_scope>`
|
|
206
|
+
block, now with the concrete root selector. Inline regions get a new, softer
|
|
207
|
+
`<area_of_interest>` block — injected once per state change (via the previously write-only
|
|
208
|
+
`previousStateHash`) — that names the region and its root but leaves page navigation
|
|
209
|
+
actionable. The strict "elements outside are not actionable" wording stays gated on the probe
|
|
210
|
+
verdict. The `researchOverlay` trigger widens from `detected` to `present`.
|
|
211
|
+
- **Pilot** (`buildStateContext`): the `modal:` line stays for verified overlays (its diagnostic
|
|
212
|
+
prompt patterns keep working) and gains the root; inline regions get a new
|
|
213
|
+
`region: <name> (inline, root: <selector>)` line plus one general system-prompt bullet.
|
|
214
|
+
- **Researcher** (`deep-analysis.ts` `researchOverlay`): the guard widens from
|
|
215
|
+
`type === 'dialog' | 'modal'` to any named present region, so drawers and subviews get the same
|
|
216
|
+
incremental Extended Research treatment, still appended under the base page's research (keyed
|
|
217
|
+
by `baseHash`).
|
|
218
|
+
- **Driller** (`detectNestedOverlayContext`): stops re-querying the live DOM through the selector
|
|
219
|
+
extractor. The nested-overlay context is built from what the tool result already carries — the
|
|
220
|
+
appeared `pageDiff.htmlParts` subtrees (plus the region part when `areaOfInterest` is set).
|
|
221
|
+
What changed after the click *is* the nested UI; no second detection approach needed.
|
|
222
|
+
|
|
223
|
+
## Removed code
|
|
224
|
+
|
|
225
|
+
Unification means the selector-heuristic path is deleted, not deprecated:
|
|
226
|
+
|
|
227
|
+
| Removed | Was |
|
|
228
|
+
|---|---|
|
|
229
|
+
| `Action.captureOverlayHtml` + `overlayHtml` capture in `capturePageState` | Selector-extractor invocation per capture |
|
|
230
|
+
| `ActionResultData.overlayHtml` + `Overlay.resolve`'s overlayHtml branch + `Overlay.fromHtml` (public) | Priority chain feeding heading-named modals |
|
|
231
|
+
| `OVERLAY_SELECTORS`, `Overlay.captureConfig` (`overlay.ts`) | Class-name patterns (`[class*="modal"]`…) |
|
|
232
|
+
| `extractVisibleOverlayHtml`, `getVisibleOverlayHtmlExtractorSource`, `VisibleOverlayExtractionConfig` (`html.ts`), plus limit fields used only by them | The selector/z-index browser extractor |
|
|
233
|
+
| `Driller.getVisibleOverlayHtml` | Driller's private extractor invocation |
|
|
234
|
+
| `extractVisibleOverlayHtml` describe-block and `overlayHtml` resolve tests | Tests of the removed path |
|
|
235
|
+
|
|
236
|
+
**Accepted trade-off:** an overlay that is *already open at the very first capture* and carries
|
|
237
|
+
no ARIA dialog role is no longer detected (there is no previous state to diff). The moment any
|
|
238
|
+
action happens, the diff path sees it. This trades a narrow first-paint case for removing a
|
|
239
|
+
site-shape heuristic that violates the core "no memorized surface forms" principle.
|
|
240
|
+
|
|
241
|
+
## Decisions
|
|
242
|
+
|
|
243
|
+
| Decision | Choice | Why |
|
|
244
|
+
|---|---|---|
|
|
245
|
+
| Single detection home | `overlay.ts` owns every decision function; `aria.ts` keeps ARIA parsing primitives; `Action` only orchestrates | One place to reason about overlays; browser access stays in the Action tier |
|
|
246
|
+
| Old selector path | Deleted, including Driller's use (rebuilt on `pageDiff`) | User decision: unify, old code gone; class-name selectors are memorized surface forms |
|
|
247
|
+
| What the probe measures | The appeared element (`elementXPath`), never the diff `container` | Container is a both-sides ancestor; portal roots have lying geometry |
|
|
248
|
+
| `detected` semantics | Unchanged: verified overlaying only; new `present` for any region | A false overlay claim makes Tester refuse legitimate navigation — worse than no detection |
|
|
249
|
+
| Hash contribution | Named regions only; `baseHash` escape hatch for research keys | Heading names are stable; selectors are not; research must stay keyed to the page |
|
|
250
|
+
| Threshold | `SUBROOT_MIN_HTML = 5_000` on the minified subtree, unexported const | Single named constant; no config knob until someone needs one |
|
|
251
|
+
| `root` retrieval gating | Sync string equality against `overlay.root`, require `overlay.present` | Deterministic, no DOM query in the sync retrieval path |
|
|
252
|
+
|
|
253
|
+
## Non-goals / follow-ups
|
|
254
|
+
|
|
255
|
+
- **DOM-presence gating for experience `root`** (querySelector against stored HTML when the
|
|
256
|
+
current region is detected by ARIA and has no `root`). Needs an async retrieval path; revisit
|
|
257
|
+
if the equality rule proves too strict.
|
|
258
|
+
- **Region-scoped ARIA slices** for the Tester context. v1 hands the root selector and the
|
|
259
|
+
region snapshot via the tool result; slicing the ARIA tree to the region is a later refinement.
|
|
260
|
+
- **First-paint overlay detection without ARIA roles.** If the accepted trade-off above bites in
|
|
261
|
+
practice, the general fix is a geometry-only probe at first capture (top-most covering element),
|
|
262
|
+
not the return of class-name selectors.
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
# Region States Fixes — Persist the Overlay, Widen the Gates, Reject the Shell
|
|
2
|
+
|
|
3
|
+
**Date:** 2026-08-29
|
|
4
|
+
**Status:** Planned
|
|
5
|
+
**Follows:** `2026-08-29-region-states-design.md`
|
|
6
|
+
**Evidence:** 7 traced test sessions against beta.testomat.io (Langfuse, 2026-08-29 10:19–13:01), analyzed
|
|
7
|
+
trace-by-trace: `SharedConstitutionalChocolate674`, `ManualConsciousPeach543`, `MagicDepressedRose1`,
|
|
8
|
+
`MedicalUnfairGold673`, `ProudHungryChocolate14`, `RegularQuarrelsomeTurquoise190`, `InevitablePoisedBlack301`.
|
|
9
|
+
|
|
10
|
+
## Problem
|
|
11
|
+
|
|
12
|
+
The region-of-interest feature detects correctly and then loses its own result. Across every traced run
|
|
13
|
+
where the diff path fired, `pageDiff.areaOfInterest` carried the right announcement — and the state hash
|
|
14
|
+
never forked (`region_` appears in **zero** traces), Pilot's `region:` line appeared **never**, and the
|
|
15
|
+
Tester context blocks arrived once-late or not at all. The concrete bill for one run
|
|
16
|
+
(`MagicDepressedRose1`): the correct scope `//body/div[16]` sat unused in the tool result while
|
|
17
|
+
`interact()` burned 9 attempts on a guessed `.modal` selector that does not exist — roughly half of a
|
|
18
|
+
4-minute run spent compensating for guidance the feature had already computed.
|
|
19
|
+
|
|
20
|
+
Where detection did classify, quality was poor at the edges: every diff-detected root was either a
|
|
21
|
+
positional XPath (`//body/div[9..16]`) or the whole app shell (`div.main-app`); a hydration burst was
|
|
22
|
+
classified as a `drawer` covering the page; a modal-*closing* click registered as a drawer *opening*; a
|
|
23
|
+
genuine overlay (its subtree literally contains `modal-footer`, Pilot's screenshot read "obscured by a
|
|
24
|
+
modal") classified as inline `region`; and nested panels inherited the outer panel's name.
|
|
25
|
+
|
|
26
|
+
Meanwhile the app pattern these runs actually exercise — a drawer that opens **with a URL change**
|
|
27
|
+
(`/suites/suite/new-test`, `/plans/new/manual`) — is invisible to the diff path by design, and the one
|
|
28
|
+
overlay the old ARIA path caught got no root because ARIA detection pre-empts the probe entirely.
|
|
29
|
+
|
|
30
|
+
## Mechanisms found
|
|
31
|
+
|
|
32
|
+
1. **Edge-triggered overlay, wiped one capture later.** `Action.detectRegionOfInterest` sets
|
|
33
|
+
`result.overlay` only on the action whose diff crossed the threshold. The next capture finds no new
|
|
34
|
+
appeared subtree (the region is no longer *new*), falls through to `Overlay.fromAria` — empty, these
|
|
35
|
+
drawers carry no `role=dialog` — and `updateState` replaces the current state with an overlay-less
|
|
36
|
+
one. Everything downstream of the state (hash fork, `isNewState`, `<focus_scope>`,
|
|
37
|
+
`<area_of_interest>`, Pilot's `region:` line, experience `root:` frontmatter) starves. Confirmed
|
|
38
|
+
empirically: `reinjectContextIfNeeded` demonstrably ran every iteration (658 `current_focus` tags in
|
|
39
|
+
one trace) while its region branches fired 0 times against 2 real detections.
|
|
40
|
+
2. **URL-change blind spot.** `Diff.calculate` short-circuits to `liveRegionMessages` whenever the URL
|
|
41
|
+
changed, and `detectRegionOfInterest` guards on `isSameUrl`. A route-synced SPA drawer — the standard
|
|
42
|
+
pattern in the app under test — is therefore never evaluated, in any run.
|
|
43
|
+
3. **Shell containers pass the body guard.** `toOverlay` falls back to the element XPath only when the
|
|
44
|
+
container is literally `body`. `div.main-app` — body's sole meaningful child — sailed through as a
|
|
45
|
+
"scope", producing `<focus_scope>` text that was factually false ("elements outside are not
|
|
46
|
+
actionable" about a container that wraps the entire page, `MedicalUnfairGold673` 12:49:08).
|
|
47
|
+
4. **Hydration and modal-close read as regions.** A ~2s same-URL hydration window between a failed click
|
|
48
|
+
and a `context()` call crossed the flat 10K threshold and, with a stray fixed-position widget, was
|
|
49
|
+
classified `drawer (root: div.main-app)`. Separately, dismissing a picker re-rendered base content
|
|
50
|
+
and fired `drawer "New Plan" opened, scope: div.main-app` on a click that *closed* an overlay
|
|
51
|
+
(`InevitablePoisedBlack301` 13:00:53). Both false positives share a shell root.
|
|
52
|
+
5. **Floating check ignores ancestors.** `inspectRegion` reads `position`/`z-index` off the appeared
|
|
53
|
+
node only. A large subtree appearing *inside* an already-floating drawer classifies as inline
|
|
54
|
+
`region` even when the subtree itself contains `modal-footer` and a screenshot shows an overlay
|
|
55
|
+
(`MagicDepressedRose1` 12:45:23).
|
|
56
|
+
6. **Naming picks the largest part, not the newest heading.** `appearedSubRoot` maximizes subtree size,
|
|
57
|
+
then `nameFrom` joins h1–h4 of that subtree — so a nested picker is named after the outer panel
|
|
58
|
+
("New Plan", "New Test") instead of its own heading ("Select tests for plan", "Select suite for
|
|
59
|
+
test"), which sat in the same state's h3 the whole time.
|
|
60
|
+
7. **ARIA pre-empts the probe; failed batches capture nothing.** `if (result.overlay.detected) return`
|
|
61
|
+
means an ARIA-detected modal never gets `root`/`html` enrichment — every `<focus_scope>` in
|
|
62
|
+
`ManualConsciousPeach543` lacked the scope sentence. And `executeOnce` only captures state on
|
|
63
|
+
success: the click that opened that modal succeeded *inside* a `form()` batch whose third line
|
|
64
|
+
failed, so no capture, no detection, and a tool result that labeled all four sub-commands FAILED —
|
|
65
|
+
including the two that succeeded. Cost: ~26s clicking a button behind a modal the system had been
|
|
66
|
+
told twice (by its own `see()`) was open.
|
|
67
|
+
|
|
68
|
+
## Changes
|
|
69
|
+
|
|
70
|
+
### 1. Persist the detected overlay until it verifiably closes (the P0)
|
|
71
|
+
|
|
72
|
+
The overlay stops being a per-action edge and becomes state that is carried forward. In
|
|
73
|
+
`Action`'s detection step, per capture, in order:
|
|
74
|
+
|
|
75
|
+
1. **Same URL, HTML unchanged** → carry the previous state's overlay verbatim. Nothing moved.
|
|
76
|
+
2. **Close check before open check.** If the previous overlay was diff-detected (has an element XPath)
|
|
77
|
+
and its element is gone from the new HTML or the openness probe says its center no longer belongs to
|
|
78
|
+
it → drop it (debug log "region closed"). The hash reverts, `updateState` records the close
|
|
79
|
+
transition — open/close cycles become visible to `isInDeadLoop`, as the original spec intended.
|
|
80
|
+
3. **New detection** from the diff, as today.
|
|
81
|
+
4. **No new detection, previous overlay still confirmed open** → carry it forward onto the new
|
|
82
|
+
`ActionResult`.
|
|
83
|
+
|
|
84
|
+
To make the close/confirm check possible, `Overlay` additionally records the appeared element's XPath
|
|
85
|
+
(`xpath`, internal — never rendered into prompts; `root` remains the scoping selector shown to agents).
|
|
86
|
+
The confirm probe is the existing center hit-test (`OverlayPage`), run against the stored XPath — one
|
|
87
|
+
cheap probe per capture, and only while an overlay is being carried.
|
|
88
|
+
|
|
89
|
+
Two persistence refinements keep common flows from losing state:
|
|
90
|
+
|
|
91
|
+
- **One-deep parent restore.** When a new detection replaces a carried overlay (a picker opening
|
|
92
|
+
inside an open drawer), the replaced overlay's identity (`type`/`name`/`root`/`xpath`, no `html`)
|
|
93
|
+
travels on the new one as `parent`. When the nested region closes, the parent's XPath is probed —
|
|
94
|
+
still open → it is restored as the current overlay. One level, deterministic; the traced
|
|
95
|
+
suite-picker-inside-drawer flow keeps its drawer.
|
|
96
|
+
- **ARIA continuity.** A fresh ARIA detection whose type and name match the previous overlay keeps the
|
|
97
|
+
previous *instance* — otherwise a root enriched by change 6 would survive exactly one capture before
|
|
98
|
+
a bare `fromAria` result replaced it.
|
|
99
|
+
|
|
100
|
+
This single change is what unlocks the already-built downstream behavior: the hash forks
|
|
101
|
+
(`region_<name>`), `isNewState` fires once, `<focus_scope>`/`<area_of_interest>` inject,
|
|
102
|
+
Pilot's `<state>` shows the region, and experience files for region states get written with their
|
|
103
|
+
`root:` frontmatter.
|
|
104
|
+
|
|
105
|
+
### 2. Detect route-synced drawers across URL changes
|
|
106
|
+
|
|
107
|
+
`Diff` always computes the HTML diff, URL change or not. Tool-result behavior for navigations does not
|
|
108
|
+
change — `PageDiff` keeps surfacing only messages for a changed URL, never navigation-noise
|
|
109
|
+
`htmlParts` — the diff is computed for detection's sake and stays memoized (one parse5 pass, shared).
|
|
110
|
+
|
|
111
|
+
Detection drops the hard `isSameUrl` gate and replaces it with a structural rule: across a URL change, a
|
|
112
|
+
region is considered only when `similarity >= SOFT_NAVIGATION_SIMILARITY` (unexported const, ~50) — the
|
|
113
|
+
old page must still substantially exist under the new content. A real navigation (low similarity)
|
|
114
|
+
produces no region; a drawer rendered over the still-present page does. The accepted-trade-off section
|
|
115
|
+
of the original spec ("first paint with no ARIA role") stands; this closes the much larger gap the field
|
|
116
|
+
runs actually hit.
|
|
117
|
+
|
|
118
|
+
### 3. Bound the region: bigger than a widget, smaller than the page
|
|
119
|
+
|
|
120
|
+
A region is a **band**, not just a floor. The appeared subtree must satisfy both:
|
|
121
|
+
|
|
122
|
+
- `size >= SUBROOT_MIN_HTML` (5K minified; the second field run showed a real split-pane form panel
|
|
123
|
+
at 6.6K minified / 9K raw sitting under the original 10K floor, while dropdowns stay under 1K) —
|
|
124
|
+
below it, a widget, ignored;
|
|
125
|
+
- `size <= REGION_MAX_RATIO * pageSize` (~0.6, raw serialized subtree against raw serialized body —
|
|
126
|
+
like against like, using the strings the diff already has in hand, no extra minify pass) — above it,
|
|
127
|
+
**this is not a region change anymore, it is a new state**. No overlay
|
|
128
|
+
is set; the ordinary state-change machinery (url + headings hash, research on change) owns a page
|
|
129
|
+
that mostly replaced itself. A full-page takeover that swaps more than 60% of the HTML *is* a new
|
|
130
|
+
state semantically, and takeovers bring their own headings, so state identity still forks.
|
|
131
|
+
|
|
132
|
+
The cap is the primary defense against the observed false positives: a hydration burst that finishes
|
|
133
|
+
rendering the page blows it, and a modal-close re-render of the base content blows it too. It is also
|
|
134
|
+
the same principle as change 2's cross-URL similarity floor, seen from the other side — a region
|
|
135
|
+
requires that most of the page **survived**.
|
|
136
|
+
|
|
137
|
+
The band alone is not enough — the second field run produced a list re-render (search filter cleared,
|
|
138
|
+
rows repopulated) that passed floor, cap and dominance yet had no heading and no semantic root
|
|
139
|
+
anywhere in its dominant chain. Such a detection can neither fork the hash nor scope anything, so it
|
|
140
|
+
is pure noise. Hence an **identity gate** after classification: an overlay with neither a `name` nor
|
|
141
|
+
a `root` is dropped. Either one alone keeps it — a named rootless overlay still forks state identity,
|
|
142
|
+
a rooted nameless one still scopes.
|
|
143
|
+
|
|
144
|
+
A third field run (a suite-selector dialog) showed the gates must be **per-candidate filters, not
|
|
145
|
+
whole-detection aborts**. Opening that dialog produced three appeared parts at once: a 622K flood of
|
|
146
|
+
detached list nodes rendered directly under `body` (96% of the page), the app shell re-keyed by the
|
|
147
|
+
flood's positional renumbering (196K of old content the differ saw as "appeared"), and the real 13K
|
|
148
|
+
modal. "Largest candidate wins, then cap" picked the flood and aborted, losing the modal every time.
|
|
149
|
+
Detection now walks all in-band candidates instead: cap-tripping parts are skipped (they are page
|
|
150
|
+
redraw, not regions), dominance is measured across the surviving candidates, and among survivors the
|
|
151
|
+
one that brings a **fresh heading** is preferred over larger heading-stale reflows — the same
|
|
152
|
+
newest-heading principle naming already uses, applied to selection. The probe then walks the ordered
|
|
153
|
+
candidates and skips any whose center is covered by another element, so a background pane never wins
|
|
154
|
+
over the dialog stacked on top of it.
|
|
155
|
+
|
|
156
|
+
Root selection still needs its own care, and its rule is now stricter: **roots are semantic only**. A
|
|
157
|
+
positional XPath is never a `root` — it neither reaches a prompt nor an experience file. It survives
|
|
158
|
+
strictly as the overlay's internal `xpath`, the handle the openness/persistence probe needs to find
|
|
159
|
+
the element, never rendered to agents. (This supersedes the original design's body → element-XPath
|
|
160
|
+
fallback.)
|
|
161
|
+
|
|
162
|
+
The real root of the appeared subtree is often an anonymous wrapper with no identity of its own —
|
|
163
|
+
`body > div > div > div.bg-overlay > div > div.modal` — so the search may **skip the real root in
|
|
164
|
+
either direction** when a better candidate exists:
|
|
165
|
+
|
|
166
|
+
- **Up:** stable ancestors, as today — except a container whose subtree spans most of the document is
|
|
167
|
+
a shell, never a scope (same treatment as literal `body`).
|
|
168
|
+
- **The appeared element itself**, when it carries a stable id or meaningful classes (the same
|
|
169
|
+
filters `findStableContainer` already applies: dynamic ids out, digit/framework/Tailwind utility
|
|
170
|
+
classes out, document-unique required).
|
|
171
|
+
- **Down:** unwrap the appeared subtree — repeatedly descend into the child that holds the bulk of
|
|
172
|
+
the subtree's content (`ROOT_CONTENT_RATIO`, ~0.8; a wrapper has one dominant child), testing each
|
|
173
|
+
node for a semantic selector. In the example above, the anonymous wrappers and the `bg-overlay`
|
|
174
|
+
utility class are all rejected by the existing filters, and the descent lands on `div.modal`.
|
|
175
|
+
- **Nothing semantic anywhere → the overlay has no root.** The context blocks already render their
|
|
176
|
+
scope sentence only when a root exists; agents then scope by the region's name and ARIA, and the
|
|
177
|
+
experience file simply omits its optional `root:` key.
|
|
178
|
+
|
|
179
|
+
Preference order: semantic non-shell ancestor → appeared element → deepest dominant descendant →
|
|
180
|
+
none. Classification is unaffected: the openness probe keeps measuring the topmost appeared element
|
|
181
|
+
(the whole overlay, backdrop included), while `root` answers the different question — where locators
|
|
182
|
+
should aim.
|
|
183
|
+
|
|
184
|
+
This kills both observed root failures: `div.main-app` (shell) and `//body/div[16]` (positional)
|
|
185
|
+
can never be produced again.
|
|
186
|
+
|
|
187
|
+
### 4. Classify with ancestors and isolation
|
|
188
|
+
|
|
189
|
+
- **Floating check walks up.** `inspectRegion` reports floating when the element *or any ancestor up to
|
|
190
|
+
body* is fixed/absolute/z-indexed. A re-render inside an open drawer now classifies as the drawer it
|
|
191
|
+
is.
|
|
192
|
+
- **Isolation guard.** A region requires a dominant single addition: the winning part must account for
|
|
193
|
+
the bulk of the diff's total changed subtree length (`REGION_DOMINANCE`, ~0.7). This catches what the
|
|
194
|
+
change-3 cap cannot: many *small* scattered changes with no dominant subtree.
|
|
195
|
+
- **Close is not open.** Because change 1 runs the close check first, a click that dismisses the current
|
|
196
|
+
overlay is consumed as a close transition; re-rendered base content underneath cannot double as a
|
|
197
|
+
fresh region in the same capture.
|
|
198
|
+
|
|
199
|
+
### 5. Name from the newest heading
|
|
200
|
+
|
|
201
|
+
`nameFrom` filters the subtree's headings to those **absent from the previous HTML** (plain containment
|
|
202
|
+
check against the prior snapshot — structural, no semantics) and takes the first survivor; only when
|
|
203
|
+
none survives does it fall back to the current first-heading join. A nested picker opening inside "New
|
|
204
|
+
Plan" is now named "Select tests for plan" — which also keys its own hash fork and its own experience
|
|
205
|
+
file, instead of colliding with the outer panel's.
|
|
206
|
+
|
|
207
|
+
### 6. Merge ARIA identity with probe geometry
|
|
208
|
+
|
|
209
|
+
ARIA detection no longer pre-empts the probe — the two paths answer different questions and merge:
|
|
210
|
+
|
|
211
|
+
- ARIA supplies **identity** (type `dialog`/`modal`, accessible name) — it is authoritative when
|
|
212
|
+
present.
|
|
213
|
+
- The diff+probe supplies **geometry** (`root`, `xpath`, `html`) when a qualifying appeared subtree
|
|
214
|
+
exists for the same moment.
|
|
215
|
+
|
|
216
|
+
An ARIA-detected modal whose opening also produced a large diff gets a root and a region snapshot; the
|
|
217
|
+
`<focus_scope>` scope sentence and Pilot's `(root: …)` suffix stop being diff-path-only.
|
|
218
|
+
|
|
219
|
+
### 7. Capture on the failure path, report batches truthfully
|
|
220
|
+
|
|
221
|
+
- `executeOnce` captures page state (detection included) on non-fatal failures too, not only on
|
|
222
|
+
success. A batch that opened a drawer on line 2 and died on line 3 must still produce a state with
|
|
223
|
+
the drawer in it.
|
|
224
|
+
- The `form()` failure report attributes per-line status from the steps that actually ran (the
|
|
225
|
+
`executedSteps` machinery from #150), instead of stamping every sub-command FAILED. The model must
|
|
226
|
+
learn "your click already opened something" from the tool result, not from 26 seconds of timeouts.
|
|
227
|
+
|
|
228
|
+
### 8. Region transitions wake the Pilot
|
|
229
|
+
|
|
230
|
+
`shouldAnalyzeProgress` treats a region open/close transition since the last analysis like a new-page
|
|
231
|
+
event: analysis triggers at the next iteration regardless of the interval modulo. In
|
|
232
|
+
`RegularQuarrelsomeTurquoise190`, Pilot went dark for 27 tool calls — the entire lifetime of the region,
|
|
233
|
+
three server 400s included; the feature's Pilot surface is worthless if Pilot never runs while a region
|
|
234
|
+
is open.
|
|
235
|
+
|
|
236
|
+
## Out of scope — separate follow-ups
|
|
237
|
+
|
|
238
|
+
Real issues from the same traces that are not this feature's subsystem, recorded here so they are not
|
|
239
|
+
lost:
|
|
240
|
+
|
|
241
|
+
- **Verdict integrity** (`MedicalUnfairGold673` false pass): pre-existing entities accepted as proof
|
|
242
|
+
despite the provenance rule; Pilot's own screenshot doubt discarded between two calls 3 seconds
|
|
243
|
+
apart; verbatim expected-result settlement unenforced at `finish()`; the post-run recipe compactor
|
|
244
|
+
correctly concluded "no step actually creates a test" and that signal reaches nothing.
|
|
245
|
+
- **Reload primitive**: `pressKey('F5')` is a no-op that reports success; persistence scenarios need a
|
|
246
|
+
real reload action.
|
|
247
|
+
- **Pilot server-error triage**: a raw backend error (`WRONGTYPE`) was first misdiagnosed as a form
|
|
248
|
+
problem; 4xx/5xx with non-validation bodies should bias to stop-and-report on first occurrence.
|
|
249
|
+
- **Experience vocabulary**: a stored recipe describing the picker as a "modal" steered `interact()`
|
|
250
|
+
into guessing `.modal`; supersede stale wording when a fresh run's classification disagrees, instead
|
|
251
|
+
of suppressing the new write as a duplicate.
|
|
252
|
+
- **App defects found** (report to the product, they are findings, not bugs here): plans `POST` → 400
|
|
253
|
+
`WRONGTYPE` with partial persistence; suites search → 500; stale Ember modal backdrop blocking Save;
|
|
254
|
+
Monaco editor duplicating filled content.
|
|
255
|
+
|
|
256
|
+
## Validation
|
|
257
|
+
|
|
258
|
+
- Unit: carry-forward and close transitions (StateManager history shows open → carried → closed);
|
|
259
|
+
cross-URL detection above/below the similarity floor; the size band — below 5K no region, inside
|
|
260
|
+
the band a region, above `REGION_MAX_RATIO` no overlay and a plain state change; root selection —
|
|
261
|
+
semantic ancestor preferred, wrapper-chain descent landing on a nested semantic container, shell
|
|
262
|
+
rejection, and the no-semantic case yielding a rootless overlay with no positional XPath anywhere
|
|
263
|
+
in prompts or frontmatter; ancestor-floating classification; dominance guard against scattered
|
|
264
|
+
diffs; newest-heading naming with a nested-panel fixture; ARIA+probe merge producing rooted
|
|
265
|
+
`dialog`; failure-path capture.
|
|
266
|
+
- The seven Langfuse sessions above are the acceptance fixture: re-run the same two focus commands
|
|
267
|
+
(`create test`, `create plan of different kinds`) and require — hash forks containing `region_`,
|
|
268
|
+
Pilot `<state>` showing the region while open, zero `.modal`-guess `interact()` scopes, and no
|
|
269
|
+
`div.main-app` root anywhere.
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# Fisherman Live-Session Auth — Design
|
|
2
|
+
|
|
3
|
+
## Problem
|
|
4
|
+
|
|
5
|
+
Langfuse trace `de95bd1cffce09169599d99d1bee56cd` (2026-08-30, beta.testomat.io, project `zyntra-don-t-touch-cloned`): every Fisherman write returned `403 {"error":"Unauthorized"}` in ~8ms while the same browser session performed successful writes to the same project minutes earlier. The requests were rejected at the auth layer because Fisherman assembled stale credentials:
|
|
6
|
+
|
|
7
|
+
1. `cookieProvider` (`src/explorbot.ts`) serialized `page.context().cookies()` — the **entire jar, unfiltered** — producing a doubled `Cookie` header with a dead `localhost` session pair ahead of the valid one. A real browser never sends localhost cookies to another host.
|
|
8
|
+
2. `extractAuthHeaders` (`src/api/request-store.ts`) scraped `x-csrf-token` from a **2026-07-07 capture of another project** (`imr_manual12`). The store iterates `capturedRequests` from the end, but `loadFromDisk` fills it in alphabetical filename order, so "last" means alphabetically-late, not newest. `output/requests/` is a graveyard spanning months and many projects, so any stale credential can win.
|
|
9
|
+
3. `refreshAuth` (`src/ai/fisherman.ts`) applies live cookies **before** capture-scraped headers, so a captured `cookie` header (in `AUTH_HEADERS`) could even clobber the fresh jar.
|
|
10
|
+
|
|
11
|
+
## Principle
|
|
12
|
+
|
|
13
|
+
**Captures are a source of API shape — endpoints, body examples — which is durable across sessions. They are never a source of credentials, which are ephemeral.** Credentials come from the live browser session or from explicit `api.headers` config. This rules out any future re-accretion of header scraping from old captures.
|
|
14
|
+
|
|
15
|
+
## Decisions
|
|
16
|
+
|
|
17
|
+
- **D1 — Cookies from the live jar, filtered by the API origin.** `page.context().cookies(baseEndpoint)`: Playwright applies the same domain/path matching a browser applies when sending to that URL. No manual dedup — same-name cookies on parent/child domains are legitimate browser behavior and Playwright's filter already yields exactly what the browser would send.
|
|
18
|
+
- **D2 — Never scrape cookies from captures.** `'cookie'` leaves `AUTH_HEADERS`. The jar is the single, always-current source of cookies.
|
|
19
|
+
- **D3 — Auth headers only from current-session captures, newest first.** `RequestStore` records `sessionStartedAt` at construction; `extractAuthHeaders` considers only captures with `timestamp >= sessionStartedAt`, sorted newest-first. Live captures (added by `XhrCapture` during this run) pass; the disk graveyard never does. This also resolves same-id collisions (an old `xhr_001_…` disk file vs a live capture reusing that counter id): the gate keeps only the live one.
|
|
20
|
+
- **D4 — Precedence: captured < live browser < config.** `refreshAuth` applies session-capture headers first, live browser headers second (current session replaces old), explicit `configHeaders` last (user intent stays authoritative). Still refreshed once per episode — the browser is idle while Fisherman runs.
|
|
21
|
+
- **D4a — Replicate mode only.** Browser-derived credentials (jar cookies, page CSRF token, session-capture headers) apply only in replicate mode, where Fisherman replays what the browser does. In achieve mode the API contract is explicit and authentication comes solely from `api.headers` config — injecting browser cookies there would be surprising and can leak a UI session into a separately-authenticated API.
|
|
22
|
+
- **D5 — Live CSRF token from the page (severable).** The provider also reads `meta[name="csrf-token"]` from the current page and sends it as `x-csrf-token`. This is a cross-framework web convention (Rails, Laravel), the same class of structural knowledge as ARIA attributes or URL anatomy — not a site-specific locator. `cookieProvider` is renamed `browserHeaderProvider` since it now supplies all live-browser-derived headers. Cutting this decision cuts only Task 4 of the plan; Tasks 1–3 stand alone.
|
|
23
|
+
- **D6 — A capture without a timestamp is stale.** `RequestResult.load` currently stamps load-time for a file missing `timestamp`, which would slip past the session gate; absent timestamp now parses as epoch.
|
|
24
|
+
|
|
25
|
+
## Declared behavior change
|
|
26
|
+
|
|
27
|
+
`Authorization` / `x-api-key` values scraped from **previous-session** captures are no longer sent. Cookie-authenticated apps are unaffected (the jar is live). An app that was only ever authenticated through a stale captured token now fails honestly instead of sending dead credentials — the remedy is `api.headers` in config.
|
|
28
|
+
|
|
29
|
+
## Out of scope
|
|
30
|
+
|
|
31
|
+
- Prompt changes to make the model stop faster on `authorization` failures (the 4-failure guard already bounds it).
|
|
32
|
+
- Pilot's final verdict misattributing the failure to later tester errors instead of the failed precondition.
|
|
33
|
+
- Mirroring the `XSRF-TOKEN` cookie into an `X-XSRF-TOKEN` header (Angular/Laravel convention) — add only if a real trace shows it's needed.
|
|
34
|
+
|
|
35
|
+
## Acceptance
|
|
36
|
+
|
|
37
|
+
A run against a cookie-authenticated app where Fisherman's writes carry only the cookies the current browser session would send to the API origin, plus a current CSRF token — verified by the request ledger in `output/requests/` showing a single-valued `Cookie` header matching the live session and no header value originating from a previous session's captures.
|
|
@@ -56,8 +56,10 @@ No `init`, no config file, no project directory, no model IDs to look up. These
|
|
|
56
56
|
| `EXPLORBOT_EPHEMERAL` | no | Keep no state between runs — output goes to a fresh temp directory instead of the site dir |
|
|
57
57
|
| `EXPLORBOT_KNOWLEDGE` | no | Inline knowledge text, applied to every page |
|
|
58
58
|
| `EXPLORBOT_KNOWLEDGE_FILE` | no | Path to a knowledge markdown file |
|
|
59
|
+
| `EXPLORBOT_SPEC` | no | Docbot application spec directory or index.md, used as page knowledge |
|
|
59
60
|
| `EXPLORBOT_API_SPEC` | no | OpenAPI spec path for the API boat |
|
|
60
61
|
| `EXPLORBOT_NO_BANNER` | no | Suppress the startup banner, for machine-readable output |
|
|
62
|
+
| `EXPLORBOT_MAX_DURATION` | no | Wall-clock budget in minutes for an explore run; same as --max-duration |
|
|
61
63
|
<!-- END env -->
|
|
62
64
|
|
|
63
65
|
`EXPLORBOT_URL` is optional when the command itself carries an absolute URL, as `docs collect https://…` does. The [API boat](../api-testing/basics.md) reads it as the base endpoint.
|
|
@@ -123,6 +125,12 @@ EXPLORBOT_AI_PROVIDER=openrouter \
|
|
|
123
125
|
EXPLORBOT_KNOWLEDGE_FILE=./checkout-knowledge.md npx explorbot explore /checkout
|
|
124
126
|
```
|
|
125
127
|
|
|
128
|
+
Both variables work in config-free runs and in runs on the global configuration, where what they carry is written into the site's knowledge directory for that run — rewritten on the next run, and removed by a run that sets neither variable. Facts worth keeping belong in `learn` or `know`. The `--knowledge` flag does the same thing as an argument, works with a project config as well, and writes nothing, so prefer it when one command needs one fact:
|
|
129
|
+
|
|
130
|
+
```bash
|
|
131
|
+
npx explorbot explore /checkout --knowledge 'Use the sandbox card 4111 1111 1111 1111'
|
|
132
|
+
```
|
|
133
|
+
|
|
126
134
|
### What this mode changes
|
|
127
135
|
|
|
128
136
|
Config-free runs leave no trace in the working directory:
|
|
@@ -212,9 +220,11 @@ The same variables drive API testing and doc collection.
|
|
|
212
220
|
EXPLORBOT_URL=https://api.example.com \
|
|
213
221
|
EXPLORBOT_API_SPEC=./openapi.yaml \
|
|
214
222
|
EXPLORBOT_AI_PROVIDER=openrouter \
|
|
215
|
-
npx explorbot api explore
|
|
223
|
+
npx explorbot api explore /users
|
|
216
224
|
```
|
|
217
225
|
|
|
226
|
+
The API boat also takes those two as flags, so one line carries the whole run: `npx explorbot api explore /users --endpoint https://api.example.com --spec ./openapi.yaml`.
|
|
227
|
+
|
|
218
228
|
```bash
|
|
219
229
|
EXPLORBOT_AI_PROVIDER=openrouter \
|
|
220
230
|
npx explorbot docs collect https://app.example.com/dashboard --max-pages 20
|
|
@@ -222,7 +232,7 @@ EXPLORBOT_AI_PROVIDER=openrouter \
|
|
|
222
232
|
|
|
223
233
|
`docs collect` takes its base URL from the absolute path argument, so `EXPLORBOT_URL` is optional there.
|
|
224
234
|
|
|
225
|
-
Knowledge written by `EXPLORBOT_KNOWLEDGE` carries `endpoint: '*'` frontmatter alongside `url: '*'`, matching the convention `api init` and `api know` use
|
|
235
|
+
Knowledge written by `EXPLORBOT_KNOWLEDGE` carries `endpoint: '*'` frontmatter alongside `url: '*'`, matching the convention `api init` and `api know` use, so one variable reaches both boats.
|
|
226
236
|
|
|
227
237
|
## See Also
|
|
228
238
|
|
package/docs/workflow/ci.md
CHANGED
|
@@ -9,6 +9,7 @@ Once your Explorbot setup works locally — it logs in, explores, and produces s
|
|
|
9
9
|
- **A headless browser.** That's the default — don't pass `--show` and there is nothing to configure.
|
|
10
10
|
- **A start path and a test budget.** `npx explorbot explore / --max-tests 10` keeps the run bounded and predictable.
|
|
11
11
|
- **A hard timeout as backstop.** AI runs can stall; a job-level timeout guarantees the pipeline never hangs.
|
|
12
|
+
- **A time budget below that timeout.** Pass `--max-duration` (or set `EXPLORBOT_MAX_DURATION`) a few minutes under the job timeout: Explorbot stops starting new tests, wraps up the in-flight one, writes the report, and finalizes the Testomatio run on its own — instead of being killed mid-test, which loses the report. The budget minus a 3-minute teardown reserve is the hard cutoff; 5 minutes before that, no new work starts.
|
|
12
13
|
|
|
13
14
|
For login, commit your `knowledge/` directory to the repo — it's the input you control, and Explorbot reads it on every run. Keep credentials out of the files with `${env.NAME}` interpolation, as described in [Knowledge](./knowledge.md), and set those variables as pipeline secrets too. Add `--session` to the run command so Explorbot logs in once and reuses the saved session.
|
|
14
15
|
|