Explain why navigation failed, and stop when the browser is gone - #118
Merged
Conversation
Navigation failures reported only where the browser ended up — "redirected to /users/sign_in and could not resolve" — which named the symptom and never the cause. The common case is a login page whose credentials were never provided as knowledge, but that is one cause among several, so the reason is now composed the same way for all of them: the blocker the AI reported through the stop tool, otherwise the step that kept failing, plus the `learn` command to run when nothing is known about the page. The reason reaches both callers. `visit()` puts it in the thrown error, and the `interact` tool appends it to its failure instead of discarding it. resolveState carried that logic inside a ~300 line method. It is now an orchestrator over value-returning helpers, so the loop body reads as its three real phases: ask the AI for a batch, feed failures back, run one block. No behavior moved in that step — the tests added here were written against the unmodified method first, and stayed green through it. Those tests pin the parts most likely to drift: an AI call and the first attempt share one loop iteration, code blocks are alternatives without an expected URL but a sequence with one, and progress is measured against two different baselines — the state resolveState started from for resolution, the state before the attempt for the ARIA diff. One behavior did change deliberately. The loop swallowed every error, including the BrowserRecoveryError raised when the browser cannot be restored, so a dead browser burned every remaining attempt and a model call with each. Fatal browser errors now propagate, which is what Action.attempt rethrows them for and what the interact tool's throwIfFatalBrowserError already expects. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
DenysKuchma
approved these changes
Aug 18, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
Navigation failures reported only where the browser ended up —
redirected to /users/sign_in and could not resolve— which names the symptom and never the cause.The case that prompted this is a login page whose credentials were never provided as knowledge. That is one cause among several, so rather than special-case it, the reason is now composed the same way for every failure:
stoptool, otherwise3 attempts failed, last: …), otherwisethe AI proposed no working solution for this pageplus, when nothing is known about the page, the
explorbot learn "<path>"command to run.The reason reaches both callers:
visit()puts it in the thrown error, and theinteracttool appends it to its failure result instead of discarding it.Refactor
resolveStateheld all of that inside a ~300 line method. It is now an orchestrator over value-returning helpers —buildResolutionPrompt,buildRetryFeedback,executeAttempt,verifyNavigation,ariaDiff,saveFlow,rescueDelayedRedirect,askUserToResolve— so the loop body reads as its three real phases: ask the AI for a batch, feed failures back, run one block.Helpers derive what they can rather than taking it as arguments, so the signatures stay narrow:
verifyNavigation(action, expectedUrl)returns the facts and lets the caller apply the resolution policy, andbuildResolutionPrompt(message, actionResult)renders its own knowledge.The refactor moved no behavior. The tests here were written against the unmodified method first and stayed green through it.
Tests
Written before the refactor, they pin the parts most likely to drift silently:
resolveStatestarted from for resolution, the state before the attempt for the ARIA diff<previous_failures>, the ARIA-diff branch, full HTML injected exactly oncestoptool wins over code blocks in the same responseOne deliberate behavior change
The loop swallowed every error, including the
BrowserRecoveryErrorraised when the browser cannot be restored, so a dead browser burned every remaining attempt and a model call with each — then reported it as an ordinary navigation failure. Fatal browser errors now propagate, which is whatAction.attemptrethrows them for and what theinteracttool'sthrowIfFatalBrowserErroralready expects. Ordinary failures still retry exactly as before, pinned by a test either way.Recoverable crashes were already handled and are untouched:
explorer.action()injectsrunWithRecovery, soaction.attempt()andexplorer.capture()self-heal without the navigator knowing.Left alone, worth a follow-up
verifyStatehas the same swallow atnavigator.ts:821. It is arguably worse there — a browser dying mid-verification returnsverified: false, a false failed assertion. Both callers already tolerate a throw.URL did not change (still X)is also emitted when the URL did match but the page state did not. Pinned as-is; it feeds slightly wrong evidence to the AI.Verification
1008 unit + 80 integration passing, lint and format clean, no new
tscerrors. No regression run started.🤖 Generated with Claude Code