On the default local in-process backend, an approval card whose stream
ended while still pending (cancel/fork/provider-error/completion) left the
agent queue entry dropped and the _pending mirror reconciled away. Clicking
Approve/Deny then sent an approval_id that matched nothing, so the handler
returned a bare {ok:false}; since #4771 the frontend surfaces that as
'Approval response not accepted.' with a stuck card (reported by
@santastabber on v0.51.666 and b3nw).
Distinguish a genuinely stale card (no pending approval for the session ->
benign {ok:true, stale_cleared:true} so the UI clears the orphan) from a
stale-id click made while a DIFFERENT approval is live (still ok:false so it
can never resolve the wrong command -- #527 guard preserved). Frontend
clears the orphan card on stale_cleared even if the displayed id drifted.
Co-authored-by: b3nw <b3nw@users.noreply.github.com>
227 lines
7.9 KiB
Python
227 lines
7.9 KiB
Python
"""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)
|
|
|
|
|
|
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)
|