Skip to content

prima check: run a scenario end to end and report what it proved - #110

Open
DavertMik wants to merge 30 commits into
mainfrom
fix/prima-executor
Open

prima check: run a scenario end to end and report what it proved#110
DavertMik wants to merge 30 commits into
mainfrom
fix/prima-executor

Conversation

@DavertMik

@DavertMik DavertMik commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Field-tested prima against a live app (review) and fixed what that turned up, then benchmarked it head-to-head against playwright-cli until the numbers moved. Spec: 2026-08-07-prima-fixes-design.md.

The bug that motivated this

prima pw '({page}) => page.click("[data-test=nonexistent-thing]")'
→ ok: true
  healed: true (recovered after 1 attempt)
  used: I.click({ role: "button", text: "New agent" })

The element did not exist. Heal picked an unrelated control, clicked it, and reported success — so ok: true did not mean your own action landed. Heal is removed outright, along with --no-heal, the healed: line and the healing-attempts block. No code path can now reach an element other than the one asked for.

→ ok: false, 11.3s, exact error + compact ARIA inline

The tier that pays: do and check

The point of prima is that the expensive model outside it never sees a page. That only works if one prima call can carry a whole sequence, so both long-running commands now do.

do has the same toolset a test run has — act, look, assert — and ends where the instructions end. A done() step closes the sequence and names the instructions it could not carry out, which fail the command. Before this it kept acting past the last step and could report ok: true while a check it had been asked to make never held.

check <scenario> spawns a full test run: it drives the page, verifies the outcome itself, and reports every step with its proof.

prima check "signup rejects a duplicate email" \
  --expected "an error names the email as taken" \
  --expected "no second account is created"

Each --expected comes back under ### Expected outcomes as PASSED, FAILED or not verified. "not verified" is deliberately not "false" — it means nothing checked it. That mapping only works if the run records an outcome in its own words, so the prompt now says an expected result is settled only when it is repeated word for word; measured live, that moved both outcomes of a real scenario from not verified to a real verdict.

click and fill are gone — one-action-per-process was the shape that lost to playwright-cli.

Envelopes carry proof, not noise

### Changes renders on every action. It was structurally impossible alongside an answer, research map or verdict, so a successful click proved nothing.

used: I.click("//*[@id=\"base-ui-_R_qkmpbmulb_\"]")

### Changes
ariaDiff:
  toggled:
    - button "Project": unpressed -> pressed
  typed:
    - textbox "Title": empty -> "Quarterly report"

The typed: section is new — filling a form used to diff as no change at all. Long values are cut to an excerpt with a pointer to the full text; on a page holding a document that took one envelope from 23,176 B to 4,873 B.

pw returns its expression's value under ### Value — a page.title() or locator.count() used to run and have its answer discarded. verify lists every assertion with its own PASSED/FAILED and the Playwright form of the ones that held, and gives no overall verdict; the caller reads the lines and decides.

Everything else is behind prima status <hash>, printed on each envelope's ### Instance line. Prima commands print the envelope and nothing else — the banner, config line and browser chatter were 22% of what a caller was paying for.

Verdicts belong to review, not to a loop counter

A test that stopped making progress was marked FAILED on the spot by a stall counter. A run that had already done its work and gone quiet was therefore reported as a failure — which is exactly what check then handed back. The guard now stops the loop and hands the task to final review; the verdict comes from reviewing the result. Console and network errors are recorded as page problems rather than failed steps, so they no longer sink a passing test.

Also fixed, each found live

Attach Discovery keyed on a workspaceDir field no @playwright/cli release writes (checked 0.1.13 and 0.1.17), so prima reported no browser while a session was open and told you to open the one you already had. Attached sessions now connect through the daemon's own Playwright build — connect() succeeds across builds but ariaSnapshot does not. Pin moved to ^1.62.
Page readiness networkidle never fires on an app with a live websocket, so every capture paid the full 6s timeout. Readiness now also completes when the DOM goes quiet: 6,034ms → 25ms per capture.
Compaction Only the first bracket group survived, so every [pressed] / [disabled] / [checked] control lost the rest of its attributes.
Redirects A redirect that only appends query params (//?session=…&ws=1) counted as failed navigation: 3m00s of retries, or a hard failure, on a page that had loaded correctly. Now 22s.
verify Reported claims it could not phrase as failing checks, and remembered them as such. Now separates the two. The rule that taught it to assert control state was itself wrong — { disabled: null } matches nothing — and is replaced by attribute selectors probed against a live page.
Generated tests Visibility, hidden state and field values are emitted as real Playwright locators instead of a // TODO.

Testing

901 unit + 78 integration + 101 prima tests pass, 0 fail (2 pre-existing file-level errors in the unit scope, identical on a clean tree). New coverage for done() ending the loop, unmet instructions failing the command, a check that fails then passes on retry, expectation mapping including the unverified case, typed-value diffing, value offloading, and DOM-quiet readiness. The heal integration suite is deleted with the feature.

Verified end to end against a live app after building dist, not from source.

@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown

Explorbot Self-Regression

Commit e18e545 · run

Scenario Result Attempts Duration
basic (native) PASS 3/3 19m
experience: control OK — failed as expected 1/1 1m
experience: seeded PASS 1/3 2m

Attempt details

  • basic (native) attempt 1 — FAIL: login evidence: PASS (post-login plan=true, post-login research=true); research: PASS (files=6, wellFormed=true, keywords=6/3); scenarios: PASS (tests=6/5, features=4/3); tests passed: FAIL (4 passed, 1 failed (reporter: 4 passed, 1 failed))
  • basic (native) attempt 2 — FAIL: login evidence: PASS (post-login plan=true, post-login research=true); research: PASS (files=5, wellFormed=true, keywords=7/3); scenarios: PASS (tests=18/5, features=5/3); tests passed: FAIL (4 passed, 1 failed (reporter: 4 passed, 1 failed))
  • basic (native) attempt 3 — PASS: login evidence: PASS (post-login plan=true, post-login research=true); research: PASS (files=6, wellFormed=true, keywords=7/3); scenarios: PASS (tests=7/5, features=3/3); tests passed: PASS (5 passed, 0 failed (reporter: 5 passed, 0 failed))
  • experience: control attempt 1 — PASS: control: OK — failed as expected (0 passed, 1 failed)
  • experience: seeded attempt 1 — PASS: seeded: PASS (1 passed, 0 failed)

Session analysis — basic (native):

Session Analysis

The Issues feature is functional: users can create bug/feature issues, search by title, filter by label, and view issue details. All core flows work. Test automation has reliability issues with initial click actions, requiring retries or workarounds, but the product behaves correctly.

Coverage

  • Pages: /issues, /issues?new=1, /issues/7
  • Features: Create issue, search issues, filter by label, view issue details

What works

  • Create bug issueET-1 Create a new bug issue and verify it appears in the list
  • Create feature issueET-2 Create a new feature issue and verify it appears in the list
  • Search by titleET-3 Search for an issue by title and verify filtered results
  • Filter by labelET-4 Filter issues by label and verify results
  • View issue detailsET-5 Navigate to issue detail page from the list

Execution Issues

  • ET-1 — initial click on New Issue button failed, then succeeded via toolbar selector
  • ET-2 — click on New Issue link failed, bypassed with direct URL navigation
  • ET-4 — label filter dropdown failed to open multiple times, succeeded via summary element
  • ET-5 — initial click on issue link failed, succeeded on second attempt

A `pw` call on a selector that did not exist returned ok: true, healed: true,
having clicked an unrelated control. Heal is removed outright — with it the
--no-heal flag, the healed: line and the healing-attempts block — so no code
path can reach an element other than the one asked for.

`do` now drives by the refs Playwright puts in the page snapshot. New clickRef
and hoverRef tools in boat/prima/src/tools.ts take a ref, resolve it through
WebElement to an attribute-based clickXPath, and require exactly one match. One
instruction becomes one command instead of a fallback ladder, and a stale ref is
reported as stale rather than replaced by a guess.

click and hover in src/ai/tools.ts are untouched. A tool's schema is shared with
every caller, and Tester never receives ref-bearing snapshots, so it must not be
shown a ref parameter it could only fill by inventing one. The ref tools live in
the boat that uses them; core only lends its result-shaping helpers so they are
not copied. A new context() tool returns the page with fresh refs when one dies
mid-run, and drops to raw markup on a second call.

### Changes now renders on every action, including "no change" — it was
structurally impossible alongside an answer, a research map or a verdict, so a
successful click proved nothing. Refs never reach the diff or state hash: an
identical page whose refs merely renumber produced six phantom entries.

Also fixed, each found against a live app:

- Attach discovery keyed on a workspaceDir field no release of @playwright/cli
  writes, so prima reported no browser while a session was open. Attached
  sessions now connect through the daemon's own Playwright build; connect
  succeeds across builds but ariaSnapshot does not.
- ARIA compaction kept only the first bracket group, so every control carrying
  [pressed], [disabled] or [checked] lost its ref — exactly the controls worth
  acting on.
- A redirect that only appends query parameters counted as failed navigation,
  costing three minutes of retries on a page that had loaded correctly.
- verify reported claims it could not phrase as failing checks, and remembered
  them as such. It now separates the two, and can assert control state.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`prima check <scenario>` spawns a full test run — it drives the page,
verifies the outcome itself and reports every step with its proof. Each
--expected outcome comes back as PASSED, FAILED or not verified, so a
caller sees the mapping instead of a bare ok flag.

`prima do` now ends where the instructions end. A done() step closes the
sequence, names the instructions it could not carry out, and fails the
command for them; it used to keep acting past the last step and could
report success while a check it was asked to make never held.

A test that stops making progress is handed to final review rather than
marked failed on the spot, and console/network errors are recorded as
page problems instead of failed steps — both used to sink runs that had
already done their work.

Also: pw returns its value, verify lists each assertion with its own
result, aria diffs report typed values, long values are offloaded to an
excerpt, pages are ready when the DOM goes quiet, and prima prints the
envelope and nothing else.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@DavertMik DavertMik changed the title Make prima an executor you can trust: no substitution, refs, proofs prima check: run a scenario end to end and report what it proved Aug 9, 2026
An instruction satisfied by absence — "dismiss the banner if one appeared"
— or one that only verifies leaves no executed command behind. The
"no action was performed" guard fired on both and reported failure for a
sequence the model had closed cleanly. It now applies only when the model
ended without calling done().

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
do had no idea which instruction it was on. It re-attempted satisfied
steps, kept acting past the end of the sequence, and its one self-report
at the end could claim a success it never earned — measured against a
live app, four do calls spent 227s of a 245s run thrashing.

Instructions now carry numbers that never change. completed() reports the
ones just satisfied, blocked() reports one the page cannot do, and what is
still open is re-stated after each turn. The loop ends when nothing is
open. Prima staples the actions it actually executed to each report, so a
completed instruction with an empty trail is visible as such — an
instruction satisfied by absence still passes, since conditionals
legitimately complete without acting.

### Steps is now one line per instruction with its proof, and an
instruction nobody reported is named as unaccounted for rather than
silently passing. A stray failed action no longer fails a sequence whose
instructions all closed.

Also: prima reads the attached session's own URL, so --url is no longer
required on every command; --pw-session and --url parse before the
subcommand too; cached research is returned without a banner telling the
model to refresh it; help leads with check and orders the tiers by what
they cost.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A test that achieved all its expectations was never given a result. Test
.hasFinished is true once every expectation carries a status, so the loop
broke, finishTest saw a finished task and never called finish(), and the
run reported success: false on a scenario that fully passed. The dead
"Test with no result" log branch existed because this state was reachable.

That is what made prima check print ok: false over six PASSED outcomes,
and it is the same null status that crashed the HTML reporter on
test.status.toLowerCase() after every check run.

finishTest now decides from what the run achieved: all expectations met
passes, anything short fails.

do re-states what is still open on every turn rather than only when the
list changes — a model that acted without reporting was otherwise never
reminded, and its instruction came back as unaccounted for despite the
envelope proving it landed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The help was the single largest thing prima put in a caller's context —
6,718 bytes, 56% of everything an agent read across a whole benchmark run,
for one call. Per-command semantics now live under each subcommand's own
--help, leaving the top level with the tiers, the envelope shape and the
session default: 4,439 bytes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
pressKey refused every key that needs focus — Enter, End, Home, arrows,
Backspace, Delete, single characters — because it decided focus by
regexing [focused] out of the cached ARIA snapshot, and Playwright's
ariaSnapshot never emits [focused] in either plain or ai mode. Probed
against a live page: a button that IS document.activeElement produces
zero lines containing "focus". The guard could therefore never pass, and
only the focus-free keys (Escape, Tab, F-keys) ever ran.

That is what cost the last benchmark three editor close/reopen cycles and
four failed attempts to move a caret to end of content.

Focus is now read from document.activeElement at the moment of the press,
and an unreadable page lets the press through rather than blocking it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Tester's <current_focus> block and Pilot's focused: line have never
rendered. Both read focus by regexing [focused] out of the ARIA snapshot,
and Playwright does not put it there — so both silently omitted focus on
every single turn, and the tester never knew it was sitting in a text
field.

Focus is now read from document.activeElement alongside the ARIA snapshot
and carried on ActionResult and WebPageState, with the element's tag
mapped to its ARIA role so the existing textbox/combobox checks work.
The ARIA parser it replaces is deleted rather than left as a decoy.

Verified live: a focused search input reads back as
{role: textbox, name: "Search skills", value: "explorbot"}.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A check of an eight-point goal printed 41 numbered steps, most of them
successful intermediate actions with near-duplicate labels. The verdict
already lives in ### Expected outcomes, so listing every step that worked
spends the caller's context on what it will not read.

### Steps now carries the failures and the expectation outcomes, and
closes with how many routine steps ran and the status hash that has the
full log. Nothing is dropped silently.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
do told the model which instructions were still open but never which were
closed, so nothing stopped it redoing satisfied work — measured on a live
page, it re-clicked a panel toggle it had already activated, closing what
it had just opened, and repeated the fill behind it.

Each turn now carries the whole ledger with each instruction's state and
the proof that closed it, and asks for completed() on anything the page
already shows is satisfied before acting again.

Same instructions against the same page: 10 commands and both instructions
unaccounted for, down to 2 commands with both closed and proven.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
prepareInstructionsForNextStep reassigned its message instead of
appending, so from the second step onward the tester lost the whole
<rules> block along with the scenario reminder — including "do not do
unsuccessful clicks again" and "do not run same tool calls with same
parameters again", the two rules written to stop exactly the repetition
that fills its step logs.

The log now appends to the rules rather than replacing them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An expected outcome counted as met only when the tester recorded a note
whose text matched it byte for byte. Paraphrase once and a run that
genuinely verified everything reported "not verified" across the board —
which is what happened in the last benchmark, and what pushed the caller
down into do and tripled the cost of the run.

Pilot now settles the outcomes nothing checked off by name: it reads the
run log and judges each one on what the steps show happened, with
"unverified" reserved for outcomes the run never established either way.
Outcomes the run did check off by name skip the call entirely.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The tool told the model to "use it to verify the actions were performed
correctly and the page is in the expected state", so it ran a second,
slower model after almost every action — 18 vision calls in one measured
scenario, 23% of that run's wall clock. Every action already reports what
changed on the page, so those calls asked a question that had just been
answered.

It now describes what a screenshot uniquely gives — layout, what an image
or canvas depicts, colour, whether something is covered — and says not to
use it to confirm an action landed. The example follows the description
instead of contradicting it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
do asked the page for a ref-bearing snapshot and then stripped every ref
back out before showing it to the model. Refs are what make a click
unambiguous: aria-ref resolves to exactly one element in ~2ms, so it can
raise no strict-mode violation, produce no "multiple elements" error, and
never reach disambiguateElements — the model call measured at ~10s per
ambiguous click, and 21% of one check's wall clock.

clickRef takes a ref from the context and clicks it through Playwright's
own selector engine, so CodeceptJS locator resolution never runs. It sits
beside click rather than changing it, so nothing that composes locators is
affected. click now says to prefer it when the element carries a ref.

Verified: 100 refs reach do's context, a ref resolves to one element in
2ms, and refs survive a DOM change caused by our own click. Adoption by
the model is not yet consistent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
clickRef executes through aria-ref, which names an element only for the
session that produced the snapshot. Reporting that as the executed code
put a selector into generated tests that cannot resolve when they run
again.

The click still goes through the ref — that is what makes it unambiguous
and fast. What gets reported is the element's own role and accessible
name, read back from the page, so the generated test has something that
survives the session. If the element cannot be described that way, the
ref form is reported unchanged rather than a guess.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
check runs through the tester, which had clickRef available but no refs to
pass it — its ARIA block came from the stored snapshot, captured without
mode: 'ai'. The tool was unreachable in the tier where clicks cost most.

The block is now built from a ref-bearing snapshot taken at the moment the
context is written. The stored snapshot is untouched, so state hashes and
page diffs stay ref-free and an identical page whose refs merely renumber
still reads as unchanged. A page that cannot be re-snapshotted falls back
to the stored one rather than losing its context.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
clickRef was defined in createAgentTools, which has no task in scope, so
its first line threw ReferenceError on every call. It had never executed
once — which is why no run ever produced a ref click, and why the model
looked like it was ignoring the tool. It lives beside click now, where
the task it records into is in scope.

do offers clickRef while the accessibility tree is the context and brings
click back only once the model has dropped to markup, where there are no
refs to use. That follows the shape of the context rather than asking the
model to choose between two tools for the same job.

The recorded locator derives the element's implicit role, since a plain
button carries no role attribute and the description came back empty.

Verified against a live page: a ref click succeeds in ~800ms and reports
I.click({"role":"button","text":"..."}).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Withholding click while the accessibility tree was the context did not
push the model onto refs — it routed the same locator clicks through
form, which takes arbitrary CodeceptJS commands — and the run came back
with every instruction unaccounted for where the same three had closed
before.

Both tools are offered again. clickRef stays available and working; which
one the model reaches for is a separate problem from whether the ref path
exists, and starving it of the familiar tool is not what solves it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The locator priority rule opened straight into how to compose a locator,
and form invited any CodeceptJS command, so both taught locator
composition as the way to click even for elements the context had already
named with a ref.

Each now says what it does not cover: an element carrying a ref is
clicked with clickRef and that ref, and the priority list is for elements
the context gives no ref for. The clause is self-limiting — an agent whose
context has no refs never meets the case — so navigator and tester read
the same rule they did before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The help was 6.7k, then 4.4k, of prose about envelope sections and session
management. Every agent reads it before its first command, and in one
benchmark it was over half of everything prima put into the caller's
context — to explain output the agent is about to see anyway and flags
the option list already documents.

What is left says what prima is, how it pairs with playwright-cli, and
shows check, do and pw carrying a whole job rather than one click. 2.4k,
most of it the command list commander generates.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The help told the model what to do — give check a whole job, describe
targets, give pw code only. Instructions compete with everything else in
the context for authority. A description of what each command takes and
what it costs carries the same information without spending that.

The same rewrite applies to the check and do help.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The examples show what check, do and pw take. The paragraph above them
restated it in longer words.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
"Drive an already-open browser one command at a time" describes the
plumbing, which an agent reading help does not need to decide anything.
What decides is that prima takes described behaviour rather than
locators, and returns a verdict with its proof.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Which model answers for which role was only discoverable by reading
~/.explorbot/config.js. prima models prints the resolved roles and the
config file they came from.

It reads configuration only — no browser, no session, and any registered
site will do — so it answers before anything is running.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@DavertMik
DavertMik deployed to regression August 9, 2026 21:27 — with GitHub Actions Active
The envelope described the run three ways and none of them was a trace.
used: listed only the commands that succeeded, so a run that clicked,
failed, then escaped reported "used: I.pressKey(Escape)" as if that were
the whole job. ### Changes repeated the page diff that already appeared
under every step. And evidence only reached the envelope when the model
reported an instruction — when it did not, everything it had done
vanished behind "never reported".

### Steps is now the log: every action, check and report in the order it
happened, each with its own outcome. One report closing several
instructions is one line carrying one proof. Instructions nobody reported
are appended as their own lines rather than swallowing the trace.

used and ### Changes are gone from do, since the log carries both.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The step log said what ran but nothing showed what the page looked like
when it ran, so a claim in it could not be checked against anything.

Each action now writes the page it produced into the run's folder as
<n>-<step>.aria.yaml, .html and, when the action changed something,
.diff.yaml. The envelope prints the folder once at the end of ### Steps.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A check that failed before the action which satisfied it vetoed the whole
command. Verifying "debug panel is enabled", then enabling it, then
reporting the instruction done came back ok: false with "unproven: Debug
panel is enabled" — the check was never re-run, and its stale result
outranked what the model had proven.

do no longer draws a verdict from verify results. Each check is logged
with its own outcome, as verify itself does, and whether the run succeeded
follows from the instructions the model accounted for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant