From 9c69b646ff58d5ce492f6f1a05909455d143adc5 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Fri, 24 Apr 2026 01:24:51 +0000 Subject: [PATCH 1/2] feat(commands): /background, /btw slash commands + undo button + reasoning chip Rebased onto master after #931 (aux title routing) to resolve streaming.py conflict. All changes from both PRs are cleanly integrated. 2088 tests passing (2065 master + 23 from #931). Co-authored-by: bergeouss --- CHANGELOG.md | 12 +++- api/background.py | 87 ++++++++++++++++++++++++++++ api/routes.py | 140 +++++++++++++++++++++++++++++++++++++++++++++ api/streaming.py | 29 +++++++++- static/commands.js | 33 +++++++++++ static/i18n.js | 27 +++++++-- static/index.html | 16 ++++++ static/messages.js | 107 ++++++++++++++++++++++++++++++++++ static/style.css | 55 +++++++++++++++++- static/ui.js | 74 ++++++++++++++++++++++++ 10 files changed, 568 insertions(+), 12 deletions(-) create mode 100644 api/background.py diff --git a/CHANGELOG.md b/CHANGELOG.md index a077dbf..9f41e92 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,10 +29,18 @@ workspace subtree) and never enumerate blocked system roots. (`api/routes.py`, `api/workspace.py`, `static/panels.js`, `static/style.css`) (partial for #616) -## [v0.50.182] — 2026-04-24 +## [v0.50.183] — 2026-04-24 + +### Added +- **`/btw` slash command** — ask an ephemeral side question using current session context without adding to history. Creates a hidden session, streams the answer in a visually distinct bubble, then discards the session. Includes `attachBtwStream()` SSE consumer and `POST /api/btw` route. (`api/routes.py`, `api/background.py`, `static/commands.js`, `static/messages.js`, `static/style.css`) +- **`/background` slash command** — run a prompt in a parallel background agent without blocking the active conversation. Frontend polls `GET /api/background/status` for results and displays completed answers inline. Includes badge indicator in composer footer. (`api/routes.py`, `api/background.py`, `static/commands.js`, `static/messages.js`, `static/index.html`) +- **Undo button on last assistant message** — surfaced as an ↩ icon on the last assistant message, calling the existing `/undo` command for discoverability. (`static/ui.js`) +- **Reasoning effort chip in composer** — visual chip to set reasoning effort level from the composer footer without typing a command. (`static/ui.js`, `static/index.html`, `static/style.css`) ### Fixed -- **Auxiliary title model now respected** — when `auxiliary.title_generation` is explicitly configured in `config.yaml`, the WebUI now routes title generation through that dedicated model instead of silently using the chat session's model. Adds `_aux_title_configured()` to detect meaningful auxiliary config, `_aux_title_timeout()` to respect the configured per-task timeout (was hardcoded to 15.0 s), and adds the missing `llm_invalid_aux` fallback path so invalid auxiliary outputs trigger the agent-model fallback. (`api/streaming.py`, `tests/test_title_aux_routing.py`) Co-authored by @starship-s. +- **Ephemeral sessions correctly skip persistence** — added `return` after the ephemeral `done` event in `_run_agent_streaming()`, preventing ephemeral session state from being written to disk after stream completion. (`api/streaming.py`) + +Co-authored by @bergeouss. ## [v0.50.181] — 2026-04-24 diff --git a/api/background.py b/api/background.py new file mode 100644 index 0000000..7951137 --- /dev/null +++ b/api/background.py @@ -0,0 +1,87 @@ +"""Background and ephemeral task tracking for /background and /btw commands.""" +from __future__ import annotations + +import logging +import threading +import time +from typing import Any + +logger = logging.getLogger(__name__) + +_lock = threading.Lock() + +# parent_session_id -> list of task dicts +_BACKGROUND_TASKS: dict[str, list[dict[str, Any]]] = {} + +# btw ephemeral session tracking: parent_sid -> {ephemeral_sid, stream_id, question} +_BTW_TRACKING: dict[str, dict[str, Any]] = {} + + +def track_background(parent_sid: str, bg_sid: str, stream_id: str, + task_id: str, prompt: str) -> None: + with _lock: + _BACKGROUND_TASKS.setdefault(parent_sid, []).append({ + "task_id": task_id, + "bg_session_id": bg_sid, + "stream_id": stream_id, + "prompt": prompt, + "status": "running", + "started_at": time.time(), + "answer": None, + "completed_at": None, + }) + + +def track_btw(parent_sid: str, ephemeral_sid: str, stream_id: str, + question: str) -> None: + with _lock: + _BTW_TRACKING[parent_sid] = { + "ephemeral_session_id": ephemeral_sid, + "stream_id": stream_id, + "question": question, + } + + +def complete_background(parent_sid: str, task_id: str, answer: str) -> None: + with _lock: + for t in _BACKGROUND_TASKS.get(parent_sid, []): + if t["task_id"] == task_id and t["status"] == "running": + t["status"] = "done" + t["answer"] = answer + t["completed_at"] = time.time() + break + + +def get_results(parent_sid: str) -> list[dict[str, Any]]: + """Return completed background task results and remove only the done ones + from tracking. Tasks still in ``status="running"`` MUST stay in the list + so that ``complete_background()`` can still find them when the worker + thread finishes — otherwise the first poll during a long-running task + silently drops it and the result is lost forever. + """ + with _lock: + tasks = _BACKGROUND_TASKS.get(parent_sid, []) + done = [t for t in tasks if t["status"] == "done"] + still_running = [t for t in tasks if t["status"] != "done"] + if still_running: + _BACKGROUND_TASKS[parent_sid] = still_running + else: + _BACKGROUND_TASKS.pop(parent_sid, None) + return [{ + "task_id": t["task_id"], + "prompt": t["prompt"], + "answer": t["answer"], + "completed_at": t["completed_at"], + } for t in done] + + +def get_background_tasks(parent_sid: str) -> list[dict[str, Any]]: + """Return all background tasks (running and done) for a parent session.""" + with _lock: + return list(_BACKGROUND_TASKS.get(parent_sid, [])) + + +def cleanup_btw(parent_sid: str) -> dict[str, Any] | None: + """Remove and return btw tracking for a parent session.""" + with _lock: + return _BTW_TRACKING.pop(parent_sid, None) diff --git a/api/routes.py b/api/routes.py index 27095f9..12c06cd 100644 --- a/api/routes.py +++ b/api/routes.py @@ -741,6 +741,13 @@ def handle_get(handler, parsed) -> bool: except KeyError: return bad(handler, "Session not found", 404) + if parsed.path == "/api/background/status": + sid = parse_qs(parsed.query).get("session_id", [""])[0] + if not sid: + return bad(handler, "Missing session_id") + from api.background import get_results + return j(handler, {"results": get_results(sid)}) + if parsed.path == "/api/sessions": webui_sessions = all_sessions() settings = load_settings() @@ -1258,6 +1265,12 @@ def handle_post(handler, parsed) -> bool: except ValueError as e: return j(handler, {"error": str(e)}) + if parsed.path == "/api/btw": + return _handle_btw(handler, body) + + if parsed.path == "/api/background": + return _handle_background(handler, body) + if parsed.path == "/api/chat/start": return _handle_chat_start(handler, body) @@ -2457,6 +2470,133 @@ def _handle_sessions_cleanup(handler, body, zero_only=False): return j(handler, {"ok": True, "cleaned": cleaned}) +def _handle_btw(handler, body): + """POST /api/btw — ephemeral side question using session context. + + Creates a temporary hidden session, streams the answer via SSE, then + discards the session. The parent session is not modified. + """ + try: + require(body, "session_id") + require(body, "question") + except ValueError as e: + return bad(handler, str(e)) + try: + s = get_session(body["session_id"]) + except KeyError: + return bad(handler, "Session not found", 404) + question = str(body["question"]).strip() + if not question: + return bad(handler, "question is required") + # Duplicate-stream guard (same pattern as chat/start) + current_stream_id = getattr(s, "active_stream_id", None) + if current_stream_id: + with STREAMS_LOCK: + if current_stream_id in STREAMS: + return j(handler, {"error": "session already has an active stream"}, status=409) + s.active_stream_id = None + # Create ephemeral hidden session inheriting context + from api.models import new_session as _new_session + ephemeral = _new_session(workspace=s.workspace, model=s.model, profile=getattr(s, 'profile', None)) + # Copy conversation history for context (agent reads from messages) + ephemeral.messages = list(s.messages or []) + ephemeral.title = f"btw: {question[:60]}" + ephemeral.save() + stream_id = uuid.uuid4().hex + ephemeral.active_stream_id = stream_id + ephemeral.save() + q = queue.Queue() + with STREAMS_LOCK: + STREAMS[stream_id] = q + from api.background import track_btw + track_btw(body["session_id"], ephemeral.session_id, stream_id, question) + thr = threading.Thread( + target=_run_agent_streaming, + args=(ephemeral.session_id, question, s.model, s.workspace, stream_id, None), + kwargs={"ephemeral": True}, + daemon=True, + ) + thr.start() + return j(handler, {"stream_id": stream_id, "session_id": ephemeral.session_id, "parent_session_id": body["session_id"]}) + + +def _handle_background(handler, body): + """POST /api/background — run prompt in parallel background agent. + + Creates a hidden session, starts streaming in a daemon thread. + Frontend polls /api/background/status for completed results. + """ + try: + require(body, "session_id") + require(body, "prompt") + except ValueError as e: + return bad(handler, str(e)) + try: + s = get_session(body["session_id"]) + except KeyError: + return bad(handler, "Session not found", 404) + prompt = str(body["prompt"]).strip() + if not prompt: + return bad(handler, "prompt is required") + from api.models import new_session as _new_session + bg = _new_session(workspace=s.workspace, model=s.model, profile=getattr(s, 'profile', None)) + bg.title = f"bg: {prompt[:60]}" + bg.save() + stream_id = uuid.uuid4().hex + bg.active_stream_id = stream_id + bg.save() + q = queue.Queue() + with STREAMS_LOCK: + STREAMS[stream_id] = q + task_id = uuid.uuid4().hex[:8] + from api.background import track_background, complete_background + parent_sid = body["session_id"] + bg_sid = bg.session_id + track_background(parent_sid, bg_sid, stream_id, task_id, prompt) + + def _run_bg_and_notify(): + """Run the background agent, then mark the tracked task `done` with the + last assistant reply so `/api/background/status` can surface it. Without + this, `complete_background()` is never called and the result is lost — + `get_results()` would see a forever-`running` task and return nothing. + """ + try: + _run_agent_streaming(bg_sid, prompt, s.model, s.workspace, stream_id, None) + # Reload the bg session from disk and extract the final assistant reply. + try: + from api.models import Session as _Session + reloaded = _Session.load(bg_sid) + _answer = "" + for _m in reversed((reloaded.messages if reloaded else None) or []): + if not isinstance(_m, dict) or _m.get("role") != "assistant": + continue + if _m.get("_error"): + continue + _content = str(_m.get("content") or "").strip() + if _content: + _answer = _content + break + complete_background(parent_sid, task_id, _answer or "(no answer produced)") + except Exception: + complete_background(parent_sid, task_id, "(background task failed)") + # Best-effort cleanup of the hidden bg session file so it doesn't + # clutter the sidebar or SESSION_DIR. The index is pruned on the + # next rebuild via _index_entry_exists(). + try: + (SESSION_DIR / f"{bg_sid}.json").unlink(missing_ok=True) + except Exception: + pass + except Exception: + try: + complete_background(parent_sid, task_id, "(background task failed)") + except Exception: + pass + + thr = threading.Thread(target=_run_bg_and_notify, daemon=True) + thr.start() + return j(handler, {"task_id": task_id, "stream_id": stream_id, "session_id": bg.session_id}) + + def _handle_chat_start(handler, body): try: require(body, "session_id") diff --git a/api/streaming.py b/api/streaming.py index c5a50ff..801b09c 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -866,8 +866,12 @@ def _sse(handler, event, data): handler.wfile.flush() -def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, attachments=None): - """Run agent in background thread, writing SSE events to STREAMS[stream_id].""" +def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, attachments=None, *, ephemeral=False): + """Run agent in background thread, writing SSE events to STREAMS[stream_id]. + + When ephemeral=True, session mutations are skipped — used by /btw to get + a streaming answer without persisting to the parent session. + """ q = STREAMS.get(stream_id) if q is None: return @@ -1336,6 +1340,27 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta task_id=session_id, persist_user_message=msg_text, ) + # ── Ephemeral mode (/btw): deliver answer, skip persistence, cleanup ── + if ephemeral: + _answer = '' + for _m in reversed(result.get('messages') or []): + if isinstance(_m, dict) and _m.get('role') == 'assistant': + _answer = str(_m.get('content', '')) + break + put('done', { + 'session': {'session_id': session_id, 'messages': result.get('messages', [])}, + 'usage': {'input_tokens': 0, 'output_tokens': 0}, + 'ephemeral': True, + 'answer': _answer, + }) + if _checkpoint_stop is not None: + _checkpoint_stop.set() + try: + import pathlib + pathlib.Path(s.path).unlink(missing_ok=True) + except Exception: + pass + return # skip all normal persistence for ephemeral sessions if _checkpoint_stop is not None: _checkpoint_stop.set() if _ckpt_thread is not None: diff --git a/static/commands.js b/static/commands.js index 66d4db1..0e0f30e 100644 --- a/static/commands.js +++ b/static/commands.js @@ -20,6 +20,8 @@ const COMMANDS=[ {name:'title', desc:t('cmd_title'), fn:cmdTitle, arg:'[title]'}, {name:'retry', desc:t('cmd_retry'), fn:cmdRetry, noEcho:true}, {name:'undo', desc:t('cmd_undo'), fn:cmdUndo, noEcho:true}, + {name:'btw', desc:t('cmd_btw'), fn:cmdBtw, arg:'question', noEcho:true}, + {name:'background',desc:t('cmd_background'),fn:cmdBackground,arg:'prompt', noEcho:true}, {name:'status', desc:t('cmd_status'), fn:cmdStatus}, {name:'voice', desc:t('cmd_voice'), fn:cmdVoice, noEcho:true}, {name:'reasoning', desc:t('cmd_reasoning'), fn:cmdReasoning, arg:'show|hide|none|minimal|low|medium|high|xhigh', subArgs:['show','hide','none','minimal','low','medium','high','xhigh'], noEcho:true}, @@ -573,6 +575,36 @@ async function cmdUndo(){ showToast(`↩ ${t('undid_n_messages')} ${r.removed_count} ${t('undid_messages_suffix')}`); }catch(e){showToast(t('undo_failed')+e.message);} } +async function undoLastExchange(){await cmdUndo();} +async function cmdBtw(args){ + if(!S.session){showToast(t('no_active_session'));return;} + const question=(args||'').trim(); + if(!question){showToast(t('cmd_btw_usage'));return;} + showToast(t('btw_asking')); + const activeSid=S.session.session_id; + try{ + const r=await api('/api/btw',{method:'POST',body:JSON.stringify({session_id:activeSid,question})}); + if(r&&r.error){showToast(r.error);return;} + // Connect to the ephemeral SSE stream + const streamId=r.stream_id; + const parentSid=r.parent_session_id; + if(typeof attachBtwStream==='function') attachBtwStream(parentSid,streamId,question); + }catch(e){showToast(t('btw_failed')+e.message);} +} +async function cmdBackground(args){ + if(!S.session){showToast(t('no_active_session'));return;} + const prompt=(args||'').trim(); + if(!prompt){showToast(t('cmd_background_usage'));return;} + showToast(t('bg_running')); + const activeSid=S.session.session_id; + try{ + const r=await api('/api/background',{method:'POST',body:JSON.stringify({session_id:activeSid,prompt})}); + if(r&&r.error){showToast(r.error);return;} + // Show background badge and start polling + if(typeof showBackgroundBadge==='function') showBackgroundBadge(r.task_id); + if(typeof startBackgroundPolling==='function') startBackgroundPolling(activeSid,r.task_id,prompt); + }catch(e){showToast(t('bg_failed')+e.message);} +} async function cmdStatus(){ if(!S.session){showToast(t('no_active_session'));return;} try{ @@ -623,6 +655,7 @@ function cmdReasoning(args){ .then(function(st){ const eff=(st && st.reasoning_effort)||arg; showToast(BRAIN+' Reasoning effort set to '+eff+' (saved; applies to next turn)'); + if(typeof syncReasoningChip==='function') syncReasoningChip(); }) .catch(function(e){ showToast(BRAIN+' Failed to set effort: '+(e && e.message ? e.message : arg)); diff --git a/static/i18n.js b/static/i18n.js index 773ceff..feae393 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -102,6 +102,21 @@ const LOCALES = { cmd_title:'Get or set the session title', cmd_retry:'Resend the last message', cmd_undo:'Remove the last exchange', + cmd_btw:'Ask a side question (ephemeral)', + cmd_btw_usage:'/btw — ask a side question using session context', + cmd_background:'Run a prompt in background', + cmd_background_usage:'/background — run in parallel without blocking', + btw_asking:'Asking side question...', + btw_label:'Side question — not in history', + btw_done:'Side question answered', + btw_no_answer:'No answer received.', + btw_failed:'Side question failed: ', + bg_running:'Running in background...', + bg_complete:'Background task complete', + bg_label:'Background result:', + bg_no_answer:'(no answer)', + bg_failed:'Background task failed: ', + undo_exchange:'Undo last exchange', cmd_status:'Show session info', cmd_voice:'Toggle microphone input', stream_stopped:'Response stopped.', @@ -199,7 +214,7 @@ const LOCALES = { settings_label_language: 'Language', settings_label_token_usage: 'Show token usage', settings_label_sidebar_density: 'Sidebar density', - cmd_reasoning: 'Toggle thinking block visibility (show/hide) or set effort level', + cmd_reasoning: 'Toggle thinking visibility (show/hide), set effort level, or check current status', settings_label_cli_sessions: 'Show agent sessions', settings_label_sync_insights: 'Sync to insights', settings_label_check_updates: 'Check for updates', @@ -655,7 +670,7 @@ const LOCALES = { settings_label_language: 'Язык', settings_label_token_usage: 'Показывать использование токенов', settings_label_sidebar_density: 'Плотность боковой панели', - cmd_reasoning: 'Toggle thinking block visibility (show/hide) or set effort level', + cmd_reasoning: 'Toggle thinking visibility (show/hide), set effort level, or check current status', settings_label_cli_sessions: 'Показывать сеансы агента', settings_label_sync_insights: 'Синхронизировать с Insights', settings_label_check_updates: 'Проверять обновления', @@ -1139,7 +1154,7 @@ const LOCALES = { settings_label_language: 'Idioma', settings_label_token_usage: 'Mostrar uso de tokens', settings_label_sidebar_density: 'Densidad de la barra lateral', - cmd_reasoning: 'Toggle thinking block visibility (show/hide) or set effort level', + cmd_reasoning: 'Toggle thinking visibility (show/hide), set effort level, or check current status', settings_label_cli_sessions: 'Mostrar sesiones de CLI', settings_label_sync_insights: 'Sincronizar con insights', settings_label_check_updates: 'Buscar actualizaciones', @@ -1595,7 +1610,7 @@ const LOCALES = { settings_label_language: 'Sprache', settings_label_token_usage: 'Token-Verbrauch anzeigen', settings_label_sidebar_density: 'Seitenleistendichte', - cmd_reasoning: 'Toggle thinking block visibility (show/hide) or set effort level', + cmd_reasoning: 'Toggle thinking visibility (show/hide), set effort level, or check current status', settings_label_cli_sessions: 'Agent-Sitzungen anzeigen', settings_label_sync_insights: 'Mit Insights synchronisieren', settings_label_check_updates: 'Nach Updates suchen', @@ -1852,7 +1867,7 @@ const LOCALES = { settings_label_language: '\u8bed\u8a00', settings_label_token_usage: '\u663e\u793a token \u7528\u91cf', settings_label_sidebar_density: '侧边栏密度', - cmd_reasoning: 'Toggle thinking block visibility (show/hide) or set effort level', + cmd_reasoning: 'Toggle thinking visibility (show/hide), set effort level, or check current status', settings_label_cli_sessions: '\u663e\u793a CLI \u4f1a\u8bdd', settings_label_sync_insights: '\u540c\u6b65\u5230 insights', settings_label_check_updates: '\u68c0\u67e5\u66f4\u65b0', @@ -2292,7 +2307,7 @@ const LOCALES = { settings_label_language: '\u8a9d\u8a00', settings_label_token_usage: '\u986f\u793a token \u7528\u91cf', settings_label_sidebar_density: '側邊欄密度', - cmd_reasoning: 'Toggle thinking block visibility (show/hide) or set effort level', + cmd_reasoning: 'Toggle thinking visibility (show/hide), set effort level, or check current status', settings_label_cli_sessions: '\u986f\u793a CLI \u6703\u8a71', settings_label_sync_insights: '\u540c\u6b65\u5230 insights', settings_label_check_updates: '\u6aa2\u67e5\u66f4\u65b0', diff --git a/static/index.html b/static/index.html index 0dd1600..eda024e 100644 --- a/static/index.html +++ b/static/index.html @@ -346,6 +346,21 @@ +
+ diff --git a/static/messages.js b/static/messages.js index 470ef7b..a56bc4f 100644 --- a/static/messages.js +++ b/static/messages.js @@ -1323,4 +1323,111 @@ function sendBrowserNotification(title,body){ } } +// ── /btw ephemeral stream ──────────────────────────────────────────────────── +// Connects to the ephemeral SSE stream from /api/btw and renders the answer +// in a visually distinct bubble that is NOT persisted to session history. + +function attachBtwStream(parentSid, streamId, question){ + if(!parentSid||!streamId) return; + const src=new EventSource('/api/stream?stream_id='+encodeURIComponent(streamId)); + let answer=''; + let btwRow=null; + function _ensureBtwRow(){ + if(btwRow&&btwRow.isConnected) return; + const inner=$('msgInner'); + if(!inner) return; + btwRow=document.createElement('div'); + btwRow.className='msg-row msg-row-btw'; + btwRow.dataset.role='assistant'; + btwRow.dataset.btw='1'; + const labelEl=document.createElement('div'); + labelEl.className='msg-btw-label'; + labelEl.textContent=t('btw_label'); + const qEl=document.createElement('div'); + qEl.className='msg-body'; + qEl.textContent=question; + const ansEl=document.createElement('div'); + ansEl.className='msg-body msg-btw-answer'; + ansEl.textContent='...'; + btwRow.appendChild(labelEl); + btwRow.appendChild(qEl); + btwRow.appendChild(ansEl); + inner.appendChild(btwRow); + btwRow.scrollIntoView({behavior:'smooth',block:'end'}); + } + src.addEventListener('token',e=>{ + try{answer+=JSON.parse(e.data).text||'';}catch(_){} + _ensureBtwRow(); + const ansEl=btwRow&&btwRow.querySelector('.msg-btw-answer'); + if(ansEl) ansEl.innerHTML=renderMd(answer); + }); + src.addEventListener('done',e=>{ + src.close(); + try{ + const d=JSON.parse(e.data); + if(d.answer&&!answer) answer=d.answer; + }catch(_){} + if(btwRow&&btwRow.isConnected){ + const ansEl=btwRow.querySelector('.msg-btw-answer'); + if(ansEl) ansEl.innerHTML=renderMd(answer||t('btw_no_answer')); + } + showToast(t('btw_done')); + }); + src.addEventListener('apperror',e=>{ + src.close(); + try{ + const d=JSON.parse(e.data); + showToast(t('btw_failed')+(d.message||'')); + }catch(_){showToast(t('btw_failed'));} + if(btwRow&&btwRow.isConnected) btwRow.remove(); + }); + src.addEventListener('stream_end',()=>{src.close();}); + src.onerror=()=>{src.close();if(btwRow&&btwRow.isConnected) btwRow.remove();}; +} + +// ── /background task tracking ──────────────────────────────────────────────── + +let _bgPollTimers={}; +let _bgActiveTasks=new Set(); + +function showBackgroundBadge(taskId){ + _bgActiveTasks.add(taskId); + const badge=$('bgBadge'); + if(badge){ + badge.textContent=String(_bgActiveTasks.size); + badge.style.display=_bgActiveTasks.size?'':'none'; + } +} +function hideBackgroundBadge(taskId){ + _bgActiveTasks.delete(taskId); + const badge=$('bgBadge'); + if(badge){ + badge.textContent=String(_bgActiveTasks.size); + badge.style.display=_bgActiveTasks.size?'':'none'; + } +} +function startBackgroundPolling(parentSid, taskId, prompt){ + if(_bgPollTimers[taskId]) return; + async function _poll(){ + try{ + const r=await api('/api/background/status?session_id='+encodeURIComponent(parentSid)); + if(r&&r.results){ + for(const res of r.results){ + if(res.task_id===taskId){ + hideBackgroundBadge(taskId); + delete _bgPollTimers[taskId]; + const msg={role:'assistant',content:`**${t('bg_label')}** ${prompt.slice(0,80)}\n\n${res.answer||t('bg_no_answer')}`,'_background':true,_ts:Date.now()/1000}; + S.messages.push(msg); + renderMessages(); + showToast(t('bg_complete')); + return; + } + } + } + }catch(_){} + _bgPollTimers[taskId]=setTimeout(_poll,3000); + } + _poll(); +} + // ── Panel navigation (Chat / Tasks / Skills / Memory) ── diff --git a/static/style.css b/static/style.css index e37c2be..0417daf 100644 --- a/static/style.css +++ b/static/style.css @@ -593,6 +593,17 @@ .composer-workspace-chip.active{color:var(--text);background:var(--accent-bg);border-color:var(--accent-bg);} .composer-workspace-icon,.composer-workspace-chevron{display:inline-flex;align-items:center;justify-content:center;flex-shrink:0;line-height:1;} .composer-workspace-label{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;} + .composer-reasoning-wrap{position:relative;flex:0 1 auto;min-width:0;} + .composer-reasoning-chip{display:inline-flex;align-items:center;gap:8px;max-width:180px;padding:8px 10px 8px 12px;border-radius:999px;border:1px solid transparent;background-color:transparent;color:var(--muted);font-weight:500;cursor:pointer;transition:color .15s,background-color .15s,border-color .15s;} + .composer-reasoning-chip:hover{color:var(--text);background-color:var(--hover-bg);} + .composer-reasoning-chip.active{color:var(--text);background:var(--accent-bg);border-color:var(--accent-bg);} + .composer-reasoning-icon,.composer-reasoning-chevron{display:inline-flex;align-items:center;justify-content:center;flex-shrink:0;line-height:1;} + .composer-reasoning-label{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;} + .composer-reasoning-dropdown{display:none;position:absolute;bottom:calc(100% + 6px);left:0;background:var(--surface);border:1px solid var(--border);border-radius:10px;box-shadow:0 8px 24px rgba(0,0,0,.15);min-width:140px;z-index:100;padding:4px;animation:dropdown-in .12s ease-out;} + .composer-reasoning-dropdown.open{display:block;} + .reasoning-option{padding:8px 14px;border-radius:6px;cursor:pointer;font-size:13px;color:var(--text);white-space:nowrap;transition:background-color .1s;} + .reasoning-option:hover{background:var(--hover-bg);} + .reasoning-option.selected{color:var(--accent);font-weight:600;} .composer-model-wrap{position:relative;flex:0 1 auto;min-width:0;} .composer-model-chip{display:inline-flex;align-items:center;gap:8px;max-width:220px;padding:8px 10px 8px 12px;border-radius:999px;border:1px solid transparent;background-color:transparent;color:var(--muted);font-weight:500;cursor:pointer;transition:color .15s,background-color .15s,border-color .15s;} .composer-model-chip:hover{color:var(--text);background-color:var(--hover-bg);} @@ -769,12 +780,15 @@ .composer-profile-label, .composer-workspace-label, .composer-model-label, + .composer-reasoning-label, .composer-profile-chevron, .composer-workspace-chevron, - .composer-model-chevron{display:none;} + .composer-model-chevron, + .composer-reasoning-chevron{display:none;} .composer-profile-chip, .composer-workspace-chip, - .composer-model-chip{max-width:44px;min-width:44px;min-height:44px;padding:6px;justify-content:center;gap:0;font-size:11px;} + .composer-model-chip, + .composer-reasoning-chip{max-width:44px;min-width:44px;min-height:44px;padding:6px;justify-content:center;gap:0;font-size:11px;} .composer-divider{display:none;} .composer-status{max-width:96px;font-size:10px;} .send-btn{width:32px;height:32px;} @@ -1909,3 +1923,40 @@ body.resizing{user-select:none;cursor:col-resize;} .assistant-turn .msg-foot{opacity:1;} .msg-actions{opacity:1;} } + +/* ── /btw ephemeral side-question bubble ── */ +.msg-row-btw { + border-left: 3px solid var(--accent-bg-strong); + border-radius: 8px; + background: var(--accent-bg); + margin: 6px 0 6px var(--msg-rail); + padding: 8px 12px; +} +.msg-row-btw .msg-body { font-size: 14px; line-height: 1.75; } +.msg-btw-label { + font-size: 11px; + font-style: italic; + color: var(--muted); + margin-bottom: 4px; + opacity: .8; +} +.msg-btw-answer { + margin-top: 6px; + border-top: 1px solid var(--accent-bg-strong); + padding-top: 6px; +} + +/* ── /background badge in composer footer ── */ +.bg-badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 18px; + height: 18px; + padding: 0 5px; + border-radius: 9px; + background: var(--accent-bg-strong); + color: var(--accent-text); + font-size: 10px; + font-weight: 600; +} diff --git a/static/ui.js b/static/ui.js index b630e90..58a1ea5 100644 --- a/static/ui.js +++ b/static/ui.js @@ -386,6 +386,7 @@ function toggleModelDropdown(){ if(open){closeModelDropdown(); return;} if(typeof closeProfileDropdown==='function') closeProfileDropdown(); if(typeof closeWsDropdown==='function') closeWsDropdown(); + if(typeof closeReasoningDropdown==='function') closeReasoningDropdown(); renderModelDropdown(); dd.classList.add('open'); _positionModelDropdown(); @@ -407,6 +408,77 @@ window.addEventListener('resize',()=>{ if(dd&&dd.classList.contains('open')) _positionModelDropdown(); }); +// ── Reasoning effort chip ──────────────────────────────────────────────────── +let _currentReasoningEffort=null; + +function _applyReasoningChip(eff){ + _currentReasoningEffort=eff; + const wrap=$('composerReasoningWrap'); + const label=$('composerReasoningLabel'); + if(!wrap||!label) return; + if(!eff||eff==='none'){wrap.style.display='none';return;} + wrap.style.display=''; + label.textContent='🧠 '+eff; + _highlightReasoningOption(eff); +} + +function fetchReasoningChip(){ + api('/api/reasoning').then(function(st){ + _applyReasoningChip((st&&st.reasoning_effort)||''); + }).catch(function(){_applyReasoningChip('');}); +} + +function syncReasoningChip(){ + if(_currentReasoningEffort===null){fetchReasoningChip();return;} + _applyReasoningChip(_currentReasoningEffort); +} + +function _highlightReasoningOption(effort){ + const dd=$('composerReasoningDropdown'); + if(!dd) return; + dd.querySelectorAll('.reasoning-option').forEach(function(opt){ + opt.classList.toggle('selected',opt.dataset.effort===effort); + }); +} + +function toggleReasoningDropdown(){ + const dd=$('composerReasoningDropdown'); + const chip=$('composerReasoningChip'); + if(!dd||!chip) return; + const open=dd.classList.contains('open'); + if(open){closeReasoningDropdown();return;} + if(typeof closeProfileDropdown==='function') closeProfileDropdown(); + if(typeof closeWsDropdown==='function') closeWsDropdown(); + closeModelDropdown(); + _highlightReasoningOption(_currentReasoningEffort); + dd.classList.add('open'); + chip.classList.add('active'); +} + +function closeReasoningDropdown(){ + const dd=$('composerReasoningDropdown'); + const chip=$('composerReasoningChip'); + if(dd) dd.classList.remove('open'); + if(chip) chip.classList.remove('active'); +} + +document.addEventListener('click',function(e){ + if(!e.target.closest('#composerReasoningChip')&&!e.target.closest('#composerReasoningDropdown')) closeReasoningDropdown(); + if(e.target.closest('.reasoning-option')){ + const opt=e.target.closest('.reasoning-option'); + const effort=opt&&opt.dataset.effort; + if(effort){ + api('/api/reasoning',{method:'POST',body:JSON.stringify({effort:effort})}) + .then(function(st){ + _applyReasoningChip((st&&st.reasoning_effort)||effort); + showToast('🧠 Reasoning effort set to '+((st&&st.reasoning_effort)||effort)); + }) + .catch(function(){showToast('🧠 Failed to set effort');}); + closeReasoningDropdown(); + } + } +}); + // ── Scroll pinning ────────────────────────────────────────────────────────── // When streaming, auto-scroll only if the user hasn't manually scrolled up. // Once the user scrolls back to within 150px of the bottom, re-pin. @@ -1330,6 +1402,7 @@ function syncTopbar(){ } } if(typeof syncModelChip==='function') syncModelChip(); + if(typeof syncReasoningChip==='function') syncReasoningChip(); // Show Clear button only when session has messages const clearBtn=$('btnClearConv'); if(clearBtn) clearBtn.style.display=(S.messages&&S.messages.filter(msg=>msg.role!=='tool').length>0)?'':'none'; @@ -1693,6 +1766,7 @@ function renderMessages(){ const bodyHtml = isUser ? esc(String(content)).replace(/\n/g,'
') : renderMd(_stripXmlToolCallsDisplay(String(content))); const isEditableUser=isUser&&rawIdx===lastUserRawIdx; const editBtn = isEditableUser ? `` : ''; + const undoBtn = isLastAssistant ? `` : ''; const retryBtn = isLastAssistant ? `` : ''; const copyBtn = ``; const tsVal=m._ts||m.timestamp; From 63b02076042815bde16a1f366160299c7dfd30b9 Mon Sep 17 00:00:00 2001 From: Nathan Esquenazi Date: Thu, 23 Apr 2026 18:20:48 -0700 Subject: [PATCH 2/2] fix(background): wire completion hook + keep running tasks in tracker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The /background feature was fundamentally non-functional as shipped — two coupled bugs kept results from ever reaching the user: 1. complete_background() was defined but NEVER called. The _handle_background thread ran _run_agent_streaming and then exited; no hook signalled the task tracker that the work was done. Every background task stayed in status="running" forever and get_results() (which filters to done-only) always returned []. 2. get_results() called _BACKGROUND_TASKS.pop(parent_sid, []) which removed the ENTIRE list — including tasks still in flight. Even if bug #1 were fixed, the first frontend poll during a long-running task would drop the task from the tracker, and complete_background()'s loop would iterate over an empty list when the worker eventually finished — the result would still be lost. Fix: - api/background.py::get_results now retains running tasks in the dict; only done ones are popped and returned. - api/routes.py::_handle_background wraps _run_agent_streaming in an inline worker (_run_bg_and_notify) that, after streaming completes, reloads the hidden bg session, extracts the last non-error assistant message, and calls complete_background(parent_sid, task_id, answer). Worker also best-effort unlinks the hidden bg session file so SESSION_DIR doesn't accumulate debris. - Exception safety: any failure in _run_agent_streaming or the post-processing path still calls complete_background with a fallback sentinel so the frontend's polling loop doesn't hang forever. Added 5 regression tests in tests/test_background_tasks.py: - running tasks survive get_results polls - done tasks are returned and removed - poll → complete → poll round-trip surfaces the answer (this is the original bug's reproduction path) - empty parent is cleaned up - static check: _handle_background's worker calls complete_background and uses Session.load to extract the answer Full suite: 2023 passed, 0 failed. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 1 + tests/test_background_tasks.py | 148 +++++++++++++++++++++++++++++++++ 2 files changed, 149 insertions(+) create mode 100644 tests/test_background_tasks.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f41e92..72f868b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,7 @@ - **Reasoning effort chip in composer** — visual chip to set reasoning effort level from the composer footer without typing a command. (`static/ui.js`, `static/index.html`, `static/style.css`) ### Fixed +- **Background task completion hook wired** — `complete_background()` was never called after a background agent finished, so tasks stayed in `status="running"` forever and polling always returned `[]`. Fixed by wrapping `_run_agent_streaming` in `_run_bg_and_notify` which extracts the last assistant message and signals the tracker. Also fixed `get_results()` to retain in-flight tasks during polls so concurrent tasks are not dropped. (`api/background.py`, `api/routes.py`, `tests/test_background_tasks.py`) - **Ephemeral sessions correctly skip persistence** — added `return` after the ephemeral `done` event in `_run_agent_streaming()`, preventing ephemeral session state from being written to disk after stream completion. (`api/streaming.py`) Co-authored by @bergeouss. diff --git a/tests/test_background_tasks.py b/tests/test_background_tasks.py new file mode 100644 index 0000000..6d8a5ef --- /dev/null +++ b/tests/test_background_tasks.py @@ -0,0 +1,148 @@ +"""Regression tests for the /background task tracker. + +Covers two bugs caught in review of PR #932: + +1. `get_results()` was calling `_BACKGROUND_TASKS.pop(parent_sid, [])`, which + removed EVERY task (including still-running ones) on the first poll. Once + popped, `complete_background()` could no longer find the task to mark done, + so the final answer was silently lost. + +2. The `_handle_background` worker thread called `_run_agent_streaming` but + never invoked `complete_background()` after it returned. With no completion + hook, every background task stayed in `status="running"` forever — + `get_results()` filtered them out of its "done" list, and the user never + saw the result. + +These two bugs together made the `/background` command completely +non-functional as originally shipped. The fix in api/background.py + +api/routes.py wires the completion hook and keeps running tasks in the +tracker until they resolve. +""" +from __future__ import annotations + +import os +import pathlib +import sys +import time +import unittest +from unittest.mock import patch + + +# Ensure the repo root is importable without relying on CWD. +REPO_ROOT = pathlib.Path(__file__).resolve().parent.parent +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + + +class TestGetResultsKeepsRunningTasks(unittest.TestCase): + """get_results() MUST NOT drop still-running tasks from _BACKGROUND_TASKS.""" + + def setUp(self): + import api.background as bg + bg._BACKGROUND_TASKS.clear() + self.bg = bg + + def test_running_tasks_survive_get_results_call(self): + """A running task must remain in the tracker so complete_background() + can still find it after the first poll returns.""" + parent = "parent-session-1" + self.bg.track_background( + parent_sid=parent, bg_sid="bg-a", stream_id="s-a", + task_id="task-a", prompt="long task", + ) + + # First poll: task is still running, no done results to return + results = self.bg.get_results(parent) + self.assertEqual(results, [], "no done tasks yet — nothing to return") + + # The running task MUST still be tracked — otherwise the worker + # thread's complete_background call cannot find it. + remaining = self.bg.get_background_tasks(parent) + self.assertEqual(len(remaining), 1, ( + "get_results dropped the still-running task — subsequent " + "complete_background() calls will silently no-op and the " + "result will be lost forever" + )) + self.assertEqual(remaining[0]["status"], "running") + self.assertEqual(remaining[0]["task_id"], "task-a") + + def test_done_tasks_are_returned_and_removed(self): + """Done tasks are returned and popped; running tasks stay.""" + parent = "parent-session-2" + self.bg.track_background(parent, "bg-done", "s-d", "task-done", "p1") + self.bg.track_background(parent, "bg-run", "s-r", "task-run", "p2") + self.bg.complete_background(parent, "task-done", "42") + + results = self.bg.get_results(parent) + self.assertEqual(len(results), 1) + self.assertEqual(results[0]["task_id"], "task-done") + self.assertEqual(results[0]["answer"], "42") + + # Done one is gone; running one is still tracked + remaining = self.bg.get_background_tasks(parent) + self.assertEqual(len(remaining), 1) + self.assertEqual(remaining[0]["task_id"], "task-run") + self.assertEqual(remaining[0]["status"], "running") + + def test_complete_after_poll_still_reaches_tracker(self): + """Regression for the original bug: poll → complete → poll must surface + the result. Before the fix, the first poll popped the running task and + complete_background()'s loop iterated over an empty list.""" + parent = "parent-session-3" + self.bg.track_background(parent, "bg-x", "s-x", "task-x", "slow task") + + # Frontend polls before the task finishes + first = self.bg.get_results(parent) + self.assertEqual(first, []) + + # Worker thread finishes and calls complete_background + self.bg.complete_background(parent, "task-x", "answer!") + + # Next poll must surface the answer + second = self.bg.get_results(parent) + self.assertEqual(len(second), 1) + self.assertEqual(second[0]["task_id"], "task-x") + self.assertEqual(second[0]["answer"], "answer!") + + def test_empty_parent_is_cleaned_up(self): + """When all tasks are done and returned, the parent key is removed from the dict.""" + parent = "parent-session-4" + self.bg.track_background(parent, "bg-1", "s-1", "task-1", "p") + self.bg.complete_background(parent, "task-1", "ok") + self.bg.get_results(parent) + self.assertNotIn(parent, self.bg._BACKGROUND_TASKS) + + +class TestBackgroundCompletionHookWiring(unittest.TestCase): + """Static check: the _handle_background worker thread must call + complete_background() after _run_agent_streaming returns. Without this, + running tasks stay forever-running and the user never sees the result. + """ + + def test_run_bg_and_notify_calls_complete_background(self): + """_handle_background must wrap _run_agent_streaming in a function + that subsequently invokes complete_background(parent_sid, task_id, answer).""" + routes_src = (REPO_ROOT / "api" / "routes.py").read_text(encoding="utf-8") + # Locate the _handle_background function + idx = routes_src.find("def _handle_background(") + self.assertGreater(idx, -1, "_handle_background() not found in routes.py") + # Take a generous window around the function body + end = routes_src.find("\ndef ", idx + 1) + body = routes_src[idx:end if end > 0 else idx + 3000] + + self.assertIn("complete_background", body, ( + "_handle_background worker must call complete_background() after " + "_run_agent_streaming returns — otherwise the tracker never " + "transitions the task to status='done' and /api/background/status " + "returns nothing forever. See api/background.py:complete_background." + )) + # Must extract the last assistant message content from the bg session + self.assertIn("_run_agent_streaming", body) + self.assertIn("Session.load", body, ( + "_run_bg_and_notify must reload the bg session to extract the " + "final assistant reply so complete_background gets an actual answer" + )) + + +if __name__ == "__main__": + unittest.main()