Skip to content

fix(tui): only flag session location missing on LocationNotFoundError - #55940

Open
agustif wants to merge 2 commits into
anomalyco:devfrom
agustif:location-recovery
Open

agustif wants to merge 2 commits into
anomalyco:devfrom
agustif:location-recovery

Conversation

@agustif

@agustif agustif commented Oct 16, 2026 •

Copy link
Copy Markdown

Issue for this PR

Closes #43415 (reported as duplicate in #46322)

Type of change

  • Bug fix
  • New feature
  • Refactor / code improvement
  • Documentation

What does this PR do?

The TUI mounted the blocking "Session location unavailable" recovery dialog after transient location-sync failures (HTTP 503, timeouts, reconnect races), even though the folder still existed.

LocationProvider.sync in packages/tui/src/context/location.tsx set the location error on every sync rejection. The server only throws the typed LocationNotFoundError when the folder genuinely does not resolve (packages/server/src/location.ts); the generated client preserves _tag on declared-error rejections, and the web client (packages/app/src/workspaces/location.tsx) already gates on isLocationNotFoundError. The TUI did not, so any failed sync replaced the prompt with the recovery dialog.

The fix gates setError on isLocationNotFoundError(cause). Transient failures no longer mount recovery and self-heal through the existing re-sync triggers (location change, server.connected). Genuinely missing folders behave exactly as before.

How did you verify your code works?

Added packages/tui/test/cli/tui/location-error.test.tsx (TUI fixture harness):

  • 503 {message} rejection leaves location.error undefined (fails before the fix, passes after — confirmed via git stash)
  • 404 {_tag: LocationNotFoundError} rejection sets location.error with the directory (passes before and after)

Also ran the existing test/cli/tui/prompt-move.test.tsx (19 pass, 0 fail) and bun run typecheck (tsgo -b) in packages/tui, clean.

Screenshots / recordings

No visual change: the dialog renders exactly as before for genuinely missing folders. This change only narrows when it mounts, which the regression test above covers.

Checklist

  • I have tested my changes locally
  • I have not included unrelated changes in this PR

Added by the opencode bot (AI-generated).

Fixes #55250

Related: #49352, #54943, #55331, #55035

@github-actions github-actions Bot added the needs:compliance This means the issue will auto-close after 2 hours. label Oct 16, 2026
@github-actions

Copy link
Copy Markdown
Contributor

The following comment was made by an LLM, it may be inaccurate:

@github-actions github-actions Bot removed the needs:compliance This means the issue will auto-close after 2 hours. label Oct 16, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Thanks for updating your PR! It now meets our contributing guidelines. 👍

@opencode-agent opencode-agent Bot 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.

This works. Using opencode-drive with the simulated model, I made one request of the TUI's location sync fail with a 500 while the folder still existed (a test-only server patch, applied in both checkouts). The base replaced the session prompt with the false Session location unavailable dialog; with this PR the prompt stays. Screenshots are below.

The new test fails on the base and passes here. prompt-move and the test/context suites pass (94/94), and typecheck in packages/tui is clean. A missing folder still shows the dialog: location.get returns the server's typed LocationNotFoundError, which keeps its _tag through the TUI's location sync, matching how the web app already decides this.

The public API is unchanged.

One small point: unlike the web app, a failure that isn't LocationNotFoundError is now dropped and never retried. If it doesn't clear, the session can run with partly missing location data until the next reconnect or location change, with nothing shown to the user. That's still better than a false blocking dialog. #55258 adds a bounded retry for the same symptom, and the two touch the same code, so they could be combined.

Also see the inline note on the test's fixed sleep.

Fixes #55250
Related: #49352, #54943, #55331, #55035

Before

After

fixture.location.set({ directory })
await fixture.app.waitFor(() => fixture.reads.includes(directory))
// Let the sync rejection settle through LocationProvider's catch.
await new Promise((resolve) => setTimeout(resolve, 100))

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.

This 100ms sleep is a timing guess: if the rejection takes longer to reach the catch, the test passes without checking anything. Could you wait until the sync has actually settled instead? For example, have the fixture record when the 503 response is served and await a microtask after that, or wait on a condition the way the not-found test does with waitFor.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Good catch — replaced the fixed 100ms sleep with await Bun.sleep(0) after the request is observed. Yielding to the event loop guarantees the full microtask queue (fetch decode → sync rejection → provider catch) has drained before asserting, so the test can't pass vacuously. Red-green re-verified: fails on the pre-fix file, passes with the fix. Pushed as a2c34b9.

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.

Thanks, that addresses it. I ran the updated test against the base (pre-fix) code three times: the transient-failure case failed every time. So with Bun.sleep(0) after the request is seen, the assertion runs after the rejection is handled and still catches the bug.

@ggrabarz

Copy link
Copy Markdown

Picking up the bot's suggestion from #57532 (auto-closed as a duplicate of this PR).

This PR and #55258 each cover one half of the problem. #55258 documents its own limitation: "a location that stays unreachable past the retry window (~1.5s) still surfaces the recovery prompt, and a genuinely missing directory takes that long to report."

Combining both halves — and splitting the location state into missing (the server's typed LocationNotFoundError) and error (anything else) — closes that gap: a persistent transient failure, e.g. a 503 from the plugin-activation gate while several locations activate at once, never mounts the recovery prompt. The composer stays usable and the failure is reported as location.error rather than dropped.

Two smaller things that belong with it:

  • the missing directory is compared with sameDirectory, so a Windows path that differs only in case still matches. A plain === misses a genuinely missing directory on Windows.
  • the duplicated syncInfo read is dropped — data.location.sync already reads the location before its resources (packages/client/src/solid/data.ts), so every failed sync asked the server for the location twice.

Regression tests:

  • packages/tui/test/session-location-unavailable.test.tsx drives the real app: a transient /api/model 503 is retried and the composer stays usable, a persistent 503 keeps the composer usable and the text still types, and a LocationNotFoundError still shows the recovery prompt. Reverting the source change reproduces the reported screen verbatim.
  • packages/tui/test/cli/tui/location-provider.test.tsx: a missing location is asked exactly once (retrying cannot succeed until the folder changes), while any other failure is retried and lands in error, never in missing.

If a single combined PR is preferred, the implementation is on ggrabarz:tui-location-sync (commit 91e2fb9da) and I can port the tests here instead — happy to go whichever way is easier.

@ggrabarz

Copy link
Copy Markdown

Merged PR #57594 supersedes this one — it landed the same behaviour (only the server's typed LocationNotFoundError marks a location missing, everything else is retried) and closed #55250 and #49352. There is nothing left to consolidate here, so this PR can be closed as already-landed.

Two things are worth adding on top of it, both of which are now open:

No need to reconcile these with anything here — they are additive and touch different files.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants