Skip to content

fix(tui): recover from stale forms - #44507

Closed
opencode-agent[bot] wants to merge 1 commit into
v2from
stale-form-recovery
Closed

opencode-agent[bot] wants to merge 1 commit into
v2from
stale-form-recovery

Conversation

@opencode-agent

Copy link
Copy Markdown
Contributor

Summary

  • treat FormNotFoundError from form replies and cancellations as a terminal stale-form state
  • immediately release form key interception and hide the stale prompt
  • refresh the form cache so the normal composer can resume
  • cover both reply and escape/cancel recovery paths with TUI regressions

Verification

  • bun test test/cli/tui/form.test.tsx --timeout 30000 --only-failures
  • bun typecheck

Requested by: @kitlangton (Kit via Slack)

@Enough1122

Copy link
Copy Markdown

AI code review — automated review for reference, author can ignore or act on any point.

Solid fix for a real UX dead-end. The mechanism checks out end-to-end: both form endpoints declare declaredStatuses: [404, 409, 400, 401] (packages/client/src/promise/generated/client.ts:1331, :1343), so the promise client throws the parsed tagged body directly and the _tag-narrowing guards work as written. The early releaseMode + visible={!dismissed()} approach keeps cleanup ordering safe (mode stack splice by symbol id, layers inert once the mode pops), and the new regression tests cover both entry points.

Suggestions:

  1. FormAlreadySettledError (409) still traps the user — packages/tui/src/routes/session/form.tsx:273-279 (reply) and :478-484 (cancel) only special-case FormNotFoundError. A form answered or cancelled by another client (TUI + web open concurrently — likely the most common staleness case) returns 409 {_tag: "FormAlreadySettledError"}, which falls through to setStore("error", ...) and leaves the prompt up even though server state has settled. Suggested fix: also import isFormAlreadySettledError (exported from @opencode-ai/client via generated/types.js) and route that case through dismissStale() as well — optionally after toast.info("Form was answered elsewhere") so users aren't confused by the silent disappearance. Keeping 400 inline is right since invalid-answer errors are user-fixable.

  2. Idempotency guard in dismissStale — form.tsx:487-493. Two rapid Enter presses can put two replies in flight; both 404 and each runs invalidate + sync. A cheap if (dismissed()) return at the top avoids redundant refetch churn.

  3. Custom handler flows — dismissal now also triggers when embedder-provided props.onReply/props.onCancel reject with FormNotFoundError (form.tsx:266-267, :471-472), releasing FORM_MODE and re-syncing the shared form store under a host that may own its own lifecycle. Probably desired for consistency; just confirming it's intentional.

Minor: nice touch that the test fixture already stubs /api/session/{id}/form GET (packages/tui/test/fixture/tui-client.ts:145), so the post-dismissal sync() resolves against an empty list rather than relying on the .catch(toast.error).

@kitlangton

Copy link
Copy Markdown
Contributor

Superseded by #44516, which handles both missing and already-settled forms and restores the composer from local projection state before background revalidation.

@kitlangton kitlangton closed this Aug 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants