diff --git a/static/messages.js b/static/messages.js index 3f7fe906..9257158d 100644 --- a/static/messages.js +++ b/static/messages.js @@ -1566,9 +1566,27 @@ async function send(){ const _submittedDraftTextForClear=$('msg').value||''; $('msg').value='';autoResize(); - setComposerStatus(S.pendingFiles&&S.pendingFiles.length?'Uploading…':''); + // #5912 gate CORE fix: snapshot the pending files that belong to THIS send + // BEFORE the await, and upload exactly that snapshot. Otherwise a re-entrant / + // interrupt-mode send during the upload window inherits the still-live + // S.pendingFiles and later re-uploads the first send's attachment. Detach the + // snapshot from S.pendingFiles now so files staged AFTER this point belong to + // the next send only. + const _submittedFiles=[...(S.pendingFiles||[])]; + const _submittedDraftFilesForClear=_submittedFiles.map(f=>(f&&f.name)||'').filter(Boolean); + S.pendingFiles=[]; + if(typeof renderTray==='function')renderTray(); + + // #5912 gate SILENT fix: clear the PERSISTED draft here — alongside the + // textarea clear, BEFORE any await — so a new draft typed during the upload + // window is not clobbered by a delayed text:'' post. Keep the promise so the + // #5472 failed-send restore can chain its re-persist after this clear resolves. + let _composerDraftClearPromise=null; + if (activeSid && typeof _clearComposerDraft === 'function') _composerDraftClearPromise=_clearComposerDraft(activeSid,_submittedDraftTextForClear,_submittedDraftFilesForClear); + + setComposerStatus(_submittedFiles.length?'Uploading…':''); let uploaded=[]; - try{uploaded=await uploadPendingFiles();} + try{uploaded=await uploadPendingFiles({files:_submittedFiles, sessionId:activeSid});} catch(e){if(!text){setComposerStatus(`Upload error: ${e.message}`);return;}} // Clear the uploading status now that upload is done — if we don't clear here // it stays visible for the entire duration of the agent stream, since @@ -1603,17 +1621,11 @@ async function send(){ } } if(!msgText){setComposerStatus('Nothing to send');return;} - // Composer textarea + _submittedDraftTextForClear were already captured and - // cleared immediately after capture (above, salvage of #4750) to close the - // re-entrant double-send race. Only the files snapshot is finalized here. - const _submittedDraftFilesForClear=Array.isArray(_failedSendFilesSnapshot)?[..._failedSendFilesSnapshot]:[]; - // Clear persisted composer draft since message was sent. Capture the promise - // so the #5472 failed-send restore can chain its re-persist AFTER this clear - // resolves — otherwise the two same-origin POSTs (clear text:'' then restore - // text:) can be reordered under HTTP/2 multiplexing and leave the - // server draft empty after a reload. (Opus #5484 NIT.) - let _composerDraftClearPromise=null; - if (activeSid && typeof _clearComposerDraft === 'function') _composerDraftClearPromise=_clearComposerDraft(activeSid,_submittedDraftTextForClear,_submittedDraftFilesForClear); + // Composer textarea + persisted draft were already captured and cleared + // immediately after capture (above, salvage of #4750 + #5912 gate fix) to close + // the re-entrant double-send race AND avoid clobbering a draft typed during the + // upload window. _composerDraftClearPromise / _submittedDraftFilesForClear are + // set there; nothing to re-declare here. const displayText=_slashDisplayTextOverride||text||(uploaded.length?`Uploaded: ${uploadedNames.join(', ')}`:'(file upload)'); const userMsg={role:'user',content:displayText,attachments:uploaded.length?uploadedNames:undefined,_ts:Date.now()/1000}; S.toolCalls=[]; // clear tool calls from previous turn diff --git a/tests/test_composer_capture_clear_race.py b/tests/test_composer_capture_clear_race.py index 0c181532..19f1b535 100644 --- a/tests/test_composer_capture_clear_race.py +++ b/tests/test_composer_capture_clear_race.py @@ -61,7 +61,7 @@ def test_composer_captured_and_cleared_before_upload_await(): wipe_rel = body.index("$('msg').value='';autoResize();", capture_idx) assert wipe_rel - capture_idx < 120, "the textarea wipe must sit immediately after the capture" - upload_idx = body.index("uploaded=await uploadPendingFiles();") + upload_idx = body.index("uploaded=await uploadPendingFiles(") directive_await_idx = body.index("const _directivePayload = await _pending.promise;") assert capture_idx < upload_idx, ( diff --git a/tests/test_issue5472_preserve_draft_on_failed_send.py b/tests/test_issue5472_preserve_draft_on_failed_send.py index 76518477..9f67edbf 100644 --- a/tests/test_issue5472_preserve_draft_on_failed_send.py +++ b/tests/test_issue5472_preserve_draft_on_failed_send.py @@ -74,7 +74,7 @@ def test_send_captures_immutable_snapshot_before_rewrites_and_upload(): "const _failedSendFilesSnapshot=Array.isArray(S.pendingFiles)?[...S.pendingFiles]:[];" ) moa_idx = MESSAGES_JS.find("text=_moaArgs;") - upload_idx = MESSAGES_JS.find("uploaded=await uploadPendingFiles();") + upload_idx = MESSAGES_JS.find("uploaded=await uploadPendingFiles(") assert snap_idx != -1 and files_idx != -1, "send() must snapshot text + files for #5472" assert moa_idx != -1 and upload_idx != -1 # Snapshot happens before both the /moa rewrite and the upload drain. @@ -116,7 +116,7 @@ def test_send_still_clears_composer_on_the_happy_path(): assert "$('msg').value='';autoResize();" in window_after_capture, ( "the composer textarea wipe must sit immediately after the capture" ) - upload_idx = MESSAGES_JS.index("uploaded=await uploadPendingFiles();") + upload_idx = MESSAGES_JS.index("uploaded=await uploadPendingFiles(") clear_idx = MESSAGES_JS.index(main_clear) # THE FIX: capture+wipe happen before the upload await (closes the race)... assert capture_idx < upload_idx, ( @@ -127,12 +127,17 @@ def test_send_still_clears_composer_on_the_happy_path(): assert capture_idx < clear_idx, ( "the captured send-time draft text must precede the persisted-draft clear" ) - # The files snapshot is finalized just above the persisted-draft clear. - window_before_clear = MESSAGES_JS[clear_idx - 700:clear_idx] + # The files snapshot (#5912 gate fix) is taken from S.pendingFiles BEFORE the + # upload await and feeds the persisted-draft clear which now runs before the await. assert ( - "const _submittedDraftFilesForClear=Array.isArray(_failedSendFilesSnapshot)?[..._failedSendFilesSnapshot]:[];" - in window_before_clear - ), "the files snapshot for the persisted-draft clear must precede it" + "const _submittedDraftFilesForClear=_submittedFiles.map(f=>(f&&f.name)||'').filter(Boolean);" + in MESSAGES_JS + ), "the files snapshot for the persisted-draft clear must be captured pre-await" + # The persisted-draft clear must run BEFORE the upload await (not after it), + # so a draft typed during the upload window is not clobbered. + assert clear_idx < upload_idx, ( + "the persisted-draft clear must precede the uploadPendingFiles() await (#5912)" + ) def test_restore_persist_chains_after_the_clear_promise():