From 3dd2ace4e1629bbe8ad0c7a028d5553e3102e6fe Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Thu, 21 May 2026 17:14:33 +0000 Subject: [PATCH] =?UTF-8?q?Stage=20397:=20PR=20#2689=20=E2=80=94=20fix(cha?= =?UTF-8?q?t):=20preserve=20inflight=20send=20state=20during=20start=20rac?= =?UTF-8?q?e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: ai-ag2026 --- static/messages.js | 9 ++++- static/sessions.js | 3 ++ tests/test_inflight_send_start_race.py | 51 ++++++++++++++++++++++++++ 3 files changed, 61 insertions(+), 2 deletions(-) create mode 100644 tests/test_inflight_send_start_race.py diff --git a/static/messages.js b/static/messages.js index b5052fb8..e5477f30 100644 --- a/static/messages.js +++ b/static/messages.js @@ -415,7 +415,8 @@ async function send(){ if(typeof upsertActiveSessionForLocalTurn==='function'){ upsertActiveSessionForLocalTurn({title:displayText.slice(0,64),messageCount:S.messages.length,timestampMs:Date.now()}); } - INFLIGHT[activeSid]={messages:[...S.messages],uploaded:uploadedNames,toolCalls:[]}; + const optimisticMessages=[...S.messages]; + INFLIGHT[activeSid]={messages:optimisticMessages,uploaded:uploadedNames,toolCalls:[]}; if(typeof saveInflightState==='function'){ saveInflightState(activeSid,{streamId:null,messages:INFLIGHT[activeSid].messages,uploaded:uploadedNames,toolCalls:[]}); } @@ -486,9 +487,13 @@ async function send(){ // against real active-stream metadata before the background refresh lands. upsertActiveSessionForLocalTurn({title:S.session&&S.session.title||displayText.slice(0,64),messageCount:S.messages.length,timestampMs:Date.now()}); } + if(!INFLIGHT[activeSid]){ + INFLIGHT[activeSid]={messages:optimisticMessages,uploaded:uploadedNames,toolCalls:[]}; + } + const currentInflight=INFLIGHT[activeSid]; markInflight(activeSid, streamId); if(typeof saveInflightState==='function'){ - saveInflightState(activeSid,{streamId,messages:INFLIGHT[activeSid].messages,uploaded:uploadedNames,toolCalls:INFLIGHT[activeSid].toolCalls||[]}); + saveInflightState(activeSid,{streamId,messages:currentInflight.messages||optimisticMessages,uploaded:uploadedNames,toolCalls:currentInflight.toolCalls||[]}); } // Refresh session list so background streaming indicators appear immediately for the // session that was just started and any others that may already be running. diff --git a/static/sessions.js b/static/sessions.js index c3bc50ae..dc27366d 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -310,6 +310,9 @@ function _purgeStaleInflightEntries() { } } for (const sid of Object.keys(INFLIGHT)) { + if (typeof _sendInProgress !== 'undefined' && _sendInProgress && sid === _sendInProgressSid) { + continue; + } if (!sessionsById.has(sid)) { // Session is absent from _allSessions — it was deleted / archived / // filtered and can never stream again, so drop the entry. diff --git a/tests/test_inflight_send_start_race.py b/tests/test_inflight_send_start_race.py new file mode 100644 index 00000000..933e62a4 --- /dev/null +++ b/tests/test_inflight_send_start_race.py @@ -0,0 +1,51 @@ +"""Regression coverage for send/start optimistic INFLIGHT races.""" +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +MESSAGES_JS = (REPO_ROOT / "static" / "messages.js").read_text(encoding="utf-8") +SESSIONS_JS = (REPO_ROOT / "static" / "sessions.js").read_text(encoding="utf-8") + + +def _function_body(src: str, name: str) -> str: + marker = f"function {name}" + start = src.index(marker) + brace = src.index("{", start) + depth = 1 + i = brace + 1 + while depth and i < len(src): + if src[i] == "{": + depth += 1 + elif src[i] == "}": + depth -= 1 + i += 1 + return src[brace + 1 : i - 1] + + +def test_send_preserves_optimistic_messages_across_chat_start_await(): + """send() must not dereference INFLIGHT[activeSid] after await without a fallback.""" + body = _function_body(MESSAGES_JS, "send") + setup_idx = body.index("const optimisticMessages=[...S.messages];") + inflight_idx = body.index("INFLIGHT[activeSid]={messages:optimisticMessages") + await_idx = body.index("const startData=await api('/api/chat/start'") + save_idx = body.index("saveInflightState(activeSid,{streamId", await_idx) + + assert setup_idx < inflight_idx < await_idx < save_idx + post_await = body[await_idx:save_idx] + assert "if(!INFLIGHT[activeSid])" in post_await, ( + "send() should recreate the INFLIGHT entry if a session-list refresh pruned it" + ) + assert "messages:INFLIGHT[activeSid].messages" not in body[save_idx : save_idx + 220], ( + "saveInflightState() should use a guarded local/current inflight object, not a blind nested read" + ) + + +def test_stale_inflight_purge_preserves_current_send_before_stream_id_exists(): + """Sidebar cleanup must not delete the active send before /api/chat/start responds.""" + body = _function_body(SESSIONS_JS, "_purgeStaleInflightEntries") + + assert "_sendInProgress" in body and "_sendInProgressSid" in body, ( + "_purgeStaleInflightEntries() should skip the current send while start is in progress" + ) + skip_idx = body.index("_sendInProgress") + delete_idx = body.index("delete INFLIGHT[sid];") + assert skip_idx < delete_idx, "the current-send skip must run before any purge deletion"