stage #4577 (dso2ng): keep failed steer from cancelling active runs (#4670)

A failed /steer (or busy-mode steer) no longer silently falls back to interrupt+queue
(which cancelled the active stream). _trySteer now returns delivered:bool, restores the
draft + leaves the stream running on failure; send() only clears staged files when
_steerDelivered===true. Backend hunk comment-only (failure paths already returned
{accepted:False}, never touched CANCEL_FLAGS). i18n value-only on pre-existing keys
(locale-parity preserved, 173 locale tests pass). 48 own tests pass.

Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
Co-authored-by: dso2ng <dso2ng@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-06-21 23:22:51 -07:00
1 parent fc3d6b68e6
commit 0456003c84
8 files changed
+111 -88

No files matched your search

+7
View File
@@ -3,12 +3,19 @@
## [Unreleased]
## [v0.51.577] — 2026-06-22 — Release UJ (failed steer no longer cancels active run)
### Fixed
- **A failed Steer no longer cancels your active run.** When `/steer` (or the steer busy-input mode) couldn't be delivered — the agent isn't running/cached or doesn't support steer — WebUI used to silently fall back to interrupt+queue, cancelling the in-flight stream. It now restores your draft to the composer and leaves the active run untouched, so you explicitly choose Queue or Interrupt instead. Staged files are kept on a failed steer and only cleared once a steer is actually delivered. Thanks @dso2ng. (#4577)
## [v0.51.576] — 2026-06-22 — Release UI (scroll read-position + mobile horizontal-pan fixes)
### Fixed
- **The transcript no longer yanks you back to the bottom while you're reading mid-stream.** Scroll re-pinning now requires a deliberate move toward the bottom (and ignores tiny scroll jitter), so reading earlier messages during an active stream stays put instead of getting hijacked back to the latest token. Thanks @rodboev. (#4584, fixes #4295)
- **No more accidental horizontal panning of the mobile transcript.** Wide content (long code lines, URLs) could let the message area pan sideways on phones; the transcript now clips horizontal overflow and wraps long words. Thanks @rodboev. (#4583, fixes #4553)
>>>>>>> baa5869e4 (stage #4577 (dso2ng): keep failed steer from cancelling active runs)
## [v0.51.575] — 2026-06-22 — Release UH (session-list perf for long histories)
+6 -4
View File
@@ -8932,9 +8932,10 @@ def _handle_chat_steer(handler, body: dict) -> bool:
interrupted.
If no agent is cached, the agent is too old to support steer, or no
stream is active, return {"accepted": False, "fallback": "<reason>"}
so the frontend can fall back to interrupt or queue mode. The
fallback path is the existing behaviour from PR #1062.
stream is active, return {"accepted": False, "fallback": "<reason>"}.
The frontend must surface that failure without cancelling the active run;
Steer is active-run guidance, not implicit permission to Queue, Interrupt,
or Stop-and-send.
Returns 200 with {"accepted": bool, "fallback": str|None,
"stream_id": str|None}.
@@ -8968,7 +8969,8 @@ def _handle_chat_steer(handler, body: dict) -> bool:
except Exception:
logger.debug("Failed to close steer identity-mismatched cached agent for session %s", sid, exc_info=True)
if not cached:
# No active agent for this session — caller falls back to interrupt
# No active agent for this session — caller surfaces a steer failure
# without cancelling the active run.
return j(handler, {"accepted": False, "fallback": "no_cached_agent",
"stream_id": None})
agent = cached[0]
+24 -22
View File
@@ -1197,8 +1197,10 @@ async function cmdInterrupt(args){
* steer text to the next tool-result message so the model sees it on its
* next iteration — same pathway as the CLI's /steer command.
*
* Falls back to interrupt mode when the agent isn't running, isn't cached,
* or doesn't support steer (older hermes-agent versions).
* Leaves the active stream alone when the agent isn't running, isn't cached,
* or doesn't support steer (older hermes-agent versions). The failed steer text
* is restored to the composer so the user can choose Queue or Interrupt
* explicitly instead of WebUI silently cancelling the current run.
*/
async function cmdSteer(args){
const msg=(args||'').trim();
@@ -1218,12 +1220,15 @@ async function cmdSteer(args){
* 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.) falls back to interrupt+queue:
* queues the message and cancels the stream so the drain re-sends it.
* 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 only.
* (vs the busy-mode auto-fallback). Affects toast wording and draft restore.
* @returns {Promise<boolean>} true when the steer was delivered, false when the
* draft was restored and the active stream was left untouched.
*/
function _showSteerIndicator(text){
const inner=document.getElementById('msgInner');
@@ -1253,7 +1258,7 @@ async function _trySteer(msg, explicitSteer){
body:JSON.stringify({session_id:S.session.session_id,text:msg}),
});
}catch(e){
// Network or server error — fall back to interrupt
// Network or server error — keep the active stream running and restore the draft.
result={accepted:false, fallback:'network_error'};
}
if(result&&result.accepted){
@@ -1263,26 +1268,23 @@ async function _trySteer(msg, explicitSteer){
// all call renderMessages which rebuilds msgInner).
_showSteerIndicator(msg);
showToast(t('cmd_steer_delivered'),2500);
return;
return true;
}
// Fall back to interrupt: queue the message + cancel the stream so the
// drain in setBusy(false) re-sends it as a fresh turn.
queueSessionMessage(S.session.session_id,{text:msg,files:[...S.pendingFiles],model:S.session&&S.session.model||($('modelSelect')&&$('modelSelect').value)||'',profile:S.activeProfile||'default'});
updateQueueBadge(S.session.session_id);
S.pendingFiles=[];renderTray();
if(typeof cancelStream==='function'){await cancelStream();}
// Toast wording differs based on why we're falling back so the user
// understands what just happened.
const reason=(result&&result.fallback)||'unknown';
// Do not fall back to interrupt: Steer failure is not permission to cancel
// the active run. Restore the draft so the user can explicitly Queue or
// Interrupt if that is what they want next. Pending files remain staged.
const inp=$('msg');
if(inp){
inp.value=explicitSteer?`/steer ${msg}`:msg;
if(typeof autoResize==='function')autoResize();
}
if(typeof renderTray==='function')renderTray();
if(explicitSteer){
showToast(t('cmd_steer_fallback'),2500);
} else if(reason==='no_cached_agent'||reason==='not_running'||reason==='stream_dead'){
// Busy mode hit the steer path before the agent was ready —
// interrupt is the natural fallback, no need to call out steer.
showToast(t('busy_interrupt_confirm'),2000);
showToast(t('cmd_steer_fallback'),3500);
} else {
showToast(t('busy_steer_fallback'),2500);
showToast(t('busy_steer_fallback'),3500);
}
return false;
}
async function cmdTitle(args){
+27 -27
View File
@@ -294,13 +294,13 @@ const LOCALES = {
cmd_interrupt_no_msg: 'Usage: /interrupt <message>',
cmd_interrupt_confirm: 'Interrupted — sending new message',
cmd_steer_no_msg: 'Usage: /steer <message>',
cmd_steer_fallback: 'Steer unavailable — queued for next turn instead',
cmd_steer_fallback: 'Steer unavailable — draft restored',
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 — queued for next turn',
busy_steer_fallback: 'Steer unavailable — draft restored',
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 (falls back to queue when agent or stream unavailable).',
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.',
settings_label_fade_text_effect: 'Fade text effect',
settings_desc_fade_text_effect: 'Fade newly streamed words in while the assistant is responding. Similar to OpenWebUI; off by default for maximum performance.',
settings_busy_input_mode_queue: 'Queue follow-up',
@@ -1866,10 +1866,10 @@ const LOCALES = {
cmd_interrupt_no_msg: 'Uso: /interrupt <messaggio>',
cmd_interrupt_confirm: 'Interrotto — invio nuovo messaggio',
cmd_steer_no_msg: 'Uso: /steer <messaggio>',
cmd_steer_fallback: 'Steer non disponibile — messaggio in coda per il prossimo turno',
cmd_steer_fallback: 'Steer non disponibile — bozza ripristinata',
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 — messaggio in coda per il prossimo turno',
busy_steer_fallback: 'Steer non disponibile — bozza ripristinata',
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).",
@@ -3429,10 +3429,10 @@ const LOCALES = {
cmd_interrupt_no_msg: '使い方: /interrupt <メッセージ>',
cmd_interrupt_confirm: '中断 — 新しいメッセージを送信中',
cmd_steer_no_msg: '使い方: /steer <メッセージ>',
cmd_steer_fallback: 'ステア利用不可 — 代わりに次のターンへキュー',
cmd_steer_fallback: 'ステア利用不可 — 下書きを復元しました',
cmd_steer_delivered: 'ステア送信完了 — 次のツール結果でエージェントが認識します',
steer_leftover_queued: 'ステアを次のターンへキュー',
busy_steer_fallback: 'ステア利用不可 — 次のターンへキュー',
busy_steer_fallback: 'ステア利用不可 — 下書きを復元しました',
busy_interrupt_confirm: '中断 — 新しいメッセージを送信中',
settings_label_busy_input_mode: 'ビジー時の入力モード',
settings_desc_busy_input_mode: 'エージェント実行中にメッセージを送信した時の動作を制御します。Queue は待機、Interrupt はキャンセルして再開、Steer は中断せずにターン中に修正を注入します (エージェントやストリームが利用不可ならキューにフォールバック)。',
@@ -4943,10 +4943,10 @@ const LOCALES = {
cmd_interrupt_no_msg: 'Использование: /interrupt <сообщение>',
cmd_interrupt_confirm: 'Прервано — отправка нового сообщения',
cmd_steer_no_msg: 'Использование: /steer <сообщение>',
cmd_steer_fallback: 'Steer недоступен — прервано и поставлено в очередь',
cmd_steer_fallback: 'Steer недоступен — черновик восстановлен',
cmd_steer_delivered: 'Steer доставлен — агент увидит его в следующем ответе инструмента',
steer_leftover_queued: 'Steer поставлен в очередь на следующий ход',
busy_steer_fallback: 'Steer недоступен — прервано',
busy_steer_fallback: 'Steer недоступен — черновик восстановлен',
busy_interrupt_confirm: 'Прервано — отправка нового сообщения',
settings_label_busy_input_mode: 'Режим ввода при занятости',
settings_desc_busy_input_mode: 'Определяет поведение при отправке сообщения во время работы агента. Очередь ждёт; Прерывание отменяет и начинает заново; Steer внедряет коррекцию без прерывания.',
@@ -6493,10 +6493,10 @@ const LOCALES = {
cmd_interrupt_no_msg: 'Uso: /interrupt <mensaje>',
cmd_interrupt_confirm: 'Interrumpido \u2014 enviando nuevo mensaje',
cmd_steer_no_msg: 'Uso: /steer <mensaje>',
cmd_steer_fallback: 'Steer no disponible — en cola para el siguiente turno',
cmd_steer_fallback: 'Steer no disponible — borrador restaurado',
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 — en cola para el siguiente turno',
busy_steer_fallback: 'Steer no disponible — borrador restaurado',
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).',
@@ -7964,10 +7964,10 @@ const LOCALES = {
cmd_interrupt_no_msg: 'Verwendung: /interrupt <Nachricht>',
cmd_interrupt_confirm: 'Unterbrochen \u2014 neue Nachricht wird gesendet',
cmd_steer_no_msg: 'Verwendung: /steer <Nachricht>',
cmd_steer_fallback: 'Steer nicht verf\u00fcgbar \u2014 f\u00fcr n\u00e4chsten Durchgang eingereiht',
cmd_steer_fallback: 'Steer nicht verf\u00fcgbar \u2014 Entwurf wiederhergestellt',
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 f\u00fcr n\u00e4chsten Durchgang eingereiht',
busy_steer_fallback: 'Steer nicht verf\u00fcgbar \u2014 Entwurf wiederhergestellt',
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.',
@@ -9494,10 +9494,10 @@ const LOCALES = {
cmd_interrupt_no_msg: '用法:/interrupt <消息>',
cmd_interrupt_confirm: '已中断 — 正在发送新消息',
cmd_steer_no_msg: '用法:/steer <消息>',
cmd_steer_fallback: 'Steer 不可用 — 已中断并加入队列',
cmd_steer_fallback: 'Steer 不可用 — 草稿已恢复',
cmd_steer_delivered: 'Steer 已交付 — 代理将在下一个工具结果中看到',
steer_leftover_queued: 'Steer 已加入下轮队列',
busy_steer_fallback: 'Steer 不可用 — 已中断',
busy_steer_fallback: 'Steer 不可用 — 草稿已恢复',
busy_interrupt_confirm: '已中断 — 正在发送新消息',
settings_label_busy_input_mode: '忙碌输入模式',
settings_desc_busy_input_mode: '控制当代理正在运行时你发送消息会发生什么。队列会等待;中断会取消并重新开始;引导会在不中断的情况下注入中间修正(当流不可用时回退到队列)。',
@@ -11001,10 +11001,10 @@ const LOCALES = {
cmd_interrupt_no_msg: '用法:/interrupt <訊息>',
cmd_interrupt_confirm: '已中斷,正在傳送新訊息',
cmd_steer_no_msg: '用法:/steer <訊息>',
cmd_steer_fallback: 'Steer 不可用 — 已加入下一輪佇列',
cmd_steer_fallback: 'Steer 不可用 — 已恢復草稿',
cmd_steer_delivered: 'Steer 已送達,Agent 會在下一個工具結果中看到',
steer_leftover_queued: 'Steer 已加入下一輪佇列',
busy_steer_fallback: 'Steer 不可用 — 已加入下一輪佇列',
busy_steer_fallback: 'Steer 不可用 — 已恢復草稿',
busy_interrupt_confirm: '已中斷,正在傳送新訊息',
settings_label_busy_input_mode: '忙碌輸入模式',
settings_desc_busy_input_mode: '控制 Agent 執行時送出訊息的行為。佇列會等待;中斷會取消並重新開始;Steer 會中途注入修正且不中斷(Agent 或串流無法使用時會退回佇列)。',
@@ -12492,10 +12492,10 @@ const LOCALES = {
cmd_interrupt_no_msg: 'Uso: /interrupt <mensagem>',
cmd_interrupt_confirm: 'Interrompido — enviando nova mensagem',
cmd_steer_no_msg: 'Uso: /steer <mensagem>',
cmd_steer_fallback: 'Steer indisponível — enfileirado para próximo turno',
cmd_steer_fallback: 'Steer indisponível — rascunho restaurado',
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 — enfileirado para próximo turno',
busy_steer_fallback: 'Steer indisponível — rascunho restaurado',
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.',
@@ -13941,10 +13941,10 @@ const LOCALES = {
cmd_interrupt_no_msg: 'Usage: /interrupt <message>',
cmd_interrupt_confirm: 'Interrupted — sending new message',
cmd_steer_no_msg: 'Usage: /steer <message>',
cmd_steer_fallback: 'Steer unavailable — queued for next turn instead',
cmd_steer_fallback: 'Steer unavailable — draft restored',
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 — queued for next turn',
busy_steer_fallback: 'Steer unavailable — draft restored',
busy_interrupt_confirm: 'Interrupted — sending new message',
settings_label_busy_input_mode: '작업 중 입력 방식',
settings_desc_busy_input_mode: '에이전트가 실행 중일 때 메시지를 보내면 어떻게 처리할지 제어합니다. 대기는 다음 차례까지 기다리고, 중단은 현재 작업을 취소하고 새로 시작하며, 조정은 현재 작업을 중단하지 않고 중간 수정 사항을 전달합니다(에이전트 또는 스트림을 사용할 수 없으면 대기로 전환).',
@@ -15488,10 +15488,10 @@ const LOCALES = {
cmd_interrupt_no_msg: 'Utilisation : /interruption <message>',
cmd_interrupt_confirm: 'Interrompu — envoi d\'un nouveau message',
cmd_steer_no_msg: 'Utilisation : /steer <message>',
cmd_steer_fallback: 'Steer indisponible – mis en file d’attente pour le prochain tour à la place',
cmd_steer_fallback: 'Steer indisponible – brouillon restauré',
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 – en file d’attente pour le prochain tour',
busy_steer_fallback: 'Steer indisponible – brouillon restauré',
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).',
@@ -17011,10 +17011,10 @@ const LOCALES = {
cmd_interrupt_no_msg: 'Kullanım: /interrupt <mesaj>',
cmd_interrupt_confirm: 'Kesintiye uğradı — yeni mesaj gönderilir',
cmd_steer_no_msg: 'Kullanım: /steer <mesaj>',
cmd_steer_fallback: 'Yönlendirme kullanılamıyor - bunun yerine bir sonraki dönüş için sıraya alındı',
cmd_steer_fallback: 'Yönlendirme kullanılamıyor — taslak geri yüklendi',
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 — bir sonraki dönüş için kuyruğa alındı',
busy_steer_fallback: 'Yönlendirme kullanılamıyor — taslak geri yüklendi',
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).',
@@ -18560,10 +18560,10 @@ const LOCALES = {
cmd_interrupt_no_msg: 'Użycie: /interrupt <wiadomość>',
cmd_interrupt_confirm: 'Przerwano — wysyłanie nowej wiadomości',
cmd_steer_no_msg: 'Użycie: /steer <wiadomość>',
cmd_steer_fallback: 'Korekta niedostępna — dodano do kolejki na następną turę',
cmd_steer_fallback: 'Korekta niedostępna — przywrócono szkic',
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 — dodano do kolejki na następną turę',
busy_steer_fallback: 'Korekta niedostępna — przywrócono szkic',
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).',
+1 -1
View File
@@ -1305,7 +1305,7 @@
<option value="interrupt" data-i18n="settings_busy_input_mode_interrupt">Interrupt current turn</option>
<option value="steer" data-i18n="settings_busy_input_mode_steer">Steer (mid-turn correction)</option>
</select>
<div style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="settings_desc_busy_input_mode">Controls what happens when you send a message while the agent is running. Queue waits for the current task; Interrupt cancels and starts fresh; Steer injects a mid-turn correction without interrupting (falls back to interrupt when the agent is not yet cached or the stream has ended).</div>
<div style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="settings_desc_busy_input_mode">Controls what happens when you send a message while the agent is running. Queue waits for the current task; Interrupt cancels and starts fresh; Steer injects a mid-turn correction without interrupting. If Steer is unavailable, the draft is restored so you can choose the next action.</div>
</div>
<div class="settings-field">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
+8 -9
View File
@@ -1084,16 +1084,15 @@ async function send(){
if(busyMode==='steer'&&S.activeStreamId&&typeof _trySteer==='function'){
// Real steer: clear the input first so the user gets immediate
// feedback, then ship the steer payload via /api/chat/steer.
// _trySteer falls back to queue+cancel internally if the agent
// isn't running / cached / steer-capable.
// _trySteer restores the draft and leaves the active stream running if
// the agent isn't running / cached / steer-capable.
$('msg').value='';autoResize();
// Do NOT clear pendingFiles yet — _trySteer may fall back to
// interrupt+queue and needs the files for queueSessionMessage.
// _trySteer clears pendingFiles itself in the fallback path, and
// the server returns accepted:true (no files sent) on success.
await _trySteer(text, /*explicitSteer=*/false);
// After _trySteer: clear any remaining files (success path).
S.pendingFiles=[];renderTray();
// Do NOT clear pendingFiles yet — a failed steer restores the draft and
// must keep staged files available for the user's next explicit action.
const _steerDelivered=await _trySteer(text, /*explicitSteer=*/false);
// After a delivered steer, clear any staged files because the text-only
// steer payload has been handled. On failure, keep files staged.
if(_steerDelivered){S.pendingFiles=[];renderTray();}
} else if(busyMode==='interrupt'){
// Queue the message, then cancel so drain re-sends it.
const _modelState=_chatPayloadModelState();
+31 -20
View File
@@ -95,20 +95,20 @@ class TestSlashCommandHandlers:
def test_cmd_steer_delegates_to_try_steer(self):
"""/steer delegates to _trySteer which calls /api/chat/steer with
a queue+cancel fallback. The fallback path is exercised by tests
a non-destructive fallback. The fallback path is exercised by tests
in test_real_steer.py — this test just pins the delegation."""
idx = COMMANDS_JS.find("async function cmdSteer(")
assert idx >= 0
body = COMMANDS_JS[idx:idx + 800]
# cmdSteer now delegates to _trySteer; the fallback (queueSessionMessage
# + cancelStream) lives inside _trySteer.
# cmdSteer delegates to _trySteer; fallback must not queue+cancel.
assert "_trySteer" in body, "cmdSteer must call _trySteer to use the real /api/chat/steer endpoint"
# The shared helper must contain the fallback path
# The shared helper must contain the non-destructive fallback path.
helper_idx = COMMANDS_JS.find("async function _trySteer(")
assert helper_idx >= 0, "_trySteer helper must exist"
helper_body = COMMANDS_JS[helper_idx:helper_idx + 2000]
assert "queueSessionMessage" in helper_body
assert "cancelStream" in helper_body
assert "queueSessionMessage" not in helper_body
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
@@ -116,14 +116,13 @@ class TestSlashCommandHandlers:
# ── send() busy branch ───────────────────────────────────────────────────
def test_slash_commands_clear_pending_files(self):
"""All three busy command handlers must clear S.pendingFiles (directly
or via _trySteer) after enqueuing, so staged files are not duplicated.
"""Queue/interrupt clear S.pendingFiles after enqueuing; steer failure
preserves staged files so the user can choose the next explicit action.
cmdQueue and cmdInterrupt call queueSessionMessage themselves and clear
S.pendingFiles directly. cmdSteer delegates to _trySteer. The fallback/interrupt path clears
S.pendingFiles inside _trySteer; the success path returns early and
send() handles the post-await clear. Either way files are not
duplicated — we verify by checking _trySteer body for the clearing.
S.pendingFiles directly. cmdSteer delegates to _trySteer. _trySteer no
longer clears files on failure because it no longer falls back to
cancel-and-queue behavior.
"""
# cmdQueue and cmdInterrupt clear pendingFiles directly
for fn_name in ("cmdQueue", "cmdInterrupt"):
@@ -136,17 +135,13 @@ class TestSlashCommandHandlers:
assert "renderTray()" in body, (
f"{fn_name} must call renderTray() after clearing pendingFiles"
)
# cmdSteer delegates to _trySteer; that helper clears pendingFiles
# cmdSteer delegates to _trySteer; the helper must not clear files in
# the fallback path because the draft is restored instead of queued.
idx_try = COMMANDS_JS.find("function _trySteer(")
assert idx_try >= 0, "_trySteer not found"
try_body = COMMANDS_JS[idx_try:idx_try + 1600]
assert "S.pendingFiles=[]" in try_body, (
"_trySteer must clear S.pendingFiles in its fallback path — "
"without this, files are lost on steer→interrupt fallback"
)
assert "renderTray()" in try_body, (
"_trySteer must call renderTray() after clearing pendingFiles"
)
assert "S.pendingFiles=[]" not in try_body
assert "renderTray()" in try_body
class TestBusySendButton:
@@ -296,6 +291,22 @@ class TestSendBusyBranchDispatch:
"after setBusy(false) picks up the queued message"
)
def test_send_busy_steer_preserves_files_when_steer_not_delivered(self):
"""Busy-mode steer must only clear staged files after delivered steer.
A failed steer restores the draft and leaves the active stream running,
so staged files must remain available for the user's next explicit
Queue or Interrupt action.
"""
send_idx = MESSAGES_JS.find("async function send(")
assert send_idx >= 0, "send() not found"
steer_idx = MESSAGES_JS.find("busyMode==='steer'", send_idx)
assert steer_idx >= 0, "busy steer branch not found"
branch = MESSAGES_JS[steer_idx:steer_idx + 900]
assert "const _steerDelivered=await _trySteer" in branch
assert "if(_steerDelivered){S.pendingFiles=[];renderTray();}" in branch
assert branch.index("const _steerDelivered=await _trySteer") < branch.index("if(_steerDelivered){S.pendingFiles=[];renderTray();}")
def test_slash_commands_intercepted_before_busymode_routing(self):
"""Busy-control slash commands (/steer /interrupt /queue /yolo) must be
+7 -5
View File
@@ -7,7 +7,8 @@ text to the next tool-result message — no interruption.
Falls back to {"accepted": false, "fallback": "<reason>"} when the agent
isn't running, isn't cached, or doesn't support steer (older agent versions).
The frontend uses the fallback signal to drop back to interrupt mode.
The frontend uses the fallback signal to restore the draft without cancelling
the active run.
Plus a leftover-delivery flow: if the agent finishes its turn before the
steer is consumed (no tool-call boundary), _drain_pending_steer is called
@@ -266,13 +267,14 @@ class TestFrontendWiring:
assert "/api/chat/steer" in body, "_trySteer must POST to /api/chat/steer"
assert "method:'POST'" in body or 'method:"POST"' in body
def test_try_steer_handles_fallback(self):
def test_try_steer_handles_fallback_without_cancelling(self):
idx = self.cmds.find("async function _trySteer(")
body = self.cmds[idx:idx + 1500]
# Must check result.accepted and fall back via queueSessionMessage + cancelStream
# Must check result.accepted and surface fallback without queueing or cancelling.
assert "result&&result.accepted" in body or "result.accepted" in body
assert "queueSessionMessage" in body
assert "cancelStream" in body, "fallback path must cancel the stream"
assert "queueSessionMessage" not in body
assert "cancelStream" not in body, "fallback path must not cancel the stream"
assert "inp.value" in body, "fallback path must restore the composer draft"
def test_send_busy_steer_uses_try_steer(self):
# send() in messages.js: when busyMode === 'steer', should call _trySteer