nesquena/hermes-webui

Approval card ignores the first click, buttons only respond after the poll re-syncs the approval_id

Open

#7,091 opened on Aug 16, 2026

 (3 comments) (0 reactions) (0 assignees)Python (2,399 forks)github user discovery
bughelp wantedsprint-candidate

Repository metrics

Stars
 (17,426 stars)
PR merge metrics
 (Avg merge 14h 31m) (314 merged PRs in 30d)

Description

Summary

On the local in-process (non-gateway) backend, an approval card ignores the first click on any action button. The first click does nothing and shows an "Approval response not accepted." toast. The button only works after a short delay (about 1.5 to 2 seconds). If the user clicks rapidly, several clicks are needed before one registers. A single click made after that delay works on the first try. The issue still happens after a hard refresh, so it is not a stale cached bundle.

Environment

  • Version: exp-v0.52.228 (commit dc3bf44e, 2026-08-14)
  • Backend: default local in-process WebUI chat runtime, no gateway
  • Browser: Firefox 153.0.4 (also happens after a hard refresh)
  • Config: approvals.mode: manual

To reproduce

  1. Trigger any guarded command so an approval card appears. For example, run a destructive command like rm -rf <throwaway-dir> with approvals.mode: manual.
  2. Click "Allow once" (or Allow session / Always allow / Deny) immediately.
  3. Expected: the click resolves the approval and the agent resumes.
  4. Actual: the first click does nothing and a toast appears with "Approval response not accepted."
  5. Click the button rapidly. Several clicks are needed before one works, usually after about 1.5 to 2 seconds.
  6. A single click made after that delay works on the first try.

Secondary repro: multiple queued approvals

When more than one approval is queued for the same session (the card shows a "1 of N pending" counter), the same stale-id problem gets worse. Approving the currently shown head can leave the card re-rendered with the next entry's id, and a click that lands during that re-render window is rejected again, so it needs another click. This makes fast workflow on queued approvals error-prone.

Behavior observed in server logs

During fast clicking, the server receives a burst of POST /api/approval/respond requests within the same second (for example, four requests at 17:46:06-07). Each returns HTTP 200. A 200 status is not a success here. On the local path the server sends the rejection as a JSON body of {ok:false} with an HTTP 200 status. The client only treats a click as accepted when result.ok is truthy, so these are all rejections. This shows the click handler is firing and the network round-trip is fast (about 4 to 10 ms). The clicks are rejected because the submitted approval_id is stale, not because the handler failed to load.

Root cause analysis

The approval card's id is fed by two independent sources that can carry different approval_id values, and the client does not reconcile them before the click.

  1. The SSE approval event handler at static/messages.js (about line 5921) calls showApprovalForSession(activeSid, d, d.pending_count || 1).
  2. The 1.5 second fallback poll at static/messages.js (about line 7684) calls showApprovalForSession(sid, data.pending, ...). Both end in showApprovalCard (static/messages.js, about line 7469), which sets _approvalCurrentId = pending.approval_id. When the two sources deliver a different id, or the card's id has not yet been reconciled to the server's current pending head, the card's _approvalCurrentId is stale at click time. respondApproval (static/messages.js, about line 7594) then posts that stale id to /api/approval/respond. On the local path, _handle_approval_respond (api/routes.py) returns ok:false with HTTP 200 whenever the submitted id does not match the live pending head while a different approval is still pending. This is the deliberate #527 guard, so a stale click can never approve the wrong command. The failed click invokes _restoreFailedApprovalResponse, which re-renders the card via _renderPendingApprovalForActiveSession. The fallback poll then corrects _approvalCurrentId to the authoritative id, so the next click succeeds. The about 1.5 second poll interval (plus render) is what the user perceives as the "about 2 seconds until the button responds."

Expected behavior

  • A single click on any approval button should resolve the approval on the first try, no matter how quickly it is made after the card appears.
  • The card must not require a wasted, guaranteed-rejected first click to fix itself. The client should reconcile _approvalCurrentId against the authoritative pending head (for example /api/approval/pending) at or before click time, so the value submitted by respondApproval always matches what the server expects.
  • Ideally, the SSE event path and the fallback poll should deliver a consistent, server-authoritative approval_id so the card never renders with a stale id in the first place.
  • For the queued case, advancing to the next approval should update the card's id synchronously so the next click is never rejected during the re-render window.

Suggested area to investigate

  • static/messages.js:
    • showApprovalCard / showApprovalForSession: ensure a single, authoritative approval_id is used, and reconcile it against /api/approval/pending before the click handler reads it.
    • respondApproval / _restoreFailedApprovalResponse: avoid depending on a post-rejection re-render to correct the id.
  • api/routes.py _handle_approval_respond: consider whether the local path can surface the stale-id case as something the client can fix immediately, without requiring a second user click, while keeping the #527 guard.

Contributor guide