"""Tests for real /steer functionality (follow-up to PR #1062). Covers the new POST /api/chat/steer endpoint which mirrors the CLI's /steer command (cli.py:6140-6155): the endpoint looks up the cached AIAgent for the session, calls agent.steer(text), and the agent's run loop appends the steer text to the next tool-result message — no interruption. Falls back to {"accepted": false, "fallback": ""} when the agent isn't running, isn't cached, or doesn't support steer (older agent versions). 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 after run_conversation returns and a `pending_steer_leftover` SSE event is emitted so the frontend can queue the leftover text as a next-turn message. """ import sys import os import unittest from pathlib import Path from unittest.mock import MagicMock, patch import pytest from tests.helpers import source_between as _source_between sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) @pytest.fixture(autouse=True) def _restore_auth_sessions(): """Snapshot and restore api.auth._sessions — see test_1058 for the rationale.""" import api.auth as _auth snapshot = dict(_auth._sessions) yield _auth._sessions.clear() _auth._sessions.update(snapshot) @pytest.fixture def _clear_caches(): """Snapshot SESSION_AGENT_CACHE and STREAMS so tests don't bleed.""" from api.config import ( ACTIVE_RUNS, ACTIVE_RUNS_LOCK, SESSION_AGENT_CACHE, SESSION_AGENT_CACHE_LOCK, STREAMS, STREAMS_LOCK, ) with SESSION_AGENT_CACHE_LOCK: cache_snap = dict(SESSION_AGENT_CACHE) SESSION_AGENT_CACHE.clear() with STREAMS_LOCK: streams_snap = dict(STREAMS) STREAMS.clear() with ACTIVE_RUNS_LOCK: active_runs_snap = dict(ACTIVE_RUNS) ACTIVE_RUNS.clear() yield with SESSION_AGENT_CACHE_LOCK: SESSION_AGENT_CACHE.clear() SESSION_AGENT_CACHE.update(cache_snap) with STREAMS_LOCK: STREAMS.clear() STREAMS.update(streams_snap) with ACTIVE_RUNS_LOCK: ACTIVE_RUNS.clear() ACTIVE_RUNS.update(active_runs_snap) def _make_handler(): """Minimal handler stub matching the methods api.helpers.j() touches.""" h = MagicMock() h.wfile = MagicMock() h.headers = MagicMock() h.headers.get = MagicMock(return_value="") return h def _captured_response(handler): """Pull the JSON body that j() wrote to handler.wfile.""" import json as _json # j() calls handler.wfile.write(body) write_calls = handler.wfile.write.call_args_list assert write_calls, "no body was written to handler.wfile" body = write_calls[-1][0][0] return _json.loads(body.decode("utf-8")) def _captured_status(handler): """Pull the HTTP status passed to handler.send_response().""" calls = handler.send_response.call_args_list assert calls, "no status was sent" return calls[-1][0][0] # ── Backend: the /api/chat/steer endpoint ───────────────────────────────── class TestHandleChatSteerHappyPath: """Endpoint accepts text and calls agent.steer() when all gates pass.""" def test_accepts_when_agent_cached_and_running(self, _clear_caches, monkeypatch): from api.streaming import _handle_chat_steer from api import config from api.config import ( ACTIVE_RUNS, ACTIVE_RUNS_LOCK, SESSION_AGENT_CACHE, SESSION_AGENT_CACHE_LOCK, STREAMS, STREAMS_LOCK, ) sid, stream_id = "sid_happy", "stream_happy" agent = MagicMock() agent.steer = MagicMock(return_value=True) with SESSION_AGENT_CACHE_LOCK: SESSION_AGENT_CACHE[sid] = (agent, "sig") with STREAMS_LOCK: import queue as _q STREAMS[stream_id] = _q.Queue() # Cache-only Steer requires positive ownership: stream owner AND # active-run session must both equal the requesting session. monkeypatch.setattr(config, "STREAM_SESSION_OWNERS", {stream_id: sid}) with ACTIVE_RUNS_LOCK: ACTIVE_RUNS[stream_id] = {"session_id": sid, "backend": "legacy", "phase": "running"} sess = MagicMock() sess.active_stream_id = stream_id with patch("api.streaming.get_session", return_value=sess): handler = _make_handler() _handle_chat_steer(handler, {"session_id": sid, "text": "Use Python instead"}) agent.steer.assert_called_once_with("Use Python instead") body = _captured_response(handler) assert body == {"accepted": True, "fallback": None, "stream_id": stream_id} class TestHandleChatSteerFallbacks: """Each gate that fails returns a structured fallback the frontend can branch on.""" def test_no_cached_agent(self, _clear_caches): from api.streaming import _handle_chat_steer handler = _make_handler() _handle_chat_steer(handler, {"session_id": "sid_x", "text": "hint"}) body = _captured_response(handler) assert body["accepted"] is False assert body["fallback"] == "no_cached_agent" def test_gateway_owned_stream_without_cached_agent_queues_fallback(self, _clear_caches, monkeypatch): from api.streaming import _handle_chat_steer from api import config from api.config import ACTIVE_RUNS, ACTIVE_RUNS_LOCK, STREAMS, STREAMS_LOCK import queue as _q sid, stream_id = "sid_gateway", "stream_gateway" monkeypatch.setattr(config, "STREAM_SESSION_OWNERS", {stream_id: sid}) with STREAMS_LOCK: STREAMS[stream_id] = _q.Queue() with ACTIVE_RUNS_LOCK: ACTIVE_RUNS[stream_id] = {"session_id": sid, "backend": "gateway"} sess = MagicMock() sess.active_stream_id = stream_id with patch("api.streaming.get_session", return_value=sess): handler = _make_handler() _handle_chat_steer(handler, {"session_id": sid, "text": "preserve this"}) body = _captured_response(handler) assert body == { "accepted": False, "fallback": "gateway_steer_queued", "stream_id": stream_id, } def test_agent_lacks_steer_method(self, _clear_caches): from api.streaming import _handle_chat_steer from api.config import SESSION_AGENT_CACHE, SESSION_AGENT_CACHE_LOCK sid = "sid_old" # Older agent without steer() — use spec to suppress MagicMock auto-create agent = MagicMock(spec=["interrupt", "run_conversation"]) with SESSION_AGENT_CACHE_LOCK: SESSION_AGENT_CACHE[sid] = (agent, "sig") handler = _make_handler() _handle_chat_steer(handler, {"session_id": sid, "text": "hint"}) body = _captured_response(handler) assert body["accepted"] is False assert body["fallback"] == "agent_lacks_steer" def test_session_not_found(self, _clear_caches): from api.streaming import _handle_chat_steer from api.config import SESSION_AGENT_CACHE, SESSION_AGENT_CACHE_LOCK sid = "sid_missing" agent = MagicMock() agent.steer = MagicMock(return_value=True) with SESSION_AGENT_CACHE_LOCK: SESSION_AGENT_CACHE[sid] = (agent, "sig") with patch("api.streaming.get_session", side_effect=KeyError(sid)): handler = _make_handler() _handle_chat_steer(handler, {"session_id": sid, "text": "hint"}) body = _captured_response(handler) assert body["accepted"] is False assert body["fallback"] == "session_not_found" agent.steer.assert_not_called() # never reached the steer call def test_session_not_running(self, _clear_caches): from api.streaming import _handle_chat_steer from api.config import SESSION_AGENT_CACHE, SESSION_AGENT_CACHE_LOCK sid = "sid_idle" agent = MagicMock() agent.steer = MagicMock(return_value=True) with SESSION_AGENT_CACHE_LOCK: SESSION_AGENT_CACHE[sid] = (agent, "sig") sess = MagicMock() sess.active_stream_id = None # idle session with patch("api.streaming.get_session", return_value=sess): handler = _make_handler() _handle_chat_steer(handler, {"session_id": sid, "text": "hint"}) body = _captured_response(handler) assert body["accepted"] is False assert body["fallback"] == "not_running" agent.steer.assert_not_called() def test_stream_dead(self, _clear_caches): """Session has active_stream_id but the stream is gone from STREAMS (e.g. crashed).""" from api.streaming import _handle_chat_steer from api.config import SESSION_AGENT_CACHE, SESSION_AGENT_CACHE_LOCK sid = "sid_zombie" agent = MagicMock() agent.steer = MagicMock(return_value=True) with SESSION_AGENT_CACHE_LOCK: SESSION_AGENT_CACHE[sid] = (agent, "sig") sess = MagicMock() sess.active_stream_id = "stream_zombie" with patch("api.streaming.get_session", return_value=sess): handler = _make_handler() _handle_chat_steer(handler, {"session_id": sid, "text": "hint"}) body = _captured_response(handler) assert body["accepted"] is False assert body["fallback"] == "stream_dead" agent.steer.assert_not_called() def test_steer_raises(self, _clear_caches, monkeypatch): """If agent.steer() raises, return steer_error rather than 500.""" from api.streaming import _handle_chat_steer from api import config from api.config import ( ACTIVE_RUNS, ACTIVE_RUNS_LOCK, SESSION_AGENT_CACHE, SESSION_AGENT_CACHE_LOCK, STREAMS, STREAMS_LOCK, ) sid, stream_id = "sid_throws", "stream_throws" agent = MagicMock() agent.steer = MagicMock(side_effect=RuntimeError("boom")) with SESSION_AGENT_CACHE_LOCK: SESSION_AGENT_CACHE[sid] = (agent, "sig") with STREAMS_LOCK: import queue as _q STREAMS[stream_id] = _q.Queue() monkeypatch.setattr(config, "STREAM_SESSION_OWNERS", {stream_id: sid}) with ACTIVE_RUNS_LOCK: ACTIVE_RUNS[stream_id] = {"session_id": sid, "backend": "legacy", "phase": "running"} sess = MagicMock() sess.active_stream_id = stream_id with patch("api.streaming.get_session", return_value=sess): handler = _make_handler() _handle_chat_steer(handler, {"session_id": sid, "text": "hint"}) body = _captured_response(handler) assert body["accepted"] is False assert body["fallback"] == "steer_error" class TestHandleChatSteerInputValidation: """Bad input → 400 Bad Request, not silent acceptance.""" def test_missing_session_id(self, _clear_caches): from api.streaming import _handle_chat_steer handler = _make_handler() _handle_chat_steer(handler, {"text": "hint"}) assert _captured_status(handler) == 400 def test_missing_text(self, _clear_caches): from api.streaming import _handle_chat_steer handler = _make_handler() _handle_chat_steer(handler, {"session_id": "sid"}) assert _captured_status(handler) == 400 def test_empty_text_after_strip(self, _clear_caches): from api.streaming import _handle_chat_steer handler = _make_handler() _handle_chat_steer(handler, {"session_id": "sid", "text": " \n\t "}) assert _captured_status(handler) == 400 # ── Routing ─────────────────────────────────────────────────────────────── class TestRouting: """The POST handler must dispatch /api/chat/steer to _handle_chat_steer.""" def test_route_registered(self): src = (Path(__file__).parent.parent / "api" / "routes.py").read_text(encoding="utf-8") assert '/api/chat/steer' in src assert '_handle_chat_steer' in src # ── Frontend: cmdSteer + busy-mode steer use the new endpoint ──────────── class TestFrontendWiring: """The slash command and busy-mode steer paths must call /api/chat/steer.""" @classmethod def setup_class(cls): cls.cmds = (Path(__file__).parent.parent / "static" / "commands.js").read_text(encoding="utf-8") cls.msgs = (Path(__file__).parent.parent / "static" / "messages.js").read_text(encoding="utf-8") cls.i18n = (Path(__file__).parent.parent / "static" / "i18n.js").read_text(encoding="utf-8") def test_cmd_steer_calls_endpoint(self): idx = self.cmds.find("async function cmdSteer(") assert idx >= 0 body = self.cmds[idx:idx + 600] # Should call _trySteer (which calls the endpoint), not directly cancelStream assert "_trySteer" in body, "cmdSteer must delegate to _trySteer" def test_try_steer_calls_endpoint(self): idx = self.cmds.find("async function _trySteer(") assert idx >= 0 body = _source_between(self.cmds, "async function _trySteer(", "\nasync function cmdTitle") 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_without_cancelling(self): idx = self.cmds.find("async function _trySteer(") body = _source_between(self.cmds, "async function _trySteer(", "\nasync function cmdTitle") # Must check result.accepted and keep generic failures from cancelling. assert "result&&result.accepted" in body or "result.accepted" in body assert "result&&result.fallback==='gateway_steer_queued'" in body assert "queueSessionMessage(ownerSid" 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 idx = self.msgs.find("defaultMessageMode==='steer'") assert idx >= 0 block = self.msgs[idx:idx + 800] assert "_trySteer" in block, "send()'s steer branch must delegate to _trySteer" def test_try_steer_uploads_pending_files_without_clearing_until_accepted(self): cmds = self.cmds assert "function _steerUploadedAttachmentPaths" in cmds assert "async function _steerTextWithPendingFiles" in cmds assert "function _steerOwnerIsCurrent" in cmds assert "uploadPendingFiles({clearPending:false,sessionId:ownerSid,files:pendingFiles})" in cmds, ( "steer must upload staged files for the captured owner session without clearing chips before endpoint acceptance" ) idx = cmds.find("async function _trySteer(") assert idx >= 0 body = _source_between(cmds, "async function _trySteer(", "\nasync function cmdTitle") assert "const ownerSid=(typeof S!=='undefined'&&S.session&&S.session.session_id)||null;" in body assert "const pendingFilesSnapshot=typeof S!=='undefined'&&Array.isArray(S.pendingFiles)?[...S.pendingFiles]:[];" in body assert "steerText=await _steerTextWithPendingFiles(originalMsg,ownerSid,pendingFilesSnapshot)" in body assert "body:JSON.stringify({session_id:ownerSid,text:steerText})" in body, ( "steer endpoint must receive the captured owner session id and attachment-enriched text" ) assert "_clearComposerDraft(ownerSid,_steerRestoreText(originalMsg,explicitSteer),pendingFilesSnapshot)" in body assert "if(_steerOwnerIsCurrent(ownerSid))" in body assert "S.pendingFiles=_remaining" in body, "accepted steer should clear the delivered files (by identity) after paths are injected" def test_file_steer_does_not_read_live_session_after_upload_await(self): cmds = self.cmds idx = cmds.find("async function _trySteer(") assert idx >= 0 body = _source_between(cmds, "async function _trySteer(", "\nasync function cmdTitle") await_idx = body.find("steerText=await _steerTextWithPendingFiles") assert await_idx >= 0 after_upload = body[await_idx:] assert "session_id:S.session.session_id" not in after_upload assert "{session_id:S.session.session_id" not in after_upload assert "session_id:ownerSid" in after_upload assert "_steerOwnerIsCurrent(ownerSid)" in after_upload, ( "post-await tray/DOM mutations must be guarded by the captured owner session" ) def test_file_steer_upload_status_and_indicator_are_owner_scoped(self): steer_helpers = _source_between( self.cmds, "function _steerOwnerIsCurrent", "\nasync function cmdTitle", ) try_body = _source_between(self.cmds, "async function _trySteer(", "\nasync function cmdTitle") assert "function _steerSetComposerStatusForOwner" in steer_helpers assert "_steerSetComposerStatusForOwner(ownerSid,t('uploading')||'Uploading…')" in steer_helpers assert "_steerSetComposerStatusForOwner(ownerSid,'')" in steer_helpers assert "function _steerIndicatorText" in steer_helpers assert "_showSteerIndicator(_steerIndicatorText(originalMsg,pendingFilesSnapshot))" in try_body, ( "visible steer indicator must use original text or a file-only display label, not attachment tool instructions" ) assert "_showSteerIndicator(steerText)" not in try_body def test_file_steer_indicator_omits_attachment_tool_note(self): import json import shutil import subprocess import textwrap node = shutil.which("node") if not node: # pragma: no cover pytest.skip("node not available") assert node is not None steer_src = _source_between( self.cmds, "function _steerUploadedAttachmentPaths", "\nasync function cmdTitle", ) script = textwrap.dedent( f""" const assert = require('assert'); let S = {{session:{{session_id:'A'}}, pendingFiles:[{{name:'a.pdf'}}]}}; let apiPayload = null; let indicatorText = null; function t(k){{return k;}} function $(id){{return {{value:'', classList:{{add(){{}}, remove(){{}}}}, style:{{}}}};}} function setComposerStatus(){{}} function showToast(){{}} function renderTray(){{}} function _showSteerIndicator(text){{indicatorText = text;}} function _showSteerRecovery(){{}} function _clearComposerDraft(){{}} async function uploadPendingFiles(){{return [{{path:'/tmp/a.pdf'}}];}} async function api(url, options){{ assert.strictEqual(url, '/api/chat/steer'); apiPayload = JSON.parse(options.body); return {{accepted:true}}; }} eval({json.dumps(steer_src)}); (async()=>{{ const delivered = await _trySteer('hint', false); assert.strictEqual(delivered, true); assert.strictEqual(indicatorText, 'hint'); assert.ok(apiPayload.text.includes('[Attached files for this steer: /tmp/a.pdf]')); assert.ok(!indicatorText.includes('Attached files')); assert.ok(!indicatorText.includes('file tools/read_file')); }})().catch(err=>{{console.error(err); process.exit(1);}}); """ ) subprocess.run([node, "-e", script], check=True, capture_output=True, text=True) def test_attachment_only_steer_indicator_uses_file_label(self): import json import shutil import subprocess import textwrap node = shutil.which("node") if not node: # pragma: no cover pytest.skip("node not available") assert node is not None steer_src = _source_between( self.cmds, "function _steerUploadedAttachmentPaths", "\nasync function cmdTitle", ) script = textwrap.dedent( f""" const assert = require('assert'); let S = {{session:{{session_id:'A'}}, pendingFiles:[{{name:'a.pdf'}}]}}; let apiPayload = null; let indicatorText = null; function t(k){{return k;}} function $(id){{return {{value:'', classList:{{add(){{}}, remove(){{}}}}, style:{{}}}};}} function setComposerStatus(){{}} function showToast(){{}} function renderTray(){{}} function _showSteerIndicator(text){{indicatorText = text;}} function _showSteerRecovery(){{}} function _clearComposerDraft(){{}} async function uploadPendingFiles(){{return [{{path:'/tmp/a.pdf'}}];}} async function api(url, options){{ assert.strictEqual(url, '/api/chat/steer'); apiPayload = JSON.parse(options.body); return {{accepted:true}}; }} eval({json.dumps(steer_src)}); (async()=>{{ const delivered = await _trySteer('', false); assert.strictEqual(delivered, true); assert.strictEqual(indicatorText, 'Attached files: a.pdf'); assert.ok(apiPayload.text.includes('[Attached files for this steer: /tmp/a.pdf]')); assert.ok(!indicatorText.includes('file tools/read_file')); }})().catch(err=>{{console.error(err); process.exit(1);}}); """ ) subprocess.run([node, "-e", script], check=True, capture_output=True, text=True) def test_file_steer_targets_captured_session_when_user_switches_mid_upload(self): import json import shutil import subprocess import textwrap node = shutil.which("node") if not node: # pragma: no cover pytest.skip("node not available") assert node is not None steer_src = _source_between( self.cmds, "function _steerUploadedAttachmentPaths", "\nasync function cmdTitle", ) script = textwrap.dedent( f""" const assert = require('assert'); let S = {{session:{{session_id:'A'}}, pendingFiles:[{{name:'a.pdf'}}]}}; let uploadOptions = null; let apiPayload = null; let trayRenders = 0; let indicatorCalls = 0; let draftClears = []; function t(k){{return k;}} function $(id){{return {{value:'', classList:{{add(){{}}, remove(){{}}}}, style:{{}}}};}} function setComposerStatus(){{}} function showToast(){{}} function renderTray(){{trayRenders += 1;}} function _showSteerIndicator(){{indicatorCalls += 1;}} function _showSteerRecovery(){{}} function _clearComposerDraft(sid,text,files){{draftClears.push({{sid,text,files}});}} async function uploadPendingFiles(options){{ uploadOptions = options; S.session = {{session_id:'B'}}; S.pendingFiles = [{{name:'b.pdf'}}]; return [{{path:'/tmp/a.pdf'}}]; }} async function api(url, options){{ assert.strictEqual(url, '/api/chat/steer'); apiPayload = JSON.parse(options.body); return {{accepted:true}}; }} eval({json.dumps(steer_src)}); (async()=>{{ const delivered = await _trySteer('hint', false); assert.strictEqual(delivered, true); assert.strictEqual(uploadOptions.sessionId, 'A'); assert.strictEqual(uploadOptions.files.length, 1); assert.strictEqual(uploadOptions.files[0].name, 'a.pdf'); assert.strictEqual(apiPayload.session_id, 'A'); assert.strictEqual(S.session.session_id, 'B'); assert.strictEqual(S.pendingFiles.length, 1); assert.strictEqual(S.pendingFiles[0].name, 'b.pdf'); assert.strictEqual(trayRenders, 0); assert.strictEqual(indicatorCalls, 0); assert.strictEqual(draftClears.length, 1); assert.strictEqual(draftClears[0].sid, 'A'); assert.strictEqual(draftClears[0].files[0].name, 'a.pdf'); }})().catch(err=>{{console.error(err); process.exit(1);}}); """ ) subprocess.run([node, "-e", script], check=True, capture_output=True, text=True) def test_dead_steer_fallback_clears_busy_state_and_recovery_sends_normally(self): import json import shutil import subprocess import textwrap node = shutil.which("node") if not node: # pragma: no cover pytest.skip("node not available") assert node is not None steer_src = _source_between( self.cmds, "function _showSteerRecovery", "\nasync function cmdTitle", ) script = textwrap.dedent( f""" const assert = require('assert'); const steerSrc = {json.dumps(steer_src)}; function makeElement(tag){{ return {{ tag, className:'', textContent:'', children:[], listeners:{{}}, appendChild(child){{this.children.push(child);}}, remove(){{this.removed=true;}}, addEventListener(name,fn){{this.listeners[name]=fn;}}, querySelector(sel){{return null;}}, }}; }} let inner = makeElement('div'); const document = {{ getElementById(id){{return id==='msgInner'?inner:null;}}, createElement: makeElement, }}; function t(k){{return k;}} function _steerFailureMessageKey(fallback){{return 'steer_fail_'+fallback;}} function scrollToBottom(){{}} function setComposerStatus(){{}} function showToast(key){{if(globalThis.__toasts)globalThis.__toasts.push(key);}} function renderTray(){{if(globalThis.__trayRenders)globalThis.__trayRenders.count += 1;}} function autoResize(){{}} function _showSteerIndicator(){{}} function _clearComposerDraft(sid,text,files){{if(globalThis.__draftClears)globalThis.__draftClears.push({{sid,text,files}});}} async function uploadPendingFiles(){{return [];}} eval(steerSrc); async function runStreamDeadFallback(explicitSteer=false, msg='retry me'){{ let input = {{value:''}}; let clearInflightCalls = []; let updateSendBtnCalls = 0; let sendCalls = 0; let sendInput = null; let sendOptions = null; let apiPayload = null; inner = makeElement('div'); globalThis.S = {{ session:{{session_id:'A', active_stream_id:'stream-1'}}, activeStreamId:'stream-1', busy:true, pendingFiles:[{{name:'a.pdf'}}], }}; globalThis.INFLIGHT = {{A:{{messages:[]}}}}; globalThis.$ = id => input; globalThis.clearInflightState = sid => clearInflightCalls.push(sid); globalThis.updateSendBtn = () => {{updateSendBtnCalls += 1;}}; globalThis.send = async options => {{sendCalls += 1; sendInput = input.value; sendOptions = options;}}; globalThis.api = async (url, options) => {{ assert.strictEqual(url, '/api/chat/steer'); apiPayload = JSON.parse(options.body); return {{accepted:false, fallback:'stream_dead'}}; }}; const delivered = await _trySteer(msg, explicitSteer); assert.strictEqual(delivered, false); assert.deepStrictEqual(apiPayload, {{session_id:'A', text:msg}}); assert.strictEqual(S.busy, false); assert.strictEqual(S.activeStreamId, null); assert.strictEqual(S.session.active_stream_id, null); assert.ok(!Object.prototype.hasOwnProperty.call(INFLIGHT, 'A')); assert.deepStrictEqual(clearInflightCalls, ['A']); assert.strictEqual(updateSendBtnCalls, 1); assert.strictEqual(input.value, explicitSteer ? `/steer ${{msg}}` : msg); assert.strictEqual(S.pendingFiles.length, 1); const recovery = inner.children[inner.children.length - 1]; const retry = recovery.children[1]; assert.strictEqual(retry.textContent, 'clarify_send'); retry.listeners.click(); await Promise.resolve(); assert.strictEqual(sendCalls, 1); assert.strictEqual(sendInput, msg); assert.deepStrictEqual(sendOptions, {{literalSlash:true}}); }} async function runNoCachedAgentFallback(explicitSteer=false, msg='retry me'){{ let input = {{value:''}}; let clearInflightCalls = []; let updateSendBtnCalls = 0; let sendCalls = 0; let apiCalls = 0; let apiPayload = null; inner = makeElement('div'); globalThis.S = {{ session:{{session_id:'A', active_stream_id:'stream-1'}}, activeStreamId:'stream-1', busy:true, pendingFiles:[{{name:'a.pdf'}}], }}; globalThis.INFLIGHT = {{A:{{messages:[]}}}}; globalThis.$ = id => input; globalThis.clearInflightState = sid => clearInflightCalls.push(sid); globalThis.updateSendBtn = () => {{updateSendBtnCalls += 1;}}; globalThis.send = async () => {{sendCalls += 1;}}; globalThis.api = async (url, options) => {{ assert.strictEqual(url, '/api/chat/steer'); apiCalls += 1; apiPayload = JSON.parse(options.body); return {{accepted:false, fallback:'no_cached_agent'}}; }}; const delivered = await _trySteer(msg, explicitSteer); assert.strictEqual(delivered, false); assert.deepStrictEqual(apiPayload, {{session_id:'A', text:msg}}); assert.strictEqual(S.busy, true); assert.strictEqual(S.activeStreamId, 'stream-1'); assert.strictEqual(S.session.active_stream_id, 'stream-1'); assert.ok(Object.prototype.hasOwnProperty.call(INFLIGHT, 'A')); assert.deepStrictEqual(clearInflightCalls, []); assert.strictEqual(updateSendBtnCalls, 0); assert.strictEqual(input.value, explicitSteer ? `/steer ${{msg}}` : msg); assert.strictEqual(S.pendingFiles.length, 1); const recovery = inner.children[inner.children.length - 1]; const retry = recovery.children[1]; assert.strictEqual(retry.textContent, 'steer_recovery_retry'); retry.listeners.click(); await Promise.resolve(); await Promise.resolve(); assert.strictEqual(sendCalls, 0); assert.strictEqual(apiCalls, 2); }} async function runGatewayQueuedFallback(switchDuringAwait=false){{ let input = {{value:''}}; let clearInflightCalls = []; let updateSendBtnCalls = 0; let queued = []; let queueBadges = []; let draftClears = []; let trayRenders = 0; let toasts = []; let submittedFile = {{name:'a.pdf'}}; let replacementFile = {{name:'replacement.pdf'}}; let apiPayload = null; inner = makeElement('div'); globalThis.S = {{ session:{{session_id:'A', active_stream_id:'stream-1', model:'fallback-model', model_provider:'fallback-provider'}}, activeStreamId:'stream-1', activeProfile:'work', busy:true, pendingFiles:[submittedFile], }}; globalThis.INFLIGHT = {{A:{{messages:[]}}}}; globalThis.$ = id => input; globalThis.clearInflightState = sid => clearInflightCalls.push(sid); globalThis.updateSendBtn = () => {{updateSendBtnCalls += 1;}}; globalThis.queueSessionMessage = (sid, payload) => queued.push({{sid, payload}}); globalThis.updateQueueBadge = sid => queueBadges.push(sid); globalThis.__draftClears = draftClears; globalThis.__trayRenders = {{count:0}}; globalThis.__toasts = toasts; globalThis._chatPayloadModelState = () => ({{model:'captured-model', model_provider:'captured-provider'}}); globalThis.api = async (url, options) => {{ assert.strictEqual(url, '/api/chat/steer'); apiPayload = JSON.parse(options.body); if(switchDuringAwait){{ S.session={{session_id:'B', active_stream_id:'stream-B'}}; S.activeStreamId='stream-B'; S.pendingFiles=[replacementFile]; }}else{{ S.pendingFiles=[submittedFile, replacementFile]; }} return {{accepted:false, fallback:'gateway_steer_queued'}}; }}; const delivered = await _trySteer('queue me', false); assert.strictEqual(delivered, true); assert.deepStrictEqual(apiPayload, {{session_id:'A', text:'queue me'}}); assert.strictEqual(S.busy, true); assert.ok(Object.prototype.hasOwnProperty.call(INFLIGHT, 'A')); assert.deepStrictEqual(clearInflightCalls, []); assert.strictEqual(updateSendBtnCalls, 0); assert.strictEqual(inner.children.length, 0); assert.deepStrictEqual(queueBadges, ['A']); assert.strictEqual(queued.length, 1); assert.strictEqual(queued[0].sid, 'A'); assert.strictEqual(queued[0].payload.text, 'queue me'); assert.deepStrictEqual(queued[0].payload.files, [submittedFile]); assert.strictEqual(queued[0].payload.model, 'captured-model'); assert.strictEqual(queued[0].payload.model_provider, 'captured-provider'); assert.strictEqual(queued[0].payload.profile, 'work'); assert.strictEqual(draftClears.length, 1); assert.strictEqual(draftClears[0].sid, 'A'); assert.strictEqual(draftClears[0].text, 'queue me'); assert.deepStrictEqual(draftClears[0].files, [submittedFile]); assert.deepStrictEqual(toasts, ['steer_leftover_queued']); if(switchDuringAwait){{ assert.strictEqual(S.session.session_id, 'B'); assert.deepStrictEqual(S.pendingFiles, [replacementFile]); assert.strictEqual(globalThis.__trayRenders.count, 0); }}else{{ assert.deepStrictEqual(S.pendingFiles, [replacementFile]); assert.strictEqual(globalThis.__trayRenders.count, 1); }} delete globalThis.__draftClears; delete globalThis.__trayRenders; delete globalThis.__toasts; }} async function runLateDeadFallbackDoesNotClearNewStream(){{ let input = {{value:''}}; let clearInflightCalls = []; let updateSendBtnCalls = 0; inner = makeElement('div'); globalThis.S = {{ session:{{session_id:'A', active_stream_id:'stream-1'}}, activeStreamId:'stream-1', busy:true, pendingFiles:[], }}; globalThis.INFLIGHT = {{A:{{messages:[]}}}}; globalThis.$ = id => input; globalThis.clearInflightState = sid => clearInflightCalls.push(sid); globalThis.updateSendBtn = () => {{updateSendBtnCalls += 1;}}; globalThis.send = async () => {{throw new Error('send must not run for a stale dead fallback');}}; globalThis.api = async () => {{ S.activeStreamId='stream-2'; S.session.active_stream_id='stream-2'; return {{accepted:false, fallback:'stream_dead'}}; }}; const delivered = await _trySteer('old steer', false); assert.strictEqual(delivered, false); assert.strictEqual(S.busy, true); assert.strictEqual(S.activeStreamId, 'stream-2'); assert.strictEqual(S.session.active_stream_id, 'stream-2'); assert.ok(Object.prototype.hasOwnProperty.call(INFLIGHT, 'A')); assert.deepStrictEqual(clearInflightCalls, []); assert.strictEqual(updateSendBtnCalls, 0); assert.strictEqual(input.value, ''); assert.strictEqual(inner.children.length, 0); }} async function runAdjacentLiveFailure(){{ let input = {{value:''}}; let clearInflightCalls = []; let updateSendBtnCalls = 0; inner = makeElement('div'); globalThis.S = {{ session:{{session_id:'A', active_stream_id:'stream-1'}}, activeStreamId:'stream-1', busy:true, pendingFiles:[{{name:'a.pdf'}}], }}; globalThis.INFLIGHT = {{A:{{messages:[]}}}}; globalThis.$ = id => input; globalThis.clearInflightState = sid => clearInflightCalls.push(sid); globalThis.updateSendBtn = () => {{updateSendBtnCalls += 1;}}; globalThis.send = async () => {{throw new Error('send must not run for live steer failures');}}; globalThis.api = async () => {{return {{accepted:false, fallback:'agent_lacks_steer'}};}}; const delivered = await _trySteer('live hint', false); assert.strictEqual(delivered, false); assert.strictEqual(S.busy, true); assert.strictEqual(S.activeStreamId, 'stream-1'); assert.strictEqual(S.session.active_stream_id, 'stream-1'); assert.ok(Object.prototype.hasOwnProperty.call(INFLIGHT, 'A')); assert.deepStrictEqual(clearInflightCalls, []); assert.strictEqual(updateSendBtnCalls, 0); assert.strictEqual(input.value, 'live hint'); assert.strictEqual(S.pendingFiles.length, 1); const recovery = inner.children[inner.children.length - 1]; const retry = recovery.children[1]; assert.strictEqual(retry.textContent, 'steer_recovery_retry'); }} (async()=>{{ await runNoCachedAgentFallback(); await runNoCachedAgentFallback(true); await runGatewayQueuedFallback(false); await runGatewayQueuedFallback(true); await runStreamDeadFallback(); await runStreamDeadFallback(true); await runStreamDeadFallback(true, '/help'); await runLateDeadFallbackDoesNotClearNewStream(); await runAdjacentLiveFailure(); }})().catch(err=>{{console.error(err); process.exit(1);}}); """ ) subprocess.run([node, "-e", script], check=True, capture_output=True, text=True) def test_send_busy_steer_accepts_file_only_input(self): idx = self.msgs.find("if(S.busy||compressionRunning)") assert idx >= 0 block = self.msgs[idx:idx + 500] assert "if(text||S.pendingFiles.length)" in block, ( "busy send must route file-only composer submissions through queue/interrupt/steer" ) assert "_trySteer uploads with clearPending=false" in self.msgs def test_upload_pending_files_can_preserve_staged_files_for_steer(self): ui = (Path(__file__).parent.parent / "static" / "ui.js").read_text(encoding="utf-8") assert "async function uploadPendingFiles(options={})" in ui assert "const pendingFiles=Array.isArray(opts.files)?opts.files.filter(Boolean):[...(S.pendingFiles||[])];" in ui assert "const sessionId=String(opts.sessionId||(S.session&&S.session.session_id)||'');" in ui assert "const clearPending=!(opts&&opts.clearPending===false)" in ui assert "fd.append('session_id',sessionId)" in ui assert "if(clearPending&&_uploadPendingFilesCurrentSession(sessionId)){S.pendingFiles=[];renderTray();}" in ui assert "else if(typeof renderTray==='function'&&_uploadPendingFilesCurrentSession(sessionId))renderTray();" in ui def test_upload_pending_files_progress_bar_is_session_scoped(self): ui = (Path(__file__).parent.parent / "static" / "ui.js").read_text(encoding="utf-8") progress_helper = _source_between( ui, "const _uploadPendingFilesProgressBySession", "\nasync function uploadPendingFiles", ) upload_body = ui[ui.index("async function uploadPendingFiles") :] sessions = (Path(__file__).parent.parent / "static" / "sessions.js").read_text(encoding="utf-8") load_body = _source_between(sessions, "async function loadSession", "\nfunction _isMessagingSession") assert "_uploadPendingFilesSyncProgressForSession(sid)" in load_body assert "_uploadPendingFilesProgressBySession.set(owner,{percent:clamped})" in progress_helper assert "function _uploadPendingFilesSyncProgressForSession" in progress_helper assert "if(!_uploadPendingFilesCurrentSession(sessionId)){" in progress_helper assert "barWrap.dataset.uploadSessionId=owner" in progress_helper assert "activeForOwner" in progress_helper assert "barWrap.classList.remove('active')" in progress_helper assert "_uploadPendingFilesUpdateProgress(sessionId,0)" in upload_body assert "_uploadPendingFilesUpdateProgress(sessionId,Math.round((i+1)/total*100))" in upload_body assert "_uploadPendingFilesUpdateProgress(sessionId,null)" in upload_body assert "barWrap.classList.add('active');bar.style.width='0%';" not in upload_body assert "barWrap.classList.remove('active');bar.style.width='0%';" not in upload_body def test_upload_progress_bar_hides_on_switch_and_reappears_on_owner_return(self): import json import shutil import subprocess import textwrap node = shutil.which("node") if not node: # pragma: no cover pytest.skip("node not available") assert node is not None ui = (Path(__file__).parent.parent / "static" / "ui.js").read_text(encoding="utf-8") progress_src = _source_between( ui, "const _uploadPendingFilesProgressBySession", "\nasync function uploadPendingFiles", ) script = textwrap.dedent( f""" const assert = require('assert'); let S = {{session:{{session_id:'A'}}}}; const bar = {{style:{{width:''}}}}; const barWrap = {{ dataset: {{}}, active: false, classList: {{ add(cls){{ if(cls === 'active') barWrap.active = true; }}, remove(cls){{ if(cls === 'active') barWrap.active = false; }}, }}, }}; function $(id){{ if(id === 'uploadBar') return bar; if(id === 'uploadBarWrap') return barWrap; return null; }} eval({json.dumps(progress_src)}); _uploadPendingFilesUpdateProgress('A', 0); assert.strictEqual(barWrap.active, true); assert.strictEqual(bar.style.width, '0%'); assert.strictEqual(barWrap.dataset.uploadSessionId, 'A'); S.session = {{session_id:'B'}}; _uploadPendingFilesSyncProgressForSession('B'); assert.strictEqual(barWrap.active, false); assert.strictEqual(bar.style.width, '0%'); assert.strictEqual(barWrap.dataset.uploadSessionId, undefined); _uploadPendingFilesUpdateProgress('A', 50); assert.strictEqual(barWrap.active, false); assert.strictEqual(bar.style.width, '0%'); S.session = {{session_id:'A'}}; _uploadPendingFilesSyncProgressForSession('A'); assert.strictEqual(barWrap.active, true); assert.strictEqual(bar.style.width, '50%'); assert.strictEqual(barWrap.dataset.uploadSessionId, 'A'); _uploadPendingFilesUpdateProgress('A', null); assert.strictEqual(barWrap.active, false); assert.strictEqual(bar.style.width, '0%'); assert.strictEqual(barWrap.dataset.uploadSessionId, undefined); """ ) subprocess.run([node, "-e", script], check=True, capture_output=True, text=True) def test_pending_steer_leftover_listener(self): """Frontend must listen for pending_steer_leftover SSE events and queue them.""" idx = self.msgs.find("addEventListener('pending_steer_leftover'") assert idx >= 0, "messages.js must add a listener for pending_steer_leftover" block = self.msgs[idx:idx + 600] assert "queueSessionMessage" in block, ( "pending_steer_leftover handler must queue the leftover text for the next turn" ) # ── i18n keys ───────────────────────────────────────────────────────────── class TestI18nKeys: """The two new keys (cmd_steer_delivered, steer_leftover_queued) must be in all 6 locales.""" @classmethod def setup_class(cls): cls.i18n = (Path(__file__).parent.parent / "static" / "i18n.js").read_text(encoding="utf-8") def test_cmd_steer_delivered_in_all_locales(self): assert self.i18n.count("cmd_steer_delivered:") >= 6, ( f"cmd_steer_delivered appears {self.i18n.count('cmd_steer_delivered:')} times; " f"expected ≥6 (one per locale)" ) def test_steer_leftover_queued_in_all_locales(self): assert self.i18n.count("steer_leftover_queued:") >= 6, ( f"steer_leftover_queued appears {self.i18n.count('steer_leftover_queued:')} times; " f"expected ≥6 (one per locale)" ) # ── Leftover SSE delivery: streaming.py emits pending_steer_leftover ───── class TestLeftoverDelivery: """After run_conversation returns, _drain_pending_steer is called and a pending_steer_leftover SSE event is emitted if there's still text stashed.""" def test_leftover_drain_call_in_streaming(self): """Verify the streaming.py source contains the drain call before put('done', ...).""" src = (Path(__file__).parent.parent / "api" / "streaming.py").read_text(encoding="utf-8") assert "_drain_pending_steer" in src, ( "_run_agent_streaming must call agent._drain_pending_steer() to deliver leftovers" ) assert "pending_steer_leftover" in src, ( "_run_agent_streaming must emit a pending_steer_leftover SSE event" ) def test_leftover_drain_runs_before_done_event(self): """The drain must happen BEFORE put('done', ...) so frontend gets both events on the same turn.""" src = (Path(__file__).parent.parent / "api" / "streaming.py").read_text(encoding="utf-8") # Find the drain invocation and the next put('done', ...) AFTER it drain_idx = src.find("_settle_pending_steer()", src.index(" def put(event, data):", src.index("def _run_agent_streaming("))) assert drain_idx >= 0 done_idx = src.find("put('done'", drain_idx) assert done_idx >= 0 # No put('done', ...) should appear BEFORE the drain in the same code block # (we already check the drain is in the file; ordering matters within the # non-ephemeral success path) assert drain_idx < done_idx, ( "_drain_pending_steer must run before put('done', ...) so the SSE listener " "sees the leftover before stream_end fires" )