-
-
Notifications
You must be signed in to change notification settings - Fork 2.6k
fix(approval): stale local approval card clears instead of dead-ending on "Approval response not accepted." (#4948 local variant, #4771 follow-up) #4955
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -5625,12 +5625,35 @@ async function respondApproval(choice) { | |||||||||||||||||||||||||||||
| _approvalResponding = null; | ||||||||||||||||||||||||||||||
| const pendingEntry = _approvalPendingBySession.get(sid); | ||||||||||||||||||||||||||||||
| const samePending = !!(pendingEntry && pendingEntry.pending && (pendingEntry.pending.approval_id || null) === (approvalId || null)); | ||||||||||||||||||||||||||||||
| if (_approvalSessionId === sid && _approvalCurrentId === approvalId) { | ||||||||||||||||||||||||||||||
| // `stale_cleared` means the server found nothing pending for this session | ||||||||||||||||||||||||||||||
| // (the approval already resolved or its stream ended while the card was | ||||||||||||||||||||||||||||||
| // up). The orphan card must be cleared unconditionally so it can never | ||||||||||||||||||||||||||||||
| // get stuck — even if the displayed id has since drifted. (#4948 local | ||||||||||||||||||||||||||||||
| // variant: previously surfaced as a stuck "Approval response not | ||||||||||||||||||||||||||||||
| // accepted." toast.) | ||||||||||||||||||||||||||||||
| if (result.stale_cleared || (_approvalSessionId === sid && _approvalCurrentId === approvalId)) { | ||||||||||||||||||||||||||||||
| _approvalSessionId = null; | ||||||||||||||||||||||||||||||
| _approvalCurrentId = null; | ||||||||||||||||||||||||||||||
| hideApprovalCard(true); | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| if (samePending) _clearApprovalPendingForSession(sid); | ||||||||||||||||||||||||||||||
| if (samePending || result.stale_cleared) _clearApprovalPendingForSession(sid); | ||||||||||||||||||||||||||||||
|
greptile-apps[bot] marked this conversation as resolved.
greptile-apps[bot] marked this conversation as resolved.
Comment on lines
+5634
to
+5639
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||||||||||||||||||||||
| // Hardening for the narrow stale-clear race: a brand-new approval could | ||||||||||||||||||||||||||||||
| // have been parked server-side after the server's empty-check but before | ||||||||||||||||||||||||||||||
| // we processed this stale response. The unconditional clear above would | ||||||||||||||||||||||||||||||
| // hide that fresh card. Re-query the authoritative server pending state | ||||||||||||||||||||||||||||||
| // (same endpoint the fallback poll uses) so any approval that arrived in | ||||||||||||||||||||||||||||||
| // the window re-surfaces immediately instead of waiting for the next | ||||||||||||||||||||||||||||||
| // SSE/poll tick. Best-effort; poll/SSE remain the backstop. (Opus review | ||||||||||||||||||||||||||||||
| // nit on the #4948 fix.) | ||||||||||||||||||||||||||||||
| if (result.stale_cleared) { | ||||||||||||||||||||||||||||||
| api("/api/approval/pending?session_id=" + encodeURIComponent(sid), {timeoutToast: false}) | ||||||||||||||||||||||||||||||
| .then(data => { | ||||||||||||||||||||||||||||||
| if (data && data.pending && _approvalPromptBelongsToActiveSession(sid)) { | ||||||||||||||||||||||||||||||
| showApprovalForSession(sid, data.pending, data.pending_count || 1); | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||
| .catch(() => {}); | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| return; | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| const errMsg = (result && result.error) || "Approval response not accepted."; | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,226 @@ | ||||||||||||||||||||||||||
| """Regression tests for the #4948 LOCAL-backend variant (Jamie/.666 report): | ||||||||||||||||||||||||||
| a STALE approval card click must clear gracefully, not dead-end on | ||||||||||||||||||||||||||
| "Approval response not accepted." with a stuck card. | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| Background | ||||||||||||||||||||||||||
| ---------- | ||||||||||||||||||||||||||
| #4771 changed the frontend `respondApproval` from fire-and-forget to checking | ||||||||||||||||||||||||||
| `result.ok` and surfacing a toast on failure. That exposed a pre-existing | ||||||||||||||||||||||||||
| local-backend edge: when a guarded command's approval card is still on screen | ||||||||||||||||||||||||||
| but its stream has ended (user cancel / fork / provider error / completion | ||||||||||||||||||||||||||
| while pending), the agent's gateway entry is dropped and reconcile purges the | ||||||||||||||||||||||||||
| mirrored `_pending` entry. A click then sends the held `approval_id`, which | ||||||||||||||||||||||||||
| `_resolve_approval_legacy` cannot match -> returns False -> the handler | ||||||||||||||||||||||||||
| returned a bare `{ok: false}` (no `error`) -> the frontend showed | ||||||||||||||||||||||||||
| "Approval response not accepted." with a STUCK card. (Reported by Jamie on | ||||||||||||||||||||||||||
| v0.51.666 and b3nw on Discord; the local-backend sibling of the gateway-side | ||||||||||||||||||||||||||
| #4948.) | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| The fix: when the local resolution returns False AND the session has no live | ||||||||||||||||||||||||||
| pending approval at all, treat the click as a benign stale-card clear and | ||||||||||||||||||||||||||
| return `{ok: True, stale_cleared: True}` so the UI clears the orphan card. | ||||||||||||||||||||||||||
| The #527 protective guard is preserved: a stale explicit-id click made WHILE a | ||||||||||||||||||||||||||
| DIFFERENT approval is still live must stay `ok: false` so it can never resolve | ||||||||||||||||||||||||||
| the wrong command. | ||||||||||||||||||||||||||
| """ | ||||||||||||||||||||||||||
| from __future__ import annotations | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| import json | ||||||||||||||||||||||||||
| import threading | ||||||||||||||||||||||||||
| import uuid | ||||||||||||||||||||||||||
| from unittest.mock import patch | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| import pytest | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| from api import routes | ||||||||||||||||||||||||||
| from api import models | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| try: | ||||||||||||||||||||||||||
| import tools.approval as ta | ||||||||||||||||||||||||||
| from api import route_approvals as ra | ||||||||||||||||||||||||||
| APPROVAL_AVAILABLE = True | ||||||||||||||||||||||||||
| except ImportError: | ||||||||||||||||||||||||||
| ta = None | ||||||||||||||||||||||||||
| ra = None | ||||||||||||||||||||||||||
| APPROVAL_AVAILABLE = False | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| pytestmark = pytest.mark.skipif( | ||||||||||||||||||||||||||
| not APPROVAL_AVAILABLE, | ||||||||||||||||||||||||||
| reason="tools.approval not available in this environment", | ||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| class _FakeHandler: | ||||||||||||||||||||||||||
| def __init__(self): | ||||||||||||||||||||||||||
| self.status = None | ||||||||||||||||||||||||||
| self._body = b"" | ||||||||||||||||||||||||||
| self.client_address = ("127.0.0.1", 0) | ||||||||||||||||||||||||||
| self.headers = {} | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| class _W: | ||||||||||||||||||||||||||
| def __init__(self, outer): | ||||||||||||||||||||||||||
| self.outer = outer | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| def write(self, b): | ||||||||||||||||||||||||||
| self.outer._body += b | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| self.wfile = _W(self) | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| def send_response(self, code): | ||||||||||||||||||||||||||
| self.status = code | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| def send_header(self, k, v): | ||||||||||||||||||||||||||
| pass | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| def end_headers(self): | ||||||||||||||||||||||||||
| pass | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| def json(self): | ||||||||||||||||||||||||||
| return json.loads(self._body.decode("utf-8")) | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| def _register_session(sid: str): | ||||||||||||||||||||||||||
| s = models.Session(session_id=sid, title="approval-4948-local") | ||||||||||||||||||||||||||
| s.active_stream_id = None | ||||||||||||||||||||||||||
| with models.LOCK: | ||||||||||||||||||||||||||
| models.SESSIONS[sid] = s | ||||||||||||||||||||||||||
| return s | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| def _park_local_approval(sid: str, command: str = "rm -rf /tmp/x", key: str = "dangerous_command"): | ||||||||||||||||||||||||||
| """Wire the notify callback exactly like api/streaming.py and park a | ||||||||||||||||||||||||||
| guarded command on a background thread (it blocks awaiting approval). | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| Returns the rendered approval_id (what the frontend card holds), taken from | ||||||||||||||||||||||||||
| /api/approval/pending — the same surface the real UI reads. | ||||||||||||||||||||||||||
| """ | ||||||||||||||||||||||||||
| with ta._lock: | ||||||||||||||||||||||||||
| ta._gateway_queues.pop(sid, None) | ||||||||||||||||||||||||||
| ta._pending.pop(sid, None) | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| def _cb(approval_data): | ||||||||||||||||||||||||||
| ra.submit_gateway_pending_mirror(sid, approval_data) | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| ta.register_gateway_notify(sid, _cb) | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| def _agent(): | ||||||||||||||||||||||||||
| ad = { | ||||||||||||||||||||||||||
| "command": command, | ||||||||||||||||||||||||||
| "pattern_key": key, | ||||||||||||||||||||||||||
| "pattern_keys": [key], | ||||||||||||||||||||||||||
| "description": "Dangerous command", | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| ta._await_gateway_decision(sid, ta._gateway_notify_cbs.get(sid), ad, surface="gateway") | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| th = threading.Thread(target=_agent, daemon=True) | ||||||||||||||||||||||||||
| th.start() | ||||||||||||||||||||||||||
| th.join(timeout=1.5) # still blocked; the queue is seeded | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| h = _FakeHandler() | ||||||||||||||||||||||||||
| routes._handle_approval_pending(h, type("P", (), {"query": f"session_id={sid}"})()) | ||||||||||||||||||||||||||
| return (h.json().get("pending") or {}).get("approval_id") | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| def _end_stream_drop_entry(sid: str): | ||||||||||||||||||||||||||
| """Simulate the stream ending while the card is pending: the agent's | ||||||||||||||||||||||||||
| gateway entry is dropped (cancel/fork/error/completion) and reconcile | ||||||||||||||||||||||||||
| purges the orphaned _pending mirror — exactly what _drop_entry + | ||||||||||||||||||||||||||
| _cleanup_gateway_pending_mirror do.""" | ||||||||||||||||||||||||||
| with ta._lock: | ||||||||||||||||||||||||||
| ta._gateway_queues.pop(sid, None) | ||||||||||||||||||||||||||
| with ra._lock: | ||||||||||||||||||||||||||
| ra.reconcile_gateway_pending_mirror_locked(sid) | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| def _respond(sid: str, approval_id: str, choice: str = "once"): | ||||||||||||||||||||||||||
| h = _FakeHandler() | ||||||||||||||||||||||||||
| with patch("api.gateway_chat.webui_gateway_chat_enabled", return_value=False): | ||||||||||||||||||||||||||
| routes._handle_approval_respond( | ||||||||||||||||||||||||||
| h, {"session_id": sid, "choice": choice, "approval_id": approval_id} | ||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||
| return h | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| def _cleanup(sid: str): | ||||||||||||||||||||||||||
| with ta._lock: | ||||||||||||||||||||||||||
| ta._gateway_queues.pop(sid, None) | ||||||||||||||||||||||||||
| ta._pending.pop(sid, None) | ||||||||||||||||||||||||||
| with models.LOCK: | ||||||||||||||||||||||||||
| models.SESSIONS.pop(sid, None) | ||||||||||||||||||||||||||
|
Comment on lines
+144
to
+149
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The helper removes queue entries directly but does not unregister the notify callback or signal the parked
Suggested change
Comment on lines
+144
to
+149
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| def test_stale_card_click_clears_not_dead_ends(): | ||||||||||||||||||||||||||
| """The Jamie/.666 bug: clicking a card whose approval is gone returns a | ||||||||||||||||||||||||||
| benign cleared result, NOT a bare ok:false (which the UI rendered as | ||||||||||||||||||||||||||
| 'Approval response not accepted.' with a stuck card).""" | ||||||||||||||||||||||||||
| sid = f"stale-local-{uuid.uuid4().hex[:8]}" | ||||||||||||||||||||||||||
| _register_session(sid) | ||||||||||||||||||||||||||
| try: | ||||||||||||||||||||||||||
| card_id = _park_local_approval(sid) | ||||||||||||||||||||||||||
| assert card_id, "precondition: a pending approval card should be rendered" | ||||||||||||||||||||||||||
| _end_stream_drop_entry(sid) | ||||||||||||||||||||||||||
| # Nothing is pending now. | ||||||||||||||||||||||||||
| assert routes._session_has_pending_approval(sid) is False | ||||||||||||||||||||||||||
| resp = _respond(sid, card_id) | ||||||||||||||||||||||||||
| body = resp.json() | ||||||||||||||||||||||||||
| assert resp.status == 200, f"expected 200, got {resp.status}: {body}" | ||||||||||||||||||||||||||
| assert body.get("ok") is True, f"stale card must clear gracefully: {body}" | ||||||||||||||||||||||||||
| assert body.get("stale_cleared") is True | ||||||||||||||||||||||||||
| # Crucially: no bare ok:false-without-error (the stuck-card symptom). | ||||||||||||||||||||||||||
| finally: | ||||||||||||||||||||||||||
| _cleanup(sid) | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| def test_fresh_local_approval_still_resolves(): | ||||||||||||||||||||||||||
| """A normal, live local approval still resolves (200/ok) and is NOT | ||||||||||||||||||||||||||
| mislabeled stale_cleared.""" | ||||||||||||||||||||||||||
| sid = f"fresh-local-{uuid.uuid4().hex[:8]}" | ||||||||||||||||||||||||||
| _register_session(sid) | ||||||||||||||||||||||||||
| try: | ||||||||||||||||||||||||||
| card_id = _park_local_approval(sid) | ||||||||||||||||||||||||||
| assert card_id | ||||||||||||||||||||||||||
| assert routes._session_has_pending_approval(sid) is True | ||||||||||||||||||||||||||
| resp = _respond(sid, card_id, choice="once") | ||||||||||||||||||||||||||
| body = resp.json() | ||||||||||||||||||||||||||
| assert resp.status == 200, f"{body}" | ||||||||||||||||||||||||||
| assert body.get("ok") is True | ||||||||||||||||||||||||||
| assert not body.get("stale_cleared"), "a live approval must not be tagged stale_cleared" | ||||||||||||||||||||||||||
| finally: | ||||||||||||||||||||||||||
| _cleanup(sid) | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| def test_stale_id_while_different_approval_live_still_blocked(): | ||||||||||||||||||||||||||
| """#527 guard preserved: a stale explicit-id click made WHILE a different | ||||||||||||||||||||||||||
| approval is live must NOT resolve the live one (stays ok:false) and must | ||||||||||||||||||||||||||
| leave the live approval pending.""" | ||||||||||||||||||||||||||
| sid = f"guard-local-{uuid.uuid4().hex[:8]}" | ||||||||||||||||||||||||||
| _register_session(sid) | ||||||||||||||||||||||||||
| try: | ||||||||||||||||||||||||||
| live_id = _park_local_approval(sid, command="rm -rf /tmp/B", key="dangerous_B") | ||||||||||||||||||||||||||
| assert live_id | ||||||||||||||||||||||||||
| stale_id = uuid.uuid4().hex # a long-gone approval A | ||||||||||||||||||||||||||
| resp = _respond(sid, stale_id, choice="once") | ||||||||||||||||||||||||||
| body = resp.json() | ||||||||||||||||||||||||||
| assert body.get("ok") is False, f"stale id must not resolve while B is live: {body}" | ||||||||||||||||||||||||||
| assert not body.get("stale_cleared") | ||||||||||||||||||||||||||
| # B is still pending and unchanged. | ||||||||||||||||||||||||||
| assert routes._session_has_pending_approval(sid) is True | ||||||||||||||||||||||||||
| h = _FakeHandler() | ||||||||||||||||||||||||||
| routes._handle_approval_pending(h, type("P", (), {"query": f"session_id={sid}"})()) | ||||||||||||||||||||||||||
| assert (h.json().get("pending") or {}).get("approval_id") == live_id | ||||||||||||||||||||||||||
| finally: | ||||||||||||||||||||||||||
| _cleanup(sid) | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| def test_session_has_pending_approval_predicate(): | ||||||||||||||||||||||||||
| """Unit: the predicate the benign-clear hinges on reports live vs empty.""" | ||||||||||||||||||||||||||
| sid = f"pred-{uuid.uuid4().hex[:8]}" | ||||||||||||||||||||||||||
| _register_session(sid) | ||||||||||||||||||||||||||
| try: | ||||||||||||||||||||||||||
| assert routes._session_has_pending_approval(sid) is False | ||||||||||||||||||||||||||
| _park_local_approval(sid) | ||||||||||||||||||||||||||
| assert routes._session_has_pending_approval(sid) is True | ||||||||||||||||||||||||||
| _end_stream_drop_entry(sid) | ||||||||||||||||||||||||||
| assert routes._session_has_pending_approval(sid) is False | ||||||||||||||||||||||||||
| finally: | ||||||||||||||||||||||||||
| _cleanup(sid) | ||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
stale_cleared, this branch clears whatever approval card is visible instead of only clearing the card that submitted the response. If approval B is shown before approval A's stale response is handled, the stale response hides B and clears the session's pending state. The follow-up fetch is best-effort and only queries the submittedsid, so it cannot reliably restore a newer card from another active session.