fix: include attachments in steer submissions
Steering (submitting mid-stream) dropped any pending composer attachments. Now the pending files are uploaded before the /steer payload is sent, the uploaded file paths are appended to the steer text so the active agent can inspect them with tools, file chips stay staged if the steer fails (so nothing is lost), and file-only busy submissions are handled. Contributor stage; gate-pass. Co-authored-by: ruizanthony <ruizanthony@users.noreply.github.com>
This commit is contained in:
parent
abdf6c7ae3
commit
ceea2438eb
7 changed files with 311 additions and 45 deletions
|
|
@ -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 <msg> 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<boolean>} 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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
22
static/ui.js
22
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;i<total;i++){
|
||||
const f=S.pendingFiles[i];
|
||||
const f=pendingFiles[i];
|
||||
try{
|
||||
if(f&&f.size>MAX_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);
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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] == "{":
|
||||
|
|
|
|||
|
|
@ -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'")
|
||||
|
|
|
|||
Loading…
Reference in a new issue