feat: harden clarify dialog flow and refresh recovery
This commit is contained in:
128
api/clarify.py
Normal file
128
api/clarify.py
Normal file
@@ -0,0 +1,128 @@
|
||||
"""Clarify prompt state for the WebUI.
|
||||
|
||||
This mirrors the approval flow structure, but the response is a free-form
|
||||
clarification string instead of an approval decision.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from typing import Optional
|
||||
|
||||
|
||||
_lock = threading.Lock()
|
||||
_pending: dict[str, dict] = {}
|
||||
_gateway_queues: dict[str, list] = {}
|
||||
_gateway_notify_cbs: dict[str, object] = {}
|
||||
|
||||
|
||||
class _ClarifyEntry:
|
||||
"""One pending clarify request inside a session."""
|
||||
|
||||
__slots__ = ("event", "data", "result")
|
||||
|
||||
def __init__(self, data: dict):
|
||||
self.event = threading.Event()
|
||||
self.data = data
|
||||
self.result: Optional[str] = None
|
||||
|
||||
|
||||
def register_gateway_notify(session_key: str, cb) -> None:
|
||||
"""Register a per-session callback for sending clarify requests to the UI."""
|
||||
with _lock:
|
||||
_gateway_notify_cbs[session_key] = cb
|
||||
|
||||
|
||||
def _clear_queue_locked(session_key: str) -> list[_ClarifyEntry]:
|
||||
entries = _gateway_queues.pop(session_key, [])
|
||||
_pending.pop(session_key, None)
|
||||
return entries
|
||||
|
||||
|
||||
def unregister_gateway_notify(session_key: str) -> None:
|
||||
"""Unregister the per-session callback and unblock any waiting clarify prompt."""
|
||||
with _lock:
|
||||
_gateway_notify_cbs.pop(session_key, None)
|
||||
entries = _clear_queue_locked(session_key)
|
||||
for entry in entries:
|
||||
entry.event.set()
|
||||
|
||||
|
||||
def clear_pending(session_key: str) -> int:
|
||||
"""Clear any pending clarify prompts for the session without removing the callback."""
|
||||
with _lock:
|
||||
entries = _clear_queue_locked(session_key)
|
||||
for entry in entries:
|
||||
entry.event.set()
|
||||
return len(entries)
|
||||
|
||||
|
||||
def submit_pending(session_key: str, data: dict) -> _ClarifyEntry:
|
||||
"""Queue a pending clarify request and notify the UI callback if registered."""
|
||||
with _lock:
|
||||
queue = _gateway_queues.setdefault(session_key, [])
|
||||
# De-duplicate while unresolved: if the most recent pending clarify is
|
||||
# semantically identical, reuse it instead of stacking duplicates.
|
||||
if queue:
|
||||
last = queue[-1]
|
||||
if (
|
||||
str(last.data.get("question", "")) == str(data.get("question", ""))
|
||||
and list(last.data.get("choices_offered") or [])
|
||||
== list(data.get("choices_offered") or [])
|
||||
):
|
||||
entry = last
|
||||
cb = _gateway_notify_cbs.get(session_key)
|
||||
# Keep _pending aligned to the oldest unresolved entry.
|
||||
_pending[session_key] = queue[0].data
|
||||
if cb:
|
||||
try:
|
||||
cb(dict(entry.data))
|
||||
except Exception:
|
||||
pass
|
||||
return entry
|
||||
|
||||
entry = _ClarifyEntry(data)
|
||||
queue.append(entry)
|
||||
_pending[session_key] = queue[0].data
|
||||
cb = _gateway_notify_cbs.get(session_key)
|
||||
if cb:
|
||||
try:
|
||||
cb(data)
|
||||
except Exception:
|
||||
pass
|
||||
return entry
|
||||
|
||||
|
||||
def get_pending(session_key: str) -> dict | None:
|
||||
"""Return the oldest pending clarify request for this session, if any."""
|
||||
with _lock:
|
||||
queue = _gateway_queues.get(session_key) or []
|
||||
if queue:
|
||||
return dict(queue[0].data)
|
||||
pending = _pending.get(session_key)
|
||||
return dict(pending) if pending else None
|
||||
|
||||
|
||||
def has_pending(session_key: str) -> bool:
|
||||
with _lock:
|
||||
return bool(_gateway_queues.get(session_key))
|
||||
|
||||
|
||||
def resolve_clarify(session_key: str, response: str, resolve_all: bool = False) -> int:
|
||||
"""Resolve the oldest pending clarify request for a session."""
|
||||
with _lock:
|
||||
queue = _gateway_queues.get(session_key)
|
||||
if not queue:
|
||||
_pending.pop(session_key, None)
|
||||
return 0
|
||||
entries = list(queue) if resolve_all else [queue.pop(0)]
|
||||
if queue:
|
||||
_pending[session_key] = queue[0].data
|
||||
else:
|
||||
_clear_queue_locked(session_key)
|
||||
count = 0
|
||||
for entry in entries:
|
||||
entry.result = response
|
||||
entry.event.set()
|
||||
count += 1
|
||||
return count
|
||||
@@ -214,6 +214,18 @@ except ImportError:
|
||||
_lock = threading.Lock()
|
||||
_permanent_approved = set()
|
||||
|
||||
# Clarify prompts (optional -- graceful fallback if agent not available)
|
||||
try:
|
||||
from api.clarify import (
|
||||
submit_pending as submit_clarify_pending,
|
||||
get_pending as get_clarify_pending,
|
||||
resolve_clarify,
|
||||
)
|
||||
except ImportError:
|
||||
submit_clarify_pending = lambda *a, **k: None
|
||||
get_clarify_pending = lambda *a, **k: None
|
||||
resolve_clarify = lambda *a, **k: 0
|
||||
|
||||
|
||||
# ── Login page locale strings ─────────────────────────────────────────────────
|
||||
# Add entries here to support more languages on the login page.
|
||||
@@ -603,6 +615,15 @@ def handle_get(handler, parsed) -> bool:
|
||||
return j(handler, {"error": "not found"}, status=404)
|
||||
return _handle_approval_inject(handler, parsed)
|
||||
|
||||
if parsed.path == "/api/clarify/pending":
|
||||
return _handle_clarify_pending(handler, parsed)
|
||||
|
||||
if parsed.path == "/api/clarify/inject_test":
|
||||
# Loopback-only: used by automated tests; blocked from any remote client
|
||||
if handler.client_address[0] != "127.0.0.1":
|
||||
return j(handler, {"error": "not found"}, status=404)
|
||||
return _handle_clarify_inject(handler, parsed)
|
||||
|
||||
# ── Cron API (GET) ──
|
||||
if parsed.path == "/api/crons":
|
||||
from cron.jobs import list_jobs
|
||||
@@ -911,6 +932,10 @@ def handle_post(handler, parsed) -> bool:
|
||||
if parsed.path == "/api/approval/respond":
|
||||
return _handle_approval_respond(handler, body)
|
||||
|
||||
# ── Clarify (POST) ──
|
||||
if parsed.path == "/api/clarify/respond":
|
||||
return _handle_clarify_respond(handler, body)
|
||||
|
||||
# ── Skills (POST) ──
|
||||
if parsed.path == "/api/skills/save":
|
||||
return _handle_skill_save(handler, body)
|
||||
@@ -1672,6 +1697,34 @@ def _handle_approval_inject(handler, parsed):
|
||||
return j(handler, {"error": "session_id required"}, status=400)
|
||||
|
||||
|
||||
def _handle_clarify_pending(handler, parsed):
|
||||
sid = parse_qs(parsed.query).get("session_id", [""])[0]
|
||||
pending = get_clarify_pending(sid)
|
||||
if pending:
|
||||
return j(handler, {"pending": pending})
|
||||
return j(handler, {"pending": None})
|
||||
|
||||
|
||||
def _handle_clarify_inject(handler, parsed):
|
||||
"""Inject a fake pending clarify prompt -- loopback-only, used by automated tests."""
|
||||
qs = parse_qs(parsed.query)
|
||||
sid = qs.get("session_id", [""])[0]
|
||||
question = qs.get("question", ["Which option?"])[0]
|
||||
choices = qs.get("choices", [])
|
||||
if sid:
|
||||
submit_clarify_pending(
|
||||
sid,
|
||||
{
|
||||
"question": question,
|
||||
"choices_offered": choices,
|
||||
"session_id": sid,
|
||||
"kind": "clarify",
|
||||
},
|
||||
)
|
||||
return j(handler, {"ok": True, "session_id": sid})
|
||||
return j(handler, {"error": "session_id required"}, status=400)
|
||||
|
||||
|
||||
def _handle_live_models(handler, parsed):
|
||||
"""Return the live model list for a provider.
|
||||
|
||||
@@ -1892,6 +1945,24 @@ def _handle_chat_start(handler, body):
|
||||
except ValueError as e:
|
||||
return bad(handler, str(e))
|
||||
model = body.get("model") or s.model
|
||||
# Prevent duplicate runs in the same session while a stream is still active.
|
||||
# This commonly happens after page refresh/reconnect races and can produce
|
||||
# duplicated clarify cards for what appears to be a single user request.
|
||||
current_stream_id = getattr(s, "active_stream_id", None)
|
||||
if current_stream_id:
|
||||
with STREAMS_LOCK:
|
||||
current_active = current_stream_id in STREAMS
|
||||
if current_active:
|
||||
return j(
|
||||
handler,
|
||||
{
|
||||
"error": "session already has an active stream",
|
||||
"active_stream_id": current_stream_id,
|
||||
},
|
||||
status=409,
|
||||
)
|
||||
# Stale stream id from a previous run; clear and continue.
|
||||
s.active_stream_id = None
|
||||
stream_id = uuid.uuid4().hex
|
||||
s.workspace = workspace
|
||||
s.model = model
|
||||
@@ -2303,6 +2374,22 @@ def _handle_approval_respond(handler, body):
|
||||
return j(handler, {"ok": True, "choice": choice})
|
||||
|
||||
|
||||
def _handle_clarify_respond(handler, body):
|
||||
sid = body.get("session_id", "")
|
||||
if not sid:
|
||||
return bad(handler, "session_id is required")
|
||||
response = body.get("response")
|
||||
if response is None:
|
||||
response = body.get("answer")
|
||||
if response is None:
|
||||
response = body.get("choice")
|
||||
response = str(response or "").strip()
|
||||
if not response:
|
||||
return bad(handler, "response is required")
|
||||
resolve_clarify(sid, response, resolve_all=False)
|
||||
return j(handler, {"ok": True, "response": response})
|
||||
|
||||
|
||||
def _handle_skill_save(handler, body):
|
||||
try:
|
||||
require(body, "name", "content")
|
||||
|
||||
@@ -88,6 +88,16 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta
|
||||
if q is None:
|
||||
return
|
||||
|
||||
# ── MCP Server Discovery (lazy import, idempotent) ──
|
||||
# discover_mcp_tools() is called here (rather than at server startup) so that
|
||||
# the hermes-agent package is fully initialized before we try to connect.
|
||||
# It is safe to call multiple times — already-connected servers are skipped.
|
||||
try:
|
||||
from tools.mcp_tool import discover_mcp_tools
|
||||
discover_mcp_tools()
|
||||
except Exception:
|
||||
pass # MCP not available or not configured — non-fatal
|
||||
|
||||
# Sprint 10: create a cancel event for this stream
|
||||
cancel_event = threading.Event()
|
||||
with STREAMS_LOCK:
|
||||
@@ -162,6 +172,65 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta
|
||||
except ImportError:
|
||||
logger.debug("Approval module not available, falling back to polling")
|
||||
|
||||
_clarify_registered = False
|
||||
_unreg_clarify_notify = None
|
||||
try:
|
||||
from api.clarify import (
|
||||
register_gateway_notify as _reg_clarify_notify,
|
||||
unregister_gateway_notify as _unreg_clarify_notify,
|
||||
)
|
||||
|
||||
def _clarify_notify_cb(clarify_data):
|
||||
put('clarify', clarify_data)
|
||||
|
||||
_reg_clarify_notify(session_id, _clarify_notify_cb)
|
||||
_clarify_registered = True
|
||||
except ImportError:
|
||||
logger.debug("Clarify module not available, falling back to polling")
|
||||
|
||||
def _clarify_callback_impl(question, choices, sid, cancel_evt, put_event):
|
||||
"""Bridge Hermes clarify prompts to the WebUI."""
|
||||
timeout = 120
|
||||
choices_list = [str(choice) for choice in (choices or [])]
|
||||
data = {
|
||||
'question': str(question or ''),
|
||||
'choices_offered': choices_list,
|
||||
'session_id': sid,
|
||||
'kind': 'clarify',
|
||||
'requested_at': time.time(),
|
||||
}
|
||||
try:
|
||||
from api.clarify import submit_pending as _submit_clarify_pending, clear_pending as _clear_clarify_pending
|
||||
except ImportError:
|
||||
return (
|
||||
"The user did not provide a response within the time limit. "
|
||||
"Use your best judgement to make the choice and proceed."
|
||||
)
|
||||
|
||||
entry = _submit_clarify_pending(sid, data)
|
||||
deadline = time.monotonic() + timeout
|
||||
while True:
|
||||
if cancel_evt.is_set():
|
||||
_clear_clarify_pending(sid)
|
||||
return (
|
||||
"The user did not provide a response within the time limit. "
|
||||
"Use your best judgement to make the choice and proceed."
|
||||
)
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
_clear_clarify_pending(sid)
|
||||
return (
|
||||
"The user did not provide a response within the time limit. "
|
||||
"Use your best judgement to make the choice and proceed."
|
||||
)
|
||||
if entry.event.wait(timeout=min(1.0, remaining)):
|
||||
response = str(entry.result or "").strip()
|
||||
return (
|
||||
response
|
||||
or "The user did not provide a response within the time limit. "
|
||||
"Use your best judgement to make the choice and proceed."
|
||||
)
|
||||
|
||||
try:
|
||||
_token_sent = False # tracks whether any streamed tokens were sent
|
||||
_reasoning_text = '' # accumulates reasoning/thinking trace for persistence
|
||||
@@ -304,6 +373,11 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta
|
||||
stream_delta_callback=on_token,
|
||||
reasoning_callback=on_reasoning,
|
||||
tool_progress_callback=on_tool,
|
||||
clarify_callback=(
|
||||
lambda question, choices: _clarify_callback_impl(
|
||||
question, choices, session_id, cancel_event, put
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
# Store agent instance for cancel/interrupt propagation
|
||||
@@ -565,6 +639,11 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta
|
||||
_unreg_notify(session_id)
|
||||
except Exception:
|
||||
logger.debug("Failed to unregister approval callback")
|
||||
if _clarify_registered and _unreg_clarify_notify is not None:
|
||||
try:
|
||||
_unreg_clarify_notify(session_id)
|
||||
except Exception:
|
||||
logger.debug("Failed to unregister clarify callback")
|
||||
with _ENV_LOCK:
|
||||
if old_cwd is None: os.environ.pop('TERMINAL_CWD', None)
|
||||
else: os.environ['TERMINAL_CWD'] = old_cwd
|
||||
@@ -660,6 +739,15 @@ def cancel_stream(stream_id: str) -> bool:
|
||||
f"cancel_event flag set, will be checked on agent startup"
|
||||
)
|
||||
|
||||
# Clear any pending clarify prompt so the blocked tool call can unwind.
|
||||
try:
|
||||
from api.clarify import clear_pending as _clear_clarify_pending
|
||||
|
||||
if agent and getattr(agent, "session_id", None):
|
||||
_clear_clarify_pending(agent.session_id)
|
||||
except Exception:
|
||||
logger.debug("Failed to clear clarify prompt during cancel")
|
||||
|
||||
# Put a cancel sentinel into the queue so the SSE handler wakes up
|
||||
q = STREAMS.get(stream_id)
|
||||
if q:
|
||||
|
||||
@@ -44,6 +44,12 @@ const LOCALES = {
|
||||
approval_btn_deny: 'Deny',
|
||||
approval_btn_deny_title: 'Deny — do not run this command',
|
||||
approval_responding: 'Responding\u2026',
|
||||
clarify_heading: 'Clarification needed',
|
||||
clarify_hint: 'Pick a choice, or type your own answer below.',
|
||||
clarify_other: 'Other',
|
||||
clarify_send: 'Send',
|
||||
clarify_input_placeholder: 'Type your response…',
|
||||
clarify_responding: 'Responding\u2026',
|
||||
untitled: 'Untitled',
|
||||
n_messages: (n) => `${n} messages`,
|
||||
model_unavailable: ' (unavailable)',
|
||||
@@ -452,6 +458,12 @@ const LOCALES = {
|
||||
approval_btn_deny: 'Denegar',
|
||||
approval_btn_deny_title: 'Denegar — no ejecutar este comando',
|
||||
approval_responding: 'Respondiendo…',
|
||||
clarify_heading: 'Se necesita aclaración',
|
||||
clarify_hint: 'Elige una opción o escribe tu propia respuesta abajo.',
|
||||
clarify_other: 'Otra',
|
||||
clarify_send: 'Enviar',
|
||||
clarify_input_placeholder: 'Escribe tu respuesta…',
|
||||
clarify_responding: 'Respondiendo…',
|
||||
untitled: 'Sin título',
|
||||
n_messages: (n) => `${n} mensajes`,
|
||||
model_unavailable: ' (no disponible)',
|
||||
@@ -852,6 +864,12 @@ const LOCALES = {
|
||||
approval_btn_deny: 'Ablehnen',
|
||||
approval_btn_deny_title: 'Ablehnen \u2014 diesen Befehl nicht ausführen',
|
||||
approval_responding: 'Antwortet\u2026',
|
||||
clarify_heading: 'Klärung erforderlich',
|
||||
clarify_hint: 'Wähle eine Option oder schreibe deine eigene Antwort unten.',
|
||||
clarify_other: 'Andere',
|
||||
clarify_send: 'Senden',
|
||||
clarify_input_placeholder: 'Gib deine Antwort ein…',
|
||||
clarify_responding: 'Antwortet\u2026',
|
||||
untitled: 'Unbenannt',
|
||||
n_messages: (n) => `${n} Nachrichten`,
|
||||
model_unavailable: ' (nicht verfügbar)',
|
||||
@@ -1056,6 +1074,12 @@ const LOCALES = {
|
||||
approval_btn_deny: '拒绝',
|
||||
approval_btn_deny_title: '拒绝 — 不执行此命令',
|
||||
approval_responding: '处理中…',
|
||||
clarify_heading: '需要澄清',
|
||||
clarify_hint: '请选择一个选项,或在下方输入你自己的回答。',
|
||||
clarify_other: '其他',
|
||||
clarify_send: '发送',
|
||||
clarify_input_placeholder: '请输入你的回答…',
|
||||
clarify_responding: '处理中…',
|
||||
untitled: '\u672a\u547d\u540d',
|
||||
n_messages: (n) => `${n} \u6761\u6d88\u606f`,
|
||||
model_unavailable: '\uff08\u4e0d\u53ef\u7528\uff09',
|
||||
@@ -1369,7 +1393,7 @@ const LOCALES = {
|
||||
workspace_paths_validated_hint: '保存前会校验路径是否为已存在目录。',
|
||||
workspace_added: '工作区已添加',
|
||||
workspace_remove_confirm_title: '移除工作区',
|
||||
workspace_remove_confirm_message: (path) => `要移除“${path}”吗?`,
|
||||
workspace_remove_confirm_message: (path) => `要移除"${path}"吗?`,
|
||||
workspace_removed: '工作区已移除',
|
||||
workspace_switch_prompt_title: '切换工作区',
|
||||
workspace_switch_prompt_message: '输入绝对路径以添加并切换当前会话的工作区。',
|
||||
@@ -1455,6 +1479,12 @@ const LOCALES = {
|
||||
approval_btn_deny: '\u62d2\u7edd',
|
||||
approval_btn_deny_title: '\u62d2\u7edd — \u4e0d\u57f7\u884c\u6b64\u547d\u4ee4',
|
||||
approval_responding: '\u8655\u7406\u4e2d\u2026',
|
||||
clarify_heading: '\u9700\u8981\u91cb\u6e05',
|
||||
clarify_hint: '\u8acb\u9078\u64c7\u4e00\u500b\u9078\u9805\uff0c\u6216\u5728\u4e0b\u65b9\u8f38\u5165\u4f60\u81ea\u5df1\u7684\u56de\u7b54\u3002',
|
||||
clarify_other: '\u5176\u4ed6',
|
||||
clarify_send: '\u9001\u51fa',
|
||||
clarify_input_placeholder: '\u8f38\u5165\u4f60\u7684\u56de\u7b54\u2026',
|
||||
clarify_responding: '\u8655\u7406\u4e2d\u2026',
|
||||
untitled: '\u672a\u547d\u540d',
|
||||
n_messages: (n) => `${n} \u689d\u8a0a\u606f`,
|
||||
model_unavailable: '\uff08\u4e0d\u53ef\u7528\uff09',
|
||||
|
||||
@@ -246,6 +246,21 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="clarify-card" id="clarifyCard" role="dialog" aria-labelledby="clarifyHeading" aria-describedby="clarifyQuestion clarifyHint">
|
||||
<div class="clarify-inner">
|
||||
<div class="clarify-header">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 17h.01"/><path d="M9.09 9a3 3 0 1 1 5.82 1c0 2-3 2-3 4"/><circle cx="12" cy="12" r="10"/></svg>
|
||||
<span id="clarifyHeading" data-i18n="clarify_heading">Clarification needed</span>
|
||||
</div>
|
||||
<div class="clarify-question" id="clarifyQuestion"></div>
|
||||
<div class="clarify-choices" id="clarifyChoices"></div>
|
||||
<div class="clarify-response">
|
||||
<input class="clarify-input" id="clarifyInput" type="text" data-i18n-placeholder="clarify_input_placeholder" placeholder="Type your response…">
|
||||
<button class="clarify-submit" id="clarifySubmit" onclick="respondClarify()" data-i18n="clarify_send">Send</button>
|
||||
</div>
|
||||
<div class="clarify-hint" id="clarifyHint" data-i18n="clarify_hint">Pick a choice, or type your own answer below.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="composer-wrap" id="composerWrap">
|
||||
<div class="cmd-dropdown" id="cmdDropdown"></div>
|
||||
<div class="composer-box" id="composerBox">
|
||||
|
||||
@@ -44,6 +44,7 @@ async function send(){
|
||||
saveInflightState(activeSid,{streamId:null,messages:INFLIGHT[activeSid].messages,uploaded,toolCalls:[]});
|
||||
}
|
||||
startApprovalPolling(activeSid);
|
||||
startClarifyPolling(activeSid);
|
||||
S.activeStreamId = null; // will be set after stream starts
|
||||
|
||||
// Set provisional title from user message immediately so session appears
|
||||
@@ -79,12 +80,34 @@ async function send(){
|
||||
const cancelBtn=$('btnCancel');
|
||||
if(cancelBtn) cancelBtn.style.display='inline-flex';
|
||||
}catch(e){
|
||||
const errMsg=String((e&&e.message)||'');
|
||||
const conflictActiveStream=/session already has an active stream/i.test(errMsg);
|
||||
if(conflictActiveStream){
|
||||
delete INFLIGHT[activeSid];
|
||||
if(typeof clearInflightState==='function') clearInflightState(activeSid);
|
||||
stopApprovalPolling();
|
||||
stopClarifyPolling();
|
||||
// Keep the user's attempted turn by queueing it for after the current run.
|
||||
queueSessionMessage(activeSid,{text:msgText,files:[]});
|
||||
updateQueueBadge(activeSid);
|
||||
showToast('Current session is still running. Reconnected and queued your message.',2600);
|
||||
try{
|
||||
await loadSession(activeSid);
|
||||
setComposerStatus('');
|
||||
return;
|
||||
}catch(_){
|
||||
// Fall through to standard error handling if session reload fails.
|
||||
}
|
||||
}
|
||||
|
||||
delete INFLIGHT[activeSid];
|
||||
stopApprovalPolling();
|
||||
stopClarifyPolling();
|
||||
// Only hide approval card if it belongs to the session that just finished
|
||||
if(!_approvalSessionId || _approvalSessionId===activeSid) hideApprovalCard(true);removeThinking();
|
||||
S.messages.push({role:'assistant',content:`**Error:** ${e.message}`});
|
||||
renderMessages();setBusy(false);setComposerStatus(`Error: ${e.message}`);
|
||||
if(!_clarifySessionId || _clarifySessionId===activeSid) hideClarifyCard(true);
|
||||
S.messages.push({role:'assistant',content:`**Error:** ${errMsg}`});
|
||||
renderMessages();setBusy(false);setComposerStatus(`Error: ${errMsg}`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -290,6 +313,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
|
||||
source.addEventListener('tool',e=>{
|
||||
const d=JSON.parse(e.data);
|
||||
if(d.name==='clarify') return;
|
||||
const tc={name:d.name, preview:d.preview||'', args:d.args||{}, snippet:'', done:false, tid:d.tid||`live-${Date.now()}-${Math.random().toString(36).slice(2,8)}`};
|
||||
if(!Array.isArray(INFLIGHT[activeSid].toolCalls)) INFLIGHT[activeSid].toolCalls=[];
|
||||
INFLIGHT[activeSid].toolCalls.push(tc);
|
||||
@@ -305,6 +329,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
|
||||
source.addEventListener('tool_complete',e=>{
|
||||
const d=JSON.parse(e.data);
|
||||
if(d.name==='clarify') return;
|
||||
const inflight=INFLIGHT[activeSid];
|
||||
if(!inflight) return;
|
||||
if(!Array.isArray(inflight.toolCalls)) inflight.toolCalls=[];
|
||||
@@ -340,13 +365,23 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
sendBrowserNotification('Approval required',d.description||'Tool approval needed');
|
||||
});
|
||||
|
||||
source.addEventListener('clarify',e=>{
|
||||
const d=JSON.parse(e.data);
|
||||
d._session_id=activeSid;
|
||||
showClarifyCard(d);
|
||||
playNotificationSound();
|
||||
sendBrowserNotification('Clarification needed',d.question||'Tool clarification needed');
|
||||
});
|
||||
|
||||
source.addEventListener('done',e=>{
|
||||
source.close();
|
||||
const d=JSON.parse(e.data);
|
||||
delete INFLIGHT[activeSid];
|
||||
clearInflight();clearInflightState(activeSid);
|
||||
stopApprovalPolling();
|
||||
stopClarifyPolling();
|
||||
if(!_approvalSessionId || _approvalSessionId===activeSid) hideApprovalCard(true);
|
||||
if(!_clarifySessionId || _clarifySessionId===activeSid) hideClarifyCard(true);
|
||||
if(S.session&&S.session.session_id===activeSid){
|
||||
S.activeStreamId=null;
|
||||
const _cb=$('btnCancel');if(_cb)_cb.style.display='none';
|
||||
@@ -396,8 +431,9 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
// Application-level error sent explicitly by the server (rate limit, crash, etc.)
|
||||
// This is distinct from the SSE network 'error' event below.
|
||||
source.close();
|
||||
delete INFLIGHT[activeSid];clearInflight();clearInflightState(activeSid);stopApprovalPolling();
|
||||
delete INFLIGHT[activeSid];clearInflight();clearInflightState(activeSid);stopApprovalPolling();stopClarifyPolling();
|
||||
if(!_approvalSessionId||_approvalSessionId===activeSid) hideApprovalCard(true);
|
||||
if(!_clarifySessionId||_clarifySessionId===activeSid) hideClarifyCard(true);
|
||||
if(S.session&&S.session.session_id===activeSid){
|
||||
S.activeStreamId=null;const _cbe=$('btnCancel');if(_cbe)_cbe.style.display='none';
|
||||
clearLiveToolCards();if(!assistantText)removeThinking();
|
||||
@@ -457,8 +493,9 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
|
||||
source.addEventListener('cancel',e=>{
|
||||
source.close();
|
||||
delete INFLIGHT[activeSid];clearInflight();clearInflightState(activeSid);stopApprovalPolling();
|
||||
delete INFLIGHT[activeSid];clearInflight();clearInflightState(activeSid);stopApprovalPolling();stopClarifyPolling();
|
||||
if(!_approvalSessionId||_approvalSessionId===activeSid) hideApprovalCard(true);
|
||||
if(!_clarifySessionId||_clarifySessionId===activeSid) hideClarifyCard(true);
|
||||
if(S.session&&S.session.session_id===activeSid){
|
||||
S.activeStreamId=null;const _cbc=$('btnCancel');if(_cbc)_cbc.style.display='none';
|
||||
}
|
||||
@@ -472,9 +509,10 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
}
|
||||
|
||||
function _handleStreamError(){
|
||||
delete INFLIGHT[activeSid];clearInflight();clearInflightState(activeSid);stopApprovalPolling();
|
||||
delete INFLIGHT[activeSid];clearInflight();clearInflightState(activeSid);stopApprovalPolling();stopClarifyPolling();
|
||||
_closeSource();
|
||||
if(!_approvalSessionId||_approvalSessionId===activeSid) hideApprovalCard(true);
|
||||
if(!_clarifySessionId||_clarifySessionId===activeSid) hideClarifyCard(true);
|
||||
if(S.session&&S.session.session_id===activeSid){
|
||||
S.activeStreamId=null;const _cbe=$('btnCancel');if(_cbe)_cbe.style.display='none';
|
||||
clearLiveToolCards();if(!assistantText)removeThinking();
|
||||
@@ -622,6 +660,255 @@ function stopApprovalPolling() {
|
||||
if (_approvalPollTimer) { clearInterval(_approvalPollTimer); _approvalPollTimer = null; }
|
||||
}
|
||||
|
||||
// ── Clarify polling ──
|
||||
let _clarifyPollTimer = null;
|
||||
let _clarifyHideTimer = null;
|
||||
let _clarifyVisibleSince = 0;
|
||||
let _clarifySignature = '';
|
||||
let _clarifySessionId = null;
|
||||
let _clarifyMissingEndpointWarned = false;
|
||||
const CLARIFY_MIN_VISIBLE_MS = 30000;
|
||||
|
||||
function _ensureClarifyCardDom() {
|
||||
let card = $("clarifyCard");
|
||||
if (card) return card;
|
||||
const host = $("msgInner") || $("messages");
|
||||
if (!host) return null;
|
||||
card = document.createElement("div");
|
||||
card.className = "clarify-card";
|
||||
card.id = "clarifyCard";
|
||||
card.setAttribute("role", "dialog");
|
||||
card.setAttribute("aria-labelledby", "clarifyHeading");
|
||||
card.setAttribute("aria-describedby", "clarifyQuestion clarifyHint");
|
||||
card.innerHTML = `
|
||||
<div class="clarify-inner">
|
||||
<div class="clarify-header">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 17h.01"/><path d="M9.09 9a3 3 0 1 1 5.82 1c0 2-3 2-3 4"/><circle cx="12" cy="12" r="10"/></svg>
|
||||
<span id="clarifyHeading" data-i18n="clarify_heading">Clarification needed</span>
|
||||
</div>
|
||||
<div class="clarify-question" id="clarifyQuestion"></div>
|
||||
<div class="clarify-choices" id="clarifyChoices"></div>
|
||||
<div class="clarify-response">
|
||||
<input class="clarify-input" id="clarifyInput" type="text" data-i18n-placeholder="clarify_input_placeholder" placeholder="Type your response…">
|
||||
<button class="clarify-submit" id="clarifySubmit" data-i18n="clarify_send">Send</button>
|
||||
</div>
|
||||
<div class="clarify-hint" id="clarifyHint" data-i18n="clarify_hint">Please choose one option, or type your own response below.</div>
|
||||
</div>
|
||||
`;
|
||||
host.appendChild(card);
|
||||
const submit = $("clarifySubmit");
|
||||
if (submit) submit.onclick = () => respondClarify();
|
||||
if (typeof applyLocaleToDOM === "function") applyLocaleToDOM();
|
||||
return card;
|
||||
}
|
||||
|
||||
function _clearClarifyHideTimer() {
|
||||
if (_clarifyHideTimer) {
|
||||
clearTimeout(_clarifyHideTimer);
|
||||
_clarifyHideTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function _resetClarifyCardState() {
|
||||
_clearClarifyHideTimer();
|
||||
_clarifyVisibleSince = 0;
|
||||
_clarifySignature = '';
|
||||
}
|
||||
|
||||
function hideClarifyCard(force=false) {
|
||||
const card = $("clarifyCard");
|
||||
if (!card) {
|
||||
_clarifySessionId = null;
|
||||
_resetClarifyCardState();
|
||||
if (typeof unlockComposerForClarify === "function") unlockComposerForClarify();
|
||||
return;
|
||||
}
|
||||
if (!force && _clarifyVisibleSince) {
|
||||
const remaining = CLARIFY_MIN_VISIBLE_MS - (Date.now() - _clarifyVisibleSince);
|
||||
if (remaining > 0) {
|
||||
const scheduledSignature = _clarifySignature;
|
||||
_clearClarifyHideTimer();
|
||||
_clarifyHideTimer = setTimeout(() => {
|
||||
_clarifyHideTimer = null;
|
||||
if (_clarifySignature !== scheduledSignature) return;
|
||||
hideClarifyCard(true);
|
||||
}, remaining);
|
||||
return;
|
||||
}
|
||||
}
|
||||
_clarifySessionId = null;
|
||||
_resetClarifyCardState();
|
||||
card.classList.remove("visible");
|
||||
if (typeof unlockComposerForClarify === "function") unlockComposerForClarify();
|
||||
$("clarifyQuestion").textContent = "";
|
||||
$("clarifyChoices").innerHTML = "";
|
||||
$("clarifyInput").value = "";
|
||||
$("clarifyInput").disabled = false;
|
||||
$("clarifyInput").onkeydown = null;
|
||||
const submit = $("clarifySubmit");
|
||||
if (submit) { submit.disabled = false; submit.classList.remove("loading"); }
|
||||
}
|
||||
|
||||
function _clarifySetControlsDisabled(disabled, loading=false) {
|
||||
const input = $("clarifyInput");
|
||||
const submit = $("clarifySubmit");
|
||||
if (input) input.disabled = disabled;
|
||||
if (submit) {
|
||||
submit.disabled = disabled;
|
||||
submit.classList.toggle("loading", !!loading);
|
||||
}
|
||||
const choices = $("clarifyChoices");
|
||||
if (choices) {
|
||||
choices.querySelectorAll("button").forEach(btn => {
|
||||
btn.disabled = disabled;
|
||||
if (loading && btn.dataset && btn.dataset.choice === "other") {
|
||||
btn.classList.toggle("loading", false);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function showClarifyCard(pending) {
|
||||
const question = pending.question || pending.description || '';
|
||||
const choices = Array.isArray(pending.choices_offered)
|
||||
? pending.choices_offered
|
||||
: (Array.isArray(pending.choices) ? pending.choices : []);
|
||||
const sig = JSON.stringify({
|
||||
question,
|
||||
choices,
|
||||
sid: pending._session_id || (S.session && S.session.session_id) || null,
|
||||
});
|
||||
const card = _ensureClarifyCardDom();
|
||||
if (!card) return;
|
||||
const questionEl = $("clarifyQuestion");
|
||||
const choicesEl = $("clarifyChoices");
|
||||
const input = $("clarifyInput");
|
||||
const sameClarify = card.classList.contains("visible") && _clarifySignature === sig;
|
||||
_clarifySessionId = pending._session_id || (S.session && S.session.session_id) || null;
|
||||
_clarifySignature = sig;
|
||||
if (!sameClarify) {
|
||||
_clarifyVisibleSince = Date.now();
|
||||
_clearClarifyHideTimer();
|
||||
}
|
||||
if (questionEl) questionEl.textContent = question;
|
||||
if (choicesEl) {
|
||||
choicesEl.innerHTML = '';
|
||||
choicesEl.style.display = choices.length ? '' : 'none';
|
||||
if (choices.length) {
|
||||
choices.forEach((choice, idx) => {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'clarify-choice';
|
||||
btn.dataset.choice = choice;
|
||||
btn.onclick = () => respondClarify(choice);
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'clarify-choice-badge';
|
||||
badge.textContent = String(idx + 1);
|
||||
const text = document.createElement('span');
|
||||
text.className = 'clarify-choice-text';
|
||||
text.textContent = choice;
|
||||
btn.appendChild(badge);
|
||||
btn.appendChild(text);
|
||||
choicesEl.appendChild(btn);
|
||||
});
|
||||
const other = document.createElement('button');
|
||||
other.type = 'button';
|
||||
other.className = 'clarify-choice other';
|
||||
other.dataset.choice = 'other';
|
||||
other.setAttribute('data-i18n', 'clarify_other');
|
||||
const otherBadge = document.createElement('span');
|
||||
otherBadge.className = 'clarify-choice-badge other';
|
||||
otherBadge.textContent = '•';
|
||||
const otherText = document.createElement('span');
|
||||
otherText.className = 'clarify-choice-text';
|
||||
otherText.textContent = t('clarify_other') || 'Other';
|
||||
other.appendChild(otherBadge);
|
||||
other.appendChild(otherText);
|
||||
other.onclick = () => {
|
||||
const el = $("clarifyInput");
|
||||
if (el) {
|
||||
el.focus();
|
||||
if (typeof el.select === 'function') el.select();
|
||||
}
|
||||
};
|
||||
choicesEl.appendChild(other);
|
||||
}
|
||||
}
|
||||
if (input) {
|
||||
if (!sameClarify) input.value = '';
|
||||
input.disabled = false;
|
||||
input.onkeydown = (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
respondClarify();
|
||||
}
|
||||
};
|
||||
}
|
||||
if (typeof lockComposerForClarify === "function") {
|
||||
lockComposerForClarify(question ? `Clarification needed: ${question}` : "Clarification needed");
|
||||
}
|
||||
_clarifySetControlsDisabled(false, false);
|
||||
const msgInner = $("msgInner");
|
||||
if (msgInner && card.parentElement !== msgInner) {
|
||||
msgInner.appendChild(card);
|
||||
}
|
||||
card.classList.add("visible");
|
||||
if (!sameClarify) card.scrollIntoView({block:"nearest", behavior:"smooth"});
|
||||
if (typeof applyLocaleToDOM === "function") applyLocaleToDOM();
|
||||
if (input && !sameClarify) setTimeout(() => input.focus(), 50);
|
||||
}
|
||||
|
||||
async function respondClarify(response) {
|
||||
const sid = _clarifySessionId || (S.session && S.session.session_id);
|
||||
if (!sid) return;
|
||||
const input = $("clarifyInput");
|
||||
let value = typeof response === 'string' ? response : (input ? input.value : '');
|
||||
value = String(value || '').trim();
|
||||
if (!value) {
|
||||
if (input) input.focus();
|
||||
return;
|
||||
}
|
||||
_clarifySessionId = null;
|
||||
_clarifySetControlsDisabled(true, true);
|
||||
hideClarifyCard(true);
|
||||
try {
|
||||
await api("/api/clarify/respond", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ session_id: sid, response: value })
|
||||
});
|
||||
} catch(e) { setStatus(t("clarify_responding") + " " + e.message); }
|
||||
}
|
||||
|
||||
function startClarifyPolling(sid) {
|
||||
stopClarifyPolling();
|
||||
_clarifyMissingEndpointWarned = false;
|
||||
_clarifyPollTimer = setInterval(async () => {
|
||||
if (!S.session || S.session.session_id !== sid) {
|
||||
stopClarifyPolling(); hideClarifyCard(true); return;
|
||||
}
|
||||
try {
|
||||
const data = await api("/api/clarify/pending?session_id=" + encodeURIComponent(sid));
|
||||
if (data.pending) { data.pending._session_id=sid; showClarifyCard(data.pending); }
|
||||
else { hideClarifyCard(); }
|
||||
} catch(e) {
|
||||
const msg = String((e && e.message) || "");
|
||||
if (!_clarifyMissingEndpointWarned && /(^|\b)(404|not found)(\b|$)/i.test(msg)) {
|
||||
_clarifyMissingEndpointWarned = true;
|
||||
setComposerStatus("Clarify unavailable on current server build. Restart server.");
|
||||
if (typeof showToast === "function") {
|
||||
showToast("Clarify endpoint unavailable. Please restart server.", 5000);
|
||||
}
|
||||
stopClarifyPolling();
|
||||
}
|
||||
// Ignore transient poll errors; SSE clarify event still provides a fast path.
|
||||
}
|
||||
}, 1500);
|
||||
}
|
||||
|
||||
function stopClarifyPolling() {
|
||||
if (_clarifyPollTimer) { clearInterval(_clarifyPollTimer); _clarifyPollTimer = null; }
|
||||
}
|
||||
|
||||
// ── Notifications and Sound ──────────────────────────────────────────────────
|
||||
|
||||
function playNotificationSound(){
|
||||
|
||||
@@ -38,6 +38,8 @@ async function newSession(flash){
|
||||
|
||||
async function loadSession(sid){
|
||||
stopApprovalPolling();hideApprovalCard();
|
||||
if(typeof stopClarifyPolling==='function') stopClarifyPolling();
|
||||
if(typeof hideClarifyCard==='function') hideClarifyCard();
|
||||
const data=await api(`/api/session?session_id=${encodeURIComponent(sid)}`);
|
||||
S.session=data.session;
|
||||
S.lastUsage={...(data.session.last_usage||{})};
|
||||
@@ -95,6 +97,7 @@ async function loadSession(sid){
|
||||
}
|
||||
setBusy(true);setComposerStatus('');
|
||||
startApprovalPolling(sid);
|
||||
if(typeof startClarifyPolling==='function') startClarifyPolling(sid);
|
||||
S.activeStreamId=activeStreamId;
|
||||
const _cb=$('btnCancel');if(_cb&&activeStreamId)_cb.style.display='inline-flex';
|
||||
if(INFLIGHT[sid].reattach&&activeStreamId&&typeof attachLiveStream==='function'){
|
||||
@@ -122,6 +125,7 @@ async function loadSession(sid){
|
||||
syncTopbar();renderMessages();appendThinking();loadDir('.');
|
||||
updateQueueBadge(sid);
|
||||
startApprovalPolling(sid);
|
||||
if(typeof startClarifyPolling==='function') startClarifyPolling(sid);
|
||||
if(typeof attachLiveStream==='function') attachLiveStream(sid, activeStreamId, data.session.pending_attachments||[], {reconnecting:true});
|
||||
else if(typeof watchInflightSession==='function') watchInflightSession(sid, activeStreamId);
|
||||
}else{
|
||||
|
||||
@@ -304,6 +304,30 @@
|
||||
.approval-btn.deny{border-color:rgba(233,69,96,0.5);color:var(--accent);}
|
||||
.approval-btn.deny:hover{background:rgba(233,69,96,0.12);border-color:rgba(233,69,96,0.7);}
|
||||
.approval-btn.loading{opacity:.7;cursor:wait;}
|
||||
/* ── Clarify card ── */
|
||||
.clarify-card{display:none;max-width:680px;margin:4px 0 2px 40px;padding:0;}
|
||||
.clarify-card.visible{display:block;}
|
||||
.clarify-inner{background:rgba(255,255,255,.03);backdrop-filter:blur(8px);border:1px solid rgba(124,185,255,0.16);border-radius:12px;padding:12px 14px 13px;box-shadow:0 1px 0 rgba(255,255,255,.02) inset;}
|
||||
.clarify-header{display:flex;align-items:center;gap:8px;margin-bottom:10px;font-size:12px;font-weight:700;color:var(--blue);letter-spacing:.01em;}
|
||||
.clarify-question{font-size:14px;color:var(--text);line-height:1.7;white-space:pre-wrap;margin-bottom:12px;}
|
||||
.clarify-choices{display:flex;flex-direction:column;gap:8px;margin-bottom:12px;}
|
||||
.clarify-choice{display:flex;align-items:flex-start;gap:10px;width:100%;padding:11px 14px;border-radius:12px;font-size:13px;font-weight:600;border:1px solid rgba(124,185,255,0.3);background:rgba(124,185,255,0.08);color:var(--blue);cursor:pointer;transition:all .15s;white-space:normal;text-align:left;box-shadow:0 1px 0 rgba(255,255,255,.03) inset;}
|
||||
.clarify-choice:hover{background:rgba(124,185,255,0.16);transform:translateY(-1px);box-shadow:0 4px 12px rgba(0,0,0,0.18);}
|
||||
.clarify-choice:focus-visible{outline:2px solid rgba(124,185,255,.75);outline-offset:2px;}
|
||||
.clarify-choice-badge{display:inline-flex;align-items:center;justify-content:center;min-width:24px;height:24px;border-radius:999px;background:rgba(124,185,255,0.16);border:1px solid rgba(124,185,255,0.3);color:var(--blue);font-size:11px;font-weight:800;flex-shrink:0;line-height:1;}
|
||||
.clarify-choice-badge.other{background:rgba(201,168,76,0.12);border-color:rgba(201,168,76,0.32);color:var(--gold);}
|
||||
.clarify-choice-text{flex:1;line-height:1.45;min-width:0;}
|
||||
.clarify-choice.other{border-color:rgba(201,168,76,0.35);color:var(--gold);background:rgba(201,168,76,0.08);}
|
||||
.clarify-choice.other:hover{background:rgba(201,168,76,0.14);border-color:rgba(201,168,76,0.55);}
|
||||
.clarify-response{display:flex;gap:8px;align-items:center;flex-wrap:wrap;}
|
||||
.clarify-input{flex:1;min-width:220px;padding:10px 12px;border-radius:8px;border:1px solid var(--border2);background:var(--input-bg);color:var(--text);font:inherit;outline:none;transition:all .15s;}
|
||||
.clarify-input:focus{border-color:rgba(124,185,255,.5);box-shadow:0 0 0 3px rgba(124,185,255,.08);background:var(--hover-bg);}
|
||||
.clarify-submit{display:inline-flex;align-items:center;justify-content:center;min-width:92px;padding:10px 14px;border-radius:8px;border:1px solid rgba(124,185,255,0.35);background:rgba(124,185,255,0.14);color:var(--blue);font-size:12px;font-weight:700;cursor:pointer;transition:all .15s;white-space:nowrap;}
|
||||
.clarify-submit:hover{background:rgba(124,185,255,0.22);transform:translateY(-1px);}
|
||||
.clarify-submit:disabled{opacity:.6;cursor:not-allowed;transform:none;}
|
||||
.clarify-submit.loading{opacity:.75;cursor:wait;}
|
||||
.clarify-hint{margin-top:8px;font-size:11px;line-height:1.45;color:var(--muted);}
|
||||
.clarify-card.visible .clarify-question{padding-left:1px;}
|
||||
/* Sidebar navigation tabs */
|
||||
.sidebar-nav{display:flex;border-bottom:1px solid var(--border);flex-shrink:0;padding:6px 8px 0;gap:2px;}
|
||||
.nav-tab{flex:1;padding:10px 4px 8px;font-size:20px;text-align:center;cursor:pointer;color:var(--muted);border:none;background:none;transition:color .15s;border-bottom:2px solid transparent;white-space:nowrap;overflow:hidden;position:relative;}
|
||||
@@ -691,6 +715,13 @@
|
||||
.approval-btns{gap:6px;}
|
||||
.approval-btn{padding:8px 12px;font-size:12px;min-height:44px;}
|
||||
.approval-kbd{display:none;}
|
||||
/* Clarify card */
|
||||
.clarify-card{margin:6px 0 4px 0;max-width:100%;}
|
||||
.clarify-inner{padding:12px 12px 13px;}
|
||||
.clarify-response{flex-direction:column;align-items:stretch;}
|
||||
.clarify-input,.clarify-submit{width:100%;min-height:44px;}
|
||||
.clarify-choice{min-height:44px;}
|
||||
.clarify-choice-badge{min-width:22px;height:22px;}
|
||||
.app-dialog-overlay{padding:12px;}
|
||||
.app-dialog{width:100%;padding:16px 16px 14px;border-radius:16px;}
|
||||
.app-dialog-actions{flex-direction:column-reverse;align-items:stretch;}
|
||||
@@ -825,6 +856,7 @@
|
||||
|
||||
/* Approval buttons: tab stops */
|
||||
.approval-btn:focus{outline:2px solid var(--blue);outline-offset:2px;}
|
||||
.clarify-choice:focus,.clarify-submit:focus,.clarify-input:focus{outline:2px solid var(--blue);outline-offset:2px;}
|
||||
|
||||
/* Message role: breathing room between icon and name */
|
||||
.msg-role > span{line-height:1;}
|
||||
|
||||
37
static/ui.js
37
static/ui.js
@@ -611,11 +611,43 @@ function setComposerStatus(t){
|
||||
el.style.display='';
|
||||
}
|
||||
|
||||
let _composerLockState=null;
|
||||
|
||||
function lockComposerForClarify(placeholderText){
|
||||
const input=$('msg');
|
||||
if(!input) return;
|
||||
if(!_composerLockState){
|
||||
_composerLockState={
|
||||
disabled: input.disabled,
|
||||
placeholder: input.placeholder,
|
||||
};
|
||||
}
|
||||
input.disabled=true;
|
||||
if(placeholderText) input.placeholder=placeholderText;
|
||||
updateSendBtn();
|
||||
}
|
||||
|
||||
function unlockComposerForClarify(){
|
||||
const input=$('msg');
|
||||
if(!input) return;
|
||||
if(_composerLockState){
|
||||
input.disabled=!!_composerLockState.disabled;
|
||||
if(typeof _composerLockState.placeholder==='string'){
|
||||
input.placeholder=_composerLockState.placeholder;
|
||||
}
|
||||
_composerLockState=null;
|
||||
}else{
|
||||
input.disabled=false;
|
||||
}
|
||||
updateSendBtn();
|
||||
}
|
||||
|
||||
function updateSendBtn(){
|
||||
const btn=$('btnSend');
|
||||
if(!btn) return;
|
||||
const hasContent=$('msg').value.trim().length>0||S.pendingFiles.length>0;
|
||||
const canSend=hasContent&&!S.busy;
|
||||
const msg=$('msg');
|
||||
const hasContent=msg&&msg.value.trim().length>0||S.pendingFiles.length>0;
|
||||
const canSend=hasContent&&!S.busy&&!(msg&&msg.disabled);
|
||||
// Hide while busy (cancel button takes its place); show otherwise
|
||||
btn.style.display=S.busy?'none':'';
|
||||
btn.disabled=!canSend;
|
||||
@@ -1830,4 +1862,3 @@ async function uploadPendingFiles(){
|
||||
if(failures===total&&total>0)throw new Error(t('all_uploads_failed',total));
|
||||
return names;
|
||||
}
|
||||
|
||||
|
||||
165
tests/test_clarify_unblock.py
Normal file
165
tests/test_clarify_unblock.py
Normal file
@@ -0,0 +1,165 @@
|
||||
"""Tests for clarify prompt unblocking and HTTP endpoints."""
|
||||
|
||||
import json
|
||||
import threading
|
||||
import uuid
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
|
||||
import pytest
|
||||
|
||||
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",
|
||||
)
|
||||
|
||||
from tests._pytest_port import BASE
|
||||
|
||||
|
||||
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 == data
|
||||
with _lock:
|
||||
assert sid in _gateway_queues
|
||||
|
||||
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_ok_no_pending(self):
|
||||
sid = f"http-no-pending-{uuid.uuid4().hex[:8]}"
|
||||
result, status = post("/api/clarify/respond", {
|
||||
"session_id": sid,
|
||||
"response": "Use option A",
|
||||
})
|
||||
assert status == 200
|
||||
assert result["ok"] 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
|
||||
@@ -91,6 +91,31 @@ class TestApprovalCardHTML:
|
||||
"approval card missing aria-labelledby"
|
||||
|
||||
|
||||
class TestClarifyCardHTML:
|
||||
|
||||
def test_clarify_card_markup_present(self):
|
||||
html = read(REPO / "static/index.html")
|
||||
assert 'id="clarifyCard"' in html, "clarify card missing from index.html"
|
||||
assert 'id="clarifyHeading"' in html, "clarify heading missing"
|
||||
assert 'id="clarifyQuestion"' in html, "clarify question text missing"
|
||||
assert 'id="clarifyChoices"' in html, "clarify choices container missing"
|
||||
assert 'id="clarifyInput"' in html, "clarify input missing"
|
||||
assert 'id="clarifySubmit"' in html, "clarify submit button missing"
|
||||
|
||||
def test_clarify_card_has_data_i18n(self):
|
||||
html = read(REPO / "static/index.html")
|
||||
assert 'data-i18n="clarify_heading"' in html
|
||||
assert 'data-i18n="clarify_send"' in html
|
||||
assert 'data-i18n-placeholder="clarify_input_placeholder"' in html
|
||||
|
||||
def test_clarify_card_has_aria_roles(self):
|
||||
html = read(REPO / "static/index.html")
|
||||
assert 'role="dialog"' in html, \
|
||||
"clarify card missing role=dialog for accessibility"
|
||||
assert 'aria-labelledby="clarifyHeading"' in html, \
|
||||
"clarify card missing aria-labelledby"
|
||||
|
||||
|
||||
# ── CSS ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestApprovalCardCSS:
|
||||
@@ -130,6 +155,37 @@ class TestApprovalCardCSS:
|
||||
assert cls in css, f"CSS class '{cls}' missing"
|
||||
|
||||
|
||||
class TestClarifyCardCSS:
|
||||
|
||||
def test_clarify_styles_present(self):
|
||||
css = read(REPO / "static/style.css")
|
||||
for cls in (
|
||||
".clarify-card",
|
||||
".clarify-card.visible",
|
||||
".clarify-inner",
|
||||
".clarify-header",
|
||||
".clarify-question",
|
||||
".clarify-choices",
|
||||
".clarify-choice",
|
||||
".clarify-response",
|
||||
".clarify-input",
|
||||
".clarify-submit",
|
||||
".clarify-hint",
|
||||
):
|
||||
assert cls in css, f"CSS class '{cls}' missing"
|
||||
|
||||
def test_clarify_mobile_styles_present(self):
|
||||
css = read(REPO / "static/style.css")
|
||||
assert ".clarify-card{padding:0 10px 8px;}" in css or \
|
||||
".clarify-card { padding:0 10px 8px; }" in css or \
|
||||
"clarify-card" in css, "clarify mobile styles missing"
|
||||
|
||||
def test_clarify_focus_styles_present(self):
|
||||
css = read(REPO / "static/style.css")
|
||||
assert ".clarify-choice:focus" in css and ".clarify-submit:focus" in css, \
|
||||
"clarify focus styles missing"
|
||||
|
||||
|
||||
# ── i18n keys ────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestApprovalI18nKeys:
|
||||
@@ -178,6 +234,38 @@ class TestApprovalI18nKeys:
|
||||
"English approval_btn_deny value incorrect"
|
||||
|
||||
|
||||
class TestClarifyI18nKeys:
|
||||
|
||||
REQUIRED_KEYS = [
|
||||
"clarify_heading",
|
||||
"clarify_hint",
|
||||
"clarify_other",
|
||||
"clarify_send",
|
||||
"clarify_input_placeholder",
|
||||
"clarify_responding",
|
||||
]
|
||||
|
||||
def test_english_locale_has_all_clarify_keys(self):
|
||||
src = read(REPO / "static/i18n.js")
|
||||
en_block_end = src.find("\n};")
|
||||
en_block = src[:en_block_end]
|
||||
for key in self.REQUIRED_KEYS:
|
||||
assert f"{key}:" in en_block, f"English locale missing i18n key: {key}"
|
||||
|
||||
def test_chinese_locale_has_all_clarify_keys(self):
|
||||
src = read(REPO / "static/i18n.js")
|
||||
zh_start = src.find("\n zh: {")
|
||||
assert zh_start != -1, "zh locale block not found in i18n.js"
|
||||
zh_block = src[zh_start:]
|
||||
for key in self.REQUIRED_KEYS:
|
||||
assert f"{key}:" in zh_block, f"Chinese locale missing i18n key: {key}"
|
||||
|
||||
def test_clarify_heading_english_value(self):
|
||||
src = read(REPO / "static/i18n.js")
|
||||
assert "clarify_heading: 'Clarification needed'" in src, \
|
||||
"English clarify_heading value incorrect"
|
||||
|
||||
|
||||
# ── messages.js behaviour ────────────────────────────────────────────────────
|
||||
|
||||
class TestApprovalMessagesJS:
|
||||
@@ -209,6 +297,30 @@ class TestApprovalMessagesJS:
|
||||
"showApprovalCard should focus the Allow once button"
|
||||
|
||||
|
||||
class TestClarifyMessagesJS:
|
||||
|
||||
def test_clarify_event_listener_present(self):
|
||||
src = read(REPO / "static/messages.js")
|
||||
assert "addEventListener('clarify'" in src, \
|
||||
"clarify SSE listener missing from messages.js"
|
||||
|
||||
def test_show_clarify_card_present(self):
|
||||
src = read(REPO / "static/messages.js")
|
||||
assert "function showClarifyCard" in src, "showClarifyCard missing"
|
||||
assert "clarifyChoices" in src and "clarifyInput" in src, \
|
||||
"showClarifyCard should manage clarify DOM elements"
|
||||
|
||||
def test_respond_clarify_uses_api_endpoint(self):
|
||||
src = read(REPO / "static/messages.js")
|
||||
assert '/api/clarify/respond' in src, \
|
||||
"respondClarify should POST to /api/clarify/respond"
|
||||
|
||||
def test_clarify_polling_helpers_present(self):
|
||||
src = read(REPO / "static/messages.js")
|
||||
for token in ("startClarifyPolling", "stopClarifyPolling", "hideClarifyCard", "_clarifySessionId"):
|
||||
assert token in src, f"{token} missing from messages.js"
|
||||
|
||||
|
||||
# ── boot.js keyboard shortcut ────────────────────────────────────────────────
|
||||
|
||||
class TestApprovalKeyboardShortcut:
|
||||
@@ -248,6 +360,21 @@ class TestStreamingApprovalScoping:
|
||||
assert "_approval_registered = False" in src, \
|
||||
"_approval_registered flag must be initialised to False"
|
||||
|
||||
def test_clarify_registered_flag_present(self):
|
||||
src = read(REPO / "api/streaming.py")
|
||||
assert "_clarify_registered = False" in src, \
|
||||
"_clarify_registered flag must be initialised to False"
|
||||
|
||||
def test_clarify_unreg_notify_initialised_to_none(self):
|
||||
src = read(REPO / "api/streaming.py")
|
||||
assert "_unreg_clarify_notify = None" in src, \
|
||||
"_unreg_clarify_notify must be initialised to None before the try block"
|
||||
|
||||
def test_finally_checks_clarify_unreg_notify_not_none(self):
|
||||
src = read(REPO / "api/streaming.py")
|
||||
assert "_unreg_clarify_notify is not None" in src, \
|
||||
"finally block must check '_unreg_clarify_notify is not None' before calling it"
|
||||
|
||||
|
||||
# ── HTTP regression: approval respond ────────────────────────────────────────
|
||||
|
||||
@@ -384,3 +511,66 @@ class TestApprovalCardTimerLogic:
|
||||
src = self._get_js().read_text()
|
||||
assert '_clearApprovalHideTimer' in src, \
|
||||
'_clearApprovalHideTimer helper must exist to cancel deferred setTimeout'
|
||||
|
||||
|
||||
class TestClarifyCardTimerLogic:
|
||||
|
||||
def _get_js(self):
|
||||
return pathlib.Path(__file__).parent.parent / 'static' / 'messages.js'
|
||||
|
||||
def test_clarify_min_visible_ms_constant_present(self):
|
||||
src = self._get_js().read_text()
|
||||
assert 'CLARIFY_MIN_VISIBLE_MS' in src
|
||||
import re
|
||||
m = re.search(r'CLARIFY_MIN_VISIBLE_MS\s*=\s*(\d+)', src)
|
||||
assert m is not None, 'CLARIFY_MIN_VISIBLE_MS not assigned'
|
||||
assert int(m.group(1)) == 30000, f'Expected 30000, got {m.group(1)}'
|
||||
|
||||
def test_hide_clarify_card_has_force_parameter(self):
|
||||
src = self._get_js().read_text()
|
||||
assert 'hideClarifyCard(force=false)' in src or \
|
||||
'hideClarifyCard(force = false)' in src, \
|
||||
'hideClarifyCard must have force=false default parameter'
|
||||
|
||||
def test_hide_clarify_card_checks_force_flag(self):
|
||||
src = self._get_js().read_text()
|
||||
assert '!force' in src, 'hideClarifyCard must check !force before deferred hide'
|
||||
|
||||
def test_clarify_hide_timer_variable_present(self):
|
||||
src = self._get_js().read_text()
|
||||
assert '_clarifyHideTimer' in src
|
||||
|
||||
def test_clarify_visible_since_variable_present(self):
|
||||
src = self._get_js().read_text()
|
||||
assert '_clarifyVisibleSince' in src
|
||||
|
||||
def test_clarify_signature_variable_present(self):
|
||||
src = self._get_js().read_text()
|
||||
assert '_clarifySignature' in src
|
||||
|
||||
def test_respond_clarify_calls_hide_with_force(self):
|
||||
src = self._get_js().read_text()
|
||||
import re
|
||||
m = re.search(r'async function respondClarify.*?(?=\nasync function|\nfunction |\Z)',
|
||||
src, re.DOTALL)
|
||||
assert m, 'respondClarify function not found'
|
||||
body = m.group(0)
|
||||
assert 'hideClarifyCard(true)' in body, \
|
||||
'respondClarify must call hideClarifyCard(true) so card hides immediately after user clicks'
|
||||
|
||||
def test_clarify_poll_loop_uses_no_force(self):
|
||||
src = self._get_js().read_text()
|
||||
assert 'else { hideClarifyCard(); }' in src or \
|
||||
'else {hideClarifyCard();}' in src or \
|
||||
'else { hideClarifyCard() }' in src, \
|
||||
'Clarify poll loop should hide without force=true'
|
||||
|
||||
def test_show_clarify_card_signature_dedup(self):
|
||||
src = self._get_js().read_text()
|
||||
import re
|
||||
m = re.search(r'function showClarifyCard.*?(?=\nfunction |\nasync function |\Z)',
|
||||
src, re.DOTALL)
|
||||
assert m, 'showClarifyCard function not found'
|
||||
body = m.group(0)
|
||||
assert 'JSON.stringify' in body, 'showClarifyCard must compute a signature via JSON.stringify'
|
||||
assert '_clarifySignature' in body, 'showClarifyCard must check/set _clarifySignature'
|
||||
|
||||
Reference in New Issue
Block a user