diff --git a/CHANGELOG.md b/CHANGELOG.md index d7c701f3..7e9a7d89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,12 @@ ## [Unreleased] +## [v0.51.642] — 2026-06-25 — Release WW (a failed steer now shows why, with Retry / Dismiss) + +### Fixed + +- **When a mid-turn steer can't be delivered, the composer no longer dead-ends on a generic toast.** A `/steer` (or busy-mode auto-fallback) that failed previously surfaced one of two vague toasts and restored your draft, with no explanation and no way forward — in the auto-fallback case this could trap you in a bounce loop. Each of the distinct failure reasons (agent not running, no cached agent, stream ended, session not found, agent lacks steer, network/steer error) now maps to a specific message, shown in a small recovery bar above the composer with **Retry** and **Dismiss** actions. The bar's buttons are theme-aware (readable in both light and dark, including on mobile). Thanks @rodboev. (#4850, closes #4749) + ## [v0.51.641] — 2026-06-25 — Release WV (disabled plugins read as disabled in dark mode, active ones sort first) ### Fixed diff --git a/static/commands.js b/static/commands.js index 63bf57c2..49b484cd 100644 --- a/static/commands.js +++ b/static/commands.js @@ -1216,20 +1216,12 @@ async function cmdSteer(args){ await _trySteer(msg, /*explicitSteer=*/true); } -/** - * Shared implementation for /steer and the busy_input_mode='steer' path. - * - * Tries the real steer endpoint first. On any non-accept response (no cached - * agent, agent lacks steer, stream dead, etc.) it restores the draft and keeps - * the active stream running. Steer belongs to the active run; a failed Steer - * must not be silently upgraded into Queue, Interrupt, or Stop-and-send. - * - * @param {string} msg - The steer text. - * @param {boolean} explicitSteer - True if the user explicitly invoked /steer - * (vs the busy-mode auto-fallback). Affects toast wording and draft restore. - * @returns {Promise} true when the steer was delivered, false when the - * draft was restored and the active stream was left untouched. - */ +function _steerFailureMessageKey(fallback) { + const key = 'steer_fail_' + (fallback || 'unknown'); + return (typeof LOCALES !== 'undefined' && LOCALES.en && LOCALES.en[key]) + ? key : 'steer_fail_unknown'; +} + function _showSteerIndicator(text){ const inner=document.getElementById('msgInner'); if(!inner) return; @@ -1250,6 +1242,49 @@ function _showSteerIndicator(text){ if(typeof scrollToBottom==='function') scrollToBottom(); } +function _showSteerRecovery(msg, explicitSteer, fallback) { + const inner = document.getElementById('msgInner'); + if (!inner) return; + const old = inner.querySelector('.steer-recovery'); + if (old) old.remove(); + const el = document.createElement('div'); + el.className = 'steer-recovery'; + const label = document.createElement('span'); + label.className = 'steer-recovery-label'; + label.textContent = t(_steerFailureMessageKey(fallback)); + el.appendChild(label); + const retryBtn = document.createElement('button'); + retryBtn.className = 'steer-recovery-retry'; + retryBtn.textContent = t('steer_recovery_retry'); + retryBtn.addEventListener('click', () => { + el.remove(); + void _trySteer(msg, explicitSteer).catch(console.error); + }); + el.appendChild(retryBtn); + const dismissBtn = document.createElement('button'); + dismissBtn.className = 'steer-recovery-dismiss'; + dismissBtn.textContent = t('steer_recovery_dismiss'); + dismissBtn.addEventListener('click', () => el.remove()); + el.appendChild(dismissBtn); + inner.appendChild(el); + if (typeof scrollToBottom === 'function') scrollToBottom(); +} + +/** + * Shared implementation for /steer and the busy_input_mode='steer' path. + * + * Tries the real steer endpoint first. On any non-accept response (no cached + * agent, agent lacks steer, stream dead, etc.) it restores the draft and keeps + * the active stream running. Steer belongs to the active run; a failed Steer + * must not be silently upgraded into Queue, Interrupt, or Stop-and-send. + * + * @param {string} msg - The steer text. + * @param {boolean} explicitSteer - True if the user explicitly invoked /steer + * (vs the busy-mode auto-fallback). Affects draft restore prefix only; + * toast wording is determined by the failure reason code. + * @returns {Promise} true when the steer was delivered, false when the + * draft was restored and the active stream was left untouched. + */ async function _trySteer(msg, explicitSteer){ let result=null; try{ @@ -1279,11 +1314,9 @@ async function _trySteer(msg, explicitSteer){ if(typeof autoResize==='function')autoResize(); } if(typeof renderTray==='function')renderTray(); - if(explicitSteer){ - showToast(t('cmd_steer_fallback'),3500); - } else { - showToast(t('busy_steer_fallback'),3500); - } + const fallbackCode = result && result.fallback; + showToast(t(_steerFailureMessageKey(fallbackCode)), 3500); + _showSteerRecovery(msg, explicitSteer, fallbackCode); return false; } diff --git a/static/i18n.js b/static/i18n.js index cc450645..62f4c719 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -1,4 +1,4 @@ -// ── i18n: locale bundles and t() helper ────────────────────────────────────── +// ── i18n: locale bundles and t() helper ────────────────────────────────────── // To add a new language: add an entry to LOCALES below with all keys translated. // The language code must match a valid BCP 47 tag (used for speech recognition). // Keys missing in a non-English locale fall back to English automatically. @@ -298,6 +298,16 @@ const LOCALES = { cmd_steer_delivered: 'Steer delivered — agent will see it on its next tool result', steer_leftover_queued: 'Steer queued for next turn', busy_steer_fallback: 'Steer unavailable — draft restored', + steer_fail_no_cached_agent: 'No agent cached for this session — try refreshing', + steer_fail_agent_lacks_steer: 'This agent version does not support steer', + steer_fail_session_not_found: 'Session not found — it may have expired', + steer_fail_not_running: 'Agent is not currently running', + steer_fail_stream_dead: 'The agent stream has ended', + steer_fail_steer_error: 'Steer delivery failed — the agent may have finished', + steer_fail_network_error: 'Network error — check your connection', + steer_fail_unknown: 'Steer unavailable', + steer_recovery_retry: 'Retry', + steer_recovery_dismiss: 'Dismiss', busy_interrupt_confirm: 'Interrupted — sending new message', settings_label_busy_input_mode: 'Busy input mode', settings_desc_busy_input_mode: 'Controls what happens when you send a message while the agent is running. Queue waits; Interrupt cancels and starts fresh; Steer injects a correction mid-turn without interrupting. If Steer is unavailable, the draft is restored so you can choose the next action.', @@ -1884,6 +1894,16 @@ const LOCALES = { cmd_steer_delivered: "Steer consegnato — l'agente lo vedrà al prossimo risultato strumento", steer_leftover_queued: 'Steer in coda per il prossimo turno', busy_steer_fallback: 'Steer non disponibile — bozza ripristinata', + steer_fail_no_cached_agent: 'No agent cached for this session — try refreshing', + steer_fail_agent_lacks_steer: 'This agent version does not support steer', + steer_fail_session_not_found: 'Session not found — it may have expired', + steer_fail_not_running: 'Agent is not currently running', + steer_fail_stream_dead: 'The agent stream has ended', + steer_fail_steer_error: 'Steer delivery failed — the agent may have finished', + steer_fail_network_error: 'Network error — check your connection', + steer_fail_unknown: 'Steer unavailable', + steer_recovery_retry: 'Retry', + steer_recovery_dismiss: 'Dismiss', busy_interrupt_confirm: 'Interrotto — invio nuovo messaggio', settings_label_busy_input_mode: 'Modalità input occupato', settings_desc_busy_input_mode: "Controlla cosa succede quando invii un messaggio mentre l'agente è in esecuzione. Coda attende; Interrompi annulla e ricomincia; Steer inietta una correzione a metà turno senza interrompere (fallback a coda se agente o stream non disponibili).", @@ -3461,6 +3481,16 @@ const LOCALES = { cmd_steer_delivered: 'ステア送信完了 — 次のツール結果でエージェントが認識します', steer_leftover_queued: 'ステアを次のターンへキュー', busy_steer_fallback: 'ステア利用不可 — 下書きを復元しました', + steer_fail_no_cached_agent: 'No agent cached for this session — try refreshing', + steer_fail_agent_lacks_steer: 'This agent version does not support steer', + steer_fail_session_not_found: 'Session not found — it may have expired', + steer_fail_not_running: 'Agent is not currently running', + steer_fail_stream_dead: 'The agent stream has ended', + steer_fail_steer_error: 'Steer delivery failed — the agent may have finished', + steer_fail_network_error: 'Network error — check your connection', + steer_fail_unknown: 'Steer unavailable', + steer_recovery_retry: 'Retry', + steer_recovery_dismiss: 'Dismiss', busy_interrupt_confirm: '中断 — 新しいメッセージを送信中', settings_label_busy_input_mode: 'ビジー時の入力モード', settings_desc_busy_input_mode: 'エージェント実行中にメッセージを送信した時の動作を制御します。Queue は待機、Interrupt はキャンセルして再開、Steer は中断せずにターン中に修正を注入します (エージェントやストリームが利用不可ならキューにフォールバック)。', @@ -4989,6 +5019,16 @@ const LOCALES = { cmd_steer_delivered: 'Steer доставлен — агент увидит его в следующем ответе инструмента', steer_leftover_queued: 'Steer поставлен в очередь на следующий ход', busy_steer_fallback: 'Steer недоступен — черновик восстановлен', + steer_fail_no_cached_agent: 'No agent cached for this session — try refreshing', + steer_fail_agent_lacks_steer: 'This agent version does not support steer', + steer_fail_session_not_found: 'Session not found — it may have expired', + steer_fail_not_running: 'Agent is not currently running', + steer_fail_stream_dead: 'The agent stream has ended', + steer_fail_steer_error: 'Steer delivery failed — the agent may have finished', + steer_fail_network_error: 'Network error — check your connection', + steer_fail_unknown: 'Steer unavailable', + steer_recovery_retry: 'Retry', + steer_recovery_dismiss: 'Dismiss', busy_interrupt_confirm: 'Прервано — отправка нового сообщения', settings_label_busy_input_mode: 'Режим ввода при занятости', settings_desc_busy_input_mode: 'Определяет поведение при отправке сообщения во время работы агента. Очередь ждёт; Прерывание отменяет и начинает заново; Steer внедряет коррекцию без прерывания.', @@ -6553,6 +6593,16 @@ const LOCALES = { cmd_steer_delivered: 'Steer entregado \u2014 el agente lo ver\u00e1 en su pr\u00f3ximo resultado de herramienta', steer_leftover_queued: 'Steer en cola para el pr\u00f3ximo turno', busy_steer_fallback: 'Steer no disponible — borrador restaurado', + steer_fail_no_cached_agent: 'No agent cached for this session — try refreshing', + steer_fail_agent_lacks_steer: 'This agent version does not support steer', + steer_fail_session_not_found: 'Session not found — it may have expired', + steer_fail_not_running: 'Agent is not currently running', + steer_fail_stream_dead: 'The agent stream has ended', + steer_fail_steer_error: 'Steer delivery failed — the agent may have finished', + steer_fail_network_error: 'Network error — check your connection', + steer_fail_unknown: 'Steer unavailable', + steer_recovery_retry: 'Retry', + steer_recovery_dismiss: 'Dismiss', busy_interrupt_confirm: 'Interrumpido \u2014 enviando nuevo mensaje', settings_label_busy_input_mode: 'Modo de entrada ocupada', settings_desc_busy_input_mode: 'Controla qué sucede al enviar mensajes mientras el agente está activo. Cola espera; Interrumpir cancela y empieza de nuevo; Steer inyecta una corrección sin interrumpir (usa cola si el agente no está disponible).', @@ -8038,6 +8088,16 @@ const LOCALES = { cmd_steer_delivered: 'Steer geliefert \u2014 der Agent sieht es bei seinem n\u00e4chsten Tool-Ergebnis', steer_leftover_queued: 'Steer f\u00fcr n\u00e4chsten Durchgang eingereiht', busy_steer_fallback: 'Steer nicht verf\u00fcgbar \u2014 Entwurf wiederhergestellt', + steer_fail_no_cached_agent: 'No agent cached for this session — try refreshing', + steer_fail_agent_lacks_steer: 'This agent version does not support steer', + steer_fail_session_not_found: 'Session not found — it may have expired', + steer_fail_not_running: 'Agent is not currently running', + steer_fail_stream_dead: 'The agent stream has ended', + steer_fail_steer_error: 'Steer delivery failed — the agent may have finished', + steer_fail_network_error: 'Network error — check your connection', + steer_fail_unknown: 'Steer unavailable', + steer_recovery_retry: 'Retry', + steer_recovery_dismiss: 'Dismiss', busy_interrupt_confirm: 'Unterbrochen \u2014 neue Nachricht wird gesendet', settings_label_busy_input_mode: 'Eingabemodus bei Besch\u00e4ftigung', settings_desc_busy_input_mode: 'Steuert, was passiert, wenn Sie w\u00e4hrend der Agentenaktivit\u00e4t eine Nachricht senden. Warteschlange wartet; Unterbrechen bricht ab und startet neu; Steer f\u00fcgt eine Korrektur ein ohne zu unterbrechen.', @@ -9582,6 +9642,16 @@ const LOCALES = { cmd_steer_delivered: 'Steer 已交付 — 代理将在下一个工具结果中看到', steer_leftover_queued: 'Steer 已加入下轮队列', busy_steer_fallback: 'Steer 不可用 — 草稿已恢复', + steer_fail_no_cached_agent: 'No agent cached for this session — try refreshing', + steer_fail_agent_lacks_steer: 'This agent version does not support steer', + steer_fail_session_not_found: 'Session not found — it may have expired', + steer_fail_not_running: 'Agent is not currently running', + steer_fail_stream_dead: 'The agent stream has ended', + steer_fail_steer_error: 'Steer delivery failed — the agent may have finished', + steer_fail_network_error: 'Network error — check your connection', + steer_fail_unknown: 'Steer unavailable', + steer_recovery_retry: 'Retry', + steer_recovery_dismiss: 'Dismiss', busy_interrupt_confirm: '已中断 — 正在发送新消息', settings_label_busy_input_mode: '忙碌输入模式', settings_desc_busy_input_mode: '控制当代理正在运行时你发送消息会发生什么。队列会等待;中断会取消并重新开始;引导会在不中断的情况下注入中间修正(当流不可用时回退到队列)。', @@ -11103,6 +11173,16 @@ const LOCALES = { cmd_steer_delivered: 'Steer 已送達,Agent 會在下一個工具結果中看到', steer_leftover_queued: 'Steer 已加入下一輪佇列', busy_steer_fallback: 'Steer 不可用 — 已恢復草稿', + steer_fail_no_cached_agent: 'No agent cached for this session — try refreshing', + steer_fail_agent_lacks_steer: 'This agent version does not support steer', + steer_fail_session_not_found: 'Session not found — it may have expired', + steer_fail_not_running: 'Agent is not currently running', + steer_fail_stream_dead: 'The agent stream has ended', + steer_fail_steer_error: 'Steer delivery failed — the agent may have finished', + steer_fail_network_error: 'Network error — check your connection', + steer_fail_unknown: 'Steer unavailable', + steer_recovery_retry: 'Retry', + steer_recovery_dismiss: 'Dismiss', busy_interrupt_confirm: '已中斷,正在傳送新訊息', settings_label_busy_input_mode: '忙碌輸入模式', settings_desc_busy_input_mode: '控制 Agent 執行時送出訊息的行為。佇列會等待;中斷會取消並重新開始;Steer 會中途注入修正且不中斷(Agent 或串流無法使用時會退回佇列)。', @@ -12608,6 +12688,16 @@ const LOCALES = { cmd_steer_delivered: 'Steer entregue — agente verá no próximo resultado', steer_leftover_queued: 'Steer enfileirado para próximo turno', busy_steer_fallback: 'Steer indisponível — rascunho restaurado', + steer_fail_no_cached_agent: 'No agent cached for this session — try refreshing', + steer_fail_agent_lacks_steer: 'This agent version does not support steer', + steer_fail_session_not_found: 'Session not found — it may have expired', + steer_fail_not_running: 'Agent is not currently running', + steer_fail_stream_dead: 'The agent stream has ended', + steer_fail_steer_error: 'Steer delivery failed — the agent may have finished', + steer_fail_network_error: 'Network error — check your connection', + steer_fail_unknown: 'Steer unavailable', + steer_recovery_retry: 'Retry', + steer_recovery_dismiss: 'Dismiss', busy_interrupt_confirm: 'Interrompido — enviando nova mensagem', settings_label_busy_input_mode: 'Modo de input ocupado', settings_desc_busy_input_mode: 'Controla o que acontece ao enviar mensagem com agente rodando. Fila espera; Interromper cancela; Steer injeta correção.', @@ -14071,6 +14161,16 @@ const LOCALES = { cmd_steer_delivered: 'Steer delivered — agent will see it on its next tool result', steer_leftover_queued: 'Steer queued for next turn', busy_steer_fallback: 'Steer unavailable — draft restored', + steer_fail_no_cached_agent: 'No agent cached for this session — try refreshing', + steer_fail_agent_lacks_steer: 'This agent version does not support steer', + steer_fail_session_not_found: 'Session not found — it may have expired', + steer_fail_not_running: 'Agent is not currently running', + steer_fail_stream_dead: 'The agent stream has ended', + steer_fail_steer_error: 'Steer delivery failed — the agent may have finished', + steer_fail_network_error: 'Network error — check your connection', + steer_fail_unknown: 'Steer unavailable', + steer_recovery_retry: 'Retry', + steer_recovery_dismiss: 'Dismiss', busy_interrupt_confirm: 'Interrupted — sending new message', settings_label_busy_input_mode: '작업 중 입력 방식', settings_desc_busy_input_mode: '에이전트가 실행 중일 때 메시지를 보내면 어떻게 처리할지 제어합니다. 대기는 다음 차례까지 기다리고, 중단은 현재 작업을 취소하고 새로 시작하며, 조정은 현재 작업을 중단하지 않고 중간 수정 사항을 전달합니다(에이전트 또는 스트림을 사용할 수 없으면 대기로 전환).', @@ -15632,6 +15732,16 @@ const LOCALES = { cmd_steer_delivered: 'Steer livré - l\'agent le verra sur son prochain résultat d\'outil', steer_leftover_queued: 'Bœuf en attente pour le prochain tour', busy_steer_fallback: 'Steer indisponible – brouillon restauré', + steer_fail_no_cached_agent: 'No agent cached for this session — try refreshing', + steer_fail_agent_lacks_steer: 'This agent version does not support steer', + steer_fail_session_not_found: 'Session not found — it may have expired', + steer_fail_not_running: 'Agent is not currently running', + steer_fail_stream_dead: 'The agent stream has ended', + steer_fail_steer_error: 'Steer delivery failed — the agent may have finished', + steer_fail_network_error: 'Network error — check your connection', + steer_fail_unknown: 'Steer unavailable', + steer_recovery_retry: 'Retry', + steer_recovery_dismiss: 'Dismiss', busy_interrupt_confirm: 'Interrompu — envoi d\'un nouveau message', settings_label_busy_input_mode: 'Mode de saisie occupé', settings_desc_busy_input_mode: 'Contrôle ce qui se passe lorsque vous envoyez un message pendant l\'exécution de l\'agent. La file d\'attente attend ; L\'interruption s\'annule et recommence ; Steer injecte une correction à mi-tour sans interrompre (revient en file d\'attente lorsque l\'agent ou le flux est indisponible).', @@ -17169,6 +17279,16 @@ const LOCALES = { cmd_steer_delivered: 'Yönlendirme teslim edildi — temsilci bunu bir sonraki takım sonucunda görecek', steer_leftover_queued: 'Direksiyon bir sonraki dönüş için kuyruğa alındı', busy_steer_fallback: 'Yönlendirme kullanılamıyor — taslak geri yüklendi', + steer_fail_no_cached_agent: 'No agent cached for this session — try refreshing', + steer_fail_agent_lacks_steer: 'This agent version does not support steer', + steer_fail_session_not_found: 'Session not found — it may have expired', + steer_fail_not_running: 'Agent is not currently running', + steer_fail_stream_dead: 'The agent stream has ended', + steer_fail_steer_error: 'Steer delivery failed — the agent may have finished', + steer_fail_network_error: 'Network error — check your connection', + steer_fail_unknown: 'Steer unavailable', + steer_recovery_retry: 'Retry', + steer_recovery_dismiss: 'Dismiss', busy_interrupt_confirm: 'Kesintiye uğradı — yeni mesaj gönderilir', settings_label_busy_input_mode: 'Meşgul giriş modu', settings_desc_busy_input_mode: 'Aracı çalışırken bir mesaj gönderdiğinizde ne olacağını kontrol eder. Sıra bekler; Interrupt iptal eder ve yeniden başlar; Steer, dönüşün ortasında kesintiye uğramadan bir düzeltme enjekte eder (aracı veya akış mevcut olmadığında kuyruğa geri döner).', @@ -18732,6 +18852,16 @@ const LOCALES = { cmd_steer_delivered: 'Korekta dostarczona — agent zobaczy ją przy następnym wyniku narzędzia', steer_leftover_queued: 'Korekta dodana do kolejki na następną turę', busy_steer_fallback: 'Korekta niedostępna — przywrócono szkic', + steer_fail_no_cached_agent: 'No agent cached for this session — try refreshing', + steer_fail_agent_lacks_steer: 'This agent version does not support steer', + steer_fail_session_not_found: 'Session not found — it may have expired', + steer_fail_not_running: 'Agent is not currently running', + steer_fail_stream_dead: 'The agent stream has ended', + steer_fail_steer_error: 'Steer delivery failed — the agent may have finished', + steer_fail_network_error: 'Network error — check your connection', + steer_fail_unknown: 'Steer unavailable', + steer_recovery_retry: 'Retry', + steer_recovery_dismiss: 'Dismiss', busy_interrupt_confirm: 'Przerwano — wysyłanie nowej wiadomości', settings_label_busy_input_mode: 'Tryb wprowadzania przy zajętości', settings_desc_busy_input_mode: 'Kontroluje, co dzieje się, gdy wysyłasz wiadomość, podczas gdy agent pracuje. Kolejkowanie czeka; Przerwanie anuluje i zaczyna od nowa; Kierowanie (Steer) wprowadza korektę w środku tury bez przerywania (cofa się do kolejkowania, gdy agent lub strumień jest niedostępny).', @@ -20281,6 +20411,16 @@ const LOCALES = { cmd_steer_delivered: 'Steer đã được gửi — agent sẽ thấy ở kết quả tool tiếp theo', steer_leftover_queued: 'Steer đã được đưa vào hàng chờ lượt tiếp theo', busy_steer_fallback: 'Steer không khả dụng — đã đưa vào hàng chờ lượt tiếp theo', + steer_fail_no_cached_agent: 'No agent cached for this session — try refreshing', + steer_fail_agent_lacks_steer: 'This agent version does not support steer', + steer_fail_session_not_found: 'Session not found — it may have expired', + steer_fail_not_running: 'Agent is not currently running', + steer_fail_stream_dead: 'The agent stream has ended', + steer_fail_steer_error: 'Steer delivery failed — the agent may have finished', + steer_fail_network_error: 'Network error — check your connection', + steer_fail_unknown: 'Steer unavailable', + steer_recovery_retry: 'Retry', + steer_recovery_dismiss: 'Dismiss', busy_interrupt_confirm: 'Đã ngắt — đang gửi tin nhắn mới', settings_label_busy_input_mode: 'Chế độ nhập khi bận', settings_desc_busy_input_mode: 'Điều khiển hành vi khi bạn gửi tin nhắn trong lúc agent đang chạy. Queue sẽ chờ; Interrupt sẽ hủy và bắt đầu mới; Steer sẽ chèn điều chỉnh giữa lượt mà không ngắt (sẽ fallback sang queue nếu agent hoặc stream không khả dụng).', diff --git a/static/style.css b/static/style.css index dbd6e194..764d8048 100644 --- a/static/style.css +++ b/static/style.css @@ -1735,6 +1735,13 @@ .steer-indicator{display:flex;align-items:baseline;gap:8px;padding:10px 0;opacity:.65;font-style:italic;color:var(--accent-text);} .steer-indicator .steer-body{white-space:pre-wrap;word-break:break-word;} .steer-badge{display:inline-block;font-size:10px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--accent-text);background:var(--accent-bg);border:1px solid var(--accent-bg-strong);border-radius:4px;padding:1px 6px;vertical-align:middle;line-height:1.6;font-style:normal;flex-shrink:0;} + /* Steer recovery bar: dismissible after failure, same lifecycle as steer-indicator */ + .steer-recovery{display:flex;align-items:center;gap:0.5rem;padding:0.5rem 0.75rem;margin:0.25rem 0;border-radius:var(--radius-md);background:var(--surface-subtle);font-size:0.85rem} + .steer-recovery-label{flex:1;min-width:0;color:var(--text)} + .steer-recovery button{padding:0.25rem 0.75rem;border-radius:var(--radius-md);border:1px solid var(--border);background:var(--surface);color:var(--text);cursor:pointer;font-size:0.8rem;line-height:1.4;white-space:nowrap} + .steer-recovery button:hover{background:var(--hover-bg);color:var(--text)} + .steer-recovery-retry{border-color:var(--accent);color:var(--accent);font-weight:600} + .steer-recovery-retry:hover{color:var(--accent)} .msg-role{font-size:12px;font-weight:500;letter-spacing:.01em;margin-bottom:8px;display:flex;align-items:center;gap:8px;} .msg-role.user{color:var(--accent);} .msg-role.assistant{color:var(--accent-text);opacity:.6;} diff --git a/tests/test_1062_busy_input_modes.py b/tests/test_1062_busy_input_modes.py index 5d8724f9..b7eb9ce5 100644 --- a/tests/test_1062_busy_input_modes.py +++ b/tests/test_1062_busy_input_modes.py @@ -110,7 +110,7 @@ class TestSlashCommandHandlers: assert "cancelStream" not in helper_body assert "inp.value" in helper_body # Toast should differ from interrupt to signal it's the steer path - assert "cmd_steer_fallback" in helper_body or "steer_fallback" in helper_body + assert "_steerFailureMessageKey" in helper_body or "steer_fail_" in helper_body # ── send() busy branch ─────────────────────────────────────────────────── diff --git a/tests/test_issue4749_steer_reason_and_recovery.py b/tests/test_issue4749_steer_reason_and_recovery.py new file mode 100644 index 00000000..7a946b58 --- /dev/null +++ b/tests/test_issue4749_steer_reason_and_recovery.py @@ -0,0 +1,275 @@ +"""Tests for issue #4749: steer failure reason display and recovery bar. + +Covers: + 1. i18n contract — all expected keys exist in the en locale block + 2. Reason map contract — _steerFailureMessageKey returns correct keys + 3. Backend parity — frontend reason map covers all backend fallback codes + 4. Recovery DOM — _showSteerRecovery creates correct structure; dismiss removes it +""" +import re +import subprocess +import sys +import textwrap +from pathlib import Path + +REPO = Path(__file__).parent.parent +I18N_JS = REPO / "static" / "i18n.js" +COMMANDS_JS = REPO / "static" / "commands.js" +STREAMING_PY = REPO / "api" / "streaming.py" + +EXPECTED_I18N_KEYS = [ + "steer_fail_no_cached_agent", + "steer_fail_agent_lacks_steer", + "steer_fail_session_not_found", + "steer_fail_not_running", + "steer_fail_stream_dead", + "steer_fail_steer_error", + "steer_fail_network_error", + "steer_fail_unknown", + "steer_recovery_retry", + "steer_recovery_dismiss", +] + +BACKEND_CODES = { + "no_cached_agent", + "agent_lacks_steer", + "session_not_found", + "not_running", + "stream_dead", + "steer_error", +} + +FRONTEND_NETWORK_CODE = "network_error" + + +def test_i18n_steer_failure_keys_exist(): + """All 10 expected i18n keys are present in the en locale block.""" + text = I18N_JS.read_text(encoding="utf-8") + # Find the en locale block: from 'en:' or 'en =' up to the next top-level locale key + en_match = re.search(r"\ben\s*:\s*\{", text) + assert en_match, "Could not locate 'en:' locale block in i18n.js" + # Grab a large slice starting at the en block; the block is thousands of lines + en_block = text[en_match.start():] + for key in EXPECTED_I18N_KEYS: + pattern = rf"^\s+{re.escape(key)}\s*:" + assert re.search(pattern, en_block, re.MULTILINE), ( + f"Expected i18n key '{key}' not found in the en locale block" + ) + + +def test_reason_map_contract(): + """_steerFailureMessageKey maps each known code to the correct key.""" + node = _find_node() + script = textwrap.dedent(""" + const LOCALES = { en: { + steer_fail_no_cached_agent: 'x', + steer_fail_agent_lacks_steer: 'x', + steer_fail_session_not_found: 'x', + steer_fail_not_running: 'x', + steer_fail_stream_dead: 'x', + steer_fail_steer_error: 'x', + steer_fail_network_error: 'x', + steer_fail_unknown: 'x', + }}; + + function _steerFailureMessageKey(fallback) { + const key = 'steer_fail_' + (fallback || 'unknown'); + return (typeof LOCALES !== 'undefined' && LOCALES.en && LOCALES.en[key]) + ? key : 'steer_fail_unknown'; + } + + const codes = [ + 'no_cached_agent', 'agent_lacks_steer', 'session_not_found', + 'not_running', 'stream_dead', 'steer_error', 'network_error', + ]; + let ok = true; + for (const c of codes) { + const got = _steerFailureMessageKey(c); + const want = 'steer_fail_' + c; + if (got !== want) { + console.error('FAIL code=' + c + ' got=' + got + ' want=' + want); + ok = false; + } + } + // unknown code + const unk = _steerFailureMessageKey('something_unknown_xyz'); + if (unk !== 'steer_fail_unknown') { + console.error('FAIL unknown: got=' + unk); + ok = false; + } + // null / undefined + const n = _steerFailureMessageKey(null); + if (n !== 'steer_fail_unknown') { + console.error('FAIL null: got=' + n); + ok = false; + } + const u = _steerFailureMessageKey(undefined); + if (u !== 'steer_fail_unknown') { + console.error('FAIL undefined: got=' + u); + ok = false; + } + process.exit(ok ? 0 : 1); + """) + result = subprocess.run([node, "-e", script], capture_output=True, text=True) + assert result.returncode == 0, ( + f"_steerFailureMessageKey contract failed:\n{result.stdout}\n{result.stderr}" + ) + + +def test_backend_parity(): + """Frontend reason map covers all backend fallback codes from _handle_chat_steer.""" + streaming_text = STREAMING_PY.read_text(encoding="utf-8") + # Extract fallback codes from _handle_chat_steer function only + fn_match = re.search(r"def _handle_chat_steer\b.*?(?=\ndef |\Z)", streaming_text, re.DOTALL) + assert fn_match, "Could not find _handle_chat_steer in streaming.py" + fn_body = fn_match.group(0) + # Exclude placeholder strings like "" that appear in docstrings + found_codes = set( + c for c in re.findall(r'"fallback":\s*"([^"<>]+)"', fn_body) + if c # non-empty after filtering + ) + assert found_codes, "No fallback codes found in _handle_chat_steer" + assert found_codes == BACKEND_CODES, ( + f"Backend fallback codes mismatch.\n" + f" Found: {sorted(found_codes)}\n" + f" Expected: {sorted(BACKEND_CODES)}" + ) + # Also confirm frontend adds network_error + commands_text = COMMANDS_JS.read_text(encoding="utf-8") + assert FRONTEND_NETWORK_CODE in commands_text, ( + "network_error not found in commands.js" + ) + # Confirm the dynamic key builder and recovery bar are present in commands.js + assert "_steerFailureMessageKey" in commands_text, ( + "_steerFailureMessageKey not found in commands.js" + ) + assert "_showSteerRecovery" in commands_text, ( + "_showSteerRecovery not found in commands.js" + ) + # The builder uses the 'steer_fail_' prefix pattern + assert "steer_fail_" in commands_text, ( + "'steer_fail_' prefix not found in commands.js" + ) + + +def test_recovery_dom_structure(): + """_showSteerRecovery creates a div with label, retry, dismiss; dismiss removes it.""" + node = _find_node() + script = textwrap.dedent(""" + // Minimal DOM stubs + const elements = {}; + function createElement(tag) { + const el = { + tag, className: '', textContent: '', children: [], + listeners: {}, + appendChild(c) { this.children.push(c); }, + addEventListener(ev, fn) { this.listeners[ev] = fn; }, + remove() { el._removed = true; }, + querySelector(sel) { + // only handle .steer-recovery for old-removal check + return null; + }, + }; + elements[tag + '_' + Math.random()] = el; + return el; + } + const inner = createElement('div'); + inner.querySelector = (sel) => null; // no existing recovery bar + const document = { + getElementById(id) { return id === 'msgInner' ? inner : null; }, + createElement, + }; + function t(key) { return key; } + function _steerFailureMessageKey(fallback) { + const key = 'steer_fail_' + (fallback || 'unknown'); + const LOCALES = { en: { + steer_fail_not_running: 'Agent is not currently running', + steer_fail_unknown: 'Steer unavailable', + steer_recovery_retry: 'Retry', + steer_recovery_dismiss: 'Dismiss', + }}; + return (LOCALES.en && LOCALES.en[key]) ? key : 'steer_fail_unknown'; + } + function _trySteer() {} // stub for retry handler + + function _showSteerRecovery(msg, explicitSteer, fallback) { + const inner = document.getElementById('msgInner'); + if (!inner) return; + const old = inner.querySelector('.steer-recovery'); + if (old) old.remove(); + const el = document.createElement('div'); + el.className = 'steer-recovery'; + const label = document.createElement('span'); + label.className = 'steer-recovery-label'; + label.textContent = t(_steerFailureMessageKey(fallback)); + el.appendChild(label); + const retryBtn = document.createElement('button'); + retryBtn.className = 'steer-recovery-retry'; + retryBtn.textContent = t('steer_recovery_retry'); + retryBtn.addEventListener('click', () => { + el.remove(); + _trySteer(msg, explicitSteer); + }); + el.appendChild(retryBtn); + const dismissBtn = document.createElement('button'); + dismissBtn.className = 'steer-recovery-dismiss'; + dismissBtn.textContent = t('steer_recovery_dismiss'); + dismissBtn.addEventListener('click', () => el.remove()); + el.appendChild(dismissBtn); + inner.appendChild(el); + } + + _showSteerRecovery('hello', false, 'not_running'); + + const bar = inner.children[inner.children.length - 1]; + let ok = true; + + if (bar.className !== 'steer-recovery') { + console.error('FAIL: bar className=' + bar.className); + ok = false; + } + const [lbl, retry, dismiss] = bar.children; + if (!lbl || lbl.className !== 'steer-recovery-label') { + console.error('FAIL: label missing or wrong class'); + ok = false; + } + if (!retry || retry.className !== 'steer-recovery-retry') { + console.error('FAIL: retry btn missing or wrong class'); + ok = false; + } + if (!dismiss || dismiss.className !== 'steer-recovery-dismiss') { + console.error('FAIL: dismiss btn missing or wrong class'); + ok = false; + } + // Simulate dismiss click + dismiss.listeners['click'](); + if (!bar._removed) { + console.error('FAIL: bar not removed after dismiss'); + ok = false; + } + process.exit(ok ? 0 : 1); + """) + result = subprocess.run([node, "-e", script], capture_output=True, text=True) + assert result.returncode == 0, ( + f"Recovery DOM structure test failed:\n{result.stdout}\n{result.stderr}" + ) + + +def _find_node(): + """Return path to node.exe, skipping wrapper scripts.""" + import shutil + candidates = ["node", "node.exe"] + for c in candidates: + path = shutil.which(c) + if path: + # Verify it's actually node, not a wrapper + try: + r = subprocess.run([path, "--version"], capture_output=True, text=True, timeout=5) + if r.returncode == 0 and r.stdout.strip().startswith("v"): + return path + except Exception: + continue + pytest_skip = getattr(sys.modules.get("pytest"), "skip", None) + if pytest_skip: + pytest_skip("node.js not found — skipping node-executed tests") + raise RuntimeError("node.js not found")