Skip to content

fix(web): retry JS-shell 200s past every cache and name the escalation - #5936

Merged
Hmbown merged 2 commits into
mainfrom
fix/web-fetch-js-shell-retry-5904
Sep 6, 2026
Merged

fix(web): retry JS-shell 200s past every cache and name the escalation#5936
Hmbown merged 2 commits into
mainfrom
fix/web-fetch-js-shell-retry-5904

Conversation

@Hmbown

@Hmbown Hmbown commented Sep 6, 2026

Copy link
Copy Markdown
Owner

Closes #5904

What was broken

A 200 whose body extracted to nothing was terminal. crates/tui/src/tools/web/extract.rs:197,206 raised js_required_error on the spot, and the retries counter in crates/tui/src/tools/web/fetch.rs only ever covered transport failures — a 200-with-JS-shell was never re-fetched. Because an edge cache can hold a prerendered variant while an origin MISS serves the client-side shell, the same URL failed on one fetch and succeeded minutes later, and research agents lost primary sources non-deterministically.

What changed

  • One retry, past every cache. web/fetch.rs gains fetch_readable: the single place that fetches and extracts. A 2xx that yields no readable content is re-fetched once with Cache-Control: no-cache and Pragma: no-cache, skipping the session fetch cache. no-cache rather than no-store deliberately — shared caches still get to serve a validated copy, which is what recovers a page whose prerendered variant exists but was not the one served. Transport failures keep exactly their existing single retry and earn no extra request.
  • The receipt records both attempts. fetch_url's receipt gains attempts: per attempt the session cache_hit, whether it bypassed caches (cache_busted), which one produced_content, and the response headers that explain the cache state (age, cf-cache-status, x-nextjs-prerender, x-vercel-cache, only when present). The terminal error carries the same receipt inline, so a failure explains itself as well as a success does.
  • The error names the escalation the calling role owns. Read from capability facts already on ToolContext — no second registry: Feature::WebSearch plus the authority envelope's network_access decide whether web.run is reachable (that same envelope is what removes web.run via fleet::role::NETWORK_TOOL_DENYLIST), and shell_policy decides whether a shell fetch is even an option. A role that has the browse surface is told to use it; a read-only, network-denied worker is told plainly that it has neither web.run nor a curl fallback and should report the URL as unreadable rather than substitute a source.
  • Both consumers migrated. fetch_url and web_run now go through fetch_readable; the raw fetch entry point had no remaining callers and was removed rather than left as a second path.

Naming web.run as the escalation is accurate for this codebase and matches the evidence in the issue: web.run open requests the same URL with a browser user-agent and a ten-megabyte budget where fetch_url uses the codewhale UA and one megabyte, which is exactly the difference that gets the prerendered variant from a Vercel/Next origin.

Verified locally

No live network calls to third-party sites; every new test drives a wiremock mock server whose responder returns the JS shell to an ordinary request and the prerendered body to one carrying Cache-Control: no-cache.

cargo fmt --all -- --check                                          clean
cargo check -p codewhale-tui                                        clean
RUST_MIN_STACK=16777216 cargo test -p codewhale-tui --lib -- tools::web
    test result: ok. 194 passed; 0 failed; 1 ignored
RUST_MIN_STACK=16777216 cargo test -p codewhale-tui --lib -- tools::
    test result: ok. 1937 passed; 0 failed; 4 ignored

Both new regression tests were shown failing without the fix (retry guard disabled: 7 passed; 2 failed):

  • js_shell_is_refetched_past_every_cache_before_it_becomes_an_error — shell then content: succeeds, exactly one extra request, receipt shows two attempts with the second cache_busted and produced_content, and the cache-state headers on both.
  • two_shells_fail_with_the_escalation_the_calling_role_owns — shell twice: fails, no third request, error names web.run for a normal role and says "not available to this role" / "cannot fall back to a shell fetch" for a read-only network-denied envelope, and carries both attempt lines plus x-vercel-cache=MISS.
  • transport_failures_do_not_earn_a_cache_busting_refetch — a 503-then-200 costs the existing single transport retry and records one readable-fetch attempt, not two.

What only a live fetch can prove

The mock proves the mechanism: we send the cache-busting headers, we re-fetch once, and a differing second response is what the caller receives. It cannot prove the rate at which real edges (Vercel, Cloudflare) actually hand back a prerendered variant on a revalidating request — that is a property of those CDNs, and the issue's own evidence (x-vercel-cache: HIT + x-nextjs-prerender: 1 on the successful fetch of deepgram.com/pricing) is the only sample we have. The pages named in the issue were not fetched from this branch, and the alibabacloud.com docs app may well be a page that never has a prerendered variant, in which case the value delivered there is the second half of this change: an error that tells the model exactly which surface to escalate to, rather than one that reads as "this URL is unfetchable".

🤖 Generated with Claude Code

https://claude.ai/code/session_0188XYyJaw9Mh9uSrqQBoqhm


Note

Medium Risk
Changes default HTTP fetch behavior for fetch_url and web.run (up to one additional network request per URL) and alters error text agents act on, but scope is bounded to JS-shell 2xx cases with new regression tests.

Overview
Fixes non-deterministic failures when a 200 OK returns a JavaScript app shell (empty readable body) while a prerendered variant might exist behind edge caches (#5904).

fetch_readable replaces the old fetch-then-extract split for fetch_url and web.run open. On a 2xx whose extraction fails with the new JS-shell marker, the pipeline makes one extra request with Cache-Control: no-cache / Pragma: no-cache, skips the session fetch cache, and runs extraction again. Transport retries are unchanged and do not trigger this second pass.

Successful fetch_url responses now include a richer receipt.attempts list (cache hit, cache-busted, which attempt produced content, and selective cache-state headers). Terminal JS-shell errors embed the same attempt summary and role-aware recovery text (web.run, shell/curl, or “report unreadable”) derived from existing ToolContext capabilities.

extract.rs exposes JS_SHELL_MARKER / is_js_shell_error so the fetch layer can recognize shell failures without conflating transport errors. Wiremock tests cover shell-then-prerender recovery, double-shell failure messaging, and that flaky HTTP status retries stay a single readable-fetch attempt.

Reviewed by Cursor Bugbot for commit 77cf192. Bugbot is set up for automated code reviews on this repo. Configure here.

A 200 whose body extracts to nothing was terminal: `extract.rs` raised
`js_required_error` and `fetch.rs`'s retry counter only covered transport
failures. Edge caches can hold a prerendered variant while an origin MISS
serves the client-side shell, so the same URL failed on one fetch and
succeeded minutes later, non-deterministically losing research sources.

- `web/fetch.rs` gains `fetch_readable`, the one place that fetches and
  extracts: a 2xx that yields no readable content is re-fetched once with
  `Cache-Control: no-cache` / `Pragma: no-cache`, skipping the session
  cache. Transport failures keep exactly their existing single retry.
- The receipt now carries `attempts`: per attempt the session `cache_hit`,
  whether it bypassed caches, which one produced content, and the response
  headers that explain the cache state (`age`, `cf-cache-status`,
  `x-nextjs-prerender`, `x-vercel-cache`). The terminal error carries the
  same receipt inline, so a failure explains itself too.
- The terminal error names the recovery the *calling role* owns, read from
  the capability facts already on `ToolContext` — `Feature::WebSearch` plus
  the authority envelope's `network_access` for the `web.run` browse
  surface, `shell_policy` for a shell fallback. A read-only, network-denied
  worker is told plainly that it has neither, instead of being pointed at a
  surface it cannot call.
- `fetch_url` and `web_run` both moved onto `fetch_readable`; the raw
  `fetch` entry point had no remaining callers and is gone.

Verified locally (no live third-party fetches; wiremock fixtures only):
  cargo fmt --all -- --check: clean
  cargo check -p codewhale-tui: clean
  RUST_MIN_STACK=16777216 cargo test -p codewhale-tui --lib -- tools::web
    194 passed; 0 failed; 1 ignored
  RUST_MIN_STACK=16777216 cargo test -p codewhale-tui --lib -- tools::
    1937 passed; 0 failed; 4 ignored

Both new regression tests were shown failing without the retry
(`js_shell_is_refetched_past_every_cache_before_it_becomes_an_error` and
`two_shells_fail_with_the_escalation_the_calling_role_owns`: 7 passed;
2 failed).

Closes #5904

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0188XYyJaw9Mh9uSrqQBoqhm
Signed-off-by: CodeWhale Bot <bot@codewhale.net>
Copilot AI lite review requested due to automatic review settings September 6, 2026 07:16
@cursor

cursor Bot commented Sep 6, 2026

Copy link
Copy Markdown

Bugbot couldn't run - usage limit reached

Bugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit.

A user or team admin can review and increase usage limits in the Cursor dashboard.

(requestId: serverGenReqId_0967aec1-310e-471d-bec9-194281b1a788)

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 6, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-06T07:22:35.251889Z 820c4a9 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@claude

claude Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Claude finished @Hmbown's task in 6m 55s —— View job


Review: fix(web): retry JS-shell 200s past every cache

  • Gather context (diff, CLAUDE.md/AGENTS.md rules)
  • Read changed files in full
  • Check correctness (retry logic, error paths, exhaustive matches)
  • Check provider/model/route safety implications (n/a — no provider/model routing touched)
  • Check reuse/simplification/dead code
  • Check test coverage
  • Check security (cache-busting headers, capability checks)
  • Post final review

Overall this is a well-scoped fix: the retry/receipt mechanics in fetch_readable_inner are sound (verified the loop guard is only taken on mode == CacheMode::Default with a 2xx + is_js_shell_error, so transport failures and a still-failing revalidate pass both terminate correctly; the unreachable!() at the end of the loop is genuinely unreachable). The fetch() entry point removal is clean — no dangling callers, no stray doc references. The NETWORK_TOOL_DENYLIST / Feature::WebSearch reasoning behind browser_surface_available / shell_fallback_available checks out against fleet/role.rs and command_safety.rs (curl genuinely isn't in the ShellPolicy::ReadOnly allowlist, so that branch's message is accurate).

1. The escalation message's core justification doesn't match the code (correctness).
js_shell_recovery (crates/tui/src/tools/web/fetch.rs:301-317) tells the model that escalating to web.run helps because it "requests it with a browser user-agent and a ten-megabyte budget." The 10MB budget claim is correct (HARD_MAX_BYTES vs DEFAULT_MAX_BYTES in open_fetch_options), but the browser-UA claim is not: web_run.rs's fetch_page/document_from_fetched path goes through the same shared fetch_readablefetch_innerfetch_attempt pipeline as fetch_url, and fetch_attempt (fetch.rs:499) hardcodes .user_agent(USER_AGENT) from the module-level codewhale UA constant (fetch.rs:26-30) — it never branches on tool_label. web_run.rs does import a real BROWSER_USER_AGENT (aliased USER_AGENT, from web/scrape.rs:13), but it's only wired into run_image_search (web_run.rs:924), not into the "open a URL" path. So both tools send the identical UA string when recovering a JS-shell page — this pre-dates the PR (confirmed on origin/main, web_run.rs already called the shared fetch()), but this PR is what bakes the false "browser user-agent" claim into a message the model will now act on. Since Vercel/Next prerendering variance is commonly UA-driven, telling the model this escalation changes the UA when it doesn't could send it chasing a fix that won't reproduce the issue's own evidence (x-vercel-cache: HIT on deepgram.com/pricing) — the 10MB budget alone is unlikely to be what recovers a small pricing page. Worth either wiring BROWSER_USER_AGENT into the web.run open path (making the claim true) or dropping the UA claim from the message (the no-cache revalidation + bigger budget are already real, honest reasons to escalate).

2. fetch_readable_inner clones the full response body on every attempt, not just retries (efficiency).
crates/tui/src/tools/web/fetch.rs (~line in fetch_readable_inner): extract(payload.clone()).await clones the entire FetchedPayload — including bytes: Vec<u8>, up to HARD_MAX_BYTES (10MB for web.run) — on every call, including the common case where the first attempt already succeeds and no retry ever happens. This is on top of the pre-existing clone in fetch_inner for cache::insert. Since only the success arm needs payload back (the retry/error arms only need the already-captured record/final_url), this could be avoided by having the extract closures return (FetchedPayload, T) instead of just T, letting extract(payload) move ownership through without a defensive clone.

3. Minor: a transport failure on the revalidate (second) attempt drops the first attempt's receipt.
If the Default-mode attempt fails with is_js_shell_error (earning a retry) but the subsequent Revalidate-mode fetch_inner call itself fails with a transport error, the ? on that second fetch_inner(...).await? (fetch.rs, inside the for mode in [...] loop) propagates the raw transport error immediately — before a FetchAttempt record is even built for that second call, and discarding the first attempt's already-pushed record. The caller gets a plain transport error with no receipt, rather than the informative "attempt 1 was a JS shell, attempt 2 timed out" story. Not incorrect, just a small gap in the new receipt's completeness; not covered by the three new tests (transport_failures_do_not_earn_a_cache_busting_refetch only exercises a transport failure on the first fetch, not on the revalidate leg).

Nits:

  • fetch_url.rs:222 recomputes is_success outside the closure identically to the one computed inside it (fetch_url.rs:185) — harmless duplication, pre-existing pattern.
  • readable.payload.retries after a successful revalidate only reflects the second fetch_inner call's transport retries; any transient retry consumed during the discarded first (shell) attempt isn't reflected. Minor, likely fine given the primary signal is attempts[].status.

Tests are solid and specifically designed to fail without the fix (per the PR's before/after numbers), and correctly distinguish the JS-shell retry path from the pre-existing transport retry path. No security concerns beyond what's already reviewed — the Cache-Control/Pragma headers are inert probe headers, not secrets, and the existing header-filtering (authorization, cookie, set-cookie, x-api-key, …) in response_headers is untouched.

Branch: fix/web-fetch-js-shell-retry-5904

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The JS-shell detection and terminal error text have correctness issues (brittle matching and an inaccurate “second attempt” claim) that should be fixed before merging.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

This PR makes fetch_url and web.run resilient to cache-dependent “HTTP 200 JS shell” responses by moving fetch+extraction into a shared pipeline that can retry once with cache revalidation, while improving receipts and role-aware recovery guidance.

Changes:

  • Introduces fetch_readable to couple fetch + extract and re-fetch once with Cache-Control/Pragma: no-cache when extraction indicates a JS shell on a 2xx response.
  • Extends fetch_url receipts to include per-attempt cache/state facts (including selective cache headers) and threads that receipt into terminal JS-shell errors.
  • Migrates fetch_url and web.run to the new pipeline and refactors web.run to split “document extraction” from “page rendering” for retry reuse.
File summaries
File Description
crates/tui/src/tools/web/fetch.rs Adds readable-fetch pipeline with one cache-busting revalidate attempt, attempt receipts, and role-aware JS-shell recovery text.
crates/tui/src/tools/web/extract.rs Adds a stable JS-shell marker + predicate so the fetch layer can recognize extraction failures worth retrying.
crates/tui/src/tools/web_run.rs Routes web.run open through fetch_readable and splits extraction from rendering so extraction can be retried.
crates/tui/src/tools/fetch_url.rs Routes fetch_url through fetch_readable and emits the new per-attempt receipt in results/errors.
Review details
  • Files reviewed: 4/4 changed files
  • Comments generated: 2
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +377 to +379
pub(crate) fn is_js_shell_error(error: &ToolError) -> bool {
error.to_string().contains(JS_SHELL_MARKER)
}
Comment on lines +282 to +284
ToolError::execution_failed(format!(
"{marker} {url} after {count} attempts, the second past every cache ({receipt}). The response parsed but held no readable body, which usually means the page renders its content with JavaScript. Recovery: {recovery}",
marker = super::extract::JS_SHELL_MARKER,

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 820c4a9a06

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

fn js_shell_recovery(context: &ToolContext, tool_label: &str) -> String {
// `web.run` is itself the escalation, so it never names itself.
if tool_label != "web_run" && browser_surface_available(context) {
return "open this URL with the `web.run` browse surface (`web.run {\"open\": {\"url\": ...}}`), which requests it with a browser user-agent and a ten-megabyte budget and usually receives the prerendered variant.".to_string();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Show a valid web.run open payload

When both fetch attempts return a JavaScript shell, the suggested escalation cannot be executed: WebRunTool::input_schema requires open to be an array containing objects with a ref_id, while this message supplies a single object with a url. The implementation consequently ignores open and reports that no operation was performed. Show the accepted shape, such as {"open":[{"ref_id":"..."}]}, so the recovery path actually works.

Useful? React with 👍 / 👎.

Comment on lines +282 to +287
ToolError::execution_failed(format!(
"{marker} {url} after {count} attempts, the second past every cache ({receipt}). The response parsed but held no readable body, which usually means the page renders its content with JavaScript. Recovery: {recovery}",
marker = super::extract::JS_SHELL_MARKER,
count = attempts.len(),
recovery = js_shell_recovery(context, tool_label),
))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Localize the new user-visible shell error

This newly constructed ToolError is rendered in the transcript but hard-codes English, so every non-English locale receives an untranslated failure and recovery message. Define message IDs and produce the prose through tr(locale, MessageId::...) as required for TUI-visible text.

AGENTS.md reference: crates/tui/AGENTS.md:L25-L26

Useful? React with 👍 / 👎.

Comment on lines +306 to +310
fn network_authorized(context: &ToolContext) -> bool {
context
.tool_authority
.as_deref()
.is_none_or(|authority| authority.network_access != Some(false))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Require an explicit network grant before advertising web.run

For a context with a tool-authority envelope whose network_access is None, this predicate returns true, but ToolRegistry::build_api_tools exposes Web/web.run only when the same field is Some(true). If such a worker reaches this error through the retained Web.fetch path, it is therefore instructed to call a tool absent from its catalog; match the registry's authorization predicate rather than treating an unspecified grant as allowed.

Useful? React with 👍 / 👎.

fn js_shell_recovery(context: &ToolContext, tool_label: &str) -> String {
// `web.run` is itself the escalation, so it never names itself.
if tool_label != "web_run" && browser_surface_available(context) {
return "open this URL with the `web.run` browse surface (`web.run {\"open\": {\"url\": ...}}`), which requests it with a browser user-agent and a ten-megabyte budget and usually receives the prerendered variant.".to_string();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Stop claiming web.run changes the request user-agent

For URL opens, web.run calls fetch_page, which now calls this same fetch_readable pipeline, and fetch_attempt always sets the Codewhale guarded-fetch user-agent. The browser user-agent imported by web_run.rs is used by the separate image-search request, not by open, so an origin that serves the shell based on user-agent receives the same request identity after this escalation. Remove the claim or actually configure the open path with the browser user-agent.

Useful? React with 👍 / 👎.

Comment on lines +224 to +228
for mode in [CacheMode::Default, CacheMode::Revalidate] {
let payload = fetch_inner(
url,
options,
context,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep the cache-busting retry inside the original timeout

When the first response consumes most of options.timeout before yielding a JavaScript shell, the second loop iteration passes the unchanged options to a fresh fetch_inner, which creates a new full deadline. A fetch_url call advertised with a 60-second maximum can therefore spend nearly 120 seconds in network fetching. Establish one deadline around the readable-fetch sequence and pass only the remaining duration to the revalidation attempt.

Useful? React with 👍 / 👎.

Comment on lines +240 to +244
return Ok(ReadableFetch {
payload,
document,
attempts,
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve transport retries from the first readable attempt

If the default fetch receives a transient 5xx, retries successfully to a JavaScript shell, and the cache-busting fetch then succeeds directly, this returns only the second payload. FetchResponse.receipt.retries consequently becomes zero even though three HTTP requests occurred, and FetchAttempt has no field retaining the first payload's retry count. Aggregate retries across both readable attempts or record them per attempt so the new receipt remains accurate.

Useful? React with 👍 / 👎.

Comment on lines +326 to +328
format!(
"{unavailable} This role is read-only and cannot fall back to a shell fetch, so report this URL as unreadable rather than substituting another source."
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Do not equate a disabled shell with a read-only role

When a normal Agent or Operate session has shell access disabled, its ShellPolicy is None even though the role can still have mutation authority. If web.run itself reaches this terminal shell error, shell_fallback_available is false and this branch incorrectly tells the model that its role is read-only, which can cause it to abandon other permitted work. Describe only the missing shell fallback, or inspect the mutation authority separately before labeling the role read-only.

Useful? React with 👍 / 👎.

cargo clippy -p codewhale-tui --all-targets -- -D warnings: clean.

Signed-off-by: CodeWhale Bot <hmbown@gmail.com>
@cursor

cursor Bot commented Sep 6, 2026

Copy link
Copy Markdown

Bugbot couldn't run - usage limit reached

Bugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit.

A user or team admin can review and increase usage limits in the Cursor dashboard.

(requestId: serverGenReqId_2f79d791-62ca-4b07-95fd-06f63b47efc7)

@claude

claude Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Claude finished @Hmbown's task in 3m 4s —— View job


Review: fix(web): retry JS-shell 200s past every cache and name the escalation

  • Gather context (diff vs origin/main, CLAUDE.md/AGENTS.md rules)
  • Read changed files in full
  • Check correctness (retry logic, error paths, exhaustive matches)
  • Check provider/model/route safety implications (n/a — no provider/model routing touched)
  • Check reuse/simplification/dead code
  • Check test coverage
  • Check security
  • Post final review

The mechanism (fetch_readable_inner in crates/tui/src/tools/web/fetch.rs:212) is sound: the retry guard only fires for mode == CacheMode::Default + 2xx + is_js_shell_error, so transport failures and a still-failing revalidate both terminate correctly, and the trailing unreachable!() is genuinely unreachable. fetch() was cleanly removed with no dangling callers. I independently verified the following against the actual code (not just repeating the other bots):

1. The suggested web.run recovery payload is invalid (fetch.rs:316, correctness).
js_shell_recovery tells the model to call web.run {"open": {"url": ...}}. But WebRunTool::input_schema (web_run.rs:395-403) requires open to be an array of objects keyed by ref_id, not a single object keyed by url — confirmed at the call site too: input.get("open").and_then(|v| v.as_array()) (web_run.rs:605) then required_str(open, "ref_id") (web_run.rs:608). resolve_or_fetch_page does accept a raw URL as the ref_id value (looks_like_url(ref_id), web_run.rs:844-845), so the correct shape is web.run {"open": [{"ref_id": "https://..."}]}. As written, a model that follows this recovery text verbatim gets an ignored open field and no operation performed — the escalation path this PR exists to provide doesn't work.

2. "Requests it with a browser user-agent" is false (fetch.rs:316, correctness).
Both fetch_url and web.run's open path now go through the same fetch_readablefetch_innerfetch_attempt pipeline, and fetch_attempt hardcodes .user_agent(USER_AGENT) (fetch.rs:26, fetch.rs:499) — it never branches on tool_label. The 10MB budget claim is true (HARD_MAX_BYTES vs DEFAULT_MAX_BYTES), but the UA claim isn't, and since Vercel/Next prerendering is commonly UA-driven, this could send the model chasing a fix that won't reproduce. Either wire a browser UA into the web.run open path, or drop the UA claim and lead with no-cache revalidation + the bigger budget (both real).

3. Each readable-fetch attempt gets its own full timeout, silently doubling the advertised max (fetch.rs:398).
fetch_inner computes let deadline = Instant::now() + options.timeout fresh on every call. fetch_readable_inner calls fetch_inner twice with the same options when a JS shell triggers the revalidate pass, so a fetch_url call documented as "max 60,000" ms (fetch_url.rs:127) can now take up to ~120s wall-clock. Worth capping the revalidate attempt to the deadline's remaining budget rather than a fresh one.

4. The failure message always claims "the second past every cache" even when only one attempt was made (fetch.rs:283).
The retry guard requires mode == CacheMode::Default && (200..300).contains(status); if is_js_shell_error is true but the status is not 2xx on the first attempt (reachable for fetch_url when a non-2xx response has a non-declared-textual content-type but an HTML-sniffed, empty body — extract_fetched_document's textual-fallback catch in fetch_url.rs:317-318 only saves the textual case), js_shell_failure is still called with attempts.len() == 1, producing a message that's both grammatically and factually wrong about a "second" attempt. Narrow, but worth gating the wording on attempts.len().

Reuse/simplification:
fetch_readable_inner calls extract(payload.clone()).await unconditionally (fetch.rs:~233), cloning the entire FetchedPayload — up to 10MB for web.run (HARD_MAX_BYTES) — even on the common first-attempt-succeeds path where no retry ever happens. Since only the success arm needs payload back, having extract return (FetchedPayload, T) and move ownership through would avoid the defensive clone in the hot path.

Minor / already-acceptable:

  • readable.payload.retries on a successful revalidate only reflects the second fetch_inner call's transport retries; any retry consumed during the discarded first (shell) attempt is dropped from the receipt. Informational only, low impact.
  • is_js_shell_error (extract.rs) matches via error.to_string().contains(JS_SHELL_MARKER) against the full Display output rather than the ExecutionFailed { message } payload directly. ToolError is a thiserror enum, so matching the variant would be marginally more robust, but the marker string is distinctive enough that this isn't a practical risk.
  • fetch_url.rs:222 recomputing is_success outside the closure identical to the one computed inside (fetch_url.rs:185) is harmless pre-existing-style duplication.

Tests: the three new tests are well-targeted — mock-based, cover the shell→content recovery, the two-shells terminal failure (including the read-only/network-denied wording), and that transport retries don't earn a second cache-busting request. Not covered: a transport failure on the revalidate leg, and nothing exercises that the suggested web.run payload in finding #1 is actually schema-valid (which would have caught it).

Security: no new concerns. The Cache-Control/Pragma headers are inert probe headers, and the existing header allowlist (authorization, cookie, set-cookie, x-api-key, …) in response_headers is untouched. The capability checks in browser_surface_available/shell_fallback_available correctly reuse Feature::WebSearch and the existing authority envelope rather than introducing a second permission model.

Branch: fix/web-fetch-js-shell-retry-5904

@codewhale-agent codewhale-agent Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Codewhale review

PR #5936 adds a single cache-busting retry for 2xx responses whose extraction hits the JS-shell marker, plus role-aware escalation text and richer receipt attempts. The core mechanism is sound and well-tested at the fetch_readable layer, but a few interactions and test gaps remain.

Findings

  • [WARNING] Field projection may mask the JS-shell marker and bypass the retry (crates/tui/src/tools/fetch_url.rs:198)
    In the fetch_url extraction closure, project_json_fields is called before extract_fetched_document. If a non-empty requested_fields input causes project_json_fields to fail on a text/html shell body, fetch_readable sees a non-JS-shell error, is_js_shell_error returns false, and the cache-busting retry never runs. The new tests only exercise the no-fields path, so this interaction is unverified and likely still broken for field requests.
  • [WARNING] A failed cache-busting second attempt loses the first JS-shell attempt and escalation (crates/tui/src/tools/web/fetch.rs:210)
    fetch_readable_inner uses ? on fetch_inner for the revalidation pass. If the no-cache request fails with a transport or policy error after the first attempt was a JS shell, the returned error is the transport error only; the first attempt's record and the role-aware web.run recovery text are discarded. This contradicts the PR goal that terminal failures carry the attempt receipt and recovery guidance.
  • [INFO] Missing tests for session-cache bypass and tool-level receipt attempts (crates/tui/src/tools/web/fetch.rs:758)
    The new tests cover shell-then-prerender, double shell, and transport retry at the fetch_readable layer, but they do not populate the session fetch cache to prove a cached JS shell is re-fetched past the cache, nor do they exercise the fetch_url/web_run tool paths to assert receipt.attempts serialization or the exact web_run recovery wording.

Suggestions

  • crates/tui/src/tools/fetch_url.rs:198 — Run extraction before field projection so JS-shell detection always wins, or ensure project_json_fields cannot return an error for HTML shells and add a regression test with non-empty requested_fields.
  • crates/tui/src/tools/web/fetch.rs:210 — Catch fetch_inner errors on the revalidation pass after an earlier JS-shell attempt and wrap them with js_shell_failure or otherwise append the prior attempt, so the model still receives the escalation context.

Assessment

The retry-by-revalidation mechanism is a good, bounded fix and the new tests demonstrate the intended behavior for the common shell-then-prerender case. Before merge, the field-projection interaction should be verified and the second-attempt error path should preserve the first attempt's JS-shell context; otherwise the fix may still miss some fetch_url invocations and produce unhelpful errors when the cache-busting request itself fails.


Advisory review by Codewhale (codewhale review --pr 5936 --post, head 77cf192081c44a46e2860a9d9d17eafd9f7a1284). Line-specific findings are also posted as inline review comments; mechanical fixes arrive as committable suggestions you can apply from the Files tab. CODEOWNERS approval still governs merge.

};
let fields = match body_text.as_deref() {
Some(body) => {
project_json_fields(body, &fetched.content_type, requested_fields)?

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[WARNING] Field projection may mask the JS-shell marker and bypass the retry

In the fetch_url extraction closure, project_json_fields is called before extract_fetched_document. If a non-empty requested_fields input causes project_json_fields to fail on a text/html shell body, fetch_readable sees a non-JS-shell error, is_js_shell_error returns false, and the cache-busting retry never runs. The new tests only exercise the no-fields path, so this interaction is unverified and likely still broken for field requests.

extract,
)
.await
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[WARNING] A failed cache-busting second attempt loses the first JS-shell attempt and escalation

fetch_readable_inner uses ? on fetch_inner for the revalidation pass. If the no-cache request fails with a transport or policy error after the first attempt was a JS shell, the returned error is the transport error only; the first attempt's record and the role-aware web.run recovery text are discarded. This contradicts the PR goal that terminal failures carry the attempt receipt and recovery guidance.

@@ -467,6 +758,229 @@ mod tests {
assert!(!large.cache_hit);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[INFO] Missing tests for session-cache bypass and tool-level receipt attempts

The new tests cover shell-then-prerender, double shell, and transport retry at the fetch_readable layer, but they do not populate the session fetch cache to prove a cached JS shell is re-fetched past the cache, nor do they exercise the fetch_url/web_run tool paths to assert receipt.attempts serialization or the exact web_run recovery wording.

project_json_fields(body, &fetched.content_type, requested_fields)?
}
None => None,
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Run extraction before field projection so JS-shell detection always wins, or ensure project_json_fields cannot return an error for HTML shells and add a regression test with non-empty requested_fields.

async fn fetch_readable_inner<'e, T, F>(
url: &str,
options: &FetchOptions,
context: &ToolContext,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Catch fetch_inner errors on the revalidation pass after an earlier JS-shell attempt and wrap them with js_shell_failure or otherwise append the prior attempt, so the model still receives the escalation context.

@Hmbown
Hmbown merged commit 9f8ecf3 into main Sep 6, 2026
34 checks passed
@Hmbown
Hmbown deleted the fix/web-fetch-js-shell-retry-5904 branch September 6, 2026 12:18
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.

web fetch: JS-shell 200s fail extraction with no retry or browser escalation — cache-state dependent

2 participants