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.
Files changed (74) hide show
  1. package/boat/api-tester/src/ai/chief.ts +3 -1
  2. package/boat/api-tester/src/ai/curler.ts +74 -66
  3. package/boat/api-tester/src/apibot.ts +1 -0
  4. package/boat/api-tester/src/cli.ts +2 -0
  5. package/boat/api-tester/src/config.ts +18 -1
  6. package/dist/boat/api-tester/src/ai/chief.js +3 -1
  7. package/dist/boat/api-tester/src/ai/curler.js +59 -56
  8. package/dist/boat/api-tester/src/apibot.js +1 -0
  9. package/dist/boat/api-tester/src/cli.js +2 -0
  10. package/dist/boat/api-tester/src/config.js +3 -1
  11. package/dist/package.json +2 -2
  12. package/dist/rules/chief/general.md +2 -0
  13. package/dist/rules/researcher/pagination.md +7 -0
  14. package/dist/src/action-result.d.ts +6 -0
  15. package/dist/src/action-result.js +12 -0
  16. package/dist/src/action.js +3 -2
  17. package/dist/src/ai/navigator.js +1 -4
  18. package/dist/src/ai/pilot.js +8 -12
  19. package/dist/src/ai/planner/session-dedup.d.ts +2 -1
  20. package/dist/src/ai/planner/session-dedup.js +18 -1
  21. package/dist/src/ai/planner.js +12 -4
  22. package/dist/src/ai/provider.js +18 -4
  23. package/dist/src/ai/researcher/locators.js +1 -1
  24. package/dist/src/ai/researcher/pagination.d.ts +16 -0
  25. package/dist/src/ai/researcher/pagination.js +62 -0
  26. package/dist/src/ai/researcher/parser.d.ts +3 -0
  27. package/dist/src/ai/researcher/parser.js +22 -6
  28. package/dist/src/ai/researcher/sections.js +1 -1
  29. package/dist/src/ai/researcher.js +7 -2
  30. package/dist/src/ai/rules.js +17 -0
  31. package/dist/src/ai/scout.js +8 -2
  32. package/dist/src/ai/tester.js +1 -1
  33. package/dist/src/ai/tools.js +12 -4
  34. package/dist/src/commands/options/ws-option.d.ts +7 -0
  35. package/dist/src/commands/options/ws-option.js +14 -0
  36. package/dist/src/config.d.ts +1 -0
  37. package/dist/src/config.js +14 -11
  38. package/dist/src/remote.d.ts +2 -0
  39. package/dist/src/remote.js +23 -16
  40. package/dist/src/utils/aria.d.ts +2 -0
  41. package/dist/src/utils/aria.js +6 -1
  42. package/dist/src/utils/code-extractor.js +6 -2
  43. package/dist/src/utils/markdown-query.d.ts +2 -0
  44. package/dist/src/utils/markdown-query.js +39 -0
  45. package/dist/src/utils/pagination.d.ts +16 -0
  46. package/dist/src/utils/pagination.js +20 -0
  47. package/docs/superpowers/plans/2026-09-10-pagination.md +1420 -0
  48. package/docs/superpowers/specs/2026-09-09-pagination-rule-design.md +125 -97
  49. package/package.json +2 -2
  50. package/rules/chief/general.md +2 -0
  51. package/rules/researcher/pagination.md +7 -0
  52. package/src/action-result.ts +16 -0
  53. package/src/action.ts +3 -2
  54. package/src/ai/navigator.ts +1 -4
  55. package/src/ai/pilot.ts +8 -12
  56. package/src/ai/planner/session-dedup.ts +16 -2
  57. package/src/ai/planner.ts +12 -4
  58. package/src/ai/provider.ts +18 -3
  59. package/src/ai/researcher/locators.ts +1 -1
  60. package/src/ai/researcher/pagination.ts +68 -0
  61. package/src/ai/researcher/parser.ts +23 -5
  62. package/src/ai/researcher/sections.ts +1 -1
  63. package/src/ai/researcher.ts +9 -3
  64. package/src/ai/rules.ts +17 -0
  65. package/src/ai/scout.ts +9 -2
  66. package/src/ai/tester.ts +1 -1
  67. package/src/ai/tools.ts +9 -4
  68. package/src/commands/options/ws-option.ts +14 -0
  69. package/src/config.ts +15 -11
  70. package/src/remote.ts +22 -15
  71. package/src/utils/aria.ts +8 -1
  72. package/src/utils/code-extractor.ts +6 -2
  73. package/src/utils/markdown-query.ts +39 -0
  74. package/src/utils/pagination.ts +36 -0
@@ -22,12 +22,12 @@ untouched.
22
22
 
23
23
  ## Approach
24
24
 
25
- Detection finds out which strategy a list uses; a rule tells the tester what to do about it,
26
- and is injected only when there is something to say. No new tool: the gesture already exists in
27
- CodeceptJS and is reachable through `form`.
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
- Splitting it that way is what keeps the rule short. The tester never has to discover anything
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
- Only asked when step 0 found nothing. Controls are named in open-ended ways — words, arrows,
104
- bare numbers — so this is AI judgment, not a pattern match. Researcher is already describing
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. Can the section scroll at all? (deterministic gate)**
81
+ **2. Markers and the scroll gate one `page.evaluate` per container (`inspectList`)**
113
82
 
114
- Only asked when no controls were found. One `page.evaluate` per container:
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
- - `el.scrollHeight > el.clientHeight`the container has its own scroller.
117
- - `el.getBoundingClientRect().bottom > innerHeight` the list continues below the fold.
118
- - Neither nothing more to do; no line recorded.
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
- This gate exists to keep step 3 from running on every short list.
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 descendant rows than before, or a same-origin
126
- xhr/fetch fired during the scroll, means the list appends. Record `> Pagination: infinite`.
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 the recorded
144
- value or null.
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 pagination rule, injected only when pagination was detected
188
+ ### B. The UI map carries it nothing is injected per page
177
189
 
178
- Not part of the static system message. A page with no list should not carry list guidance, and
179
- a page with page numbers should not be told how to scroll.
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
- **Seam:** `reinjectContextIfNeeded` (`src/ai/tester.ts:554`), which already injects per-state
182
- blocks conditionally `focusedElementRule` when something is focused, an `<overlay>` block
183
- when a region is open. A `<pagination>` block joins them. Navigator gets the same block where
184
- it builds its own per-state context (`src/ai/navigator.ts:414`).
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
- **Condition:** the state shows pagination if step 0's markers are present in the current HTML,
187
- or `extractPaginationFromBlockquote` finds a recorded value for a section. Markers win when
188
- both are available, since they describe the page as it is now rather than as research left it.
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
- **Which text:** the strategy selects the fragment, so the model is never shown the other one.
203
+ Consequences:
191
204
 
192
- `paginationControlsRule`:
193
-
194
- ```
195
- <pagination>
196
- This list pages through a larger collection. If what you need is not on screen,
197
- click next or the page number you need before concluding it is absent.
198
- </pagination>
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: `aria-current="page"` and `rel=next/prev` yield
296
- `controls`; `role="feed"` and `aria-setsize="-1"` yield `infinite`; `aria-current="true"` on a
297
- tab list yields nothing.
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.6",
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.2",
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",
@@ -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>
@@ -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
- codeceptjs.event.dispatcher.on(codeceptjs.event.step.passed, listener);
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, listener);
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
  };
@@ -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; "continue" only when evidence is genuinely insufficient.
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": scenario was attempted but the goal was not achieved.
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": tester hasn't completed the goal; provide concrete guidance (which tool, what to check).
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
- - Click failed + button in "disabled buttons" → required field missing. Instruct fill first.
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 { Plan } from '../../test-plan.ts';
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(`${plan.url || '/'} | ${test.style || 'default'} | ${test.scenario}`);
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
- Focus on error or success messages as outcome.
362
- Focus on URL page change or data persistency after page reload.
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
- For scenarios that act on existing items or search/filter by existing values, use only item names or values visible in research, visited pages, or prior observed flows.
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}
@@ -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 (!tools?.commentary && options.toolChoice !== 'required') toolsWithCommentary = { ...tools, commentary: createHarmonyChannelFallbackTool() };
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
- const config = this.buildGenerateConfig({ tools: toolsWithCommentary, maxOutputTokens: 16384, toolChoice: 'auto', experimental_repairToolCall: repairToolCall }, { stopWhen: stopConditions, model }, options);
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?.message?.includes('Tool choice is required')) {
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]').replace(`Container: '${newCss}'`);
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`, '');