diff --git a/static/messages.js b/static/messages.js
index 6537afb..20e7564 100644
--- a/static/messages.js
+++ b/static/messages.js
@@ -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 = `
+
+
+
+
+
+
+
+
+
Please choose one option, or type your own response below.
+
+ `;
+ 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(){
diff --git a/static/sessions.js b/static/sessions.js
index 11dba4f..f24ce8d 100644
--- a/static/sessions.js
+++ b/static/sessions.js
@@ -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{
diff --git a/static/style.css b/static/style.css
index 5063dc3..483707e 100644
--- a/static/style.css
+++ b/static/style.css
@@ -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;}
diff --git a/static/ui.js b/static/ui.js
index 3eb479c..3fb2601 100644
--- a/static/ui.js
+++ b/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;
}
-
diff --git a/tests/test_clarify_unblock.py b/tests/test_clarify_unblock.py
new file mode 100644
index 0000000..89fb7d2
--- /dev/null
+++ b/tests/test_clarify_unblock.py
@@ -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
diff --git a/tests/test_sprint30.py b/tests/test_sprint30.py
index 93dc823..328d670 100644
--- a/tests/test_sprint30.py
+++ b/tests/test_sprint30.py
@@ -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'