HERMES-GIELIE/tests/test_clarify_unblock.py

182 lines
5.8 KiB
Python
Raw Normal View History

"""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."""
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
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",
})
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
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