Files
hermes-webui/tests/test_clarify_unblock.py
Arsh Kumar Singh d385db69d5 fix(clarify): require stable clarify_id and wait for backend ack so stale responses are rejected
The WebUI clarification popup had a response-delivery failure: users
submitted answers in the popup, but the agent still fell through to the
timeout fallback message.  Three bugs conspired:

1. No stable clarify_id — _ClarifyEntry had no unique identifier, so
   the frontend could not reference a specific pending prompt.  The
   backend used FIFO resolution which silently failed for stale/late
   responses.

2. Frontend hid the card before confirmation — respondClarify() called
   hideClarifyCard(true, 'sent') BEFORE the API call completed.  If the
   backend rejected the response, the card was already gone and the
   user's draft was discarded.

3. Backend lied about success — _resolve_clarify_legacy() returned
   bool(resolved) or not bool(clarify_id).  Since the frontend never
   sent clarify_id, the backend always reported ok:true even when
   nothing was resolved.

Changes:

api/clarify.py:
- _ClarifyEntry now auto-generates a stable clarify_id (uuid4.hex[:12])
- submit_pending() injects clarify_id into the data dict visible to the
  frontend via SSE and polling
- New resolve_clarify_by_id() for O(1) lookup by id instead of FIFO pop

api/routes.py:
- _resolve_clarify_legacy() uses resolve_clarify_by_id when clarify_id
  is provided; returns actual bool result (no more unconditional True)
- _handle_clarify_respond() returns HTTP 409 + {ok:false, stale:true}
  when resolution fails

static/messages.js:
- respondClarify() now sends clarify_id in the POST body
- Waits for a positive backend acknowledgement before hiding the card
- Saves a draft copy before POST and restores it on failure
- On 409/network error: re-enables controls, shows error toast
- Guards against parallel-SSE race where clearing the cache after a
  successful response could erase a newly queued next prompt (codex P1)

tests:
- Updated test_sprint30.py for new ack-before-hide behaviour
- Updated test_clarify_unblock.py for 409 on stale responses

Closes #2639.
2026-05-20 16:35:15 +00:00

182 lines
5.8 KiB
Python

"""Tests for clarify prompt unblocking and HTTP endpoints."""
import json
import uuid
import urllib.request
import urllib.error
import urllib.parse
import pytest
from tests._pytest_port import BASE
try:
from api.clarify import (
register_gateway_notify,
unregister_gateway_notify,
resolve_clarify,
clear_pending,
_gateway_queues,
_gateway_notify_cbs,
_lock,
_ClarifyEntry,
submit_pending,
)
CLARIFY_AVAILABLE = True
except ImportError:
CLARIFY_AVAILABLE = False
pytestmark = pytest.mark.skipif(
not CLARIFY_AVAILABLE,
reason="api.clarify not available in this environment",
)
def get(path):
url = BASE + path
with urllib.request.urlopen(url, timeout=10) as r:
return json.loads(r.read())
def post(path, body=None):
url = BASE + path
data = json.dumps(body or {}).encode()
req = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"})
try:
with urllib.request.urlopen(req, timeout=10) as r:
return json.loads(r.read()), r.status
except urllib.error.HTTPError as e:
return json.loads(e.read()), e.code
class TestClarifyUnblocking:
"""Unit tests for clarify queue resolution."""
def test_resolve_clarify_sets_event(self):
sid = f"unit-clarify-{uuid.uuid4().hex[:8]}"
entry = _ClarifyEntry({"question": "Pick one", "choices_offered": ["a", "b"]})
with _lock:
_gateway_queues.setdefault(sid, []).append(entry)
resolved = resolve_clarify(sid, "a", resolve_all=False)
assert resolved == 1
assert entry.event.is_set()
assert entry.result == "a"
def test_register_and_fire_notify_cb(self):
sid = f"unit-notify-{uuid.uuid4().hex[:8]}"
fired = []
register_gateway_notify(sid, lambda d: fired.append(d))
with _lock:
cb = _gateway_notify_cbs.get(sid)
assert cb is not None
data = {"question": "What now?", "choices_offered": ["x", "y"]}
cb(data)
assert fired == [data]
unregister_gateway_notify(sid)
def test_clear_pending_unblocks_waiters(self):
sid = f"unit-clear-{uuid.uuid4().hex[:8]}"
entry = _ClarifyEntry({"question": "Wait", "choices_offered": []})
with _lock:
_gateway_queues.setdefault(sid, []).append(entry)
cleared = clear_pending(sid)
assert cleared == 1
assert entry.event.is_set()
with _lock:
assert sid not in _gateway_queues
def test_submit_pending_registers_entry(self):
sid = f"unit-submit-{uuid.uuid4().hex[:8]}"
data = {"question": "Pick", "choices_offered": ["one", "two"], "session_id": sid}
entry = submit_pending(sid, data)
assert entry.data["question"] == data["question"]
assert entry.data["choices_offered"] == data["choices_offered"]
assert entry.data["session_id"] == data["session_id"]
with _lock:
assert sid in _gateway_queues
clear_pending(sid)
def test_submit_pending_adds_timeout_metadata(self):
sid = f"unit-timeout-{uuid.uuid4().hex[:8]}"
entry = submit_pending(sid, {"question": "Wait", "choices_offered": []})
assert isinstance(entry.data["requested_at"], (int, float))
assert entry.data["timeout_seconds"] == 120
assert entry.data["expires_at"] == pytest.approx(
entry.data["requested_at"] + 120,
abs=0.1,
)
clear_pending(sid)
class TestClarifyModuleExports:
def test_register_gateway_notify_exported(self):
import api.clarify as ap
assert hasattr(ap, "register_gateway_notify")
def test_unregister_gateway_notify_exported(self):
import api.clarify as ap
assert hasattr(ap, "unregister_gateway_notify")
def test_resolve_clarify_exported(self):
import api.clarify as ap
assert hasattr(ap, "resolve_clarify")
def test_clarify_entry_exported(self):
import api.clarify as ap
assert hasattr(ap, "_ClarifyEntry")
class TestClarifyHTTPEndpoints:
"""Regression tests for /api/clarify/respond against the live test server."""
def test_respond_returns_stale_when_no_pending(self):
"""When no clarify prompt is pending, respond returns 409 (issue #2639)."""
sid = f"http-no-pending-{uuid.uuid4().hex[:8]}"
result, status = post("/api/clarify/respond", {
"session_id": sid,
"response": "Use option A",
})
assert status == 409
assert result["ok"] is False
assert result.get("stale") is True
def test_respond_requires_session_id(self):
result, status = post("/api/clarify/respond", {"response": "Hello"})
assert status == 400
def test_respond_requires_response(self):
sid = f"http-no-response-{uuid.uuid4().hex[:8]}"
result, status = post("/api/clarify/respond", {"session_id": sid})
assert status == 400
def test_respond_clears_injected_pending(self):
sid = f"http-clear-{uuid.uuid4().hex[:8]}"
question = urllib.parse.quote("Pick the better option")
choices = urllib.parse.quote("A")
inject = get(
f"/api/clarify/inject_test?session_id={urllib.parse.quote(sid)}"
f"&question={question}&choices={choices}"
)
assert inject["ok"] is True
data = get(f"/api/clarify/pending?session_id={urllib.parse.quote(sid)}")
assert data["pending"] is not None
result, status = post("/api/clarify/respond", {
"session_id": sid,
"response": "B",
})
assert status == 200
assert result["ok"] is True
data2 = get(f"/api/clarify/pending?session_id={urllib.parse.quote(sid)}")
assert data2["pending"] is None