fix(send): snapshot files + clear draft pre-await (gate fixes)

#5912 gate found 2 defects the capture-clear salvage exposed:
- [CORE] uploadPendingFiles() read still-live S.pendingFiles, so a re-entrant/
  interrupt send during the upload window inherited + re-uploaded the first
  send's attachment. Now snapshot _submittedFiles before the await, detach from
  S.pendingFiles, and upload exactly that snapshot.
- [SILENT] the persisted-draft clear ran AFTER the await, cancelling a draft
  typed during the upload window. Moved it (and _composerDraftClearPromise)
  alongside the textarea clear, before any await. Failed-send restore still
  chains off the promise via the pre-await _failedSendFilesSnapshot.
Updated source-assertion tests for the new call shape + ordering.
This commit is contained in:
nesquena-hermes committed 2026-07-11 03:58:52 +00:00
1 parent 67c9277ed4
commit d6323bfff0
3 files changed
+38 -21

No files matched your search

+25 -13
View File
@@ -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:<draft>) 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
+1 -1
View File
@@ -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, (
@@ -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():