diff --git a/static/commands.js b/static/commands.js index 631c7d2b..cde8e0d0 100644 --- a/static/commands.js +++ b/static/commands.js @@ -1336,7 +1336,8 @@ async function cmdInterrupt(args){ */ async function cmdSteer(args){ const msg=(args||'').trim(); - if(!msg){showToast(t('cmd_steer_no_msg'));return;} + const hasPendingFiles=typeof S!=='undefined'&&Array.isArray(S.pendingFiles)&&S.pendingFiles.length>0; + if(!msg&&!hasPendingFiles){showToast(t('cmd_steer_no_msg'));return;} // If nothing is running, /steer just sends like a normal message if(!S.busy||!S.activeStreamId){ const inp=$('msg'); @@ -1417,38 +1418,125 @@ function _showSteerRecovery(msg, explicitSteer, fallback) { * @returns {Promise} true when the steer was delivered, false when the * draft was restored and the active stream was left untouched. */ +function _steerUploadedAttachmentPaths(uploaded){ + if(!Array.isArray(uploaded))return[]; + return uploaded.map(u=>{ + if(!u)return''; + if(typeof u==='string')return u; + return u.path||u.name||u.filename||''; + }).map(v=>String(v||'').trim()).filter(Boolean); +} + +function _steerOwnerIsCurrent(ownerSid){ + return !!(ownerSid&&typeof S!=='undefined'&&S.session&&S.session.session_id===ownerSid); +} + +function _steerRestoreText(originalMsg, explicitSteer){ + return explicitSteer?`/steer ${originalMsg}`:originalMsg; +} + +async function _steerPersistDraftForOwner(ownerSid, originalMsg, explicitSteer, filesSnapshot){ + if(!ownerSid||typeof _saveComposerDraftNow!=='function')return; + await _saveComposerDraftNow(ownerSid,_steerRestoreText(originalMsg,explicitSteer),filesSnapshot); +} + +async function _steerTextWithPendingFiles(msg, ownerSid, filesSnapshot){ + const base=String(msg||'').trim(); + const pendingFiles=Array.isArray(filesSnapshot)?filesSnapshot.filter(Boolean):[]; + if(!pendingFiles.length)return base; + if(typeof uploadPendingFiles!=='function')return base; + if(typeof setComposerStatus==='function')setComposerStatus(t('uploading')||'Uploading…'); + let uploaded=[]; + try{ + // Keep File objects staged until /api/chat/steer confirms acceptance. If + // steer falls back, the draft and chips stay available for Queue/Interrupt. + uploaded=await uploadPendingFiles({clearPending:false,sessionId:ownerSid,files:pendingFiles}); + }finally{ + if(typeof setComposerStatus==='function')setComposerStatus(''); + } + const paths=_steerUploadedAttachmentPaths(uploaded); + if(!paths.length)return base; + const note=`[Attached files for this steer: ${paths.join(', ')}]\nUse the file tools/read_file to inspect these documents if needed.`; + return base?`${base}\n\n${note}`:note; +} + async function _trySteer(msg, explicitSteer){ let result=null; + const originalMsg=String(msg||'').trim(); + const ownerSid=(typeof S!=='undefined'&&S.session&&S.session.session_id)||null; + const pendingFilesSnapshot=typeof S!=='undefined'&&Array.isArray(S.pendingFiles)?[...S.pendingFiles]:[]; + if(!ownerSid){showToast(t('no_active_session'));return false;} + let steerText=originalMsg; + try{ + steerText=await _steerTextWithPendingFiles(originalMsg,ownerSid,pendingFilesSnapshot); + }catch(e){ + if(_steerOwnerIsCurrent(ownerSid)){ + const inp=$('msg'); + if(inp){ + inp.value=_steerRestoreText(originalMsg,explicitSteer); + if(typeof autoResize==='function')autoResize(); + } + if(typeof renderTray==='function')renderTray(); + }else{ + await _steerPersistDraftForOwner(ownerSid,originalMsg,explicitSteer,pendingFilesSnapshot); + } + if(typeof setComposerStatus==='function')setComposerStatus(''); + showToast(`${t('upload_failed')}${e&&e.message?e.message:e}`,3500); + return false; + } + if(!steerText){ + if(_steerOwnerIsCurrent(ownerSid)){ + const inp=$('msg'); + if(inp){ + inp.value=_steerRestoreText(originalMsg,explicitSteer); + if(typeof autoResize==='function')autoResize(); + } + if(typeof renderTray==='function')renderTray(); + }else{ + await _steerPersistDraftForOwner(ownerSid,originalMsg,explicitSteer,pendingFilesSnapshot); + } + showToast(t('cmd_steer_no_msg')); + return false; + } try{ result=await api('/api/chat/steer',{ method:'POST', - body:JSON.stringify({session_id:S.session.session_id,text:msg}), + body:JSON.stringify({session_id:ownerSid,text:steerText}), }); }catch(e){ // Network or server error — keep the active stream running and restore the draft. result={accepted:false, fallback:'network_error'}; } if(result&&result.accepted){ + if(ownerSid&&typeof _clearComposerDraft==='function') _clearComposerDraft(ownerSid,_steerRestoreText(originalMsg,explicitSteer),pendingFilesSnapshot); // Show a transient steer indicator in the chat (NOT in S.messages — it must // survive the done event's S.messages=d.session.messages replacement). // The indicator self-removes when the turn completes (done/cancel/error - // all call renderMessages which rebuilds msgInner). - _showSteerIndicator(msg); + // all call renderMessages which rebuilds msgInner). Only mutate the visible + // tray/DOM if the user is still looking at the owning session. + if(_steerOwnerIsCurrent(ownerSid)){ + if(typeof S!=='undefined'&&Array.isArray(S.pendingFiles)&&S.pendingFiles.length){S.pendingFiles=[];if(typeof renderTray==='function')renderTray();} + _showSteerIndicator(steerText); + } showToast(t('cmd_steer_delivered'),2500); return true; } // 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(_steerOwnerIsCurrent(ownerSid)){ + const inp=$('msg'); + if(inp){ + inp.value=_steerRestoreText(originalMsg,explicitSteer); + if(typeof autoResize==='function')autoResize(); + } + if(typeof renderTray==='function')renderTray(); + }else{ + await _steerPersistDraftForOwner(ownerSid,originalMsg,explicitSteer,pendingFilesSnapshot); } - if(typeof renderTray==='function')renderTray(); const fallbackCode = result && result.fallback; showToast(t(_steerFailureMessageKey(fallbackCode)), 3500); - _showSteerRecovery(msg, explicitSteer, fallbackCode); + if(_steerOwnerIsCurrent(ownerSid)) _showSteerRecovery(originalMsg, explicitSteer, fallbackCode); return false; } diff --git a/static/messages.js b/static/messages.js index 8c63221c..8563a521 100644 --- a/static/messages.js +++ b/static/messages.js @@ -1345,7 +1345,7 @@ async function send(){ _clearStaleBusyStateBeforeSend({compressionRunning}); // If busy or a manual compression is still running, handle based on default_message_mode if(S.busy||compressionRunning){ - if(text){ + if(text||S.pendingFiles.length){ if(!S.session){await newSession();await renderSessionList();} // Busy-control slash commands must be intercepted HERE, before the // defaultMessageMode routing block, so the user can always type /steer, /interrupt, @@ -1367,19 +1367,17 @@ async function send(){ } const defaultMessageMode=window._defaultMessageMode||'steer'; if(defaultMessageMode==='steer'&&S.activeStreamId&&typeof _trySteer==='function'){ - const _steerDraftFiles=Array.isArray(S.pendingFiles)?[...S.pendingFiles]:[]; // Real steer: clear the input first so the user gets immediate // feedback, then ship the steer payload via /api/chat/steer. - // _trySteer restores the draft and leaves the active stream running if - // the agent isn't running / cached / steer-capable. + // _trySteer captures the owner session/files before awaiting uploads, + // restores/persists the draft on failure, and clears the owner draft + // only after /api/chat/steer accepts. $('msg').value='';autoResize(); - // 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();} - if(_steerDelivered&&S.session&&typeof _clearComposerDraft==='function') _clearComposerDraft(S.session.session_id,text,_steerDraftFiles); + // Do NOT clear pendingFiles yet — _trySteer uploads with clearPending=false, + // and a failed steer must keep staged files available for the user's next explicit action. + await _trySteer(text, /*explicitSteer=*/false); + // _trySteer clears staged files only after /api/chat/steer accepts, and + // only when the visible session still matches the captured owner sid. } else if(defaultMessageMode==='interrupt'){ // Queue the message, then cancel so drain re-sends it. const _modelState=_chatPayloadModelState(); diff --git a/static/ui.js b/static/ui.js index e51c1aa2..a1d67d39 100644 --- a/static/ui.js +++ b/static/ui.js @@ -17563,18 +17563,25 @@ function addFiles(files){ } renderTray(); } -async function uploadPendingFiles(){ - if(!S.pendingFiles.length||!S.session)return[]; +function _uploadPendingFilesCurrentSession(sessionId){ + return !!(!sessionId||(S.session&&S.session.session_id===sessionId)); +} +async function uploadPendingFiles(options={}){ + const opts=options||{}; + const pendingFiles=Array.isArray(opts.files)?opts.files.filter(Boolean):[...(S.pendingFiles||[])]; + const sessionId=String(opts.sessionId||(S.session&&S.session.session_id)||''); + if(!pendingFiles.length||!sessionId)return[]; + const clearPending=!(opts&&opts.clearPending===false); const names=[];let failures=0; const bar=$('uploadBar');const barWrap=$('uploadBarWrap'); barWrap.classList.add('active');bar.style.width='0%'; - const total=S.pendingFiles.length; + const total=pendingFiles.length; for(let i=0;iMAX_UPLOAD_BYTES)throw new Error(_uploadTooLargeMessage(f)); const fd=new FormData(); - fd.append('session_id',S.session.session_id);fd.append('file',f,f.name); + fd.append('session_id',sessionId);fd.append('file',f,f.name); const isArchive=_ARCHIVE_EXTS.test(f.name); const url=new URL(isArchive?'api/upload/extract':'api/upload',document.baseURI||location.href).href; const res=await fetch(url,{method:'POST',credentials:'include',body:fd}); @@ -17584,7 +17591,7 @@ async function uploadPendingFiles(){ if(data.error)throw new Error(data.error); if(isArchive){ names.push({name: data.dest, path: data.dest, extracted: data.extracted}); - if(typeof loadDir==='function')loadDir(S.currentDir||'.'); + if(typeof loadDir==='function'&&_uploadPendingFilesCurrentSession(sessionId))loadDir(S.currentDir||'.'); }else{ names.push({name: data.filename, path: data.path, mime: data.mime, size: data.size, is_image: !!data.is_image}); } @@ -17592,7 +17599,8 @@ async function uploadPendingFiles(){ bar.style.width=`${Math.round((i+1)/total*100)}%`; } barWrap.classList.remove('active');bar.style.width='0%'; - S.pendingFiles=[];renderTray(); + if(clearPending&&_uploadPendingFilesCurrentSession(sessionId)){S.pendingFiles=[];renderTray();} + else if(typeof renderTray==='function'&&_uploadPendingFilesCurrentSession(sessionId))renderTray(); if(failures===total&&total>0)throw new Error(t('all_uploads_failed',total)); // Show extraction summary const extracted=names.filter(n=>n.extracted); diff --git a/tests/test_1062_busy_input_modes.py b/tests/test_1062_busy_input_modes.py index 3087110e..5aad231a 100644 --- a/tests/test_1062_busy_input_modes.py +++ b/tests/test_1062_busy_input_modes.py @@ -22,6 +22,14 @@ INDEX_HTML = (ROOT / "static" / "index.html").read_text(encoding="utf-8") I18N_JS = (ROOT / "static" / "i18n.js").read_text(encoding="utf-8") +def _source_between(src, start_marker, end_marker): + start = src.find(start_marker) + assert start >= 0, f"{start_marker} not found" + end = src.find(end_marker, start) + assert end > start, f"{end_marker} not found after {start_marker}" + return src[start:end] + + # ── Backend: setting registration + enum validation ───────────────────── class TestBusyInputModeSetting: @@ -105,10 +113,12 @@ class TestSlashCommandHandlers: # 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] + helper_body = _source_between(COMMANDS_JS, "async function _trySteer(", "\nasync function cmdTitle") assert "queueSessionMessage" not in helper_body assert "cancelStream" not in helper_body assert "inp.value" in helper_body + assert "if(result&&result.accepted)" in helper_body + assert "S.pendingFiles=[]" in helper_body # Toast should differ from interrupt to signal it's the steer path assert "_steerFailureMessageKey" in helper_body or "steer_fail_" in helper_body @@ -135,13 +145,17 @@ class TestSlashCommandHandlers: assert "renderTray()" in body, ( f"{fn_name} must call renderTray() after clearing 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=[]" not in try_body - assert "renderTray()" in try_body + # cmdSteer delegates to _trySteer; the helper may clear files only on + # accepted steer. The fallback path restores the draft and keeps staged + # files available for the next explicit action. + try_body = _source_between(COMMANDS_JS, "async function _trySteer(", "\nasync function cmdTitle") + accepted_idx = try_body.find("if(result&&result.accepted)") + failure_idx = try_body.find("// Do not fall back to interrupt") + clear_idx = try_body.find("S.pendingFiles=[]", accepted_idx) + assert accepted_idx >= 0, "_trySteer must branch on accepted steer responses" + assert clear_idx > accepted_idx, "accepted steer should clear staged files" + assert failure_idx > clear_idx, "staged files must not be cleared in the failure path" + assert "renderTray()" in try_body[failure_idx:] class TestBusySendButton: @@ -313,11 +327,35 @@ class TestSendBusyBranchDispatch: branch_end = MESSAGES_JS.find("} else if(defaultMessageMode==='interrupt')", steer_idx) assert branch_end > steer_idx, "busy steer branch end not found" branch = MESSAGES_JS[steer_idx:branch_end] - assert "const _steerDelivered=await _trySteer" in branch - assert "const _steerDraftFiles=Array.isArray(S.pendingFiles)?[...S.pendingFiles]:[];" in branch - assert "if(_steerDelivered){S.pendingFiles=[];renderTray();}" in branch - assert "_clearComposerDraft(S.session.session_id,text,_steerDraftFiles)" in branch - assert branch.index("const _steerDelivered=await _trySteer") < branch.index("if(_steerDelivered){S.pendingFiles=[];renderTray();}") + assert "await _trySteer(text, /*explicitSteer=*/false)" in branch + assert "_trySteer captures the owner session/files before awaiting uploads" in branch + assert "_trySteer clears staged files only after /api/chat/steer accepts" in branch + assert "_clearComposerDraft(S.session.session_id,text" not in branch + try_body = _source_between(COMMANDS_JS, "async function _trySteer(", "\nasync function cmdTitle") + accepted_idx = try_body.find("if(result&&result.accepted)") + failure_idx = try_body.find("// Do not fall back to interrupt") + clear_idx = try_body.find("S.pendingFiles=[]", accepted_idx) + assert accepted_idx >= 0 and clear_idx > accepted_idx + assert "_clearComposerDraft(ownerSid,_steerRestoreText(originalMsg,explicitSteer),pendingFilesSnapshot)" in try_body + assert failure_idx > clear_idx, "failed steer must leave staged files intact" + + def test_reentrant_send_does_not_queue_staged_files_while_steer_uploads(self): + """Repeated Enter during steer upload must not double-send staged files. + + _trySteer uploads with clearPending=false, so S.pendingFiles intentionally + stays populated until the steer endpoint accepts. The early reentrant + guard must therefore be text-only; file-only busy submissions are handled + by the normal busy branch after the first send call owns the turn. + """ + send_idx = MESSAGES_JS.find("async function send(") + assert send_idx >= 0, "send() not found" + guard_start = MESSAGES_JS.find("if (_sendInProgress)", send_idx) + guard_end = MESSAGES_JS.find("_sendInProgress = true", guard_start) + assert guard_start >= 0 and guard_end > guard_start, "send() reentrant guard not found" + guard = MESSAGES_JS[guard_start:guard_end] + assert "if(_text && _targetSid)" in guard + assert "S.pendingFiles.length" not in guard + assert "files:[...S.pendingFiles]" in guard def test_slash_commands_intercepted_before_busymode_routing(self): diff --git a/tests/test_composer_draft_after_send.py b/tests/test_composer_draft_after_send.py index 41fcc500..7b1f5b40 100644 --- a/tests/test_composer_draft_after_send.py +++ b/tests/test_composer_draft_after_send.py @@ -4,6 +4,7 @@ from pathlib import Path ROOT = Path(__file__).resolve().parents[1] SESSIONS_JS = ROOT.joinpath("static", "sessions.js").read_text(encoding="utf-8") MESSAGES_JS = ROOT.joinpath("static", "messages.js").read_text(encoding="utf-8") +COMMANDS_JS = ROOT.joinpath("static", "commands.js").read_text(encoding="utf-8") def _block(source: str, start_marker: str, end_marker: str) -> str: @@ -53,7 +54,11 @@ def test_busy_send_paths_clear_persisted_composer_draft(): busy_body = _block(MESSAGES_JS, "if(S.busy||compressionRunning){", " if(S.session&&(S.session.read_only||S.session.is_read_only))") assert "_clearComposerAfterQueuedSelectionSend(S.session&&S.session.session_id);" in busy_body assert busy_body.count("_clearComposerAfterQueuedSelectionSend(S.session&&S.session.session_id);") >= 2 - assert "_clearComposerDraft(S.session.session_id,text,_steerDraftFiles)" in busy_body, "delivered steer must clear persisted draft with the submitted payload signature" + assert "_clearComposerDraft(S.session.session_id,text" not in busy_body + try_steer_body = _block(COMMANDS_JS, "async function _trySteer(", "\nasync function cmdTitle") + assert "_clearComposerDraft(ownerSid,_steerRestoreText(originalMsg,explicitSteer),pendingFilesSnapshot)" in try_steer_body, ( + "delivered steer must clear the captured owner draft with the submitted payload signature" + ) def test_file_signature_survives_server_draft_round_trip(): diff --git a/tests/test_issue1867_upload_size_preflight.py b/tests/test_issue1867_upload_size_preflight.py index b169208a..13539174 100644 --- a/tests/test_issue1867_upload_size_preflight.py +++ b/tests/test_issue1867_upload_size_preflight.py @@ -11,7 +11,8 @@ UPLOAD_PY = ROOT / "api" / "upload.py" def _function_body(src: str, name: str) -> str: marker = f"function {name}" start = src.index(marker) - brace = src.index("{", start) + signature_end = src.index(")", start) + brace = src.index("{", signature_end) depth = 0 for idx in range(brace, len(src)): if src[idx] == "{": diff --git a/tests/test_real_steer.py b/tests/test_real_steer.py index 338deb49..28cd78d3 100644 --- a/tests/test_real_steer.py +++ b/tests/test_real_steer.py @@ -81,6 +81,14 @@ def _captured_status(handler): return calls[-1][0][0] +def _source_between(src, start_marker, end_marker): + start = src.find(start_marker) + assert start >= 0, f"{start_marker} not found" + end = src.find(end_marker, start) + assert end > start, f"{end_marker} not found after {start_marker}" + return src[start:end] + + # ── Backend: the /api/chat/steer endpoint ───────────────────────────────── class TestHandleChatSteerHappyPath: @@ -263,13 +271,13 @@ class TestFrontendWiring: def test_try_steer_calls_endpoint(self): idx = self.cmds.find("async function _trySteer(") assert idx >= 0 - body = self.cmds[idx:idx + 1500] + 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 = self.cmds[idx:idx + 1500] + body = _source_between(self.cmds, "async function _trySteer(", "\nasync function cmdTitle") # Must check result.accepted and surface fallback without queueing or cancelling. assert "result&&result.accepted" in body or "result.accepted" in body assert "queueSessionMessage" not in body @@ -283,6 +291,126 @@ class TestFrontendWiring: 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=[]" in body, "accepted steer should clear visible files only 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_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_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_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'")