explorbot 0.4.6 → 0.4.8
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/boat/api-tester/src/ai/chief.ts +3 -1
- package/boat/api-tester/src/ai/curler.ts +74 -66
- package/boat/api-tester/src/apibot.ts +1 -0
- package/boat/api-tester/src/cli.ts +2 -0
- package/boat/api-tester/src/config.ts +18 -1
- package/dist/boat/api-tester/src/ai/chief.js +3 -1
- package/dist/boat/api-tester/src/ai/curler.js +59 -56
- package/dist/boat/api-tester/src/apibot.js +1 -0
- package/dist/boat/api-tester/src/cli.js +2 -0
- package/dist/boat/api-tester/src/config.js +3 -1
- package/dist/package.json +2 -2
- package/dist/rules/chief/general.md +2 -0
- package/dist/rules/researcher/pagination.md +7 -0
- package/dist/src/action-result.d.ts +6 -0
- package/dist/src/action-result.js +12 -0
- package/dist/src/action.js +3 -2
- package/dist/src/ai/navigator.js +1 -4
- package/dist/src/ai/pilot.js +8 -12
- package/dist/src/ai/planner/session-dedup.d.ts +2 -1
- package/dist/src/ai/planner/session-dedup.js +18 -1
- package/dist/src/ai/planner.js +12 -4
- package/dist/src/ai/provider.js +18 -4
- package/dist/src/ai/researcher/locators.js +1 -1
- package/dist/src/ai/researcher/pagination.d.ts +16 -0
- package/dist/src/ai/researcher/pagination.js +62 -0
- package/dist/src/ai/researcher/parser.d.ts +3 -0
- package/dist/src/ai/researcher/parser.js +22 -6
- package/dist/src/ai/researcher/sections.js +1 -1
- package/dist/src/ai/researcher.js +7 -2
- package/dist/src/ai/rules.js +17 -0
- package/dist/src/ai/scout.js +8 -2
- package/dist/src/ai/tester.js +1 -1
- package/dist/src/ai/tools.js +12 -4
- package/dist/src/commands/options/ws-option.d.ts +7 -0
- package/dist/src/commands/options/ws-option.js +14 -0
- package/dist/src/config.d.ts +1 -0
- package/dist/src/config.js +14 -11
- package/dist/src/remote.d.ts +2 -0
- package/dist/src/remote.js +23 -16
- package/dist/src/utils/aria.d.ts +2 -0
- package/dist/src/utils/aria.js +6 -1
- package/dist/src/utils/code-extractor.js +6 -2
- package/dist/src/utils/markdown-query.d.ts +2 -0
- package/dist/src/utils/markdown-query.js +39 -0
- package/dist/src/utils/pagination.d.ts +16 -0
- package/dist/src/utils/pagination.js +20 -0
- package/docs/superpowers/plans/2026-09-10-pagination.md +1420 -0
- package/docs/superpowers/specs/2026-09-09-pagination-rule-design.md +125 -97
- package/package.json +2 -2
- package/rules/chief/general.md +2 -0
- package/rules/researcher/pagination.md +7 -0
- package/src/action-result.ts +16 -0
- package/src/action.ts +3 -2
- package/src/ai/navigator.ts +1 -4
- package/src/ai/pilot.ts +8 -12
- package/src/ai/planner/session-dedup.ts +16 -2
- package/src/ai/planner.ts +12 -4
- package/src/ai/provider.ts +18 -3
- package/src/ai/researcher/locators.ts +1 -1
- package/src/ai/researcher/pagination.ts +68 -0
- package/src/ai/researcher/parser.ts +23 -5
- package/src/ai/researcher/sections.ts +1 -1
- package/src/ai/researcher.ts +9 -3
- package/src/ai/rules.ts +17 -0
- package/src/ai/scout.ts +9 -2
- package/src/ai/tester.ts +1 -1
- package/src/ai/tools.ts +9 -4
- package/src/commands/options/ws-option.ts +14 -0
- package/src/config.ts +15 -11
- package/src/remote.ts +22 -15
- package/src/utils/aria.ts +8 -1
- package/src/utils/code-extractor.ts +6 -2
- package/src/utils/markdown-query.ts +39 -0
- package/src/utils/pagination.ts +36 -0
|
@@ -22,12 +22,12 @@ untouched.
|
|
|
22
22
|
|
|
23
23
|
## Approach
|
|
24
24
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
CodeceptJS and is reachable
|
|
25
|
+
Research finds out which strategy each list uses and records it in the UI map, beside that
|
|
26
|
+
list's own container. The tester already reads the UI map, so the fact arrives attached to the
|
|
27
|
+
list it describes. No new tool: the gesture already exists in CodeceptJS and is reachable
|
|
28
|
+
through `form`.
|
|
28
29
|
|
|
29
|
-
|
|
30
|
-
at run time, and never carries guidance for a strategy this page does not use.
|
|
30
|
+
Nothing is injected per page — see section B for why the first attempt at that was wrong.
|
|
31
31
|
|
|
32
32
|
### Why `I.scrollTo` is sufficient
|
|
33
33
|
|
|
@@ -67,41 +67,10 @@ Four steps, cheapest first, stopping as soon as one answers. This is the escalat
|
|
|
67
67
|
CLAUDE.md end to end: a table lookup, then AI judgment, then a probe whose result converts
|
|
68
68
|
judgment back into a recorded fact.
|
|
69
69
|
|
|
70
|
-
**0. Do the ARIA/HTML conventions name it? (deterministic, no research, no AI)**
|
|
71
|
-
|
|
72
|
-
Some markup states the answer outright. These are spec-defined attributes and values, so this
|
|
73
|
-
tier is a lookup, not a guess — and it is the only step that works when research has not run.
|
|
74
|
-
|
|
75
|
-
| Marker | Means | Available in |
|
|
76
|
-
|---|---|---|
|
|
77
|
-
| `[aria-current="page"]` | current page of a pagination set | HTML only |
|
|
78
|
-
| `a[rel="next"]`, `a[rel="prev"]` | sequential document relations | HTML only |
|
|
79
|
-
| `[role="feed"]` | scrollable list that grows as it is scrolled | HTML and ARIA snapshot |
|
|
80
|
-
| `[aria-setsize="-1"]` | total count unknown, so the set loads lazily | HTML only |
|
|
81
|
-
|
|
82
|
-
The first two mean `controls`, the last two mean `infinite`.
|
|
83
|
-
|
|
84
|
-
Verified against Chromium: `ariaSnapshot()` does **not** emit `aria-current`, so a link marked
|
|
85
|
-
as the current page is indistinguishable from its neighbours in the ARIA path. Explorbot also
|
|
86
|
-
dissolves `navigation` wrappers (`src/utils/aria.ts:46`, `:147`) and treats the role as
|
|
87
|
-
template chrome (`src/utils/aria.ts:543`), so a `nav` labelled "Pagination" never reaches the
|
|
88
|
-
model either. **These markers must be read from HTML.** `role="feed"` is the one exception —
|
|
89
|
-
it survives as `- feed "…"` in the snapshot.
|
|
90
|
-
|
|
91
|
-
Two constraints that keep this a lookup rather than a heuristic:
|
|
92
|
-
|
|
93
|
-
- The value must be `aria-current="page"` exactly. `aria-current="true"` is what tabs and
|
|
94
|
-
breadcrumbs use and would over-match — confirmed in the same probe.
|
|
95
|
-
- A `nav` whose `aria-label` reads "Pagination" is author prose, not closed grammar. It is not
|
|
96
|
-
part of this tier.
|
|
97
|
-
|
|
98
|
-
**Absence proves nothing.** A pager built from plain buttons, and an infinite feed built from
|
|
99
|
-
plain divs, carry none of these. That is what steps 1–3 are for.
|
|
100
|
-
|
|
101
70
|
**1. Are there pagination controls? (AI, free)**
|
|
102
71
|
|
|
103
|
-
|
|
104
|
-
|
|
72
|
+
Controls are named in open-ended ways — words, arrows, bare numbers — so this is AI judgment,
|
|
73
|
+
not a pattern match. Researcher is already describing
|
|
105
74
|
the section, so it costs nothing extra:
|
|
106
75
|
a new `rules/researcher/pagination.md`, loaded alongside the existing three at
|
|
107
76
|
`src/ai/researcher/sections.ts:81`, asks it to note when a section contains controls that move
|
|
@@ -109,26 +78,69 @@ between pages of the same collection.
|
|
|
109
78
|
|
|
110
79
|
If found, the section records `> Pagination: controls` and the remaining steps are skipped.
|
|
111
80
|
|
|
112
|
-
**2.
|
|
81
|
+
**2. Markers and the scroll gate — one `page.evaluate` per container (`inspectList`)**
|
|
113
82
|
|
|
114
|
-
Only
|
|
83
|
+
Only reached when research recorded nothing. Everything the probe needs comes back in one call,
|
|
84
|
+
**scoped to the container**, never to the page:
|
|
115
85
|
|
|
116
|
-
- `
|
|
117
|
-
- `
|
|
118
|
-
|
|
86
|
+
- `a[rel="next"]`/`a[rel="prev"]` inside it → `controls`, recorded without scrolling.
|
|
87
|
+
- `[role="feed"]` → `infinite`, recorded without scrolling. These are spec-defined relations, so
|
|
88
|
+
this tier is a lookup, not a heuristic.
|
|
89
|
+
- `scrollHeight > clientHeight`, or the container's bottom below the fold → it can scroll, so
|
|
90
|
+
step 3 may run. Neither → nothing recorded.
|
|
119
91
|
|
|
120
|
-
|
|
92
|
+
`aria-current` is excluded in every value: its primary spec use is a site-navigation link
|
|
93
|
+
marking the page you are on, and `aria-current="true"` is what tabs and breadcrumbs use.
|
|
94
|
+
`rel="next"`/`rel="prev"` carry the sequential meaning unambiguously.
|
|
95
|
+
|
|
96
|
+
The scoping is the point. Asking the same question of the whole page is what section B removed.
|
|
121
97
|
|
|
122
98
|
**3. Probe: does scrolling load more? (deterministic measurement)**
|
|
123
99
|
|
|
124
100
|
Scroll the container to its end, wait for readiness (`waitForPageReadiness`,
|
|
125
|
-
`src/utils/page-readiness.ts`), and compare. More
|
|
126
|
-
|
|
101
|
+
`src/utils/page-readiness.ts`), and compare descendant counts. More than before means the list
|
|
102
|
+
appends. Record `> Pagination: infinite`.
|
|
103
|
+
|
|
104
|
+
**Rows are the evidence, not requests.** The same rule the tester follows: a request that
|
|
105
|
+
brings no rows tells you nothing arrived. A page also fires telemetry and prefetches while
|
|
106
|
+
scrolling, so a bare request count would report growth where there is none. `networkRequests`
|
|
107
|
+
is private to `Action` (`src/action.ts:46`) and stays that way — nothing here needs widening.
|
|
108
|
+
|
|
109
|
+
**The scroll goes through `Action`, not through `page.evaluate`.** `deep-analysis.ts` sets the
|
|
110
|
+
precedent at `:405` — `this.explorer.action()`, then `action.attempt(cmd)` per command. Action
|
|
111
|
+
is the only thing that moves the browser (CLAUDE.md glue tiers), and going around it would
|
|
112
|
+
bypass the recorder and state updates. Measurement (row counts, scroll offsets) still uses
|
|
113
|
+
`withPage`, which reads without moving.
|
|
127
114
|
|
|
128
115
|
Then restore `scrollTop` to what it was, so screenshots, coordinates and later research see the
|
|
129
116
|
page as they found it. Scroll position is not app state, so this needs none of the modal
|
|
130
117
|
cleanup `_restorePageState` does in `deep-analysis.ts:453` — there is nothing to reuse there.
|
|
131
118
|
|
|
119
|
+
**Which sections get probed — `Data:` sections are the point.**
|
|
120
|
+
|
|
121
|
+
Researcher is instructed to emit a list of similar data items as a `## Data: <name>` section
|
|
122
|
+
holding a container and a summary line, no table (`src/ai/researcher.ts:502-509`). That is
|
|
123
|
+
precisely where a paginated list lands.
|
|
124
|
+
|
|
125
|
+
But `parseResearchSections` (`src/ai/researcher/parser.ts:100`) filters those out:
|
|
126
|
+
|
|
127
|
+
```js
|
|
128
|
+
.filter((s) => !SKIP_SECTIONS.has(s.name.toLowerCase()) && !s.name.toLowerCase().includes('data:'))
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
`SKIP_SECTIONS` (`:27`) also drops a section literally named `data`. So iterating
|
|
132
|
+
`parseResearchSections` — as `validateContainers` does — would probe every section **except**
|
|
133
|
+
the lists. Nothing else in the codebase parses `Data:` sections today.
|
|
134
|
+
|
|
135
|
+
A new `parseDataSections(markdown): ResearchSection[]` in `parser.ts` returns them: the same
|
|
136
|
+
`parseSections` call, filtered to names beginning with `data:`, reusing
|
|
137
|
+
`extractContainerFromBlockquote` and yielding an empty `elements` array (Data sections carry no
|
|
138
|
+
table by construction). Both parsers stay single-purpose.
|
|
139
|
+
|
|
140
|
+
Steps 1–3 then run over `[...parseResearchSections(text), ...parseDataSections(text)]`. A
|
|
141
|
+
non-Data section can hold a list too, and it costs nothing to include it: step 2 gates it out
|
|
142
|
+
when it does not scroll.
|
|
143
|
+
|
|
132
144
|
**Recorded vocabulary:** `controls` or `infinite`, as a line in the section's container
|
|
133
145
|
blockquote. Nothing is written when a list neither paginates nor grows, which is the common
|
|
134
146
|
case and should stay silent.
|
|
@@ -139,9 +151,9 @@ case and should stay silent.
|
|
|
139
151
|
```
|
|
140
152
|
|
|
141
153
|
**This line has a reader**, because section B injects the rule only when pagination was
|
|
142
|
-
detected, and that decision is code. `extractPaginationFromBlockquote` joins
|
|
143
|
-
`extractContainerFromBlockquote` (`src/ai/researcher/parser.ts:86`) and returns
|
|
144
|
-
|
|
154
|
+
detected, and that decision is code. `extractPaginationFromBlockquote(sectionMarkdown)` joins
|
|
155
|
+
`extractContainerFromBlockquote` (`src/ai/researcher/parser.ts:86`) and returns `'controls'`,
|
|
156
|
+
`'infinite'`, or null — anything else in the line is ignored, keeping the vocabulary closed.
|
|
145
157
|
|
|
146
158
|
That makes `Pagination:` a closed vocabulary read deterministically by code, so the envelope
|
|
147
159
|
checklist from CLAUDE.md applies and holds: read by code, scoped to a section of a state,
|
|
@@ -173,56 +185,30 @@ references. One concern: how a list continues. `measureLayout` in `overlay.ts` i
|
|
|
173
185
|
it is xpath-based and returns a modal-scoring `RegionLayout`, while sections carry CSS
|
|
174
186
|
selectors and need neither.
|
|
175
187
|
|
|
176
|
-
### B. The
|
|
188
|
+
### B. The UI map carries it — nothing is injected per page
|
|
177
189
|
|
|
178
|
-
|
|
179
|
-
|
|
190
|
+
**Superseded during implementation.** The original design injected a `<pagination>` block into
|
|
191
|
+
Tester and Navigator whenever a strategy was detected. That was wrong and is removed.
|
|
180
192
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
193
|
+
The condition was computed from the whole page's HTML, which carries no context. A pager
|
|
194
|
+
anywhere on the page — a sidebar list, a widget behind an open modal, a drawer — told the
|
|
195
|
+
tester "this list pages through a larger collection" whatever it was actually looking at. A
|
|
196
|
+
page-level answer cannot address a question about one list among several.
|
|
185
197
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
198
|
+
The UI map already solves this. Research records `> Pagination:` under the section's own
|
|
199
|
+
container, and the tester already reads the UI map. The fact arrives attached to the list it
|
|
200
|
+
describes, and says nothing about any other list on the page. `actionRule` documents
|
|
201
|
+
`I.scrollTo` for the capability itself (section C), which is genuinely page-independent.
|
|
189
202
|
|
|
190
|
-
|
|
203
|
+
Consequences:
|
|
191
204
|
|
|
192
|
-
`
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
```
|
|
200
|
-
|
|
201
|
-
`infiniteScrollRule`:
|
|
202
|
-
|
|
203
|
-
```
|
|
204
|
-
<pagination>
|
|
205
|
-
This list grows as it is scrolled. If what you need is not on screen, scroll to
|
|
206
|
-
the last item in the list — every scrollable ancestor of that item scrolls, so
|
|
207
|
-
this reaches a list with its own scrollbar.
|
|
208
|
-
|
|
209
|
-
New rows in the aria changes mean more arrived; a request with none means nothing
|
|
210
|
-
was left. Stop on the first attempt that adds no rows: the end of a collection is
|
|
211
|
-
an answer, not a failure.
|
|
212
|
-
</pagination>
|
|
213
|
-
```
|
|
214
|
-
|
|
215
|
-
Both live in `src/ai/rules.ts` as exports. Not `rules/*.md`: those load per agent, and these
|
|
216
|
-
have two consumers.
|
|
217
|
-
|
|
218
|
-
**Reach narrows from the earlier draft.** Composing into `actionRule` would have reached
|
|
219
|
-
Rerunner (`src/ai/rerunner.ts:450`) and Captain web-mode (`src/ai/captain/web-mode.ts:148`) too;
|
|
220
|
-
conditional injection reaches only agents that build per-state context, which is Tester and
|
|
221
|
-
Navigator. That is the cost of making it conditional, and it is the right trade: Rerunner heals
|
|
222
|
-
known steps rather than traversing lists.
|
|
223
|
-
|
|
224
|
-
Constraints both fragments keep: general phrasing, no selectors, no site names, no example
|
|
225
|
-
taken from a debug session, one to three lines per bullet.
|
|
205
|
+
- `paginationRuleFor` and `paginationFromResearch` are gone; `src/ai/rules.ts` gains only the
|
|
206
|
+
scroll commands in section C.
|
|
207
|
+
- `Pagination:` has no code reader, so the envelope checklist does not apply to it — it is
|
|
208
|
+
prompt context, like every other line in the UI map.
|
|
209
|
+
- Marker detection moves in-page, scoped to the container, inside `inspectList`. jsdom is no
|
|
210
|
+
longer used: it was pulled in only to parse whole-page HTML for the injection, and eagerly at
|
|
211
|
+
that, while the single existing use in `src/utils/xpath.ts:94` imports it lazily.
|
|
226
212
|
|
|
227
213
|
### C. `actionRule` documents the gesture
|
|
228
214
|
|
|
@@ -292,9 +278,12 @@ The added/removed split exists inside `diffAriaSnapshots` but is flattened into
|
|
|
292
278
|
|
|
293
279
|
## Testing
|
|
294
280
|
|
|
295
|
-
- Unit coverage for step 0's marker scan: `
|
|
296
|
-
`
|
|
297
|
-
|
|
281
|
+
- Unit coverage for step 0's marker scan: `rel=next/prev` yields `controls`; `role="feed"` and
|
|
282
|
+
`aria-setsize="-1"` yield `infinite`; `aria-current` in any value yields nothing.
|
|
283
|
+
- Unit coverage for `parseDataSections`: a `## Data: Suites List` section with a container is
|
|
284
|
+
returned with its `containerCss`, and `parseResearchSections` still excludes it.
|
|
285
|
+
- Unit coverage for `extractPaginationFromBlockquote`: reads `controls` and `infinite`, returns
|
|
286
|
+
null for an absent line and for any other value.
|
|
298
287
|
- `tests/integration/researcher-sections.test.ts` — `> Pagination: controls` appears for a
|
|
299
288
|
section whose UI map holds next/prev controls, and step 1 is not asked when step 0 already
|
|
300
289
|
answered.
|
|
@@ -315,3 +304,42 @@ The added/removed split exists inside `diffAriaSnapshots` but is flattened into
|
|
|
315
304
|
session-scoped.
|
|
316
305
|
- Virtualized list support.
|
|
317
306
|
- Changing `detectRegion` thresholds.
|
|
307
|
+
- The API signal below — a follow-up branch, not this one.
|
|
308
|
+
|
|
309
|
+
## Follow-up: pagination from the API
|
|
310
|
+
|
|
311
|
+
The DOM says a list continues; the API says **how far**. That number decides whether paging on
|
|
312
|
+
is worth it or the item is not in the collection at all, and nothing in this design can supply
|
|
313
|
+
it. Deferred to its own branch because it depends on the API boat being configured and lands on
|
|
314
|
+
Pilot rather than on Researcher or Tester.
|
|
315
|
+
|
|
316
|
+
**Already in place, verified:**
|
|
317
|
+
|
|
318
|
+
- `XhrCapture.captureReadEndpoint` (`src/api/xhr-capture.ts`) stores GETs as
|
|
319
|
+
`fullUrl = pathname + search`, so the **query string is already captured**.
|
|
320
|
+
- `queryParamNames()` extracts the names and `queryParamHint()` renders them into
|
|
321
|
+
`toEndpointList()` (`src/api/request-store.ts:78`), so fisherman already sees
|
|
322
|
+
`GET /api/items ?page,per_page` in its endpoint list.
|
|
323
|
+
- `askApi` (`src/ai/fisherman/tools.ts:220`) is wired to **Pilot alone**
|
|
324
|
+
(`src/ai/pilot.ts:786`), and `fisherman.lookupData()` issues live read-only requests, reading
|
|
325
|
+
full responses. Totals are reachable today without touching capture.
|
|
326
|
+
|
|
327
|
+
**The gap:** GET response bodies are deliberately dropped (`rawResponseBodyValue = ''`,
|
|
328
|
+
`responseHeaders: {}`), so `total`, `X-Total-Count` and `Link: rel="next"` are not passively
|
|
329
|
+
available. `askApi` re-requests instead, which is why that gap does not block this.
|
|
330
|
+
|
|
331
|
+
**Shape of the follow-up:**
|
|
332
|
+
|
|
333
|
+
1. A Pilot rule saying **when** asking is worth it — after repeated paging or scrolling has not
|
|
334
|
+
produced the target — never "ask about every list". Pilot *guides* while Tester *executes*,
|
|
335
|
+
and judging whether to keep paging is guidance; Pilot's conversation is also the light one,
|
|
336
|
+
so API reasoning belongs there rather than in Tester's ARIA-heavy loop.
|
|
337
|
+
2. A deterministic signal from captured read requests whose query params name a pagination
|
|
338
|
+
scheme (`page`, `offset`, `cursor`, `limit`, `per_page`). Free — the data is already stored,
|
|
339
|
+
and `queryParamNames` already isolates it.
|
|
340
|
+
|
|
341
|
+
**Rejected:** capturing GET response bodies to read totals passively. Bodies are large, reads
|
|
342
|
+
are discarded on purpose, and `askApi` already obtains them on demand.
|
|
343
|
+
|
|
344
|
+
Both must stay optional: `fisherman?.isAvailable()` already returns a clean "no API access,
|
|
345
|
+
judge from the page instead", and a run without an API boat must behave exactly as it does now.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "explorbot",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.8",
|
|
4
4
|
"description": "CLI app built with React Ink, CodeceptJS, and Playwright",
|
|
5
5
|
"license": "Elastic-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -101,7 +101,7 @@
|
|
|
101
101
|
"bash-tool": "^1.3.15",
|
|
102
102
|
"chalk": "^5.6.2",
|
|
103
103
|
"cli-highlight": "^2.1.11",
|
|
104
|
-
"codeceptjs": "4.2.0-beta.
|
|
104
|
+
"codeceptjs": "^4.2.0-beta.3",
|
|
105
105
|
"commander": "^14.0.1",
|
|
106
106
|
"debug": "^4.4.3",
|
|
107
107
|
"dedent": "^1.6.0",
|
package/rules/chief/general.md
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
- Steps should specify exact HTTP methods, paths, and key payload details
|
|
3
3
|
- Expected outcomes should be specific and verifiable (status codes, response fields, error messages)
|
|
4
4
|
- For CRUD operations, each test should handle its own setup and teardown
|
|
5
|
+
- Treat existing records and IDs discovered from the API, knowledge, or sample data as read-only
|
|
6
|
+
- A scenario that updates, patches, deletes, archives, or otherwise mutates a record must create that target inside the same scenario first; omit the scenario if safe setup is impossible
|
|
5
7
|
- Expect standard REST conventions: 200 OK, 201 Created, 204 No Content, 400 Bad Request, 404 Not Found, 422 Unprocessable Entity
|
|
6
8
|
- NEVER propose scenarios that test the same thing. "Create a basic suite" and "Successful creation of a simple suite" are DUPLICATES. Each scenario must test a DISTINCT behavior or aspect.
|
|
7
9
|
- Before finalizing, review all scenarios and remove any that overlap in what they actually verify.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
<pagination>
|
|
2
|
+
When a section is a list that continues beyond what is shown, add one line under its `> Container:` line:
|
|
3
|
+
`> Pagination: controls` — it has page numbers (1, 2, 3), prev/next arrows, or a "load more" button.
|
|
4
|
+
`> Pagination: infinite` — it has none of those and loads more as it is scrolled.
|
|
5
|
+
Omit the line when the items already shown are the whole collection.
|
|
6
|
+
Sorting, filtering and switching tabs are not pagination — omit the line then.
|
|
7
|
+
</pagination>
|
package/src/action-result.ts
CHANGED
|
@@ -45,6 +45,8 @@ export interface PageDiff {
|
|
|
45
45
|
currentUrl: string;
|
|
46
46
|
ariaChanges?: string | null;
|
|
47
47
|
ariaChangeCount?: number;
|
|
48
|
+
ariaAdded?: number;
|
|
49
|
+
ariaRemoved?: number;
|
|
48
50
|
messages?: string[];
|
|
49
51
|
requests?: NetworkCall[];
|
|
50
52
|
consoleErrors?: string[];
|
|
@@ -551,6 +553,8 @@ export class ActionResult implements ActionResultData {
|
|
|
551
553
|
if (diff.ariaChanged) {
|
|
552
554
|
pageDiff.ariaChanges = diff.ariaChanged;
|
|
553
555
|
pageDiff.ariaChangeCount = diff.ariaChangeCount;
|
|
556
|
+
pageDiff.ariaAdded = diff.ariaAdded;
|
|
557
|
+
pageDiff.ariaRemoved = diff.ariaRemoved;
|
|
554
558
|
}
|
|
555
559
|
|
|
556
560
|
if (this.overlay.isOpen && (!previousState.overlay.isOpen || previousState.overlay.name !== this.overlay.name)) {
|
|
@@ -652,6 +656,8 @@ export class Diff {
|
|
|
652
656
|
private _messages: string[] = [];
|
|
653
657
|
private _ariaDiffResult: string | null = null;
|
|
654
658
|
private _ariaChangeCount = 0;
|
|
659
|
+
private _ariaAdded = 0;
|
|
660
|
+
private _ariaRemoved = 0;
|
|
655
661
|
private _isSameUrl: boolean;
|
|
656
662
|
|
|
657
663
|
constructor(
|
|
@@ -709,6 +715,14 @@ export class Diff {
|
|
|
709
715
|
return this._ariaChangeCount;
|
|
710
716
|
}
|
|
711
717
|
|
|
718
|
+
get ariaAdded(): number {
|
|
719
|
+
return this._ariaAdded;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
get ariaRemoved(): number {
|
|
723
|
+
return this._ariaRemoved;
|
|
724
|
+
}
|
|
725
|
+
|
|
712
726
|
get htmlDiff(): HtmlDiffResult | null {
|
|
713
727
|
return this._htmlDiffResult;
|
|
714
728
|
}
|
|
@@ -740,6 +754,8 @@ export class Diff {
|
|
|
740
754
|
const ariaDiff = diffAriaSnapshots(this.previous.ariaSnapshot, this.current.ariaSnapshot);
|
|
741
755
|
this._ariaDiffResult = ariaDiff.text;
|
|
742
756
|
this._ariaChangeCount = ariaDiff.count;
|
|
757
|
+
this._ariaAdded = ariaDiff.added;
|
|
758
|
+
this._ariaRemoved = ariaDiff.removed;
|
|
743
759
|
}
|
|
744
760
|
}
|
|
745
761
|
|
package/src/action.ts
CHANGED
|
@@ -592,10 +592,11 @@ export const attachStepLogger = (target: ExecutedStep[], assertionsTarget?: Arra
|
|
|
592
592
|
}
|
|
593
593
|
tag('step').log(step);
|
|
594
594
|
};
|
|
595
|
-
|
|
595
|
+
const onPassed: StepListener = (step) => listener(step);
|
|
596
|
+
codeceptjs.event.dispatcher.on(codeceptjs.event.step.passed, onPassed);
|
|
596
597
|
codeceptjs.event.dispatcher.on(codeceptjs.event.step.failed, listener);
|
|
597
598
|
return () => {
|
|
598
|
-
codeceptjs.event.dispatcher.off(codeceptjs.event.step.passed,
|
|
599
|
+
codeceptjs.event.dispatcher.off(codeceptjs.event.step.passed, onPassed);
|
|
599
600
|
codeceptjs.event.dispatcher.off(codeceptjs.event.step.failed, listener);
|
|
600
601
|
};
|
|
601
602
|
};
|
package/src/ai/navigator.ts
CHANGED
|
@@ -703,7 +703,7 @@ class Navigator implements Agent {
|
|
|
703
703
|
const cachedVerification = actionResult.getVerification(message);
|
|
704
704
|
if (cachedVerification !== null) {
|
|
705
705
|
tag('operation').log(`Reusing cached verification: ${cachedVerification ? 'PASS' : 'FAIL'}`);
|
|
706
|
-
return { verified: cachedVerification, successfulCodes: [], assertionSteps: [], totalAttempted: 0 };
|
|
706
|
+
return { verified: cachedVerification, inexpressible: false, results: [], successfulCodes: [], assertionSteps: [], totalAttempted: 0 };
|
|
707
707
|
}
|
|
708
708
|
|
|
709
709
|
const knowledge = this.knowledgeTracker.renderRelevantContext(actionResult);
|
|
@@ -832,9 +832,6 @@ class Navigator implements Agent {
|
|
|
832
832
|
observability: {
|
|
833
833
|
agent: 'navigator',
|
|
834
834
|
},
|
|
835
|
-
catch: async (error) => {
|
|
836
|
-
debugLog(error);
|
|
837
|
-
},
|
|
838
835
|
}
|
|
839
836
|
);
|
|
840
837
|
} finally {
|
package/src/ai/pilot.ts
CHANGED
|
@@ -152,14 +152,6 @@ export class Pilot implements Agent {
|
|
|
152
152
|
${sessionLog || 'No actions recorded'}
|
|
153
153
|
</session_log>
|
|
154
154
|
|
|
155
|
-
Decide and commit. "continue" extends the loop and burns iterations — choose it only when
|
|
156
|
-
evidence is genuinely insufficient to call pass/fail, not as a safety hedge.
|
|
157
|
-
- "pass" if final state proves the SCENARIO GOAL is accomplished. Set requestVerification.
|
|
158
|
-
- "fail" if scenario was attempted but goal not achieved.
|
|
159
|
-
- "skipped" if scenario is irrelevant/inapplicable, OR systematic infrastructure failures.
|
|
160
|
-
- "continue" only when a concrete missing piece of evidence (a verify/see) would change your verdict.
|
|
161
|
-
- Mixed evidence + final state shows success → pass. Mixed + final state unclear → continue with guidance.
|
|
162
|
-
|
|
163
155
|
When deciding "pass", you MUST also set requestVerification to a one-sentence natural-language
|
|
164
156
|
claim about the current page (e.g., "New item Foo is visible in the items list"). NOT
|
|
165
157
|
code — do not write I.*, expect(), .then(), or any JavaScript. Choose the strongest single
|
|
@@ -401,7 +393,7 @@ export class Pilot implements Agent {
|
|
|
401
393
|
private buildVerdictSystemPrompt(task: Test): string {
|
|
402
394
|
return dedent`
|
|
403
395
|
You are Pilot — final decision maker for test pass/fail. Review the evidence and commit to a
|
|
404
|
-
verdict
|
|
396
|
+
verdict.
|
|
405
397
|
|
|
406
398
|
${capabilityGroundingRule}
|
|
407
399
|
|
|
@@ -415,10 +407,11 @@ export class Pilot implements Agent {
|
|
|
415
407
|
DOM assertion can't be made.
|
|
416
408
|
Do not pass when Tester achieved only a related navigation/filter/tab/status outcome instead of the
|
|
417
409
|
requested action, workflow, or entity detail goal.
|
|
418
|
-
- "fail":
|
|
410
|
+
- "fail": goal not achieved and no further step toward it is available on the current page.
|
|
419
411
|
- "skipped": scenario is irrelevant to the app, OR systematic infrastructure failures (LLM errors,
|
|
420
412
|
crashes) prevented testing. NOT for "test failed to interact" — that's "fail" or "continue".
|
|
421
|
-
- "continue":
|
|
413
|
+
- "continue": goal incomplete but the control for the NEXT step is present on the current page, or a
|
|
414
|
+
concrete missing check would change your verdict. Guidance must name that step.
|
|
422
415
|
If a verify() asserted a state that was ALREADY TRUE before the test, it proves nothing — reject.
|
|
423
416
|
|
|
424
417
|
reason field: one short sentence, maximum 120 characters. Do NOT restate the decision
|
|
@@ -1134,7 +1127,9 @@ export class Pilot implements Agent {
|
|
|
1134
1127
|
${interactive ? '- Use askUser() only as last resort.' : ''}
|
|
1135
1128
|
|
|
1136
1129
|
Diagnostic patterns (use <state>, executed/element/skipped fields, ariaDiff):
|
|
1137
|
-
-
|
|
1130
|
+
- Scenario's target control in "disabled buttons" → a precondition is unmet; identify which before acting.
|
|
1131
|
+
Other disabled controls often name the unsatisfied constraint; "active form" marks [required] fields.
|
|
1132
|
+
Aim Tester at the constraint the page names, not the one the scenario assumed — note the difference in PROGRESS.
|
|
1138
1133
|
- "overlay: none" but Tester targets an overlay → overlay closed; re-trigger.
|
|
1139
1134
|
- "region:" in <state> → a large area appeared in place without navigation (subview, wizard step, panel). Direct Tester to act inside it; the rest of the page is still usable.
|
|
1140
1135
|
- Action SUCCESS but ariaDiff empty → may have worked without visible DOM change; check result message.
|
|
@@ -1156,6 +1151,7 @@ export class Pilot implements Agent {
|
|
|
1156
1151
|
Tester tools: click, pressKey, form, see, verify, interact, context, research, xpathCheck,
|
|
1157
1152
|
visualClick, back, getVisitedStates, reset, stop, finish, record.
|
|
1158
1153
|
Use tool names exactly as listed. Do not invent combined names or aliases.
|
|
1154
|
+
Reloading is not a tool: to re-read a page from the server, instruct Tester to run I.reloadPage() through form.
|
|
1159
1155
|
|
|
1160
1156
|
${capabilityGroundingRule}
|
|
1161
1157
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type Plan, type Test, TestResult } from '../../test-plan.ts';
|
|
2
2
|
import type { Constructor } from '../researcher/mixin.ts';
|
|
3
3
|
|
|
4
4
|
const previousPlans: Plan[] = [];
|
|
@@ -18,7 +18,7 @@ export function WithSessionDedup<T extends Constructor>(Base: T) {
|
|
|
18
18
|
for (const plan of previousPlans) {
|
|
19
19
|
if (plan === this.currentPlan) continue;
|
|
20
20
|
for (const test of plan.tests) {
|
|
21
|
-
lines.push(
|
|
21
|
+
lines.push(formatSessionTest(plan, test));
|
|
22
22
|
}
|
|
23
23
|
}
|
|
24
24
|
return lines.join('\n');
|
|
@@ -34,6 +34,20 @@ export function WithSessionDedup<T extends Constructor>(Base: T) {
|
|
|
34
34
|
};
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
export function formatSessionTest(plan: Plan, test: Test): string {
|
|
38
|
+
const lastNote = Object.values(test.notes)
|
|
39
|
+
.filter((note) => note.message)
|
|
40
|
+
.pop();
|
|
41
|
+
let outcome: string | null = test.result;
|
|
42
|
+
if (!outcome) outcome = 'pending';
|
|
43
|
+
if (!test.result && lastNote) outcome = 'unfinished';
|
|
44
|
+
|
|
45
|
+
const line = `${plan.url || '/'} | ${test.style || 'default'} | ${outcome} | ${test.scenario}`;
|
|
46
|
+
if (!lastNote) return line;
|
|
47
|
+
if (outcome !== TestResult.FAILED && outcome !== 'unfinished') return line;
|
|
48
|
+
return `${line} — ${lastNote.message.slice(0, 140)}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
37
51
|
export function clearSessionDedup(): void {
|
|
38
52
|
previousPlans.length = 0;
|
|
39
53
|
}
|
package/src/ai/planner.ts
CHANGED
|
@@ -358,8 +358,10 @@ export class Planner extends PlannerBase implements Agent {
|
|
|
358
358
|
You can suggest scenarios that can be tested only through web interface.
|
|
359
359
|
You can't test emails, database, SMS, or any external services.
|
|
360
360
|
Suggest scenarios that can be potentially verified by UI.
|
|
361
|
-
|
|
362
|
-
|
|
361
|
+
Prefer outcomes grounded in observed interface behavior.
|
|
362
|
+
Every expected outcome must be verifiable through the web interface.
|
|
363
|
+
If a page or subpage has not been observed, describe the expected visible result generically instead of inventing interface details.
|
|
364
|
+
Persistency after a reload counts only when the persisted state can be confirmed through the interface.
|
|
363
365
|
If there are subpages (pages with same URL path) plan testing of those subpages as well
|
|
364
366
|
Plan CRUD operations in order: create, read, update, delete.
|
|
365
367
|
Do not invent specific route names, success messages, validation texts, badge counts, or welcome messages unless they are visible in research, visited pages, or prior observed flows.
|
|
@@ -370,7 +372,7 @@ export class Planner extends PlannerBase implements Agent {
|
|
|
370
372
|
If a scenario needs existing records, recipients, results, notifications, or other target data, propose it only when that data is visible, API preconditions can create it, or the scenario itself creates the record as its setup.
|
|
371
373
|
If the page appears read-only, degraded, demo-limited, maintenance-like, or lacks write controls, prefer read-only scenarios such as opening panels, inspecting visible lists, filtering, searching, or verifying current state.
|
|
372
374
|
Do not assume hidden data exists just because a control is present.
|
|
373
|
-
|
|
375
|
+
Do not put record IDs or unique record names in test plans. Describe which record is needed and let Pilot choose it during execution; name a specific record only when research shows a small, complete list of available records.
|
|
374
376
|
If the list is empty or no concrete item names are visible, do not invent "known" or "existing" items. Prefer empty-state, no-match search, clear-search, or read-only list behavior scenarios.
|
|
375
377
|
Search, filter, sorting, tab, and list scenarios must start from a stable page where those controls are visible; avoid transient create/edit/new URLs unless the scenario tests that form.
|
|
376
378
|
For option values and list items, use only visible or previously observed data; do not add create/update/delete setup unless the user explicitly requests that workflow.
|
|
@@ -452,12 +454,16 @@ export class Planner extends PlannerBase implements Agent {
|
|
|
452
454
|
}
|
|
453
455
|
}
|
|
454
456
|
|
|
457
|
+
let activeRegion = '';
|
|
458
|
+
if (state.overlay.isOpen) activeRegion = `Active region: ${state.overlay.describe()} — the user's current focus area. Plan tests for the controls inside it first.`;
|
|
459
|
+
|
|
455
460
|
conversation.addUserText(dedent`
|
|
456
461
|
${this.buildApproach(style)}
|
|
457
462
|
|
|
458
463
|
<context>
|
|
459
464
|
URL: ${state.url || 'Unknown'}
|
|
460
465
|
Title: ${state.title || 'Unknown'}
|
|
466
|
+
${activeRegion}
|
|
461
467
|
</context>
|
|
462
468
|
`);
|
|
463
469
|
|
|
@@ -591,7 +597,9 @@ export class Planner extends PlannerBase implements Agent {
|
|
|
591
597
|
const sessionTests = this.getSessionTestsSummary();
|
|
592
598
|
if (sessionTests) {
|
|
593
599
|
conversation.addUserText(dedent`
|
|
594
|
-
Tests already planned in this session across all pages. DO NOT duplicate any of these
|
|
600
|
+
Tests already planned in this session across all pages, with how each one ended. DO NOT duplicate any of these.
|
|
601
|
+
A failed test means the app or the harness could not do what it tried: do not re-propose the same behavior on another page unless you can name what makes it work this time.
|
|
602
|
+
A failed or unfinished test carries the last thing it observed after the dash — read it before deciding that the behavior is worth trying again.
|
|
595
603
|
|
|
596
604
|
<session_tests>
|
|
597
605
|
${sessionTests}
|
package/src/ai/provider.ts
CHANGED
|
@@ -38,6 +38,11 @@ function createHarmonyChannelFallbackTool() {
|
|
|
38
38
|
});
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
function withHarmonyChannelFallback(tools: any): any {
|
|
42
|
+
if (tools?.commentary) return tools;
|
|
43
|
+
return { ...tools, commentary: createHarmonyChannelFallbackTool() };
|
|
44
|
+
}
|
|
45
|
+
|
|
41
46
|
let telemetryRegistered = false;
|
|
42
47
|
let beforeExitFlushHooked = false;
|
|
43
48
|
let activeOtelSdk: NodeSDK | null = null;
|
|
@@ -410,7 +415,7 @@ export class Provider {
|
|
|
410
415
|
promptLog(`Using model: ${modelName}`);
|
|
411
416
|
|
|
412
417
|
let toolsWithCommentary = tools;
|
|
413
|
-
if (
|
|
418
|
+
if (options.toolChoice !== 'required') toolsWithCommentary = withHarmonyChannelFallback(tools);
|
|
414
419
|
const toolNames = Object.keys(toolsWithCommentary || {});
|
|
415
420
|
tag('debug').log(`Tools enabled: [${toolNames.join(', ')}]`);
|
|
416
421
|
promptLog('Available tools:', toolNames);
|
|
@@ -420,9 +425,10 @@ export class Provider {
|
|
|
420
425
|
const extraStop = options.stopWhen;
|
|
421
426
|
const stopConditions: any[] = [isStepCount(maxRoundtrips)];
|
|
422
427
|
if (extraStop) stopConditions.push(extraStop);
|
|
423
|
-
|
|
428
|
+
let config = this.buildGenerateConfig({ tools: toolsWithCommentary, maxOutputTokens: 16384, toolChoice: 'auto', experimental_repairToolCall: repairToolCall }, { stopWhen: stopConditions, model }, options);
|
|
424
429
|
let attemptMessages = messages;
|
|
425
430
|
let invalidRequestFeedbackAdded = false;
|
|
431
|
+
let requiredToolChoiceRelaxed = false;
|
|
426
432
|
const executedStepMessages: ModelMessage[] = [];
|
|
427
433
|
try {
|
|
428
434
|
let response = await this.withModelRequestSlot(() =>
|
|
@@ -442,6 +448,11 @@ export class Provider {
|
|
|
442
448
|
invalidRequestFeedbackAdded = amended !== attemptMessages;
|
|
443
449
|
attemptMessages = amended;
|
|
444
450
|
}
|
|
451
|
+
if (!requiredToolChoiceRelaxed && isRequiredToolChoiceError(error)) {
|
|
452
|
+
requiredToolChoiceRelaxed = true;
|
|
453
|
+
config = { ...config, tools: withHarmonyChannelFallback(tools), toolChoice: 'auto' };
|
|
454
|
+
tag('warning').log('Provider rejected required tool choice — retrying with automatic tool choice and channel fallback');
|
|
455
|
+
}
|
|
445
456
|
throw error;
|
|
446
457
|
})) as any;
|
|
447
458
|
this.recordUsage(options.agentName || 'unknown', modelName, result.usage);
|
|
@@ -470,7 +481,7 @@ export class Provider {
|
|
|
470
481
|
return response;
|
|
471
482
|
} catch (error: any) {
|
|
472
483
|
clearActivity();
|
|
473
|
-
if (error
|
|
484
|
+
if (isRequiredToolChoiceError(error)) {
|
|
474
485
|
return { text: '', toolCalls: [], toolResults: [], responseMessages: executedStepMessages, usage: null };
|
|
475
486
|
}
|
|
476
487
|
if (error?.name === 'AbortError') throw error;
|
|
@@ -734,6 +745,10 @@ function withInvalidRequestFeedback(messages: ModelMessage[], error: unknown): M
|
|
|
734
745
|
];
|
|
735
746
|
}
|
|
736
747
|
|
|
748
|
+
function isRequiredToolChoiceError(error: unknown): boolean {
|
|
749
|
+
return error instanceof Error && error.message.includes('Tool choice is required');
|
|
750
|
+
}
|
|
751
|
+
|
|
737
752
|
function repairChannelMarker({ toolCall, tools }: ToolCallRepairOptions): any | null {
|
|
738
753
|
const markerIndex = toolCall.toolName.indexOf('<|channel|>');
|
|
739
754
|
if (markerIndex <= 0) return null;
|
|
@@ -304,7 +304,7 @@ export function WithLocators<T extends Constructor>(Base: T) {
|
|
|
304
304
|
if (sectionQuery.count() === 0) sectionQuery = mdq(result.text).query(`section3(~"${escaped}")`);
|
|
305
305
|
|
|
306
306
|
if (newCss) {
|
|
307
|
-
result.text = sectionQuery.query('blockquote[0]').
|
|
307
|
+
result.text = sectionQuery.query('blockquote[0]').setKeyValue('Container', `'${newCss}'`);
|
|
308
308
|
} else {
|
|
309
309
|
result.text = sectionQuery.query('blockquote[0]').replace('');
|
|
310
310
|
result.text = result.text.replace(`${FOCUSED_MARKER}\n`, '');
|